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 }