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") + } + }) } }