From d90b3987e9040fe0996c181de54541313027a5ea Mon Sep 17 00:00:00 2001 From: Omry Yadan Date: Thu, 22 Oct 2026 21:21:17 +0800 Subject: [PATCH] B5: preserve pending identity mappings through checked recovery Add private mapping operations for noncreating status/check, staged configure, exact pending phases, proved-unused correction or cancellation, and conservative recovery under the existing operation lock. Preserve accepted reservations, retained storage and installation authority through retries and crash boundaries. Resolve the selected blueprint and override once. Keep mapped execution and real storage effect owners in their later slices; do not grant admission or release retained IDs. Validation: complete local Go, Python/package, Docker CLI smoke, release and docs checks passed against the exact final snapshot. Focused mapping and race checks passed. Current-agent review and fresh independent full review are complete with no open findings. --- .../multi-identity-pending-operations.yaml | 2 + .../deploy/identity_mapping_operations.go | 383 +++++++++ .../identity_mapping_operations_test.go | 776 ++++++++++++++++++ .../deploy/identity_mapping_state_file.go | 3 + internal/deploy/package_overrides.go | 17 +- .../deploy/package_overrides_open_unix.go | 18 + .../package_overrides_open_unix_test.go | 118 +++ .../deploy/package_overrides_open_windows.go | 24 + internal/deploy/state_v1_file.go | 3 + internal/dockerdeploy/build_publication.go | 3 + .../dockerdeploy/identity_mapping_input.go | 49 +- .../identity_mapping_operations_test.go | 217 +++++ .../identity_mapping_retention_test.go | 51 +- 13 files changed, 1651 insertions(+), 13 deletions(-) create mode 100644 .changes/unreleased/multi-identity-pending-operations.yaml create mode 100644 internal/deploy/identity_mapping_operations.go create mode 100644 internal/deploy/identity_mapping_operations_test.go create mode 100644 internal/deploy/package_overrides_open_unix.go create mode 100644 internal/deploy/package_overrides_open_unix_test.go create mode 100644 internal/deploy/package_overrides_open_windows.go create mode 100644 internal/dockerdeploy/identity_mapping_operations_test.go diff --git a/.changes/unreleased/multi-identity-pending-operations.yaml b/.changes/unreleased/multi-identity-pending-operations.yaml new file mode 100644 index 00000000..95a042bc --- /dev/null +++ b/.changes/unreleased/multi-identity-pending-operations.yaml @@ -0,0 +1,2 @@ +kind: Added +body: Add private identity-mapping configuration and recovery metadata with inhibited pending intents, exact retries, and conservative reservation retention. Read selected overrides through checked regular-file handles without following replacement symlinks. Reject build publication, generation changes, and generic mapping revision changes until a pending mapping operation finishes. Mapped execution remains unavailable. diff --git a/internal/deploy/identity_mapping_operations.go b/internal/deploy/identity_mapping_operations.go new file mode 100644 index 00000000..dabb7b83 --- /dev/null +++ b/internal/deploy/identity_mapping_operations.go @@ -0,0 +1,383 @@ +package deploy + +import ( + "context" + "crypto/rand" + "encoding/json" + "fmt" + "math" + "path/filepath" + "reflect" + "slices" + "strconv" + + "github.com/omry/reploy/internal/blueprint" + "github.com/omry/reploy/internal/identitymapping" +) + +// IdentityMappingEvidenceV1 is a trusted effect-owner seam, not operator input. +// Plan proves complete inactive inventory and compatible storage/history and +// returns exact planned retained-storage handles without effects. Unused proves that the +// exact intent's candidate has never been used, including storage/preparation. +// Verify reconciles the recorded owner, operation, generations and exact handles. +// Container absence alone cannot satisfy any of these proofs. G/H supply real +// effect owners; B5 exercises their metadata boundary with injected effects. +type IdentityMappingEvidenceV1 interface { + Plan(context.Context, StateV1, identitymapping.Geometry) ([]string, error) + Unused(context.Context, StateV1) error + Verify(context.Context, StateV1) error +} + +// IdentityMappingOperationsV1 holds no independent authority or admission cache. +// Each call rereads the selected owner under its existing operation lock. +type IdentityMappingOperationsV1 struct { + Operation *OperationLock + Evidence IdentityMappingEvidenceV1 + hostCheck func(context.Context, string, []identitymapping.Interval, []identitymapping.Interval) (identitymapping.HostIdentity, error) + write func(string, []byte) error +} + +type IdentityMappingStatusV1 struct { + State StateV1 + Status string + Reservations *identitymapping.Geometry +} + +// Status creates nothing and reports recorded authority even when desired input +// is missing. Reservations include the candidate as well as protected history. +func (ops IdentityMappingOperationsV1) Status() (IdentityMappingStatusV1, error) { + state, found, err := ops.Operation.ReadStateV1() + if err != nil || !found || state.TerminalRemoval || state.Staging == nil && state.Deployment == nil { + return IdentityMappingStatusV1{}, fmt.Errorf("mapping status requires an existing selected owner: %v", err) + } + result := IdentityMappingStatusV1{State: state, Status: "ordinary"} + m := state.IdentityMapping + if m == nil { + document, err := blueprint.DecodeResolvedDocumentV1(state.Blueprint) + if err != nil { + return IdentityMappingStatusV1{}, err + } + if document.Environment.Runtime.IdentityMapping != nil { + result.Status = "inhibited" + } + return result, nil + } + result.Status = "retained" + if m.AdmittedGeneration != nil { + result.Status = "admitted" + } + var geometry *identitymapping.Geometry + if len(m.UIDAssignments) != 0 { + geometry = geometryPointerV1(m.geometry().numeric()) + } + if p := m.PendingOperation; p != nil { + reserved, err := identitymapping.RetainGeometry(p.Candidate.numeric(), geometry) + if err != nil { + return IdentityMappingStatusV1{}, err + } + geometry = &reserved + result.Status = "inhibited" + if p.Phase == "intent" { + result.Status = "candidate" + } + } + if m.Revoked { + result.Status = "revoked" + } + result.Reservations = geometry + return result, nil +} + +// Check validates one captured effective input without modifying any files. +// A successful check does not accept a candidate or grant execution admission. +func (ops IdentityMappingOperationsV1) Check(ctx context.Context, candidate identitymapping.Geometry) (StateV1, error) { + status, err := ops.Status() + if err != nil { + return StateV1{}, err + } + if _, _, err := ops.check(ctx, status.State, candidate); err != nil { + return StateV1{}, err + } + return status.State, nil +} + +func (ops IdentityMappingOperationsV1) check(ctx context.Context, state StateV1, candidate identitymapping.Geometry) (identitymapping.Geometry, identitymapping.HostIdentity, error) { + if ctx == nil { + return identitymapping.Geometry{}, identitymapping.HostIdentity{}, fmt.Errorf("mapping operation requires a context") + } + if err := ctx.Err(); err != nil { + return identitymapping.Geometry{}, identitymapping.HostIdentity{}, err + } + if state.Deployment != nil && state.IdentityMapping == nil { + return identitymapping.Geometry{}, identitymapping.HostIdentity{}, fmt.Errorf("first installed mapping opt-in requires its reviewed destination adapter") + } + var protected *identitymapping.Geometry + if m := state.IdentityMapping; m != nil { + if m.Revoked || m.Profile != candidate.Profile { + return identitymapping.Geometry{}, identitymapping.HostIdentity{}, fmt.Errorf("mapping is revoked or its retained profile differs") + } + if len(m.UIDAssignments) != 0 { + protected = geometryPointerV1(m.geometry().numeric()) + } + } + reserved, err := identitymapping.RetainGeometry(candidate, protected) + if err != nil { + return reserved, identitymapping.HostIdentity{}, err + } + check := ops.hostCheck + if check == nil { + check = identitymapping.CheckHost + } + identity, err := ops.Operation.checkIdentityMappingHostV1(ctx, reserved.UIDs, reserved.GIDs, check) + if err != nil { + return reserved, identity, err + } + // Check old and replacement candidates independently against protected + // history. A never-used candidate may be corrected to a different binding; + // it remains reserved until the separate Unused proof permits replacement. + if m := state.IdentityMapping; m != nil && m.PendingOperation != nil { + old, err := identitymapping.RetainGeometry(m.PendingOperation.Candidate.numeric(), protected) + if err != nil { + return reserved, identity, err + } + other, err := ops.Operation.checkIdentityMappingHostV1(ctx, old.UIDs, old.GIDs, check) + if err != nil || other != identity { + return reserved, identity, fmt.Errorf("pending reservations no longer pass the same host check: %v", err) + } + } + if m := state.IdentityMapping; m != nil && (m.OperatorUID != strconv.FormatUint(uint64(identity.UID), 10) || m.OperatorGID != strconv.FormatUint(uint64(identity.GID), 10)) { + return reserved, identity, fmt.Errorf("mapping operator differs from retained authority") + } + return reserved, identity, nil +} + +// Configure records only an inhibited intent. It never builds, starts, chowns, +// accepts proposed assignments or changes admission. expected is the caller's +// complete locked observation, binding selected input, generation and revision. +func (ops IdentityMappingOperationsV1) Configure(ctx context.Context, expected StateV1, candidate identitymapping.Geometry, operationID string) (StateV1, error) { + if !safeRecoveryIdentity(operationID) { + return StateV1{}, fmt.Errorf("mapping configuration requires a safe operation ID") + } + canonical, err := identitymapping.ValidateGeometry(candidate) + if err != nil { + return StateV1{}, err + } + _, operator, err := ops.check(ctx, expected, canonical) + if err != nil { + return StateV1{}, err + } + return ops.change(ctx, expected, func(state *StateV1) error { + m := state.IdentityMapping + if m != nil && m.PendingOperation != nil && m.PendingOperation.Kind != "configure" { + return fmt.Errorf("pending operation %s has unsupported kind %q", m.PendingOperation.OperationID, m.PendingOperation.Kind) + } + if ops.Evidence == nil { + return fmt.Errorf("mapping configuration requires complete inactive inventory") + } + observedCandidate, _ := identitymapping.ValidateGeometry(canonical) + handles, err := ops.Evidence.Plan(ctx, cloneMappingStateV1(*state), observedCandidate) + handles = append([]string(nil), handles...) + if err != nil || validateMappingHandlesV1(handles) != nil { + return fmt.Errorf("mapping inventory is unknown or invalid: %v", err) + } + if m == nil { + if state.Staging == nil { + return fmt.Errorf("first installed mapping opt-in requires its reviewed destination adapter") + } + id, err := newOpaqueAdmissionIDV1(rand.Reader, "mapping", "mapping owner") + if err != nil { + return err + } + owner, err := filepath.EvalSymlinks(filepath.Dir(filepath.Dir(ops.Operation.path))) + if err != nil { + return err + } + m = &IdentityMappingStateV1{Schema: IdentityMappingStateSchemaV1, InstallationID: id, OwnerDir: owner, Revision: "0", OperatorUID: strconv.FormatUint(uint64(operator.UID), 10), OperatorGID: strconv.FormatUint(uint64(operator.GID), 10), Profile: canonical.Profile} + state.IdentityMapping = m + } + if p := m.PendingOperation; p != nil { + if !reflect.DeepEqual(p.ExpectedGeneration, state.Current) || !reflect.DeepEqual(p.PriorAccepted, m.AdmittedGeneration) || p.Phase == "intent" && p.ExpectedRevision != m.Revision { + return fmt.Errorf("pending intent observation is stale") + } + if p.OperationID == operationID && p.Phase == "intent" && reflect.DeepEqual(p.Candidate.numeric(), canonical) && reflect.DeepEqual(p.OwnedObjects, handles) { + return nil // exact retry; change detects identical bytes + } + if p.OperationID == operationID || p.Phase != "intent" { + return fmt.Errorf("pending operation %s requires exact recovery", p.OperationID) + } + if err := ops.Evidence.Unused(ctx, cloneMappingStateV1(*state)); err != nil { + return fmt.Errorf("candidate replacement lacks never-used proof: %w", err) + } + } + m.PendingOperation = &IdentityMappingOperationV1{OperationID: operationID, Kind: "configure", ExpectedRevision: strconv.FormatUint(mappingDecimalV1(m.Revision)+1, 10), ExpectedGeneration: state.Current, Candidate: mappingGeometryStateV1(canonical), PriorAccepted: m.AdmittedGeneration, OwnedObjects: append([]string(nil), handles...), Phase: "intent", Inhibited: true} + return nil + }) +} + +// BeginEffects durably inhibits the exact intent before its trusted effect owner +// can issue a request. This method itself performs no runtime or storage effect. +func (ops IdentityMappingOperationsV1) BeginEffects(ctx context.Context, expected StateV1, operationID string) (StateV1, error) { + if err := ops.checkPending(ctx, expected, operationID); err != nil { + return StateV1{}, err + } + return ops.change(ctx, expected, func(state *StateV1) error { + p := state.IdentityMapping.PendingOperation + if p.Phase != "intent" { + return fmt.Errorf("effects require the exact intent phase") + } + if ops.Evidence == nil { + return fmt.Errorf("effects require fresh inactive inventory") + } + handles, err := ops.Evidence.Plan(ctx, cloneMappingStateV1(*state), p.Candidate.numeric()) + handles = append([]string(nil), handles...) + if err != nil || !reflect.DeepEqual(handles, p.OwnedObjects) { + return fmt.Errorf("effect inventory differs from the recorded intent: %v", err) + } + p.Phase = "effects-pending" + return nil + }) +} + +// Recover never infers replacement input or reissues effects. Cancellation is +// restricted to a proved-unused intent; completion verifies exact effects again, +// including after a crash in verified phase. Accepted reservations cannot shrink. +func (ops IdentityMappingOperationsV1) Recover(ctx context.Context, expected StateV1, operationID string, cancel bool) (StateV1, error) { + if err := ops.checkPending(ctx, expected, operationID); err != nil { + return StateV1{}, err + } + if ops.Evidence == nil { + return StateV1{}, fmt.Errorf("mapping recovery requires exact inventory evidence") + } + if cancel { + return ops.change(ctx, expected, func(state *StateV1) error { + if state.IdentityMapping.PendingOperation.Phase != "intent" { + return fmt.Errorf("effects-pending or verified candidates cannot be cancelled") + } + if err := ops.Evidence.Unused(ctx, cloneMappingStateV1(*state)); err != nil { + return fmt.Errorf("cancellation lacks never-used proof: %w", err) + } + state.IdentityMapping.PendingOperation = nil + return nil + }) + } + if expected.IdentityMapping.PendingOperation.Phase == "intent" { + return StateV1{}, fmt.Errorf("intent has no effects to reconcile; retain it or prove it unused") + } + verified, err := ops.change(ctx, expected, func(state *StateV1) error { + if err := ops.Evidence.Verify(ctx, cloneMappingStateV1(*state)); err != nil { + return fmt.Errorf("exact effects remain unverified: %w", err) + } + state.IdentityMapping.PendingOperation.Phase = "verified" + return nil + }) + if err != nil { + return verified, err + } + return ops.change(ctx, verified, func(state *StateV1) error { + m := state.IdentityMapping + var previous *identitymapping.Geometry + if len(m.UIDAssignments) != 0 { + previous = geometryPointerV1(m.geometry().numeric()) + } + reserved, err := identitymapping.RetainGeometry(m.PendingOperation.Candidate.numeric(), previous) + if err != nil { + return err + } + geometry := mappingGeometryStateV1(reserved) + m.UIDAssignments, m.GIDAssignments = geometry.UIDs, geometry.GIDs + m.RetainedStorage = slices.Compact(slices.Sorted(slices.Values(append(m.RetainedStorage, m.PendingOperation.OwnedObjects...)))) + m.PendingOperation = nil + return nil + }) +} + +func (ops IdentityMappingOperationsV1) checkPending(ctx context.Context, expected StateV1, operationID string) error { + m := expected.IdentityMapping + if m == nil || m.PendingOperation == nil || m.PendingOperation.Kind != "configure" || m.PendingOperation.OperationID != operationID || + !reflect.DeepEqual(m.PendingOperation.ExpectedGeneration, expected.Current) || !reflect.DeepEqual(m.PendingOperation.PriorAccepted, m.AdmittedGeneration) || + m.PendingOperation.Phase == "intent" && m.PendingOperation.ExpectedRevision != m.Revision { + return fmt.Errorf("recovery requires its exact pending owner, operation and generations") + } + _, _, err := ops.check(ctx, expected, m.PendingOperation.Candidate.numeric()) + return err +} + +func (ops IdentityMappingOperationsV1) change(ctx context.Context, expected StateV1, update func(*StateV1) error) (StateV1, error) { + lock := ops.Operation + if lock == nil || ctx == nil { + return StateV1{}, fmt.Errorf("mapping change requires its context and operation lock") + } + lock.mutex.Lock() + defer lock.mutex.Unlock() + if err := lock.requireWritableLocked(); err != nil { + return StateV1{}, err + } + path, err := lock.statePathV1Locked() + if err != nil { + return StateV1{}, err + } + state, found, err := readStateV1Path(path) + if err != nil || !found || !reflect.DeepEqual(state, expected) { + return StateV1{}, fmt.Errorf("mapping owner, input, generation or revision changed: %v", err) + } + if state.TerminalRemoval || state.Staging == nil && state.Deployment == nil { + return StateV1{}, fmt.Errorf("mapping change requires a nonterminal staged or installed owner") + } + if m := state.IdentityMapping; m != nil && (m.Revoked || mappingDecimalV1(m.Revision) == math.MaxUint64) { + return StateV1{}, fmt.Errorf("mapping is revoked or its revision is exhausted") + } + before := cloneMappingStateV1(state) + if err := update(&state); err != nil { + return before, err + } + if reflect.DeepEqual(before, state) { + return state, nil + } + state.IdentityMapping.Revision = strconv.FormatUint(mappingDecimalV1(state.IdentityMapping.Revision)+1, 10) + content, err := EncodeStateV1(state) + if err != nil { + return before, err + } + if err := ctx.Err(); err != nil { + return before, err + } + if err := lock.requireWritableLocked(); err != nil { + return before, err + } + if err := validateMappingOwnerPathV1(path, state); err != nil { + return before, err + } + write := ops.write + if write == nil { + write = func(path string, content []byte) error { return writeAtomicStateFile(path, content, 0o600) } + } + if err := write(path, content); err != nil { + return before, fmt.Errorf("mapping operation remains subject to exact status/recovery: %w", err) + } + return state, nil +} + +func cloneMappingStateV1(state StateV1) StateV1 { + // State was strictly decoded before this call; JSON detaches all pointers and + // slices so a trusted observer cannot accidentally mutate commit authority. + content, _ := json.Marshal(state) + var result StateV1 + _ = json.Unmarshal(content, &result) + return result +} + +func mappingGeometryStateV1(geometry identitymapping.Geometry) IdentityMappingGeometryV1 { + result := IdentityMappingGeometryV1{Profile: geometry.Profile} + for i, values := range [][]identitymapping.Interval{geometry.UIDs, geometry.GIDs} { + var out []IdentityMappingIntervalV1 + for _, v := range values { + out = append(out, IdentityMappingIntervalV1{ContainerStart: strconv.FormatUint(uint64(v.ContainerStart), 10), HostStart: strconv.FormatUint(uint64(v.HostStart), 10), Count: strconv.FormatUint(uint64(v.Count), 10)}) + } + if i == 0 { + result.UIDs = out + } else { + result.GIDs = out + } + } + return result +} diff --git a/internal/deploy/identity_mapping_operations_test.go b/internal/deploy/identity_mapping_operations_test.go new file mode 100644 index 00000000..c15d258f --- /dev/null +++ b/internal/deploy/identity_mapping_operations_test.go @@ -0,0 +1,776 @@ +package deploy + +import ( + "context" + "errors" + "fmt" + "os" + "path/filepath" + "reflect" + "strings" + "sync" + "testing" + + "github.com/omry/reploy/internal/blueprint" + "github.com/omry/reploy/internal/identitymapping" +) + +type mappingOperationEvidenceV1 struct { + handles []string + planErr, unusedErr, verifyErr error + plans, unused, verified int + effects bool + observed StateV1 + mutate bool +} + +func (e *mappingOperationEvidenceV1) Plan(_ context.Context, state StateV1, candidate identitymapping.Geometry) ([]string, error) { + e.plans++ + e.observed = state + if e.mutate { + state.Blueprint = "foreign" + candidate.UIDs[0].HostStart++ + if state.IdentityMapping != nil { + state.IdentityMapping.OperatorUID = "999" + } + } + return e.handles, e.planErr +} +func (e *mappingOperationEvidenceV1) Unused(_ context.Context, state StateV1) error { + e.unused++ + e.observed = state + if e.effects { + return errors.New("candidate has owned storage or preparation history") + } + return e.unusedErr +} +func (e *mappingOperationEvidenceV1) Verify(_ context.Context, state StateV1) error { + e.verified++ + e.observed = state + if !e.effects || !reflect.DeepEqual(state.IdentityMapping.PendingOperation.OwnedObjects, e.handles) { + return errors.New("exact effects are missing or foreign") + } + return e.verifyErr +} + +func operationGeometryV1(host uint32) identitymapping.Geometry { + return identitymapping.Geometry{Profile: "exact", UIDs: []identitymapping.Interval{{ContainerStart: 0, HostStart: host, Count: 16}}, GIDs: []identitymapping.Interval{{ContainerStart: 0, HostStart: host + 100000, Count: 16}}} +} + +func operationFixtureV1(t *testing.T, installed, first bool) (string, IdentityMappingOperationsV1, StateV1, *mappingOperationEvidenceV1) { + t.Helper() + path, lock, state := mappingStateFixtureV1(t, installed) + if installed { + state.Deployment.Installation.Scope = "user" + writeMappingFixtureV1(t, path, state) + } + if first { + state.IdentityMapping = nil + writeMappingFixtureV1(t, path, state) + } + evidence := &mappingOperationEvidenceV1{} + ops := IdentityMappingOperationsV1{Operation: lock, Evidence: evidence, + hostCheck: func(ctx context.Context, dir string, uids, gids []identitymapping.Interval) (identitymapping.HostIdentity, error) { + if dir != filepath.Dir(filepath.Dir(path)) || len(uids) == 0 || len(gids) == 0 { + t.Fatal("host check lost the selected owner or reservations") + } + for _, values := range [][]identitymapping.Interval{uids, gids} { + for _, value := range values { + if err := identitymapping.ValidateInterval(value); err != nil { + t.Fatal(err) + } + } + } + return identitymapping.HostIdentity{Username: "operator", UID: 1000, GID: 1000}, ctx.Err() + }} + return path, ops, state, evidence +} + +func readOperationStateV1(t *testing.T, ops IdentityMappingOperationsV1) StateV1 { + t.Helper() + state, found, err := ops.Operation.ReadStateV1() + if err != nil || !found { + t.Fatalf("read operation state: found=%v err=%v", found, err) + } + return state +} + +func TestMappingPendingGenerationRejectsStateChangeAndRemainsRecoverableV1(t *testing.T) { + for _, installed := range []bool{false, true} { + for _, phase := range []string{"intent", "effects-pending", "verified"} { + t.Run(fmt.Sprintf("installed=%v/%s", installed, phase), func(t *testing.T) { + path, ops, original, evidence := operationFixtureV1(t, installed, !installed) + pending, err := ops.Configure(t.Context(), original, operationGeometryV1(100000), "generation-bound") + if err != nil { + t.Fatal(err) + } + if phase != "intent" { + pending, err = ops.BeginEffects(t.Context(), pending, "generation-bound") + if err != nil { + t.Fatal(err) + } + evidence.effects = true + } + if phase == "verified" { + pending.IdentityMapping.PendingOperation.Phase = phase + writeMappingFixtureV1(t, path, pending) // Crash after durable verification. + } + before, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + replacement := cloneMappingStateV1(pending) + replacement.Current = nil + if err := ops.Operation.CommitStateV1(pending.Current, replacement); err == nil { + t.Fatal("generation change orphaned a pending mapping operation") + } + if after, err := os.ReadFile(path); err != nil || string(after) != string(before) { + t.Fatalf("rejected generation change mutated state: %v", err) + } + metadata := cloneMappingStateV1(pending) + metadata.BlueprintSource = "same-generation metadata update" + if err := ops.Operation.CommitStateV1(pending.Current, metadata); err != nil { + t.Fatalf("same-generation update rejected: %v", err) + } + recovered, err := ops.Recover(t.Context(), readOperationStateV1(t, ops), "generation-bound", phase == "intent") + if err != nil || recovered.IdentityMapping.PendingOperation != nil || !reflect.DeepEqual(recovered.Current, original.Current) { + t.Fatalf("preserved operation cannot recover: %v", err) + } + }) + } + } +} + +func TestMappingOperationsConfigureRetryAndExactEffectRecoveryV1(t *testing.T) { + for _, installed := range []bool{false, true} { + t.Run(map[bool]string{false: "staged", true: "installed retained"}[installed], func(t *testing.T) { + path, ops, original, evidence := operationFixtureV1(t, installed, !installed) + evidence.handles = []string{"data-volume"} + input := operationGeometryV1(100000) + before, _ := os.ReadFile(path) + checked, err := ops.Check(t.Context(), input) + if err != nil || !reflect.DeepEqual(checked, original) { + t.Fatalf("check: %v", err) + } + if got, _ := os.ReadFile(path); string(got) != string(before) { + t.Fatal("check wrote state") + } + candidate, err := ops.Configure(t.Context(), original, input, "operation-one") + if err != nil { + t.Fatal(err) + } + m := candidate.IdentityMapping + if m.InstallationID == "" || m.PendingOperation.Phase != "intent" || !m.PendingOperation.Inhibited || len(m.UIDAssignments) != 0 || m.AdmittedGeneration != nil || m.Engine != nil || m.Supervisor != nil || !reflect.DeepEqual(candidate.Current, original.Current) { + t.Fatalf("configure accepted or admitted a candidate: %#v", m) + } + status, err := ops.Status() + if err != nil || status.Status != "candidate" || !reflect.DeepEqual(*status.Reservations, input) { + t.Fatalf("candidate status: %#v %v", status, err) + } + intentBytes, _ := os.ReadFile(path) + retry, err := ops.Configure(t.Context(), candidate, input, "operation-one") + if err != nil || !reflect.DeepEqual(retry, candidate) { + t.Fatalf("exact retry: %v", err) + } + if after, _ := os.ReadFile(path); string(after) != string(intentBytes) { + t.Fatal("exact retry rewrote state") + } + if _, err := ops.Configure(t.Context(), original, input, "operation-one"); err == nil { + t.Fatal("stale observation accepted") + } + if _, err := ops.Recover(t.Context(), candidate, "operation-one", false); err == nil { + t.Fatal("intent completed without effects") + } + pending, err := ops.BeginEffects(t.Context(), candidate, "operation-one") + if err != nil || pending.IdentityMapping.PendingOperation.Phase != "effects-pending" { + t.Fatalf("durable inhibition: %v", err) + } + if _, err := ops.BeginEffects(t.Context(), pending, "operation-one"); err == nil { + t.Fatal("advanced phase silently retried effects") + } + if _, err := ops.Recover(t.Context(), pending, "operation-one", true); err == nil { + t.Fatal("effects-pending candidate cancelled") + } + if _, err := ops.Recover(t.Context(), pending, "operation-one", false); err == nil { + t.Fatal("missing effects accepted") + } + evidence.effects = true // injected effect occurs only after durable phase + complete, err := ops.Recover(t.Context(), pending, "operation-one", false) + if err != nil { + t.Fatal(err) + } + m = complete.IdentityMapping + if m.PendingOperation != nil || m.AdmittedGeneration != nil || m.Engine != nil || m.Supervisor != nil || !reflect.DeepEqual(m.geometry().numeric(), input) || !reflect.DeepEqual(m.RetainedStorage, evidence.handles) { + t.Fatalf("completion lost reservations or granted admission: %#v", m) + } + if installed && !reflect.DeepEqual(complete.Deployment, original.Deployment) { + t.Fatal("installed binding changed") + } + status, err = ops.Status() + if err != nil || status.Status != "retained" || !reflect.DeepEqual(*status.Reservations, input) { + t.Fatalf("retained status: %v", err) + } + info, err := os.Stat(path) + if err != nil || hasPOSIXPermissionBits() && info.Mode().Perm() != 0o600 { + t.Fatalf("state mode: %v %v", info, err) + } + }) + } +} + +func TestMappingOperationsUnusedCorrectionAndCancellationV1(t *testing.T) { + for _, protect := range []bool{false, true} { + t.Run(map[bool]string{false: "first", true: "protected"}[protect], func(t *testing.T) { + path, ops, state, evidence := operationFixtureV1(t, false, false) + input := operationGeometryV1(100000) + if protect { + geometry := mappingGeometryStateV1(input) + state.IdentityMapping.UIDAssignments, state.IdentityMapping.GIDAssignments = geometry.UIDs, geometry.GIDs + input.UIDs = append(input.UIDs, identitymapping.Interval{ContainerStart: 30, HostStart: 300000, Count: 16}) + input.GIDs = append(input.GIDs, identitymapping.Interval{ContainerStart: 30, HostStart: 400000, Count: 16}) + writeMappingFixtureV1(t, path, state) + } + intent, err := ops.Configure(t.Context(), state, input, "surplus") + if err != nil { + t.Fatal(err) + } + corrected := operationGeometryV1(500000) + if protect { + corrected = operationGeometryV1(100000) // remove only unused surplus + } + before, _ := os.ReadFile(path) + evidence.unusedErr = errors.New("incomplete ownership history") + if _, err := ops.Configure(t.Context(), intent, corrected, "corrected"); err == nil { + t.Fatal("unknown history discarded candidate") + } + if _, err := ops.Recover(t.Context(), intent, "surplus", true); err == nil { + t.Fatal("unknown history cancelled candidate") + } + if after, _ := os.ReadFile(path); string(after) != string(before) { + t.Fatal("rejected candidate changes wrote state") + } + evidence.unusedErr = nil + evidence.effects = true + if _, err := ops.Configure(t.Context(), intent, corrected, "corrected"); err == nil { + t.Fatal("used candidate replaced") + } + evidence.effects = false + replacement, err := ops.Configure(t.Context(), intent, corrected, "corrected") + if err != nil || !reflect.DeepEqual(replacement.IdentityMapping.PendingOperation.Candidate.numeric(), corrected) { + t.Fatalf("unused correction: %v", err) + } + if !reflect.DeepEqual(replacement.IdentityMapping.UIDAssignments, state.IdentityMapping.UIDAssignments) { + t.Fatal("correction replaced protected authority") + } + if _, err := ops.Recover(t.Context(), replacement, "surplus", true); err == nil { + t.Fatal("old operation cancelled successor") + } + cancelled, err := ops.Recover(t.Context(), replacement, "corrected", true) + if err != nil || cancelled.IdentityMapping.PendingOperation != nil || !reflect.DeepEqual(cancelled.IdentityMapping.UIDAssignments, state.IdentityMapping.UIDAssignments) || cancelled.IdentityMapping.InstallationID != state.IdentityMapping.InstallationID { + t.Fatalf("unused cancellation lost protected owner: %v", err) + } + }) + } +} + +func TestMappingOperationsCrashBoundariesV1(t *testing.T) { + for _, boundary := range []string{"intent", "effects-pending", "verified", "complete"} { + for _, durable := range []bool{false, true} { + t.Run(boundary+map[bool]string{false: "/before", true: "/after"}[durable], func(t *testing.T) { + path, ops, state, evidence := operationFixtureV1(t, false, true) + evidence.handles = []string{"data-volume"} + input := operationGeometryV1(100000) + crashed := false + ops.write = func(path string, content []byte) error { + next, err := DecodeStateV1(content) + if err != nil { + t.Fatal(err) + } + phase := "complete" + if next.IdentityMapping.PendingOperation != nil { + phase = next.IdentityMapping.PendingOperation.Phase + } + if !crashed && phase == boundary { + crashed = true + if durable { + if err := writeAtomicStateFile(path, content, 0o600); err != nil { + t.Fatal(err) + } + } + return errors.New("injected process loss") + } + return writeAtomicStateFile(path, content, 0o600) + } + state, err := ops.Configure(t.Context(), state, input, "crash-operation") + if err == nil { + state, err = ops.BeginEffects(t.Context(), state, "crash-operation") + } + if err == nil { + evidence.effects = true + _, err = ops.Recover(t.Context(), state, "crash-operation", false) + } + if err == nil || !crashed { + t.Fatal("crash boundary did not execute") + } + stored := readOperationStateV1(t, ops) + m := stored.IdentityMapping + if boundary == "intent" && !durable { + if m != nil { + t.Fatal("pre-intent failure changed authority") + } + return + } + if m == nil || m.InstallationID == "" || m.AdmittedGeneration != nil { + t.Fatal("crash lost owner or granted admission") + } + status, statusErr := ops.Status() + if statusErr != nil || status.Reservations == nil || !reflect.DeepEqual(*status.Reservations, input) { + t.Fatalf("crash released reservations: %v", statusErr) + } + if p := m.PendingOperation; p != nil { + if !p.Inhibited || !reflect.DeepEqual(p.OwnedObjects, evidence.handles) { + t.Fatal("crash forgot pending authority") + } + if p.Phase == "intent" { + if _, err := ops.Recover(t.Context(), stored, "crash-operation", true); err != nil { + t.Fatalf("unused intent recovery: %v", err) + } + } else { + if _, err := ops.Recover(t.Context(), stored, "crash-operation", true); err == nil { + t.Fatal("crash after phase advance allowed cancellation") + } + if boundary == "effects-pending" { + if _, err := ops.Recover(t.Context(), stored, "crash-operation", false); err == nil { + t.Fatal("call never issued but recovery assumed success") + } + evidence.effects = true // exact owner later completes effect + } + if _, err := ops.Recover(t.Context(), stored, "crash-operation", false); err != nil { + t.Fatalf("exact recovery: %v", err) + } + } + } else if boundary != "complete" || !durable || !reflect.DeepEqual(m.RetainedStorage, evidence.handles) { + t.Fatal("unexpected completed crash state") + } + if info, err := os.Stat(path); err != nil || hasPOSIXPermissionBits() && info.Mode().Perm() != 0o600 { + t.Fatal("recovery changed private state mode") + } + }) + } + } +} + +func TestMappingOperationsRejectUnknownStaleForeignAndRevokedV1(t *testing.T) { + for _, scenario := range []string{"no evidence", "active inventory", "bad handles", "host failure", "operator changed", "generation changed", "revision changed", "revoked", "overflow", "wrong profile", "first installed", "cancelled", "bad operation", "protected remap", "invalid geometry"} { + t.Run(scenario, func(t *testing.T) { + path, ops, state, evidence := operationFixtureV1(t, scenario == "first installed", scenario == "first installed") + input := operationGeometryV1(100000) + ctx := t.Context() + opID := "one" + switch scenario { + case "no evidence": + ops.Evidence = nil + case "invalid geometry": + input.UIDs = nil + case "active inventory": + evidence.planErr = errors.New("live or unknown inventory") + case "bad handles": + evidence.handles = []string{"z", "a"} + case "host failure": + ops.hostCheck = func(context.Context, string, []identitymapping.Interval, []identitymapping.Interval) (identitymapping.HostIdentity, error) { + return identitymapping.HostIdentity{}, errors.New("incomplete delegation or principal inventory") + } + case "operator changed": + state.IdentityMapping.OperatorUID = "1001" + writeMappingFixtureV1(t, path, state) + case "generation changed": + stale := cloneMappingStateV1(state) + state.Current = nil + writeMappingFixtureV1(t, path, state) + state = stale + case "revision changed": + stale := cloneMappingStateV1(state) + state.IdentityMapping.Revision = "2" + writeMappingFixtureV1(t, path, state) + state = stale + case "revoked": + state.IdentityMapping.Revoked = true + writeMappingFixtureV1(t, path, state) + case "overflow": + state.IdentityMapping.Revision = "18446744073709551615" + writeMappingFixtureV1(t, path, state) + case "wrong profile": + input.Profile = "bounded" + state.IdentityMapping.Profile = "exact" + case "cancelled": + var cancel context.CancelFunc + ctx, cancel = context.WithCancel(ctx) + cancel() + case "bad operation": + opID = "-foreign" + case "protected remap": + g := mappingGeometryStateV1(operationGeometryV1(500000)) + state.IdentityMapping.UIDAssignments, state.IdentityMapping.GIDAssignments = g.UIDs, g.GIDs + writeMappingFixtureV1(t, path, state) + } + before, _ := os.ReadFile(path) + if _, err := ops.Configure(ctx, state, input, opID); err == nil { + t.Fatal("invalid configuration accepted") + } + if after, _ := os.ReadFile(path); string(after) != string(before) { + t.Fatal("rejection changed state") + } + }) + } +} + +func TestMappingOperationsReadOnlyAndObserverIsolationV1(t *testing.T) { + path, ops, state, evidence := operationFixtureV1(t, false, false) + before, _ := os.ReadFile(path) + status, err := ops.Status() + if err != nil || status.Status != "retained" || status.Reservations != nil { + t.Fatalf("empty retained status: %v", err) + } + evidence.mutate = true + configured, err := ops.Configure(t.Context(), state, operationGeometryV1(100000), "isolated") + if err != nil || configured.Blueprint != state.Blueprint || configured.IdentityMapping.OperatorUID != "1000" || configured.IdentityMapping.PendingOperation.Candidate.UIDs[0].HostStart != "100000" { + t.Fatalf("observer mutated authority: %v", err) + } + stale := cloneMappingStateV1(configured) + stale.IdentityMapping.PendingOperation.Kind = "uninstall" + if _, err := ops.BeginEffects(t.Context(), stale, "isolated"); err == nil { + t.Fatal("foreign operation kind accepted") + } + for _, phase := range []string{"effects-pending", "verified"} { + stale = cloneMappingStateV1(configured) + stale.IdentityMapping.PendingOperation.Phase = phase + writeMappingFixtureV1(t, path, stale) + if _, err := ops.Configure(t.Context(), stale, operationGeometryV1(100000), "other"); err == nil { + t.Fatal("advanced candidate replaced") + } + } + writeMappingFixtureV1(t, path, configured) + configured.IdentityMapping.PendingOperation.ExpectedRevision = "1" + if configured.IdentityMapping.Revision != "1" { + writeMappingFixtureV1(t, path, configured) + if _, err := ops.BeginEffects(t.Context(), configured, "isolated"); err == nil { + t.Fatal("stale intent revision accepted") + } + } + if err := os.WriteFile(path, before, 0o600); err != nil { + t.Fatal(err) + } + if err := ops.Operation.Unlock(); err != nil { + t.Fatal(err) + } + if _, err := ops.Status(); err == nil { + t.Fatal("unheld status accepted") + } + if _, err := (IdentityMappingOperationsV1{}).Status(); err == nil { + t.Fatal("nil lock status accepted") + } + missing := filepath.Join(t.TempDir(), "missing") + if _, err := AcquireExistingOperationLock(t.Context(), missing); err == nil { + t.Fatal("missing read-only owner created") + } + if _, err := os.Stat(missing); !os.IsNotExist(err) { + t.Fatal("inspection created owner") + } + if _, err := ops.Configure(nil, state, operationGeometryV1(100000), "one"); err == nil || !strings.Contains(err.Error(), "context") { + t.Fatal("nil context accepted") + } +} + +func TestMappingOperationsCompetingWritersV1(t *testing.T) { + _, ops, state, _ := operationFixtureV1(t, false, true) + start := make(chan struct{}) + results := make(chan error, 2) + var writers sync.WaitGroup + for _, id := range []string{"writer-one", "writer-two"} { + writers.Add(1) + go func() { + defer writers.Done() + <-start + _, err := ops.Configure(t.Context(), state, operationGeometryV1(100000), id) + results <- err + }() + } + close(start) + writers.Wait() + close(results) + succeeded := 0 + for err := range results { + if err == nil { + succeeded++ + } + } + if succeeded != 1 { + t.Fatalf("competing configurations succeeded=%d", succeeded) + } + stored := readOperationStateV1(t, ops) + if stored.IdentityMapping.Revision != "1" || stored.IdentityMapping.PendingOperation == nil { + t.Fatal("competing writer changed the winner") + } + if err := ops.Operation.RequireNoRetainedIdentityV1(); err == nil { + t.Fatal("actual configured candidate lost destructive protection") + } + omitted := cloneMappingStateV1(stored) + omitted.IdentityMapping = nil + if err := ops.Operation.CommitStateV1(stored.Current, omitted); err == nil { + t.Fatal("ordinary writer stripped actual candidate") + } +} + +func TestMappingOperationsPhaseAndVerificationGuardsV1(t *testing.T) { + for _, scenario := range []string{"no evidence", "changed handles", "active before effects", "wrong operation", "changed candidate same ID", "stale intent revision", "stale intent generation", "foreign kind", "unknown verification", "foreign verification", "nil context"} { + t.Run(scenario, func(t *testing.T) { + path, ops, state, evidence := operationFixtureV1(t, false, false) + evidence.handles = []string{} // empty and nil inventory are equivalent + input := operationGeometryV1(100000) + intent, err := ops.Configure(t.Context(), state, input, "guarded") + if err != nil { + t.Fatal(err) + } + ctx := t.Context() + operationID := "guarded" + switch scenario { + case "no evidence": + ops.Evidence = nil + case "changed handles": + evidence.handles = []string{"foreign"} + case "active before effects": + evidence.planErr = errors.New("owner became active") + case "wrong operation": + operationID = "foreign" + case "stale intent revision": + intent.IdentityMapping.PendingOperation.ExpectedRevision = "1" + writeMappingFixtureV1(t, path, intent) + case "stale intent generation": + intent.IdentityMapping.PendingOperation.ExpectedGeneration = nil + writeMappingFixtureV1(t, path, intent) + case "foreign kind": + intent.IdentityMapping.PendingOperation.Kind = "uninstall" + writeMappingFixtureV1(t, path, intent) + case "nil context": + ctx = nil + } + before, _ := os.ReadFile(path) + if scenario == "changed candidate same ID" { + _, err = ops.Configure(ctx, intent, operationGeometryV1(500000), operationID) + } else if strings.HasSuffix(scenario, "verification") { + intent, err = ops.BeginEffects(ctx, intent, operationID) + if err != nil { + t.Fatal(err) + } + before, _ = os.ReadFile(path) + evidence.effects = true + if scenario == "unknown verification" { + evidence.verifyErr = errors.New("incomplete exact inventory") + } else { + evidence.handles = []string{"foreign"} + } + _, err = ops.Recover(ctx, intent, operationID, false) + } else { + _, err = ops.BeginEffects(ctx, intent, operationID) + } + if err == nil { + t.Fatal("invalid phase/effect authority accepted") + } + if after, _ := os.ReadFile(path); string(after) != string(before) { + t.Fatal("phase rejection changed state") + } + if scenario == "no evidence" { + if _, err := ops.Recover(ctx, intent, operationID, true); err == nil { + t.Fatal("recovery without evidence accepted") + } + } + }) + } +} + +func TestMappingOperationsStatusInhibitsUnconfiguredDeclarationV1(t *testing.T) { + for _, installed := range []bool{false, true} { + for _, hostStart := range []string{"100000", `"???"`} { + t.Run(fmt.Sprintf("installed=%v/host=%s", installed, hostStart), func(t *testing.T) { + path, ops, state, evidence := operationFixtureV1(t, installed, true) + document, err := blueprint.DecodeResolvedDocumentV1(state.Blueprint) + if err != nil { + t.Fatal(err) + } + source, err := blueprint.Decode([]byte(fmt.Sprintf("blueprint: {schema: 1}\nenvironment:\n runtime:\n identity_mapping: {schema: 1, profile: exact, uids: [{container_start: 0, host_start: %s, count: 16}], gids: [{container_start: 0, host_start: 200000, count: 16}]}\n", hostStart))) + if err != nil { + t.Fatal(err) + } + document.Environment.Runtime.IdentityMapping = source.Environment.Runtime.IdentityMapping + state.Blueprint, err = blueprint.EncodeResolvedDocumentV1(document) + if err != nil { + t.Fatal(err) + } + writeMappingFixtureV1(t, path, state) + before, _ := os.ReadFile(path) + status, err := ops.Status() + if err != nil || status.Status != "inhibited" || status.Reservations != nil || status.State.IdentityMapping != nil { + t.Fatalf("declared status=%s reservations=%v retained=%v: %v", status.Status, status.Reservations, status.State.IdentityMapping != nil, err) + } + if after, _ := os.ReadFile(path); string(after) != string(before) || evidence.plans != 0 || evidence.unused != 0 || evidence.verified != 0 { + t.Fatal("status changed state or consulted effect evidence") + } + }) + } + } +} + +func TestMappingOperationsStatusDoesNotNeedDesiredInputV1(t *testing.T) { + path, ops, state, _ := operationFixtureV1(t, false, false) + input := mappingGeometryStateV1(operationGeometryV1(100000)) + for _, disposition := range []string{"ordinary", "admitted", "revoked"} { + next := cloneMappingStateV1(state) + if disposition == "ordinary" { + next.IdentityMapping = nil + } else { + m := next.IdentityMapping + m.UIDAssignments, m.GIDAssignments = input.UIDs, input.GIDs + m.AdmittedGeneration = next.Current + engine, supervisor := "engine", "supervisor" + m.Engine, m.Supervisor = &engine, &supervisor + m.Revoked = disposition == "revoked" + } + writeMappingFixtureV1(t, path, next) + before, _ := os.ReadFile(path) + status, err := ops.Status() + if err != nil || status.Status != disposition { + t.Fatalf("status=%s: %v", status.Status, err) + } + if after, _ := os.ReadFile(path); string(after) != string(before) { + t.Fatal("status wrote state") + } + } + writeMappingFixtureV1(t, path, state) + if _, err := ops.Check(t.Context(), identitymapping.Geometry{}); err == nil { + t.Fatal("check accepted incomplete input") + } + if _, err := ops.Check(nil, operationGeometryV1(100000)); err == nil { + t.Fatal("check accepted nil context") + } + if err := os.WriteFile(path, []byte(`{"identity_mapping":null}`), 0o600); err != nil { + t.Fatal(err) + } + if _, err := ops.Check(t.Context(), operationGeometryV1(100000)); err == nil { + t.Fatal("malformed authority accepted") + } +} + +func TestMappingOperationsFreshPendingHostAndNativeCheckV1(t *testing.T) { + path, ops, state, _ := operationFixtureV1(t, false, false) + intent, err := ops.Configure(t.Context(), state, operationGeometryV1(100000), "fresh") + if err != nil { + t.Fatal(err) + } + before, _ := os.ReadFile(path) + calls := 0 + ops.hostCheck = func(context.Context, string, []identitymapping.Interval, []identitymapping.Interval) (identitymapping.HostIdentity, error) { + calls++ + name := "operator" + if calls > 1 { + name = "different-principal" + } + return identitymapping.HostIdentity{Username: name, UID: 1000, GID: 1000}, nil + } + if _, err := ops.BeginEffects(t.Context(), intent, "fresh"); err == nil { + t.Fatal("host changed while pending reservations were checked") + } + if after, _ := os.ReadFile(path); string(after) != string(before) { + t.Fatal("host conflict wrote metadata") + } + // Exercise the actual B1 probe as well. Unsupported test hosts may reject; + // either result must leave this read-only operation's authority unchanged. + ops.hostCheck = nil + _, _ = ops.Check(t.Context(), operationGeometryV1(100000)) + if after, _ := os.ReadFile(path); string(after) != string(before) { + t.Fatal("native host check changed state") + } +} + +func TestMappingOperationsRejectForeignConfigureKindsV1(t *testing.T) { + for _, kind := range []string{"uninstall", "future-operation"} { + for _, requestedID := range []string{"original", "replacement"} { + t.Run(kind+"/"+requestedID, func(t *testing.T) { + path, ops, state, evidence := operationFixtureV1(t, false, false) + input := operationGeometryV1(100000) + intent, err := ops.Configure(t.Context(), state, input, "original") + if err != nil { + t.Fatal(err) + } + intent.IdentityMapping.PendingOperation.Kind = kind + writeMappingFixtureV1(t, path, intent) + before, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if _, err := ops.Configure(t.Context(), intent, input, requestedID); err == nil { + t.Errorf("configure adopted/replaced foreign pending kind %q", kind) + } + after, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if string(after) != string(before) { + t.Error("foreign-operation rejection changed authority") + } + if evidence.unused != 0 { + t.Error("mapping unused proof was applied to a foreign operation") + } + }) + } + } +} + +func TestMappingPendingRevisionRejectsGenericCommitAndRemainsRecoverableV1(t *testing.T) { + for _, installed := range []bool{false, true} { + for _, phase := range []string{"intent", "effects-pending", "verified"} { + t.Run(fmt.Sprintf("installed=%v/%s", installed, phase), func(t *testing.T) { + path, ops, original, evidence := operationFixtureV1(t, installed, !installed) + pending, err := ops.Configure(t.Context(), original, operationGeometryV1(100000), "revision-bound") + if err != nil { + t.Fatal(err) + } + if phase != "intent" { + pending, err = ops.BeginEffects(t.Context(), pending, "revision-bound") + if err != nil { + t.Fatal(err) + } + evidence.effects = true + } + if phase == "verified" { + pending.IdentityMapping.PendingOperation.Phase = phase + writeMappingFixtureV1(t, path, pending) + } + before, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + mapping := pending.IdentityMapping + _, err = ops.Operation.CommitIdentityMappingV1(pending.Current, mapping.InstallationID, mappingDecimalV1(mapping.Revision), *mapping) + if err == nil { + bumped := readOperationStateV1(t, ops) + _, recoveryErr := ops.Recover(t.Context(), bumped, "revision-bound", phase == "intent") + t.Fatalf("generic commit changed a pending revision; recovery error: %v", recoveryErr) + } + if after, err := os.ReadFile(path); err != nil || string(after) != string(before) { + t.Fatalf("rejected generic commit changed pending state: %v", err) + } + if phase == "intent" { + pending, err = ops.BeginEffects(t.Context(), readOperationStateV1(t, ops), "revision-bound") + if err != nil { + t.Fatalf("preserved intent cannot begin effects: %v", err) + } + evidence.effects = true + } + recovered, err := ops.Recover(t.Context(), readOperationStateV1(t, ops), "revision-bound", false) + if err != nil || recovered.IdentityMapping.PendingOperation != nil || !reflect.DeepEqual(recovered.Current, original.Current) { + t.Fatalf("preserved operation cannot recover: %v", err) + } + }) + } + } +} diff --git a/internal/deploy/identity_mapping_state_file.go b/internal/deploy/identity_mapping_state_file.go index 5406a743..05042f61 100644 --- a/internal/deploy/identity_mapping_state_file.go +++ b/internal/deploy/identity_mapping_state_file.go @@ -125,6 +125,9 @@ func (lock *OperationLock) CommitIdentityMappingV1(expectedGeneration *Environme } next.Revision = "1" } else { + if old.PendingOperation != nil { + return StateV1{}, fmt.Errorf("pending identity mapping requires its checked operation") + } if old.InstallationID != expectedID || mappingDecimalV1(old.Revision) != expectedRevision || expectedRevision == math.MaxUint64 { return StateV1{}, fmt.Errorf("identity owner or revision changed before commit") } diff --git a/internal/deploy/package_overrides.go b/internal/deploy/package_overrides.go index 1773645b..33097bf6 100644 --- a/internal/deploy/package_overrides.go +++ b/internal/deploy/package_overrides.go @@ -620,17 +620,28 @@ func ReadPackageOverridesV1(deploymentDir string) (PackageOverridesV1, bool, err if err != nil { return PackageOverridesV1{}, false, err } - info, err := os.Lstat(path) + return ReadPackageOverridesFileV1(path) +} + +// ReadPackageOverridesFileV1 captures one explicitly selected override file. +// Callers decide whether absence is allowed and bind its environment/declaration. +func ReadPackageOverridesFileV1(path string) (PackageOverridesV1, bool, error) { + file, err := openPackageOverridesFile(path) if errors.Is(err, fs.ErrNotExist) { return PackageOverridesV1{}, false, nil } + if err != nil { + return PackageOverridesV1{}, false, fmt.Errorf("open package overrides: %w", err) + } + defer file.Close() + info, err := file.Stat() if err != nil { return PackageOverridesV1{}, false, fmt.Errorf("inspect package overrides: %w", err) } - if !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 { + if !info.Mode().IsRegular() { return PackageOverridesV1{}, false, fmt.Errorf("package overrides path must be a regular file: %s", path) } - content, err := os.ReadFile(path) + content, err := io.ReadAll(file) if err != nil { return PackageOverridesV1{}, false, fmt.Errorf("read package overrides: %w", err) } diff --git a/internal/deploy/package_overrides_open_unix.go b/internal/deploy/package_overrides_open_unix.go new file mode 100644 index 00000000..89b8b91e --- /dev/null +++ b/internal/deploy/package_overrides_open_unix.go @@ -0,0 +1,18 @@ +//go:build !windows + +package deploy + +import ( + "os" + + "golang.org/x/sys/unix" +) + +func openPackageOverridesFile(path string) (*os.File, error) { + // Do not follow a replaced symlink or wait for a FIFO writer before Stat. + fd, err := unix.Open(path, unix.O_RDONLY|unix.O_CLOEXEC|unix.O_NOFOLLOW|unix.O_NONBLOCK, 0) + if err != nil { + return nil, err + } + return os.NewFile(uintptr(fd), path), nil +} diff --git a/internal/deploy/package_overrides_open_unix_test.go b/internal/deploy/package_overrides_open_unix_test.go new file mode 100644 index 00000000..5d8c32d2 --- /dev/null +++ b/internal/deploy/package_overrides_open_unix_test.go @@ -0,0 +1,118 @@ +//go:build !windows + +package deploy + +import ( + "os" + "path/filepath" + "runtime" + "testing" + + "golang.org/x/sys/unix" +) + +func TestReadPackageOverridesFileRejectsSpecialFiles(t *testing.T) { + for _, kind := range []string{"symlink", "dangling-symlink", "fifo", "directory"} { + t.Run(kind, func(t *testing.T) { + dir := t.TempDir() + selected := filepath.Join(dir, "selected.yaml") + var err error + switch kind { + case "symlink", "dangling-symlink": + target := filepath.Join(dir, "target.yaml") + if kind == "symlink" { + err = os.WriteFile(target, overrideReadFixture(t, "target"), 0o600) + if err != nil { + t.Fatal(err) + } + } + err = os.Symlink(target, selected) + case "fifo": + err = unix.Mkfifo(selected, 0o600) + case "directory": + err = os.Mkdir(selected, 0o700) + } + if err != nil { + t.Fatal(err) + } + if _, found, err := ReadPackageOverridesFileV1(selected); err == nil || found { + t.Fatalf("special file accepted: found=%v, err=%v", found, err) + } + }) + } +} + +func TestReadPackageOverridesFileNeverFollowsReplacement(t *testing.T) { + previous := runtime.GOMAXPROCS(2) + defer runtime.GOMAXPROCS(previous) + dir := t.TempDir() + selected := filepath.Join(dir, "selected.yaml") + other := filepath.Join(dir, "other") + regular := filepath.Join(dir, "regular.yaml") + target := filepath.Join(dir, "target.yaml") + if err := os.WriteFile(selected, overrideReadFixture(t, "selected"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(target, overrideReadFixture(t, "forbidden-target"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.Link(selected, regular); err != nil { + t.Fatal(err) + } + if err := os.Symlink(target, other); err != nil { + t.Fatal(err) + } + stop := make(chan struct{}) + done := make(chan error, 1) + go func() { + for { + select { + case <-stop: + done <- nil + return + default: + } + // Each replacement is atomic. Neither regular file's contents change. + for _, replace := range []func() error{ + func() error { return os.Rename(other, selected) }, + func() error { return os.Link(regular, other) }, + func() error { return os.Rename(other, selected) }, + func() error { return os.Symlink(target, other) }, + } { + if err := replace(); err != nil { + done <- err + return + } + } + } + }() + defer func() { + close(stop) + if err := <-done; err != nil { + t.Fatal(err) + } + }() + reads := 0 + for range 16384 { + overrides, found, err := ReadPackageOverridesFileV1(selected) + if err != nil || !found { + continue // Rejected symlink entries are valid race outcomes. + } + reads++ + if overrides.Environment.ID != "selected" { + t.Fatalf("followed a replacement symlink: environment=%q", overrides.Environment.ID) + } + } + if reads == 0 { + t.Fatal("race test did not exercise a successful regular-file read") + } +} + +func overrideReadFixture(t *testing.T, id string) []byte { + t.Helper() + content, err := EncodePackageOverridesV1(EmptyPackageOverridesV1(id)) + if err != nil { + t.Fatal(err) + } + return content +} diff --git a/internal/deploy/package_overrides_open_windows.go b/internal/deploy/package_overrides_open_windows.go new file mode 100644 index 00000000..a1d1a9aa --- /dev/null +++ b/internal/deploy/package_overrides_open_windows.go @@ -0,0 +1,24 @@ +//go:build windows + +package deploy + +import ( + "os" + + "golang.org/x/sys/windows" +) + +func openPackageOverridesFile(path string) (*os.File, error) { + pointer, err := windows.UTF16PtrFromString(path) + if err != nil { + return nil, err + } + handle, err := windows.CreateFile(pointer, windows.GENERIC_READ, + windows.FILE_SHARE_READ|windows.FILE_SHARE_WRITE|windows.FILE_SHARE_DELETE, + nil, windows.OPEN_EXISTING, + windows.FILE_FLAG_OPEN_REPARSE_POINT|windows.FILE_FLAG_BACKUP_SEMANTICS, 0) + if err != nil { + return nil, err + } + return os.NewFile(uintptr(handle), path), nil +} diff --git a/internal/deploy/state_v1_file.go b/internal/deploy/state_v1_file.go index 69e248e5..ef249277 100644 --- a/internal/deploy/state_v1_file.go +++ b/internal/deploy/state_v1_file.go @@ -55,6 +55,9 @@ func (lock *OperationLock) CommitStateV1(expected *EnvironmentGenerationState, s if !reflect.DeepEqual(current.IdentityMapping, state.IdentityMapping) { return fmt.Errorf("identity authority changed or was omitted before state commit") } + if current.IdentityMapping != nil && current.IdentityMapping.PendingOperation != nil && !reflect.DeepEqual(observed, state.Current) { + return fmt.Errorf("pending identity mapping operation %s must finish before changing the current generation", current.IdentityMapping.PendingOperation.OperationID) + } if err := validateMappingOwnerPathV1(path, state); err != nil { return err } diff --git a/internal/dockerdeploy/build_publication.go b/internal/dockerdeploy/build_publication.go index bc68397d..702817f3 100644 --- a/internal/dockerdeploy/build_publication.go +++ b/internal/dockerdeploy/build_publication.go @@ -163,6 +163,9 @@ func publishBuild( return deploy.StateV1{}, fmt.Errorf("publication cannot replace another environment's owner context") } } + if state.IdentityMapping != nil && state.IdentityMapping.PendingOperation != nil { + return deploy.StateV1{}, fmt.Errorf("pending identity mapping operation %s must finish before build publication", state.IdentityMapping.PendingOperation.OperationID) + } var old *deploy.EnvironmentGenerationState var oldImage *providers.RealizedImageV1 var oldPairs []OwnedImageReferenceV1 diff --git a/internal/dockerdeploy/identity_mapping_input.go b/internal/dockerdeploy/identity_mapping_input.go index 1904f433..80b40b3e 100644 --- a/internal/dockerdeploy/identity_mapping_input.go +++ b/internal/dockerdeploy/identity_mapping_input.go @@ -1,7 +1,9 @@ package dockerdeploy import ( + "context" "fmt" + "path/filepath" "github.com/omry/reploy/internal/blueprint" "github.com/omry/reploy/internal/deploy" @@ -9,25 +11,56 @@ import ( ) // This private metadata seam does not allocate IDs or admit a workload. -// B5 will bind its candidate to the selected generation and mapping revision. func loadStagedIdentityMappingV1(operation *deploy.OperationLock, dir string) (*identitymapping.Geometry, error) { + _, candidate, err := loadSelectedIdentityMappingV1(operation, dir, "") + return candidate, err +} + +func loadSelectedIdentityMappingV1(operation *deploy.OperationLock, dir, overrideFile string) (deploy.StateV1, *identitymapping.Geometry, error) { if operation == nil { - return nil, fmt.Errorf("mapping input requires a deployment operation lock") + return deploy.StateV1{}, nil, fmt.Errorf("mapping input requires a deployment operation lock") } state, found, err := operation.ReadStateV1() if err != nil { - return nil, err + return state, nil, err } if !found || state.Deployment != nil || state.Staging == nil { - return nil, fmt.Errorf("mapping input requires selected staging state") + return state, nil, fmt.Errorf("mapping input requires selected staging state") + } + absolute, err := filepath.Abs(dir) + if err != nil || operation.Path() != filepath.Join(absolute, ".reploy", "operation.lock") { + return state, nil, fmt.Errorf("mapping input lock does not belong to the selected deployment") } document, err := blueprint.DecodeResolvedDocumentV1(state.Blueprint) if err != nil { - return nil, err + return state, nil, err + } + var overrides deploy.PackageOverridesV1 + if overrideFile == "" { + overrides, _, _, err = LoadStagedPackageOverridesV1(operation, dir, document) + } else { + overrides, found, err = deploy.ReadPackageOverridesFileV1(overrideFile) + if err == nil && !found { + err = fmt.Errorf("selected mapping override file does not exist") + } + if err == nil && overrides.Environment.ID != document.Environment.ID { + err = fmt.Errorf("selected mapping override targets a different environment") + } + if err == nil { + err = overrides.CheckIdentityMappingDeclaration(document) + } } - overrides, _, _, err := LoadStagedPackageOverridesV1(operation, dir, document) if err != nil { - return nil, err + return state, nil, err + } + candidate, err := deploy.ResolveIdentityMappingGeometryV1(document, overrides.IdentityMappingOverride()) + return state, candidate, err +} + +func configureStagedIdentityMappingV1(ctx context.Context, operation *deploy.OperationLock, dir, overrideFile, operationID string, evidence deploy.IdentityMappingEvidenceV1) (deploy.StateV1, error) { + state, candidate, err := loadSelectedIdentityMappingV1(operation, dir, overrideFile) + if err != nil || candidate == nil { + return state, fmt.Errorf("mapping configuration requires a complete declared mapping: %v", err) } - return deploy.ResolveIdentityMappingGeometryV1(document, overrides.IdentityMappingOverride()) + return (deploy.IdentityMappingOperationsV1{Operation: operation, Evidence: evidence}).Configure(ctx, state, *candidate, operationID) } diff --git a/internal/dockerdeploy/identity_mapping_operations_test.go b/internal/dockerdeploy/identity_mapping_operations_test.go new file mode 100644 index 00000000..8be35889 --- /dev/null +++ b/internal/dockerdeploy/identity_mapping_operations_test.go @@ -0,0 +1,217 @@ +package dockerdeploy + +import ( + "context" + "os" + "path/filepath" + "reflect" + "strings" + "testing" + + "github.com/omry/reploy/internal/blueprint" + "github.com/omry/reploy/internal/deploy" + "github.com/omry/reploy/internal/providers" + "github.com/omry/reploy/internal/providerstore" +) + +func TestMappingOperationsSelectedOverrideSnapshotV1(t *testing.T) { + dir, state := identityGuardFixtureV1(t, true, false) + operation, err := deploy.AcquireExistingOperationLock(t.Context(), dir) + if err != nil { + t.Fatal(err) + } + defer operation.Unlock() + document, err := blueprint.DecodeResolvedDocumentV1(state.Blueprint) + if err != nil { + t.Fatal(err) + } + if err := operation.CommitPackageOverridesV1(mappingInputOverridesV1(t, document.Environment.ID, "300000")); err != nil { + t.Fatal(err) + } + selected := filepath.Join(t.TempDir(), "selected.yaml") + content, err := deploy.EncodePackageOverridesV1(mappingInputOverridesV1(t, document.Environment.ID, "400000")) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(selected, content, 0o600); err != nil { + t.Fatal(err) + } + before := identityGuardTreeV1(t, dir) + observed, candidate, err := loadSelectedIdentityMappingV1(operation, dir, selected) + if err != nil || !reflect.DeepEqual(observed, state) || candidate.UIDs[0].HostStart != 400000 { + t.Fatalf("selected capture: %#v %v", candidate, err) + } + if err := os.WriteFile(selected, []byte("invalid: true"), 0o600); err != nil { + t.Fatal(err) + } + if candidate.UIDs[0].HostStart != 400000 { + t.Fatal("captured input changed after file replacement") + } + if _, _, err := loadSelectedIdentityMappingV1(operation, dir, selected); err == nil { + t.Fatal("new operation reused stale override") + } + _, local, err := loadSelectedIdentityMappingV1(operation, dir, "") + if err != nil || local.UIDs[0].HostStart != 300000 { + t.Fatalf("local selection: %v", err) + } + if err := os.WriteFile(selected, content, 0o600); err != nil { + t.Fatal(err) + } + // An explicitly selected valid override must not also load the local sidecar. + if err := os.WriteFile(filepath.Join(dir, deploy.PackageOverridesFilename), []byte("invalid: true"), 0o600); err != nil { + t.Fatal(err) + } + if _, _, err := loadSelectedIdentityMappingV1(operation, dir, selected); err != nil { + t.Fatal("explicit selection read unrelated sidecar:", err) + } + if err := operation.CommitPackageOverridesV1(mappingInputOverridesV1(t, document.Environment.ID, "300000")); err != nil { + t.Fatal(err) + } + if _, err := configureStagedIdentityMappingV1(nil, operation, dir, selected, "configure", nil); err == nil || !strings.Contains(err.Error(), "context") { + t.Fatalf("private configure did not consume captured input: %v", err) + } + // Restore exact sidecar timestamps before comparing metadata-only reads. + after := identityGuardTreeV1(t, dir) + for path, fingerprint := range before { + if strings.HasSuffix(path, deploy.PackageOverridesFilename) { + continue + } + if after[path] != fingerprint { + t.Fatalf("mapping input changed %s", path) + } + } + for _, scenario := range []string{"missing", "wrong environment", "wrong owner", "undeclared"} { + t.Run(scenario, func(t *testing.T) { + selectedDir, selectedPath := dir, selected + switch scenario { + case "missing": + selectedPath = selected + ".missing" + case "wrong environment": + if err := os.WriteFile(selected, []byte(strings.Replace(string(content), document.Environment.ID, "foreign", 1)), 0o600); err != nil { + t.Fatal(err) + } + case "wrong owner": + selectedDir = t.TempDir() + case "undeclared": + ordinary := identityGuardDocumentV1(t, state, false, false) + if err := operation.CommitStateV1(state.Current, ordinary); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(selected, content, 0o600); err != nil { + t.Fatal(err) + } + } + if _, _, err := loadSelectedIdentityMappingV1(operation, selectedDir, selectedPath); err == nil { + t.Fatal("invalid selected input accepted") + } + if err := os.WriteFile(selected, content, 0o600); err != nil { + t.Fatal(err) + } + }) + } +} + +func TestMappingOperationsActualRetainedUninstallThroughStageAndGuardsV1(t *testing.T) { + for _, input := range []string{"complete", "missing", "invalid"} { + t.Run(input, func(t *testing.T) { + dir, installed, store := retainedIdentityStageV1(t, "empty", true) + op, err := deploy.AcquireExistingOperationLock(t.Context(), dir) + if err != nil { + t.Fatal(err) + } + installed = identityGuardDocumentV1(t, installed, true, true) + if err := op.CommitStateV1(installed.Current, installed); err != nil { + t.Fatal(err) + } + cleaned := false + plan := providerUninstallPlanV1{State: installed, Installation: installed.Deployment.Installation} + err = executeProviderUninstallWithV1(t.Context(), op, plan, RunOptions{}, func(context.Context, providerUninstallPlanV1, RunOptions) error { cleaned = true; return nil }) + if err != nil || !cleaned { + t.Fatalf("actual retained uninstall: %v", err) + } + retained, found, err := op.ReadStateV1() + if err != nil || !found || retained.Deployment != nil || retained.Staging == nil || retained.IdentityMapping.InstallationBinding != nil { + t.Fatalf("retained staging: %v", err) + } + owner := *retained.IdentityMapping + document, err := blueprint.DecodeResolvedDocumentV1(retained.Blueprint) + if err != nil { + t.Fatal(err) + } + if input != "missing" { + if err := op.CommitPackageOverridesV1(mappingInputOverridesV1(t, document.Environment.ID, "300000")); err != nil { + t.Fatal(err) + } + } + if input == "invalid" { + if err := os.WriteFile(filepath.Join(dir, deploy.PackageOverridesFilename), []byte("environment: null"), 0o600); err != nil { + t.Fatal(err) + } + } + _, candidate, err := loadSelectedIdentityMappingV1(op, dir, "") + if input == "complete" { + if err != nil || candidate.UIDs[0].HostStart != 300000 { + t.Fatalf("retained selected mapping: %v", err) + } + raw, _, _, err := LoadStagedPackageOverridesV1(op, dir, document) + if err != nil { + t.Fatal(err) + } + if _, err := ValidatedBuildInputs(document, retained.Overlay, raw, dir, retained.Platform); err != nil { + t.Fatalf("retained real build validation: %v", err) + } + } else if err == nil { + t.Fatal("missing/invalid retained mapping input accepted") + } + if _, err := unmappedRuntimeDocumentV1(retained); err == nil { + t.Fatal("retained uninstall enabled ordinary payload execution") + } + if err := op.Unlock(); err != nil { + t.Fatal(err) + } + // Use the existing public stage update, with the actual uninstall result. + update, updateErr := StageDesiredStateV1(t.Context(), DesiredStateStageInputV1{DeploymentDir: dir, Document: document, ExplicitPlatform: retained.Platform.Canonical, BlueprintSource: retained.BlueprintSource}) + if input == "invalid" { + if updateErr == nil { + t.Fatal("invalid sidecar accepted on retained update") + } + } else if updateErr != nil || !reflect.DeepEqual(update.State.IdentityMapping, &owner) { + t.Fatalf("same-environment update lost retained mapping: %v", updateErr) + } + before := identityGuardTreeV1(t, dir) + replacement := document + replacement.Environment.ID = "foreign-replacement" + _, err = forceReplaceStagedDesiredStateV1(t.Context(), ForceReplaceStagedDesiredStateInputV1{DesiredState: DesiredStateStageInputV1{DeploymentDir: dir, Document: replacement, ExplicitPlatform: retained.Platform.Canonical, BlueprintSource: "replacement"}}, forceReplaceStagedDesiredStateBackendV1{ + acquire: deploy.AcquireExistingOperationLock, + newStore: func(string) (providerstore.Store, error) { t.Fatal("replacement reached effects"); return store, nil }, + recoverPending: func(context.Context, *deploy.OperationLock, providerstore.Store, *deploy.EnvironmentGenerationState, string, string) (bool, error) { + t.Fatal("replacement recovery reached") + return false, nil + }, + admit: AdmitControlOperationV1, complete: CompleteControlAdmissionV1, stopOwned: stopOwnedCurrentWorkloadV1, + removeReference: func(context.Context, providers.RealizedImageV1, string, string, string) error { + t.Fatal("replacement image removal reached") + return nil + }, + commit: func(*deploy.OperationLock, *deploy.EnvironmentGenerationState, deploy.StateV1) error { + t.Fatal("replacement commit reached") + return nil + }, + stageSame: StageDesiredStateV1, + }) + requireRetainedIdentityRejectedV1(t, dir, before, err) + backend := testStagedRemovalBackendV1(store) + backend.newStore = func(string) (providerstore.Store, error) { t.Fatal("removal reached effects"); return store, nil } + _, err = removeStagedDeploymentV1(t.Context(), StagedDeploymentRemoveInputV1{DeploymentDir: dir, ControlMode: ControlAdmissionForceV1}, backend) + requireRetainedIdentityRejectedV1(t, dir, before, err) + current, err := os.ReadFile(filepath.Join(dir, StateFileName)) + if err != nil { + t.Fatal(err) + } + final, err := deploy.DecodeStateV1(current) + if err != nil || !reflect.DeepEqual(final.IdentityMapping, &owner) || final.Staging == nil || final.Deployment != nil { + t.Fatalf("retained authority changed through guards: %v", err) + } + }) + } +} diff --git a/internal/dockerdeploy/identity_mapping_retention_test.go b/internal/dockerdeploy/identity_mapping_retention_test.go index 894ab9f9..9cb789cb 100644 --- a/internal/dockerdeploy/identity_mapping_retention_test.go +++ b/internal/dockerdeploy/identity_mapping_retention_test.go @@ -32,6 +32,8 @@ func retainedIdentityRecordV1(t *testing.T, dir string, state deploy.StateV1, va } values := []deploy.IdentityMappingIntervalV1{{ContainerStart: "0", HostStart: "100000", Count: "16"}} switch variant { + case "retained": + m.UIDAssignments, m.GIDAssignments = values, values case "pending": m.PendingOperation = &deploy.IdentityMappingOperationV1{OperationID: "operation-fixture", Kind: "configure", ExpectedRevision: "7", ExpectedGeneration: state.Current, Candidate: deploy.IdentityMappingGeometryV1{Profile: "exact", UIDs: values, GIDs: values}, Phase: "effects-pending", Inhibited: true} @@ -403,7 +405,7 @@ func TestRetainedIdentityBuildPublicationPreservesRecordV1(t *testing.T) { defer op.Unlock() input := publicationInput(t, dir, lock) state := deploy.StateV1{Schema: deploy.StateSchemaV1, Blueprint: testResolvedBlueprintV1(t, input.Document), BlueprintSource: "retained source", Platform: lock.Platform, Overlay: deploy.EmptyRequestOverlayV1(), Staging: &deploy.StagingStateV1{Schema: deploy.StagingStateSchemaV1}} - state = retainedIdentityRecordV1(t, dir, state, "pending") + state = retainedIdentityRecordV1(t, dir, state, "retained") references := fixedPublicationReferences(t, dir, 0x51) result, err := publishBuild(t.Context(), op, store, input, buildPublicationBackend{ newReferences: func(string, string) (EnvironmentImageReferences, error) { return references, nil }, @@ -418,7 +420,52 @@ func TestRetainedIdentityBuildPublicationPreservesRecordV1(t *testing.T) { t.Fatal(err) } if result.Current == nil || !reflect.DeepEqual(state.IdentityMapping, result.IdentityMapping) { - t.Fatal("explicit portable publication lost mapping or pending intent") + t.Fatal("explicit portable publication lost retained mapping authority") + } +} + +func TestPendingIdentityBuildPublicationRejectsBeforeEffectsV1(t *testing.T) { + for _, phase := range []string{"intent", "effects-pending", "verified"} { + t.Run(phase, func(t *testing.T) { + dir := t.TempDir() + store, lock := publicationLockFixture(t, dir, "4", "5", "6") + op, err := deploy.AcquireOperationLock(t.Context(), dir) + if err != nil { + t.Fatal(err) + } + defer op.Unlock() + input := publicationInput(t, dir, lock) + state := deploy.StateV1{Schema: deploy.StateSchemaV1, Blueprint: testResolvedBlueprintV1(t, input.Document), BlueprintSource: "retained source", Platform: lock.Platform, Overlay: deploy.EmptyRequestOverlayV1(), Staging: &deploy.StagingStateV1{Schema: deploy.StagingStateSchemaV1}} + state = retainedIdentityRecordV1(t, dir, state, "pending") + state.IdentityMapping.PendingOperation.Phase = phase + content, err := deploy.EncodeStateV1(state) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, StateFileName), content, 0o600); err != nil { + t.Fatal(err) + } + before := identityGuardTreeV1(t, dir) + effects := 0 + references := fixedPublicationReferences(t, dir, 0x51) + _, err = publishBuild(t.Context(), op, store, input, buildPublicationBackend{ + newReferences: func(string, string) (EnvironmentImageReferences, error) { return references, nil }, + createReference: func(context.Context, providers.RealizedImageV1, EnvironmentImageReferences, EnvironmentReferenceKind, string, string) error { + effects++ + return nil + }, + removeReference: func(context.Context, providers.RealizedImageV1, EnvironmentImageReferences, EnvironmentReferenceKind, string, string) error { + effects++ + return nil + }, + }) + if err == nil || !strings.Contains(err.Error(), "pending identity mapping") || effects != 0 { + t.Fatalf("publication did not reject before effects: effects=%d err=%v", effects, err) + } + if after := identityGuardTreeV1(t, dir); !reflect.DeepEqual(after, before) { + t.Fatal("rejected publication changed pending state or build metadata") + } + }) } }