Add diagnostics run directory
This commit is contained in:
12
internal/core/diagnostics/artifacts.go
Normal file
12
internal/core/diagnostics/artifacts.go
Normal file
@@ -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"
|
||||
)
|
||||
22
internal/core/diagnostics/artifacts_test.go
Normal file
22
internal/core/diagnostics/artifacts_test.go
Normal file
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
220
internal/core/diagnostics/run_dir.go
Normal file
220
internal/core/diagnostics/run_dir.go
Normal file
@@ -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
|
||||
}
|
||||
262
internal/core/diagnostics/run_dir_test.go
Normal file
262
internal/core/diagnostics/run_dir_test.go
Normal file
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user