Write run outputs and diagnostics
This commit is contained in:
@@ -7,17 +7,21 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/core/artifacts"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/core/config"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/core/diagnostics"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
|
||||
)
|
||||
|
||||
const defaultConfigPath = "/usr/local/etc/notarius/config.yml"
|
||||
const defaultOutputRoot = "./notarius-output"
|
||||
|
||||
const usage = `Usage:
|
||||
notarius help
|
||||
@@ -118,7 +122,7 @@ func runPipelineCommand(args []string, stdout, stderr io.Writer, opts Options) i
|
||||
return 2
|
||||
}
|
||||
|
||||
cfg, _, err := loadConfig(*configPath, opts)
|
||||
cfg, loadedConfigPath, err := loadConfig(*configPath, opts)
|
||||
if err != nil {
|
||||
fmt.Fprintf(stderr, "notarius: %v\n", err)
|
||||
return 1
|
||||
@@ -127,11 +131,30 @@ func runPipelineCommand(args []string, stdout, stderr io.Writer, opts Options) i
|
||||
cfg.Diagnostics.WorkDir = dir
|
||||
}
|
||||
|
||||
catalog, err := effectiveCatalog(opts)
|
||||
startedAt := opts.Now().UTC()
|
||||
runDir, err := diagnostics.NewRunDirectory(cfg.Diagnostics.WorkDir, cfg.Diagnostics.Retention)
|
||||
if err != nil {
|
||||
fmt.Fprintf(stderr, "notarius: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
invocation := diagnostics.InvocationMetadata{
|
||||
Operation: "run",
|
||||
PipelineID: pipelineID,
|
||||
InputPath: strings.TrimSpace(*inputPath),
|
||||
ConfigPath: loadedConfigPath,
|
||||
ConfigSource: configSource(*configPath),
|
||||
OnlyLanes: append([]string(nil), only...),
|
||||
RunID: runDir.RunID(),
|
||||
StartedAt: startedAt,
|
||||
}
|
||||
if err := runDir.WriteInvocationMetadata(invocation); err != nil {
|
||||
return failPipelineCommand(stderr, runDir, cfg.Diagnostics.Retention, fmt.Errorf("write diagnostics invocation metadata: %w", err))
|
||||
}
|
||||
|
||||
catalog, err := effectiveCatalog(opts)
|
||||
if err != nil {
|
||||
return failPipelineCommand(stderr, runDir, cfg.Diagnostics.Retention, err)
|
||||
}
|
||||
effective, err := cfg.Resolve(config.ResolveInput{
|
||||
PipelineID: pipelineID,
|
||||
Only: only,
|
||||
@@ -139,33 +162,38 @@ func runPipelineCommand(args []string, stdout, stderr io.Writer, opts Options) i
|
||||
LLMProfileOverride: *llmProfile,
|
||||
})
|
||||
if err != nil {
|
||||
fmt.Fprintf(stderr, "notarius: %v\n", err)
|
||||
return 1
|
||||
return failPipelineCommand(stderr, runDir, cfg.Diagnostics.Retention, err)
|
||||
}
|
||||
invocation.PipelineDigest = effective.ResolvedPipeline.Digest
|
||||
if err := runDir.WriteInvocationMetadata(invocation); err != nil {
|
||||
return failPipelineCommand(stderr, runDir, cfg.Diagnostics.Retention, fmt.Errorf("write diagnostics invocation metadata: %w", err))
|
||||
}
|
||||
if err := runDir.WriteRedactedEffectiveConfig(effective); err != nil {
|
||||
return failPipelineCommand(stderr, runDir, cfg.Diagnostics.Retention, fmt.Errorf("write diagnostics effective config: %w", err))
|
||||
}
|
||||
if err := runDir.WriteResolvedPipeline(effective.ResolvedPipeline); err != nil {
|
||||
return failPipelineCommand(stderr, runDir, cfg.Diagnostics.Retention, fmt.Errorf("write diagnostics resolved pipeline: %w", err))
|
||||
}
|
||||
|
||||
profileIDs := effectiveLLMProfileIDs(effective.ResolvedPipeline)
|
||||
if len(profileIDs) != 1 {
|
||||
fmt.Fprintf(stderr, "notarius: pipeline %q uses %d distinct LLM profiles; current runs require exactly one: %s\n", pipelineID, len(profileIDs), strings.Join(profileIDs, ", "))
|
||||
return 1
|
||||
return failPipelineCommand(stderr, runDir, cfg.Diagnostics.Retention, fmt.Errorf("pipeline %q uses %d distinct LLM profiles; current runs require exactly one: %s", pipelineID, len(profileIDs), strings.Join(profileIDs, ", ")))
|
||||
}
|
||||
|
||||
rawInput, err := os.ReadFile(strings.TrimSpace(*inputPath))
|
||||
if err != nil {
|
||||
fmt.Fprintf(stderr, "notarius: read input %q: %v\n", strings.TrimSpace(*inputPath), err)
|
||||
return 1
|
||||
return failPipelineCommand(stderr, runDir, cfg.Diagnostics.Retention, fmt.Errorf("read input %q: %w", strings.TrimSpace(*inputPath), err))
|
||||
}
|
||||
|
||||
registries, err := effectiveRegistries(opts)
|
||||
if err != nil {
|
||||
fmt.Fprintf(stderr, "notarius: %v\n", err)
|
||||
return 1
|
||||
return failPipelineCommand(stderr, runDir, cfg.Diagnostics.Retention, err)
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
llmClient, llmProfiles, err := opts.LLMClientFactory(ctx, effective.Config, profileIDs[0])
|
||||
if err != nil {
|
||||
fmt.Fprintf(stderr, "notarius: create LLM client for profile %q: %v\n", profileIDs[0], err)
|
||||
return 1
|
||||
return failPipelineCommand(stderr, runDir, cfg.Diagnostics.Retention, fmt.Errorf("create LLM client for profile %q: %w", profileIDs[0], err))
|
||||
}
|
||||
|
||||
output, err := pipeline.New(registries).Run(ctx, pipeline.RunInput{
|
||||
@@ -173,22 +201,193 @@ func runPipelineCommand(args []string, stdout, stderr io.Writer, opts Options) i
|
||||
Path: strings.TrimSpace(*inputPath),
|
||||
RawInput: rawInput,
|
||||
LLMClient: llmClient,
|
||||
StartedAt: opts.Now().UTC(),
|
||||
RunID: runDir.RunID(),
|
||||
StartedAt: startedAt,
|
||||
LLMProfiles: llmProfiles,
|
||||
Metadata: runMetadata(*outputDir, *diagnosticsDir),
|
||||
})
|
||||
if err != nil {
|
||||
fmt.Fprintf(stderr, "notarius: run pipeline %q: %v\n", pipelineID, err)
|
||||
return 1
|
||||
if output.Manifest.PipelineID != "" {
|
||||
_ = runDir.WriteRunManifest(output.Manifest)
|
||||
}
|
||||
return failPipelineCommand(stderr, runDir, cfg.Diagnostics.Retention, fmt.Errorf("run pipeline %q: %w", pipelineID, err))
|
||||
}
|
||||
|
||||
fmt.Fprintf(stdout, "pipeline %q complete: approved=%d rejected=%d\n", effective.PipelineID, len(output.Approved), len(output.Rejected))
|
||||
runOutputDir := filepath.Join(outputRoot(*outputDir), runDir.RunID())
|
||||
if err := writeOutputFiles(runOutputDir, output.OutputFiles); err != nil {
|
||||
return failPipelineCommand(stderr, runDir, cfg.Diagnostics.Retention, err)
|
||||
}
|
||||
if err := runDir.WriteRunManifest(output.Manifest); err != nil {
|
||||
return failPipelineCommand(stderr, runDir, cfg.Diagnostics.Retention, fmt.Errorf("write diagnostics run manifest: %w", err))
|
||||
}
|
||||
if err := runDir.WriteWarnings(output.Warnings); err != nil {
|
||||
return failPipelineCommand(stderr, runDir, cfg.Diagnostics.Retention, fmt.Errorf("write diagnostics warnings: %w", err))
|
||||
}
|
||||
if err := runDir.WriteRunReport(runReport{
|
||||
RunID: runDir.RunID(),
|
||||
PipelineID: effective.PipelineID,
|
||||
OutputPath: runOutputDir,
|
||||
DiagnosticsPath: runDir.Path(),
|
||||
ApprovedCount: len(output.Approved),
|
||||
RejectedCount: len(output.Rejected),
|
||||
WarningCount: len(output.Warnings),
|
||||
ValidationStatus: output.Manifest.ValidationStatus,
|
||||
}); err != nil {
|
||||
return failPipelineCommand(stderr, runDir, cfg.Diagnostics.Retention, fmt.Errorf("write diagnostics run report: %w", err))
|
||||
}
|
||||
if err := runDir.ApplyRetention(diagnostics.RetentionDecisionInput{
|
||||
RetentionMode: cfg.Diagnostics.Retention,
|
||||
RunSucceeded: true,
|
||||
HasWarnings: len(output.Warnings) > 0,
|
||||
}); err != nil {
|
||||
return failPipelineCommand(stderr, runDir, cfg.Diagnostics.Retention, fmt.Errorf("apply diagnostics retention: %w", err))
|
||||
}
|
||||
|
||||
fmt.Fprintf(stdout, "pipeline %q complete: approved=%d rejected=%d output=%s\n", effective.PipelineID, len(output.Approved), len(output.Rejected), runOutputDir)
|
||||
if len(output.Warnings) > 0 {
|
||||
fmt.Fprintf(stderr, "notarius: run completed with %d warning(s)\n", len(output.Warnings))
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
type runReport struct {
|
||||
RunID string `json:"run_id"`
|
||||
PipelineID string `json:"pipeline_id"`
|
||||
OutputPath string `json:"output_path"`
|
||||
DiagnosticsPath string `json:"diagnostics_path,omitempty"`
|
||||
ApprovedCount int `json:"approved_count"`
|
||||
RejectedCount int `json:"rejected_count"`
|
||||
WarningCount int `json:"warning_count"`
|
||||
ValidationStatus string `json:"validation_status,omitempty"`
|
||||
}
|
||||
|
||||
func failPipelineCommand(stderr io.Writer, runDir *diagnostics.RunDirectory, retention diagnostics.RetentionMode, err error) int {
|
||||
fmt.Fprintf(stderr, "notarius: %v\n", err)
|
||||
if runDir != nil {
|
||||
if logErr := runDir.WriteErrorLog(err.Error()); logErr != nil {
|
||||
fmt.Fprintf(stderr, "notarius: write diagnostics error log: %v\n", logErr)
|
||||
}
|
||||
if retentionErr := runDir.ApplyRetention(diagnostics.RetentionDecisionInput{
|
||||
RetentionMode: retention,
|
||||
RunSucceeded: false,
|
||||
}); retentionErr != nil {
|
||||
fmt.Fprintf(stderr, "notarius: apply diagnostics retention: %v\n", retentionErr)
|
||||
}
|
||||
}
|
||||
return 1
|
||||
}
|
||||
|
||||
func configSource(configPath string) string {
|
||||
if strings.TrimSpace(configPath) != "" {
|
||||
return "flag"
|
||||
}
|
||||
return "discovered"
|
||||
}
|
||||
|
||||
func outputRoot(outputDir string) string {
|
||||
if dir := strings.TrimSpace(outputDir); dir != "" {
|
||||
return dir
|
||||
}
|
||||
return defaultOutputRoot
|
||||
}
|
||||
|
||||
func writeOutputFiles(runOutputDir string, files []contracts.OutputFile) error {
|
||||
type outputTarget struct {
|
||||
path string
|
||||
file contracts.OutputFile
|
||||
}
|
||||
targets := make([]outputTarget, 0, len(files))
|
||||
for _, file := range files {
|
||||
targetPath, err := outputFilePath(runOutputDir, file.Name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
targets = append(targets, outputTarget{path: targetPath, file: file})
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(runOutputDir, 0o755); err != nil {
|
||||
return fmt.Errorf("create output directory %q: %w", runOutputDir, err)
|
||||
}
|
||||
for _, target := range targets {
|
||||
if err := os.MkdirAll(filepath.Dir(target.path), 0o755); err != nil {
|
||||
return fmt.Errorf("create output directory %q: %w", filepath.Dir(target.path), err)
|
||||
}
|
||||
if err := writeFileAtomic(target.path, target.file.Bytes, 0o644); err != nil {
|
||||
return fmt.Errorf("write output file %q: %w", target.file.Name, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func outputFilePath(runOutputDir, logicalName string) (string, error) {
|
||||
name := strings.TrimSpace(logicalName)
|
||||
if name == "" {
|
||||
return "", fmt.Errorf("output file name must not be empty")
|
||||
}
|
||||
if strings.Contains(name, `\`) {
|
||||
return "", fmt.Errorf("output file name %q must use slash-separated relative paths", name)
|
||||
}
|
||||
if path.IsAbs(name) || filepath.IsAbs(name) {
|
||||
return "", fmt.Errorf("output file name %q must be relative", name)
|
||||
}
|
||||
if strings.Contains(name, "..") {
|
||||
return "", fmt.Errorf("output file name %q must not contain ..", name)
|
||||
}
|
||||
cleaned := path.Clean(name)
|
||||
if cleaned == "." || cleaned != name {
|
||||
return "", fmt.Errorf("output file name %q must be clean", name)
|
||||
}
|
||||
|
||||
root, err := filepath.Abs(runOutputDir)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("resolve output directory %q: %w", runOutputDir, err)
|
||||
}
|
||||
target, err := filepath.Abs(filepath.Join(root, filepath.FromSlash(cleaned)))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("resolve output file %q: %w", name, err)
|
||||
}
|
||||
rel, err := filepath.Rel(root, target)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("resolve output file %q: %w", name, err)
|
||||
}
|
||||
if rel == "." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) || rel == ".." {
|
||||
return "", fmt.Errorf("output file name %q resolves outside output directory", name)
|
||||
}
|
||||
return target, nil
|
||||
}
|
||||
|
||||
func writeFileAtomic(path string, data []byte, perm os.FileMode) error {
|
||||
dir := filepath.Dir(path)
|
||||
temp, err := os.CreateTemp(dir, "."+filepath.Base(path)+".tmp-*")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tempPath := temp.Name()
|
||||
removeTemp := true
|
||||
defer func() {
|
||||
if removeTemp {
|
||||
_ = os.Remove(tempPath)
|
||||
}
|
||||
}()
|
||||
|
||||
if _, err := temp.Write(data); err != nil {
|
||||
_ = temp.Close()
|
||||
return err
|
||||
}
|
||||
if err := temp.Chmod(perm); err != nil {
|
||||
_ = temp.Close()
|
||||
return err
|
||||
}
|
||||
if err := temp.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Rename(tempPath, path); err != nil {
|
||||
return err
|
||||
}
|
||||
removeTemp = false
|
||||
return nil
|
||||
}
|
||||
|
||||
func reorderRunArgs(args []string) []string {
|
||||
var flags []string
|
||||
var positionals []string
|
||||
|
||||
Reference in New Issue
Block a user