Implement structured run reporting

This commit is contained in:
2026-06-03 11:43:50 +00:00
parent 44df38e555
commit f236a8086a
5 changed files with 263 additions and 120 deletions

View File

@@ -47,83 +47,66 @@ func runConfig(ctx context.Context, cfg config.Config, options RunOptions) error
type backendFactoryProvider func(config.Environment) *backendFactory type backendFactoryProvider func(config.Environment) *backendFactory
func runConfigWithBackendFactory(ctx context.Context, cfg config.Config, options RunOptions, provider backendFactoryProvider) error { func runConfigWithBackendFactory(ctx context.Context, cfg config.Config, options RunOptions, provider backendFactoryProvider) error {
report, err := buildRunReportWithBackendFactory(ctx, cfg, options, provider)
if err != nil && !IsPartialResultError(err) {
return err
}
if outputErr := WriteRunReport(options.Stdout, options.OutputFormat, report); outputErr != nil {
return outputErr
}
return err
}
func buildRunReportWithBackendFactory(ctx context.Context, cfg config.Config, options RunOptions, provider backendFactoryProvider) (RunReport, error) {
notifier := options.Notifier notifier := options.Notifier
if notifier == nil { if notifier == nil {
notifier = notify.Noop{} notifier = notify.Noop{}
} }
jsonOutput := IsJSONOutput(options.OutputFormat)
summary := runSummary{dryRun: options.DryRun} summary := runSummary{dryRun: options.DryRun}
result := runResult{ report := RunReport{
DryRun: options.DryRun, DryRun: options.DryRun,
Pipelines: []runPipelineResult{}, Pipelines: []RunPipelineSummary{},
Actions: []runActionResult{}, Actions: []RunActionRecord{},
} }
var warnings []OutputWarning
var failures runFailures var failures runFailures
secretLoad, err := config.LoadSecretEnvironment(cfg.Secrets.Directory, nil) secretLoad, err := config.LoadSecretEnvironment(cfg.Secrets.Directory, nil)
if err != nil { if err != nil {
return err return report, err
} }
secretWarnings := secretConflictWarnings(secretLoad.Conflicts) secretWarnings := secretConflictWarnings(secretLoad.Conflicts)
if jsonOutput { report.PreambleWarnings = append(report.PreambleWarnings, secretWarnings...)
warnings = append(warnings, secretWarnings...) report.addWarnings(secretWarnings)
} else if options.Stdout != nil {
if err := writeWarnings(options.Stdout, secretWarnings); err != nil {
return err
}
}
backends := provider(secretLoad.Environment) backends := provider(secretLoad.Environment)
backends.readOnlyKnownHosts = options.DryRun backends.readOnlyKnownHosts = options.DryRun
transforms := newTransformRegistry() transforms := newTransformRegistry()
if options.Stdout != nil && !jsonOutput {
if _, err := fmt.Fprintf(options.Stdout, "Configured pipelines: %d\n", len(cfg.Pipelines)); err != nil {
return err
}
}
for _, pipeline := range cfg.Pipelines { for _, pipeline := range cfg.Pipelines {
pipelineWarnings := sshWarnings(pipeline) pipelineWarnings := sshWarnings(pipeline)
if jsonOutput { report.addWarnings(pipelineWarnings)
warnings = append(warnings, pipelineWarnings...)
} else if options.Stdout != nil {
if err := writeWarnings(options.Stdout, pipelineWarnings); err != nil {
return err
}
}
sourceBackend, err := backends.openSource(ctx, pipeline.Source) sourceBackend, err := backends.openSource(ctx, pipeline.Source)
if err != nil { if err != nil {
return fmt.Errorf("pipeline %s source backend %s: %w", pipeline.ID, pipeline.Source.Backend, err) return report, fmt.Errorf("pipeline %s source backend %s: %w", pipeline.ID, pipeline.Source.Backend, err)
} }
bundles, err := bundle.Discover(ctx, sourceBackend, "") bundles, err := bundle.Discover(ctx, sourceBackend, "")
if err != nil { if err != nil {
closeBackend(sourceBackend) closeBackend(sourceBackend)
return fmt.Errorf("pipeline %s source backend %s discover source bundles: %w", pipeline.ID, pipeline.Source.Backend, err) return report, fmt.Errorf("pipeline %s source backend %s discover source bundles: %w", pipeline.ID, pipeline.Source.Backend, err)
} }
result.Pipelines = append(result.Pipelines, runPipelineResult{ report.Pipelines = append(report.Pipelines, RunPipelineSummary{
ID: pipeline.ID, ID: pipeline.ID,
SourceBackend: pipeline.Source.Backend, SourceBackend: pipeline.Source.Backend,
BundleCount: len(bundles), BundleCount: len(bundles),
Destinations: destinationIDs(pipeline.Destinations), Destinations: destinationIDs(pipeline.Destinations),
Warnings: pipelineWarnings,
}) })
if options.Stdout != nil && !jsonOutput { pipelineIndex := len(report.Pipelines) - 1
if _, err := fmt.Fprintf(options.Stdout, "- pipeline=%s source=%s bundles=%d destinations=%s\n", pipeline.ID, pipeline.Source.Backend, len(bundles), destinationSummary(pipeline.Destinations)); err != nil {
closeBackend(sourceBackend)
return err
}
}
for _, destination := range pipeline.Destinations { for _, destination := range pipeline.Destinations {
selections := selectDestinationBundles(destination, bundles) selections := selectDestinationBundles(destination, bundles)
if isFixedPathDestination(destination) { if isFixedPathDestination(destination) {
summary.recordFixedPath() summary.recordFixedPath()
if options.DryRun { if options.DryRun {
warning := fixedPathSelectionWarning(pipeline.ID, destination.ID, selections, len(bundles)) warning := fixedPathSelectionWarning(pipeline.ID, destination.ID, selections, len(bundles))
if jsonOutput { report.addWarning(warning)
warnings = append(warnings, warning) report.Pipelines[pipelineIndex].events = append(report.Pipelines[pipelineIndex].events, warningEvent(warning))
} else if options.Stdout != nil {
if err := writeWarnings(options.Stdout, []OutputWarning{warning}); err != nil {
closeBackend(sourceBackend)
return err
}
}
} }
} }
if len(selections) == 0 { if len(selections) == 0 {
@@ -134,11 +117,8 @@ func runConfigWithBackendFactory(ctx context.Context, cfg config.Config, options
for _, selection := range selections { for _, selection := range selections {
failures.add(pipeline.ID, destination.ID, destination.Backend, storage.DisplayPath(selection.SourceBundle.RootRelativePath), err) failures.add(pipeline.ID, destination.ID, destination.Backend, storage.DisplayPath(selection.SourceBundle.RootRelativePath), err)
summary.recordFailure() summary.recordFailure()
if jsonOutput { report.Actions = append(report.Actions, errorAction(pipeline.ID, destination.ID, destination.Backend, selection.SourceBundle.RootRelativePath, err))
result.Actions = append(result.Actions, errorAction(pipeline.ID, destination.ID, destination.Backend, selection.SourceBundle.RootRelativePath, err)) report.Pipelines[pipelineIndex].events = append(report.Pipelines[pipelineIndex].events, actionEvent(len(report.Actions)-1))
} else if options.Stdout != nil {
writeErrorLine(options.Stdout, selection.SourceBundle.RootRelativePath, destination.ID, destination.Backend, err)
}
} }
continue continue
} }
@@ -189,22 +169,12 @@ func runConfigWithBackendFactory(ctx context.Context, cfg config.Config, options
plan.PathMapping = config.PathMappingFixed plan.PathMapping = config.PathMappingFixed
if options.DryRun && isDestructiveFixedPathAction(plan.Action) { if options.DryRun && isDestructiveFixedPathAction(plan.Action) {
warning := fixedPathReplacementWarning(plan) warning := fixedPathReplacementWarning(plan)
if jsonOutput { report.addWarning(warning)
warnings = append(warnings, warning) report.Pipelines[pipelineIndex].events = append(report.Pipelines[pipelineIndex].events, warningEvent(warning))
} else if options.Stdout != nil {
if err := writeWarnings(options.Stdout, []OutputWarning{warning}); err != nil {
deferCloseDestination()
closeBackend(sourceBackend)
return err
}
}
} }
} }
if jsonOutput { report.Actions = append(report.Actions, runActionFromPlan(destination.Backend, plan, err))
result.Actions = append(result.Actions, runActionFromPlan(destination.Backend, plan, err)) report.Pipelines[pipelineIndex].events = append(report.Pipelines[pipelineIndex].events, actionEvent(len(report.Actions)-1))
} else if options.Stdout != nil {
writePlanLine(options.Stdout, destination.Backend, plan, err)
}
if err != nil { if err != nil {
failures.add(pipeline.ID, destination.ID, destination.Backend, storage.DisplayPath(sourceBundle.RootRelativePath), err) failures.add(pipeline.ID, destination.ID, destination.Backend, storage.DisplayPath(sourceBundle.RootRelativePath), err)
summary.recordFailure() summary.recordFailure()
@@ -230,20 +200,12 @@ func runConfigWithBackendFactory(ctx context.Context, cfg config.Config, options
} }
closeBackend(sourceBackend) closeBackend(sourceBackend)
} }
result.Summary = summary.Result() report.Summary = summary.Result()
if jsonOutput { report.OutputErrors = failures.outputErrors()
if err := WriteJSONEnvelope(options.Stdout, "run", len(failures.items) == 0, warnings, result, failures.outputErrors()); err != nil {
return err
}
} else if options.Stdout != nil {
if _, err := fmt.Fprintln(options.Stdout, summary.Line()); err != nil {
return err
}
}
if len(failures.items) > 0 { if len(failures.items) > 0 {
return failures return report, failures
} }
return nil return report, nil
} }
type closeableBackend interface { type closeableBackend interface {

View File

@@ -10,61 +10,125 @@ import (
"gitea.maximumdirect.net/eric/distributor/internal/storage" "gitea.maximumdirect.net/eric/distributor/internal/storage"
) )
func writePlanLine(w io.Writer, backend string, plan publish.Plan, planErr error) { func WriteRunReport(w io.Writer, format OutputFormat, report RunReport) error {
if w == nil { if IsJSONOutput(format) {
return return WriteJSONEnvelope(w, "run", len(report.OutputErrors) == 0, report.Warnings, report, report.OutputErrors)
} }
if planErr != nil { return writeRunReportText(w, report)
destinationID := plan.DestinationID }
func writeRunReportText(w io.Writer, report RunReport) error {
if w == nil {
return nil
}
if err := writeWarnings(w, report.PreambleWarnings); err != nil {
return err
}
if _, err := fmt.Fprintf(w, "Configured pipelines: %d\n", len(report.Pipelines)); err != nil {
return err
}
for _, pipeline := range report.Pipelines {
if err := writeWarnings(w, pipeline.Warnings); err != nil {
return err
}
if _, err := fmt.Fprintf(w, "- pipeline=%s source=%s bundles=%d destinations=%s\n", pipeline.ID, pipeline.SourceBackend, pipeline.BundleCount, destinationIDSummary(pipeline.Destinations)); err != nil {
return err
}
for _, event := range pipeline.events {
if event.warning != nil {
if err := writeWarnings(w, []OutputWarning{*event.warning}); err != nil {
return err
}
continue
}
if event.actionIndex < 0 || event.actionIndex >= len(report.Actions) {
continue
}
writeRunActionLine(w, report.Actions[event.actionIndex])
}
}
_, err := fmt.Fprintln(w, report.Summary.Line())
return err
}
func writeRunActionLine(w io.Writer, action RunActionRecord) {
if action.Action == "error" {
destinationID := action.DestinationID
if destinationID == "" { if destinationID == "" {
destinationID = "unknown" destinationID = "unknown"
} }
fmt.Fprintf(w, " - bundle=%s destination=%s backend=%s%s action=error reason=%q\n", storage.DisplayPath(plan.BundlePath), destinationID, backend, pathMappingSummary(plan), planErr.Error()) fmt.Fprintf(w, " - bundle=%s destination=%s backend=%s%s action=error reason=%q\n", action.BundlePath, destinationID, action.Backend, pathMappingRecordSummary(action), action.Reason)
return return
} }
fmt.Fprintf(w, " - bundle=%s destination=%s backend=%s%s action=%s outputs=%s reason=%q\n", storage.DisplayPath(plan.BundlePath), plan.DestinationID, backend, pathMappingSummary(plan), plan.Action, outputSummary(plan.Outputs), plan.Reason) fmt.Fprintf(w, " - bundle=%s destination=%s backend=%s%s action=%s outputs=%s reason=%q\n", action.BundlePath, action.DestinationID, action.Backend, pathMappingRecordSummary(action), action.Action, outputRecordSummary(action.Outputs), action.Reason)
} }
func pathMappingSummary(plan publish.Plan) string { func pathMappingRecordSummary(action RunActionRecord) string {
if plan.PathMapping != config.PathMappingFixed { if action.PathMapping != config.PathMappingFixed {
return "" return ""
} }
return fmt.Sprintf(" path_mapping=fixed target=%s", storage.DisplayPath(plan.DestinationBundlePath)) return fmt.Sprintf(" path_mapping=fixed target=%s", action.DestinationPath)
} }
func writeErrorLine(w io.Writer, bundlePath, destinationID, backend string, err error) { func outputRecordSummary(outputs []RunOutputRecord) string {
if w == nil {
return
}
fmt.Fprintf(w, " - bundle=%s destination=%s backend=%s action=error reason=%q\n", storage.DisplayPath(bundlePath), destinationID, backend, err.Error())
}
func outputSummary(outputs []publish.Output) string {
if len(outputs) == 0 { if len(outputs) == 0 {
return "none" return "none"
} }
paths := make([]string, 0, len(outputs)) paths := make([]string, 0, len(outputs))
for _, output := range outputs { for _, output := range outputs {
paths = append(paths, output.DestinationPath) paths = append(paths, output.Path)
} }
return strings.Join(paths, ",") return strings.Join(paths, ",")
} }
type runResult struct { func destinationIDSummary(ids []string) string {
DryRun bool `json:"dry_run"` if len(ids) == 0 {
Pipelines []runPipelineResult `json:"pipelines"` return "none"
Actions []runActionResult `json:"actions"` }
Summary runSummaryResult `json:"summary"` return strings.Join(ids, ",")
} }
type runPipelineResult struct { type RunReport struct {
ID string `json:"id"` DryRun bool `json:"dry_run"`
SourceBackend string `json:"source_backend"` Pipelines []RunPipelineSummary `json:"pipelines"`
BundleCount int `json:"bundle_count"` Actions []RunActionRecord `json:"actions"`
Destinations []string `json:"destinations"` Summary RunSummaryCounters `json:"summary"`
Warnings []OutputWarning `json:"-"`
OutputErrors []OutputError `json:"-"`
PreambleWarnings []OutputWarning `json:"-"`
} }
type runActionResult struct { func (r *RunReport) addWarning(warning OutputWarning) {
r.Warnings = append(r.Warnings, warning)
}
func (r *RunReport) addWarnings(warnings []OutputWarning) {
r.Warnings = append(r.Warnings, warnings...)
}
type RunPipelineSummary struct {
ID string `json:"id"`
SourceBackend string `json:"source_backend"`
BundleCount int `json:"bundle_count"`
Destinations []string `json:"destinations"`
Warnings []OutputWarning `json:"-"`
events []runPipelineEvent
}
type runPipelineEvent struct {
warning *OutputWarning
actionIndex int
}
func warningEvent(warning OutputWarning) runPipelineEvent {
return runPipelineEvent{warning: &warning, actionIndex: -1}
}
func actionEvent(actionIndex int) runPipelineEvent {
return runPipelineEvent{actionIndex: actionIndex}
}
type RunActionRecord struct {
PipelineID string `json:"pipeline_id,omitempty"` PipelineID string `json:"pipeline_id,omitempty"`
DestinationID string `json:"destination_id"` DestinationID string `json:"destination_id"`
Backend string `json:"backend"` Backend string `json:"backend"`
@@ -75,10 +139,10 @@ type runActionResult struct {
Action string `json:"action"` Action string `json:"action"`
PrimaryURL string `json:"primary_url,omitempty"` PrimaryURL string `json:"primary_url,omitempty"`
Reason string `json:"reason,omitempty"` Reason string `json:"reason,omitempty"`
Outputs []runOutputResult `json:"outputs"` Outputs []RunOutputRecord `json:"outputs"`
} }
type runOutputResult struct { type RunOutputRecord struct {
Path string `json:"path"` Path string `json:"path"`
Kind string `json:"kind"` Kind string `json:"kind"`
SourcePath string `json:"source_path,omitempty"` SourcePath string `json:"source_path,omitempty"`
@@ -88,13 +152,13 @@ type runOutputResult struct {
Size int64 `json:"size"` Size int64 `json:"size"`
} }
func runActionFromPlan(backend string, plan publish.Plan, planErr error) runActionResult { func runActionFromPlan(backend string, plan publish.Plan, planErr error) RunActionRecord {
if planErr != nil { if planErr != nil {
destinationID := plan.DestinationID destinationID := plan.DestinationID
if destinationID == "" { if destinationID == "" {
destinationID = "unknown" destinationID = "unknown"
} }
return runActionResult{ return RunActionRecord{
PipelineID: plan.PipelineID, PipelineID: plan.PipelineID,
DestinationID: destinationID, DestinationID: destinationID,
Backend: backend, Backend: backend,
@@ -105,10 +169,10 @@ func runActionFromPlan(backend string, plan publish.Plan, planErr error) runActi
Action: "error", Action: "error",
PrimaryURL: plan.PrimaryURL, PrimaryURL: plan.PrimaryURL,
Reason: planErr.Error(), Reason: planErr.Error(),
Outputs: []runOutputResult{}, Outputs: []RunOutputRecord{},
} }
} }
return runActionResult{ return RunActionRecord{
PipelineID: plan.PipelineID, PipelineID: plan.PipelineID,
DestinationID: plan.DestinationID, DestinationID: plan.DestinationID,
Backend: backend, Backend: backend,
@@ -123,8 +187,8 @@ func runActionFromPlan(backend string, plan publish.Plan, planErr error) runActi
} }
} }
func errorAction(pipelineID, destinationID, backend, bundlePath string, err error) runActionResult { func errorAction(pipelineID, destinationID, backend, bundlePath string, err error) RunActionRecord {
return runActionResult{ return RunActionRecord{
PipelineID: pipelineID, PipelineID: pipelineID,
DestinationID: destinationID, DestinationID: destinationID,
Backend: backend, Backend: backend,
@@ -132,15 +196,15 @@ func errorAction(pipelineID, destinationID, backend, bundlePath string, err erro
DestinationPath: storage.DisplayPath(bundlePath), DestinationPath: storage.DisplayPath(bundlePath),
Action: "error", Action: "error",
Reason: err.Error(), Reason: err.Error(),
Outputs: []runOutputResult{}, Outputs: []RunOutputRecord{},
} }
} }
func runOutputsFromPlan(outputs []publish.Output) []runOutputResult { func runOutputsFromPlan(outputs []publish.Output) []RunOutputRecord {
results := make([]runOutputResult, 0, len(outputs)) results := make([]RunOutputRecord, 0, len(outputs))
for _, output := range outputs { for _, output := range outputs {
stateOutput := output.StateOutputFile() stateOutput := output.StateOutputFile()
results = append(results, runOutputResult{ results = append(results, RunOutputRecord{
Path: stateOutput.Path, Path: stateOutput.Path,
Kind: stateOutput.Kind, Kind: stateOutput.Kind,
SourcePath: stateOutput.SourcePath, SourcePath: stateOutput.SourcePath,

View File

@@ -47,7 +47,7 @@ func (s runSummary) Line() string {
return fmt.Sprintf("Final status: %s planned=%d publish_new=%d replace_older=%d force_replace=%d skipped=%d failed=%d dry_run=%t fixed_path=%d", status, s.planned, s.publishNew, s.replaceOlder, s.forceReplace, s.skipped, s.failures, s.dryRun, s.fixedPath) return fmt.Sprintf("Final status: %s planned=%d publish_new=%d replace_older=%d force_replace=%d skipped=%d failed=%d dry_run=%t fixed_path=%d", status, s.planned, s.publishNew, s.replaceOlder, s.forceReplace, s.skipped, s.failures, s.dryRun, s.fixedPath)
} }
type runSummaryResult struct { type RunSummaryCounters struct {
Status string `json:"status"` Status string `json:"status"`
Planned int `json:"planned"` Planned int `json:"planned"`
PublishNew int `json:"publish_new"` PublishNew int `json:"publish_new"`
@@ -59,12 +59,16 @@ type runSummaryResult struct {
FixedPath int `json:"fixed_path"` FixedPath int `json:"fixed_path"`
} }
func (s runSummary) Result() runSummaryResult { func (s RunSummaryCounters) Line() string {
return fmt.Sprintf("Final status: %s planned=%d publish_new=%d replace_older=%d force_replace=%d skipped=%d failed=%d dry_run=%t fixed_path=%d", s.Status, s.Planned, s.PublishNew, s.ReplaceOlder, s.ForceReplace, s.Skipped, s.Failed, s.DryRun, s.FixedPath)
}
func (s runSummary) Result() RunSummaryCounters {
status := "ok" status := "ok"
if s.failures > 0 { if s.failures > 0 {
status = "failed" status = "failed"
} }
return runSummaryResult{ return RunSummaryCounters{
Status: status, Status: status,
Planned: s.planned, Planned: s.planned,
PublishNew: s.publishNew, PublishNew: s.publishNew,

View File

@@ -718,6 +718,99 @@ func TestRunJSONIncludesGeneratedOutputMetadata(t *testing.T) {
} }
} }
func TestBuildRunReportIncludesStructuredDryRunResults(t *testing.T) {
sourceRoot := t.TempDir()
destinationRoot := t.TempDir()
writeSourceBundle(t, sourceRoot, "", testBundleOptions{})
configPath := testutil.WriteLocalConfigWithLinks(t, sourceRoot, destinationRoot, config.PathMappingFixed, "https://reports.example.com/latest", config.LinkPrimaryAuto, false, true, config.TransformModeIndex)
cfg, err := config.LoadFile(configPath)
if err != nil {
t.Fatalf("load config: %v", err)
}
report, err := buildRunReportWithBackendFactory(context.Background(), cfg, RunOptions{DryRun: true}, newBackendFactoryWithEnvironment)
if err != nil {
t.Fatalf("buildRunReportWithBackendFactory() error = %v", err)
}
if !report.DryRun || report.Summary.Status != "ok" || !report.Summary.DryRun {
t.Fatalf("report dry-run/status = dry_run:%t summary:%#v, want ok dry-run", report.DryRun, report.Summary)
}
if got, want := len(report.Pipelines), 1; got != want {
t.Fatalf("pipeline count = %d, want %d", got, want)
}
pipeline := report.Pipelines[0]
if pipeline.ID != "reports" || pipeline.SourceBackend != config.BackendLocal || pipeline.BundleCount != 1 || strings.Join(pipeline.Destinations, ",") != "archive" {
t.Fatalf("pipeline summary = %#v, want reports/local bundle summary", pipeline)
}
if got, want := len(report.Warnings), 1; got != want {
t.Fatalf("warning count = %d, want %d", got, want)
}
if !strings.Contains(report.Warnings[0].Message, "path_mapping=fixed candidates=1 selected_bundle=.") {
t.Fatalf("warning = %#v, want fixed path selection", report.Warnings[0])
}
if got, want := len(report.Actions), 1; got != want {
t.Fatalf("action count = %d, want %d", got, want)
}
action := report.Actions[0]
if action.PipelineID != "reports" || action.DestinationID != "archive" || action.Action != "publish_new" || action.PrimaryURL != "https://reports.example.com/latest/" {
t.Fatalf("action = %#v, want publish_new with primary URL", action)
}
if action.PathMapping != config.PathMappingFixed || action.DestinationPath != "." {
t.Fatalf("action path mapping = %q destination path = %q, want fixed root", action.PathMapping, action.DestinationPath)
}
if got, want := len(action.Outputs), 1; got != want {
t.Fatalf("output count = %d, want %d", got, want)
}
output := action.Outputs[0]
if output.Path != "index.html" || output.Kind != state.OutputKindGenerated || output.SourcePath != "report.md" || output.Transform != "markdown_to_html" || output.URL != "https://reports.example.com/latest/" {
t.Fatalf("output = %#v, want generated index metadata", output)
}
if report.Summary.Planned != 1 || report.Summary.PublishNew != 1 || report.Summary.FixedPath != 1 || report.Summary.Failed != 0 {
t.Fatalf("summary = %#v, want publish_new fixed path counters", report.Summary)
}
if len(report.OutputErrors) != 0 {
t.Fatalf("output errors = %#v, want none", report.OutputErrors)
}
}
func TestBuildRunReportIncludesPartialFailures(t *testing.T) {
sourceRoot := t.TempDir()
firstDestination := t.TempDir()
secondDestination := t.TempDir()
writeSourceBundle(t, sourceRoot, "", testBundleOptions{})
if err := os.WriteFile(filepath.Join(firstDestination, "unmanaged.txt"), []byte("data"), 0o600); err != nil {
t.Fatalf("write unmanaged file: %v", err)
}
cfg, err := config.LoadFile(writeFanoutConfig(t, sourceRoot, firstDestination, secondDestination))
if err != nil {
t.Fatalf("load config: %v", err)
}
report, err := buildRunReportWithBackendFactory(context.Background(), cfg, RunOptions{}, newBackendFactoryWithEnvironment)
if err == nil || !IsPartialResultError(err) {
t.Fatalf("buildRunReportWithBackendFactory() error = %v, want partial result error", err)
}
if report.Summary.Status != "failed" || report.Summary.Planned != 1 || report.Summary.PublishNew != 1 || report.Summary.Failed != 1 {
t.Fatalf("summary = %#v, want one planned publish and one failure", report.Summary)
}
if got, want := len(report.Actions), 2; got != want {
t.Fatalf("action count = %d, want %d", got, want)
}
if report.Actions[0].DestinationID != "archive-one" || report.Actions[0].Action != "error" || !strings.Contains(report.Actions[0].Reason, "fail_unmanaged") {
t.Fatalf("first action = %#v, want archive-one error", report.Actions[0])
}
if report.Actions[1].DestinationID != "archive-two" || report.Actions[1].Action != "publish_new" {
t.Fatalf("second action = %#v, want archive-two publish_new", report.Actions[1])
}
if got, want := len(report.OutputErrors), 1; got != want {
t.Fatalf("output error count = %d, want %d", got, want)
}
outputError := report.OutputErrors[0]
if outputError.PipelineID != "reports" || outputError.DestinationID != "archive-one" || outputError.Backend != config.BackendLocal || outputError.BundlePath != "." || !strings.Contains(outputError.Message, "fail_unmanaged") {
t.Fatalf("output error = %#v, want archive-one unmanaged failure", outputError)
}
}
func TestRunDoesNotNotifyForSkippedDestination(t *testing.T) { func TestRunDoesNotNotifyForSkippedDestination(t *testing.T) {
sourceRoot := t.TempDir() sourceRoot := t.TempDir()
destinationRoot := t.TempDir() destinationRoot := t.TempDir()

View File

@@ -603,8 +603,12 @@ func TestExecuteRunDryRun(t *testing.T) {
if code != exitOK { if code != exitOK {
t.Fatalf("exit code = %d, want %d; stderr = %q", code, exitOK, stderr.String()) t.Fatalf("exit code = %d, want %d; stderr = %q", code, exitOK, stderr.String())
} }
if !strings.Contains(stdout.String(), "action=publish_new") { wantStdout := "Configured pipelines: 1\n" +
t.Fatalf("stdout = %q, want config summary", stdout.String()) "- pipeline=reports source=local bundles=1 destinations=archive\n" +
" - bundle=. destination=archive backend=local action=publish_new outputs=report.md,summary.txt reason=\"destination state is absent\"\n" +
"Final status: ok planned=1 publish_new=1 replace_older=0 force_replace=0 skipped=0 failed=0 dry_run=true fixed_path=0\n"
if got := stdout.String(); got != wantStdout {
t.Fatalf("stdout = %q, want %q", got, wantStdout)
} }
if stderr.Len() != 0 { if stderr.Len() != 0 {
t.Fatalf("stderr = %q, want empty", stderr.String()) t.Fatalf("stderr = %q, want empty", stderr.String())
@@ -631,6 +635,14 @@ func TestExecuteRunJSONDryRun(t *testing.T) {
if result["dry_run"] != true { if result["dry_run"] != true {
t.Fatalf("result = %#v, want dry_run true", result) t.Fatalf("result = %#v, want dry_run true", result)
} }
pipelines, ok := result["pipelines"].([]any)
if !ok || len(pipelines) != 1 {
t.Fatalf("pipelines = %#v, want one pipeline", result["pipelines"])
}
pipeline, ok := pipelines[0].(map[string]any)
if !ok || pipeline["id"] != "reports" || pipeline["source_backend"] != "local" || pipeline["bundle_count"] != float64(1) {
t.Fatalf("pipeline = %#v, want reports/local summary", pipelines[0])
}
actions, ok := result["actions"].([]any) actions, ok := result["actions"].([]any)
if !ok || len(actions) != 1 { if !ok || len(actions) != 1 {
t.Fatalf("actions = %#v, want one action", result["actions"]) t.Fatalf("actions = %#v, want one action", result["actions"])
@@ -639,6 +651,14 @@ func TestExecuteRunJSONDryRun(t *testing.T) {
if !ok || action["action"] != "publish_new" { if !ok || action["action"] != "publish_new" {
t.Fatalf("action = %#v, want publish_new", actions[0]) t.Fatalf("action = %#v, want publish_new", actions[0])
} }
outputs, ok := action["outputs"].([]any)
if !ok || len(outputs) != 2 {
t.Fatalf("outputs = %#v, want source outputs", action["outputs"])
}
summary, ok := result["summary"].(map[string]any)
if !ok || summary["status"] != "ok" || summary["planned"] != float64(1) || summary["publish_new"] != float64(1) || summary["dry_run"] != true {
t.Fatalf("summary = %#v, want ok dry-run publish counters", result["summary"])
}
if stderr.Len() != 0 { if stderr.Len() != 0 {
t.Fatalf("stderr = %q, want empty", stderr.String()) t.Fatalf("stderr = %q, want empty", stderr.String())
} }