Implement single-owner managed takeover
This commit is contained in:
@@ -418,6 +418,24 @@ pipelines:
|
|||||||
|
|
||||||
Every output path in a shared root belongs to exactly one `pipeline_id` and `destination_id`. A different owner planning the same path fails as a conflict.
|
Every output path in a shared root belongs to exactly one `pipeline_id` and `destination_id`. A different owner planning the same path fails as a conflict.
|
||||||
|
|
||||||
|
## Takeover Policy
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
takeover:
|
||||||
|
mode: same_pipeline
|
||||||
|
```
|
||||||
|
|
||||||
|
- `takeover.mode`: optional. Accepted values are `same_pipeline`, `same_source`, `any_managed`, and `never`; default is `same_pipeline`.
|
||||||
|
|
||||||
|
Takeover controls when a single-owner destination may normally replace valid distributor-managed state whose pipeline, destination, or source identity differs from the current publication.
|
||||||
|
|
||||||
|
- `same_pipeline`: replace managed state owned by the same pipeline. The previous destination id and source id may differ.
|
||||||
|
- `same_source`: replace managed state only when the existing source manifest id matches the current source id.
|
||||||
|
- `any_managed`: replace any valid distributor-managed single-owner state at the selected destination bundle path.
|
||||||
|
- `never`: do not replace identity or source conflicts without the explicit forced replacement workflow.
|
||||||
|
|
||||||
|
Takeover does not apply to unmanaged content, invalid destination state, same-created digest conflicts, same-source destination-newer comparisons, or shared-root output paths owned by another owner.
|
||||||
|
|
||||||
## Reconciliation Policy
|
## Reconciliation Policy
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
@@ -529,6 +547,7 @@ Defaults are applied after YAML decoding and before validation:
|
|||||||
- `links.primary: auto` when a `links` block is present and `primary` is omitted
|
- `links.primary: auto` when a `links` block is present and `primary` is omitted
|
||||||
- `state.mode: single_owner`
|
- `state.mode: single_owner`
|
||||||
- `reconciliation.mode: replace`
|
- `reconciliation.mode: replace`
|
||||||
|
- `takeover.mode: same_pipeline`
|
||||||
- `retention.prune.enabled: false`
|
- `retention.prune.enabled: false`
|
||||||
- `transfer.on_destination_same: skip`
|
- `transfer.on_destination_same: skip`
|
||||||
- `transfer.on_destination_older: replace`
|
- `transfer.on_destination_older: replace`
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ Audience: developers and LLM coding agents changing `internal/publish`.
|
|||||||
|
|
||||||
## Inputs And Outputs
|
## Inputs And Outputs
|
||||||
|
|
||||||
Inputs are a source bundle, source backend, destination backend, pipeline id, destination id, destination bundle path, path mapping mode, publish policy, transform policy, optional link policy, state policy, reconciliation policy, transformer resolver, transfer policy, distributor version, and force flag.
|
Inputs are a source bundle, source backend, destination backend, pipeline id, destination id, destination bundle path, path mapping mode, publish policy, transform policy, optional link policy, state policy, reconciliation policy, takeover policy, transformer resolver, transfer policy, distributor version, and force flag.
|
||||||
|
|
||||||
Output from planning is a `Plan` with action, reason, destination identity, selected outputs, state mode, owner scope, reconciliation mode, optional existing single-owner or shared-root state, optional primary URL, and force metadata. Shared-root plans also expose other-owner outputs to preserve, current-owner outputs retained by merge, current-owner outputs deleted by replace, and current-owner outputs to write. Execution writes selected source outputs, generated outputs, and `.distributor.json` for executable publish or replacement actions.
|
Output from planning is a `Plan` with action, reason, destination identity, selected outputs, state mode, owner scope, reconciliation mode, optional existing single-owner or shared-root state, optional primary URL, and force metadata. Shared-root plans also expose other-owner outputs to preserve, current-owner outputs retained by merge, current-owner outputs deleted by replace, and current-owner outputs to write. Execution writes selected source outputs, generated outputs, and `.distributor.json` for executable publish or replacement actions.
|
||||||
|
|
||||||
@@ -20,7 +20,7 @@ External destination state semantics are documented in `docs/integrations/destin
|
|||||||
|
|
||||||
## Config Fields Used
|
## Config Fields Used
|
||||||
|
|
||||||
The package consumes already-defaulted config values for destination `publish`, `transform`, `links`, `state`, `reconciliation`, `transfer`, and path mapping mode. It uses `config.ValidatePublishTransformPolicy` for publish/transform consistency.
|
The package consumes already-defaulted config values for destination `publish`, `transform`, `links`, `state`, `reconciliation`, `takeover`, `transfer`, and path mapping mode. It uses `config.ValidatePublishTransformPolicy` for publish/transform consistency.
|
||||||
|
|
||||||
## Adapters Used
|
## Adapters Used
|
||||||
|
|
||||||
@@ -28,15 +28,15 @@ 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 policy into actions: `publish_new`, `replace_older`, `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_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. 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 fail as conflicts.
|
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 fail as conflicts.
|
||||||
|
|
||||||
Execution writes destination state after selected outputs are written. Destination state includes copied source output metadata, generated output metadata, output timestamps, embedded source manifest, reconciliation metadata, link metadata when configured, pipeline id, destination id, and publication timestamps.
|
Execution writes destination state after selected outputs are written. Destination state includes copied source output metadata, generated output metadata, output timestamps, embedded source manifest, reconciliation metadata, link metadata when configured, pipeline id, destination id, and publication timestamps.
|
||||||
|
|
||||||
## 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. Replacement-mode shared-root updates remove only current-owner omitted outputs and preserve unrelated owners. Merge-mode 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; 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. Single-owner `replace_takeover` uses the same 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. 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; 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.
|
||||||
|
|
||||||
|
|||||||
@@ -67,12 +67,15 @@ 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: skip by default.
|
- Newer destination state for the same source id: skip by default.
|
||||||
- Invalid destination state, identity mismatch, different source id, or same-created digest mismatch: fail by default.
|
- Valid single-owner state with an identity or source mismatch: replace only when destination `takeover.mode` allows it.
|
||||||
|
- Invalid destination state, identity or source mismatches not allowed by `takeover.mode`, or same-created digest mismatch: 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 than the source, `transfer.on_destination_older` controls whether publication may proceed and `reconciliation.mode` controls how managed outputs are updated.
|
||||||
|
|
||||||
|
For single-owner 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.
|
||||||
|
|
||||||
`reconciliation.mode: replace` is the default. It deletes only managed output paths recorded in `.distributor.json` plus the state file, verifies the destination bundle path is empty, then writes the newly planned outputs and state. The new state `outputs` array is exactly the newly planned output set.
|
`reconciliation.mode: replace` is the default. It deletes only managed output paths recorded in `.distributor.json` plus the state file, verifies the destination bundle path is empty, then writes the newly planned outputs and state. The new state `outputs` array is exactly the newly planned output set.
|
||||||
|
|
||||||
`reconciliation.mode: merge` retains prior managed outputs that are omitted from the new plan. It overwrites planned paths only when those paths are already recorded in existing state as managed. If a newly planned path already exists in storage but is not recorded in state, publication fails as an unmanaged path collision. The new state `outputs` array is the cumulative managed output set.
|
`reconciliation.mode: merge` retains prior managed outputs that are omitted from the new plan. It overwrites planned paths only when those paths are already recorded in existing state as managed. If a newly planned path already exists in storage but is not recorded in state, publication fails as an unmanaged path collision. The new state `outputs` array is the cumulative managed output set.
|
||||||
@@ -150,12 +153,13 @@ For single-owner state, the state owner must match the selected pipeline and des
|
|||||||
|
|
||||||
`run --dry-run` loads config, resolves credentials, discovers source bundles, opens destinations, inspects destination state, builds publish plans, and prints actions. It does not write outputs, `.distributor.json`, or SSH `known_hosts` entries. For reconciliation, dry runs report the same high-level action labels as execution; inspect the configured destination's `reconciliation.mode` to determine whether `replace_older` will replace the managed set or merge into it.
|
`run --dry-run` loads config, resolves credentials, discovers source bundles, opens destinations, inspects destination state, builds publish plans, and prints actions. It does not write outputs, `.distributor.json`, or SSH `known_hosts` entries. For reconciliation, dry runs report the same high-level action labels as execution; inspect the configured destination's `reconciliation.mode` to determine whether `replace_older` will replace the managed set or merge into it.
|
||||||
|
|
||||||
For shared-root destinations, dry runs are owner-scoped. A `replace_older` action replaces or merges only the current owner according to `reconciliation.mode`; unrelated owners remain managed by the shared-root state.
|
For shared-root destinations, dry runs are owner-scoped. A `replace_older` action replaces or merges only the current owner according to `reconciliation.mode`; unrelated owners remain managed by the shared-root state. Paths owned by another owner still fail as conflicts.
|
||||||
|
|
||||||
Review these action labels before publishing:
|
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_takeover`: single-owner destination state is valid managed state and `takeover.mode` allows replacement across an identity or source 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.
|
||||||
- `force_replace`: destructive replacement selected because `--force` is present and policy permits it.
|
- `force_replace`: destructive replacement selected because `--force` is present and policy permits it.
|
||||||
|
|||||||
@@ -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, or same-created source with different content.
|
Likely cause: `.distributor.json` belongs to a different pipeline, destination, source id, or same-created source with different content. For single-owner state, identity and source mismatches can publish as `replace_takeover` only when destination `takeover.mode` allows them.
|
||||||
|
|
||||||
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. To replace the existing state, 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 single-owner 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`.
|
||||||
|
|
||||||
Reference: [Operations](operations.md#destination-state-and-retry-behavior).
|
Reference: [Operations](operations.md#destination-state-and-retry-behavior).
|
||||||
|
|
||||||
|
|||||||
@@ -71,6 +71,7 @@ func processDestinationSelection(ctx context.Context, request runDestinationRequ
|
|||||||
Links: request.destination.Links,
|
Links: request.destination.Links,
|
||||||
State: request.destination.State,
|
State: request.destination.State,
|
||||||
Reconciliation: request.destination.Reconciliation,
|
Reconciliation: request.destination.Reconciliation,
|
||||||
|
Takeover: request.destination.Takeover,
|
||||||
Transformers: request.transforms,
|
Transformers: request.transforms,
|
||||||
Transfer: request.destination.Transfer,
|
Transfer: request.destination.Transfer,
|
||||||
DistributorVersion: Version,
|
DistributorVersion: Version,
|
||||||
|
|||||||
@@ -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.ActionForceReplace
|
return action == publish.ActionPublishNew || action == publish.ActionReplaceOlder || action == publish.ActionReplaceTakeover || action == publish.ActionForceReplace
|
||||||
}
|
}
|
||||||
|
|
||||||
func notifyEvent(plan publish.Plan) notify.Event {
|
func notifyEvent(plan publish.Plan) notify.Event {
|
||||||
|
|||||||
@@ -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.ActionForceReplace
|
return action == publish.ActionReplaceOlder || action == publish.ActionReplaceTakeover || action == publish.ActionForceReplace
|
||||||
}
|
}
|
||||||
|
|
||||||
func fixedPathReplacementWarning(plan publish.Plan) OutputWarning {
|
func fixedPathReplacementWarning(plan publish.Plan) OutputWarning {
|
||||||
|
|||||||
@@ -592,8 +592,8 @@ func TestRunFixedPathDryRunWarnsForReplacement(t *testing.T) {
|
|||||||
}
|
}
|
||||||
output := stdout.String()
|
output := stdout.String()
|
||||||
for _, want := range []string{
|
for _, want := range []string{
|
||||||
"Warning: pipeline=reports destination=archive path_mapping=fixed action=replace_older replaces destination root for selected_bundle=new",
|
"Warning: pipeline=reports destination=archive path_mapping=fixed action=replace_takeover replaces destination root for selected_bundle=new",
|
||||||
"bundle=new destination=archive backend=local path_mapping=fixed target=. action=replace_older",
|
"bundle=new destination=archive backend=local path_mapping=fixed target=. action=replace_takeover",
|
||||||
} {
|
} {
|
||||||
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)
|
||||||
@@ -642,7 +642,7 @@ func TestRunFixedPathSkipsWhenDestinationStateIsNewer(t *testing.T) {
|
|||||||
sourceRoot := t.TempDir()
|
sourceRoot := t.TempDir()
|
||||||
destinationRoot := t.TempDir()
|
destinationRoot := t.TempDir()
|
||||||
newer := testutil.ValidManifest(testutil.BundleOptions{
|
newer := testutil.ValidManifest(testutil.BundleOptions{
|
||||||
ID: "reports.newer",
|
ID: "reports.same",
|
||||||
Created: testutil.DefaultCreated.Add(time.Hour),
|
Created: testutil.DefaultCreated.Add(time.Hour),
|
||||||
})
|
})
|
||||||
writeDestinationState(t, destinationRoot, "", newer)
|
writeDestinationState(t, destinationRoot, "", newer)
|
||||||
@@ -650,7 +650,7 @@ func TestRunFixedPathSkipsWhenDestinationStateIsNewer(t *testing.T) {
|
|||||||
t.Fatalf("write existing report: %v", err)
|
t.Fatalf("write existing report: %v", err)
|
||||||
}
|
}
|
||||||
writeSourceBundle(t, sourceRoot, "older", testBundleOptions{
|
writeSourceBundle(t, sourceRoot, "older", testBundleOptions{
|
||||||
ID: "reports.older",
|
ID: "reports.same",
|
||||||
Created: testutil.DefaultCreated,
|
Created: testutil.DefaultCreated,
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -1526,14 +1526,27 @@ 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 TestRunFailsOnConflict(t *testing.T) {
|
func TestRunTakeoverNeverFailsOnConflict(t *testing.T) {
|
||||||
sourceRoot := t.TempDir()
|
sourceRoot := t.TempDir()
|
||||||
destinationRoot := t.TempDir()
|
destinationRoot := t.TempDir()
|
||||||
manifest := writeSourceBundle(t, sourceRoot, "", testBundleOptions{})
|
manifest := writeSourceBundle(t, sourceRoot, "", testBundleOptions{})
|
||||||
manifest.ID = "other.source"
|
manifest.ID = "other.source"
|
||||||
writeDestinationState(t, destinationRoot, "", manifest)
|
writeDestinationState(t, destinationRoot, "", manifest)
|
||||||
|
configPath := writeConfigFile(t, `
|
||||||
|
pipelines:
|
||||||
|
- id: reports
|
||||||
|
source:
|
||||||
|
backend: local
|
||||||
|
path: `+sourceRoot+`
|
||||||
|
destinations:
|
||||||
|
- id: archive
|
||||||
|
backend: local
|
||||||
|
path: `+destinationRoot+`
|
||||||
|
takeover:
|
||||||
|
mode: never
|
||||||
|
`)
|
||||||
|
|
||||||
err := Run(context.Background(), RunOptions{ConfigPath: writeLocalConfig(t, sourceRoot, destinationRoot)})
|
err := Run(context.Background(), RunOptions{ConfigPath: configPath})
|
||||||
if err == nil || !strings.Contains(err.Error(), "fail_conflict") {
|
if err == nil || !strings.Contains(err.Error(), "fail_conflict") {
|
||||||
t.Fatalf("Run() error = %v, want fail_conflict", err)
|
t.Fatalf("Run() error = %v, want fail_conflict", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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, ActionForceReplace:
|
case ActionPublishNew, ActionReplaceOlder, 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 {
|
if plan.Action == ActionReplaceOlder || 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 {
|
if plan.Reconciliation.Mode == config.ReconciliationModeReplace || 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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -189,7 +189,6 @@ func TestExecuteFixedPathSupportsReconciliationModes(t *testing.T) {
|
|||||||
})
|
})
|
||||||
destinationBackend := fake.New()
|
destinationBackend := fake.New()
|
||||||
older := sourceBundle.Manifest
|
older := sourceBundle.Manifest
|
||||||
older.ID = "older.source"
|
|
||||||
older.Created = older.Created.Add(-time.Hour)
|
older.Created = older.Created.Add(-time.Hour)
|
||||||
testutil.WriteFakeDestinationState(t, destinationBackend, "", older, testutil.DestinationStateOptions{})
|
testutil.WriteFakeDestinationState(t, destinationBackend, "", older, testutil.DestinationStateOptions{})
|
||||||
|
|
||||||
|
|||||||
@@ -166,6 +166,7 @@ func forceRequest(sourceBackend, destinationBackend *fake.Backend, sourceBundle
|
|||||||
DestinationBackend: destinationBackend,
|
DestinationBackend: destinationBackend,
|
||||||
DestinationBundlePath: sourceBundle.RootRelativePath,
|
DestinationBundlePath: sourceBundle.RootRelativePath,
|
||||||
Publish: config.PublishPolicy{Source: true},
|
Publish: config.PublishPolicy{Source: true},
|
||||||
|
Takeover: config.TakeoverPolicy{Mode: config.TakeoverModeNever},
|
||||||
Transfer: transfer,
|
Transfer: transfer,
|
||||||
DistributorVersion: "test",
|
DistributorVersion: "test",
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ const (
|
|||||||
ActionFailConflict Action = "fail_conflict"
|
ActionFailConflict Action = "fail_conflict"
|
||||||
ActionFailUnmanaged Action = "fail_unmanaged"
|
ActionFailUnmanaged Action = "fail_unmanaged"
|
||||||
ActionForceReplace Action = "force_replace"
|
ActionForceReplace Action = "force_replace"
|
||||||
|
ActionReplaceTakeover Action = "replace_takeover"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Request struct {
|
type Request struct {
|
||||||
@@ -36,6 +37,7 @@ type Request struct {
|
|||||||
Links *config.Links
|
Links *config.Links
|
||||||
State config.StatePolicy
|
State config.StatePolicy
|
||||||
Reconciliation config.ReconciliationPolicy
|
Reconciliation config.ReconciliationPolicy
|
||||||
|
Takeover config.TakeoverPolicy
|
||||||
Transformers TransformerResolver
|
Transformers TransformerResolver
|
||||||
Transfer config.TransferPolicy
|
Transfer config.TransferPolicy
|
||||||
DistributorVersion string
|
DistributorVersion string
|
||||||
@@ -96,10 +98,14 @@ func Build(ctx context.Context, req Request) (Plan, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return Plan{}, err
|
return Plan{}, err
|
||||||
}
|
}
|
||||||
comparison := compareDestination(req, status)
|
|
||||||
action, reason := actionForComparison(comparison, req.Transfer, req.Force)
|
|
||||||
reconciliation := normalizeReconciliation(req.Reconciliation)
|
reconciliation := normalizeReconciliation(req.Reconciliation)
|
||||||
stateMode := normalizeState(req.State).Mode
|
stateMode := normalizeState(req.State).Mode
|
||||||
|
comparison := compareDestination(req, status)
|
||||||
|
action, reason := actionForComparison(comparison, req.Transfer, req.Force)
|
||||||
|
if takeoverActionAllowed(req, status, comparison, stateMode, action) {
|
||||||
|
action = ActionReplaceTakeover
|
||||||
|
reason = comparison.Reason
|
||||||
|
}
|
||||||
plan := Plan{
|
plan := Plan{
|
||||||
PipelineID: req.PipelineID,
|
PipelineID: req.PipelineID,
|
||||||
DestinationID: req.DestinationID,
|
DestinationID: req.DestinationID,
|
||||||
@@ -162,6 +168,11 @@ func validateRequest(req Request) error {
|
|||||||
default:
|
default:
|
||||||
return fmt.Errorf("state.mode must be %s or %s", config.StateModeSingleOwner, config.StateModeSharedRoot)
|
return fmt.Errorf("state.mode must be %s or %s", config.StateModeSingleOwner, config.StateModeSharedRoot)
|
||||||
}
|
}
|
||||||
|
switch normalizeTakeover(req.Takeover).Mode {
|
||||||
|
case config.TakeoverModeSamePipeline, config.TakeoverModeSameSource, config.TakeoverModeAnyManaged, config.TakeoverModeNever:
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("takeover.mode must be %s, %s, %s, or %s", config.TakeoverModeSamePipeline, config.TakeoverModeSameSource, config.TakeoverModeAnyManaged, config.TakeoverModeNever)
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -179,6 +190,13 @@ func normalizeState(policy config.StatePolicy) config.StatePolicy {
|
|||||||
return policy
|
return policy
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func normalizeTakeover(policy config.TakeoverPolicy) config.TakeoverPolicy {
|
||||||
|
if policy.Mode == "" {
|
||||||
|
policy.Mode = config.TakeoverModeSamePipeline
|
||||||
|
}
|
||||||
|
return policy
|
||||||
|
}
|
||||||
|
|
||||||
func compareDestination(req Request, status state.DestinationStatus) state.Comparison {
|
func compareDestination(req Request, status state.DestinationStatus) state.Comparison {
|
||||||
if normalizeState(req.State).Mode == config.StateModeSharedRoot {
|
if normalizeState(req.State).Mode == config.StateModeSharedRoot {
|
||||||
scope := state.CurrentOwnerScope(req.PipelineID, req.DestinationID)
|
scope := state.CurrentOwnerScope(req.PipelineID, req.DestinationID)
|
||||||
@@ -207,24 +225,6 @@ func compareDestination(req Request, status state.DestinationStatus) state.Compa
|
|||||||
return comparison
|
return comparison
|
||||||
}
|
}
|
||||||
comparison := state.Compare(req.SourceBundle.Manifest, req.PipelineID, req.DestinationID, status)
|
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
|
|
||||||
}
|
|
||||||
destinationManifest := status.State.Source.Manifest
|
|
||||||
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",
|
|
||||||
Detail: state.ComparisonDetail{
|
|
||||||
Kind: state.ComparisonDetailDestinationNewer,
|
|
||||||
CurrentSourceID: req.SourceBundle.Manifest.ID,
|
|
||||||
DestinationSourceID: destinationManifest.ID,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return comparison
|
return comparison
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -296,7 +296,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, ActionForceReplace:
|
case ActionPublishNew, ActionReplaceOlder, ActionReplaceTakeover, ActionForceReplace:
|
||||||
return true
|
return true
|
||||||
default:
|
default:
|
||||||
return false
|
return false
|
||||||
@@ -439,3 +439,32 @@ func actionForComparison(comparison state.Comparison, transfer config.TransferPo
|
|||||||
return ActionFailConflict, "unsupported comparison outcome"
|
return ActionFailConflict, "unsupported comparison outcome"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func takeoverActionAllowed(req Request, status state.DestinationStatus, comparison state.Comparison, stateMode string, action Action) bool {
|
||||||
|
if stateMode != config.StateModeSingleOwner || status.State == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if action == ActionForceReplace {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
switch comparison.Detail.Kind {
|
||||||
|
case state.ComparisonDetailPipelineIDMismatch,
|
||||||
|
state.ComparisonDetailDestinationIDMismatch,
|
||||||
|
state.ComparisonDetailDifferentSourceID:
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
takeover := normalizeTakeover(req.Takeover)
|
||||||
|
switch takeover.Mode {
|
||||||
|
case config.TakeoverModeSamePipeline:
|
||||||
|
return status.State.PipelineID == req.PipelineID
|
||||||
|
case config.TakeoverModeSameSource:
|
||||||
|
return status.State.Source.Manifest.ID == req.SourceBundle.Manifest.ID
|
||||||
|
case config.TakeoverModeAnyManaged:
|
||||||
|
return true
|
||||||
|
case config.TakeoverModeNever:
|
||||||
|
return false
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ package publish
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/distributor/internal/config"
|
"gitea.maximumdirect.net/eric/distributor/internal/config"
|
||||||
"gitea.maximumdirect.net/eric/distributor/internal/state"
|
"gitea.maximumdirect.net/eric/distributor/internal/state"
|
||||||
@@ -10,12 +9,11 @@ import (
|
|||||||
"gitea.maximumdirect.net/eric/distributor/internal/testutil"
|
"gitea.maximumdirect.net/eric/distributor/internal/testutil"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestCompareDestinationFixedPathReportsDestinationNewerDetail(t *testing.T) {
|
func TestCompareDestinationFixedPathPreservesDifferentSourceConflict(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{})
|
||||||
destinationState := testutil.DestinationState(sourceBundle.Manifest, testutil.DestinationStateOptions{})
|
destinationState := testutil.DestinationState(sourceBundle.Manifest, testutil.DestinationStateOptions{})
|
||||||
destinationState.Source.Manifest.ID = "latest.previous"
|
destinationState.Source.Manifest.ID = "latest.previous"
|
||||||
destinationState.Source.Manifest.Created = sourceBundle.Manifest.Created.Add(time.Hour)
|
|
||||||
|
|
||||||
comparison := compareDestination(Request{
|
comparison := compareDestination(Request{
|
||||||
PipelineID: "reports",
|
PipelineID: "reports",
|
||||||
@@ -26,14 +24,11 @@ func TestCompareDestinationFixedPathReportsDestinationNewerDetail(t *testing.T)
|
|||||||
State: config.StatePolicy{Mode: config.StateModeSingleOwner},
|
State: config.StatePolicy{Mode: config.StateModeSingleOwner},
|
||||||
}, state.DestinationStatus{State: &destinationState, HasContents: true})
|
}, state.DestinationStatus{State: &destinationState, HasContents: true})
|
||||||
|
|
||||||
if comparison.Outcome != state.OutcomeDestinationNewer {
|
if comparison.Outcome != state.OutcomeDifferentSourceConflict {
|
||||||
t.Fatalf("comparison outcome = %s, want %s", comparison.Outcome, state.OutcomeDestinationNewer)
|
t.Fatalf("comparison outcome = %s, want %s", comparison.Outcome, state.OutcomeDifferentSourceConflict)
|
||||||
}
|
}
|
||||||
if comparison.Reason != "fixed destination source is newer than selected source" {
|
if comparison.Detail.Kind != state.ComparisonDetailDifferentSourceID {
|
||||||
t.Fatalf("comparison reason = %q, want fixed-path newer reason", comparison.Reason)
|
t.Fatalf("detail kind = %q, want %q", comparison.Detail.Kind, state.ComparisonDetailDifferentSourceID)
|
||||||
}
|
|
||||||
if comparison.Detail.Kind != state.ComparisonDetailDestinationNewer {
|
|
||||||
t.Fatalf("detail kind = %q, want %q", comparison.Detail.Kind, state.ComparisonDetailDestinationNewer)
|
|
||||||
}
|
}
|
||||||
if comparison.Detail.CurrentSourceID != sourceBundle.Manifest.ID || comparison.Detail.DestinationSourceID != "latest.previous" {
|
if comparison.Detail.CurrentSourceID != sourceBundle.Manifest.ID || comparison.Detail.DestinationSourceID != "latest.previous" {
|
||||||
t.Fatalf("detail = %#v, want source ids", comparison.Detail)
|
t.Fatalf("detail = %#v, want source ids", comparison.Detail)
|
||||||
|
|||||||
215
internal/publish/takeover_test.go
Normal file
215
internal/publish/takeover_test.go
Normal file
@@ -0,0 +1,215 @@
|
|||||||
|
package publish
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/distributor/internal/bundle"
|
||||||
|
"gitea.maximumdirect.net/eric/distributor/internal/config"
|
||||||
|
"gitea.maximumdirect.net/eric/distributor/internal/storage"
|
||||||
|
"gitea.maximumdirect.net/eric/distributor/internal/storage/fake"
|
||||||
|
"gitea.maximumdirect.net/eric/distributor/internal/testutil"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestBuildPlansSingleOwnerTakeoverByPolicy(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
takeover config.TakeoverPolicy
|
||||||
|
mutateState func(*bundle.Manifest, *testutil.DestinationStateOptions)
|
||||||
|
wantAction Action
|
||||||
|
wantErr string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "default same pipeline different source",
|
||||||
|
takeover: config.TakeoverPolicy{},
|
||||||
|
mutateState: func(manifest *bundle.Manifest, opts *testutil.DestinationStateOptions) {
|
||||||
|
manifest.ID = "other.source"
|
||||||
|
},
|
||||||
|
wantAction: ActionReplaceTakeover,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "same pipeline different newer source",
|
||||||
|
takeover: config.TakeoverPolicy{Mode: config.TakeoverModeSamePipeline},
|
||||||
|
mutateState: func(manifest *bundle.Manifest, opts *testutil.DestinationStateOptions) {
|
||||||
|
manifest.ID = "other.source"
|
||||||
|
manifest.Created = manifest.Created.AddDate(0, 0, 1)
|
||||||
|
},
|
||||||
|
wantAction: ActionReplaceTakeover,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "default same pipeline different destination",
|
||||||
|
takeover: config.TakeoverPolicy{},
|
||||||
|
mutateState: func(manifest *bundle.Manifest, opts *testutil.DestinationStateOptions) {
|
||||||
|
opts.DestinationID = "web"
|
||||||
|
},
|
||||||
|
wantAction: ActionReplaceTakeover,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "same pipeline refuses different pipeline",
|
||||||
|
takeover: config.TakeoverPolicy{Mode: config.TakeoverModeSamePipeline},
|
||||||
|
mutateState: func(manifest *bundle.Manifest, opts *testutil.DestinationStateOptions) {
|
||||||
|
opts.PipelineID = "other"
|
||||||
|
},
|
||||||
|
wantErr: "fail_conflict",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "same source allows different pipeline",
|
||||||
|
takeover: config.TakeoverPolicy{Mode: config.TakeoverModeSameSource},
|
||||||
|
mutateState: func(manifest *bundle.Manifest, opts *testutil.DestinationStateOptions) {
|
||||||
|
opts.PipelineID = "other"
|
||||||
|
},
|
||||||
|
wantAction: ActionReplaceTakeover,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "same source refuses different source",
|
||||||
|
takeover: config.TakeoverPolicy{Mode: config.TakeoverModeSameSource},
|
||||||
|
mutateState: func(manifest *bundle.Manifest, opts *testutil.DestinationStateOptions) {
|
||||||
|
manifest.ID = "other.source"
|
||||||
|
},
|
||||||
|
wantErr: "fail_conflict",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "any managed allows different pipeline",
|
||||||
|
takeover: config.TakeoverPolicy{Mode: config.TakeoverModeAnyManaged},
|
||||||
|
mutateState: func(manifest *bundle.Manifest, opts *testutil.DestinationStateOptions) {
|
||||||
|
manifest.ID = "other.source"
|
||||||
|
opts.PipelineID = "other"
|
||||||
|
},
|
||||||
|
wantAction: ActionReplaceTakeover,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "never refuses different source",
|
||||||
|
takeover: config.TakeoverPolicy{Mode: config.TakeoverModeNever},
|
||||||
|
mutateState: func(manifest *bundle.Manifest, opts *testutil.DestinationStateOptions) {
|
||||||
|
manifest.ID = "other.source"
|
||||||
|
},
|
||||||
|
wantErr: "fail_conflict",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
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()
|
||||||
|
destinationManifest := sourceBundle.Manifest
|
||||||
|
destinationManifest.Files = append([]bundle.ManifestFile(nil), sourceBundle.Manifest.Files...)
|
||||||
|
opts := testutil.DestinationStateOptions{}
|
||||||
|
tt.mutateState(&destinationManifest, &opts)
|
||||||
|
testutil.WriteFakeDestinationState(t, destinationBackend, "bundle", destinationManifest, opts)
|
||||||
|
|
||||||
|
req := takeoverRequest(sourceBackend, destinationBackend, sourceBundle, tt.takeover, config.ReconciliationModeReplace)
|
||||||
|
plan, err := Build(context.Background(), req)
|
||||||
|
if tt.wantErr != "" {
|
||||||
|
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
|
||||||
|
t.Fatalf("Build() error = %v, want %q", err, tt.wantErr)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Build() error = %v", err)
|
||||||
|
}
|
||||||
|
if plan.Action != tt.wantAction {
|
||||||
|
t.Fatalf("plan action = %s, want %s", plan.Action, tt.wantAction)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildDoesNotTakeOverInvalidOrUnmanagedDestination(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
prepare func(t *testing.T, backend *fake.Backend)
|
||||||
|
wantErr string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "invalid state",
|
||||||
|
prepare: func(t *testing.T, backend *fake.Backend) {
|
||||||
|
t.Helper()
|
||||||
|
statePath, err := storage.StatePath("bundle")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("state path: %v", err)
|
||||||
|
}
|
||||||
|
testutil.WriteFakeFile(t, backend, statePath, "{invalid")
|
||||||
|
},
|
||||||
|
wantErr: "fail_conflict",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "unmanaged content",
|
||||||
|
prepare: func(t *testing.T, backend *fake.Backend) {
|
||||||
|
t.Helper()
|
||||||
|
testutil.WriteFakeFile(t, backend, "bundle/old.txt", "old")
|
||||||
|
},
|
||||||
|
wantErr: "fail_unmanaged",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
|
||||||
|
req := takeoverRequest(sourceBackend, destinationBackend, sourceBundle, config.TakeoverPolicy{Mode: config.TakeoverModeAnyManaged}, config.ReconciliationModeReplace)
|
||||||
|
_, err := Build(context.Background(), req)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
|
||||||
|
t.Fatalf("Build() error = %v, want %q", err, tt.wantErr)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExecuteTakeoverMergeDoesNotRetainOldSourceOutputs(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()
|
||||||
|
oldManifest := sourceBundle.Manifest
|
||||||
|
oldManifest.ID = "old.source"
|
||||||
|
oldManifest.Files = []bundle.ManifestFile{
|
||||||
|
{Path: "report.md", SHA256: bundle.FileDigest([]byte("old\n")), Size: int64(len("old\n"))},
|
||||||
|
{Path: "summary.txt", SHA256: bundle.FileDigest([]byte("old summary\n")), Size: int64(len("old summary\n"))},
|
||||||
|
}
|
||||||
|
oldManifest.Digest = bundle.BundleDigest(oldManifest.Files)
|
||||||
|
testutil.WriteFakeDestinationState(t, destinationBackend, "bundle", oldManifest, testutil.DestinationStateOptions{})
|
||||||
|
|
||||||
|
req := takeoverRequest(sourceBackend, destinationBackend, sourceBundle, config.TakeoverPolicy{Mode: config.TakeoverModeSamePipeline}, config.ReconciliationModeMerge)
|
||||||
|
plan, err := Build(context.Background(), req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Build() error = %v", err)
|
||||||
|
}
|
||||||
|
if plan.Action != ActionReplaceTakeover {
|
||||||
|
t.Fatalf("plan action = %s, want %s", plan.Action, ActionReplaceTakeover)
|
||||||
|
}
|
||||||
|
if err := Execute(context.Background(), req, plan); err != nil {
|
||||||
|
t.Fatalf("Execute() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
testutil.AssertFakeFile(t, destinationBackend, "bundle/report.md", "# Report\nNew.\n")
|
||||||
|
testutil.AssertFakeMissing(t, destinationBackend, "bundle/summary.txt")
|
||||||
|
destinationState := readFakeState(t, destinationBackend, "bundle")
|
||||||
|
if got, want := len(destinationState.Outputs), 1; got != want {
|
||||||
|
t.Fatalf("state output count = %d, want %d", got, want)
|
||||||
|
}
|
||||||
|
if got, want := destinationState.Source.Manifest.ID, sourceBundle.Manifest.ID; got != want {
|
||||||
|
t.Fatalf("state source id = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func takeoverRequest(sourceBackend, destinationBackend *fake.Backend, sourceBundle bundle.Bundle, takeover config.TakeoverPolicy, reconciliationMode string) Request {
|
||||||
|
return Request{
|
||||||
|
PipelineID: "reports",
|
||||||
|
DestinationID: "archive",
|
||||||
|
SourceBundle: sourceBundle,
|
||||||
|
SourceBackend: sourceBackend,
|
||||||
|
DestinationBackend: destinationBackend,
|
||||||
|
DestinationBundlePath: sourceBundle.RootRelativePath,
|
||||||
|
Publish: config.PublishPolicy{Source: true},
|
||||||
|
Reconciliation: config.ReconciliationPolicy{Mode: reconciliationMode},
|
||||||
|
Takeover: takeover,
|
||||||
|
Transfer: defaultTransfer(),
|
||||||
|
DistributorVersion: "test",
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user