Update policy for replacement of managed files

This commit is contained in:
2026-06-18 13:38:44 -05:00
parent 8b0ce4d134
commit 69043801d0
18 changed files with 330 additions and 107 deletions

View File

@@ -292,7 +292,7 @@ Text output is optimized for direct operator use. JSON output is optimized for a
- Usage errors and fatal setup errors exit non-zero and do not emit a JSON result document. - Usage errors and fatal setup errors exit non-zero and do not emit a JSON result document.
- `run --format json` emits a JSON result for partial destination failures, sets `ok` to `false`, includes result details and errors, and exits non-zero. - `run --format json` emits a JSON result for partial destination failures, sets `ok` to `false`, includes result details and errors, and exits non-zero.
- Warnings are included in JSON output and are printed in text output when relevant. - Warnings are included in JSON output and are printed in text output when relevant.
- `run` summaries include separate `replace_older`, `replace_takeover`, and `force_replace` counters. Takeover destination actions include `takeover_mode` in JSON and text output. - `run` summaries include separate `replace_older`, `replace_conflict`, `replace_newer`, `replace_takeover`, and `force_replace` counters. Takeover destination actions include `takeover_mode` in JSON and text output.
## Diagnostics And Recovery ## Diagnostics And Recovery

View File

@@ -510,7 +510,7 @@ Transfer fields and accepted values:
- `transfer.on_destination_newer`: `skip`, `replace`, or `fail`. Default: `skip`. - `transfer.on_destination_newer`: `skip`, `replace`, or `fail`. Default: `skip`.
- `transfer.on_conflict`: `fail` or `replace`. Default: `fail`. - `transfer.on_conflict`: `fail` or `replace`. Default: `fail`.
`replace` for `on_destination_newer` and `on_conflict` is honored only when `run --force` is supplied. There is no config field that enables forced replacement by default. `replace` for `on_destination_newer` and `on_conflict` applies only to valid distributor-managed destination state. Unmanaged destination content and invalid destination state still require the explicit `run --force` workflow where supported.
## Size And Duration Values ## Size And Duration Values

View File

@@ -111,10 +111,10 @@ If a same-source merge publication fails after writing outputs, cleanup removes
- Shared-root state without the current owner: publish new outputs for that owner if planned paths do not collide with unmanaged content or with other owners that `takeover.mode` does not permit. - Shared-root state without the current owner: publish new outputs for that owner if planned paths do not collide with unmanaged content or with other owners that `takeover.mode` does not permit.
- Matching embedded source manifest: skip. - Matching embedded source manifest: skip.
- Same source id with older `created`: replace if policy allows. - Same source id with older `created`: replace if policy allows.
- Same source id with newer `created`: skip by default. - Same source id with newer `created`: skip by default, or replace when `transfer.on_destination_newer: replace` is configured.
- Same source id and same `created` with different digest: conflict. - Same source id and same `created` with different digest: conflict by default, or replace when `transfer.on_conflict: replace` is configured.
- Different source id, pipeline id, or destination id in single-owner state: conflict unless `takeover.mode` permits managed ownership transfer. - Different source id, pipeline id, or destination id in single-owner state: conflict unless `takeover.mode` permits managed ownership transfer or `transfer.on_conflict: replace` is configured.
- Shared-root output path owned by a different owner: conflict unless `takeover.mode` permits managed ownership transfer. - Shared-root output path owned by a different owner: conflict unless `takeover.mode` permits managed ownership transfer or `transfer.on_conflict: replace` is configured.
- Invalid state JSON or invalid state fields: conflict. - Invalid state JSON or invalid state fields: conflict.
Normal single-owner replacement deletes only managed output paths recorded in `outputs` plus `.distributor.json`. Shared-root replacement deletes only omitted outputs for the current owner. Merge publication retains omitted managed outputs for same-source replacement. Forced replacement deletes the bounded destination bundle path. Normal single-owner replacement deletes only managed output paths recorded in `outputs` plus `.distributor.json`. Shared-root replacement deletes only omitted outputs for the current owner. Merge publication retains omitted managed outputs for same-source replacement. Forced replacement deletes the bounded destination bundle path.

View File

@@ -32,7 +32,7 @@ The app layer registers default transforms, including Markdown-to-HTML, and supp
## State And Manifest Behavior ## State And Manifest Behavior
Run workflows discover and validate source bundles through `internal/bundle`. Destination state actions are prepared and written through `internal/publish` and `internal/state`; the app layer records report projections of those actions and results. Run summaries count takeover replacements separately from ordinary older-state replacement and explicit forced replacement. Run workflows discover and validate source bundles through `internal/bundle`. Destination state actions are prepared and written through `internal/publish` and `internal/state`; the app layer records report projections of those actions and results. Run summaries count older-state, conflict, newer-state, takeover, and explicit forced replacements separately.
Reconcile-state workflows load one configured pipeline/destination selector, open that destination root, parse the root `.distributor.json`, and report missing managed output records plus unmanaged storage entries. Managed output existence checks use storage `Stat`; unmanaged reporting uses bounded storage `Walk` and excludes `.distributor.json` plus all paths already recorded as managed. Apply mode removes missing managed output records from state and rewrites valid state only; dry-run reports the same repair without writing. Text output reports `changed`, `would_change`, or `unchanged`; JSON output uses the shared app envelope. It does not validate output digests, delete destination files, adopt unmanaged files, or rewrite invalid or mismatched state. Reconcile-state workflows load one configured pipeline/destination selector, open that destination root, parse the root `.distributor.json`, and report missing managed output records plus unmanaged storage entries. Managed output existence checks use storage `Stat`; unmanaged reporting uses bounded storage `Walk` and excludes `.distributor.json` plus all paths already recorded as managed. Apply mode removes missing managed output records from state and rewrites valid state only; dry-run reports the same repair without writing. Text output reports `changed`, `would_change`, or `unchanged`; JSON output uses the shared app envelope. It does not validate output digests, delete destination files, adopt unmanaged files, or rewrite invalid or mismatched state.

View File

@@ -28,7 +28,7 @@ The package depends on `internal/storage.Backend` for source and destination IO,
## State And Manifest Behavior ## State And Manifest Behavior
Planning inspects destination state through `internal/state`, compares it with the source manifest, and maps comparison outcomes plus transfer and takeover policy into actions: `publish_new`, `replace_older`, `replace_takeover`, `force_replace`, `skip_same`, `skip_destination_newer`, `fail_conflict`, or `fail_unmanaged`. Planning inspects destination state through `internal/state`, compares it with the source manifest, and maps comparison outcomes plus transfer and takeover policy into actions: `publish_new`, `replace_older`, `replace_conflict`, `replace_newer`, `replace_takeover`, `force_replace`, `skip_same`, `skip_destination_newer`, `fail_conflict`, or `fail_unmanaged`.
Single-owner destinations compare the whole destination state against the configured pipeline and destination ids. Valid managed identity and source conflicts can become `replace_takeover` when `takeover.mode` allows them. Shared-root destinations compare only the current owner scope, keyed by pipeline id and destination id. An absent shared-root owner is publishable for that owner unless a planned output collides with unmanaged storage content. Planned writes to a path owned by another shared-root owner become `replace_takeover` when `takeover.mode` allows that managed output path to move to the current owner. Single-owner destinations compare the whole destination state against the configured pipeline and destination ids. Valid managed identity and source conflicts can become `replace_takeover` when `takeover.mode` allows them. Shared-root destinations compare only the current owner scope, keyed by pipeline id and destination id. An absent shared-root owner is publishable for that owner unless a planned output collides with unmanaged storage content. Planned writes to a path owned by another shared-root owner become `replace_takeover` when `takeover.mode` allows that managed output path to move to the current owner.
@@ -36,11 +36,11 @@ Execution writes destination state after selected outputs are written. Destinati
## Skip And Resume Behavior ## Skip And Resume Behavior
`skip_same` and `skip_destination_newer` execute as no-ops. Replacement-mode single-owner updates remove managed output paths from existing state plus `.distributor.json`, verify the destination is empty, and write state whose outputs are exactly the new plan. `replace_takeover` uses managed replacement mechanics and does not retain omitted outputs through merge reconciliation. Replacement-mode shared-root updates remove only current-owner omitted outputs and preserve unrelated owners. Shared-root takeover rewrites only the taken-over output records and current owner records. Merge-mode same-source updates retain omitted managed outputs, overwrite only paths already recorded as managed, reject unmanaged destination path collisions, and write cumulative output state. Failed writes trigger cleanup where practical; same-source merge cleanup removes only newly created outputs from the failed attempt. `skip_same` and `skip_destination_newer` execute as no-ops. Replacement-mode single-owner updates remove managed output paths from existing state plus `.distributor.json`, verify the destination is empty, and write state whose outputs are exactly the new plan. `replace_conflict` uses managed replacement mechanics and does not retain omitted outputs through merge reconciliation. `replace_newer` follows the same managed replacement and merge-retention rules as `replace_older`. `replace_takeover` uses managed replacement mechanics and does not retain omitted outputs through merge reconciliation. Replacement-mode shared-root updates remove only current-owner omitted outputs and preserve unrelated owners. Shared-root takeover rewrites only the taken-over output records and current owner records. Merge-mode same-source updates retain omitted managed outputs, overwrite only paths already recorded as managed, reject unmanaged destination path collisions, and write cumulative output state. Failed writes trigger cleanup where practical; same-source merge cleanup removes only newly created outputs from the failed attempt.
Shared-root execution writes schema version `3` state. It preserves unrelated owner records and outputs, updates only the publishing owner metadata, preserves root `created_at`, and updates root `updated_at` after successful state writes. Compatible single-owner state for the same pipeline and destination is converted to shared-root state on successful publish. Shared-root execution writes schema version `3` state. It preserves unrelated owner records and outputs, updates only the publishing owner metadata, preserves root `created_at`, and updates root `updated_at` after successful state writes. Compatible single-owner state for the same pipeline and destination is converted to shared-root state on successful publish.
Forced replacement is explicit per request and deletes the bounded destination bundle path before writing new outputs and state. Forced replacement is explicit per request and deletes the bounded destination bundle path before writing new outputs and state. Valid managed conflict and newer-state replacements are normal managed replacement actions when transfer policy allows them; they are not forced replacement actions.
Retention pruning is not part of publish execution and does not run automatically after a successful publish. The app-level prune workflow uses destination state after publication to select managed outputs for deletion. Retention pruning is not part of publish execution and does not run automatically after a successful publish. The app-level prune workflow uses destination state after publication to select managed outputs for deletion.

View File

@@ -67,12 +67,14 @@ Published destination bundle paths contain `.distributor.json`. See [Destination
- No destination state and no destination content: publish new outputs. - No destination state and no destination content: publish new outputs.
- Matching destination state: skip as already published. - Matching destination state: skip as already published.
- Older destination state for the same source id: replace if transfer policy allows it. - Older destination state for the same source id: replace if transfer policy allows it.
- Newer destination state for the same source id: skip by default. - Newer destination state for the same source id: skip by default, or replace when `transfer.on_destination_newer: replace` is configured.
- Valid managed state with an identity, source, or shared-root output-owner mismatch: replace only when destination `takeover.mode` allows it. - Valid managed state with an identity, source, same-created digest, or shared-root output-owner mismatch: replace when destination `takeover.mode` allows it or, for remaining valid managed conflicts, when `transfer.on_conflict: replace` is configured.
- Invalid destination state, identity, source, or shared-root output-owner mismatches not allowed by `takeover.mode`, or same-created digest mismatch: fail by default. - Invalid destination state, identity, source, same-created digest, or shared-root output-owner mismatches not allowed by `takeover.mode` or `transfer.on_conflict: replace`: fail by default.
- Content without `.distributor.json`: fail as unmanaged content by default. - Content without `.distributor.json`: fail as unmanaged content by default.
When destination state is older than the source, `transfer.on_destination_older` controls whether publication may proceed and `reconciliation.mode` controls how managed outputs are updated. When destination state is older or newer than the source, `transfer.on_destination_older` and `transfer.on_destination_newer` control whether publication may proceed and `reconciliation.mode` controls how managed outputs are updated.
When `transfer.on_conflict: replace` permits `replace_conflict`, the destination is valid managed state but does not match the current source, pipeline, destination, or same-created digest. `replace_conflict` rewrites the affected managed destination state and does not retain omitted outputs through merge reconciliation.
For takeover replacement, `reconciliation.mode: merge` does not retain omitted outputs from the previous source identity. The destination is rewritten as a managed replacement for the current source or shared-root owner. For takeover replacement, `reconciliation.mode: merge` does not retain omitted outputs from the previous source identity. The destination is rewritten as a managed replacement for the current source or shared-root owner.
@@ -159,6 +161,8 @@ Review these action labels before publishing:
- `publish_new`: destination state is absent, or a shared-root owner is absent and planned paths are publishable. - `publish_new`: destination state is absent, or a shared-root owner is absent and planned paths are publishable.
- `replace_older`: destination state is older than the source. - `replace_older`: destination state is older than the source.
- `replace_newer`: destination state is newer than the source and `transfer.on_destination_newer: replace` allows managed replacement.
- `replace_conflict`: destination state is valid managed state with a conflict and `transfer.on_conflict: replace` allows managed replacement.
- `replace_takeover`: destination state is valid managed state and `takeover.mode` allows replacement across an identity, source, or shared-root output-owner mismatch. - `replace_takeover`: destination state is valid managed state and `takeover.mode` allows replacement across an identity, source, or shared-root output-owner mismatch.
- `skip_same`: destination state already matches the source. - `skip_same`: destination state already matches the source.
- `skip_destination_newer`: destination state is newer than the source and is skipped. - `skip_destination_newer`: destination state is newer than the source and is skipped.
@@ -167,7 +171,7 @@ Review these action labels before publishing:
Fixed destinations add fixed-path warnings during dry runs, including the selected source bundle and replacement warnings when the destination root would be replaced. Fixed destinations add fixed-path warnings during dry runs, including the selected source bundle and replacement warnings when the destination root would be replaced.
Text and JSON summaries count `publish_new`, `replace_older`, `replace_takeover`, `force_replace`, skipped, and failed destinations separately. Takeover action records include the configured takeover mode and the conflict reason. JSON output includes warnings, pipeline summaries, destination action records, output records, URLs when configured, final counters, and partial failure details. Fatal setup failures such as unreadable config or invalid secrets do not produce a JSON result document. Text and JSON summaries count `publish_new`, `replace_older`, `replace_conflict`, `replace_newer`, `replace_takeover`, `force_replace`, skipped, and failed destinations separately. Takeover action records include the configured takeover mode and the conflict reason. JSON output includes warnings, pipeline summaries, destination action records, output records, URLs when configured, final counters, and partial failure details. Fatal setup failures such as unreadable config or invalid secrets do not produce a JSON result document.
## Forced Replacement Workflow ## Forced Replacement Workflow
@@ -178,10 +182,7 @@ go run ./cmd/distributor run --config <config-path> --dry-run --force
go run ./cmd/distributor run --config <config-path> --force go run ./cmd/distributor run --config <config-path> --force
``` ```
Forced replacement can claim unmanaged non-empty destination paths. State conflicts require both `--force` and transfer policy that permits replacement: Forced replacement can claim unmanaged non-empty destination paths and is reserved for exceptional destructive replacement. Valid managed newer state and valid managed conflict state use `replace_newer` and `replace_conflict` when the corresponding transfer policy allows replacement; they do not require `--force`.
- newer destination state requires `transfer.on_destination_newer: replace`;
- conflict outcomes require `transfer.on_conflict: replace`.
Forced replacement deletes the current destination bundle path before writing outputs and state. It does not delete parent paths, sibling paths, or storage outside the destination bundle path. For fixed destinations, the destination bundle path is the backend root, so a forced replacement can clear that configured root. Forced replacement deletes the current destination bundle path before writing outputs and state. It does not delete parent paths, sibling paths, or storage outside the destination bundle path. For fixed destinations, the destination bundle path is the backend root, so a forced replacement can clear that configured root.

View File

@@ -169,9 +169,9 @@ Destination comparison rules are based on `.distributor.json`:
- No `.distributor.json`: publish normally only if the destination bundle path is empty. - No `.distributor.json`: publish normally only if the destination bundle path is empty.
- Existing state embeds the same normalized source manifest: skip as already published. - Existing state embeds the same normalized source manifest: skip as already published.
- Existing state has the same source id and an older source `created`: replace, subject to destructive-operation safety rules. - Existing state has the same source id and an older source `created`: replace, subject to destructive-operation safety rules.
- Existing state has the same source id and a newer source `created`: skip because the destination is newer than the source. - Existing state has the same source id and a newer source `created`: skip by default, or replace when `transfer.on_destination_newer: replace` is configured.
- Existing state has the same source id and same `created` but different digest: fail as a conflict. - Existing state has the same source id and same `created` but different digest: fail by default, or replace as valid managed conflict state when `transfer.on_conflict: replace` is configured.
- Existing valid managed state has a different source id, pipeline id, destination id, or shared-root output owner: replace only when destination `takeover.mode` permits that ownership transfer; otherwise fail as a conflict. - Existing valid managed state has a different source id, pipeline id, destination id, or shared-root output owner: replace when destination `takeover.mode` permits that ownership transfer, or replace as valid managed conflict state when `transfer.on_conflict: replace` is configured; otherwise fail as a conflict.
For older destination state, destination `reconciliation.mode` controls output updates. `replace` rewrites the managed output set to match the new plan. `merge` retains omitted managed outputs, overwrites only existing managed paths, and rejects planned paths that collide with unmanaged storage content. For older destination state, destination `reconciliation.mode` controls output updates. `replace` rewrites the managed output set to match the new plan. `merge` retains omitted managed outputs, overwrites only existing managed paths, and rejects planned paths that collide with unmanaged storage content.

View File

@@ -205,7 +205,7 @@ Reference: [Operations](operations.md#forced-replacement-workflow).
Symptom: `fail_conflict`, `destination source id differs`, `same id and created time but different digest`, `pipeline id ... does not match`, or `destination id ... does not match`. Symptom: `fail_conflict`, `destination source id differs`, `same id and created time but different digest`, `pipeline id ... does not match`, or `destination id ... does not match`.
Likely cause: `.distributor.json` belongs to a different pipeline, destination, source id, shared-root output owner, or same-created source with different content. Valid managed identity, source, and shared-root output-owner mismatches can publish as `replace_takeover` only when destination `takeover.mode` allows them. Likely cause: `.distributor.json` belongs to a different pipeline, destination, source id, shared-root output owner, or same-created source with different content. Valid managed identity, source, and shared-root output-owner mismatches can publish as `replace_takeover` when destination `takeover.mode` allows them, or as `replace_conflict` when `transfer.on_conflict: replace` allows managed conflict replacement.
Diagnostic: Diagnostic:
@@ -214,7 +214,7 @@ cat <destination-path>/.distributor.json
go run ./cmd/distributor inspect <source-root> go run ./cmd/distributor inspect <source-root>
``` ```
Safe fix: verify the source and destination are intended to match. Use a separate destination path for unrelated content. For normal managed replacement, configure destination `takeover.mode` to match the intended ownership boundary. To force exceptional replacement, configure `transfer.on_conflict: replace`, preview with `--dry-run --force`, then publish with `--force`. Safe fix: verify the source and destination are intended to match. Use a separate destination path for unrelated content. For normal managed replacement, configure destination `takeover.mode` to match the intended ownership boundary or configure `transfer.on_conflict: replace`, then preview with `--dry-run`. Use `--force` only for exceptional replacement of unmanaged content or other force-only cases reported as `force_replace`.
Reference: [Operations](operations.md#destination-state-and-retry-behavior). Reference: [Operations](operations.md#destination-state-and-retry-behavior).
@@ -262,7 +262,7 @@ Reference: [Operations](operations.md#managed-output-pruning).
## Destination Is Newer Than Source ## Destination Is Newer Than Source
Symptom: `skip_destination_newer` or `destination is newer and replacement requires --force`. Symptom: `skip_destination_newer`.
Likely cause: the destination state records a source manifest with a later `created` timestamp than the current source. Likely cause: the destination state records a source manifest with a later `created` timestamp than the current source.
@@ -272,7 +272,7 @@ Diagnostic:
go run ./cmd/distributor run --config <config-path> --dry-run --format json go run ./cmd/distributor run --config <config-path> --dry-run --format json
``` ```
Safe fix: keep the default skip behavior unless replacement is intentional. To replace newer state, configure `transfer.on_destination_newer: replace`, preview with `--dry-run --force`, then publish with `--force`. Safe fix: keep the default skip behavior unless replacement is intentional. To replace newer valid managed state, configure `transfer.on_destination_newer: replace`, preview with `--dry-run`, then publish without `--force`.
Reference: [Operations](operations.md#forced-replacement-workflow). Reference: [Operations](operations.md#forced-replacement-workflow).

View File

@@ -6,7 +6,7 @@ import (
) )
func shouldNotify(action publish.Action) bool { func shouldNotify(action publish.Action) bool {
return action == publish.ActionPublishNew || action == publish.ActionReplaceOlder || action == publish.ActionReplaceTakeover || action == publish.ActionForceReplace return action == publish.ActionPublishNew || action == publish.ActionReplaceOlder || action == publish.ActionReplaceConflict || action == publish.ActionReplaceNewer || action == publish.ActionReplaceTakeover || action == publish.ActionForceReplace
} }
func notifyEvent(plan publish.Plan) notify.Event { func notifyEvent(plan publish.Plan) notify.Event {

View File

@@ -63,7 +63,7 @@ func fixedPathSelectionWarning(pipelineID, destinationID string, selections []de
} }
func isDestructiveFixedPathAction(action publish.Action) bool { func isDestructiveFixedPathAction(action publish.Action) bool {
return action == publish.ActionReplaceOlder || action == publish.ActionReplaceTakeover || action == publish.ActionForceReplace return action == publish.ActionReplaceOlder || action == publish.ActionReplaceConflict || action == publish.ActionReplaceNewer || action == publish.ActionReplaceTakeover || action == publish.ActionForceReplace
} }
func fixedPathReplacementWarning(plan publish.Plan) OutputWarning { func fixedPathReplacementWarning(plan publish.Plan) OutputWarning {

View File

@@ -11,6 +11,8 @@ type runSummary struct {
planned int planned int
publishNew int publishNew int
replaceOlder int replaceOlder int
replaceConflict int
replaceNewer int
replaceTakeover int replaceTakeover int
forceReplace int forceReplace int
skipped int skipped int
@@ -25,6 +27,10 @@ func (s *runSummary) recordPlan(action publish.Action) {
s.publishNew++ s.publishNew++
case publish.ActionReplaceOlder: case publish.ActionReplaceOlder:
s.replaceOlder++ s.replaceOlder++
case publish.ActionReplaceConflict:
s.replaceConflict++
case publish.ActionReplaceNewer:
s.replaceNewer++
case publish.ActionReplaceTakeover: case publish.ActionReplaceTakeover:
s.replaceTakeover++ s.replaceTakeover++
case publish.ActionForceReplace: case publish.ActionForceReplace:
@@ -47,6 +53,8 @@ type RunSummaryCounters struct {
Planned int `json:"planned"` Planned int `json:"planned"`
PublishNew int `json:"publish_new"` PublishNew int `json:"publish_new"`
ReplaceOlder int `json:"replace_older"` ReplaceOlder int `json:"replace_older"`
ReplaceConflict int `json:"replace_conflict"`
ReplaceNewer int `json:"replace_newer"`
ReplaceTakeover int `json:"replace_takeover"` ReplaceTakeover int `json:"replace_takeover"`
ForceReplace int `json:"force_replace"` ForceReplace int `json:"force_replace"`
Skipped int `json:"skipped"` Skipped int `json:"skipped"`
@@ -56,7 +64,7 @@ type RunSummaryCounters struct {
} }
func (s RunSummaryCounters) Line() string { func (s RunSummaryCounters) Line() string {
return fmt.Sprintf("Final status: %s planned=%d publish_new=%d replace_older=%d replace_takeover=%d force_replace=%d skipped=%d failed=%d dry_run=%t fixed_path=%d", s.Status, s.Planned, s.PublishNew, s.ReplaceOlder, s.ReplaceTakeover, s.ForceReplace, s.Skipped, s.Failed, s.DryRun, s.FixedPath) return fmt.Sprintf("Final status: %s planned=%d publish_new=%d replace_older=%d replace_conflict=%d replace_newer=%d replace_takeover=%d force_replace=%d skipped=%d failed=%d dry_run=%t fixed_path=%d", s.Status, s.Planned, s.PublishNew, s.ReplaceOlder, s.ReplaceConflict, s.ReplaceNewer, s.ReplaceTakeover, s.ForceReplace, s.Skipped, s.Failed, s.DryRun, s.FixedPath)
} }
func (s runSummary) Result() RunSummaryCounters { func (s runSummary) Result() RunSummaryCounters {
@@ -69,6 +77,8 @@ func (s runSummary) Result() RunSummaryCounters {
Planned: s.planned, Planned: s.planned,
PublishNew: s.publishNew, PublishNew: s.publishNew,
ReplaceOlder: s.replaceOlder, ReplaceOlder: s.replaceOlder,
ReplaceConflict: s.replaceConflict,
ReplaceNewer: s.replaceNewer,
ReplaceTakeover: s.replaceTakeover, ReplaceTakeover: s.replaceTakeover,
ForceReplace: s.forceReplace, ForceReplace: s.forceReplace,
Skipped: s.skipped, Skipped: s.skipped,

View File

@@ -43,7 +43,7 @@ func TestRunDryRunPrintsConfigSummary(t *testing.T) {
"Configured pipelines: 1", "Configured pipelines: 1",
"- pipeline=reports source=local bundles=1 destinations=archive", "- pipeline=reports source=local bundles=1 destinations=archive",
"bundle=. destination=archive backend=local action=publish_new outputs=report.md,summary.txt", "bundle=. destination=archive backend=local action=publish_new outputs=report.md,summary.txt",
"Final status: ok planned=1 publish_new=1 replace_older=0 replace_takeover=0 force_replace=0 skipped=0 failed=0 dry_run=true", "Final status: ok planned=1 publish_new=1 replace_older=0 replace_conflict=0 replace_newer=0 replace_takeover=0 force_replace=0 skipped=0 failed=0 dry_run=true",
} { } {
if !strings.Contains(output, want) { if !strings.Contains(output, want) {
t.Fatalf("Run() output = %q, want substring %q", output, want) t.Fatalf("Run() output = %q, want substring %q", output, want)
@@ -1358,7 +1358,7 @@ func TestRunContinuesAfterDestinationFailure(t *testing.T) {
for _, want := range []string{ for _, want := range []string{
"destination=archive-one backend=local action=error", "destination=archive-one backend=local action=error",
"destination=archive-two backend=local action=publish_new", "destination=archive-two backend=local action=publish_new",
"Final status: failed planned=1 publish_new=1 replace_older=0 replace_takeover=0 force_replace=0 skipped=0 failed=1 dry_run=false", "Final status: failed planned=1 publish_new=1 replace_older=0 replace_conflict=0 replace_newer=0 replace_takeover=0 force_replace=0 skipped=0 failed=1 dry_run=false",
} { } {
if !strings.Contains(output, want) { if !strings.Contains(output, want) {
t.Fatalf("stdout = %q, want substring %q", output, want) t.Fatalf("stdout = %q, want substring %q", output, want)
@@ -1663,6 +1663,109 @@ func TestRunSkipsNewerDestination(t *testing.T) {
testutil.AssertFile(t, filepath.Join(destinationRoot, "report.md"), "newer\n") testutil.AssertFile(t, filepath.Join(destinationRoot, "report.md"), "newer\n")
} }
func TestRunReplacesConflictWhenTransferPolicyAllows(t *testing.T) {
sourceRoot := t.TempDir()
destinationRoot := t.TempDir()
manifest := writeSourceBundle(t, sourceRoot, "", testBundleOptions{})
conflict := manifest
conflict.ID = "other.source"
writeDestinationState(t, destinationRoot, "", conflict)
if err := os.WriteFile(filepath.Join(destinationRoot, "report.md"), []byte("old\n"), 0o600); err != nil {
t.Fatalf("write old output: %v", err)
}
configPath := writeConfigFile(t, `
pipelines:
- id: reports
source:
backend: local
path: `+sourceRoot+`
destinations:
- id: archive
backend: local
path: `+destinationRoot+`
takeover:
mode: never
transfer:
on_conflict: replace
`)
var stdout bytes.Buffer
err := Run(context.Background(), RunOptions{ConfigPath: configPath, DryRun: true, Stdout: &stdout})
if err != nil {
t.Fatalf("Run() error = %v", err)
}
output := stdout.String()
for _, want := range []string{
"action=replace_conflict",
"replace_conflict=1",
"force_replace=0",
} {
if !strings.Contains(output, want) {
t.Fatalf("stdout = %q, want substring %q", output, want)
}
}
var jsonOut bytes.Buffer
err = Run(context.Background(), RunOptions{ConfigPath: configPath, DryRun: true, Stdout: &jsonOut, OutputFormat: OutputFormatJSON})
if err != nil {
t.Fatalf("Run() JSON error = %v", err)
}
result := decodeAppResult(t, jsonOut.String())
actions, ok := result["actions"].([]any)
if !ok || len(actions) != 1 {
t.Fatalf("actions = %#v, want one action", result["actions"])
}
action, ok := actions[0].(map[string]any)
if !ok || action["action"] != "replace_conflict" {
t.Fatalf("action = %#v, want replace_conflict", actions[0])
}
summary, ok := result["summary"].(map[string]any)
if !ok || summary["replace_conflict"] != float64(1) || summary["force_replace"] != float64(0) {
t.Fatalf("summary = %#v, want replace_conflict without force", result["summary"])
}
}
func TestRunReplacesNewerWhenTransferPolicyAllows(t *testing.T) {
sourceRoot := t.TempDir()
destinationRoot := t.TempDir()
manifest := writeSourceBundle(t, sourceRoot, "", testBundleOptions{})
newer := manifest
newer.Created = newer.Created.Add(time.Hour)
writeDestinationState(t, destinationRoot, "", newer)
if err := os.WriteFile(filepath.Join(destinationRoot, "report.md"), []byte("newer\n"), 0o600); err != nil {
t.Fatalf("write newer output: %v", err)
}
configPath := writeConfigFile(t, `
pipelines:
- id: reports
source:
backend: local
path: `+sourceRoot+`
destinations:
- id: archive
backend: local
path: `+destinationRoot+`
transfer:
on_destination_newer: replace
`)
var stdout bytes.Buffer
err := Run(context.Background(), RunOptions{ConfigPath: configPath, DryRun: true, Stdout: &stdout})
if err != nil {
t.Fatalf("Run() error = %v", err)
}
output := stdout.String()
for _, want := range []string{
"action=replace_newer",
"replace_newer=1",
"force_replace=0",
} {
if !strings.Contains(output, want) {
t.Fatalf("stdout = %q, want substring %q", output, want)
}
}
}
func TestRunTakeoverNeverFailsOnConflict(t *testing.T) { func TestRunTakeoverNeverFailsOnConflict(t *testing.T) {
sourceRoot := t.TempDir() sourceRoot := t.TempDir()
destinationRoot := t.TempDir() destinationRoot := t.TempDir()
@@ -1799,7 +1902,7 @@ func TestRunExercisesRemoteBackendShapesThroughCommonPath(t *testing.T) {
"pipeline=local-to-ssh source=local", "pipeline=local-to-ssh source=local",
"destination=ssh-archive backend=ssh action=publish_new", "destination=ssh-archive backend=ssh action=publish_new",
"pipeline=ssh-to-local source=ssh", "pipeline=ssh-to-local source=ssh",
"Final status: ok planned=4 publish_new=4 replace_older=0 replace_takeover=0 force_replace=0 skipped=0 failed=0 dry_run=true", "Final status: ok planned=4 publish_new=4 replace_older=0 replace_conflict=0 replace_newer=0 replace_takeover=0 force_replace=0 skipped=0 failed=0 dry_run=true",
} { } {
if !strings.Contains(dryRunOutput.String(), want) { if !strings.Contains(dryRunOutput.String(), want) {
t.Fatalf("dry-run output = %q, want substring %q", dryRunOutput.String(), want) t.Fatalf("dry-run output = %q, want substring %q", dryRunOutput.String(), want)

View File

@@ -635,7 +635,7 @@ func TestExecuteRunDryRun(t *testing.T) {
wantStdout := "Configured pipelines: 1\n" + wantStdout := "Configured pipelines: 1\n" +
"- pipeline=reports source=local bundles=1 destinations=archive\n" + "- pipeline=reports source=local bundles=1 destinations=archive\n" +
" - bundle=. destination=archive backend=local action=publish_new outputs=report.md,summary.txt reason=\"destination state is absent\"\n" + " - bundle=. destination=archive backend=local action=publish_new outputs=report.md,summary.txt reason=\"destination state is absent\"\n" +
"Final status: ok planned=1 publish_new=1 replace_older=0 replace_takeover=0 force_replace=0 skipped=0 failed=0 dry_run=true fixed_path=0\n" "Final status: ok planned=1 publish_new=1 replace_older=0 replace_conflict=0 replace_newer=0 replace_takeover=0 force_replace=0 skipped=0 failed=0 dry_run=true fixed_path=0\n"
if got := stdout.String(); got != wantStdout { if got := stdout.String(); got != wantStdout {
t.Fatalf("stdout = %q, want %q", got, wantStdout) t.Fatalf("stdout = %q, want %q", got, wantStdout)
} }

View File

@@ -16,7 +16,7 @@ func Execute(ctx context.Context, req Request, plan Plan) error {
switch plan.Action { switch plan.Action {
case ActionSkipSame, ActionSkipDestinationNewer: case ActionSkipSame, ActionSkipDestinationNewer:
return nil return nil
case ActionPublishNew, ActionReplaceOlder, ActionReplaceTakeover, ActionForceReplace: case ActionPublishNew, ActionReplaceOlder, ActionReplaceConflict, ActionReplaceNewer, ActionReplaceTakeover, ActionForceReplace:
if usesSharedRootState(req, plan) { if usesSharedRootState(req, plan) {
return executeSharedRoot(ctx, req, plan) return executeSharedRoot(ctx, req, plan)
} }
@@ -24,11 +24,11 @@ func Execute(ctx context.Context, req Request, plan Plan) error {
return fmt.Errorf("cannot execute action %s: %s", plan.Action, plan.Reason) return fmt.Errorf("cannot execute action %s: %s", plan.Action, plan.Reason)
} }
if plan.Action == ActionReplaceOlder || plan.Action == ActionReplaceTakeover { if plan.Action == ActionReplaceOlder || plan.Action == ActionReplaceConflict || plan.Action == ActionReplaceNewer || plan.Action == ActionReplaceTakeover {
if plan.ExistingState == nil { if plan.ExistingState == nil {
return fmt.Errorf("replace requires existing destination state") return fmt.Errorf("replace requires existing destination state")
} }
if plan.Reconciliation.Mode == config.ReconciliationModeReplace || plan.Action == ActionReplaceTakeover { if plan.Reconciliation.Mode == config.ReconciliationModeReplace || plan.Action == ActionReplaceConflict || plan.Action == ActionReplaceTakeover {
if err := req.DestinationBackend.DeleteManagedBundle(ctx, req.DestinationBundlePath, state.ManagedOutputPaths(*plan.ExistingState), storage.DeleteOptions{IgnoreMissing: true, PruneEmptyDirs: true}); err != nil { if err := req.DestinationBackend.DeleteManagedBundle(ctx, req.DestinationBundlePath, state.ManagedOutputPaths(*plan.ExistingState), storage.DeleteOptions{IgnoreMissing: true, PruneEmptyDirs: true}); err != nil {
return err return err
} }
@@ -148,7 +148,7 @@ func executeSharedRoot(ctx context.Context, req Request, plan Plan) error {
return err return err
} }
} }
if plan.Action == ActionReplaceTakeover || (plan.Action == ActionReplaceOlder && plan.Reconciliation.Mode == config.ReconciliationModeReplace) { if plan.Action == ActionReplaceTakeover || plan.Action == ActionReplaceConflict || (isReconciliationReplacementAction(plan.Action) && plan.Reconciliation.Mode == config.ReconciliationModeReplace) {
if err := req.DestinationBackend.DeleteManagedOutputs(ctx, req.DestinationBundlePath, sharedRootOutputPaths(plan.OwnerOutputsToDelete), storage.DeleteOptions{IgnoreMissing: true, PruneEmptyDirs: true}); err != nil { if err := req.DestinationBackend.DeleteManagedOutputs(ctx, req.DestinationBundlePath, sharedRootOutputPaths(plan.OwnerOutputsToDelete), storage.DeleteOptions{IgnoreMissing: true, PruneEmptyDirs: true}); err != nil {
return err return err
} }
@@ -158,7 +158,7 @@ func executeSharedRoot(ctx context.Context, req Request, plan Plan) error {
newOutputs := make([]Output, 0, len(plan.Outputs)) newOutputs := make([]Output, 0, len(plan.Outputs))
cleanup := func() { cleanup := func() {
outputs := writtenOutputs outputs := writtenOutputs
if plan.Action == ActionReplaceOlder && plan.Reconciliation.Mode == config.ReconciliationModeMerge { if isReconciliationReplacementAction(plan.Action) && plan.Reconciliation.Mode == config.ReconciliationModeMerge {
outputs = newOutputs outputs = newOutputs
} }
_ = req.DestinationBackend.DeleteManagedOutputs(ctx, req.DestinationBundlePath, ManagedOutputPaths(outputs), storage.DeleteOptions{IgnoreMissing: true, PruneEmptyDirs: true}) _ = req.DestinationBackend.DeleteManagedOutputs(ctx, req.DestinationBundlePath, ManagedOutputPaths(outputs), storage.DeleteOptions{IgnoreMissing: true, PruneEmptyDirs: true})
@@ -278,7 +278,7 @@ func stateOutputsForPlan(plan Plan, now time.Time) ([]state.OutputFile, error) {
} }
func usesMergeRetention(plan Plan) bool { func usesMergeRetention(plan Plan) bool {
return plan.Reconciliation.Mode == config.ReconciliationModeMerge && plan.Action == ActionReplaceOlder return plan.Reconciliation.Mode == config.ReconciliationModeMerge && isReconciliationReplacementAction(plan.Action)
} }
func sharedRootStateForPlan(req Request, plan Plan, now time.Time) (state.SharedRootState, error) { func sharedRootStateForPlan(req Request, plan Plan, now time.Time) (state.SharedRootState, error) {
@@ -298,7 +298,7 @@ func sharedRootStateForPlan(req Request, plan Plan, now time.Time) (state.Shared
owner.Links = &state.LinkState{PrimaryURL: plan.PrimaryURL} owner.Links = &state.LinkState{PrimaryURL: plan.PrimaryURL}
} }
planned := state.ProjectSharedRootOutputs(StateOutputProjections(plan.Outputs), currentOwnerSharedRootOutputs(plan), scope, req.SourceBundle.Manifest, now) planned := state.ProjectSharedRootOutputs(StateOutputProjections(plan.Outputs), currentOwnerSharedRootOutputs(plan), scope, req.SourceBundle.Manifest, now)
if plan.Action == ActionReplaceOlder && plan.Reconciliation.Mode == config.ReconciliationModeMerge { if isReconciliationReplacementAction(plan.Action) && plan.Reconciliation.Mode == config.ReconciliationModeMerge {
return state.MergeOwnerOutputs(base, scope, owner, planned) return state.MergeOwnerOutputs(base, scope, owner, planned)
} }
return state.ReplaceOwnerOutputs(base, scope, owner, planned) return state.ReplaceOwnerOutputs(base, scope, owner, planned)

View File

@@ -120,6 +120,71 @@ func TestExecuteMergeRetainsOmittedAndOverwritesManagedOutputs(t *testing.T) {
} }
} }
func TestExecuteReplaceConflictDeletesOmittedManagedOutputs(t *testing.T) {
sourceBackend := fake.New()
sourceBundle := testutil.WriteFakeSourceBundle(t, sourceBackend, "", testutil.BundleOptions{
Files: []testutil.SourceFile{{Path: "report.md", Data: "# Report\nNew.\n"}},
})
destinationBackend := fake.New()
conflict := sourceBundle.Manifest
conflict.ID = "other.source"
conflict.Files = append([]bundle.ManifestFile(nil), testutil.ValidManifest(testutil.BundleOptions{}).Files...)
conflict.Digest = bundle.BundleDigest(conflict.Files)
testutil.WriteFakeDestinationState(t, destinationBackend, "", conflict, testutil.DestinationStateOptions{})
req := testRequest(sourceBackend, destinationBackend, sourceBundle, config.ReconciliationModeMerge)
req.Takeover = config.TakeoverPolicy{Mode: config.TakeoverModeNever}
req.Transfer.OnConflict = config.TransferActionReplace
plan, err := Build(context.Background(), req)
if err != nil {
t.Fatalf("Build() error = %v", err)
}
if plan.Action != ActionReplaceConflict {
t.Fatalf("plan action = %s, want replace_conflict", plan.Action)
}
if err := Execute(context.Background(), req, plan); err != nil {
t.Fatalf("Execute() error = %v", err)
}
testutil.AssertFakeFile(t, destinationBackend, "report.md", "# Report\nNew.\n")
testutil.AssertFakeMissing(t, destinationBackend, "summary.txt")
destinationState := readFakeState(t, destinationBackend, "")
outputs := outputsByPath(destinationState.Outputs)
if got, want := len(outputs), 1; got != want {
t.Fatalf("state output count = %d, want %d", got, want)
}
if _, ok := outputs["summary.txt"]; ok {
t.Fatalf("state retained summary.txt after replace_conflict: %#v", destinationState.Outputs)
}
}
func TestExecuteReplaceNewerUsesManagedReplacement(t *testing.T) {
sourceBackend := fake.New()
sourceBundle := testutil.WriteFakeSourceBundle(t, sourceBackend, "", testutil.BundleOptions{
Files: []testutil.SourceFile{{Path: "report.md", Data: "# Report\nNew.\n"}},
})
destinationBackend := fake.New()
newer := sourceBundle.Manifest
newer.Created = newer.Created.Add(time.Hour)
newer.Files = append([]bundle.ManifestFile(nil), testutil.ValidManifest(testutil.BundleOptions{}).Files...)
newer.Digest = bundle.BundleDigest(newer.Files)
testutil.WriteFakeDestinationState(t, destinationBackend, "", newer, testutil.DestinationStateOptions{})
req := testRequest(sourceBackend, destinationBackend, sourceBundle, config.ReconciliationModeReplace)
req.Transfer.OnDestinationNewer = config.TransferActionReplace
plan, err := Build(context.Background(), req)
if err != nil {
t.Fatalf("Build() error = %v", err)
}
if plan.Action != ActionReplaceNewer {
t.Fatalf("plan action = %s, want replace_newer", plan.Action)
}
if err := Execute(context.Background(), req, plan); err != nil {
t.Fatalf("Execute() error = %v", err)
}
testutil.AssertFakeFile(t, destinationBackend, "report.md", "# Report\nNew.\n")
testutil.AssertFakeMissing(t, destinationBackend, "summary.txt")
}
func TestExecuteMergeFailsOnUnmanagedDestinationPathCollision(t *testing.T) { func TestExecuteMergeFailsOnUnmanagedDestinationPathCollision(t *testing.T) {
sourceBackend := fake.New() sourceBackend := fake.New()
sourceBundle := testutil.WriteFakeSourceBundle(t, sourceBackend, "", testutil.BundleOptions{ sourceBundle := testutil.WriteFakeSourceBundle(t, sourceBackend, "", testutil.BundleOptions{

View File

@@ -29,61 +29,6 @@ func TestBuildPlansForcedReplacementOnlyWhenExplicit(t *testing.T) {
wantReason: "fail_unmanaged", wantReason: "fail_unmanaged",
forceAction: true, forceAction: true,
}, },
{
name: "different source id",
prepare: func(t *testing.T, backend *fake.Backend, source bundle.Manifest) {
t.Helper()
conflict := source
conflict.ID = "other.source"
testutil.WriteFakeDestinationState(t, backend, "bundle", conflict, testutil.DestinationStateOptions{})
},
transfer: conflictReplaceTransfer(),
wantReason: "requires --force",
forceAction: true,
},
{
name: "same created digest conflict",
prepare: func(t *testing.T, backend *fake.Backend, source bundle.Manifest) {
t.Helper()
conflict := testutil.ValidManifest(testutil.BundleOptions{Files: []testutil.SourceFile{{Path: "report.md", Data: "# Different\n"}}})
testutil.WriteFakeDestinationState(t, backend, "bundle", conflict, testutil.DestinationStateOptions{})
},
transfer: conflictReplaceTransfer(),
wantReason: "requires --force",
forceAction: true,
},
{
name: "pipeline mismatch",
prepare: func(t *testing.T, backend *fake.Backend, source bundle.Manifest) {
t.Helper()
testutil.WriteFakeDestinationState(t, backend, "bundle", source, testutil.DestinationStateOptions{PipelineID: "other-pipeline"})
},
transfer: conflictReplaceTransfer(),
wantReason: "requires --force",
forceAction: true,
},
{
name: "destination mismatch",
prepare: func(t *testing.T, backend *fake.Backend, source bundle.Manifest) {
t.Helper()
testutil.WriteFakeDestinationState(t, backend, "bundle", source, testutil.DestinationStateOptions{DestinationID: "other-destination"})
},
transfer: conflictReplaceTransfer(),
wantReason: "requires --force",
forceAction: true,
},
{
name: "newer destination",
prepare: func(t *testing.T, backend *fake.Backend, source bundle.Manifest) {
t.Helper()
newer := source
newer.Created = newer.Created.AddDate(0, 0, 1)
testutil.WriteFakeDestinationState(t, backend, "bundle", newer, testutil.DestinationStateOptions{})
},
transfer: newerReplaceTransfer(),
wantReason: "requires --force",
forceAction: true,
},
} }
for _, tt := range tests { for _, tt := range tests {
@@ -113,6 +58,89 @@ func TestBuildPlansForcedReplacementOnlyWhenExplicit(t *testing.T) {
} }
} }
func TestBuildPlansConflictReplacementWithoutForce(t *testing.T) {
tests := []struct {
name string
prepare func(t *testing.T, backend *fake.Backend, source bundle.Manifest)
wantReason string
}{
{
name: "different source id",
prepare: func(t *testing.T, backend *fake.Backend, source bundle.Manifest) {
t.Helper()
conflict := source
conflict.ID = "other.source"
testutil.WriteFakeDestinationState(t, backend, "bundle", conflict, testutil.DestinationStateOptions{})
},
wantReason: "destination source id differs",
},
{
name: "same created digest conflict",
prepare: func(t *testing.T, backend *fake.Backend, source bundle.Manifest) {
t.Helper()
conflict := testutil.ValidManifest(testutil.BundleOptions{Files: []testutil.SourceFile{{Path: "report.md", Data: "# Different\n"}}})
testutil.WriteFakeDestinationState(t, backend, "bundle", conflict, testutil.DestinationStateOptions{})
},
wantReason: "same id and created time but different digest",
},
{
name: "pipeline mismatch",
prepare: func(t *testing.T, backend *fake.Backend, source bundle.Manifest) {
t.Helper()
testutil.WriteFakeDestinationState(t, backend, "bundle", source, testutil.DestinationStateOptions{PipelineID: "other-pipeline"})
},
wantReason: "pipeline id",
},
{
name: "destination mismatch",
prepare: func(t *testing.T, backend *fake.Backend, source bundle.Manifest) {
t.Helper()
testutil.WriteFakeDestinationState(t, backend, "bundle", source, testutil.DestinationStateOptions{DestinationID: "other-destination"})
},
wantReason: "destination id",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
sourceBackend := fake.New()
sourceBundle := testutil.WriteFakeSourceBundle(t, sourceBackend, "bundle", testutil.BundleOptions{})
destinationBackend := fake.New()
tt.prepare(t, destinationBackend, sourceBundle.Manifest)
req := forceRequest(sourceBackend, destinationBackend, sourceBundle, conflictReplaceTransfer())
plan, err := Build(context.Background(), req)
if err != nil {
t.Fatalf("Build() error = %v", err)
}
if plan.Action != ActionReplaceConflict || plan.Force {
t.Fatalf("plan action = %s force=%t, want replace_conflict without force", plan.Action, plan.Force)
}
if !strings.Contains(plan.Reason, tt.wantReason) {
t.Fatalf("plan reason = %q, want %q", plan.Reason, tt.wantReason)
}
})
}
}
func TestBuildPlansNewerReplacementWithoutForce(t *testing.T) {
sourceBackend := fake.New()
sourceBundle := testutil.WriteFakeSourceBundle(t, sourceBackend, "bundle", testutil.BundleOptions{})
destinationBackend := fake.New()
newer := sourceBundle.Manifest
newer.Created = newer.Created.AddDate(0, 0, 1)
testutil.WriteFakeDestinationState(t, destinationBackend, "bundle", newer, testutil.DestinationStateOptions{})
req := forceRequest(sourceBackend, destinationBackend, sourceBundle, newerReplaceTransfer())
plan, err := Build(context.Background(), req)
if err != nil {
t.Fatalf("Build() error = %v", err)
}
if plan.Action != ActionReplaceNewer || plan.Force {
t.Fatalf("plan action = %s force=%t, want replace_newer without force", plan.Action, plan.Force)
}
}
func TestBuildRequiresConflictPolicyForStateConflicts(t *testing.T) { func TestBuildRequiresConflictPolicyForStateConflicts(t *testing.T) {
sourceBackend := fake.New() sourceBackend := fake.New()
sourceBundle := testutil.WriteFakeSourceBundle(t, sourceBackend, "bundle", testutil.BundleOptions{}) sourceBundle := testutil.WriteFakeSourceBundle(t, sourceBackend, "bundle", testutil.BundleOptions{})

View File

@@ -16,6 +16,8 @@ type Action string
const ( const (
ActionPublishNew Action = "publish_new" ActionPublishNew Action = "publish_new"
ActionReplaceOlder Action = "replace_older" ActionReplaceOlder Action = "replace_older"
ActionReplaceConflict Action = "replace_conflict"
ActionReplaceNewer Action = "replace_newer"
ActionSkipSame Action = "skip_same" ActionSkipSame Action = "skip_same"
ActionSkipDestinationNewer Action = "skip_destination_newer" ActionSkipDestinationNewer Action = "skip_destination_newer"
ActionFailConflict Action = "fail_conflict" ActionFailConflict Action = "fail_conflict"
@@ -269,16 +271,21 @@ func planSharedRootOwner(ctx context.Context, req Request, status state.Destinat
} }
conflicts := sharedRootPathOwnershipConflicts(status, scope, plannedPaths) conflicts := sharedRootPathOwnershipConflicts(status, scope, plannedPaths)
if len(conflicts) > 0 { if len(conflicts) > 0 {
conflictAction := ActionReplaceTakeover
for _, conflict := range conflicts { for _, conflict := range conflicts {
if sharedRootTakeoverAllowed(req, status, conflict) { if sharedRootTakeoverAllowed(req, status, conflict) {
continue continue
} }
if req.Transfer.OnConflict == config.TransferActionReplace {
conflictAction = ActionReplaceConflict
continue
}
reason := sharedRootOwnershipConflictReason(conflict) reason := sharedRootOwnershipConflictReason(conflict)
details.Action = ActionFailConflict details.Action = ActionFailConflict
details.Reason = reason details.Reason = reason
return details, fmt.Errorf("%s: %s", ActionFailConflict, reason) return details, fmt.Errorf("%s: %s", ActionFailConflict, reason)
} }
details.Action = ActionReplaceTakeover details.Action = conflictAction
details.Reason = sharedRootOwnershipConflictReason(conflicts[0]) details.Reason = sharedRootOwnershipConflictReason(conflicts[0])
details.TakenOverOwnerOutputs = sharedRootConflictOutputs(status.SharedRoot, conflicts) details.TakenOverOwnerOutputs = sharedRootConflictOutputs(status.SharedRoot, conflicts)
} }
@@ -299,11 +306,11 @@ func planSharedRootOwner(ctx context.Context, req Request, status state.Destinat
if _, exists := planned[output.Path]; exists { if _, exists := planned[output.Path]; exists {
continue continue
} }
if details.Action == ActionReplaceTakeover || (details.Action == ActionReplaceOlder && reconciliation.Mode == config.ReconciliationModeReplace) { if details.Action == ActionReplaceTakeover || details.Action == ActionReplaceConflict || (isReconciliationReplacementAction(details.Action) && reconciliation.Mode == config.ReconciliationModeReplace) {
details.OwnerOutputsToDelete = append(details.OwnerOutputsToDelete, output) details.OwnerOutputsToDelete = append(details.OwnerOutputsToDelete, output)
continue continue
} }
if details.Action == ActionReplaceOlder && reconciliation.Mode == config.ReconciliationModeMerge { if isReconciliationReplacementAction(details.Action) && reconciliation.Mode == config.ReconciliationModeMerge {
details.RetainedOwnerOutputs = append(details.RetainedOwnerOutputs, output) details.RetainedOwnerOutputs = append(details.RetainedOwnerOutputs, output)
} }
} }
@@ -313,7 +320,7 @@ func planSharedRootOwner(ctx context.Context, req Request, status state.Destinat
func isWriteAction(action Action) bool { func isWriteAction(action Action) bool {
switch action { switch action {
case ActionPublishNew, ActionReplaceOlder, ActionReplaceTakeover, ActionForceReplace: case ActionPublishNew, ActionReplaceOlder, ActionReplaceConflict, ActionReplaceNewer, ActionReplaceTakeover, ActionForceReplace:
return true return true
default: default:
return false return false
@@ -506,10 +513,7 @@ func actionForComparison(comparison state.Comparison, transfer config.TransferPo
return ActionFailConflict, comparison.Reason return ActionFailConflict, comparison.Reason
case state.OutcomeIdentityMismatch, state.OutcomeSameCreatedConflict, state.OutcomeDifferentSourceConflict: case state.OutcomeIdentityMismatch, state.OutcomeSameCreatedConflict, state.OutcomeDifferentSourceConflict:
if transfer.OnConflict == config.TransferActionReplace { if transfer.OnConflict == config.TransferActionReplace {
if force { return ActionReplaceConflict, comparison.Reason
return ActionForceReplace, "forced replacement of conflicting destination state: " + comparison.Reason
}
return ActionFailConflict, "destination conflict replacement requires --force"
} }
return ActionFailConflict, comparison.Reason return ActionFailConflict, comparison.Reason
case state.OutcomeSameSource: case state.OutcomeSameSource:
@@ -524,10 +528,7 @@ func actionForComparison(comparison state.Comparison, transfer config.TransferPo
return ActionReplaceOlder, comparison.Reason return ActionReplaceOlder, comparison.Reason
case state.OutcomeDestinationNewer: case state.OutcomeDestinationNewer:
if transfer.OnDestinationNewer == config.TransferActionReplace { if transfer.OnDestinationNewer == config.TransferActionReplace {
if force { return ActionReplaceNewer, comparison.Reason
return ActionForceReplace, "forced replacement of newer destination state"
}
return ActionFailConflict, "destination is newer and replacement requires --force"
} }
if transfer.OnDestinationNewer == config.TransferActionFail { if transfer.OnDestinationNewer == config.TransferActionFail {
return ActionFailConflict, "destination is newer and transfer policy requires failure" return ActionFailConflict, "destination is newer and transfer policy requires failure"
@@ -538,6 +539,10 @@ func actionForComparison(comparison state.Comparison, transfer config.TransferPo
} }
} }
func isReconciliationReplacementAction(action Action) bool {
return action == ActionReplaceOlder || action == ActionReplaceNewer
}
func takeoverActionAllowed(req Request, status state.DestinationStatus, comparison state.Comparison, stateMode string, action Action) bool { func takeoverActionAllowed(req Request, status state.DestinationStatus, comparison state.Comparison, stateMode string, action Action) bool {
if stateMode != config.StateModeSingleOwner || status.State == nil { if stateMode != config.StateModeSingleOwner || status.State == nil {
return false return false

View File

@@ -117,6 +117,7 @@ func TestBuildSharedRootPlansOutputTakeoverByPolicy(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
takeover config.TakeoverPolicy takeover config.TakeoverPolicy
transfer config.TransferPolicy
ownerScope state.OwnerScope ownerScope state.OwnerScope
sameSource bool sameSource bool
wantAction Action wantAction Action
@@ -159,6 +160,13 @@ func TestBuildSharedRootPlansOutputTakeoverByPolicy(t *testing.T) {
ownerScope: state.CurrentOwnerScope("reports", "web"), ownerScope: state.CurrentOwnerScope("reports", "web"),
wantErr: "fail_conflict", wantErr: "fail_conflict",
}, },
{
name: "transfer conflict replacement allows managed owner conflict",
takeover: config.TakeoverPolicy{Mode: config.TakeoverModeNever},
transfer: config.TransferPolicy{OnConflict: config.TransferActionReplace},
ownerScope: state.CurrentOwnerScope("other", "archive"),
wantAction: ActionReplaceConflict,
},
} }
for _, tt := range tests { for _, tt := range tests {
@@ -178,6 +186,9 @@ func TestBuildSharedRootPlansOutputTakeoverByPolicy(t *testing.T) {
req := sharedRootRequest(sourceBackend, destinationBackend, sourceBundle, config.ReconciliationModeReplace) req := sharedRootRequest(sourceBackend, destinationBackend, sourceBundle, config.ReconciliationModeReplace)
req.Takeover = tt.takeover req.Takeover = tt.takeover
if tt.transfer.OnConflict != "" {
req.Transfer.OnConflict = tt.transfer.OnConflict
}
plan, err := Build(context.Background(), req) plan, err := Build(context.Background(), req)
if tt.wantErr != "" { if tt.wantErr != "" {
if err == nil || !strings.Contains(err.Error(), tt.wantErr) { if err == nil || !strings.Contains(err.Error(), tt.wantErr) {