Add internal debug bundle collaborators

This commit is contained in:
2026-07-18 04:57:50 +00:00
parent 5bd0ba7a72
commit a9250206d5
11 changed files with 336 additions and 28 deletions

View File

@@ -0,0 +1,89 @@
// Package debugbundle owns explicitly requested per-run debug bundles.
package debugbundle
import (
"fmt"
"os"
"path/filepath"
"strings"
"time"
)
const maxCreateAttempts = 16
var utcNow = func() time.Time { return time.Now().UTC() }
type Bundle struct {
path, summaryRoot, traceRoot string
createdAt time.Time
}
func Allocate(parent string) (*Bundle, error) {
parent = strings.TrimSpace(parent)
if parent == "" {
return nil, fmt.Errorf("debug parent must not be empty")
}
if err := os.MkdirAll(parent, 0o700); err != nil {
return nil, fmt.Errorf("create debug parent %q: %w", parent, err)
}
var last string
for attempt := 0; attempt < maxCreateAttempts; attempt++ {
createdAt := utcNow()
runID := fmt.Sprintf("run-%d", createdAt.UnixNano())
path := filepath.Join(parent, runID)
last = path
if err := os.Mkdir(path, 0o700); err != nil {
if os.IsExist(err) {
continue
}
return nil, fmt.Errorf("create debug bundle %q: %w", path, err)
}
summary, trace := filepath.Join(path, "summary"), filepath.Join(path, "trace")
if err := os.Mkdir(summary, 0o700); err != nil {
_ = os.Remove(path)
return nil, fmt.Errorf("create debug summary %q: %w", summary, err)
}
if err := os.Mkdir(trace, 0o700); err != nil {
_ = os.RemoveAll(path)
return nil, fmt.Errorf("create debug trace %q: %w", trace, err)
}
return &Bundle{path: path, summaryRoot: summary, traceRoot: trace, createdAt: createdAt}, nil
}
return nil, fmt.Errorf("create debug bundle %q: exhausted unique run ID attempts", last)
}
func (b *Bundle) Path() string {
if b == nil {
return ""
}
return b.path
}
func (b *Bundle) SummaryRoot() string {
if b == nil {
return ""
}
return b.summaryRoot
}
func (b *Bundle) TraceRoot() string {
if b == nil {
return ""
}
return b.traceRoot
}
func (b *Bundle) RunID() string {
if b == nil {
return ""
}
return filepath.Base(b.path)
}
func (b *Bundle) CreatedAt() time.Time {
if b == nil {
return time.Time{}
}
return b.createdAt
}
func (b *Bundle) Summary() *SummaryWriter {
if b == nil {
return nil
}
return &SummaryWriter{root: b.summaryRoot, runID: b.RunID(), createdAt: b.createdAt}
}

View File

@@ -0,0 +1,75 @@
package debugbundle
import (
"os"
"path/filepath"
"testing"
"time"
)
func TestAllocateCreatesRestrictiveSummaryAndTrace(t *testing.T) {
parent := t.TempDir()
fixed := time.Unix(0, 42).UTC()
previous := utcNow
utcNow = func() time.Time { return fixed }
defer func() { utcNow = previous }()
bundle, err := Allocate(parent)
if err != nil {
t.Fatal(err)
}
if bundle.RunID() != "run-42" || bundle.SummaryRoot() != filepath.Join(bundle.Path(), "summary") || bundle.TraceRoot() != filepath.Join(bundle.Path(), "trace") {
t.Fatalf("bundle=%#v", bundle)
}
for _, path := range []string{bundle.Path(), bundle.SummaryRoot(), bundle.TraceRoot()} {
info, err := os.Stat(path)
if err != nil {
t.Fatal(err)
}
if info.Mode().Perm() != 0o700 {
t.Fatalf("%s mode=%#o", path, info.Mode().Perm())
}
}
if err := bundle.Summary().WriteError("failed"); err != nil {
t.Fatal(err)
}
info, err := os.Stat(filepath.Join(bundle.SummaryRoot(), ArtifactErrorLog))
if err != nil {
t.Fatal(err)
}
if info.Mode().Perm() != 0o600 {
t.Fatalf("file mode=%#o", info.Mode().Perm())
}
}
func TestAllocateRetriesAndDoesNotDeleteBundle(t *testing.T) {
parent := t.TempDir()
fixed := time.Unix(0, 9).UTC()
previous := utcNow
defer func() { utcNow = previous }()
calls := 0
utcNow = func() time.Time { calls++; return fixed.Add(time.Duration(calls-1) * time.Nanosecond) }
if err := os.Mkdir(filepath.Join(parent, "run-9"), 0o700); err != nil {
t.Fatal(err)
}
bundle, err := Allocate(parent)
if err != nil {
t.Fatal(err)
}
if bundle.RunID() != "run-10" {
t.Fatalf("run id=%q", bundle.RunID())
}
if _, err := os.Stat(bundle.Path()); err != nil {
t.Fatal(err)
}
}
func TestSummaryWriterConfinesArtifacts(t *testing.T) {
bundle, err := Allocate(t.TempDir())
if err != nil {
t.Fatal(err)
}
if err := bundle.Summary().WriteJSON("../outside.json", map[string]any{}); err == nil {
t.Fatal("accepted traversal")
}
if err := bundle.Summary().WriteBytes(`trace\\x`, []byte("x")); err == nil {
t.Fatal("accepted backslash")
}
}

View File

@@ -0,0 +1,115 @@
package debugbundle
import (
"fmt"
"strings"
"time"
"gitea.maximumdirect.net/eric/notarius/internal/core/artifacts"
"gitea.maximumdirect.net/eric/notarius/internal/core/fileio"
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
)
const (
ArtifactInvocationMetadata = "invocation.json"
ArtifactEffectiveConfig = "effective-config.json"
ArtifactResolvedPipeline = "resolved-pipeline.json"
ArtifactResolvedReferences = "resolved-references.json"
ArtifactCheckpointEvents = "checkpoint-events.json"
ArtifactRunManifest = "run-manifest.json"
ArtifactChunkPlan = "chunk-plan.json"
ArtifactRunReport = "run-report.json"
ArtifactWarnings = "warnings.json"
ArtifactErrorLog = "error.log"
)
type RedactedSummaryPayload interface{ RedactedSummaryPayload() any }
type Invocation struct {
Operation string `json:"operation"`
PipelineID string `json:"pipeline_id,omitempty"`
PipelineDigest string `json:"pipeline_digest,omitempty"`
Resume bool `json:"resume,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"`
ChunkCacheOverride string `json:"chunk_cache_override,omitempty"`
RunID string `json:"run_id"`
StartedAt time.Time `json:"started_at"`
}
type RunReport struct {
OutputPath string `json:"output_path,omitempty"`
DebugPath string `json:"debug_path,omitempty"`
Succeeded bool `json:"succeeded"`
WarningCount int `json:"warning_count"`
}
type SummaryWriter struct {
root, runID string
createdAt time.Time
}
func (w *SummaryWriter) WriteInvocation(payload Invocation) error {
if w == nil {
return fmt.Errorf("debug summary writer must not be nil")
}
if payload.RunID == "" {
payload.RunID = w.runID
}
if payload.StartedAt.IsZero() {
payload.StartedAt = w.createdAt
}
return w.WriteJSON(ArtifactInvocationMetadata, payload)
}
func (w *SummaryWriter) WriteRedactedEffectiveConfig(payload RedactedSummaryPayload) error {
if payload == nil {
return fmt.Errorf("redacted summary payload must not be nil")
}
return w.WriteJSON(ArtifactEffectiveConfig, payload.RedactedSummaryPayload())
}
func (w *SummaryWriter) WriteResolvedPipeline(v any) error {
return w.WriteJSON(ArtifactResolvedPipeline, v)
}
func (w *SummaryWriter) WriteResolvedReferences(v any) error {
return w.WriteJSON(ArtifactResolvedReferences, v)
}
func (w *SummaryWriter) WriteCheckpointEvents(v any) error {
return w.WriteJSON(ArtifactCheckpointEvents, v)
}
func (w *SummaryWriter) WriteRunManifest(v artifacts.RunManifest) error {
return w.WriteJSON(ArtifactRunManifest, v)
}
func (w *SummaryWriter) WriteChunkPlan(v artifacts.ChunkPlanSummary) error {
return w.WriteJSON(ArtifactChunkPlan, v)
}
func (w *SummaryWriter) WriteRunReport(v RunReport) error { return w.WriteJSON(ArtifactRunReport, v) }
func (w *SummaryWriter) WriteWarnings(v []contracts.Warning) error {
return w.WriteJSON(ArtifactWarnings, v)
}
func (w *SummaryWriter) WriteError(message string) error {
return w.WriteBytes(ArtifactErrorLog, []byte(message+"\n"))
}
func (w *SummaryWriter) WriteJSON(name string, v any) error {
if w == nil {
return fmt.Errorf("debug summary writer must not be nil")
}
if err := fileio.WriteJSON(w.root, summaryName(name), v, 0o700, 0o600); err != nil {
return fmt.Errorf("write debug summary artifact %q: %w", name, err)
}
return nil
}
func (w *SummaryWriter) WriteBytes(name string, v []byte) error {
if w == nil {
return fmt.Errorf("debug summary writer must not be nil")
}
if err := fileio.WriteBytes(w.root, summaryName(name), v, 0o700, 0o600); err != nil {
return fmt.Errorf("write debug summary artifact %q: %w", name, err)
}
return nil
}
func summaryName(name string) string {
name = strings.TrimSpace(name)
if name == "" || strings.ContainsAny(name, "/\\") {
return "../invalid"
}
return name
}