Add reconcile state planning core

This commit is contained in:
2026-06-08 19:14:44 +00:00
parent cb9502f790
commit de6723c5de
7 changed files with 799 additions and 5 deletions

View File

@@ -0,0 +1,395 @@
package app
import (
"context"
"encoding/json"
"fmt"
"io"
"sort"
"time"
"gitea.maximumdirect.net/eric/distributor/internal/config"
"gitea.maximumdirect.net/eric/distributor/internal/state"
"gitea.maximumdirect.net/eric/distributor/internal/storage"
)
const reconcileStateWalkLimit = 10000
type ReconcileStateOptions struct {
ConfigPath string
PipelineID string
DestinationID string
AllOwners bool
DryRun bool
Stdout io.Writer
OutputFormat OutputFormat
}
type ReconcileStateReport struct {
PipelineID string `json:"pipeline_id"`
DestinationID string `json:"destination_id"`
Backend string `json:"backend"`
RootPath string `json:"root_path"`
StateSchema int `json:"state_schema"`
OwnerScope *ReconcileStateOwnerScope `json:"owner_scope,omitempty"`
CheckedCount int `json:"checked_count"`
MissingManagedOutputs []ReconcileStatePath `json:"missing_managed_outputs"`
UnmanagedEntries []ReconcileStateEntry `json:"unmanaged_entries"`
Changed bool `json:"changed"`
WouldChange bool `json:"would_change"`
DryRun bool `json:"dry_run"`
}
type ReconcileStateOwnerScope struct {
PipelineID string `json:"pipeline_id"`
DestinationID string `json:"destination_id"`
AllOwners bool `json:"all_owners,omitempty"`
}
type ReconcileStatePath struct {
Path string `json:"path"`
OwnerScope *ReconcileStateOwnerScope `json:"owner_scope,omitempty"`
StorageStatus string `json:"storage_status"`
}
type ReconcileStateEntry struct {
Path string `json:"path"`
Type string `json:"type"`
Size int64 `json:"size,omitempty"`
}
func ReconcileState(ctx context.Context, options ReconcileStateOptions) (ReconcileStateReport, error) {
if err := ValidateOutputFormat(options.OutputFormat); err != nil {
return ReconcileStateReport{}, err
}
if err := ctx.Err(); err != nil {
return ReconcileStateReport{}, err
}
setup, err := loadRuntimeSetup(options.ConfigPath)
if err != nil {
return ReconcileStateReport{}, err
}
return reconcileStateSetup(ctx, setup, options)
}
func reconcileStateConfigWithBackendFactory(ctx context.Context, cfg config.Config, options ReconcileStateOptions, provider backendFactoryProvider) (ReconcileStateReport, error) {
setup, err := runtimeSetupFromConfig("", cfg)
if err != nil {
return ReconcileStateReport{}, err
}
return reconcileStateSetupWithBackendFactory(ctx, setup, options, provider)
}
func reconcileStateSetup(ctx context.Context, setup runtimeSetup, options ReconcileStateOptions) (ReconcileStateReport, error) {
return reconcileStateSetupWithBackendFactory(ctx, setup, options, newBackendFactoryWithEnvironment)
}
func reconcileStateSetupWithBackendFactory(ctx context.Context, setup runtimeSetup, options ReconcileStateOptions, provider backendFactoryProvider) (ReconcileStateReport, error) {
if err := requireReconcileStateScope(options); err != nil {
return ReconcileStateReport{}, err
}
pipeline, ok := findPipeline(setup.Config, options.PipelineID)
if !ok {
return ReconcileStateReport{}, PipelineNotFoundError{ID: options.PipelineID}
}
destination, ok := findDestination(pipeline, options.DestinationID)
if !ok {
return ReconcileStateReport{}, fmt.Errorf("pipeline %s destination %s not found", options.PipelineID, options.DestinationID)
}
backends := provider(setup.Environment)
destinationBackend, err := backends.openDestination(ctx, destination)
if err != nil {
return ReconcileStateReport{}, err
}
defer closeBackend(destinationBackend)
report, err := buildReconcileStateReport(ctx, destinationBackend, pipeline, destination, options)
if err != nil {
return ReconcileStateReport{}, err
}
if err := WriteReconcileStateReport(options.Stdout, options.OutputFormat, report); err != nil {
return ReconcileStateReport{}, err
}
return report, nil
}
func requireReconcileStateScope(options ReconcileStateOptions) error {
if options.PipelineID == "" {
return fmt.Errorf("pipeline id is required")
}
if options.DestinationID == "" {
return fmt.Errorf("destination id is required")
}
return nil
}
func findDestination(pipeline config.Pipeline, id string) (config.Destination, bool) {
for _, destination := range pipeline.Destinations {
if destination.ID == id {
return destination, true
}
}
return config.Destination{}, false
}
func buildReconcileStateReport(ctx context.Context, backend storage.Backend, pipeline config.Pipeline, destination config.Destination, options ReconcileStateOptions) (ReconcileStateReport, error) {
statePath, err := storage.StatePath("")
if err != nil {
return ReconcileStateReport{}, err
}
data, err := backend.ReadFile(ctx, statePath)
if err != nil {
return ReconcileStateReport{}, err
}
document, err := state.ParseDocument(data)
if err != nil {
return ReconcileStateReport{}, err
}
report := ReconcileStateReport{
PipelineID: pipeline.ID,
DestinationID: destination.ID,
Backend: destination.Backend,
RootPath: destinationRootPath(destination),
MissingManagedOutputs: []ReconcileStatePath{},
UnmanagedEntries: []ReconcileStateEntry{},
DryRun: options.DryRun,
}
scope := state.CurrentOwnerScope(pipeline.ID, destination.ID)
if document.SingleOwner != nil {
return reconcileSingleOwnerState(ctx, backend, statePath, *document.SingleOwner, scope, report, options)
}
return reconcileSharedRootState(ctx, backend, statePath, *document.SharedRoot, scope, report, options)
}
func reconcileSingleOwnerState(ctx context.Context, backend storage.Backend, statePath string, destinationState state.DistributorState, scope state.OwnerScope, report ReconcileStateReport, options ReconcileStateOptions) (ReconcileStateReport, error) {
if destinationState.PipelineID != scope.PipelineID || destinationState.DestinationID != scope.DestinationID {
return ReconcileStateReport{}, fmt.Errorf("state owner is %s/%s, not %s/%s", destinationState.PipelineID, destinationState.DestinationID, scope.PipelineID, scope.DestinationID)
}
report.StateSchema = destinationState.SchemaVersion
report.OwnerScope = &ReconcileStateOwnerScope{PipelineID: scope.PipelineID, DestinationID: scope.DestinationID}
managed := state.ManagedOutputPaths(destinationState)
missing, err := missingSingleOwnerOutputs(ctx, backend, destinationState.Outputs)
if err != nil {
return ReconcileStateReport{}, err
}
report.CheckedCount = len(managed)
report.MissingManagedOutputs = missing
unmanaged, err := unmanagedEntries(ctx, backend, managed)
if err != nil {
return ReconcileStateReport{}, err
}
report.UnmanagedEntries = unmanaged
report.WouldChange = options.DryRun && len(missing) > 0
if !options.DryRun && len(missing) > 0 {
missingPaths := missingReportPaths(missing)
next, changed := state.RemoveMissingOutputs(destinationState, missingPaths)
report.Changed = changed
if changed {
next.UpdatedAt = time.Now().UTC()
if err := state.Validate(next); err != nil {
return ReconcileStateReport{}, err
}
if err := writeRepairedState(ctx, backend, statePath, next); err != nil {
return ReconcileStateReport{}, err
}
}
}
return report, nil
}
func reconcileSharedRootState(ctx context.Context, backend storage.Backend, statePath string, sharedRoot state.SharedRootState, scope state.OwnerScope, report ReconcileStateReport, options ReconcileStateOptions) (ReconcileStateReport, error) {
report.StateSchema = sharedRoot.SchemaVersion
report.OwnerScope = &ReconcileStateOwnerScope{
PipelineID: scope.PipelineID,
DestinationID: scope.DestinationID,
AllOwners: options.AllOwners,
}
managed := sharedRoot.AllManagedOutputPaths()
outputs := sharedRoot.Outputs
if !options.AllOwners {
outputs = sharedRootOutputsForOwner(sharedRoot.Outputs, scope)
}
missing, err := missingSharedRootOutputs(ctx, backend, outputs)
if err != nil {
return ReconcileStateReport{}, err
}
report.CheckedCount = len(outputs)
report.MissingManagedOutputs = missing
unmanaged, err := unmanagedEntries(ctx, backend, managed)
if err != nil {
return ReconcileStateReport{}, err
}
report.UnmanagedEntries = unmanaged
report.WouldChange = options.DryRun && len(missing) > 0
if !options.DryRun && len(missing) > 0 {
missingPaths := missingReportPaths(missing)
var next state.SharedRootState
var changed bool
if options.AllOwners {
next, changed = state.RemoveMissingSharedRootOutputs(sharedRoot, missingPaths)
} else {
next, changed = state.RemoveMissingSharedRootOwnerOutputs(sharedRoot, scope, missingPaths)
}
report.Changed = changed
if changed {
next.UpdatedAt = time.Now().UTC()
if err := state.ValidateSharedRoot(next); err != nil {
return ReconcileStateReport{}, err
}
if err := writeRepairedState(ctx, backend, statePath, next); err != nil {
return ReconcileStateReport{}, err
}
}
}
return report, nil
}
func missingSingleOwnerOutputs(ctx context.Context, backend storage.Backend, outputs []state.OutputFile) ([]ReconcileStatePath, error) {
missing := make([]ReconcileStatePath, 0)
for _, output := range outputs {
if err := checkManagedOutput(ctx, backend, output.Path); err != nil {
if storage.IsNotFound(err) {
missing = append(missing, ReconcileStatePath{Path: output.Path, StorageStatus: "missing"})
continue
}
return nil, err
}
}
return missing, nil
}
func missingSharedRootOutputs(ctx context.Context, backend storage.Backend, outputs []state.SharedRootOutputFile) ([]ReconcileStatePath, error) {
missing := make([]ReconcileStatePath, 0)
for _, output := range outputs {
if err := checkManagedOutput(ctx, backend, output.Path); err != nil {
if storage.IsNotFound(err) {
missing = append(missing, ReconcileStatePath{
Path: output.Path,
OwnerScope: &ReconcileStateOwnerScope{
PipelineID: output.Owner.PipelineID,
DestinationID: output.Owner.DestinationID,
},
StorageStatus: "missing",
})
continue
}
return nil, err
}
}
return missing, nil
}
func checkManagedOutput(ctx context.Context, backend storage.Backend, path string) error {
_, err := backend.Stat(ctx, path)
return err
}
func unmanagedEntries(ctx context.Context, backend storage.Backend, managedPaths []string) ([]ReconcileStateEntry, error) {
managed := make(map[string]struct{}, len(managedPaths)+1)
for _, path := range managedPaths {
managed[path] = struct{}{}
}
managed[storage.StateFileName] = struct{}{}
entries := make([]ReconcileStateEntry, 0)
err := backend.Walk(ctx, "", storage.WalkOptions{Recursive: true, Limit: reconcileStateWalkLimit}, func(entry storage.Entry) error {
if entry.Type == storage.EntryTypeDirectory {
return nil
}
if _, ok := managed[entry.Path]; ok {
return nil
}
entries = append(entries, ReconcileStateEntry{
Path: entry.Path,
Type: string(entry.Type),
Size: entry.Size,
})
return nil
})
if err != nil {
return nil, err
}
sort.Slice(entries, func(i, j int) bool {
return entries[i].Path < entries[j].Path
})
return entries, nil
}
func sharedRootOutputsForOwner(outputs []state.SharedRootOutputFile, scope state.OwnerScope) []state.SharedRootOutputFile {
selected := make([]state.SharedRootOutputFile, 0, len(outputs))
for _, output := range outputs {
if output.Owner == scope {
selected = append(selected, output)
}
}
return selected
}
func missingReportPaths(missing []ReconcileStatePath) []string {
paths := make([]string, 0, len(missing))
for _, item := range missing {
paths = append(paths, item.Path)
}
return paths
}
func writeRepairedState(ctx context.Context, backend storage.Backend, path string, value any) error {
data, err := json.MarshalIndent(value, "", " ")
if err != nil {
return err
}
data = append(data, '\n')
_, err = backend.WriteFile(ctx, path, data, storage.WriteOptions{Overwrite: true, PreferAtomic: true})
return err
}
func destinationRootPath(destination config.Destination) string {
switch destination.Backend {
case config.BackendS3:
if destination.Prefix == "" {
return "."
}
return destination.Prefix
default:
if destination.Path == "" {
return "."
}
return destination.Path
}
}
func WriteReconcileStateReport(w io.Writer, format OutputFormat, report ReconcileStateReport) error {
if IsJSONOutput(format) {
return WriteJSONEnvelope(w, "reconcile-state", true, nil, report, nil)
}
return writeReconcileStateReportText(w, report)
}
func writeReconcileStateReportText(w io.Writer, report ReconcileStateReport) error {
if w == nil {
return nil
}
status := "unchanged"
if report.Changed {
status = "changed"
} else if report.WouldChange {
status = "would_change"
}
_, err := fmt.Fprintf(w, "Reconcile state: pipeline=%s destination=%s backend=%s root=%s status=%s checked=%d missing=%d unmanaged=%d dry_run=%t\n",
report.PipelineID,
report.DestinationID,
report.Backend,
report.RootPath,
status,
report.CheckedCount,
len(report.MissingManagedOutputs),
len(report.UnmanagedEntries),
report.DryRun,
)
return err
}

View File

@@ -0,0 +1,269 @@
package app
import (
"context"
"encoding/json"
"strings"
"testing"
"time"
"gitea.maximumdirect.net/eric/distributor/internal/config"
"gitea.maximumdirect.net/eric/distributor/internal/state"
"gitea.maximumdirect.net/eric/distributor/internal/storage"
"gitea.maximumdirect.net/eric/distributor/internal/storage/fake"
"gitea.maximumdirect.net/eric/distributor/internal/testutil"
)
func TestReconcileStateDryRunReportsMissingManagedOutputsWithoutRewrite(t *testing.T) {
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 {
t.Fatalf("delete managed output: %v", err)
}
testutil.WriteFakeFile(t, backend, "extra.txt", "unmanaged")
report, err := reconcileStateConfigWithBackendFactory(context.Background(), cfg, ReconcileStateOptions{
PipelineID: "reports",
DestinationID: "archive",
DryRun: true,
}, fakeBackendFactoryProvider(t, map[string]storage.Backend{"s3:reports": backend}))
if err != nil {
t.Fatalf("reconcileStateConfigWithBackendFactory() error = %v", err)
}
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 := 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" {
t.Fatalf("state outputs = %q, want original outputs", got)
}
}
func TestReconcileStateApplyRemovesMissingRecordsAndPreservesUnmanagedFiles(t *testing.T) {
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 {
t.Fatalf("delete managed output: %v", err)
}
testutil.WriteFakeFile(t, backend, "extra.txt", "unmanaged")
report, err := reconcileStateConfigWithBackendFactory(context.Background(), cfg, ReconcileStateOptions{
PipelineID: "reports",
DestinationID: "archive",
}, fakeBackendFactoryProvider(t, map[string]storage.Backend{"s3:reports": backend}))
if err != nil {
t.Fatalf("reconcileStateConfigWithBackendFactory() error = %v", err)
}
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)
}
if got := strings.Join(state.ManagedOutputPaths(destinationState), ","); got != "report.md" {
t.Fatalf("state outputs = %q, want report.md", got)
}
testutil.AssertFakeFile(t, backend, "extra.txt", "unmanaged")
}
func TestReconcileStateInvalidStateFailsWithoutRewrite(t *testing.T) {
backend := fake.New()
cfg := reconcileStateS3Config(t)
invalid := `{"schema_version":2,"pipeline_id":"reports"}`
testutil.WriteFakeFile(t, backend, storage.StateFileName, invalid)
_, err := reconcileStateConfigWithBackendFactory(context.Background(), cfg, ReconcileStateOptions{
PipelineID: "reports",
DestinationID: "archive",
}, fakeBackendFactoryProvider(t, map[string]storage.Backend{"s3:reports": backend}))
if err == nil {
t.Fatal("reconcileStateConfigWithBackendFactory() error = nil, want invalid state error")
}
data, readErr := backend.ReadFile(context.Background(), storage.StateFileName)
if readErr != nil {
t.Fatalf("read invalid state: %v", readErr)
}
if string(data) != invalid {
t.Fatalf("state data = %q, want original invalid data", data)
}
}
func TestReconcileStateSharedRootOwnerScopeRepairsCurrentOwnerOnly(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 {
t.Fatalf("delete managed outputs: %v", err)
}
report, err := reconcileStateConfigWithBackendFactory(context.Background(), cfg, ReconcileStateOptions{
PipelineID: "reports",
DestinationID: "archive",
}, fakeBackendFactoryProvider(t, map[string]storage.Backend{"s3:reports": backend}))
if err != nil {
t.Fatalf("reconcileStateConfigWithBackendFactory() error = %v", err)
}
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)
}
}
func TestReconcileStateSharedRootAllOwnersRepairsEveryOwner(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 {
t.Fatalf("delete managed outputs: %v", err)
}
report, err := reconcileStateConfigWithBackendFactory(context.Background(), cfg, ReconcileStateOptions{
PipelineID: "reports",
DestinationID: "archive",
AllOwners: true,
}, fakeBackendFactoryProvider(t, map[string]storage.Backend{"s3:reports": backend}))
if err != nil {
t.Fatalf("reconcileStateConfigWithBackendFactory() error = %v", err)
}
if !report.Changed || report.CheckedCount != 2 {
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)
}
}
func reconcileStateS3Config(t *testing.T) config.Config {
t.Helper()
cfg := config.Config{Pipelines: []config.Pipeline{{
ID: "reports",
Source: config.Backend{Backend: config.BackendLocal, Path: t.TempDir()},
Destinations: []config.Destination{{
ID: "archive",
Backend: config.BackendS3,
Bucket: "reports",
}},
}}}
config.ApplyDefaults(&cfg)
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 {
values = append(values, path.Path)
}
return strings.Join(values, ",")
}
func entryPathList(entries []ReconcileStateEntry) string {
values := make([]string, 0, len(entries))
for _, entry := range entries {
values = append(values, entry.Path)
}
return strings.Join(values, ",")
}

View File

@@ -61,6 +61,38 @@ func TestParseValidStateWithLinks(t *testing.T) {
}
}
func TestRemoveMissingOutputs(t *testing.T) {
state, err := Parse([]byte(validStateJSON(t)))
if err != nil {
t.Fatalf("Parse() error = %v", err)
}
state.Outputs = append(state.Outputs, OutputFile{
Path: "summary.txt",
Kind: OutputKindSource,
SourcePath: "summary.txt",
SHA256: "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
Size: 10,
CreatedAt: state.CreatedAt,
UpdatedAt: state.UpdatedAt,
})
next, changed := RemoveMissingOutputs(state, []string{"summary.txt"})
if !changed {
t.Fatal("RemoveMissingOutputs() changed = false, want true")
}
if got, want := ManagedOutputPaths(next), []string{"report.md"}; strings.Join(got, ",") != strings.Join(want, ",") {
t.Fatalf("paths = %#v, want %#v", got, want)
}
unchanged, changed := RemoveMissingOutputs(next, []string{"missing.txt"})
if changed {
t.Fatal("RemoveMissingOutputs() changed = true, want false")
}
if got, want := ManagedOutputPaths(unchanged), []string{"report.md"}; strings.Join(got, ",") != strings.Join(want, ",") {
t.Fatalf("unchanged paths = %#v, want %#v", got, want)
}
}
func TestParseNormalizesPublishedAtOffset(t *testing.T) {
body := strings.Replace(validStateJSON(t), `"published_at": "2026-05-30T11:12:00Z"`, `"published_at": "2026-05-30T13:12:00+02:00"`, 1)
state, err := Parse([]byte(body))

View File

@@ -60,6 +60,24 @@ func ManagedOutputPaths(s DistributorState) []string {
return paths
}
func RemoveMissingOutputs(s DistributorState, missingPaths []string) (DistributorState, bool) {
if len(missingPaths) == 0 {
return s, false
}
missing := pathSet(missingPaths)
next := s
next.Outputs = make([]OutputFile, 0, len(s.Outputs))
changed := false
for _, output := range s.Outputs {
if _, remove := missing[output.Path]; remove {
changed = true
continue
}
next.Outputs = append(next.Outputs, output)
}
return next, changed
}
func ProjectOutputs(outputs []OutputProjection, existing []OutputFile, now time.Time) []OutputFile {
now = now.UTC()
files := make([]OutputFile, 0, len(outputs))
@@ -122,6 +140,44 @@ func (s SharedRootState) AllManagedOutputPaths() []string {
return paths
}
func RemoveMissingSharedRootOwnerOutputs(s SharedRootState, scope OwnerScope, missingPaths []string) (SharedRootState, bool) {
if len(missingPaths) == 0 {
return s, false
}
missing := pathSet(missingPaths)
next := s
next.Outputs = make([]SharedRootOutputFile, 0, len(s.Outputs))
changed := false
for _, output := range s.Outputs {
if output.Owner == scope {
if _, remove := missing[output.Path]; remove {
changed = true
continue
}
}
next.Outputs = append(next.Outputs, output)
}
return next, changed
}
func RemoveMissingSharedRootOutputs(s SharedRootState, missingPaths []string) (SharedRootState, bool) {
if len(missingPaths) == 0 {
return s, false
}
missing := pathSet(missingPaths)
next := s
next.Outputs = make([]SharedRootOutputFile, 0, len(s.Outputs))
changed := false
for _, output := range s.Outputs {
if _, remove := missing[output.Path]; remove {
changed = true
continue
}
next.Outputs = append(next.Outputs, output)
}
return next, changed
}
func (s SharedRootState) OutputOwner(path string) (OwnerScope, bool) {
for _, output := range s.Outputs {
if output.Path == path {
@@ -264,3 +320,11 @@ func upsertOwner(owners []OwnerRecord, owner OwnerRecord) []OwnerRecord {
}
return append(next, owner)
}
func pathSet(paths []string) map[string]struct{} {
set := make(map[string]struct{}, len(paths))
for _, path := range paths {
set[path] = struct{}{}
}
return set
}

View File

@@ -132,6 +132,32 @@ func TestSharedRootOutputHelpers(t *testing.T) {
}
}
func TestRemoveMissingSharedRootOwnerOutputs(t *testing.T) {
state := validSharedRootState(t)
archive := CurrentOwnerScope("reports", "archive")
next, changed := RemoveMissingSharedRootOwnerOutputs(state, archive, []string{"report.md", "report.html"})
if !changed {
t.Fatal("RemoveMissingSharedRootOwnerOutputs() changed = false, want true")
}
if got, want := next.AllManagedOutputPaths(), []string{"report.html"}; strings.Join(got, ",") != strings.Join(want, ",") {
t.Fatalf("paths = %#v, want %#v", got, want)
}
if _, ok := next.OutputOwner("report.html"); !ok {
t.Fatal("report.html owner missing, want unrelated owner preserved")
}
}
func TestRemoveMissingSharedRootOutputs(t *testing.T) {
state := validSharedRootState(t)
next, changed := RemoveMissingSharedRootOutputs(state, []string{"report.md", "report.html"})
if !changed {
t.Fatal("RemoveMissingSharedRootOutputs() changed = false, want true")
}
if got := next.AllManagedOutputPaths(); len(got) != 0 {
t.Fatalf("paths = %#v, want none", got)
}
}
func TestSharedRootProjectAndMergeOwnerOutputs(t *testing.T) {
state := validSharedRootState(t)
archive := CurrentOwnerScope("reports", "archive")