From 61a40ab656c8ffe97cf8295fa9937b5a39f33c53 Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Sun, 31 May 2026 03:17:40 +0000 Subject: [PATCH] Centralize managed state targets --- internal/adapters/local/backend.go | 14 +----- internal/adapters/local/backend_test.go | 10 ++-- internal/app/run_test.go | 11 +++-- internal/bundle/manifest_test.go | 4 +- internal/bundle/validate.go | 2 +- internal/cli/root_test.go | 4 +- internal/storage/fake/backend.go | 14 +----- internal/storage/fake/backend_test.go | 13 ++++-- internal/storage/path.go | 26 +++++++++-- internal/storage/path_test.go | 61 +++++++++++++++++++++++++ 10 files changed, 116 insertions(+), 43 deletions(-) diff --git a/internal/adapters/local/backend.go b/internal/adapters/local/backend.go index e312dd8..2f4907e 100644 --- a/internal/adapters/local/backend.go +++ b/internal/adapters/local/backend.go @@ -234,22 +234,10 @@ func (b *Backend) DeleteManagedBundle(ctx context.Context, bundlePath string, ma if err := ctx.Err(); err != nil { return err } - if err := storage.ValidatePrefix(bundlePath); err != nil { - return err - } - targets := make([]string, 0, len(managedOutputPaths)+1) - for _, outputPath := range managedOutputPaths { - target, err := storage.Join(bundlePath, outputPath) - if err != nil { - return err - } - targets = append(targets, target) - } - statePath, err := storage.StatePath(bundlePath) + targets, err := storage.ManagedBundleTargets(bundlePath, managedOutputPaths) if err != nil { return err } - targets = append(targets, statePath) for _, logicalPath := range targets { nativePath, err := b.nativePath(logicalPath, false) diff --git a/internal/adapters/local/backend_test.go b/internal/adapters/local/backend_test.go index b7ed9cd..a97ab75 100644 --- a/internal/adapters/local/backend_test.go +++ b/internal/adapters/local/backend_test.go @@ -145,16 +145,20 @@ func TestBackendManagedDeletion(t *testing.T) { backend := newBackend(t) mustWrite(t, backend, "bundle/report.html", "html") mustWrite(t, backend, "bundle/keep.txt", "keep") - mustWrite(t, backend, "bundle/.distributor.json", "{}") + statePath, err := storage.StatePath("bundle") + if err != nil { + t.Fatalf("StatePath() error = %v", err) + } + mustWrite(t, backend, statePath, "{}") - err := backend.DeleteManagedBundle(context.Background(), "bundle", []string{"report.html"}, storage.DeleteOptions{PruneEmptyDirs: true}) + err = backend.DeleteManagedBundle(context.Background(), "bundle", []string{"report.html"}, storage.DeleteOptions{PruneEmptyDirs: true}) if err != nil { t.Fatalf("DeleteManagedBundle() error = %v", err) } if _, err := backend.Stat(context.Background(), "bundle/report.html"); !storage.IsNotFound(err) { t.Fatalf("managed output stat error = %v, want not found", err) } - if _, err := backend.Stat(context.Background(), "bundle/.distributor.json"); !storage.IsNotFound(err) { + if _, err := backend.Stat(context.Background(), statePath); !storage.IsNotFound(err) { t.Fatalf("state stat error = %v, want not found", err) } if _, err := backend.Stat(context.Background(), "bundle/keep.txt"); err != nil { diff --git a/internal/app/run_test.go b/internal/app/run_test.go index 0f4e900..cb03db8 100644 --- a/internal/app/run_test.go +++ b/internal/app/run_test.go @@ -14,6 +14,7 @@ import ( "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/storage" ) func TestRunDryRunPrintsConfigSummary(t *testing.T) { @@ -63,7 +64,7 @@ func TestRunPublishesNewLocalBundle(t *testing.T) { if _, err := os.Stat(filepath.Join(destinationRoot, "manifest.json")); !os.IsNotExist(err) { t.Fatalf("destination manifest stat error = %v, want not exist", err) } - destinationState := readStateFile(t, filepath.Join(destinationRoot, ".distributor.json")) + destinationState := readStateFile(t, filepath.Join(destinationRoot, storage.StateFileName)) if destinationState.PipelineID != "reports" || destinationState.DestinationID != "archive" { t.Fatalf("state identity = %s/%s", destinationState.PipelineID, destinationState.DestinationID) } @@ -81,7 +82,7 @@ func TestRunNotifiesAfterPublication(t *testing.T) { writeSourceBundle(t, sourceRoot, "", testBundleOptions{}) notifier := &recordingNotifier{ check: func() { - if _, err := os.Stat(filepath.Join(destinationRoot, ".distributor.json")); err != nil { + if _, err := os.Stat(filepath.Join(destinationRoot, storage.StateFileName)); err != nil { t.Fatalf("state stat during notify: %v", err) } }, @@ -217,7 +218,7 @@ func TestRunPublishesHTMLOnly(t *testing.T) { if _, err := os.Stat(filepath.Join(destinationRoot, "report.md")); !os.IsNotExist(err) { t.Fatalf("report.md stat error = %v, want not exist", err) } - destinationState := readStateFile(t, filepath.Join(destinationRoot, ".distributor.json")) + destinationState := readStateFile(t, filepath.Join(destinationRoot, storage.StateFileName)) if got, want := len(destinationState.Outputs), 1; got != want { t.Fatalf("state output count = %d, want %d", got, want) } @@ -239,7 +240,7 @@ func TestRunPublishesSourceAndHTML(t *testing.T) { assertFile(t, filepath.Join(destinationRoot, "report.md"), "# Report\nSunny.\n") assertFileContains(t, filepath.Join(destinationRoot, "report.html"), "

Sunny.

") assertFile(t, filepath.Join(destinationRoot, "summary.txt"), "Summary\n") - destinationState := readStateFile(t, filepath.Join(destinationRoot, ".distributor.json")) + destinationState := readStateFile(t, filepath.Join(destinationRoot, storage.StateFileName)) if got, want := len(destinationState.Outputs), 3; got != want { t.Fatalf("state output count = %d, want %d", got, want) } @@ -567,7 +568,7 @@ func writeDestinationState(t *testing.T, root, relative string, manifest bundle. t.Fatalf("marshal state: %v", err) } data = append(data, '\n') - if err := os.WriteFile(filepath.Join(bundleRoot, ".distributor.json"), data, 0o600); err != nil { + if err := os.WriteFile(filepath.Join(bundleRoot, storage.StateFileName), data, 0o600); err != nil { t.Fatalf("write state: %v", err) } } diff --git a/internal/bundle/manifest_test.go b/internal/bundle/manifest_test.go index 5644b68..76e1541 100644 --- a/internal/bundle/manifest_test.go +++ b/internal/bundle/manifest_test.go @@ -4,6 +4,8 @@ import ( "os" "strings" "testing" + + "gitea.maximumdirect.net/eric/distributor/internal/storage" ) func TestParseManifestValid(t *testing.T) { @@ -71,7 +73,7 @@ func TestParseManifestRejectsUnsafeFilePaths(t *testing.T) { `"path": "/report.md"`, `"path": "nested/../report.md"`, `"path": "manifest.json"`, - `"path": ".distributor.json"`, + `"path": "` + storage.StateFileName + `"`, } for _, replacement := range tests { t.Run(replacement, func(t *testing.T) { diff --git a/internal/bundle/validate.go b/internal/bundle/validate.go index c6e56ca..63b6741 100644 --- a/internal/bundle/validate.go +++ b/internal/bundle/validate.go @@ -12,7 +12,7 @@ func ValidateSourcePath(path string) error { return err } switch path { - case ManifestName, ".distributor.json": + case ManifestName, storage.StateFileName: return fmt.Errorf("%q is reserved", path) } return nil diff --git a/internal/cli/root_test.go b/internal/cli/root_test.go index 248fad3..98aab3e 100644 --- a/internal/cli/root_test.go +++ b/internal/cli/root_test.go @@ -7,6 +7,8 @@ import ( "path/filepath" "strings" "testing" + + "gitea.maximumdirect.net/eric/distributor/internal/storage" ) func TestExecuteRootHelp(t *testing.T) { @@ -128,7 +130,7 @@ pipelines: if code != exitOK { t.Fatalf("exit code = %d, want %d; stderr = %q", code, exitOK, stderr.String()) } - if _, err := os.Stat(filepath.Join(destinationRoot, ".distributor.json")); err != nil { + if _, err := os.Stat(filepath.Join(destinationRoot, storage.StateFileName)); err != nil { t.Fatalf("state stat error = %v", err) } } diff --git a/internal/storage/fake/backend.go b/internal/storage/fake/backend.go index 288f3d8..c6ffa8e 100644 --- a/internal/storage/fake/backend.go +++ b/internal/storage/fake/backend.go @@ -181,22 +181,10 @@ func (b *Backend) DeleteManagedBundle(ctx context.Context, bundlePath string, ma if err := ctx.Err(); err != nil { return err } - if err := storage.ValidatePrefix(bundlePath); err != nil { - return err - } - targets := make([]string, 0, len(managedOutputPaths)+1) - for _, outputPath := range managedOutputPaths { - target, err := storage.Join(bundlePath, outputPath) - if err != nil { - return err - } - targets = append(targets, target) - } - statePath, err := storage.StatePath(bundlePath) + targets, err := storage.ManagedBundleTargets(bundlePath, managedOutputPaths) if err != nil { return err } - targets = append(targets, statePath) for _, target := range targets { if _, ok := b.dirs[target]; ok { diff --git a/internal/storage/fake/backend_test.go b/internal/storage/fake/backend_test.go index 4dbac21..12ea799 100644 --- a/internal/storage/fake/backend_test.go +++ b/internal/storage/fake/backend_test.go @@ -116,21 +116,28 @@ func TestBackendManagedDeletion(t *testing.T) { backend := New() mustWrite(t, backend, "bundle/report.html", "html") mustWrite(t, backend, "bundle/keep.txt", "keep") - mustWrite(t, backend, "bundle/.distributor.json", "{}") + statePath, err := storage.StatePath("bundle") + if err != nil { + t.Fatalf("StatePath() error = %v", err) + } + mustWrite(t, backend, statePath, "{}") - err := backend.DeleteManagedBundle(context.Background(), "bundle", []string{"report.html"}, storage.DeleteOptions{PruneEmptyDirs: true}) + err = backend.DeleteManagedBundle(context.Background(), "bundle", []string{"report.html"}, storage.DeleteOptions{PruneEmptyDirs: true}) if err != nil { t.Fatalf("DeleteManagedBundle() error = %v", err) } if _, err := backend.Stat(context.Background(), "bundle/report.html"); !storage.IsNotFound(err) { t.Fatalf("managed output stat error = %v, want not found", err) } - if _, err := backend.Stat(context.Background(), "bundle/.distributor.json"); !storage.IsNotFound(err) { + if _, err := backend.Stat(context.Background(), statePath); !storage.IsNotFound(err) { t.Fatalf("state stat error = %v, want not found", err) } if _, err := backend.Stat(context.Background(), "bundle/keep.txt"); err != nil { t.Fatalf("unlisted file stat error = %v", err) } + if err := backend.DeleteManagedBundle(context.Background(), "bundle", []string{""}, storage.DeleteOptions{}); !storage.IsInvalidPath(err) { + t.Fatalf("DeleteManagedBundle invalid output error = %v, want invalid path", err) + } } func TestBackendHasAnyAndWalkStop(t *testing.T) { diff --git a/internal/storage/path.go b/internal/storage/path.go index 0dd8513..8f15980 100644 --- a/internal/storage/path.go +++ b/internal/storage/path.go @@ -6,7 +6,7 @@ import ( "strings" ) -const stateFileName = ".distributor.json" +const StateFileName = ".distributor.json" func ValidatePath(value string) error { if value == "" { @@ -37,9 +37,29 @@ func Join(base, child string) (string, error) { func StatePath(bundlePath string) (string, error) { if bundlePath == "" { - return stateFileName, nil + return StateFileName, nil } - return Join(bundlePath, stateFileName) + return Join(bundlePath, StateFileName) +} + +func ManagedBundleTargets(bundlePath string, managedOutputPaths []string) ([]string, error) { + if err := ValidatePrefix(bundlePath); err != nil { + return nil, err + } + targets := make([]string, 0, len(managedOutputPaths)+1) + for _, outputPath := range managedOutputPaths { + target, err := Join(bundlePath, outputPath) + if err != nil { + return nil, err + } + targets = append(targets, target) + } + statePath, err := StatePath(bundlePath) + if err != nil { + return nil, err + } + targets = append(targets, statePath) + return targets, nil } func SortEntries(entries []Entry) { diff --git a/internal/storage/path_test.go b/internal/storage/path_test.go index ce59b4c..d8ccc22 100644 --- a/internal/storage/path_test.go +++ b/internal/storage/path_test.go @@ -49,6 +49,67 @@ func TestValidatePrefixAllowsRoot(t *testing.T) { } } +func TestStatePath(t *testing.T) { + tests := map[string]string{ + "": StateFileName, + "bundle": "bundle/" + StateFileName, + } + for bundlePath, want := range tests { + t.Run(bundlePath, func(t *testing.T) { + got, err := StatePath(bundlePath) + if err != nil { + t.Fatalf("StatePath(%q) error = %v", bundlePath, err) + } + if got != want { + t.Fatalf("StatePath(%q) = %q, want %q", bundlePath, got, want) + } + }) + } +} + +func TestManagedBundleTargets(t *testing.T) { + tests := []struct { + name string + bundlePath string + outputs []string + want []string + }{ + { + name: "root", + outputs: []string{"report.html", "assets/style.css"}, + want: []string{"report.html", "assets/style.css", StateFileName}, + }, + { + name: "nested", + bundlePath: "daily", + outputs: []string{"report.html"}, + want: []string{"daily/report.html", "daily/" + StateFileName}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := ManagedBundleTargets(tt.bundlePath, tt.outputs) + if err != nil { + t.Fatalf("ManagedBundleTargets() error = %v", err) + } + if len(got) != len(tt.want) { + t.Fatalf("targets = %v, want %v", got, tt.want) + } + for i := range got { + if got[i] != tt.want[i] { + t.Fatalf("targets = %v, want %v", got, tt.want) + } + } + }) + } +} + +func TestManagedBundleTargetsRejectsInvalidOutputPath(t *testing.T) { + if _, err := ManagedBundleTargets("bundle", []string{"../outside"}); !IsInvalidPath(err) { + t.Fatalf("ManagedBundleTargets() error = %v, want invalid path", err) + } +} + func TestListSortsEntries(t *testing.T) { backend := walkBackend{ entries: []Entry{