Implement structured run reporting
This commit is contained in:
@@ -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 {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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())
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user