Centralize managed state targets

This commit is contained in:
2026-05-31 03:17:40 +00:00
parent 4bd5b19025
commit 61a40ab656
10 changed files with 116 additions and 43 deletions

View File

@@ -234,22 +234,10 @@ func (b *Backend) DeleteManagedBundle(ctx context.Context, bundlePath string, ma
if err := ctx.Err(); err != nil { if err := ctx.Err(); err != nil {
return err return err
} }
if err := storage.ValidatePrefix(bundlePath); err != nil { targets, err := storage.ManagedBundleTargets(bundlePath, managedOutputPaths)
return err
}
targets := make([]string, 0, len(managedOutputPaths)+1)
for _, outputPath := range managedOutputPaths {
target, err := storage.Join(bundlePath, outputPath)
if err != nil { if err != nil {
return err return err
} }
targets = append(targets, target)
}
statePath, err := storage.StatePath(bundlePath)
if err != nil {
return err
}
targets = append(targets, statePath)
for _, logicalPath := range targets { for _, logicalPath := range targets {
nativePath, err := b.nativePath(logicalPath, false) nativePath, err := b.nativePath(logicalPath, false)

View File

@@ -145,16 +145,20 @@ func TestBackendManagedDeletion(t *testing.T) {
backend := newBackend(t) backend := newBackend(t)
mustWrite(t, backend, "bundle/report.html", "html") mustWrite(t, backend, "bundle/report.html", "html")
mustWrite(t, backend, "bundle/keep.txt", "keep") 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 { if err != nil {
t.Fatalf("DeleteManagedBundle() error = %v", err) t.Fatalf("DeleteManagedBundle() error = %v", err)
} }
if _, err := backend.Stat(context.Background(), "bundle/report.html"); !storage.IsNotFound(err) { if _, err := backend.Stat(context.Background(), "bundle/report.html"); !storage.IsNotFound(err) {
t.Fatalf("managed output stat error = %v, want not found", 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) t.Fatalf("state stat error = %v, want not found", err)
} }
if _, err := backend.Stat(context.Background(), "bundle/keep.txt"); err != nil { if _, err := backend.Stat(context.Background(), "bundle/keep.txt"); err != nil {

View File

@@ -14,6 +14,7 @@ import (
"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/notify"
"gitea.maximumdirect.net/eric/distributor/internal/state" "gitea.maximumdirect.net/eric/distributor/internal/state"
"gitea.maximumdirect.net/eric/distributor/internal/storage"
) )
func TestRunDryRunPrintsConfigSummary(t *testing.T) { 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) { if _, err := os.Stat(filepath.Join(destinationRoot, "manifest.json")); !os.IsNotExist(err) {
t.Fatalf("destination manifest stat error = %v, want not exist", 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" { if destinationState.PipelineID != "reports" || destinationState.DestinationID != "archive" {
t.Fatalf("state identity = %s/%s", destinationState.PipelineID, destinationState.DestinationID) t.Fatalf("state identity = %s/%s", destinationState.PipelineID, destinationState.DestinationID)
} }
@@ -81,7 +82,7 @@ func TestRunNotifiesAfterPublication(t *testing.T) {
writeSourceBundle(t, sourceRoot, "", testBundleOptions{}) writeSourceBundle(t, sourceRoot, "", testBundleOptions{})
notifier := &recordingNotifier{ notifier := &recordingNotifier{
check: func() { 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) 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) { if _, err := os.Stat(filepath.Join(destinationRoot, "report.md")); !os.IsNotExist(err) {
t.Fatalf("report.md stat error = %v, want not exist", 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 { if got, want := len(destinationState.Outputs), 1; got != want {
t.Fatalf("state output count = %d, want %d", 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") assertFile(t, filepath.Join(destinationRoot, "report.md"), "# Report\nSunny.\n")
assertFileContains(t, filepath.Join(destinationRoot, "report.html"), "<p>Sunny.</p>") assertFileContains(t, filepath.Join(destinationRoot, "report.html"), "<p>Sunny.</p>")
assertFile(t, filepath.Join(destinationRoot, "summary.txt"), "Summary\n") 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 { if got, want := len(destinationState.Outputs), 3; got != want {
t.Fatalf("state output count = %d, want %d", 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) t.Fatalf("marshal state: %v", err)
} }
data = append(data, '\n') 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) t.Fatalf("write state: %v", err)
} }
} }

View File

@@ -4,6 +4,8 @@ import (
"os" "os"
"strings" "strings"
"testing" "testing"
"gitea.maximumdirect.net/eric/distributor/internal/storage"
) )
func TestParseManifestValid(t *testing.T) { func TestParseManifestValid(t *testing.T) {
@@ -71,7 +73,7 @@ func TestParseManifestRejectsUnsafeFilePaths(t *testing.T) {
`"path": "/report.md"`, `"path": "/report.md"`,
`"path": "nested/../report.md"`, `"path": "nested/../report.md"`,
`"path": "manifest.json"`, `"path": "manifest.json"`,
`"path": ".distributor.json"`, `"path": "` + storage.StateFileName + `"`,
} }
for _, replacement := range tests { for _, replacement := range tests {
t.Run(replacement, func(t *testing.T) { t.Run(replacement, func(t *testing.T) {

View File

@@ -12,7 +12,7 @@ func ValidateSourcePath(path string) error {
return err return err
} }
switch path { switch path {
case ManifestName, ".distributor.json": case ManifestName, storage.StateFileName:
return fmt.Errorf("%q is reserved", path) return fmt.Errorf("%q is reserved", path)
} }
return nil return nil

View File

@@ -7,6 +7,8 @@ import (
"path/filepath" "path/filepath"
"strings" "strings"
"testing" "testing"
"gitea.maximumdirect.net/eric/distributor/internal/storage"
) )
func TestExecuteRootHelp(t *testing.T) { func TestExecuteRootHelp(t *testing.T) {
@@ -128,7 +130,7 @@ pipelines:
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 _, 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) t.Fatalf("state stat error = %v", err)
} }
} }

View File

@@ -181,22 +181,10 @@ func (b *Backend) DeleteManagedBundle(ctx context.Context, bundlePath string, ma
if err := ctx.Err(); err != nil { if err := ctx.Err(); err != nil {
return err return err
} }
if err := storage.ValidatePrefix(bundlePath); err != nil { targets, err := storage.ManagedBundleTargets(bundlePath, managedOutputPaths)
return err
}
targets := make([]string, 0, len(managedOutputPaths)+1)
for _, outputPath := range managedOutputPaths {
target, err := storage.Join(bundlePath, outputPath)
if err != nil { if err != nil {
return err return err
} }
targets = append(targets, target)
}
statePath, err := storage.StatePath(bundlePath)
if err != nil {
return err
}
targets = append(targets, statePath)
for _, target := range targets { for _, target := range targets {
if _, ok := b.dirs[target]; ok { if _, ok := b.dirs[target]; ok {

View File

@@ -116,21 +116,28 @@ func TestBackendManagedDeletion(t *testing.T) {
backend := New() backend := New()
mustWrite(t, backend, "bundle/report.html", "html") mustWrite(t, backend, "bundle/report.html", "html")
mustWrite(t, backend, "bundle/keep.txt", "keep") 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 { if err != nil {
t.Fatalf("DeleteManagedBundle() error = %v", err) t.Fatalf("DeleteManagedBundle() error = %v", err)
} }
if _, err := backend.Stat(context.Background(), "bundle/report.html"); !storage.IsNotFound(err) { if _, err := backend.Stat(context.Background(), "bundle/report.html"); !storage.IsNotFound(err) {
t.Fatalf("managed output stat error = %v, want not found", 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) t.Fatalf("state stat error = %v, want not found", err)
} }
if _, err := backend.Stat(context.Background(), "bundle/keep.txt"); err != nil { if _, err := backend.Stat(context.Background(), "bundle/keep.txt"); err != nil {
t.Fatalf("unlisted file stat error = %v", err) 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) { func TestBackendHasAnyAndWalkStop(t *testing.T) {

View File

@@ -6,7 +6,7 @@ import (
"strings" "strings"
) )
const stateFileName = ".distributor.json" const StateFileName = ".distributor.json"
func ValidatePath(value string) error { func ValidatePath(value string) error {
if value == "" { if value == "" {
@@ -37,9 +37,29 @@ func Join(base, child string) (string, error) {
func StatePath(bundlePath string) (string, error) { func StatePath(bundlePath string) (string, error) {
if bundlePath == "" { 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) { func SortEntries(entries []Entry) {

View File

@@ -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) { func TestListSortsEntries(t *testing.T) {
backend := walkBackend{ backend := walkBackend{
entries: []Entry{ entries: []Entry{