Support catalog maintenance commands

This commit is contained in:
2026-06-19 15:58:36 +00:00
parent 91b72478d5
commit cf7e62733e
8 changed files with 295 additions and 349 deletions

View File

@@ -2,7 +2,6 @@ package app
import (
"context"
"encoding/json"
"strings"
"testing"
"time"
@@ -15,12 +14,11 @@ import (
)
func TestReconcileStateDryRunReportsMissingManagedOutputsWithoutRewrite(t *testing.T) {
t.Skip("catalog reconcile-state execution is covered by the catalog maintenance work")
backend := fake.New()
cfg := reconcileStateS3Config(t)
manifest := testutil.ValidManifest(testutil.BundleOptions{})
testutil.WriteFakeDestinationState(t, backend, "", manifest, testutil.DestinationStateOptions{})
if err := backend.DeleteManagedOutputs(context.Background(), "", []string{"summary.txt"}, storage.DeleteOptions{}); err != nil {
catalog := pruneCatalogState(time.Date(2026, 6, 8, 12, 0, 0, 0, time.UTC))
writeFakeCatalogState(t, backend, catalog)
if err := backend.DeleteManagedOutputs(context.Background(), "", []string{"fresh.txt"}, storage.DeleteOptions{}); err != nil {
t.Fatalf("delete managed output: %v", err)
}
testutil.WriteFakeFile(t, backend, "extra.txt", "unmanaged")
@@ -36,25 +34,23 @@ func TestReconcileStateDryRunReportsMissingManagedOutputsWithoutRewrite(t *testi
if !report.WouldChange || report.Changed {
t.Fatalf("report changed=%t would_change=%t, want dry-run pending change", report.Changed, report.WouldChange)
}
if got := reportPathList(report.MissingManagedOutputs); got != "summary.txt" {
t.Fatalf("missing outputs = %q, want summary.txt", got)
if got := reportPathList(report.MissingManagedOutputs); got != "fresh.txt" {
t.Fatalf("missing outputs = %q, want fresh.txt", got)
}
if got := entryPathList(report.UnmanagedEntries); got != "extra.txt" {
t.Fatalf("unmanaged entries = %q, want extra.txt", got)
}
destinationState := readFakeSingleOwnerState(t, backend)
if got := strings.Join(state.ManagedOutputPaths(destinationState), ","); got != "report.md,summary.txt" {
repaired := readFakeCatalogState(t, backend)
if got := strings.Join(state.CatalogManagedOutputPaths(repaired), ","); got != "old.txt,fresh.txt,html.txt" {
t.Fatalf("state outputs = %q, want original outputs", got)
}
}
func TestReconcileStateApplyRemovesMissingRecordsAndPreservesUnmanagedFiles(t *testing.T) {
t.Skip("catalog reconcile-state execution is covered by the catalog maintenance work")
backend := fake.New()
cfg := reconcileStateS3Config(t)
manifest := testutil.ValidManifest(testutil.BundleOptions{})
testutil.WriteFakeDestinationState(t, backend, "", manifest, testutil.DestinationStateOptions{})
if err := backend.DeleteManagedOutputs(context.Background(), "", []string{"summary.txt"}, storage.DeleteOptions{}); err != nil {
writeFakeCatalogState(t, backend, pruneCatalogState(time.Date(2026, 6, 8, 12, 0, 0, 0, time.UTC)))
if err := backend.DeleteManagedOutputs(context.Background(), "", []string{"fresh.txt"}, storage.DeleteOptions{}); err != nil {
t.Fatalf("delete managed output: %v", err)
}
testutil.WriteFakeFile(t, backend, "extra.txt", "unmanaged")
@@ -69,12 +65,15 @@ func TestReconcileStateApplyRemovesMissingRecordsAndPreservesUnmanagedFiles(t *t
if !report.Changed || report.WouldChange {
t.Fatalf("report changed=%t would_change=%t, want applied change", report.Changed, report.WouldChange)
}
destinationState := readFakeSingleOwnerState(t, backend)
if err := state.Validate(destinationState); err != nil {
t.Fatalf("Validate() repaired state error = %v", err)
repaired := readFakeCatalogState(t, backend)
if err := state.ValidateCatalog(repaired); err != nil {
t.Fatalf("ValidateCatalog() repaired state error = %v", err)
}
if got := strings.Join(state.ManagedOutputPaths(destinationState), ","); got != "report.md" {
t.Fatalf("state outputs = %q, want report.md", got)
if got := strings.Join(state.CatalogManagedOutputPaths(repaired), ","); got != "old.txt,html.txt" {
t.Fatalf("state outputs = %q, want old.txt,html.txt", got)
}
if repaired.SchemaVersion != state.CatalogSchemaVersion {
t.Fatalf("state schema_version = %d, want %d", repaired.SchemaVersion, state.CatalogSchemaVersion)
}
testutil.AssertFakeFile(t, backend, "extra.txt", "unmanaged")
}
@@ -101,13 +100,11 @@ func TestReconcileStateInvalidStateFailsWithoutRewrite(t *testing.T) {
}
}
func TestReconcileStateSharedRootOwnerScopeRepairsCurrentOwnerOnly(t *testing.T) {
t.Skip("catalog reconcile-state execution is covered by the catalog maintenance work")
func TestReconcileStateOwnerScopeRepairsCurrentOwnerOnly(t *testing.T) {
backend := fake.New()
cfg := reconcileStateS3Config(t)
sharedRoot := reconcileSharedRootFixture(t)
writeFakeSharedRootStateForApp(t, backend, sharedRoot)
if err := backend.DeleteManagedOutputs(context.Background(), "", []string{"report.md", "report.html"}, storage.DeleteOptions{}); err != nil {
writeFakeCatalogState(t, backend, pruneCatalogState(time.Date(2026, 6, 8, 12, 0, 0, 0, time.UTC)))
if err := backend.DeleteManagedOutputs(context.Background(), "", []string{"old.txt", "html.txt"}, storage.DeleteOptions{}); err != nil {
t.Fatalf("delete managed outputs: %v", err)
}
@@ -121,19 +118,17 @@ func TestReconcileStateSharedRootOwnerScopeRepairsCurrentOwnerOnly(t *testing.T)
if !report.Changed {
t.Fatal("report changed = false, want true")
}
repaired := readFakeSharedRootStateForApp(t, backend)
if got := strings.Join(repaired.AllManagedOutputPaths(), ","); got != "report.html" {
t.Fatalf("shared-root outputs = %q, want other owner output preserved", got)
repaired := readFakeCatalogState(t, backend)
if got := strings.Join(state.CatalogManagedOutputPaths(repaired), ","); got != "fresh.txt,html.txt" {
t.Fatalf("catalog outputs = %q, want other owner output preserved", got)
}
}
func TestReconcileStateSharedRootAllOwnersRepairsEveryOwner(t *testing.T) {
t.Skip("catalog reconcile-state execution is covered by the catalog maintenance work")
func TestReconcileStateAllOwnersRepairsEveryOwner(t *testing.T) {
backend := fake.New()
cfg := reconcileStateS3Config(t)
sharedRoot := reconcileSharedRootFixture(t)
writeFakeSharedRootStateForApp(t, backend, sharedRoot)
if err := backend.DeleteManagedOutputs(context.Background(), "", []string{"report.md", "report.html"}, storage.DeleteOptions{}); err != nil {
writeFakeCatalogState(t, backend, pruneCatalogState(time.Date(2026, 6, 8, 12, 0, 0, 0, time.UTC)))
if err := backend.DeleteManagedOutputs(context.Background(), "", []string{"old.txt", "html.txt"}, storage.DeleteOptions{}); err != nil {
t.Fatalf("delete managed outputs: %v", err)
}
@@ -145,12 +140,12 @@ func TestReconcileStateSharedRootAllOwnersRepairsEveryOwner(t *testing.T) {
if err != nil {
t.Fatalf("reconcileStateConfigWithBackendFactory() error = %v", err)
}
if !report.Changed || report.CheckedCount != 2 {
if !report.Changed || report.CheckedCount != 3 {
t.Fatalf("report changed=%t checked=%d, want all-owner repair", report.Changed, report.CheckedCount)
}
repaired := readFakeSharedRootStateForApp(t, backend)
if got := repaired.AllManagedOutputPaths(); len(got) != 0 {
t.Fatalf("shared-root outputs = %#v, want none", got)
repaired := readFakeCatalogState(t, backend)
if got := strings.Join(state.CatalogManagedOutputPaths(repaired), ","); got != "fresh.txt" {
t.Fatalf("catalog outputs = %q, want fresh.txt", got)
}
}
@@ -169,93 +164,6 @@ func reconcileStateS3Config(t *testing.T) config.Config {
return cfg
}
func readFakeSingleOwnerState(t *testing.T, backend *fake.Backend) state.DistributorState {
t.Helper()
data, err := backend.ReadFile(context.Background(), storage.StateFileName)
if err != nil {
t.Fatalf("read state: %v", err)
}
destinationState, err := state.Parse(data)
if err != nil {
t.Fatalf("parse state: %v", err)
}
return destinationState
}
func writeFakeSharedRootStateForApp(t *testing.T, backend *fake.Backend, sharedRoot state.SharedRootState) {
t.Helper()
data, err := json.MarshalIndent(sharedRoot, "", " ")
if err != nil {
t.Fatalf("marshal shared-root state: %v", err)
}
testutil.WriteFakeFile(t, backend, storage.StateFileName, string(append(data, '\n')))
for _, output := range sharedRoot.Outputs {
testutil.WriteFakeFile(t, backend, output.Path, "old")
}
}
func readFakeSharedRootStateForApp(t *testing.T, backend *fake.Backend) state.SharedRootState {
t.Helper()
data, err := backend.ReadFile(context.Background(), storage.StateFileName)
if err != nil {
t.Fatalf("read shared-root state: %v", err)
}
sharedRoot, err := state.ParseSharedRoot(data)
if err != nil {
t.Fatalf("parse shared-root state: %v", err)
}
return sharedRoot
}
func reconcileSharedRootFixture(t *testing.T) state.SharedRootState {
t.Helper()
source := testutil.ValidManifest(testutil.BundleOptions{})
htmlSource := source
createdAt := time.Date(2026, 5, 30, 11, 12, 0, 0, time.UTC)
return state.SharedRootState{
SchemaVersion: state.SharedRootSchemaVersion,
DistributorVersion: "test",
CreatedAt: createdAt,
UpdatedAt: createdAt,
State: state.StatePolicy{Mode: state.StateModeSharedRoot},
Owners: []state.OwnerRecord{{
Scope: state.CurrentOwnerScope("reports", "archive"),
Reconciliation: state.ReconciliationPolicy{Mode: config.ReconciliationModeReplace},
Source: state.SourceState{Manifest: source},
}, {
Scope: state.CurrentOwnerScope("reports", "html"),
Reconciliation: state.ReconciliationPolicy{Mode: config.ReconciliationModeMerge},
Source: state.SourceState{Manifest: htmlSource},
}},
Outputs: []state.SharedRootOutputFile{{
Path: "report.md",
Kind: state.OutputKindSource,
SourcePath: "report.md",
SHA256: source.Files[0].SHA256,
Size: source.Files[0].Size,
Owner: state.CurrentOwnerScope("reports", "archive"),
SourceID: source.ID,
SourceDigest: source.Digest,
SourceCreated: source.Created,
CreatedAt: createdAt,
UpdatedAt: createdAt,
}, {
Path: "report.html",
Kind: state.OutputKindGenerated,
SourcePath: "report.md",
Transform: "markdown_to_html",
SHA256: "sha256:" + strings.Repeat("a", 64),
Size: 128,
Owner: state.CurrentOwnerScope("reports", "html"),
SourceID: htmlSource.ID,
SourceDigest: htmlSource.Digest,
SourceCreated: htmlSource.Created,
CreatedAt: createdAt,
UpdatedAt: createdAt,
}},
}
}
func reportPathList(paths []ReconcileStatePath) string {
values := make([]string, 0, len(paths))
for _, path := range paths {