From b6627eff4b4775cd1485c45770373d15d1c665b7 Mon Sep 17 00:00:00 2001 From: Omry Yadan Date: Sat, 31 Oct 2026 22:54:22 +0800 Subject: [PATCH] E4.1.2: Bind controlled entry adapters and preserve refusals Compose private fresh mapped entries through the existing immutable constructor and owned lifetime runner. Name the actual entry kind at public guards and check both controlled-session owners before preparation effects. Preserve ordinary behavior and public mapped inhibition. Refuse unsupported replacement lifecycle composition before retiring or mutating the old owner. Tests cover both map profiles, finite/master semantics, stale ownership, repeated replacement refusals and ordinary/public guard parity. No production F/G provider or public activation is introduced. --- .../dockerdeploy/current_app_command_run.go | 6 +- .../current_controlled_session_run.go | 5 +- .../current_controlled_session_run_test.go | 3 +- .../dockerdeploy/current_runtime_test_v1.go | 2 +- internal/dockerdeploy/current_shell_run.go | 2 +- .../current_workload_lifecycle.go | 3 + internal/dockerdeploy/current_workload_run.go | 4 +- internal/dockerdeploy/live_run_mapped.go | 8 +- internal/dockerdeploy/mapped_entry_adapter.go | 97 +++++ .../dockerdeploy/mapped_entry_adapter_test.go | 398 ++++++++++++++++++ 10 files changed, 519 insertions(+), 9 deletions(-) create mode 100644 internal/dockerdeploy/mapped_entry_adapter.go create mode 100644 internal/dockerdeploy/mapped_entry_adapter_test.go diff --git a/internal/dockerdeploy/current_app_command_run.go b/internal/dockerdeploy/current_app_command_run.go index b62c0c78..3cc26fb0 100644 --- a/internal/dockerdeploy/current_app_command_run.go +++ b/internal/dockerdeploy/current_app_command_run.go @@ -118,7 +118,11 @@ func runCurrentAppCommandV1(ctx context.Context, input CurrentAppCommandRunInput if input.DeployedOnly && state.Deployment == nil { return fmt.Errorf("deployed-only app commands require an installed deployment") } - document, err := unmappedRuntimeDocumentV1(state) + kind := mappedApplicationCommandV1 + if input.DeployedOnly { + kind = mappedDeployedCommandV1 + } + document, err := unmappedRuntimeDocumentForEntryV1(state, kind) if err != nil { return fmt.Errorf("runtime blueprint: %w", err) } diff --git a/internal/dockerdeploy/current_controlled_session_run.go b/internal/dockerdeploy/current_controlled_session_run.go index c3337008..e79ebf36 100644 --- a/internal/dockerdeploy/current_controlled_session_run.go +++ b/internal/dockerdeploy/current_controlled_session_run.go @@ -160,6 +160,9 @@ func runCurrentControlledSessionV1( if err != nil { return result, fmt.Errorf("controlled-session workload runtime: %w", err) } + if err := requireUnmappedSessionOwnersV1(controller.current.State, workload.current.State); err != nil { + return result, err + } for _, runtime := range []struct { role string dir string @@ -426,7 +429,7 @@ func loadCurrentControlledSessionRuntimeV1( if !found { return currentControlledSessionRuntimeV1{}, fmt.Errorf("runtime state is missing; run `reploy stage` or `reploy install`") } - document, err := unmappedRuntimeDocumentV1(state) + document, err := unmappedRuntimeDocumentForEntryV1(state, mappedControlledSessionV1) if err != nil { return currentControlledSessionRuntimeV1{}, fmt.Errorf("runtime blueprint: %w", err) } diff --git a/internal/dockerdeploy/current_controlled_session_run_test.go b/internal/dockerdeploy/current_controlled_session_run_test.go index 7edfea95..fa95d28b 100644 --- a/internal/dockerdeploy/current_controlled_session_run_test.go +++ b/internal/dockerdeploy/current_controlled_session_run_test.go @@ -421,13 +421,14 @@ func TestRunCurrentControlledSessionV1RejectsConfiguredPrivateEnvironmentBeforeP t.Fatalf("create private environment = %t, %v", created, err) } planned := false + current, _ := runtimeCurrentBuildFixture(t) _, err := runCurrentControlledSessionV1(t.Context(), CurrentControlledSessionRunInputV1{ ControllerDeploymentDir: controllerDir, WorkloadDeploymentDir: workloadDir, }, currentControlledSessionRunBackendV1{ acquire: deploy.AcquireOperationLock, loadRuntime: func(context.Context, *deploy.OperationLock, string, StagedProviderBuildRuntimeV1) (currentControlledSessionRuntimeV1, error) { - return currentControlledSessionRuntimeV1{}, nil + return currentControlledSessionRuntimeV1{current: current}, nil }, privateEnv: preparePrivateWorkloadEnvironmentV1, prepareController: preparedControlledSessionControllerForTestV1, diff --git a/internal/dockerdeploy/current_runtime_test_v1.go b/internal/dockerdeploy/current_runtime_test_v1.go index 5050ec71..096aecd2 100644 --- a/internal/dockerdeploy/current_runtime_test_v1.go +++ b/internal/dockerdeploy/current_runtime_test_v1.go @@ -88,7 +88,7 @@ func runCurrentRuntimeTestV1(ctx context.Context, input CurrentRuntimeTestInputV if !found { return fmt.Errorf("runtime state is missing; run `reploy stage` or `reploy install`") } - document, err := unmappedRuntimeDocumentV1(state) + document, err := unmappedRuntimeDocumentForEntryV1(state, mappedRuntimeTestV1) if err != nil { return fmt.Errorf("runtime blueprint: %w", err) } diff --git a/internal/dockerdeploy/current_shell_run.go b/internal/dockerdeploy/current_shell_run.go index c74ad19e..2a1fc636 100644 --- a/internal/dockerdeploy/current_shell_run.go +++ b/internal/dockerdeploy/current_shell_run.go @@ -103,7 +103,7 @@ func runCurrentShellV1(ctx context.Context, input CurrentShellRunInputV1, backen if !found { return fmt.Errorf("runtime state is missing; run `reploy stage` or `reploy install`") } - document, err := unmappedRuntimeDocumentV1(state) + document, err := unmappedRuntimeDocumentForEntryV1(state, mappedShellV1) if err != nil { return fmt.Errorf("runtime blueprint: %w", err) } diff --git a/internal/dockerdeploy/current_workload_lifecycle.go b/internal/dockerdeploy/current_workload_lifecycle.go index 4b4a741a..1682fdce 100644 --- a/internal/dockerdeploy/current_workload_lifecycle.go +++ b/internal/dockerdeploy/current_workload_lifecycle.go @@ -52,6 +52,9 @@ type currentWorkloadLifecycleBackendV1 struct { } func RunCurrentWorkloadLifecycleV1(ctx context.Context, input CurrentWorkloadLifecycleInputV1) error { + if _, err := unmappedRuntimeDocumentForEntryV1(input.Current.State, mappedLifecycleHookV1); err != nil { + return err + } return runCurrentWorkloadLifecycleV1(ctx, input, currentWorkloadLifecycleBackendV1{ acquire: deploy.AcquireOperationLock, planStart: func(plan CurrentRuntimePlanV1, current CurrentBuild) (LifecyclePlan, error) { diff --git a/internal/dockerdeploy/current_workload_run.go b/internal/dockerdeploy/current_workload_run.go index 819cc4bf..286b4f7b 100644 --- a/internal/dockerdeploy/current_workload_run.go +++ b/internal/dockerdeploy/current_workload_run.go @@ -127,7 +127,7 @@ func runCurrentWorkloadV1(ctx context.Context, input CurrentWorkloadRunInputV1, if !found { return fmt.Errorf("runtime state is missing; run `reploy stage` or `reploy install`") } - if _, err := unmappedRuntimeDocumentV1(state); err != nil { + if _, err := unmappedRuntimeDocumentForEntryV1(state, mappedWorkloadEntryKindV1(input.Action)); err != nil { return err } recoverStop := func(cause error) error { @@ -182,7 +182,7 @@ func runCurrentWorkloadV1(ctx context.Context, input CurrentWorkloadRunInputV1, if state.Current == nil { return recoverStop(fmt.Errorf("%s", currentBuildRecoveryMessageV1(state, "runtime build is missing"))) } - document, err := unmappedRuntimeDocumentV1(state) + document, err := unmappedRuntimeDocumentForEntryV1(state, mappedWorkloadEntryKindV1(input.Action)) if err != nil { return recoverStop(fmt.Errorf("runtime blueprint: %w", err)) } diff --git a/internal/dockerdeploy/live_run_mapped.go b/internal/dockerdeploy/live_run_mapped.go index 57e535c1..4b2768bb 100644 --- a/internal/dockerdeploy/live_run_mapped.go +++ b/internal/dockerdeploy/live_run_mapped.go @@ -51,7 +51,11 @@ type mappedLiveRunBackendV1 struct { // Close must interrupt Read. Borrowed readers are refused before engine effects. // It is private plumbing: no public mapped guard or F qualification is supplied. func runMappedLiveInstanceV1(admission context.Context, directory string, ops deploy.IdentityMappingOperationsV1, runID string, plan mappedEntryPlanV1, options RunOptions) error { - return runMappedLiveInstanceWithBackendV1(admission, directory, ops, runID, plan, options, mappedLiveRunBackendV1{ + return runMappedLiveInstanceWithBackendV1(admission, directory, ops, runID, plan, options, mappedLiveHostBackendV1()) +} + +func mappedLiveHostBackendV1() mappedLiveRunBackendV1 { + return mappedLiveRunBackendV1{ acquire: deploy.AcquireOperationLock, start: func(ctx context.Context, ops deploy.IdentityMappingOperationsV1, state deploy.StateV1, active identitymapping.Geometry, operation string, spec CommandSpec, policy mappedSeccompPolicyV1, binding mappedlaunch.Binding, options RunOptions) (*ownedMappedWaiterV1, error) { p, err := prepareMappedPodmanCreateV1(ctx, ops, state, active, operation, spec, policy) @@ -63,7 +67,7 @@ func runMappedLiveInstanceV1(admission context.Context, directory string, ops de recover: func(ops deploy.IdentityMappingOperationsV1, ctx context.Context, state deploy.StateV1, operation string) (deploy.StateV1, error) { return ops.Recover(ctx, state, operation, false) }, - }) + } } func runMappedLiveInstanceWithBackendV1(admission context.Context, directory string, ops deploy.IdentityMappingOperationsV1, runID string, plan mappedEntryPlanV1, options RunOptions, backend mappedLiveRunBackendV1) (failure error) { diff --git a/internal/dockerdeploy/mapped_entry_adapter.go b/internal/dockerdeploy/mapped_entry_adapter.go new file mode 100644 index 00000000..e689f7d1 --- /dev/null +++ b/internal/dockerdeploy/mapped_entry_adapter.go @@ -0,0 +1,97 @@ +package dockerdeploy + +import ( + "context" + "errors" + "fmt" + "path/filepath" + + "github.com/omry/reploy/internal/blueprint" + "github.com/omry/reploy/internal/deploy" +) + +// Public adapters name their actual operation before any runtime effect. Keep +// the public inhibition as well as the narrower occupied/host-native refusal. +// This is not an admission flag, and ordinary entries retain their old path. +func unmappedRuntimeDocumentForEntryV1(state deploy.StateV1, kind mappedEntryKindV1) (blueprint.Document, error) { + mode, err := mappedEntryModeForKindV1(kind) + if err != nil { + return blueprint.Document{}, err + } + document, err := unmappedRuntimeDocumentV1(state) + if err != nil { + _, modeErr := runtimeDocumentForEntryModeV1(state, mode) + return blueprint.Document{}, errors.Join(err, modeErr) + } + return document, nil +} + +func requireUnmappedSessionOwnersV1(controller, workload deploy.StateV1) error { + var failures []error + for _, owner := range []struct { + name string + state deploy.StateV1 + }{{"controller", controller}, {"workload", workload}} { + if _, err := unmappedRuntimeDocumentForEntryV1(owner.state, mappedControlledSessionV1); err != nil { + failures = append(failures, fmt.Errorf("controlled-session %s owner: %w", owner.name, err)) + } + } + return errors.Join(failures...) +} + +func mappedWorkloadEntryKindV1(action string) mappedEntryKindV1 { + if action == "restart" { + return mappedHostReplacementV1 + } + return mappedWorkloadStartV1 +} + +// The existing trusted caller supplies held-lock current snapshots and owns the +// queue lease. Construction grants no authority; the shared runner revalidates +// native admission and owns release, completion and cleanup. No public caller +// reaches this private composition while the public guards remain inhibited. +func runMappedControlledEntryV1(admission context.Context, directory string, ops deploy.IdentityMappingOperationsV1, runID string, input mappedEntryInputV1, options RunOptions) error { + return runMappedControlledEntryWithBackendV1(admission, directory, ops, runID, input, options, mappedLiveHostBackendV1()) +} + +func runMappedControlledEntryWithBackendV1(admission context.Context, directory string, ops deploy.IdentityMappingOperationsV1, runID string, input mappedEntryInputV1, options RunOptions, backend mappedLiveRunBackendV1) error { + if input.Kind == mappedHostReplacementV1 { + return fmt.Errorf("mapped replacement requires exact retirement and fresh admission") + } + plan, err := constructMappedEntryPlanV1(input) + if err != nil { + return err + } + return runMappedLiveInstanceWithBackendV1(admission, directory, ops, runID, plan, options, backend) +} + +// Replacement requires complete startup/retirement lifecycle composition. Until +// that integration is qualified, refuse before retiring the old instance. The +// caller retains its lock and all native, lifecycle and queue ownership. +func runMappedReplacementEntryV1(admission context.Context, directory string, ops deploy.IdentityMappingOperationsV1, runID string, reload func(context.Context, deploy.IdentityMappingOperationsV1) (mappedEntryInputV1, error), options RunOptions) error { + if ops.Operation == nil { + return fmt.Errorf("mapped replacement requires its operation lock") + } + if admission == nil || options.Context == nil || admission.Err() != nil || options.Context.Err() != nil { + return fmt.Errorf("mapped replacement requires live admission and lifetime contexts") + } + absolute, err := filepath.Abs(directory) + if err != nil || directory == "" || filepath.Dir(filepath.Dir(ops.Operation.Path())) != absolute { + return fmt.Errorf("mapped replacement lock belongs to a different deployment") + } + if err := ops.Operation.RequireQueueEntryLeaseHeldV1(runID); err != nil { + return err + } + queue, _, err := ops.Operation.ReadLiveRunQueueV1() + if err != nil { + return err + } + run, found := findLiveRunV1(queue, runID) + if !found || run.Status != deploy.LiveRunStatusActiveV1 || run.Container != "" { + return fmt.Errorf("mapped replacement requires an unstarted active live entry") + } + if _, err := requireMappedWorkloadOwnerV1(admission, ops); err != nil { + return err + } + return fmt.Errorf("mapped replacement lifecycle composition is not yet available") +} diff --git a/internal/dockerdeploy/mapped_entry_adapter_test.go b/internal/dockerdeploy/mapped_entry_adapter_test.go new file mode 100644 index 00000000..7d345f89 --- /dev/null +++ b/internal/dockerdeploy/mapped_entry_adapter_test.go @@ -0,0 +1,398 @@ +package dockerdeploy + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "reflect" + "strings" + "testing" + + "github.com/omry/reploy/internal/blueprint" + "github.com/omry/reploy/internal/deploy" +) + +// The existing fixture uses real locks, queue leases, state and waiter protocol. +// Native admission, engine and F/G observations are injected; these positive +// cases do not qualify a live engine or remove public activation gates. +func TestMappedControlledEntryV1UsesSharedLifetime(t *testing.T) { + for _, profile := range []string{"exact", "bounded"} { + for _, kind := range []mappedEntryKindV1{mappedWorkloadStartV1, mappedApplicationCommandV1, mappedRuntimeTestV1, mappedLifecycleHookV1} { + t.Run(fmt.Sprint(profile, kind), func(t *testing.T) { + f := newMappedLiveFixtureV1(t, profile, kind) + err := runMappedControlledEntryWithBackendV1(f.waiter.ctx, f.directory, f.waiter.prepared.ops, f.run.ID, f.input, f.waiter.options, f.backend) + if kind == mappedWorkloadStartV1 { + if !errors.Is(err, errMappedMasterExitedV1) { + t.Fatalf("required master completion = %v", err) + } + } else if err != nil { + t.Fatalf("finite entry became master failure: %v", err) + } + _, queue, state := f.inspect(t) + if len(queue.Runs) != 0 || state.IdentityMapping.PendingOperation != nil || f.starts != 1 || f.recoveries != 1 || f.waiter.created != 1 || f.waiter.removed != 1 || f.waiter.admissions != 1 { + t.Fatal("adapter did not use one owned release and retirement") + } + if state.IdentityMapping.AcceptedUIDs == nil || len(*state.IdentityMapping.AcceptedUIDs) == 0 || len(state.IdentityMapping.UIDAssignments) == 0 { + t.Fatal("adapter discarded retained identity history") + } + select { + case frame := <-f.waiter.frame: + if frame.Binding != f.plan.binding || frame.Container != f.waiter.id { + t.Fatal("adapter substituted the shared final entry binding") + } + default: + t.Fatal("missing single release frame") + } + }) + } + } +} + +func TestMappedControlledEntryV1RefusesBeforeEffects(t *testing.T) { + cases := []struct { + name string + change func(*mappedEntryInputV1) + }{ + {"shell", func(i *mappedEntryInputV1) { i.Kind = mappedShellV1 }}, + {"session", func(i *mappedEntryInputV1) { i.Kind = mappedControlledSessionV1 }}, + {"deployed command", func(i *mappedEntryInputV1) { i.Kind = mappedDeployedCommandV1 }}, + {"host native", func(i *mappedEntryInputV1) { i.Kind = mappedHostNativeCommandV1 }}, + {"replacement cannot skip retirement", func(i *mappedEntryInputV1) { i.Kind = mappedHostReplacementV1 }}, + {"unknown", func(i *mappedEntryInputV1) { i.Kind = 255 }}, + {"tty", func(i *mappedEntryInputV1) { i.TTY = true }}, + {"private environment", func(i *mappedEntryInputV1) { i.PrivateEnvironment = true }}, + {"observed exit", func(i *mappedEntryInputV1) { i.ObservedExit = true }}, + {"stale image", func(i *mappedEntryInputV1) { i.Runtime.Image += "-stale" }}, + {"missing role", func(i *mappedEntryInputV1) { i.Command = "undeclared" }}, + } + for _, profile := range []string{"exact", "bounded"} { + for _, tc := range cases { + t.Run(profile+"/"+tc.name, func(t *testing.T) { + f := newMappedLiveFixtureV1(t, profile, mappedApplicationCommandV1) + op := f.waiter.prepared.ops.Operation + before, _, err := op.ReadStateV1() + if err != nil { + t.Fatal(err) + } + tc.change(&f.input) + err = runMappedControlledEntryWithBackendV1(f.waiter.ctx, f.directory, f.waiter.prepared.ops, f.run.ID, f.input, f.waiter.options, f.backend) + if err == nil || f.starts != 0 || f.recoveries != 0 || f.waiter.created != 0 || f.waiter.admissions != 0 || f.waiter.application.Load() != 0 { + t.Fatalf("unsupported entry reached effects: %v", err) + } + if err := op.RequireHeld(); err != nil { + t.Fatal("preparation failure lost caller lock", err) + } + after, _, err := op.ReadStateV1() + if err != nil || !reflect.DeepEqual(before, after) { + t.Fatal("refused adapter changed native state", err) + } + }) + } + } +} + +func TestMappedControlledEntryV1RevalidatesCurrentNativeOwner(t *testing.T) { + for _, profile := range []string{"exact", "bounded"} { + t.Run(profile, func(t *testing.T) { + f := newMappedLiveFixtureV1(t, profile, mappedApplicationCommandV1) + state := cloneMappedWorkloadStateFixtureV1(t, f.input.Current.State) + state.IdentityMapping.Revision = "99" + state.IdentityMapping.PendingOperation.ExpectedRevision = "99" + writeMappedLiveStateFixtureV1(t, f.waiter.prepared.ops.Operation, state) + err := runMappedControlledEntryWithBackendV1(f.waiter.ctx, f.directory, f.waiter.prepared.ops, f.run.ID, f.input, f.waiter.options, f.backend) + if err == nil || f.starts != 0 || f.waiter.created != 0 { + t.Fatalf("old prepared snapshots authorized changed state: %v", err) + } + }) + } +} + +func TestMappedControlledEntryV1ProductionPathRequiresProof(t *testing.T) { + for _, profile := range []string{"exact", "bounded"} { + t.Run(profile, func(t *testing.T) { + f := newMappedLiveFixtureV1(t, profile, mappedApplicationCommandV1) + ops := f.waiter.prepared.ops + ops.Evidence = nil + if err := runMappedControlledEntryV1(f.waiter.ctx, f.directory, ops, f.run.ID, f.input, f.waiter.options); err == nil { + t.Fatal("production adapter accepted missing effect evidence") + } + if f.waiter.created != 0 || f.waiter.admissions != 0 || f.waiter.application.Load() != 0 { + t.Fatal("unqualified production adapter released a payload") + } + }) + } +} + +func TestMappedReplacementEntryV1RefusesMissingOwnershipOrProvider(t *testing.T) { + called := false + reload := func(context.Context, deploy.IdentityMappingOperationsV1) (mappedEntryInputV1, error) { + called = true + return mappedEntryInputV1{}, errors.New("unexpected admission") + } + if err := runMappedReplacementEntryV1(t.Context(), "", deploy.IdentityMappingOperationsV1{}, "", reload, RunOptions{}); err == nil || called { + t.Fatal("replacement reached admission without ownership", err) + } + for _, profile := range []string{"exact", "bounded"} { + t.Run(profile, func(t *testing.T) { + f := newMappedLiveFixtureV1(t, profile, mappedApplicationCommandV1) + err := runMappedReplacementEntryV1(f.waiter.ctx, f.directory, f.waiter.prepared.ops, f.run.ID, reload, f.waiter.options) + if err == nil || !strings.Contains(err.Error(), "observation provider") || called || f.waiter.created != 0 { + t.Fatalf("replacement escaped unqualified F/G boundary: %v", err) + } + if err := f.waiter.prepared.ops.Operation.RequireHeld(); err != nil { + t.Fatal("refusal lost caller lock", err) + } + }) + } +} + +func TestMappedReplacementEntryV1RefusesInvalidCallerBeforeRetirement(t *testing.T) { + for _, name := range []string{"foreign directory", "no lifetime", "cancelled lifetime", "no lease"} { + t.Run(name, func(t *testing.T) { + f := newMappedLiveFixtureV1(t, "exact", mappedApplicationCommandV1) + ops, directory, options := f.waiter.prepared.ops, f.directory, f.waiter.options + switch name { + case "foreign directory": + directory = t.TempDir() + case "no lifetime": + options.Context = nil + case "cancelled lifetime": + ctx, cancel := context.WithCancel(t.Context()) + cancel() + options.Context = ctx + case "no lease": + if err := f.lease.Close(); err != nil { + t.Fatal(err) + } + } + before, _, err := ops.Operation.ReadStateV1() + if err != nil { + t.Fatal(err) + } + reloaded := false + err = runMappedReplacementEntryV1(f.waiter.ctx, directory, ops, f.run.ID, func(context.Context, deploy.IdentityMappingOperationsV1) (mappedEntryInputV1, error) { + reloaded = true + return f.input, nil + }, options) + if err == nil || reloaded || f.waiter.created != 0 { + t.Fatalf("invalid replacement caller reached admission: %v", err) + } + after, _, err := ops.Operation.ReadStateV1() + if err != nil || !reflect.DeepEqual(before, after) { + t.Fatal("invalid replacement caller changed native state", err) + } + }) + } +} + +// Even a qualified observation owner cannot make this incomplete replacement +// composition safe. Refusal must preserve the old live owner on every attempt. +func TestMappedReplacementEntryV1QualifiedOwnerRefusesBeforeEffects(t *testing.T) { + for _, profile := range []string{"exact", "bounded"} { + for _, stream := range []string{"none", "borrowed", "owned"} { + t.Run(profile+"/"+stream, func(t *testing.T) { + f := newMappedWorkloadFixtureV1(t, profile) + op := f.ops.Operation + before, _, err := op.ReadStateV1() + if err != nil { + t.Fatal(err) + } + queue, _, err := op.ReadLiveRunQueueV1() + if err != nil { + t.Fatal(err) + } + old, found, err := op.ReadWorkloadLifecycleV1() + if err != nil || !found { + t.Fatal("missing existing owner", err) + } + options := f.live.waiter.options + if stream == "borrowed" { + options.Stdin = strings.NewReader("borrowed") + } + if stream == "owned" { + options.Stdin = io.NopCloser(strings.NewReader("owned")) + } + for attempt := 0; attempt < 2; attempt++ { + err := runMappedReplacementEntryV1(f.live.waiter.ctx, f.live.directory, f.ops, f.live.run.ID, f.reload, options) + if err == nil || !strings.Contains(err.Error(), "replacement lifecycle composition is not yet available") { + t.Fatalf("unsupported replacement was not refused: %v", err) + } + after, _, err := op.ReadStateV1() + if err != nil || !reflect.DeepEqual(before, after) { + t.Fatal("refusal changed native owner", err) + } + afterQueue, _, err := op.ReadLiveRunQueueV1() + if err != nil || !reflect.DeepEqual(queue, afterQueue) { + t.Fatal("refusal changed queue", err) + } + record, found, err := op.ReadWorkloadLifecycleV1() + if err != nil || !found || record != old { + t.Fatal("refusal changed lifecycle owner", err) + } + if len(f.trace) != 0 || f.live.waiter.created != 0 || f.live.waiter.admissions != 0 || f.live.waiter.application.Load() != 0 { + t.Fatal("refusal reached retirement, admission or application effects", f.trace) + } + if err := op.RequireHeld(); err != nil { + t.Fatal("refusal lost caller lock", err) + } + } + }) + } + } +} + +func TestMappedEntryAdaptersV1PublicKindsPreserveGuardsAndOrdinaryDocument(t *testing.T) { + kinds := []mappedEntryKindV1{mappedWorkloadStartV1, mappedApplicationCommandV1, mappedRuntimeTestV1, mappedShellV1, mappedControlledSessionV1, mappedLifecycleHookV1, mappedHostReplacementV1, mappedDeployedCommandV1, mappedHostNativeCommandV1} + for _, owner := range []string{"ordinary", "declaration", "unresolved", "policy", "retained"} { + state := mappedEntryStateFixtureV1(t, owner) + before, _ := json.Marshal(state) + want, err := blueprint.DecodeResolvedDocumentV1(state.Blueprint) + if err != nil { + t.Fatal(err) + } + for _, kind := range kinds { + document, err := unmappedRuntimeDocumentForEntryV1(state, kind) + if owner == "ordinary" { + if err != nil || !reflect.DeepEqual(document, want) { + t.Fatalf("ordinary entry %d changed: %v", kind, err) + } + } else { + if err == nil || !reflect.DeepEqual(document, blueprint.Document{}) || !strings.Contains(err.Error(), "not yet available") { + t.Fatalf("public mapped entry %s/%d escaped inhibition: %v", owner, kind, err) + } + if kind == mappedShellV1 || kind == mappedControlledSessionV1 || kind == mappedDeployedCommandV1 { + if !errors.Is(err, errMappedOccupiedEntryV1) { + t.Fatalf("occupied semantics lost: %v", err) + } + } + if kind == mappedHostNativeCommandV1 && !errors.Is(err, errMappedHostNativeEntryV1) { + t.Fatalf("host-native refusal lost: %v", err) + } + } + } + after, _ := json.Marshal(state) + if string(before) != string(after) { + t.Fatal("public guard changed owner state") + } + for _, kind := range []mappedEntryKindV1{0, 255} { + if _, err := unmappedRuntimeDocumentForEntryV1(state, kind); err == nil { + t.Fatal("unknown adapter kind accepted") + } + } + } + for _, action := range []string{"up", "down", "restart"} { + kind := mappedWorkloadEntryKindV1(action) + if (kind == mappedHostReplacementV1) != (action == "restart") { + t.Fatalf("restart lost replacement semantics: %s/%d", action, kind) + } + } +} + +func TestMappedEntryAdaptersV1CheckBothSessionOwners(t *testing.T) { + for _, controller := range []string{"ordinary", "declaration", "policy", "retained"} { + for _, workload := range []string{"ordinary", "declaration", "policy", "retained"} { + err := requireUnmappedSessionOwnersV1(mappedEntryStateFixtureV1(t, controller), mappedEntryStateFixtureV1(t, workload)) + if controller == "ordinary" && workload == "ordinary" { + if err != nil { + t.Fatal(err) + } + continue + } + if err == nil || !errors.Is(err, errMappedOccupiedEntryV1) { + t.Fatalf("session accepted mapped pair %s/%s: %v", controller, workload, err) + } + if controller != "ordinary" && !strings.Contains(err.Error(), "controller owner") || workload != "ordinary" && !strings.Contains(err.Error(), "workload owner") { + t.Fatalf("session did not inspect both owners: %v", err) + } + } + } +} + +func TestMappedEntryAdaptersV1PublicLifecycleRejectsBeforeHooks(t *testing.T) { + for _, owner := range []string{"declaration", "unresolved", "policy", "retained"} { + for _, action := range []string{"up", "down", "restart"} { + err := RunCurrentWorkloadLifecycleV1(t.Context(), CurrentWorkloadLifecycleInputV1{ + Current: CurrentBuild{State: mappedEntryStateFixtureV1(t, owner)}, Action: action, + }) + if err == nil || !strings.Contains(err.Error(), "not yet available") { + t.Fatalf("public lifecycle reached hooks or ordinary engine path: %s/%s: %v", owner, action, err) + } + } + } +} + +func TestMappedEntryAdaptersV1SessionPairRefusesBeforePreparation(t *testing.T) { + for _, role := range []string{"controller", "workload"} { + for _, owner := range []string{"declaration", "unresolved", "policy", "retained"} { + t.Run(role+"/"+owner, func(t *testing.T) { + root := t.TempDir() + controllerDir, workloadDir := filepath.Join(root, "controller"), filepath.Join(root, "workload") + for _, dir := range []string{controllerDir, workloadDir} { + if err := os.Mkdir(dir, 0700); err != nil { + t.Fatal(err) + } + } + ordinary := mappedEntryStateFixtureV1(t, "ordinary") + mapped := mappedEntryStateFixtureV1(t, owner) + loaded, effects := 0, 0 + backend := currentControlledSessionRunBackendV1{ + acquire: deploy.AcquireOperationLock, + loadRuntime: func(_ context.Context, op *deploy.OperationLock, dir string, _ StagedProviderBuildRuntimeV1) (currentControlledSessionRuntimeV1, error) { + if err := op.RequireHeld(); err != nil { + t.Fatal(err) + } + loaded++ + state := ordinary + if role == "controller" && dir == controllerDir || role == "workload" && dir == workloadDir { + state = mapped + } + return currentControlledSessionRuntimeV1{current: CurrentBuild{State: state}}, nil + }, + privateEnv: func(string) (privateWorkloadEnvironmentV1, error) { + effects++ + return privateWorkloadEnvironmentV1{}, errors.New("unexpected preparation") + }, + prepareController: preparedControlledSessionControllerForTestV1, + cleanupController: func(context.Context, *preparedControlledSessionControllerV1) error { return nil }, + prepareOutput: prepareOneShotOutput, + abortOutput: func(*oneShotOutputSession) error { return nil }, + publishOutput: func(*oneShotOutputSession) error { return nil }, + concurrency: PlanLiveRunConcurrencyV1, + newRunID: deploy.NewLiveRunIDV1, + newHandle: func() (string, error) { return "", errors.New("unexpected handle") }, + acquireLease: func(*deploy.OperationLock, string) (*deploy.QueueEntryLeaseV1, error) { + return nil, errors.New("unexpected lease") + }, + await: func(context.Context, string, *deploy.OperationLock, deploy.LiveRunV1, bool, io.Writer) (*deploy.OperationLock, error) { + return nil, errors.New("unexpected admission") + }, + plan: PlanControlledSessionV1, + run: func(context.Context, *deploy.OperationLock, *deploy.OperationLock, ControlledSessionExecutionPlanV1, ControlledSessionRunOptionsV1) (ControlledSessionRunResultV1, error) { + return ControlledSessionRunResultV1{}, errors.New("unexpected supervisor") + }, + } + _, err := runCurrentControlledSessionV1(t.Context(), CurrentControlledSessionRunInputV1{ControllerDeploymentDir: controllerDir, WorkloadDeploymentDir: workloadDir}, backend) + if err == nil || !errors.Is(err, errMappedOccupiedEntryV1) || !strings.Contains(err.Error(), role+" owner") || loaded != 2 || effects != 0 { + t.Fatalf("session reached preparation: loaded=%d effects=%d err=%v", loaded, effects, err) + } + for _, dir := range []string{controllerDir, workloadDir} { + op, err := deploy.AcquireOperationLock(t.Context(), dir) + if err != nil { + t.Fatal("session refusal leaked owner lock", err) + } + queue, _, err := op.ReadLiveRunQueueV1() + if err != nil || len(queue.Runs) != 0 { + t.Fatal("session refusal admitted a live entry", err) + } + if err := op.Unlock(); err != nil { + t.Fatal(err) + } + } + }) + } + } +}