Add shared-root publish planning

This commit is contained in:
2026-06-08 18:53:22 +00:00
parent eb86cf9ab6
commit 89169f810f
10 changed files with 629 additions and 23 deletions

View File

@@ -17,6 +17,9 @@ func Execute(ctx context.Context, req Request, plan Plan) error {
case ActionSkipSame, ActionSkipDestinationNewer:
return nil
case ActionPublishNew, ActionReplaceOlder, ActionForceReplace:
if normalizeState(req.State).Mode == config.StateModeSharedRoot || plan.StateMode == config.StateModeSharedRoot {
return fmt.Errorf("shared-root publish execution is not implemented")
}
default:
return fmt.Errorf("cannot execute action %s: %s", plan.Action, plan.Reason)
}

View File

@@ -34,6 +34,7 @@ type Request struct {
Publish config.PublishPolicy
Transform config.Transform
Links *config.Links
State config.StatePolicy
Reconciliation config.ReconciliationPolicy
Transformers TransformerResolver
Transfer config.TransferPolicy
@@ -56,9 +57,16 @@ type Plan struct {
Reason string
Force bool
PrimaryURL string
StateMode string
OwnerScope state.OwnerScope
Reconciliation config.ReconciliationPolicy
Outputs []Output
ExistingState *state.DistributorState
ExistingSharedRoot *state.SharedRootState
OtherOwnerOutputs []state.SharedRootOutputFile
RetainedOwnerOutputs []state.SharedRootOutputFile
OwnerOutputsToDelete []state.SharedRootOutputFile
OwnerOutputsToWrite []Output
}
type Output struct {
@@ -91,6 +99,7 @@ func Build(ctx context.Context, req Request) (Plan, error) {
comparison := compareDestination(req, status)
action, reason := actionForComparison(comparison, req.Transfer, req.Force)
reconciliation := normalizeReconciliation(req.Reconciliation)
stateMode := normalizeState(req.State).Mode
plan := Plan{
PipelineID: req.PipelineID,
DestinationID: req.DestinationID,
@@ -102,9 +111,24 @@ func Build(ctx context.Context, req Request) (Plan, error) {
Reason: reason,
Force: action == ActionForceReplace,
PrimaryURL: primaryURL,
StateMode: stateMode,
OwnerScope: state.CurrentOwnerScope(req.PipelineID, req.DestinationID),
Reconciliation: reconciliation,
Outputs: outputs,
ExistingState: status.State,
ExistingSharedRoot: status.SharedRoot,
}
if stateMode == config.StateModeSharedRoot {
sharedDetails, err := planSharedRootOwner(ctx, req, status, action, reconciliation, outputs)
plan.OtherOwnerOutputs = sharedDetails.OtherOwnerOutputs
plan.RetainedOwnerOutputs = sharedDetails.RetainedOwnerOutputs
plan.OwnerOutputsToDelete = sharedDetails.OwnerOutputsToDelete
plan.OwnerOutputsToWrite = sharedDetails.OwnerOutputsToWrite
if err != nil {
plan.Action = sharedDetails.Action
plan.Reason = sharedDetails.Reason
return plan, err
}
}
if action == ActionFailConflict || action == ActionFailUnmanaged {
return plan, fmt.Errorf("%s: %s", action, reason)
@@ -133,6 +157,11 @@ func validateRequest(req Request) error {
default:
return fmt.Errorf("reconciliation.mode must be %s or %s", config.ReconciliationModeReplace, config.ReconciliationModeMerge)
}
switch normalizeState(req.State).Mode {
case config.StateModeSingleOwner, config.StateModeSharedRoot:
default:
return fmt.Errorf("state.mode must be %s or %s", config.StateModeSingleOwner, config.StateModeSharedRoot)
}
return nil
}
@@ -143,7 +172,32 @@ func normalizeReconciliation(policy config.ReconciliationPolicy) config.Reconcil
return policy
}
func normalizeState(policy config.StatePolicy) config.StatePolicy {
if policy.Mode == "" {
policy.Mode = config.StateModeSingleOwner
}
return policy
}
func compareDestination(req Request, status state.DestinationStatus) state.Comparison {
if normalizeState(req.State).Mode == config.StateModeSharedRoot {
scope := state.CurrentOwnerScope(req.PipelineID, req.DestinationID)
comparison := state.CompareSharedRootOwner(req.SourceBundle.Manifest, scope, status)
if req.PathMapping != config.PathMappingFixed || comparison.Outcome != state.OutcomeDifferentSourceConflict {
return comparison
}
destinationManifest, ok := sharedRootComparisonManifest(status, scope)
if !ok {
return comparison
}
if destinationManifest.Created.Before(req.SourceBundle.Manifest.Created) {
return state.Comparison{Outcome: state.OutcomeDestinationOlder, Reason: "fixed destination source is older than selected source"}
}
if destinationManifest.Created.After(req.SourceBundle.Manifest.Created) {
return state.Comparison{Outcome: state.OutcomeDestinationNewer, Reason: "fixed destination source is newer than selected source"}
}
return comparison
}
comparison := state.Compare(req.SourceBundle.Manifest, req.PipelineID, req.DestinationID, status)
if req.PathMapping != config.PathMappingFixed || comparison.Outcome != state.OutcomeDifferentSourceConflict || status.State == nil {
return comparison
@@ -158,6 +212,169 @@ func compareDestination(req Request, status state.DestinationStatus) state.Compa
return comparison
}
func sharedRootComparisonManifest(status state.DestinationStatus, scope state.OwnerScope) (bundle.Manifest, bool) {
if status.SharedRoot != nil {
return status.SharedRoot.SourceManifest(scope)
}
if status.State != nil && status.State.PipelineID == scope.PipelineID && status.State.DestinationID == scope.DestinationID {
return status.State.Source.Manifest, true
}
return bundle.Manifest{}, false
}
type sharedRootPlanDetails struct {
Action Action
Reason string
OtherOwnerOutputs []state.SharedRootOutputFile
RetainedOwnerOutputs []state.SharedRootOutputFile
OwnerOutputsToDelete []state.SharedRootOutputFile
OwnerOutputsToWrite []Output
}
func planSharedRootOwner(ctx context.Context, req Request, status state.DestinationStatus, action Action, reconciliation config.ReconciliationPolicy, outputs []Output) (sharedRootPlanDetails, error) {
scope := state.CurrentOwnerScope(req.PipelineID, req.DestinationID)
details := sharedRootPlanDetails{Action: action}
if !isWriteAction(action) {
details.OtherOwnerOutputs = otherOwnerOutputs(status, scope)
return details, nil
}
plannedPaths := outputPaths(outputs)
if conflict, ok := sharedRootPathOwnershipConflict(status, scope, plannedPaths); ok {
reason := fmt.Sprintf("destination output path %s is owned by %s/%s", conflict.Path, conflict.Owner.PipelineID, conflict.Owner.DestinationID)
details.Action = ActionFailConflict
details.Reason = reason
return details, fmt.Errorf("%s: %s", ActionFailConflict, reason)
}
if err := rejectSharedRootUnmanagedCollisions(ctx, req.DestinationBackend, req.DestinationBundlePath, status, scope, plannedPaths); err != nil {
details.Action = ActionFailUnmanaged
details.Reason = err.Error()
return details, fmt.Errorf("%s: %s", ActionFailUnmanaged, err)
}
details.OtherOwnerOutputs = otherOwnerOutputs(status, scope)
ownerOutputs := currentOwnerOutputs(status, scope)
planned := make(map[string]struct{}, len(plannedPaths))
for _, path := range plannedPaths {
planned[path] = struct{}{}
}
for _, output := range ownerOutputs {
if _, exists := planned[output.Path]; exists {
continue
}
if action == ActionReplaceOlder && reconciliation.Mode == config.ReconciliationModeReplace {
details.OwnerOutputsToDelete = append(details.OwnerOutputsToDelete, output)
continue
}
if action == ActionReplaceOlder && reconciliation.Mode == config.ReconciliationModeMerge {
details.RetainedOwnerOutputs = append(details.RetainedOwnerOutputs, output)
}
}
details.OwnerOutputsToWrite = append([]Output(nil), outputs...)
return details, nil
}
func isWriteAction(action Action) bool {
switch action {
case ActionPublishNew, ActionReplaceOlder, ActionForceReplace:
return true
default:
return false
}
}
func outputPaths(outputs []Output) []string {
paths := make([]string, 0, len(outputs))
for _, output := range outputs {
paths = append(paths, output.DestinationPath)
}
return paths
}
func sharedRootPathOwnershipConflict(status state.DestinationStatus, scope state.OwnerScope, paths []string) (state.PathOwnershipConflict, bool) {
if status.SharedRoot != nil {
return status.SharedRoot.PathOwnershipConflict(scope, paths)
}
return state.PathOwnershipConflict{}, false
}
func rejectSharedRootUnmanagedCollisions(ctx context.Context, backend storage.Backend, bundlePath string, status state.DestinationStatus, scope state.OwnerScope, paths []string) error {
for _, path := range paths {
if pathManagedBySharedRootStatus(status, scope, path) {
continue
}
destinationPath, err := storage.Join(bundlePath, path)
if err != nil {
return err
}
if _, err := backend.Stat(ctx, destinationPath); err == nil {
return fmt.Errorf("destination output path %s exists but is not managed by destination state", storage.DisplayPath(path))
} else if !storage.IsNotFound(err) {
return err
}
}
return nil
}
func pathManagedBySharedRootStatus(status state.DestinationStatus, scope state.OwnerScope, path string) bool {
if status.SharedRoot != nil {
_, exists := status.SharedRoot.OutputOwner(path)
return exists
}
if status.State != nil && status.State.PipelineID == scope.PipelineID && status.State.DestinationID == scope.DestinationID {
_, exists := state.FindOutputByPath(status.State.Outputs, path)
return exists
}
return false
}
func otherOwnerOutputs(status state.DestinationStatus, scope state.OwnerScope) []state.SharedRootOutputFile {
if status.SharedRoot == nil {
return nil
}
outputs := make([]state.SharedRootOutputFile, 0, len(status.SharedRoot.Outputs))
for _, output := range status.SharedRoot.Outputs {
if output.Owner != scope {
outputs = append(outputs, output)
}
}
return outputs
}
func currentOwnerOutputs(status state.DestinationStatus, scope state.OwnerScope) []state.SharedRootOutputFile {
if status.SharedRoot != nil {
outputs := make([]state.SharedRootOutputFile, 0, len(status.SharedRoot.Outputs))
for _, output := range status.SharedRoot.Outputs {
if output.Owner == scope {
outputs = append(outputs, output)
}
}
return outputs
}
if status.State != nil && status.State.PipelineID == scope.PipelineID && status.State.DestinationID == scope.DestinationID {
outputs := make([]state.SharedRootOutputFile, 0, len(status.State.Outputs))
for _, output := range status.State.Outputs {
outputs = append(outputs, state.SharedRootOutputFile{
Path: output.Path,
Kind: output.Kind,
SourcePath: output.SourcePath,
Transform: output.Transform,
URL: output.URL,
SHA256: output.SHA256,
Size: output.Size,
Owner: scope,
SourceID: status.State.Source.Manifest.ID,
SourceDigest: status.State.Source.Manifest.Digest,
SourceCreated: status.State.Source.Manifest.Created,
CreatedAt: output.CreatedAt,
UpdatedAt: output.UpdatedAt,
})
}
return outputs
}
return nil
}
func actionForComparison(comparison state.Comparison, transfer config.TransferPolicy, force bool) (Action, string) {
switch comparison.Outcome {
case state.OutcomeDestinationAbsent:

View File

@@ -14,11 +14,11 @@ func inspectDestination(ctx context.Context, backend storage.Backend, bundlePath
}
data, err := backend.ReadFile(ctx, statePath)
if err == nil {
destinationState, parseErr := state.Parse(data)
document, parseErr := state.ParseDocument(data)
if parseErr != nil {
return state.DestinationStatus{StateErr: parseErr}, nil
}
return state.DestinationStatus{State: &destinationState, HasContents: true}, nil
return state.DestinationStatus{State: document.SingleOwner, SharedRoot: document.SharedRoot, HasContents: true}, nil
}
if !storage.IsNotFound(err) {
return state.DestinationStatus{}, err

View File

@@ -0,0 +1,261 @@
package publish
import (
"context"
"encoding/json"
"strings"
"testing"
"time"
"gitea.maximumdirect.net/eric/distributor/internal/bundle"
"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 TestBuildSharedRootTreatsAbsentOwnerAsPublishable(t *testing.T) {
sourceBackend := fake.New()
sourceBundle := testutil.WriteFakeSourceBundle(t, sourceBackend, "bundle", testutil.BundleOptions{
Files: []testutil.SourceFile{{Path: "report.md", Data: "# Report\n"}},
})
destinationBackend := fake.New()
writeFakeSharedRootState(t, destinationBackend, "bundle", sharedRootStateWithOwners(t, sourceBundle.Manifest, false))
req := sharedRootRequest(sourceBackend, destinationBackend, sourceBundle, config.ReconciliationModeReplace)
plan, err := Build(context.Background(), req)
if err != nil {
t.Fatalf("Build() error = %v", err)
}
if plan.Action != ActionPublishNew {
t.Fatalf("plan action = %s, want publish_new", plan.Action)
}
if plan.OwnerScope != state.CurrentOwnerScope("reports", "archive") {
t.Fatalf("owner scope = %#v, want reports/archive", plan.OwnerScope)
}
if got, want := len(plan.OtherOwnerOutputs), 1; got != want {
t.Fatalf("other owner output count = %d, want %d", got, want)
}
if got, want := len(plan.OwnerOutputsToWrite), 1; got != want {
t.Fatalf("owner output write count = %d, want %d", got, want)
}
if len(plan.OwnerOutputsToDelete) != 0 || len(plan.RetainedOwnerOutputs) != 0 {
t.Fatalf("delete=%#v retained=%#v, want none", plan.OwnerOutputsToDelete, plan.RetainedOwnerOutputs)
}
}
func TestBuildSharedRootReplaceDeletesOnlyCurrentOwnerOmittedOutputs(t *testing.T) {
sourceBackend := fake.New()
sourceBundle := testutil.WriteFakeSourceBundle(t, sourceBackend, "bundle", testutil.BundleOptions{
Files: []testutil.SourceFile{{Path: "report.md", Data: "# Report\nNew.\n"}},
})
destinationBackend := fake.New()
writeFakeSharedRootState(t, destinationBackend, "bundle", sharedRootStateWithOwners(t, sourceBundle.Manifest, true))
req := sharedRootRequest(sourceBackend, destinationBackend, sourceBundle, config.ReconciliationModeReplace)
plan, err := Build(context.Background(), req)
if err != nil {
t.Fatalf("Build() error = %v", err)
}
if plan.Action != ActionReplaceOlder {
t.Fatalf("plan action = %s, want replace_older", plan.Action)
}
if got, want := sharedRootOutputPathList(plan.OwnerOutputsToDelete), "old.md"; got != want {
t.Fatalf("owner outputs to delete = %q, want %q", got, want)
}
if got, want := sharedRootOutputPathList(plan.OtherOwnerOutputs), "other/report.md"; got != want {
t.Fatalf("other owner outputs = %q, want %q", got, want)
}
if len(plan.RetainedOwnerOutputs) != 0 {
t.Fatalf("retained owner outputs = %#v, want none", plan.RetainedOwnerOutputs)
}
}
func TestBuildSharedRootMergeRetainsCurrentOwnerOmittedOutputs(t *testing.T) {
sourceBackend := fake.New()
sourceBundle := testutil.WriteFakeSourceBundle(t, sourceBackend, "bundle", testutil.BundleOptions{
Files: []testutil.SourceFile{{Path: "report.md", Data: "# Report\nNew.\n"}},
})
destinationBackend := fake.New()
writeFakeSharedRootState(t, destinationBackend, "bundle", sharedRootStateWithOwners(t, sourceBundle.Manifest, true))
req := sharedRootRequest(sourceBackend, destinationBackend, sourceBundle, config.ReconciliationModeMerge)
plan, err := Build(context.Background(), req)
if err != nil {
t.Fatalf("Build() error = %v", err)
}
if got, want := sharedRootOutputPathList(plan.RetainedOwnerOutputs), "old.md"; got != want {
t.Fatalf("retained owner outputs = %q, want %q", got, want)
}
if len(plan.OwnerOutputsToDelete) != 0 {
t.Fatalf("owner outputs to delete = %#v, want none", plan.OwnerOutputsToDelete)
}
}
func TestBuildSharedRootRejectsOtherOwnerPathConflict(t *testing.T) {
sourceBackend := fake.New()
sourceBundle := testutil.WriteFakeSourceBundle(t, sourceBackend, "bundle", testutil.BundleOptions{
Files: []testutil.SourceFile{{Path: "report.md", Data: "# Report\n"}},
})
destinationBackend := fake.New()
sharedRoot := sharedRootStateWithOwners(t, sourceBundle.Manifest, false)
sharedRoot.Outputs[0].Path = "report.md"
writeFakeSharedRootState(t, destinationBackend, "bundle", sharedRoot)
req := sharedRootRequest(sourceBackend, destinationBackend, sourceBundle, config.ReconciliationModeReplace)
plan, err := Build(context.Background(), req)
if err == nil || !strings.Contains(err.Error(), "fail_conflict") {
t.Fatalf("Build() error = %v, want fail_conflict", err)
}
if plan.Action != ActionFailConflict {
t.Fatalf("plan action = %s, want fail_conflict", plan.Action)
}
}
func TestBuildSharedRootRejectsUnmanagedPathCollision(t *testing.T) {
sourceBackend := fake.New()
sourceBundle := testutil.WriteFakeSourceBundle(t, sourceBackend, "bundle", testutil.BundleOptions{
Files: []testutil.SourceFile{{Path: "report.md", Data: "# Report\n"}},
})
destinationBackend := fake.New()
writeFakeSharedRootState(t, destinationBackend, "bundle", sharedRootStateWithOwners(t, sourceBundle.Manifest, false))
testutil.WriteFakeFile(t, destinationBackend, "bundle/report.md", "unmanaged")
req := sharedRootRequest(sourceBackend, destinationBackend, sourceBundle, config.ReconciliationModeReplace)
plan, err := Build(context.Background(), req)
if err == nil || !strings.Contains(err.Error(), "fail_unmanaged") {
t.Fatalf("Build() error = %v, want fail_unmanaged", err)
}
if plan.Action != ActionFailUnmanaged {
t.Fatalf("plan action = %s, want fail_unmanaged", plan.Action)
}
}
func TestExecuteSharedRootWriteActionsAreDisabled(t *testing.T) {
sourceBackend := fake.New()
sourceBundle := testutil.WriteFakeSourceBundle(t, sourceBackend, "bundle", testutil.BundleOptions{
Files: []testutil.SourceFile{{Path: "report.md", Data: "# Report\n"}},
})
destinationBackend := fake.New()
req := sharedRootRequest(sourceBackend, destinationBackend, sourceBundle, config.ReconciliationModeReplace)
plan, err := Build(context.Background(), req)
if err != nil {
t.Fatalf("Build() error = %v", err)
}
if err := Execute(context.Background(), req, plan); err == nil || !strings.Contains(err.Error(), "shared-root publish execution is not implemented") {
t.Fatalf("Execute() error = %v, want shared-root disabled error", err)
}
}
func sharedRootRequest(sourceBackend, destinationBackend *fake.Backend, sourceBundle bundle.Bundle, reconciliationMode string) Request {
return Request{
PipelineID: "reports",
DestinationID: "archive",
SourceBundle: sourceBundle,
SourceBackend: sourceBackend,
DestinationBackend: destinationBackend,
DestinationBundlePath: sourceBundle.RootRelativePath,
Publish: config.PublishPolicy{Source: true},
State: config.StatePolicy{Mode: config.StateModeSharedRoot},
Reconciliation: config.ReconciliationPolicy{Mode: reconciliationMode},
Transfer: defaultTransfer(),
DistributorVersion: "test",
}
}
func writeFakeSharedRootState(t *testing.T, backend *fake.Backend, relative string, sharedRoot state.SharedRootState) {
t.Helper()
data, err := json.MarshalIndent(sharedRoot, "", " ")
if err != nil {
t.Fatalf("marshal shared-root state: %v", err)
}
statePath, err := storage.StatePath(relative)
if err != nil {
t.Fatalf("state path: %v", err)
}
testutil.WriteFakeFile(t, backend, statePath, string(append(data, '\n')))
for _, output := range sharedRoot.Outputs {
path, err := storage.Join(relative, output.Path)
if err != nil {
t.Fatalf("join output path: %v", err)
}
testutil.WriteFakeFile(t, backend, path, "old")
}
}
func sharedRootStateWithOwners(t *testing.T, current bundle.Manifest, includeCurrent bool) state.SharedRootState {
t.Helper()
createdAt := time.Date(2026, 5, 30, 11, 12, 0, 0, time.UTC)
otherManifest := testutil.ValidManifest(testutil.BundleOptions{
ID: "other.source",
Files: []testutil.SourceFile{{Path: "other/report.md", Data: "# Other\n"}},
})
sharedRoot := state.SharedRootState{
SchemaVersion: state.SharedRootSchemaVersion,
DistributorVersion: "test",
CreatedAt: createdAt,
UpdatedAt: createdAt,
State: state.StatePolicy{Mode: state.StateModeSharedRoot},
Owners: []state.OwnerRecord{{
Scope: state.CurrentOwnerScope("other", "archive"),
Reconciliation: state.ReconciliationPolicy{Mode: config.ReconciliationModeReplace},
Source: state.SourceState{Manifest: otherManifest},
}},
Outputs: []state.SharedRootOutputFile{{
Path: "other/report.md",
Kind: state.OutputKindSource,
SourcePath: "other/report.md",
SHA256: otherManifest.Files[0].SHA256,
Size: otherManifest.Files[0].Size,
Owner: state.CurrentOwnerScope("other", "archive"),
SourceID: otherManifest.ID,
SourceDigest: otherManifest.Digest,
SourceCreated: otherManifest.Created,
CreatedAt: createdAt,
UpdatedAt: createdAt,
}},
}
if !includeCurrent {
return sharedRoot
}
older := current
older.Created = older.Created.Add(-time.Hour)
older.Files = append([]bundle.ManifestFile(nil), current.Files...)
older.Files = append(older.Files, bundle.ManifestFile{
Path: "old.md",
SHA256: bundle.FileDigest([]byte("old\n")),
Size: int64(len("old\n")),
})
older.Digest = bundle.BundleDigest(older.Files)
scope := state.CurrentOwnerScope("reports", "archive")
sharedRoot.Owners = append(sharedRoot.Owners, state.OwnerRecord{
Scope: scope,
Reconciliation: state.ReconciliationPolicy{Mode: config.ReconciliationModeReplace},
Source: state.SourceState{Manifest: older},
})
for _, file := range older.Files {
sharedRoot.Outputs = append(sharedRoot.Outputs, state.SharedRootOutputFile{
Path: file.Path,
Kind: state.OutputKindSource,
SourcePath: file.Path,
SHA256: file.SHA256,
Size: file.Size,
Owner: scope,
SourceID: older.ID,
SourceDigest: older.Digest,
SourceCreated: older.Created,
CreatedAt: createdAt,
UpdatedAt: createdAt,
})
}
return sharedRoot
}
func sharedRootOutputPathList(outputs []state.SharedRootOutputFile) string {
paths := make([]string, 0, len(outputs))
for _, output := range outputs {
paths = append(paths, output.Path)
}
return strings.Join(paths, ",")
}