284 lines
7.3 KiB
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
|
|
}
|