Add notification hook and run summary
This commit is contained in:
@@ -22,7 +22,7 @@ distributor inspect
|
|||||||
|
|
||||||
`run --config <path>` executes configured local-to-local pipelines that publish source files, generated HTML files, or both.
|
`run --config <path>` executes configured local-to-local pipelines that publish source files, generated HTML files, or both.
|
||||||
|
|
||||||
`run --config <path> --dry-run` discovers source bundles, inspects destination state, and prints planned actions without writing files.
|
`run --config <path> --dry-run` discovers source bundles, inspects destination state, and prints planned actions plus a final status summary without writing files.
|
||||||
|
|
||||||
`validate <path>` validates a local source bundle directory or a local tree containing source bundles.
|
`validate <path>` validates a local source bundle directory or a local tree containing source bundles.
|
||||||
|
|
||||||
@@ -43,7 +43,7 @@ Each subcommand supports:
|
|||||||
`run` supports:
|
`run` supports:
|
||||||
|
|
||||||
- `--config <path>`: config file to load.
|
- `--config <path>`: config file to load.
|
||||||
- `--dry-run`: validate config and print the resolved summary without publishing.
|
- `--dry-run`: validate config, print planned actions and final status, and do not publish.
|
||||||
|
|
||||||
## Common workflows
|
## Common workflows
|
||||||
|
|
||||||
|
|||||||
13
docs/internal/notify.md
Normal file
13
docs/internal/notify.md
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
# Notify
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
`internal/notify` defines the internal notification interface used by the application runner.
|
||||||
|
|
||||||
|
## Current behavior
|
||||||
|
|
||||||
|
The implemented notifier is a no-op. It is invoked only after a successful publish or replacement. Dry-run, skipped destinations, and failed destinations do not invoke it.
|
||||||
|
|
||||||
|
## Boundaries
|
||||||
|
|
||||||
|
No external notification adapters are implemented. Notification configuration is not part of the current user-facing config schema.
|
||||||
@@ -40,6 +40,10 @@ If destination state is older than the source manifest, publication replaces onl
|
|||||||
|
|
||||||
If a write fails during local publication, `distributor` removes outputs written during that failed attempt where possible so a retry does not see an unmanaged destination.
|
If a write fails during local publication, `distributor` removes outputs written during that failed attempt where possible so a retry does not see an unmanaged destination.
|
||||||
|
|
||||||
|
If one destination fails, later destinations in the same fan-out are still planned and run where they are independent. The command exits non-zero after printing the final status when any destination fails.
|
||||||
|
|
||||||
|
After a successful publish or replacement, the internal notifier hook runs as a no-op. Skipped destinations do not invoke it.
|
||||||
|
|
||||||
## Caveats
|
## Caveats
|
||||||
|
|
||||||
Only local-to-local publication is implemented. SSH, S3, notification, and force overwrite behavior are not implemented.
|
Only local-to-local publication is implemented. SSH, S3, external notification adapters, and force overwrite behavior are not implemented.
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package app
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -9,6 +10,7 @@ import (
|
|||||||
"gitea.maximumdirect.net/eric/distributor/internal/adapters/local"
|
"gitea.maximumdirect.net/eric/distributor/internal/adapters/local"
|
||||||
"gitea.maximumdirect.net/eric/distributor/internal/bundle"
|
"gitea.maximumdirect.net/eric/distributor/internal/bundle"
|
||||||
"gitea.maximumdirect.net/eric/distributor/internal/config"
|
"gitea.maximumdirect.net/eric/distributor/internal/config"
|
||||||
|
"gitea.maximumdirect.net/eric/distributor/internal/notify"
|
||||||
"gitea.maximumdirect.net/eric/distributor/internal/publish"
|
"gitea.maximumdirect.net/eric/distributor/internal/publish"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -16,6 +18,7 @@ type RunOptions struct {
|
|||||||
ConfigPath string
|
ConfigPath string
|
||||||
DryRun bool
|
DryRun bool
|
||||||
Stdout io.Writer
|
Stdout io.Writer
|
||||||
|
Notifier notify.Notifier
|
||||||
}
|
}
|
||||||
|
|
||||||
func Run(ctx context.Context, options RunOptions) error {
|
func Run(ctx context.Context, options RunOptions) error {
|
||||||
@@ -35,6 +38,12 @@ func Run(ctx context.Context, options RunOptions) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func runConfig(ctx context.Context, cfg config.Config, options RunOptions) error {
|
func runConfig(ctx context.Context, cfg config.Config, options RunOptions) error {
|
||||||
|
notifier := options.Notifier
|
||||||
|
if notifier == nil {
|
||||||
|
notifier = notify.Noop{}
|
||||||
|
}
|
||||||
|
summary := runSummary{dryRun: options.DryRun}
|
||||||
|
var failures runFailures
|
||||||
if options.Stdout != nil {
|
if options.Stdout != nil {
|
||||||
if _, err := fmt.Fprintf(options.Stdout, "Configured pipelines: %d\n", len(cfg.Pipelines)); err != nil {
|
if _, err := fmt.Fprintf(options.Stdout, "Configured pipelines: %d\n", len(cfg.Pipelines)); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -53,18 +62,29 @@ func runConfig(ctx context.Context, cfg config.Config, options RunOptions) error
|
|||||||
return fmt.Errorf("pipeline %s discover source bundles: %w", pipeline.ID, err)
|
return fmt.Errorf("pipeline %s discover source bundles: %w", pipeline.ID, err)
|
||||||
}
|
}
|
||||||
if options.Stdout != nil {
|
if options.Stdout != nil {
|
||||||
if _, err := fmt.Fprintf(options.Stdout, "- %s: source=local bundles=%d destinations=%d\n", pipeline.ID, len(bundles), len(pipeline.Destinations)); err != nil {
|
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 {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for _, sourceBundle := range bundles {
|
for _, sourceBundle := range bundles {
|
||||||
for _, destination := range pipeline.Destinations {
|
for _, destination := range pipeline.Destinations {
|
||||||
if destination.Backend != config.BackendLocal {
|
if destination.Backend != config.BackendLocal {
|
||||||
return fmt.Errorf("pipeline %s destination %s backend %s is not implemented for execution", pipeline.ID, destination.ID, destination.Backend)
|
err := fmt.Errorf("backend %s is not implemented for execution", destination.Backend)
|
||||||
|
failures.add(pipeline.ID, destination.ID, displayBundlePath(sourceBundle.RootRelativePath), err)
|
||||||
|
summary.recordFailure()
|
||||||
|
if options.Stdout != nil {
|
||||||
|
writeErrorLine(options.Stdout, sourceBundle.RootRelativePath, destination.ID, err)
|
||||||
|
}
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
destinationBackend, err := local.New(destination.Path)
|
destinationBackend, err := local.New(destination.Path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
failures.add(pipeline.ID, destination.ID, displayBundlePath(sourceBundle.RootRelativePath), err)
|
||||||
|
summary.recordFailure()
|
||||||
|
if options.Stdout != nil {
|
||||||
|
writeErrorLine(options.Stdout, sourceBundle.RootRelativePath, destination.ID, err)
|
||||||
|
}
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
req := publish.Request{
|
req := publish.Request{
|
||||||
PipelineID: pipeline.ID,
|
PipelineID: pipeline.ID,
|
||||||
@@ -79,20 +99,43 @@ func runConfig(ctx context.Context, cfg config.Config, options RunOptions) error
|
|||||||
DistributorVersion: Version,
|
DistributorVersion: Version,
|
||||||
}
|
}
|
||||||
plan, err := publish.Build(ctx, req)
|
plan, err := publish.Build(ctx, req)
|
||||||
|
if err != nil && plan.DestinationID == "" {
|
||||||
|
plan = publish.Plan{DestinationID: destination.ID, BundlePath: sourceBundle.RootRelativePath}
|
||||||
|
}
|
||||||
if options.Stdout != nil {
|
if options.Stdout != nil {
|
||||||
writePlanLine(options.Stdout, plan, err)
|
writePlanLine(options.Stdout, plan, err)
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("pipeline %s destination %s bundle %s: %w", pipeline.ID, destination.ID, displayBundlePath(sourceBundle.RootRelativePath), err)
|
failures.add(pipeline.ID, destination.ID, displayBundlePath(sourceBundle.RootRelativePath), err)
|
||||||
|
summary.recordFailure()
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
|
summary.recordPlan(plan.Action)
|
||||||
if !options.DryRun {
|
if !options.DryRun {
|
||||||
if err := publish.Execute(ctx, req, plan); err != nil {
|
if err := publish.Execute(ctx, req, plan); err != nil {
|
||||||
return fmt.Errorf("pipeline %s destination %s bundle %s: %w", pipeline.ID, destination.ID, displayBundlePath(sourceBundle.RootRelativePath), err)
|
failures.add(pipeline.ID, destination.ID, displayBundlePath(sourceBundle.RootRelativePath), err)
|
||||||
|
summary.recordFailure()
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if shouldNotify(plan.Action) {
|
||||||
|
if err := notifier.Notify(ctx, notifyEvent(plan)); err != nil {
|
||||||
|
failures.add(pipeline.ID, destination.ID, displayBundlePath(sourceBundle.RootRelativePath), err)
|
||||||
|
summary.recordFailure()
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
if options.Stdout != nil {
|
||||||
|
if _, err := fmt.Fprintln(options.Stdout, summary.Line()); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(failures.items) > 0 {
|
||||||
|
return failures
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -101,12 +144,23 @@ func writePlanLine(w io.Writer, plan publish.Plan, planErr error) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if planErr != nil {
|
if planErr != nil {
|
||||||
fmt.Fprintf(w, " - bundle=%s destination=%s action=error reason=%q\n", displayBundlePath(plan.BundlePath), plan.DestinationID, planErr.Error())
|
destinationID := plan.DestinationID
|
||||||
|
if destinationID == "" {
|
||||||
|
destinationID = "unknown"
|
||||||
|
}
|
||||||
|
fmt.Fprintf(w, " - bundle=%s destination=%s action=error reason=%q\n", displayBundlePath(plan.BundlePath), destinationID, planErr.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
fmt.Fprintf(w, " - bundle=%s destination=%s action=%s outputs=%s reason=%q\n", displayBundlePath(plan.BundlePath), plan.DestinationID, plan.Action, outputSummary(plan.Outputs), plan.Reason)
|
fmt.Fprintf(w, " - bundle=%s destination=%s action=%s outputs=%s reason=%q\n", displayBundlePath(plan.BundlePath), plan.DestinationID, plan.Action, outputSummary(plan.Outputs), plan.Reason)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func writeErrorLine(w io.Writer, bundlePath, destinationID string, err error) {
|
||||||
|
if w == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fmt.Fprintf(w, " - bundle=%s destination=%s action=error reason=%q\n", displayBundlePath(bundlePath), destinationID, err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
func outputSummary(outputs []publish.Output) string {
|
func outputSummary(outputs []publish.Output) string {
|
||||||
if len(outputs) == 0 {
|
if len(outputs) == 0 {
|
||||||
return "none"
|
return "none"
|
||||||
@@ -117,3 +171,112 @@ func outputSummary(outputs []publish.Output) string {
|
|||||||
}
|
}
|
||||||
return strings.Join(paths, ",")
|
return strings.Join(paths, ",")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func destinationSummary(destinations []config.Destination) string {
|
||||||
|
if len(destinations) == 0 {
|
||||||
|
return "none"
|
||||||
|
}
|
||||||
|
ids := make([]string, 0, len(destinations))
|
||||||
|
for _, destination := range destinations {
|
||||||
|
ids = append(ids, destination.ID)
|
||||||
|
}
|
||||||
|
return strings.Join(ids, ",")
|
||||||
|
}
|
||||||
|
|
||||||
|
func shouldNotify(action publish.Action) bool {
|
||||||
|
return action == publish.ActionPublishNew || action == publish.ActionReplaceOlder
|
||||||
|
}
|
||||||
|
|
||||||
|
func notifyEvent(plan publish.Plan) notify.Event {
|
||||||
|
outputs := make([]notify.Output, 0, len(plan.Outputs))
|
||||||
|
for _, output := range plan.Outputs {
|
||||||
|
outputs = append(outputs, notify.Output{
|
||||||
|
Path: output.DestinationPath,
|
||||||
|
Kind: output.Kind,
|
||||||
|
SourcePath: output.SourcePath,
|
||||||
|
Transform: output.Transform,
|
||||||
|
SHA256: output.SHA256,
|
||||||
|
Size: output.Size,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return notify.Event{
|
||||||
|
PipelineID: plan.PipelineID,
|
||||||
|
DestinationID: plan.DestinationID,
|
||||||
|
BundleID: plan.BundleID,
|
||||||
|
BundlePath: plan.BundlePath,
|
||||||
|
Action: string(plan.Action),
|
||||||
|
Outputs: outputs,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type runSummary struct {
|
||||||
|
dryRun bool
|
||||||
|
planned int
|
||||||
|
publishNew int
|
||||||
|
replaceOlder int
|
||||||
|
skipped int
|
||||||
|
failures int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *runSummary) recordPlan(action publish.Action) {
|
||||||
|
s.planned++
|
||||||
|
switch action {
|
||||||
|
case publish.ActionPublishNew:
|
||||||
|
s.publishNew++
|
||||||
|
case publish.ActionReplaceOlder:
|
||||||
|
s.replaceOlder++
|
||||||
|
case publish.ActionSkipSame, publish.ActionSkipDestinationNewer:
|
||||||
|
s.skipped++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *runSummary) recordFailure() {
|
||||||
|
s.failures++
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s runSummary) Line() string {
|
||||||
|
status := "ok"
|
||||||
|
if s.failures > 0 {
|
||||||
|
status = "failed"
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("Final status: %s planned=%d publish_new=%d replace_older=%d skipped=%d failed=%d dry_run=%t", status, s.planned, s.publishNew, s.replaceOlder, s.skipped, s.failures, s.dryRun)
|
||||||
|
}
|
||||||
|
|
||||||
|
type runFailure struct {
|
||||||
|
pipelineID string
|
||||||
|
destinationID string
|
||||||
|
bundlePath string
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
type runFailures struct {
|
||||||
|
items []runFailure
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *runFailures) add(pipelineID, destinationID, bundlePath string, err error) {
|
||||||
|
f.items = append(f.items, runFailure{
|
||||||
|
pipelineID: pipelineID,
|
||||||
|
destinationID: destinationID,
|
||||||
|
bundlePath: bundlePath,
|
||||||
|
err: err,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f runFailures) Error() string {
|
||||||
|
if len(f.items) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
parts := make([]string, 0, len(f.items))
|
||||||
|
for _, item := range f.items {
|
||||||
|
parts = append(parts, fmt.Sprintf("pipeline %s destination %s bundle %s: %v", item.pipelineID, item.destinationID, item.bundlePath, item.err))
|
||||||
|
}
|
||||||
|
return "run failed: " + strings.Join(parts, "; ")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f runFailures) Unwrap() error {
|
||||||
|
errs := make([]error, 0, len(f.items))
|
||||||
|
for _, item := range f.items {
|
||||||
|
errs = append(errs, item.err)
|
||||||
|
}
|
||||||
|
return errors.Join(errs...)
|
||||||
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/distributor/internal/bundle"
|
"gitea.maximumdirect.net/eric/distributor/internal/bundle"
|
||||||
|
"gitea.maximumdirect.net/eric/distributor/internal/notify"
|
||||||
"gitea.maximumdirect.net/eric/distributor/internal/state"
|
"gitea.maximumdirect.net/eric/distributor/internal/state"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -34,8 +35,9 @@ func TestRunDryRunPrintsConfigSummary(t *testing.T) {
|
|||||||
output := stdout.String()
|
output := stdout.String()
|
||||||
for _, want := range []string{
|
for _, want := range []string{
|
||||||
"Configured pipelines: 1",
|
"Configured pipelines: 1",
|
||||||
"- reports: source=local bundles=1 destinations=1",
|
"- pipeline=reports source=local bundles=1 destinations=archive",
|
||||||
"bundle=. destination=archive action=publish_new outputs=report.md,summary.txt",
|
"bundle=. destination=archive action=publish_new outputs=report.md,summary.txt",
|
||||||
|
"Final status: ok planned=1 publish_new=1 replace_older=0 skipped=0 failed=0 dry_run=true",
|
||||||
} {
|
} {
|
||||||
if !strings.Contains(output, want) {
|
if !strings.Contains(output, want) {
|
||||||
t.Fatalf("Run() output = %q, want substring %q", output, want)
|
t.Fatalf("Run() output = %q, want substring %q", output, want)
|
||||||
@@ -73,6 +75,135 @@ func TestRunPublishesNewLocalBundle(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRunNotifiesAfterPublication(t *testing.T) {
|
||||||
|
sourceRoot := t.TempDir()
|
||||||
|
destinationRoot := t.TempDir()
|
||||||
|
writeSourceBundle(t, sourceRoot, "", testBundleOptions{})
|
||||||
|
notifier := &recordingNotifier{
|
||||||
|
check: func() {
|
||||||
|
if _, err := os.Stat(filepath.Join(destinationRoot, ".distributor.json")); err != nil {
|
||||||
|
t.Fatalf("state stat during notify: %v", err)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
err := Run(context.Background(), RunOptions{
|
||||||
|
ConfigPath: writeLocalConfig(t, sourceRoot, destinationRoot),
|
||||||
|
Notifier: notifier,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Run() error = %v", err)
|
||||||
|
}
|
||||||
|
if got, want := len(notifier.events), 1; got != want {
|
||||||
|
t.Fatalf("notification count = %d, want %d", got, want)
|
||||||
|
}
|
||||||
|
event := notifier.events[0]
|
||||||
|
if event.PipelineID != "reports" || event.DestinationID != "archive" || event.BundleID == "" || event.Action != "publish_new" {
|
||||||
|
t.Fatalf("notification event = %#v", event)
|
||||||
|
}
|
||||||
|
if got, want := len(event.Outputs), 2; got != want {
|
||||||
|
t.Fatalf("notification output count = %d, want %d", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunNotifiesAfterReplacement(t *testing.T) {
|
||||||
|
sourceRoot := t.TempDir()
|
||||||
|
destinationRoot := t.TempDir()
|
||||||
|
manifest := writeSourceBundle(t, sourceRoot, "", testBundleOptions{})
|
||||||
|
older := manifest
|
||||||
|
older.Created = older.Created.Add(-time.Hour)
|
||||||
|
writeDestinationState(t, destinationRoot, "", older)
|
||||||
|
if err := os.WriteFile(filepath.Join(destinationRoot, "report.md"), []byte("old\n"), 0o600); err != nil {
|
||||||
|
t.Fatalf("write old output: %v", err)
|
||||||
|
}
|
||||||
|
notifier := &recordingNotifier{}
|
||||||
|
|
||||||
|
err := Run(context.Background(), RunOptions{
|
||||||
|
ConfigPath: writeLocalConfig(t, sourceRoot, destinationRoot),
|
||||||
|
Notifier: notifier,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Run() error = %v", err)
|
||||||
|
}
|
||||||
|
if got, want := len(notifier.events), 1; got != want {
|
||||||
|
t.Fatalf("notification count = %d, want %d", got, want)
|
||||||
|
}
|
||||||
|
if notifier.events[0].Action != "replace_older" {
|
||||||
|
t.Fatalf("notification action = %q, want replace_older", notifier.events[0].Action)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunDoesNotNotifyForSkippedDestination(t *testing.T) {
|
||||||
|
sourceRoot := t.TempDir()
|
||||||
|
destinationRoot := t.TempDir()
|
||||||
|
writeSourceBundle(t, sourceRoot, "", testBundleOptions{})
|
||||||
|
configPath := writeLocalConfig(t, sourceRoot, destinationRoot)
|
||||||
|
if err := Run(context.Background(), RunOptions{ConfigPath: configPath}); err != nil {
|
||||||
|
t.Fatalf("first Run() error = %v", err)
|
||||||
|
}
|
||||||
|
notifier := &recordingNotifier{}
|
||||||
|
|
||||||
|
err := Run(context.Background(), RunOptions{ConfigPath: configPath, Notifier: notifier})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("second Run() error = %v", err)
|
||||||
|
}
|
||||||
|
if len(notifier.events) != 0 {
|
||||||
|
t.Fatalf("notifications = %#v, want none", notifier.events)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunDoesNotNotifyDuringDryRun(t *testing.T) {
|
||||||
|
sourceRoot := t.TempDir()
|
||||||
|
destinationRoot := t.TempDir()
|
||||||
|
writeSourceBundle(t, sourceRoot, "", testBundleOptions{})
|
||||||
|
notifier := &recordingNotifier{}
|
||||||
|
|
||||||
|
err := Run(context.Background(), RunOptions{
|
||||||
|
ConfigPath: writeLocalConfig(t, sourceRoot, destinationRoot),
|
||||||
|
DryRun: true,
|
||||||
|
Notifier: notifier,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Run() error = %v", err)
|
||||||
|
}
|
||||||
|
if len(notifier.events) != 0 {
|
||||||
|
t.Fatalf("notifications = %#v, want none", notifier.events)
|
||||||
|
}
|
||||||
|
if entries, err := os.ReadDir(destinationRoot); err != nil || len(entries) != 0 {
|
||||||
|
t.Fatalf("destination entries = %v err=%v, want empty", entries, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunContinuesAfterDestinationFailure(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)
|
||||||
|
}
|
||||||
|
|
||||||
|
var stdout bytes.Buffer
|
||||||
|
err := Run(context.Background(), RunOptions{
|
||||||
|
ConfigPath: writeFanoutConfig(t, sourceRoot, firstDestination, secondDestination),
|
||||||
|
Stdout: &stdout,
|
||||||
|
})
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "fail_unmanaged") {
|
||||||
|
t.Fatalf("Run() error = %v, want unmanaged failure", err)
|
||||||
|
}
|
||||||
|
output := stdout.String()
|
||||||
|
for _, want := range []string{
|
||||||
|
"destination=archive-one action=error",
|
||||||
|
"destination=archive-two action=publish_new",
|
||||||
|
"Final status: failed planned=1 publish_new=1 replace_older=0 skipped=0 failed=1 dry_run=false",
|
||||||
|
} {
|
||||||
|
if !strings.Contains(output, want) {
|
||||||
|
t.Fatalf("stdout = %q, want substring %q", output, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assertFile(t, filepath.Join(secondDestination, "report.md"), "# Report\nSunny.\n")
|
||||||
|
}
|
||||||
|
|
||||||
func TestRunPublishesHTMLOnly(t *testing.T) {
|
func TestRunPublishesHTMLOnly(t *testing.T) {
|
||||||
sourceRoot := t.TempDir()
|
sourceRoot := t.TempDir()
|
||||||
destinationRoot := t.TempDir()
|
destinationRoot := t.TempDir()
|
||||||
@@ -475,3 +606,19 @@ func assertFileContains(t *testing.T, path, want string) {
|
|||||||
t.Fatalf("%s = %q, want substring %q", path, data, want)
|
t.Fatalf("%s = %q, want substring %q", path, data, want)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type recordingNotifier struct {
|
||||||
|
events []notify.Event
|
||||||
|
check func()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (n *recordingNotifier) Notify(ctx context.Context, event notify.Event) error {
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if n.check != nil {
|
||||||
|
n.check()
|
||||||
|
}
|
||||||
|
n.events = append(n.events, event)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ Options:
|
|||||||
--config <path> Path to config file
|
--config <path> Path to config file
|
||||||
--dry-run Load and validate config without publishing
|
--dry-run Load and validate config without publishing
|
||||||
|
|
||||||
Execution behavior is not implemented yet. Dry-run currently prints a resolved
|
Run discovers local source bundles, plans each configured destination, publishes
|
||||||
configuration summary only.
|
selected outputs unless --dry-run is set, and prints a final status summary.
|
||||||
`)
|
`)
|
||||||
}
|
}
|
||||||
|
|||||||
9
internal/notify/noop.go
Normal file
9
internal/notify/noop.go
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
package notify
|
||||||
|
|
||||||
|
import "context"
|
||||||
|
|
||||||
|
type Noop struct{}
|
||||||
|
|
||||||
|
func (Noop) Notify(ctx context.Context, event Event) error {
|
||||||
|
return ctx.Err()
|
||||||
|
}
|
||||||
25
internal/notify/notify.go
Normal file
25
internal/notify/notify.go
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
package notify
|
||||||
|
|
||||||
|
import "context"
|
||||||
|
|
||||||
|
type Event struct {
|
||||||
|
PipelineID string
|
||||||
|
DestinationID string
|
||||||
|
BundleID string
|
||||||
|
BundlePath string
|
||||||
|
Action string
|
||||||
|
Outputs []Output
|
||||||
|
}
|
||||||
|
|
||||||
|
type Output struct {
|
||||||
|
Path string
|
||||||
|
Kind string
|
||||||
|
SourcePath string
|
||||||
|
Transform string
|
||||||
|
SHA256 string
|
||||||
|
Size int64
|
||||||
|
}
|
||||||
|
|
||||||
|
type Notifier interface {
|
||||||
|
Notify(ctx context.Context, event Event) error
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user