diff --git a/internal/core/diagnostics/artifacts.go b/internal/core/diagnostics/artifacts.go new file mode 100644 index 0000000..a84aa7c --- /dev/null +++ b/internal/core/diagnostics/artifacts.go @@ -0,0 +1,12 @@ +package diagnostics + +const ( + ArtifactInvocationMetadata = "invocation.json" + ArtifactEffectiveConfig = "effective-config.json" + ArtifactResolvedPipeline = "resolved-pipeline.json" + ArtifactSourceDocument = "source-document.json" + ArtifactRunManifest = "run-manifest.json" + ArtifactRunReport = "run-report.json" + ArtifactWarnings = "warnings.json" + ArtifactErrorLog = "error.log" +) diff --git a/internal/core/diagnostics/artifacts_test.go b/internal/core/diagnostics/artifacts_test.go new file mode 100644 index 0000000..a1b39b9 --- /dev/null +++ b/internal/core/diagnostics/artifacts_test.go @@ -0,0 +1,22 @@ +package diagnostics + +import "testing" + +func TestArtifactNamesUseExtractionOrientedNames(t *testing.T) { + names := []string{ + ArtifactInvocationMetadata, + ArtifactEffectiveConfig, + ArtifactResolvedPipeline, + ArtifactSourceDocument, + ArtifactRunManifest, + ArtifactRunReport, + ArtifactWarnings, + ArtifactErrorLog, + } + + for _, name := range names { + if name == "" { + t.Fatalf("artifact name must not be empty") + } + } +} diff --git a/internal/core/diagnostics/run_dir.go b/internal/core/diagnostics/run_dir.go new file mode 100644 index 0000000..36fa88e --- /dev/null +++ b/internal/core/diagnostics/run_dir.go @@ -0,0 +1,220 @@ +package diagnostics + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + "time" + + "gitea.maximumdirect.net/eric/notarius/internal/core/artifacts" + "gitea.maximumdirect.net/eric/notarius/internal/framework/contracts" +) + +const defaultWorkDir = "/tmp/notarius" + +// RunDirectory represents a per-run diagnostics directory. +type RunDirectory struct { + path string + retention RetentionMode + createdAt time.Time +} + +type RetentionMode string + +const ( + RetentionAuto RetentionMode = "auto" + RetentionAlways RetentionMode = "always" + RetentionNever RetentionMode = "never" +) + +type RetentionDecisionInput struct { + RetentionMode RetentionMode + RunSucceeded bool + HasWarnings bool +} + +// InvocationMetadata captures non-secret invocation details for diagnostics. +type InvocationMetadata struct { + Operation string `json:"operation"` + PipelineID string `json:"pipeline_id,omitempty"` + PipelineDigest string `json:"pipeline_digest,omitempty"` + InputPath string `json:"input_path,omitempty"` + ConfigPath string `json:"config_path,omitempty"` + ConfigSource string `json:"config_source,omitempty"` + OnlyLanes []string `json:"only_lanes,omitempty"` + RunID string `json:"run_id"` + StartedAt time.Time `json:"started_at"` +} + +func ShouldRetainRunDirectory(input RetentionDecisionInput) bool { + if !input.RunSucceeded { + return true + } + + switch input.RetentionMode { + case RetentionAlways: + return true + case RetentionNever: + return false + case RetentionAuto, "": + return input.HasWarnings + default: + return true + } +} + +func NewRunDirectory(workDir string, retention RetentionMode) (*RunDirectory, error) { + if strings.TrimSpace(workDir) == "" { + workDir = defaultWorkDir + } + if retention == "" { + retention = RetentionAuto + } + + if err := os.MkdirAll(workDir, 0o755); err != nil { + return nil, fmt.Errorf("create diagnostics work directory %q: %w", workDir, err) + } + + createdAt := time.Now().UTC() + runID := fmt.Sprintf("run-%d", createdAt.UnixNano()) + runPath := filepath.Join(workDir, runID) + if err := os.Mkdir(runPath, 0o755); err != nil { + return nil, fmt.Errorf("create diagnostics run directory %q: %w", runPath, err) + } + + return &RunDirectory{ + path: runPath, + retention: retention, + createdAt: createdAt, + }, nil +} + +func (r *RunDirectory) Path() string { + if r == nil { + return "" + } + return r.path +} + +func (r *RunDirectory) RunID() string { + if r == nil { + return "" + } + return filepath.Base(r.path) +} + +func (r *RunDirectory) WriteInvocationMetadata(metadata InvocationMetadata) error { + if r == nil { + return fmt.Errorf("run directory must not be nil") + } + if metadata.RunID == "" { + metadata.RunID = r.RunID() + } + if metadata.StartedAt.IsZero() { + metadata.StartedAt = r.createdAt + } + return r.WriteJSONArtifact(ArtifactInvocationMetadata, metadata) +} + +func (r *RunDirectory) WriteRedactedEffectiveConfig(payload any) error { + return r.WriteJSONArtifact(ArtifactEffectiveConfig, payload) +} + +func (r *RunDirectory) WriteResolvedPipeline(payload any) error { + return r.WriteJSONArtifact(ArtifactResolvedPipeline, payload) +} + +func (r *RunDirectory) WriteSourceDocument(payload any) error { + return r.WriteJSONArtifact(ArtifactSourceDocument, payload) +} + +func (r *RunDirectory) WriteRunManifest(manifest artifacts.RunManifest) error { + return r.WriteJSONArtifact(ArtifactRunManifest, manifest) +} + +func (r *RunDirectory) WriteRunReport(payload any) error { + return r.WriteJSONArtifact(ArtifactRunReport, payload) +} + +func (r *RunDirectory) WriteWarnings(warnings []contracts.Warning) error { + return r.WriteJSONArtifact(ArtifactWarnings, warnings) +} + +func (r *RunDirectory) WriteErrorLog(errorMessage string) error { + if r == nil { + return fmt.Errorf("run directory must not be nil") + } + path, err := r.artifactPath(ArtifactErrorLog) + if err != nil { + return err + } + if err := os.WriteFile(path, []byte(errorMessage+"\n"), 0o644); err != nil { + return fmt.Errorf("write diagnostics artifact %q: %w", ArtifactErrorLog, err) + } + return nil +} + +func (r *RunDirectory) WriteJSONArtifact(name string, payload any) error { + if r == nil { + return fmt.Errorf("run directory must not be nil") + } + path, err := r.artifactPath(name) + if err != nil { + return err + } + + data, err := json.MarshalIndent(payload, "", " ") + if err != nil { + return fmt.Errorf("marshal diagnostics artifact %q: %w", name, err) + } + data = append(data, '\n') + if err := os.WriteFile(path, data, 0o644); err != nil { + return fmt.Errorf("write diagnostics artifact %q: %w", name, err) + } + return nil +} + +func (r *RunDirectory) ApplyRetention(input RetentionDecisionInput) error { + if r == nil { + return fmt.Errorf("run directory must not be nil") + } + decision := input + if decision.RetentionMode == "" { + decision.RetentionMode = r.retention + } + if ShouldRetainRunDirectory(decision) { + return nil + } + if err := os.RemoveAll(r.path); err != nil { + return fmt.Errorf("remove diagnostics run directory %q: %w", r.path, err) + } + return nil +} + +func (r *RunDirectory) artifactPath(name string) (string, error) { + name = strings.TrimSpace(name) + if name == "" { + return "", fmt.Errorf("diagnostics artifact name must not be empty") + } + if filepath.IsAbs(name) { + return "", fmt.Errorf("diagnostics artifact name %q must not be absolute", name) + } + if name != filepath.Base(name) || strings.Contains(name, "/") || strings.Contains(name, `\`) { + return "", fmt.Errorf("diagnostics artifact name %q must not contain path separators", name) + } + + runPath, err := filepath.Abs(r.path) + if err != nil { + return "", fmt.Errorf("resolve diagnostics run directory %q: %w", r.path, err) + } + artifactPath, err := filepath.Abs(filepath.Join(runPath, name)) + if err != nil { + return "", fmt.Errorf("resolve diagnostics artifact %q: %w", name, err) + } + if filepath.Dir(artifactPath) != runPath { + return "", fmt.Errorf("diagnostics artifact name %q resolves outside run directory", name) + } + return artifactPath, nil +} diff --git a/internal/core/diagnostics/run_dir_test.go b/internal/core/diagnostics/run_dir_test.go new file mode 100644 index 0000000..a7055ec --- /dev/null +++ b/internal/core/diagnostics/run_dir_test.go @@ -0,0 +1,262 @@ +package diagnostics + +import ( + "encoding/json" + "os" + "path/filepath" + "regexp" + "strings" + "testing" + "time" + + "gitea.maximumdirect.net/eric/notarius/internal/core/artifacts" + "gitea.maximumdirect.net/eric/notarius/internal/framework/contracts" +) + +func TestNewRunDirectoryCreatesRunDirectoryAndRunID(t *testing.T) { + workDir := t.TempDir() + runDir, err := NewRunDirectory(workDir, RetentionAuto) + if err != nil { + t.Fatalf("NewRunDirectory: %v", err) + } + + if filepath.Dir(runDir.Path()) != workDir { + t.Fatalf("unexpected run directory parent: %q", runDir.Path()) + } + if ok := regexp.MustCompile(`^run-\d+$`).MatchString(runDir.RunID()); !ok { + t.Fatalf("unexpected run ID: %q", runDir.RunID()) + } + info, err := os.Stat(runDir.Path()) + if err != nil { + t.Fatalf("stat run directory: %v", err) + } + if !info.IsDir() { + t.Fatalf("expected run path to be a directory") + } +} + +func TestNewRunDirectoryUsesDefaultWorkDirectory(t *testing.T) { + runDir, err := NewRunDirectory("", RetentionAuto) + if err != nil { + t.Fatalf("NewRunDirectory: %v", err) + } + t.Cleanup(func() { + _ = os.RemoveAll(runDir.Path()) + _ = os.Remove(defaultWorkDir) + }) + + if filepath.Dir(runDir.Path()) != defaultWorkDir { + t.Fatalf("expected default work directory %q, got %q", defaultWorkDir, filepath.Dir(runDir.Path())) + } +} + +func TestWriteJSONArtifactWritesIndentedNewlineTerminatedJSON(t *testing.T) { + runDir := newTestRunDirectory(t) + + if err := runDir.WriteJSONArtifact("artifact.json", map[string]any{"value": "ok"}); err != nil { + t.Fatalf("WriteJSONArtifact: %v", err) + } + + data := readArtifact(t, runDir, "artifact.json") + if !strings.HasSuffix(string(data), "\n") { + t.Fatalf("expected trailing newline, got %q", data) + } + if !strings.Contains(string(data), "\n \"value\": \"ok\"\n") { + t.Fatalf("expected indented JSON, got %s", data) + } +} + +func TestWriteInvocationMetadataFillsMissingRunIDAndStartTime(t *testing.T) { + runDir := newTestRunDirectory(t) + + if err := runDir.WriteInvocationMetadata(InvocationMetadata{Operation: "validate"}); err != nil { + t.Fatalf("WriteInvocationMetadata: %v", err) + } + + var got InvocationMetadata + if err := json.Unmarshal(readArtifact(t, runDir, ArtifactInvocationMetadata), &got); err != nil { + t.Fatalf("unmarshal invocation metadata: %v", err) + } + if got.RunID != runDir.RunID() { + t.Fatalf("unexpected run ID: got %q want %q", got.RunID, runDir.RunID()) + } + if got.StartedAt.IsZero() { + t.Fatalf("expected started_at to be filled") + } + if got.Operation != "validate" { + t.Fatalf("unexpected operation: %q", got.Operation) + } +} + +func TestWriteInvocationMetadataPreservesProvidedRunIDAndStartTime(t *testing.T) { + runDir := newTestRunDirectory(t) + startedAt := time.Date(2026, 7, 3, 12, 0, 0, 0, time.UTC) + + if err := runDir.WriteInvocationMetadata(InvocationMetadata{ + Operation: "validate", + RunID: "provided", + StartedAt: startedAt, + }); err != nil { + t.Fatalf("WriteInvocationMetadata: %v", err) + } + + var got InvocationMetadata + if err := json.Unmarshal(readArtifact(t, runDir, ArtifactInvocationMetadata), &got); err != nil { + t.Fatalf("unmarshal invocation metadata: %v", err) + } + if got.RunID != "provided" { + t.Fatalf("unexpected run ID: %q", got.RunID) + } + if !got.StartedAt.Equal(startedAt) { + t.Fatalf("unexpected started_at: %s", got.StartedAt) + } +} + +func TestWriteTypedArtifacts(t *testing.T) { + runDir := newTestRunDirectory(t) + + if err := runDir.WriteRedactedEffectiveConfig(map[string]any{"redacted": true}); err != nil { + t.Fatalf("WriteRedactedEffectiveConfig: %v", err) + } + if err := runDir.WriteResolvedPipeline(map[string]any{"pipeline": "test"}); err != nil { + t.Fatalf("WriteResolvedPipeline: %v", err) + } + if err := runDir.WriteSourceDocument(map[string]any{"source_id": "source-1"}); err != nil { + t.Fatalf("WriteSourceDocument: %v", err) + } + if err := runDir.WriteRunManifest(artifacts.RunManifest{RunID: "run-1"}); err != nil { + t.Fatalf("WriteRunManifest: %v", err) + } + if err := runDir.WriteRunReport(map[string]any{"ok": true}); err != nil { + t.Fatalf("WriteRunReport: %v", err) + } + if err := runDir.WriteWarnings([]contracts.Warning{{ReasonCode: "test", Message: "warning"}}); err != nil { + t.Fatalf("WriteWarnings: %v", err) + } + + for _, name := range []string{ + ArtifactEffectiveConfig, + ArtifactResolvedPipeline, + ArtifactSourceDocument, + ArtifactRunManifest, + ArtifactRunReport, + ArtifactWarnings, + } { + if _, err := os.Stat(filepath.Join(runDir.Path(), name)); err != nil { + t.Fatalf("expected artifact %q: %v", name, err) + } + } +} + +func TestWriteErrorLogWritesPlainTextWithTrailingNewline(t *testing.T) { + runDir := newTestRunDirectory(t) + + if err := runDir.WriteErrorLog("something failed"); err != nil { + t.Fatalf("WriteErrorLog: %v", err) + } + + if got := string(readArtifact(t, runDir, ArtifactErrorLog)); got != "something failed\n" { + t.Fatalf("unexpected error log: %q", got) + } +} + +func TestArtifactPathRejectsUnsafeNames(t *testing.T) { + runDir := newTestRunDirectory(t) + + tests := []string{ + "", + " ", + "/absolute.json", + "nested/artifact.json", + `nested\artifact.json`, + "../escape.json", + } + + for _, name := range tests { + t.Run(name, func(t *testing.T) { + if err := runDir.WriteJSONArtifact(name, map[string]any{}); err == nil { + t.Fatalf("expected unsafe artifact name %q to be rejected", name) + } + }) + } +} + +func TestShouldRetainRunDirectoryDecisions(t *testing.T) { + tests := []struct { + name string + input RetentionDecisionInput + want bool + }{ + {name: "failed auto retained", input: RetentionDecisionInput{RetentionMode: RetentionAuto, RunSucceeded: false}, want: true}, + {name: "failed always retained", input: RetentionDecisionInput{RetentionMode: RetentionAlways, RunSucceeded: false}, want: true}, + {name: "failed never retained", input: RetentionDecisionInput{RetentionMode: RetentionNever, RunSucceeded: false}, want: true}, + {name: "successful always retained", input: RetentionDecisionInput{RetentionMode: RetentionAlways, RunSucceeded: true}, want: true}, + {name: "successful never removed", input: RetentionDecisionInput{RetentionMode: RetentionNever, RunSucceeded: true}, want: false}, + {name: "successful auto without warnings removed", input: RetentionDecisionInput{RetentionMode: RetentionAuto, RunSucceeded: true}, want: false}, + {name: "successful auto with warnings retained", input: RetentionDecisionInput{RetentionMode: RetentionAuto, RunSucceeded: true, HasWarnings: true}, want: true}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if got := ShouldRetainRunDirectory(tc.input); got != tc.want { + t.Fatalf("ShouldRetainRunDirectory() = %v, want %v", got, tc.want) + } + }) + } +} + +func TestApplyRetentionRemovesOnlyRunDirectory(t *testing.T) { + workDir := t.TempDir() + runDir, err := NewRunDirectory(workDir, RetentionNever) + if err != nil { + t.Fatalf("NewRunDirectory: %v", err) + } + siblingPath := filepath.Join(workDir, "sibling") + if err := os.WriteFile(siblingPath, []byte("keep"), 0o644); err != nil { + t.Fatalf("write sibling: %v", err) + } + + if err := runDir.ApplyRetention(RetentionDecisionInput{RunSucceeded: true}); err != nil { + t.Fatalf("ApplyRetention: %v", err) + } + + if _, err := os.Stat(runDir.Path()); !os.IsNotExist(err) { + t.Fatalf("expected run directory removed, stat err=%v", err) + } + if _, err := os.Stat(workDir); err != nil { + t.Fatalf("expected work directory retained: %v", err) + } + if _, err := os.Stat(siblingPath); err != nil { + t.Fatalf("expected sibling retained: %v", err) + } +} + +func TestApplyRetentionKeepsRetainedRunDirectory(t *testing.T) { + runDir := newTestRunDirectory(t) + + if err := runDir.ApplyRetention(RetentionDecisionInput{RunSucceeded: true, HasWarnings: true}); err != nil { + t.Fatalf("ApplyRetention: %v", err) + } + + if _, err := os.Stat(runDir.Path()); err != nil { + t.Fatalf("expected run directory retained: %v", err) + } +} + +func newTestRunDirectory(t *testing.T) *RunDirectory { + t.Helper() + runDir, err := NewRunDirectory(t.TempDir(), RetentionAuto) + if err != nil { + t.Fatalf("NewRunDirectory: %v", err) + } + return runDir +} + +func readArtifact(t *testing.T, runDir *RunDirectory, name string) []byte { + t.Helper() + data, err := os.ReadFile(filepath.Join(runDir.Path(), name)) + if err != nil { + t.Fatalf("read artifact %q: %v", name, err) + } + return data +}