Files
notarius/internal/core/diagnostics/run_dir.go

284 lines
7.3 KiB
Go

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"
maxRunDirectoryCreateAttempts = 16
)
var utcNow = func() time.Time {
return time.Now().UTC()
}
// 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
}
type RedactedEffectiveConfigPayload interface {
RedactedDiagnosticsPayload() any
}
// 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)
}
var lastRunPath string
for attempt := 0; attempt < maxRunDirectoryCreateAttempts; attempt++ {
createdAt := utcNow()
runID := fmt.Sprintf("run-%d", createdAt.UnixNano())
runPath := filepath.Join(workDir, runID)
lastRunPath = runPath
if err := os.Mkdir(runPath, 0o755); err != nil {
if os.IsExist(err) {
continue
}
return nil, fmt.Errorf("create diagnostics run directory %q: %w", runPath, err)
}
return &RunDirectory{
path: runPath,
retention: retention,
createdAt: createdAt,
}, nil
}
return nil, fmt.Errorf("create diagnostics run directory %q: exhausted unique run ID attempts", lastRunPath)
}
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 RedactedEffectiveConfigPayload) error {
if payload == nil {
return fmt.Errorf("redacted effective config payload must not be nil")
}
return r.WriteJSONArtifact(ArtifactEffectiveConfig, payload.RedactedDiagnosticsPayload())
}
func (r *RunDirectory) WriteResolvedPipeline(payload any) error {
return r.WriteJSONArtifact(ArtifactResolvedPipeline, payload)
}
func (r *RunDirectory) WriteResolvedReferences(payload any) error {
return r.WriteJSONArtifact(ArtifactResolvedReferences, 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 := writeFileAtomic(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 := writeFileAtomic(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
}
func writeFileAtomic(path string, data []byte, perm os.FileMode) error {
dir := filepath.Dir(path)
if err := os.MkdirAll(dir, 0o755); err != nil {
return err
}
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
}