From bddfa7db7756f43db0bdee48ee26682a595b7a94 Mon Sep 17 00:00:00 2001 From: Omry Yadan Date: Thu, 22 Oct 2026 21:21:17 +0800 Subject: [PATCH] B4: retain deployment identity authority and guard destructive transitions Persist optional identity mapping authority in the existing deployment state under its operation lock. Bind the stable owner and selected directory, compare mapping and generation observations, and preserve mappings through ordinary state writers and retained uninstall staging. Reject copied authority, mapped installation adoption, unsupported payload execution and destructive replacement or removal before recovery and effects, including missing-target service-name recovery. Keep runtime admission and safe ID release in their later slices. Validation: full local Go, Python/package, Docker CLI smoke, release and docs checks passed. Two local review rounds resolved one recovery guard omission; fresh independent full review is clean. Guard-removal mutation controls fail as expected. --- .../multi-identity-retained-state.yaml | 2 + internal/deploy/deployment_state_v1.go | 29 +- internal/deploy/desired_state.go | 1 + internal/deploy/identity_mapping_state.go | 222 +++++ .../deploy/identity_mapping_state_file.go | 170 ++++ .../deploy/identity_mapping_state_test.go | 553 ++++++++++++ .../legacy_components_state_recovery.go | 3 + internal/deploy/state_v1.go | 3 +- internal/deploy/state_v1_file.go | 9 + internal/deploy/terminal_removal_guard.go | 3 + internal/dockerdeploy/build_publication.go | 1 + .../desired_state_force_replace.go | 8 +- .../dockerdeploy/identity_mapping_guard.go | 4 +- .../identity_mapping_guard_test.go | 16 +- .../identity_mapping_retention_test.go | 818 ++++++++++++++++++ .../installed_build_publication.go | 6 + .../provider_install_prepare_destination.go | 6 + .../provider_uninstall_backend.go | 13 +- .../provider_uninstall_pending_remove.go | 27 +- .../provider_uninstall_recovery.go | 48 +- .../provider_uninstall_remove_dir.go | 3 + .../dockerdeploy/provider_uninstall_run.go | 8 + .../dockerdeploy/staged_deployment_remove.go | 3 + 23 files changed, 1940 insertions(+), 16 deletions(-) create mode 100644 .changes/unreleased/multi-identity-retained-state.yaml create mode 100644 internal/deploy/identity_mapping_state.go create mode 100644 internal/deploy/identity_mapping_state_file.go create mode 100644 internal/deploy/identity_mapping_state_test.go create mode 100644 internal/dockerdeploy/identity_mapping_retention_test.go diff --git a/.changes/unreleased/multi-identity-retained-state.yaml b/.changes/unreleased/multi-identity-retained-state.yaml new file mode 100644 index 00000000..82877043 --- /dev/null +++ b/.changes/unreleased/multi-identity-retained-state.yaml @@ -0,0 +1,2 @@ +kind: Added +body: Preserve deployment-local identity mapping authority under the deployment lock. Reject copied mapping state, unsupported mapped execution, and destructive replacement or directory removal until checked release is available. diff --git a/internal/deploy/deployment_state_v1.go b/internal/deploy/deployment_state_v1.go index 890011c3..4468cf67 100644 --- a/internal/deploy/deployment_state_v1.go +++ b/internal/deploy/deployment_state_v1.go @@ -102,7 +102,7 @@ func ValidateInstallationStateV1(state InstallationStateV1) error { // MarkInstallationReadyV1 completes the only supported installation-state // transition. The ready record must describe the exact configuring install; // changing any other installation fact requires a new install publication. -func (lock *OperationLock) MarkInstallationReadyV1(ready InstallationStateV1) (StateV1, bool, error) { +func (lock *OperationLock) MarkInstallationReadyV1(ready InstallationStateV1, expectedMapping ...*IdentityMappingStateV1) (StateV1, bool, error) { if lock == nil { return StateV1{}, false, fmt.Errorf("mark installation ready requires an operation lock") } @@ -129,6 +129,9 @@ func (lock *OperationLock) MarkInstallationReadyV1(ready InstallationStateV1) (S if !found || state.Current == nil || state.Deployment == nil { return StateV1{}, false, fmt.Errorf("mark installation ready requires a committed installation") } + if err := compareIdentityObservationV1(state.IdentityMapping, expectedMapping); err != nil { + return StateV1{}, false, err + } targetDir := filepath.Dir(filepath.Dir(path)) if ready.TargetDir != targetDir { return StateV1{}, false, fmt.Errorf("installation target directory %q does not match locked deployment %q", ready.TargetDir, targetDir) @@ -161,7 +164,7 @@ func validInstallationPort(value string) bool { // installed. The caller retains the deployment operation lock across any // filesystem, service, image-reference, and provider-store work that precedes // this final state publication. -func (lock *OperationLock) SetInstallationStateV1(installation InstallationStateV1) (StateV1, bool, error) { +func (lock *OperationLock) SetInstallationStateV1(installation InstallationStateV1, expectedMapping ...*IdentityMappingStateV1) (StateV1, bool, error) { if lock == nil { return StateV1{}, false, fmt.Errorf("set installation state requires an operation lock") } @@ -188,6 +191,9 @@ func (lock *OperationLock) SetInstallationStateV1(installation InstallationState if state.Current == nil { return StateV1{}, false, fmt.Errorf("set installation state requires a current build") } + if err := compareIdentityObservationV1(state.IdentityMapping, expectedMapping); err != nil { + return StateV1{}, false, err + } targetDir := filepath.Dir(filepath.Dir(path)) if installation.TargetDir != targetDir { return StateV1{}, false, fmt.Errorf("installation target directory %q does not match locked deployment %q", installation.TargetDir, targetDir) @@ -207,7 +213,7 @@ func (lock *OperationLock) SetInstallationStateV1(installation InstallationState // ClearInstallationStateV1 removes the exact recorded installation facts // after host uninstall succeeds, returning the deployment to staged state // without changing its current build. -func (lock *OperationLock) ClearInstallationStateV1(expected InstallationStateV1) (StateV1, bool, error) { +func (lock *OperationLock) ClearInstallationStateV1(expected InstallationStateV1, expectedMapping ...*IdentityMappingStateV1) (StateV1, bool, error) { if lock == nil { return StateV1{}, false, fmt.Errorf("clear installation state requires an operation lock") } @@ -234,7 +240,13 @@ func (lock *OperationLock) ClearInstallationStateV1(expected InstallationStateV1 if !reflect.DeepEqual(state.Deployment.Installation, expected) { return StateV1{}, false, fmt.Errorf("installed facts changed before clearing installation state") } - state.Deployment = nil + if err := compareIdentityObservationV1(state.IdentityMapping, expectedMapping); err != nil { + return StateV1{}, false, err + } + state, err = RetainedStagingAfterUninstallV1(state, expected) + if err != nil { + return StateV1{}, false, err + } if err := writeInstalledStateV1(path, state); err != nil { return StateV1{}, false, err } @@ -260,6 +272,9 @@ func (lock *OperationLock) CommitInstalledStateV1( if sourceState.Current == nil { return StateV1{}, false, fmt.Errorf("commit installed state requires a current source build") } + if sourceState.IdentityMapping != nil { + return StateV1{}, false, fmt.Errorf("mapped install requires its reviewed destination adapter") + } if sourceState.Deployment != nil { return StateV1{}, false, fmt.Errorf("commit installed state requires staged source state without deployment-local facts") } @@ -287,6 +302,9 @@ func (lock *OperationLock) CommitInstalledStateV1( if err != nil { return StateV1{}, false, err } + if current.IdentityMapping != nil { + return StateV1{}, false, fmt.Errorf("mapped destination requires its reviewed installation adapter") + } var observed *EnvironmentGenerationState if found { observed = current.Current @@ -322,6 +340,9 @@ func validateInstalledDestinationGeneration(source EnvironmentGenerationState, d } func writeInstalledStateV1(path string, state StateV1) error { + if err := validateMappingOwnerPathV1(path, state); err != nil { + return err + } content, err := EncodeStateV1(state) if err != nil { return err diff --git a/internal/deploy/desired_state.go b/internal/deploy/desired_state.go index 0119670d..a4bb6eff 100644 --- a/internal/deploy/desired_state.go +++ b/internal/deploy/desired_state.go @@ -192,6 +192,7 @@ func setDesiredStateV1( Schema: StateSchemaV1, Blueprint: payload, BlueprintSource: blueprintSource, Platform: platform, Overlay: overlay, Current: generation, Staging: staging, Deployment: deployment, + IdentityMapping: current.IdentityMapping, } candidateBytes, err := EncodeStateV1(candidate) if err != nil { diff --git a/internal/deploy/identity_mapping_state.go b/internal/deploy/identity_mapping_state.go new file mode 100644 index 00000000..8cc6cb3c --- /dev/null +++ b/internal/deploy/identity_mapping_state.go @@ -0,0 +1,222 @@ +package deploy + +import ( + "fmt" + "math" + "path/filepath" + "reflect" + "slices" + "strconv" + + "github.com/omry/reploy/internal/identitymapping" +) + +const IdentityMappingStateSchemaV1 = "identity-mapping-v1" + +// IdentityMappingStateV1 is deployment-local retained authority, not execution +// admission. InstallationID identifies the mapping owner across uninstall. +// InstallationBinding identifies only its currently installed service. +type IdentityMappingStateV1 struct { + Schema string `json:"schema"` + InstallationID string `json:"installation_id"` + OwnerDir string `json:"owner_dir"` + InstallationBinding *string `json:"installation_binding"` + Revision string `json:"revision"` + OperatorUID string `json:"operator_uid"` + OperatorGID string `json:"operator_gid"` + Profile string `json:"profile"` + UIDAssignments []IdentityMappingIntervalV1 `json:"uid_assignments"` + GIDAssignments []IdentityMappingIntervalV1 `json:"gid_assignments"` + DormantUIDs []IdentityMappingIntervalV1 `json:"dormant_uids"` + DormantGIDs []IdentityMappingIntervalV1 `json:"dormant_gids"` + Supervisor *string `json:"supervisor"` + Engine *string `json:"engine"` + AdmittedGeneration *EnvironmentGenerationState `json:"admitted_generation"` + RetainedStorage []string `json:"retained_storage"` + Revoked bool `json:"revoked"` + PendingOperation *IdentityMappingOperationV1 `json:"pending_operation"` +} + +// IdentityMappingOperationV1 retains an inhibited candidate separately from +// protected assignments. A pending candidate is reserved, never admitted. +// B5 owns operation transitions; later effect owners must verify exact handles. +type IdentityMappingOperationV1 struct { + OperationID string `json:"operation_id"` + Kind string `json:"kind"` + ExpectedRevision string `json:"expected_revision"` + ExpectedGeneration *EnvironmentGenerationState `json:"expected_generation"` + Candidate IdentityMappingGeometryV1 `json:"candidate"` + PriorAccepted *EnvironmentGenerationState `json:"prior_accepted"` + OwnedObjects []string `json:"owned_objects"` + Phase string `json:"phase"` + Inhibited bool `json:"inhibited"` +} + +func validateIdentityMappingStateV1(state StateV1) error { + m := state.IdentityMapping + if m == nil { + return nil + } + if m.Schema != IdentityMappingStateSchemaV1 || !safeRecoveryIdentity(m.InstallationID) || !validMappingDecimalV1(m.Revision, 64, true) { + return fmt.Errorf("identity mapping requires its schema, stable owner ID and positive revision") + } + if !filepath.IsAbs(m.OwnerDir) || filepath.Clean(m.OwnerDir) != m.OwnerDir || !safeRecoveryIdentity(m.OwnerDir) { + return fmt.Errorf("identity mapping requires a canonical absolute owner directory") + } + if !validMappingDecimalV1(m.OperatorUID, 32, true) || !validMappingDecimalV1(m.OperatorGID, 32, true) { + return fmt.Errorf("identity mapping requires a non-root usable operator UID/GID") + } + if m.Profile != "exact" && m.Profile != "bounded" { + return fmt.Errorf("identity mapping profile must be exact or bounded") + } + if state.Staging == nil && state.Deployment == nil { + return fmt.Errorf("identity mapping requires retained staging or installed authority") + } + if state.Deployment == nil { + if m.InstallationBinding != nil { + return fmt.Errorf("staged identity mapping cannot retain an active installation binding") + } + } else if m.InstallationBinding == nil || *m.InstallationBinding != state.Deployment.Installation.InstanceID { + return fmt.Errorf("identity mapping installation binding does not match installed facts") + } + if (len(m.UIDAssignments) == 0) != (len(m.GIDAssignments) == 0) { + return fmt.Errorf("identity mapping requires both protected ID domains or neither") + } + if len(m.UIDAssignments) != 0 { + if err := requireCanonicalMappingGeometryV1(m.geometry()); err != nil { + return err + } + } + for _, binding := range []*string{m.Supervisor, m.Engine} { + if binding != nil && !safeRecoveryIdentity(*binding) { + return fmt.Errorf("identity mapping binding must identify retained authority") + } + } + if m.AdmittedGeneration != nil { + if err := ValidateEnvironmentGenerationState(*m.AdmittedGeneration); err != nil { + return fmt.Errorf("identity mapping admitted generation: %w", err) + } + if len(m.UIDAssignments) == 0 || m.Supervisor == nil || m.Engine == nil { + return fmt.Errorf("identity mapping admission requires retained assignments and bindings") + } + } + for i, values := range [][]IdentityMappingIntervalV1{m.DormantUIDs, m.DormantGIDs} { + if len(values) == 0 { + continue + } + if m.AdmittedGeneration == nil { + return fmt.Errorf("dormant assignments require prior admitted authority") + } + if err := requireCanonicalMappingGeometryV1(IdentityMappingGeometryV1{Profile: "exact", UIDs: values, GIDs: values}); err != nil { + return err + } + protected := m.UIDAssignments + if i == 1 { + protected = m.GIDAssignments + } + for _, value := range values { + if !slices.ContainsFunc(protected, func(old IdentityMappingIntervalV1) bool { + return mappingDecimalV1(value.ContainerStart) >= mappingDecimalV1(old.ContainerStart) && + mappingDecimalV1(value.ContainerStart)+mappingDecimalV1(value.Count) <= mappingDecimalV1(old.ContainerStart)+mappingDecimalV1(old.Count) && + int64(mappingDecimalV1(value.HostStart))-int64(mappingDecimalV1(value.ContainerStart)) == int64(mappingDecimalV1(old.HostStart))-int64(mappingDecimalV1(old.ContainerStart)) + }) { + return fmt.Errorf("dormant assignment is not a retained admitted association") + } + } + } + if err := validateMappingHandlesV1(m.RetainedStorage); err != nil { + return err + } + if pending := m.PendingOperation; pending != nil { + if !safeRecoveryIdentity(pending.OperationID) || !safeRecoveryIdentity(pending.Kind) || !pending.Inhibited || + !validMappingDecimalV1(pending.ExpectedRevision, 64, true) || mappingDecimalV1(pending.ExpectedRevision) > mappingDecimalV1(m.Revision) || + (pending.Phase != "intent" && pending.Phase != "effects-pending" && pending.Phase != "verified") { + return fmt.Errorf("identity mapping pending operation requires exact inhibited authority and phase") + } + if pending.Candidate.Profile != m.Profile { + return fmt.Errorf("identity mapping pending profile differs from retained profile") + } + if err := requireCanonicalMappingGeometryV1(pending.Candidate); err != nil { + return err + } + if len(m.UIDAssignments) != 0 { + protected := m.geometry() + if _, err := identitymapping.RetainGeometry(pending.Candidate.numeric(), geometryPointerV1(protected.numeric())); err != nil { + return err + } + } + for _, generation := range []*EnvironmentGenerationState{pending.ExpectedGeneration, pending.PriorAccepted} { + if generation != nil { + if err := ValidateEnvironmentGenerationState(*generation); err != nil { + return err + } + } + } + if err := validateMappingHandlesV1(pending.OwnedObjects); err != nil { + return err + } + } + return nil +} + +func (m IdentityMappingStateV1) geometry() IdentityMappingGeometryV1 { + return IdentityMappingGeometryV1{Profile: m.Profile, UIDs: m.UIDAssignments, GIDs: m.GIDAssignments} +} + +func requireCanonicalMappingGeometryV1(value IdentityMappingGeometryV1) error { + for _, values := range [][]IdentityMappingIntervalV1{value.UIDs, value.GIDs} { + for _, v := range values { + if !validMappingDecimalV1(v.ContainerStart, 32, false) || !validMappingDecimalV1(v.HostStart, 32, false) || !validMappingDecimalV1(v.Count, 64, true) || mappingDecimalV1(v.Count) > math.MaxUint32 { + return fmt.Errorf("mapping intervals require canonical decimal IDs and counts") + } + } + } + checked, err := identitymapping.ValidateGeometry(value.numeric()) + if err != nil || !reflect.DeepEqual(checked, value.numeric()) { + return fmt.Errorf("identity mapping geometry must be valid and canonical: %v", err) + } + return nil +} + +func validateMappingHandlesV1(handles []string) error { + for i, handle := range handles { + if !safeRecoveryIdentity(handle) || i > 0 && handles[i-1] >= handle { + return fmt.Errorf("identity mapping handles must be safe, unique and sorted") + } + } + return nil +} + +// State uses decimal strings, as required by the canonical state encoder. +type IdentityMappingIntervalV1 struct { + ContainerStart string `json:"container_start"` + HostStart string `json:"host_start"` + Count string `json:"count"` +} +type IdentityMappingGeometryV1 struct { + Profile string `json:"profile"` + UIDs []IdentityMappingIntervalV1 `json:"uids"` + GIDs []IdentityMappingIntervalV1 `json:"gids"` +} + +func mappingDecimalV1(value string) uint64 { n, _ := strconv.ParseUint(value, 10, 64); return n } +func validMappingDecimalV1(value string, bits int, positive bool) bool { + n, err := strconv.ParseUint(value, 10, bits) + return err == nil && strconv.FormatUint(n, 10) == value && (!positive || n > 0) && (bits != 32 || n < math.MaxUint32) +} +func (g IdentityMappingGeometryV1) numeric() identitymapping.Geometry { + result := identitymapping.Geometry{Profile: g.Profile} + for i, values := range [][]IdentityMappingIntervalV1{g.UIDs, g.GIDs} { + var out []identitymapping.Interval + for _, v := range values { + out = append(out, identitymapping.Interval{ContainerStart: uint32(mappingDecimalV1(v.ContainerStart)), HostStart: uint32(mappingDecimalV1(v.HostStart)), Count: uint32(mappingDecimalV1(v.Count))}) + } + if i == 0 { + result.UIDs = out + } else { + result.GIDs = out + } + } + return result +} +func geometryPointerV1(g identitymapping.Geometry) *identitymapping.Geometry { return &g } diff --git a/internal/deploy/identity_mapping_state_file.go b/internal/deploy/identity_mapping_state_file.go new file mode 100644 index 00000000..5406a743 --- /dev/null +++ b/internal/deploy/identity_mapping_state_file.go @@ -0,0 +1,170 @@ +package deploy + +import ( + "crypto/rand" + "encoding/json" + "fmt" + "math" + "os" + "path/filepath" + "reflect" + "strconv" + "strings" +) + +// RequireNoRetainedIdentityV1 is a before-effects barrier, including for legacy +// recovery. Presence alone rejects; a compatibility decoder must not erase it. +func (lock *OperationLock) RequireNoRetainedIdentityV1() error { + if lock == nil { + return fmt.Errorf("identity protection requires an operation lock") + } + lock.mutex.Lock() + defer lock.mutex.Unlock() + path, err := lock.statePathV1Locked() + if err != nil { + return err + } + return requireNoRetainedIdentityPathV1(path) +} + +func requireNoRetainedIdentityPathV1(path string) error { + info, err := os.Lstat(path) + if os.IsNotExist(err) { + return nil + } + if err != nil { + return err + } + if !info.Mode().IsRegular() { + return fmt.Errorf("identity state must be a regular file") + } + content, err := os.ReadFile(path) + if err != nil { + return err + } + var fields map[string]json.RawMessage + if err := json.Unmarshal(content, &fields); err != nil { + return err + } + for key := range fields { + if strings.EqualFold(key, "identity_mapping") { + return fmt.Errorf("retained identity mapping requires checked safe release; replacement or directory removal is unavailable") + } + } + return nil +} + +func validateMappingOwnerPathV1(path string, state StateV1) error { + if state.IdentityMapping == nil { + return nil + } + owner, err := filepath.EvalSymlinks(filepath.Dir(filepath.Dir(path))) + if err != nil { + return err + } + if owner != state.IdentityMapping.OwnerDir { + return fmt.Errorf("identity mapping belongs to %q, not selected directory %q; copied authority cannot be adopted", state.IdentityMapping.OwnerDir, owner) + } + return nil +} + +func compareIdentityObservationV1(actual *IdentityMappingStateV1, expected []*IdentityMappingStateV1) error { + if len(expected) > 1 || len(expected) == 0 && actual != nil || len(expected) == 1 && !reflect.DeepEqual(actual, expected[0]) { + return fmt.Errorf("identity revision changed or was omitted before installation state commit") + } + return nil +} + +// CommitIdentityMappingV1 compares both independent revisions under the existing +// operation lock. This metadata transaction cannot grant runtime admission. +func (lock *OperationLock) CommitIdentityMappingV1(expectedGeneration *EnvironmentGenerationState, expectedID string, expectedRevision uint64, next IdentityMappingStateV1) (StateV1, error) { + if lock == nil { + return StateV1{}, fmt.Errorf("identity commit requires an 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 { + return StateV1{}, err + } + if !found || !reflect.DeepEqual(state.Current, expectedGeneration) { + return StateV1{}, fmt.Errorf("identity generation changed or state is missing") + } + // Detach all caller-owned slices and pointers before checking or returning. + payload, err := json.Marshal(next) + if err != nil { + return StateV1{}, err + } + if err := json.Unmarshal(payload, &next); err != nil { + return StateV1{}, err + } + old := state.IdentityMapping + if old == nil { + if expectedID != "" || expectedRevision != 0 || state.Deployment != nil || state.Staging == nil { + return StateV1{}, fmt.Errorf("first mapping configuration requires an unconfigured staged owner; installed opt-in requires its reviewed adapter") + } + if next.InstallationID != "" || next.OwnerDir != "" || next.Revision != "" || next.InstallationBinding != nil || + len(next.UIDAssignments)+len(next.GIDAssignments)+len(next.DormantUIDs)+len(next.DormantGIDs)+len(next.RetainedStorage) != 0 || + next.AdmittedGeneration != nil || next.Supervisor != nil || next.Engine != nil || next.Revoked || next.PendingOperation != nil { + return StateV1{}, fmt.Errorf("first mapping configuration cannot supply retained authority") + } + next.InstallationID, err = newOpaqueAdmissionIDV1(rand.Reader, "mapping", "mapping owner") + if err != nil { + return StateV1{}, err + } + next.OwnerDir, err = filepath.EvalSymlinks(filepath.Dir(filepath.Dir(path))) + if err != nil { + return StateV1{}, err + } + next.Revision = "1" + } else { + if old.InstallationID != expectedID || mappingDecimalV1(old.Revision) != expectedRevision || expectedRevision == math.MaxUint64 { + return StateV1{}, fmt.Errorf("identity owner or revision changed before commit") + } + // B5 supplies checked pending-operation transitions. Existing authority is + // immutable here; generic metadata writes cannot replace accepted history. + if !reflect.DeepEqual(*old, next) { + return StateV1{}, fmt.Errorf("identity authority transition requires its checked operation") + } + next.Revision = strconv.FormatUint(expectedRevision+1, 10) + } + state.IdentityMapping = &next + content, err := EncodeStateV1(state) + if err != nil { + return StateV1{}, err + } + if err := validateMappingOwnerPathV1(path, state); err != nil { + return StateV1{}, err + } + if err := writeAtomicStateFile(path, content, 0o600); err != nil { + return StateV1{}, err + } + return state, nil +} + +// RetainedStagingAfterUninstallV1 validates the whole candidate before host +// cleanup. The final commit compares the exact prior mapping and installation. +func RetainedStagingAfterUninstallV1(state StateV1, expected InstallationStateV1) (StateV1, error) { + if state.Deployment == nil || !reflect.DeepEqual(state.Deployment.Installation, expected) { + return StateV1{}, fmt.Errorf("installed facts changed before retained staging") + } + state.Deployment = nil + if m := state.IdentityMapping; m != nil { + if m.PendingOperation != nil || mappingDecimalV1(m.Revision) == math.MaxUint64 { + return StateV1{}, fmt.Errorf("identity operation must be reconciled before uninstall") + } + copy := *m + copy.InstallationBinding = nil + copy.Revision = strconv.FormatUint(mappingDecimalV1(m.Revision)+1, 10) + state.IdentityMapping = © + state.Staging = &StagingStateV1{Schema: StagingStateSchemaV1} + } + return state, ValidateStateV1(state) +} diff --git a/internal/deploy/identity_mapping_state_test.go b/internal/deploy/identity_mapping_state_test.go new file mode 100644 index 00000000..beb5900a --- /dev/null +++ b/internal/deploy/identity_mapping_state_test.go @@ -0,0 +1,553 @@ +package deploy + +import ( + "encoding/json" + "errors" + "fmt" + "github.com/omry/reploy/internal/blueprint" + "os" + "path/filepath" + "reflect" + "strings" + "testing" +) + +func mappingStateFixtureV1(t *testing.T, installed bool) (string, *OperationLock, StateV1) { + t.Helper() + dir := t.TempDir() + path := writeOverlayTestState(t, dir) + state := readOverlayTestState(t, path) + state.Staging = &StagingStateV1{Schema: StagingStateSchemaV1} + state.BlueprintSource = "retained blueprint.yaml" + owner, err := filepath.EvalSymlinks(dir) + if err != nil { + t.Fatal(err) + } + state.IdentityMapping = &IdentityMappingStateV1{ + Schema: IdentityMappingStateSchemaV1, InstallationID: "mapping-fixture", OwnerDir: owner, + Revision: "1", OperatorUID: "1000", OperatorGID: "1000", Profile: "exact", + } + if installed { + installation := installationStateV1Fixture(dir) + state.Staging = nil + state.Deployment = &DeploymentStateV1{Schema: DeploymentStateSchemaV1, Installation: installation} + state.IdentityMapping.InstallationBinding = &installation.InstanceID + } + writeMappingFixtureV1(t, path, state) + lock, err := AcquireOperationLock(t.Context(), dir) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = lock.Unlock() }) + return path, lock, state +} + +func writeMappingFixtureV1(t *testing.T, path string, state StateV1) { + t.Helper() + content, err := EncodeStateV1(state) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, content, 0o600); err != nil { + t.Fatal(err) + } +} + +func TestIdentityMappingOptionalStateAndStrictValidation(t *testing.T) { + path, _, state := mappingStateFixtureV1(t, false) + content, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + decoded, err := DecodeStateV1(content) + if err != nil || !reflect.DeepEqual(state, decoded) { + t.Fatalf("round trip: %v", err) + } + for name, mutate := range map[string]func(*StateV1){ + "schema": func(s *StateV1) { s.IdentityMapping.Schema = "future" }, + "profile": func(s *StateV1) { s.IdentityMapping.Profile = "implicit" }, + "missing owner kind": func(s *StateV1) { s.Staging = nil }, + "zero revision": func(s *StateV1) { s.IdentityMapping.Revision = "0" }, + "relative owner": func(s *StateV1) { s.IdentityMapping.OwnerDir = "relative" }, + "root operator": func(s *StateV1) { s.IdentityMapping.OperatorUID = "0" }, + "staged active binding": func(s *StateV1) { id := "service"; s.IdentityMapping.InstallationBinding = &id }, + "one assignment domain": func(s *StateV1) { + s.IdentityMapping.UIDAssignments = []IdentityMappingIntervalV1{{ContainerStart: "0", HostStart: "100000", Count: "1"}} + }, + "admission without bindings": func(s *StateV1) { s.IdentityMapping.AdmittedGeneration = s.Current }, + "dormant without admission": func(s *StateV1) { + s.IdentityMapping.DormantUIDs = []IdentityMappingIntervalV1{{ContainerStart: "0", HostStart: "100000", Count: "1"}} + }, + "duplicate retained handle": func(s *StateV1) { s.IdentityMapping.RetainedStorage = []string{"x", "x"} }, + "unsafe pending": func(s *StateV1) { s.IdentityMapping.PendingOperation = &IdentityMappingOperationV1{} }, + "wrong installed binding": func(s *StateV1) { + s.Staging = nil + s.Deployment = &DeploymentStateV1{Schema: DeploymentStateSchemaV1, Installation: installationStateV1Fixture(s.IdentityMapping.OwnerDir)} + }, + } { + t.Run(name, func(t *testing.T) { + var next StateV1 + if err := json.Unmarshal(content, &next); err != nil { + t.Fatal(err) + } + mutate(&next) + if _, err := EncodeStateV1(next); err == nil { + t.Fatal("invalid authority accepted") + } + }) + } + state.IdentityMapping = nil + ordinary, err := EncodeStateV1(state) + if err != nil || strings.Contains(string(ordinary), "identity_mapping") { + t.Fatalf("ordinary representation changed: %s %v", ordinary, err) + } +} + +func TestIdentityMappingCompareRevisionAndGeneration(t *testing.T) { + path, lock, state := mappingStateFixtureV1(t, false) + before, _ := os.ReadFile(path) + for name, mutate := range map[string]func(*StateV1){ + "omitted": func(s *StateV1) { s.IdentityMapping = nil }, + "stale": func(s *StateV1) { m := *s.IdentityMapping; m.Revision = "2"; s.IdentityMapping = &m }, + "replace": func(s *StateV1) { m := *s.IdentityMapping; m.InstallationID = "other"; s.IdentityMapping = &m }, + } { + t.Run(name, func(t *testing.T) { + next := state + mutate(&next) + if err := lock.CommitStateV1(state.Current, next); err == nil { + t.Fatal("authority overwrite accepted") + } + }) + } + if _, err := lock.CommitIdentityMappingV1(nil, state.IdentityMapping.InstallationID, 1, *state.IdentityMapping); err == nil { + t.Fatal("stale generation accepted") + } + if _, err := lock.CommitIdentityMappingV1(state.Current, "other", 1, *state.IdentityMapping); err == nil { + t.Fatal("foreign owner accepted") + } + if _, err := lock.CommitIdentityMappingV1(state.Current, state.IdentityMapping.InstallationID, 2, *state.IdentityMapping); err == nil { + t.Fatal("stale revision accepted") + } + after, _ := os.ReadFile(path) + if string(before) != string(after) { + t.Fatal("rejected writes changed state") + } + next, err := lock.CommitIdentityMappingV1(state.Current, state.IdentityMapping.InstallationID, 1, *state.IdentityMapping) + if err != nil || next.IdentityMapping.Revision != "2" { + t.Fatalf("revision commit: %#v %v", next.IdentityMapping, err) + } + if _, err := lock.CommitIdentityMappingV1(state.Current, state.IdentityMapping.InstallationID, 1, *state.IdentityMapping); err == nil { + t.Fatal("old observation accepted after mapping-only change") + } + info, err := os.Stat(path) + if err != nil || hasPOSIXPermissionBits() && info.Mode().Perm() != 0o600 { + t.Fatalf("private state mode: %v %v", info, err) + } +} + +func TestIdentityMappingFirstOwnerAndCopiedState(t *testing.T) { + path, lock, state := mappingStateFixtureV1(t, false) + state.IdentityMapping = nil + writeMappingFixtureV1(t, path, state) + input := IdentityMappingStateV1{Schema: IdentityMappingStateSchemaV1, OperatorUID: "1000", OperatorGID: "1000", Profile: "bounded"} + created, err := lock.CommitIdentityMappingV1(state.Current, "", 0, input) + if err != nil || created.IdentityMapping.InstallationID == "" || created.IdentityMapping.Revision != "1" { + t.Fatalf("first owner: %#v %v", created.IdentityMapping, err) + } + if _, err := lock.CommitIdentityMappingV1(state.Current, "", 0, input); err == nil { + t.Fatal("retry allocated another owner") + } + other := t.TempDir() + otherPath := writeOverlayTestState(t, other) + writeMappingFixtureV1(t, otherPath, created) + copyLock, err := AcquireOperationLock(t.Context(), other) + if err != nil { + t.Fatal(err) + } + defer copyLock.Unlock() + if _, _, err := copyLock.ReadStateV1(); err == nil || !strings.Contains(err.Error(), "copied") { + t.Fatalf("copied owner: %v", err) + } + if err := copyLock.RequireNoRetainedIdentityV1(); err == nil { + t.Fatal("copied mapping passed destructive guard") + } +} + +func TestIdentityMappingUninstallIsAtomicRetainedStaging(t *testing.T) { + path, lock, state := mappingStateFixtureV1(t, true) + expected := state.Deployment.Installation + before, _ := os.ReadFile(path) + if _, _, err := lock.ClearInstallationStateV1(expected); err == nil { + t.Fatal("omitted mapping observation accepted") + } + bad := state + bad.BlueprintSource = "" + if _, err := RetainedStagingAfterUninstallV1(bad, expected); err == nil { + t.Fatal("missing retained input accepted") + } + after, _ := os.ReadFile(path) + if string(before) != string(after) { + t.Fatal("rejection changed installed facts") + } + staged, changed, err := lock.ClearInstallationStateV1(expected, state.IdentityMapping) + if err != nil || !changed { + t.Fatal(err) + } + if staged.Deployment != nil || staged.Staging == nil || staged.IdentityMapping.InstallationBinding != nil || staged.IdentityMapping.Revision != "2" { + t.Fatalf("invalid retained staging: %#v", staged) + } + if staged.IdentityMapping.InstallationID != state.IdentityMapping.InstallationID || !reflect.DeepEqual(staged.Current, state.Current) { + t.Fatal("owner or generation changed") + } + if _, _, err := lock.ReadStateV1(); err != nil { + t.Fatal(err) + } + if err := lock.RequireNoRetainedIdentityV1(); err == nil { + t.Fatal("uninstall released mapping") + } + if _, _, err := lock.SetInstallationStateV1(expected); err == nil { + t.Fatal("retained staging implicitly rebound to installation") + } +} + +func TestIdentityMappingPresenceBarrierIncludesMalformedAndLegacy(t *testing.T) { + path, lock, _ := mappingStateFixtureV1(t, false) + for _, content := range []string{ + `{"schema":"legacy","identity_mapping":null}`, + `{"schema":"legacy","Identity_Mapping":false}`, + `{"identity_mapping":{},"identity_mapping":null}`, + `{"identity_mapping":`, + } { + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { + t.Fatal(err) + } + if err := lock.RequireNoRetainedIdentityV1(); err == nil { + t.Fatalf("guard accepted %s", content) + } + } + if err := os.WriteFile(path, []byte(`{"schema":"legacy"}`), 0o600); err != nil { + t.Fatal(err) + } + if err := lock.RequireNoRetainedIdentityV1(); err != nil { + t.Fatalf("presence guard broke legacy recovery: %v", err) + } +} + +func TestIdentityMappingOrdinaryWriterFamilyPreservesAuthority(t *testing.T) { + path, lock, state := mappingStateFixtureV1(t, false) + if err := lock.Unlock(); err != nil { + t.Fatal(err) + } + dir := filepath.Dir(filepath.Dir(path)) + document, err := blueprint.DecodeResolvedDocumentV1(state.Blueprint) + if err != nil { + t.Fatal(err) + } + if _, err := SetStagedDesiredStateV1(t.Context(), dir, document, state.Platform, overlayTestPackageValidator, "updated source", false); err != nil { + t.Fatal(err) + } + if _, err := SetDesiredPlatformV1(t.Context(), dir, state.Platform, overlayTestPackageValidator); err != nil { + t.Fatal(err) + } + if _, err := MutateRequestOverlayV1(t.Context(), dir, overlayTestPackageValidator, func(_ blueprint.Document, overlay RequestOverlayV1) (RequestOverlayV1, error) { + overlay.SelectedOptions = append(overlay.SelectedOptions, QualifiedOption{Application: "app", Option: "debug"}) + return overlay, nil + }); err != nil { + t.Fatal(err) + } + after := readOverlayTestState(t, path) + if !reflect.DeepEqual(state.IdentityMapping, after.IdentityMapping) { + t.Fatal("stage/platform/overlay update changed mapping authority") + } + if after.BlueprintSource != "updated source" || len(after.Overlay.SelectedOptions) != 1 { + t.Fatal("ordinary desired updates did not run") + } +} + +func TestIdentityMappingUninstallPreservesAdmittedAndDormantHistory(t *testing.T) { + path, lock, state := mappingStateFixtureV1(t, true) + m := state.IdentityMapping + values := []IdentityMappingIntervalV1{{ContainerStart: "0", HostStart: "100000", Count: "16"}} + m.UIDAssignments, m.GIDAssignments, m.DormantUIDs, m.DormantGIDs = values, values, values, values + m.AdmittedGeneration = state.Current + supervisor, engine := "supervisor-1", "engine-1" + m.Supervisor, m.Engine = &supervisor, &engine + m.RetainedStorage = []string{"data-1"} + m.Revoked = true + writeMappingFixtureV1(t, path, state) + old := *m + installation := state.Deployment.Installation + result, _, err := lock.ClearInstallationStateV1(installation, m) + if err != nil { + t.Fatal(err) + } + old.InstallationBinding = nil + old.Revision = "2" + if !reflect.DeepEqual(*result.IdentityMapping, old) { + t.Fatal("uninstall released retained ownership history") + } + // Repeating a stale installed observation cannot create a second transition. + before, _ := os.ReadFile(path) + if _, _, err := lock.ClearInstallationStateV1(installation, m); err == nil { + t.Fatal("stale uninstall accepted") + } + after, _ := os.ReadFile(path) + if string(before) != string(after) { + t.Fatal("stale uninstall rewrote staging") + } +} + +func TestIdentityMappingUninstallRejectsPendingAndInvalidInputBeforeCommit(t *testing.T) { + for _, pending := range []bool{false, true} { + t.Run(strings.ToLower(fmt.Sprint(pending)), func(t *testing.T) { + path, lock, state := mappingStateFixtureV1(t, true) + if pending { + values := []IdentityMappingIntervalV1{{ContainerStart: "0", HostStart: "100000", Count: "16"}} + state.IdentityMapping.PendingOperation = &IdentityMappingOperationV1{OperationID: "operation-1", Kind: "configure", ExpectedRevision: "1", Candidate: IdentityMappingGeometryV1{Profile: "exact", UIDs: values, GIDs: values}, Phase: "effects-pending", Inhibited: true} + } else { + state.BlueprintSource = "" + } + writeMappingFixtureV1(t, path, state) + before, _ := os.ReadFile(path) + if _, _, err := lock.ClearInstallationStateV1(state.Deployment.Installation, state.IdentityMapping); err == nil { + t.Fatal("unproved retained staging accepted") + } + after, _ := os.ReadFile(path) + if string(before) != string(after) { + t.Fatal("invalid uninstall changed state") + } + }) + } +} + +func TestIdentityMappingInstalledWritersCompareObservation(t *testing.T) { + path, lock, state := mappingStateFixtureV1(t, true) + installation := state.Deployment.Installation + before, _ := os.ReadFile(path) + if _, _, err := lock.SetInstallationStateV1(installation); err == nil { + t.Fatal("set omitted mapping revision") + } + if _, _, err := lock.MarkInstallationReadyV1(installation); err == nil { + t.Fatal("ready omitted mapping revision") + } + stale := *state.IdentityMapping + stale.Revision = "2" + if _, _, err := lock.SetInstallationStateV1(installation, &stale); err == nil { + t.Fatal("set accepted stale mapping") + } + after, _ := os.ReadFile(path) + if string(before) != string(after) { + t.Fatal("rejected installation writer changed state") + } + if _, changed, err := lock.SetInstallationStateV1(installation, state.IdentityMapping); err != nil || changed { + t.Fatalf("preserved installation: changed=%v err=%v", changed, err) + } +} + +func TestIdentityMappingUninstallCrashLeavesCompleteOldOrNewState(t *testing.T) { + for _, afterReplace := range []bool{false, true} { + t.Run(fmt.Sprint(afterReplace), func(t *testing.T) { + path, lock, state := mappingStateFixtureV1(t, true) + replace, sync := replaceAtomicStateFile, syncAtomicStateFileDirectory + t.Cleanup(func() { replaceAtomicStateFile = replace; syncAtomicStateFileDirectory = sync }) + fault := errors.New("injected uninstall publication fault") + if afterReplace { + syncAtomicStateFileDirectory = func(string) error { return fault } + } else { + replaceAtomicStateFile = func(string, string) error { return fault } + } + if _, _, err := lock.ClearInstallationStateV1(state.Deployment.Installation, state.IdentityMapping); !errors.Is(err, fault) { + t.Fatalf("fault not reached: %v", err) + } + stored := readOverlayTestState(t, path) + if stored.IdentityMapping.InstallationID != state.IdentityMapping.InstallationID { + t.Fatal("crash changed stable owner") + } + if afterReplace { + if stored.Deployment != nil || stored.Staging == nil || stored.IdentityMapping.InstallationBinding != nil || stored.IdentityMapping.Revision != "2" { + t.Fatal("incomplete retained staging after replace") + } + } else if !reflect.DeepEqual(stored, state) { + t.Fatal("pre-replace failure changed installed authority") + } + }) + } +} + +func TestIdentityMappingLegacyRecoveryCannotStripRetainedRecord(t *testing.T) { + dir := t.TempDir() + op, err := AcquireOperationLock(t.Context(), dir) + if err != nil { + t.Fatal(err) + } + defer op.Unlock() + content, _ := legacyComponentsStagingStateFixtureV1(t) + var envelope map[string]json.RawMessage + if err := json.Unmarshal(content, &envelope); err != nil { + t.Fatal(err) + } + envelope["identity_mapping"] = json.RawMessage(`null`) + content, err = json.Marshal(envelope) + if err != nil { + t.Fatal(err) + } + path := filepath.Join(dir, ".reploy", stateFilenameV1) + if err := os.WriteFile(path, content, 0o600); err != nil { + t.Fatal(err) + } + _, err = op.PrepareLegacyComponentsStagingRecoveryV1(func(blueprint.Document) (blueprint.Platform, error) { + t.Fatal("legacy platform selection reached") + return blueprint.Platform{}, nil + }, overlayTestPackageValidator, true) + if err == nil || !strings.Contains(err.Error(), "identity") { + t.Fatalf("legacy identity barrier: %v", err) + } + after, _ := os.ReadFile(path) + if string(content) != string(after) { + t.Fatal("legacy recovery erased retained identity") + } +} + +func TestIdentityMappingProtectedCandidateAndWireGeometry(t *testing.T) { + _, _, state := mappingStateFixtureV1(t, false) + m := state.IdentityMapping + values := []IdentityMappingIntervalV1{{ContainerStart: "0", HostStart: "100000", Count: "16"}} + m.UIDAssignments, m.GIDAssignments = values, values + m.PendingOperation = &IdentityMappingOperationV1{OperationID: "operation-1", Kind: "configure", ExpectedRevision: "1", ExpectedGeneration: state.Current, PriorAccepted: state.Current, Candidate: IdentityMappingGeometryV1{Profile: "exact", UIDs: values, GIDs: values}, Phase: "intent", Inhibited: true} + payload, err := EncodeStateV1(state) + if err != nil { + t.Fatal(err) + } + for name, mutate := range map[string]func(*IdentityMappingStateV1){ + "leading zero": func(m *IdentityMappingStateV1) { m.UIDAssignments[0].HostStart = "0100000" }, + "sentinel ID": func(m *IdentityMappingStateV1) { m.OperatorGID = "4294967295" }, + "overflow count": func(m *IdentityMappingStateV1) { m.UIDAssignments[0].Count = "4294967296" }, + "range overflow": func(m *IdentityMappingStateV1) { m.GIDAssignments[0].HostStart = "4294967290" }, + "reassigned candidate": func(m *IdentityMappingStateV1) { m.PendingOperation.Candidate.UIDs[0].HostStart = "200000" }, + "different profile": func(m *IdentityMappingStateV1) { m.PendingOperation.Candidate.Profile = "bounded" }, + "invalid candidate geometry": func(m *IdentityMappingStateV1) { m.PendingOperation.Candidate.GIDs[0].Count = "0" }, + "future expected revision": func(m *IdentityMappingStateV1) { m.PendingOperation.ExpectedRevision = "2" }, + "invalid expected generation": func(m *IdentityMappingStateV1) { m.PendingOperation.ExpectedGeneration = &EnvironmentGenerationState{} }, + "unsafe accepted binding": func(m *IdentityMappingStateV1) { s := "-invalid"; m.Engine = &s }, + "unsafe pending handle": func(m *IdentityMappingStateV1) { m.PendingOperation.OwnedObjects = []string{"-invalid"} }, + "unretained dormant assignment": func(m *IdentityMappingStateV1) { + supervisor, engine := "supervisor-1", "engine-1" + m.Supervisor, m.Engine, m.AdmittedGeneration = &supervisor, &engine, state.Current + m.DormantUIDs = []IdentityMappingIntervalV1{{ContainerStart: "0", HostStart: "100000", Count: "17"}} + }, + "invalid admitted generation": func(m *IdentityMappingStateV1) { m.AdmittedGeneration = &EnvironmentGenerationState{} }, + "invalid dormant geometry": func(m *IdentityMappingStateV1) { + supervisor, engine := "supervisor-1", "engine-1" + m.Supervisor, m.Engine, m.AdmittedGeneration = &supervisor, &engine, state.Current + m.DormantUIDs = []IdentityMappingIntervalV1{{ContainerStart: "0", HostStart: "100000", Count: "0"}} + }, + } { + t.Run(name, func(t *testing.T) { + var next StateV1 + if err := json.Unmarshal(payload, &next); err != nil { + t.Fatal(err) + } + mutate(next.IdentityMapping) + if err := ValidateStateV1(next); err == nil { + t.Fatal("invalid retained authority accepted") + } + }) + } + // Cardinality is not a policy cap; host root remains excluded. + all := IdentityMappingGeometryV1{Profile: "exact", UIDs: []IdentityMappingIntervalV1{{ContainerStart: "0", HostStart: "100000", Count: "4000000000"}}, GIDs: []IdentityMappingIntervalV1{{ContainerStart: "0", HostStart: "100000", Count: "4000000000"}}} + if err := requireCanonicalMappingGeometryV1(all); err != nil { + t.Fatal(err) + } +} + +func TestIdentityMappingCreationAndUnsupportedChangesDoNotPublish(t *testing.T) { + path, lock, state := mappingStateFixtureV1(t, true) + state.IdentityMapping = nil + writeMappingFixtureV1(t, path, state) + input := IdentityMappingStateV1{Schema: IdentityMappingStateSchemaV1, OperatorUID: "1000", OperatorGID: "1000", Profile: "exact"} + if _, err := lock.CommitIdentityMappingV1(state.Current, "", 0, input); err == nil { + t.Fatal("installed opt-in bypassed destination adapter") + } + state.Deployment = nil + state.Staging = &StagingStateV1{Schema: StagingStateSchemaV1} + writeMappingFixtureV1(t, path, state) + for _, field := range []string{"owner", "revision", "pending"} { + next := input + switch field { + case "owner": + next.InstallationID = "foreign" + case "revision": + next.Revision = "1" + case "pending": + next.PendingOperation = &IdentityMappingOperationV1{OperationID: "supplied"} + } + if _, err := lock.CommitIdentityMappingV1(state.Current, "", 0, next); err == nil { + t.Fatalf("first creation accepted %s", field) + } + } + created, err := lock.CommitIdentityMappingV1(state.Current, "", 0, input) + if err != nil { + t.Fatal(err) + } + next := *created.IdentityMapping + next.Revoked = true + if _, err := lock.CommitIdentityMappingV1(state.Current, next.InstallationID, 1, next); err == nil { + t.Fatal("unchecked authority transition accepted") + } + if err := lock.Unlock(); err != nil { + t.Fatal(err) + } + if _, err := lock.CommitIdentityMappingV1(state.Current, next.InstallationID, 1, *created.IdentityMapping); err == nil { + t.Fatal("released lock authorized mapping update") + } +} + +func TestIdentityMappingInstalledPublicationCannotAdoptEitherOwner(t *testing.T) { + path, lock, state := mappingStateFixtureV1(t, false) + dir := filepath.Dir(filepath.Dir(path)) + installation := installationStateV1Fixture(dir) + destination := *state.Current + destination.Reference += "-destination" + before, _ := os.ReadFile(path) + if _, _, err := lock.CommitInstalledStateV1(state.Current, state, destination, installation); err == nil || !strings.Contains(err.Error(), "mapped") { + t.Fatalf("mapped source adoption: %v", err) + } + state.IdentityMapping = nil + if _, _, err := lock.CommitInstalledStateV1(state.Current, state, destination, installation); err == nil || !strings.Contains(err.Error(), "mapped") { + t.Fatalf("mapped destination adoption: %v", err) + } + after, _ := os.ReadFile(path) + if string(before) != string(after) { + t.Fatal("rejected install publication changed authority") + } +} + +func TestIdentityMappingReadyTransitionPreservesObservedAuthority(t *testing.T) { + path, lock, state := mappingStateFixtureV1(t, true) + state.Deployment.Installation.Status = InstallationStatusConfiguring + writeMappingFixtureV1(t, path, state) + ready := state.Deployment.Installation + ready.Status = InstallationStatusReady + next, changed, err := lock.MarkInstallationReadyV1(ready, state.IdentityMapping) + if err != nil || !changed { + t.Fatalf("ready transition changed=%v: %v", changed, err) + } + if !reflect.DeepEqual(next.IdentityMapping, state.IdentityMapping) { + t.Fatal("ready transition changed mapping authority") + } +} + +func TestIdentityMappingDirectTerminalGuardCannotAuthorizeRemoval(t *testing.T) { + path, lock, state := mappingStateFixtureV1(t, false) + before, _ := os.ReadFile(path) + if err := lock.BeginTerminalRemovalV1(state); err == nil || !strings.Contains(err.Error(), "identity") { + t.Fatalf("begin mapped removal: %v", err) + } + if err := lock.ResumeTerminalRemovalV1(state); err == nil || !strings.Contains(err.Error(), "identity") { + t.Fatalf("resume mapped removal: %v", err) + } + after, _ := os.ReadFile(path) + if string(before) != string(after) { + t.Fatal("terminal guard changed retained authority") + } +} diff --git a/internal/deploy/legacy_components_state_recovery.go b/internal/deploy/legacy_components_state_recovery.go index a9c8ed8d..3fb3389e 100644 --- a/internal/deploy/legacy_components_state_recovery.go +++ b/internal/deploy/legacy_components_state_recovery.go @@ -65,6 +65,9 @@ func (lock *OperationLock) PrepareLegacyComponentsStagingRecoveryV1( validatePackage PackageRequestValidator, preserveSelectedPlatform bool, ) (LegacyComponentsStagingRecoveryV1, error) { + if err := lock.RequireNoRetainedIdentityV1(); err != nil { + return LegacyComponentsStagingRecoveryV1{}, err + } if selectPlatform == nil { return LegacyComponentsStagingRecoveryV1{}, fmt.Errorf( "recover legacy staging state requires a platform selector", diff --git a/internal/deploy/state_v1.go b/internal/deploy/state_v1.go index 520ec2d8..f28b9575 100644 --- a/internal/deploy/state_v1.go +++ b/internal/deploy/state_v1.go @@ -27,6 +27,7 @@ type StateV1 struct { Staging *StagingStateV1 `json:"staging"` Deployment *DeploymentStateV1 `json:"deployment"` TerminalRemoval bool `json:"terminal_removal,omitempty"` + IdentityMapping *IdentityMappingStateV1 `json:"identity_mapping,omitempty"` } // StagingStateV1 marks a deployment as staged. Staging-only package overrides @@ -73,7 +74,7 @@ func ValidateStateV1(state StateV1) error { return fmt.Errorf("state deployment: %w", err) } } - return nil + return validateIdentityMappingStateV1(state) } func ValidateStagingStateV1(state StagingStateV1, _ blueprint.Document) error { diff --git a/internal/deploy/state_v1_file.go b/internal/deploy/state_v1_file.go index 77b4fade..69e248e5 100644 --- a/internal/deploy/state_v1_file.go +++ b/internal/deploy/state_v1_file.go @@ -52,6 +52,12 @@ func (lock *OperationLock) CommitStateV1(expected *EnvironmentGenerationState, s if !reflect.DeepEqual(observed, expected) { return fmt.Errorf("state current generation changed before commit") } + if !reflect.DeepEqual(current.IdentityMapping, state.IdentityMapping) { + return fmt.Errorf("identity authority changed or was omitted before state commit") + } + if err := validateMappingOwnerPathV1(path, state); err != nil { + return err + } content, err := EncodeStateV1(state) if err != nil { return err @@ -88,5 +94,8 @@ func readStateV1Path(path string) (StateV1, bool, error) { if err != nil { return StateV1{}, false, err } + if err := validateMappingOwnerPathV1(path, state); err != nil { + return StateV1{}, false, err + } return state, true, nil } diff --git a/internal/deploy/terminal_removal_guard.go b/internal/deploy/terminal_removal_guard.go index 3b28f63d..26e2f5ad 100644 --- a/internal/deploy/terminal_removal_guard.go +++ b/internal/deploy/terminal_removal_guard.go @@ -210,6 +210,9 @@ func (lock *OperationLock) requireTerminalPublicationCompleteLocked() error { } func (lock *OperationLock) requireExactTerminalStateLocked(expected StateV1) error { + if err := requireNoRetainedIdentityPathV1(filepath.Join(filepath.Dir(lock.path), stateFilenameV1)); err != nil { + return err + } if err := lock.requireHeldLocked(); err != nil { return err } diff --git a/internal/dockerdeploy/build_publication.go b/internal/dockerdeploy/build_publication.go index 425bb611..bc68397d 100644 --- a/internal/dockerdeploy/build_publication.go +++ b/internal/dockerdeploy/build_publication.go @@ -264,6 +264,7 @@ func publishBuild( Schema: deploy.StateSchemaV1, Blueprint: blueprintPayload, BlueprintSource: state.BlueprintSource, Platform: input.Lock.Platform, Overlay: input.Lock.Overlay, Current: &candidate, Staging: state.Staging, Deployment: state.Deployment, + IdentityMapping: state.IdentityMapping, } commitState := backend.commitState if commitState == nil { diff --git a/internal/dockerdeploy/desired_state_force_replace.go b/internal/dockerdeploy/desired_state_force_replace.go index 82032121..4e3269e0 100644 --- a/internal/dockerdeploy/desired_state_force_replace.go +++ b/internal/dockerdeploy/desired_state_force_replace.go @@ -173,7 +173,7 @@ func forceReplaceStagedDesiredStateV1( // A prior replacement may have committed before storage cleanup failed. // Its committed state has no current or validated owner. Leave ordinary // owner-bearing staging to stageSame, including deferred reference cleanup. - if state.Current == nil { + if state.Current == nil && state.IdentityMapping == nil { _, validated, err := operation.ReadValidatedBuildV1() if err != nil { return result, err @@ -194,6 +194,9 @@ func forceReplaceStagedDesiredStateV1( operation = nil return backend.stageSame(ctx, desired) } + if err := operation.RequireNoRetainedIdentityV1(); err != nil { + return result, err + } store, err := backend.newStore(dir) if err != nil { return result, err @@ -311,6 +314,9 @@ func forceReplaceStagedDesiredStateV1( operation = admitted.Operation markerID = admitted.Marker.ID controlLease = admitted.Lease + if err := operation.RequireNoRetainedIdentityV1(); err != nil { + return result, err + } state, found, oldEnvironment, err = readForceReplacementStateV1(operation) if err != nil { diff --git a/internal/dockerdeploy/identity_mapping_guard.go b/internal/dockerdeploy/identity_mapping_guard.go index cdde22a0..27828533 100644 --- a/internal/dockerdeploy/identity_mapping_guard.go +++ b/internal/dockerdeploy/identity_mapping_guard.go @@ -14,7 +14,7 @@ func unmappedRuntimeDocumentV1(state deploy.StateV1) (blueprint.Document, error) if err != nil { return blueprint.Document{}, err } - if document.Environment.Runtime.IdentityMapping != nil { + if state.IdentityMapping != nil || document.Environment.Runtime.IdentityMapping != nil { return blueprint.Document{}, fmt.Errorf("environment %q: environment.runtime.identity_mapping requires multi-identity runtime admission, which is not yet available; stage and explicit build remain available", document.Environment.ID) } return document, nil @@ -35,7 +35,7 @@ func requireUnmappedDeploymentV1(operation *deploy.OperationLock) error { // B4 must check retained identity authority before this declaration exception. func requireUnmappedControlDeploymentV1(operation *deploy.OperationLock, kind deploy.ControlOperationV1) error { if kind == deploy.ControlOperationStageV1 { - return nil + return operation.RequireNoRetainedIdentityV1() } return requireUnmappedDeploymentV1(operation) } diff --git a/internal/dockerdeploy/identity_mapping_guard_test.go b/internal/dockerdeploy/identity_mapping_guard_test.go index edf62ed3..6925a44c 100644 --- a/internal/dockerdeploy/identity_mapping_guard_test.go +++ b/internal/dockerdeploy/identity_mapping_guard_test.go @@ -88,6 +88,20 @@ func identityGuardTreeV1(t *testing.T, root string) map[string]string { if info.IsDir() { return nil } + if !info.Mode().IsRegular() { + return fmt.Errorf("snapshot fixture requires a regular file: %s", path) + } + file, err := os.Open(path) + if err != nil { + return err + } + defer file.Close() + // NTFS directory entries can retain stale attributes for a hard link. + // Compare actual handle metadata and bytes, including the file mode. + info, err = file.Stat() + if err != nil { + return err + } relative, err := filepath.Rel(root, path) if err != nil { return err @@ -102,7 +116,7 @@ func identityGuardTreeV1(t *testing.T, root string) map[string]string { result[strings.TrimPrefix(path, root)] = fmt.Sprintf("%o:%d:%d:%d:empty-lock", info.Mode(), uid, gid, info.ModTime().UnixNano()) return nil } - content, err := os.ReadFile(path) + content, err := io.ReadAll(file) if err != nil { return err } diff --git a/internal/dockerdeploy/identity_mapping_retention_test.go b/internal/dockerdeploy/identity_mapping_retention_test.go new file mode 100644 index 00000000..894ab9f9 --- /dev/null +++ b/internal/dockerdeploy/identity_mapping_retention_test.go @@ -0,0 +1,818 @@ +package dockerdeploy + +import ( + "context" + "errors" + "fmt" + "os" + "path/filepath" + "reflect" + "sort" + "strings" + "testing" + "time" + + "github.com/omry/reploy/internal/deploy" + "github.com/omry/reploy/internal/providers" + "github.com/omry/reploy/internal/providerstore" +) + +func retainedIdentityRecordV1(t *testing.T, dir string, state deploy.StateV1, variant string) deploy.StateV1 { + t.Helper() + owner, err := filepath.EvalSymlinks(dir) + if err != nil { + t.Fatal(err) + } + m := &deploy.IdentityMappingStateV1{ + Schema: deploy.IdentityMappingStateSchemaV1, InstallationID: "mapping-retained", OwnerDir: owner, + Revision: "7", OperatorUID: "1000", OperatorGID: "1000", Profile: "exact", + } + if state.Deployment != nil { + m.InstallationBinding = &state.Deployment.Installation.InstanceID + } + values := []deploy.IdentityMappingIntervalV1{{ContainerStart: "0", HostStart: "100000", Count: "16"}} + switch variant { + 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} + case "revoked": + m.Revoked = true + case "dormant": + m.UIDAssignments, m.GIDAssignments = values, values + m.DormantUIDs, m.DormantGIDs = values, values + m.AdmittedGeneration = state.Current + supervisor, engine := "retained-supervisor", "retained-engine" + m.Supervisor, m.Engine = &supervisor, &engine + m.RetainedStorage = []string{"retained-data"} + } + state.IdentityMapping = m + 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) + } + return state +} + +func retainedIdentityStageV1(t *testing.T, variant string, installed bool) (string, deploy.StateV1, providerstore.Store) { + t.Helper() + dir, operation, store, _, state := currentBuildFixture(t, true) + state.Staging = &deploy.StagingStateV1{Schema: deploy.StagingStateSchemaV1} + state.BlueprintSource = "retained source" + state = identityGuardDocumentV1(t, state, false, false) // declaration removed + if installed { + installation := installedBuildPublicationInstallation(dir) + state.Staging = nil + state.Deployment = &deploy.DeploymentStateV1{Schema: deploy.DeploymentStateSchemaV1, Installation: installation} + } + state = retainedIdentityRecordV1(t, dir, state, variant) + if err := operation.Unlock(); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "managed-data"), []byte("keep"), 0o600); err != nil { + t.Fatal(err) + } + return dir, state, store +} + +func requireRetainedIdentityRejectedV1(t *testing.T, dir string, before map[string]string, err error) { + t.Helper() + if err == nil || !strings.Contains(err.Error(), "identity") { + t.Fatalf("identity protection: %v", err) + } + if after := identityGuardTreeV1(t, dir); !reflect.DeepEqual(before, after) { + t.Fatalf("rejected operation changed retained files: %s", retainedIdentityTreeChangesV1(before, after)) + } +} + +func retainedIdentityTreeChangesV1(before, after map[string]string) string { + keys := map[string]bool{} + for key := range before { + keys[key] = true + } + for key := range after { + keys[key] = true + } + var changes []string + for key := range keys { + if before[key] != after[key] { + changes = append(changes, fmt.Sprintf("%q: %q -> %q", key, before[key], after[key])) + } + } + sort.Strings(changes) + if len(changes) > 8 { + changes = append(changes[:8], "additional changes omitted") + } + return strings.Join(changes, "; ") +} + +func TestRetainedIdentityBlocksRemovalBeforeEffectsV1(t *testing.T) { + for _, variant := range []string{"empty", "pending", "revoked", "dormant"} { + for _, mode := range []ControlAdmissionModeV1{ControlAdmissionImmediateV1, ControlAdmissionWaitV1, ControlAdmissionDrainV1, ControlAdmissionForceV1} { + for _, terminal := range []bool{false, true} { + t.Run(fmt.Sprintf("%s/%s/terminal=%t", variant, mode, terminal), func(t *testing.T) { + dir, state, store := retainedIdentityStageV1(t, variant, false) + if terminal { + state.TerminalRemoval = true + retainedIdentityRecordV1(t, dir, state, variant) + } + before := identityGuardTreeV1(t, dir) + backend := testStagedRemovalBackendV1(store) + backend.newStore = func(string) (providerstore.Store, error) { + t.Fatal("store creation before identity barrier") + return store, nil + } + _, err := removeStagedDeploymentV1(t.Context(), StagedDeploymentRemoveInputV1{DeploymentDir: dir, ControlMode: mode}, backend) + requireRetainedIdentityRejectedV1(t, dir, before, err) + }) + } + } + } +} + +func TestRetainedIdentityBlocksInstalledRemovalBeforeRecoveryV1(t *testing.T) { + for _, variant := range []string{"empty", "pending", "revoked", "dormant"} { + for _, mode := range []ControlAdmissionModeV1{ControlAdmissionImmediateV1, ControlAdmissionWaitV1, ControlAdmissionDrainV1, ControlAdmissionForceV1} { + for _, terminal := range []bool{false, true} { + t.Run(fmt.Sprintf("%s/%s/terminal=%t", variant, mode, terminal), func(t *testing.T) { + dir, state, _ := retainedIdentityStageV1(t, variant, true) + if terminal { + state.TerminalRemoval = true + retainedIdentityRecordV1(t, dir, state, variant) + } + before := identityGuardTreeV1(t, dir) + backend := newProviderUninstallRunBackendV1() + backend.recoverPending = func(context.Context, *deploy.OperationLock, string) error { + t.Fatal("publication recovery reached") + return nil + } + backend.plan = func(providerUninstallPlanningInputV1) (providerUninstallPlanV1, error) { + t.Fatal("planning reached") + return providerUninstallPlanV1{}, nil + } + err := runProviderUninstallV1(t.Context(), ProviderUninstallInputV1{DeploymentDir: dir, Service: state.Deployment.Installation.Service, RemoveDir: true, ControlMode: mode}, backend) + requireRetainedIdentityRejectedV1(t, dir, before, err) + }) + } + } + } +} + +func TestRetainedIdentityForceReplacementBeforeEffectsV1(t *testing.T) { + for _, variant := range []string{"empty", "pending", "revoked", "dormant"} { + t.Run(variant, func(t *testing.T) { + dir, state, store := retainedIdentityStageV1(t, variant, false) + before := identityGuardTreeV1(t, dir) + document, _ := testSelectedPlatformDocumentV1(t) + document.Environment.ID = "replacement" + _, err := forceReplaceStagedDesiredStateV1(t.Context(), ForceReplaceStagedDesiredStateInputV1{DesiredState: DesiredStateStageInputV1{DeploymentDir: dir, Document: document, BlueprintSource: "replacement", ExplicitPlatform: state.Platform.Canonical}}, forceReplaceStagedDesiredStateBackendV1{ + acquire: deploy.AcquireExistingOperationLock, + newStore: func(string) (providerstore.Store, error) { t.Fatal("replacement store reached"); 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: RemoveEnvironmentGenerationReference, + commit: func(*deploy.OperationLock, *deploy.EnvironmentGenerationState, deploy.StateV1) error { + t.Fatal("replacement commit reached") + return nil + }, stageSame: StageDesiredStateV1, + }) + requireRetainedIdentityRejectedV1(t, dir, before, err) + }) + } +} + +func TestRetainedIdentityPendingRemovalAndDirectFinalizerV1(t *testing.T) { + for _, location := range []string{"tombstone", "control"} { + for _, variant := range []string{"empty", "pending", "revoked", "dormant"} { + t.Run(location+"/"+variant, func(t *testing.T) { + dir, tombstone, _ := pendingProviderUninstallRemovalFixture(t) + path := filepath.Join(tombstone, StateFileName) + content, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + state, err := deploy.DecodeStateV1(content) + if err != nil { + t.Fatal(err) + } + retainedIdentityRecordV1(t, tombstone, state, variant) + // Preserve the original owner binding even after isolation. + content, err = os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + state, err = deploy.DecodeStateV1(content) + if err != nil { + t.Fatal(err) + } + state.IdentityMapping.OwnerDir = dir + content, err = deploy.EncodeStateV1(state) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, content, 0o600); err != nil { + t.Fatal(err) + } + root := tombstone + if location == "control" { + control, err := providerUninstallControlV1(dir) + if err != nil { + t.Fatal(err) + } + if err := os.Mkdir(control, 0o700); err != nil { + t.Fatal(err) + } + if err := os.Rename(filepath.Join(tombstone, ".reploy"), filepath.Join(control, ".reploy")); err != nil { + t.Fatal(err) + } + root = control + } + before := identityGuardTreeV1(t, root) + _, found, err := retryPendingProviderUninstallRemovalWithV1(t.Context(), dir, "demo", providerUninstallPendingRemovalBackendV1{ + acquire: deploy.AcquireExistingOperationLock, + removeReference: func(context.Context, providers.RealizedImageV1, string, string, string) error { + t.Fatal("pending retirement reached") + return nil + }, + finalize: func(string, string) error { t.Fatal("pending finalization reached"); return nil }, + }) + if !found { + t.Fatal("pending owner was lost") + } + requireRetainedIdentityRejectedV1(t, root, before, err) + err = finalizePendingProviderUninstallRemovalV1(dir, tombstone) + requireRetainedIdentityRejectedV1(t, root, before, err) + }) + } + } +} + +func TestRetainedIdentityPayloadAndControlRemainInhibitedV1(t *testing.T) { + for _, installed := range []bool{false, true} { + for _, variant := range []string{"empty", "pending", "revoked", "dormant"} { + t.Run(fmt.Sprintf("%s/installed=%t", variant, installed), func(t *testing.T) { + dir, state, _ := retainedIdentityStageV1(t, variant, installed) + if _, err := unmappedRuntimeDocumentV1(state); err == nil { + t.Fatal("retained mapping admitted after declaration removal") + } + op, err := deploy.AcquireExistingOperationLock(t.Context(), dir) + if err != nil { + t.Fatal(err) + } + defer op.Unlock() + before := identityGuardTreeV1(t, dir) + for _, kind := range []deploy.ControlOperationV1{deploy.ControlOperationStageV1, deploy.ControlOperationUninstallV1, deploy.ControlOperationUpV1} { + requireRetainedIdentityRejectedV1(t, dir, before, requireUnmappedControlDeploymentV1(op, kind)) + } + }) + } + } +} + +func TestRetainedIdentityRecheckedAfterRemovalWaitV1(t *testing.T) { + for _, installed := range []bool{false, true} { + t.Run(fmt.Sprint(installed), func(t *testing.T) { + dir, op, store, _, state := currentBuildFixture(t, true) + state.Staging = &deploy.StagingStateV1{Schema: deploy.StagingStateSchemaV1} + state.BlueprintSource = "retained source" + if installed { + state.Staging = nil + state.Deployment = &deploy.DeploymentStateV1{Schema: deploy.DeploymentStateSchemaV1, Installation: installedBuildPublicationInstallation(dir)} + } + if err := op.CommitStateV1(state.Current, state); err != nil { + t.Fatal(err) + } + if err := op.Unlock(); err != nil { + t.Fatal(err) + } + recoveries := 0 + admit := func(ctx context.Context, _ string, op *deploy.OperationLock, _ ControlAdmissionInputV1) (AdmittedControlV1, error) { + if recoveries != 1 { + t.Fatalf("pre-wait recoveries=%d", recoveries) + } + if err := op.Unlock(); err != nil { + t.Fatal(err) + } + next, err := deploy.AcquireExistingOperationLock(ctx, dir) + if err != nil { + t.Fatal(err) + } + retainedIdentityRecordV1(t, dir, state, "pending") + return AdmittedControlV1{Operation: next, Marker: deploy.ControlMarkerV1{ID: "test-marker"}}, nil + } + complete := func(op *deploy.OperationLock, _ string, _ *deploy.ControlLeaseV1) error { return op.Unlock() } + var err error + if installed { + backend := newProviderUninstallRunBackendV1() + backend.admit = admit + backend.complete = complete + backend.recoverPending = func(context.Context, *deploy.OperationLock, string) error { + recoveries++ + if recoveries > 1 { + t.Fatal("recovery after retained authority appeared") + } + return nil + } + backend.execute = func(context.Context, *deploy.OperationLock, providerUninstallPlanV1, RunOptions) error { + t.Fatal("host cleanup after wait") + return nil + } + err = runProviderUninstallV1(t.Context(), ProviderUninstallInputV1{DeploymentDir: dir, RemoveDir: true, ControlMode: ControlAdmissionWaitV1, Runtime: blueprintRuntimeFixtureV1()}, backend) + } else { + backend := testStagedRemovalBackendV1(store) + backend.admit = admit + backend.complete = complete + backend.recoverPending = func(context.Context, *deploy.OperationLock, providerstore.Store, *deploy.EnvironmentGenerationState, string, string) (bool, error) { + recoveries++ + if recoveries > 1 { + t.Fatal("recovery after retained authority appeared") + } + return false, nil + } + backend.stopOwned = func(context.Context, *deploy.OperationLock, deploy.StateV1, string, RunOptions) error { + t.Fatal("runtime stop after wait") + return nil + } + _, err = removeStagedDeploymentV1(t.Context(), StagedDeploymentRemoveInputV1{DeploymentDir: dir, ControlMode: ControlAdmissionWaitV1}, backend) + } + if err == nil || !strings.Contains(err.Error(), "identity") { + t.Fatalf("reacquired authority: %v", err) + } + loaded, readErr := os.ReadFile(filepath.Join(dir, StateFileName)) + if readErr != nil { + t.Fatal(readErr) + } + retained, readErr := deploy.DecodeStateV1(loaded) + if readErr != nil || retained.IdentityMapping == nil || retained.IdentityMapping.PendingOperation == nil { + t.Fatalf("authority lost: %v", readErr) + } + }) + } +} + +func TestRetainedIdentityInstallAndUninstallPreflightV1(t *testing.T) { + for _, installed := range []bool{false, true} { + t.Run(fmt.Sprint(installed), func(t *testing.T) { + dir, state, _ := retainedIdentityStageV1(t, "empty", installed) + before := identityGuardTreeV1(t, dir) + if installed { + requireRetainedIdentityRejectedV1(t, dir, before, preflightProviderInstallDestinationV1(dir, "")) + op, err := deploy.AcquireExistingOperationLock(t.Context(), dir) + if err != nil { + t.Fatal(err) + } + defer op.Unlock() + requireRetainedIdentityRejectedV1(t, dir, before, validateProviderInstallDestinationServiceV1(op, "")) + state.BlueprintSource = "" + retainedIdentityRecordV1(t, dir, state, "empty") + before = identityGuardTreeV1(t, dir) + err = executeProviderUninstallWithV1(t.Context(), op, providerUninstallPlanV1{Installation: state.Deployment.Installation}, RunOptions{}, func(context.Context, providerUninstallPlanV1, RunOptions) error { + t.Fatal("cleanup before retained inputs validate") + return nil + }) + if err == nil { + t.Fatal("invalid retained staging accepted") + } + if !reflect.DeepEqual(before, identityGuardTreeV1(t, dir)) { + t.Fatal("failed preflight changed state") + } + } else { + target := filepath.Join(t.TempDir(), "destination") + _, err := RunProviderInstallV1(t.Context(), ProviderInstallInputV1{SourceDeploymentDir: dir, DestinationDeploymentDir: target, Scope: InstallScopeUser, Runtime: blueprintRuntimeFixtureV1()}) + requireRetainedIdentityRejectedV1(t, dir, before, err) + if _, err := os.Stat(target); !os.IsNotExist(err) { + t.Fatal("mapped source prepared install destination", err) + } + } + }) + } +} + +func TestRetainedIdentityBuildPublicationPreservesRecordV1(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") + references := fixedPublicationReferences(t, dir, 0x51) + result, 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 { + return nil + }, + removeReference: func(context.Context, providers.RealizedImageV1, EnvironmentImageReferences, EnvironmentReferenceKind, string, string) error { + return nil + }, + }) + if err != nil { + t.Fatal(err) + } + if result.Current == nil || !reflect.DeepEqual(state.IdentityMapping, result.IdentityMapping) { + t.Fatal("explicit portable publication lost mapping or pending intent") + } +} + +func TestRetainedIdentityRealPayloadEntriesRejectRemovedDeclarationV1(t *testing.T) { + for _, entry := range []string{"shell", "app", "test", "up", "restart", "down"} { + t.Run(entry, func(t *testing.T) { + dir, _, _ := retainedIdentityStageV1(t, "pending", false) + before := identityGuardTreeV1(t, dir) + var err error + switch entry { + case "shell": + err = RunCurrentShellV1(t.Context(), CurrentShellRunInputV1{DeploymentDir: dir, Wait: true}) + case "app": + err = RunCurrentAppCommandV1(t.Context(), CurrentAppCommandRunInputV1{DeploymentDir: dir, Arguments: []string{"config", "init"}}) + case "test": + err = RunCurrentRuntimeTestV1(t.Context(), CurrentRuntimeTestInputV1{DeploymentDir: dir}) + default: + err = RunCurrentWorkloadV1(t.Context(), CurrentWorkloadRunInputV1{DeploymentDir: dir, Action: entry, ControlMode: ControlAdmissionForceV1}) + } + requireRetainedIdentityRejectedV1(t, dir, before, err) + }) + } +} + +func TestRetainedIdentityForceReplacementRechecksReacquiredLockV1(t *testing.T) { + dir, op, store, _, state := currentBuildFixture(t, true) + state.Staging = &deploy.StagingStateV1{Schema: deploy.StagingStateSchemaV1} + state.BlueprintSource = "retained source" + if err := op.CommitStateV1(state.Current, state); err != nil { + t.Fatal(err) + } + if err := op.Unlock(); err != nil { + t.Fatal(err) + } + document, _ := testSelectedPlatformDocumentV1(t) + document.Environment.ID = "replacement" + recoveries := 0 + _, err := forceReplaceStagedDesiredStateV1(t.Context(), ForceReplaceStagedDesiredStateInputV1{DesiredState: DesiredStateStageInputV1{DeploymentDir: dir, Document: document, BlueprintSource: "replacement", ExplicitPlatform: state.Platform.Canonical}}, forceReplaceStagedDesiredStateBackendV1{ + acquire: deploy.AcquireExistingOperationLock, newStore: func(string) (providerstore.Store, error) { return store, nil }, + recoverPending: func(context.Context, *deploy.OperationLock, providerstore.Store, *deploy.EnvironmentGenerationState, string, string) (bool, error) { + recoveries++ + if recoveries > 1 { + t.Fatal("replacement recovery after identity appeared") + } + return false, nil + }, + admit: func(ctx context.Context, _ string, op *deploy.OperationLock, _ ControlAdmissionInputV1) (AdmittedControlV1, error) { + if err := op.Unlock(); err != nil { + t.Fatal(err) + } + next, err := deploy.AcquireExistingOperationLock(ctx, dir) + if err != nil { + t.Fatal(err) + } + retainedIdentityRecordV1(t, dir, state, "pending") + return AdmittedControlV1{Operation: next, Marker: deploy.ControlMarkerV1{ID: "test-marker"}}, nil + }, + complete: func(op *deploy.OperationLock, _ string, _ *deploy.ControlLeaseV1) error { return op.Unlock() }, + stopOwned: func(context.Context, *deploy.OperationLock, deploy.StateV1, string, RunOptions) error { + t.Fatal("replacement stop after identity appeared") + return nil + }, + removeReference: func(context.Context, providers.RealizedImageV1, string, string, string) error { + t.Fatal("replacement retirement after identity appeared") + return nil + }, + commit: func(*deploy.OperationLock, *deploy.EnvironmentGenerationState, deploy.StateV1) error { + t.Fatal("replacement commit after identity appeared") + return nil + }, stageSame: StageDesiredStateV1, + }) + if err == nil || !strings.Contains(err.Error(), "identity") { + t.Fatalf("reacquired replacement authority: %v", err) + } + if recoveries != 1 { + t.Fatalf("recovery count=%d", recoveries) + } +} + +func TestRetainedIdentityFinalizerRejectsMalformedAuthorityBeforeMoveV1(t *testing.T) { + dir, tombstone, _ := pendingProviderUninstallRemovalFixture(t) + path := filepath.Join(tombstone, StateFileName) + if err := os.WriteFile(path, []byte(`{"schema":"unknown"}`), 0o600); err != nil { + t.Fatal(err) + } + before := identityGuardTreeV1(t, tombstone) + if err := finalizePendingProviderUninstallRemovalV1(dir, tombstone); err == nil { + t.Fatal("unknown retained state authorized finalization") + } + if after := identityGuardTreeV1(t, tombstone); !reflect.DeepEqual(before, after) { + t.Fatalf("finalizer moved or deleted unproved state: %s", retainedIdentityTreeChangesV1(before, after)) + } + control, err := providerUninstallControlV1(dir) + if err != nil { + t.Fatal(err) + } + if _, err := os.Lstat(control); !os.IsNotExist(err) { + t.Fatal("finalizer created control directory before authority check", err) + } +} + +func TestRetainedIdentityDirectInstallAdaptersRejectBeforeEffectsV1(t *testing.T) { + for _, mappedSource := range []bool{false, true} { + t.Run(fmt.Sprint(mappedSource), func(t *testing.T) { + dir, _, _ := retainedIdentityStageV1(t, "pending", false) + mapped, err := deploy.AcquireExistingOperationLock(t.Context(), dir) + if err != nil { + t.Fatal(err) + } + defer mapped.Unlock() + ordinary, err := deploy.AcquireOperationLock(t.Context(), t.TempDir()) + if err != nil { + t.Fatal(err) + } + defer ordinary.Unlock() + source, destination := ordinary, mapped + if mappedSource { + source, destination = mapped, ordinary + } + before := identityGuardTreeV1(t, dir) + _, err = publishInstalledBuildV1(t.Context(), source, destination, providerstore.Store{}, providerstore.Store{}, InstalledBuildPublicationInputV1{}, installedBuildPublicationBackend{}) + requireRetainedIdentityRejectedV1(t, dir, before, err) + _, err = prepareProviderInstallDestinationWithV1(t.Context(), lockedProviderInstallV1{SourceOperation: source, DestinationOperation: destination}, "", false, providerInstallPrepareDestinationBackendV1{}) + requireRetainedIdentityRejectedV1(t, dir, before, err) + }) + } +} + +func TestRetainedIdentityDirectUninstallBackendsV1(t *testing.T) { + for _, entry := range []string{"directory removal", "remove-dir execute", "retained staging execute"} { + t.Run(entry, func(t *testing.T) { + dir, state, _ := retainedIdentityStageV1(t, "empty", true) + op, err := deploy.AcquireExistingOperationLock(t.Context(), dir) + if err != nil { + t.Fatal(err) + } + defer op.Unlock() + before := identityGuardTreeV1(t, dir) + plan := providerUninstallPlanV1{State: state, Installation: state.Deployment.Installation, RemoveDir: entry != "retained staging execute"} + if entry == "directory removal" { + err = removeProviderUninstallDeploymentV1(t.Context(), op, "", nil, plan, RunOptions{}) + requireRetainedIdentityRejectedV1(t, dir, before, err) + return + } + cleaned := false + err = executeProviderUninstallWithV1(t.Context(), op, plan, RunOptions{}, func(context.Context, providerUninstallPlanV1, RunOptions) error { cleaned = true; return nil }) + if plan.RemoveDir { + requireRetainedIdentityRejectedV1(t, dir, before, err) + if cleaned { + t.Fatal("mapped remove-dir performed host cleanup") + } + return + } + if err != nil || !cleaned { + t.Fatalf("retained staging transition: cleaned=%v err=%v", cleaned, err) + } + stored, found, err := op.ReadStateV1() + if err != nil || !found { + t.Fatal(err) + } + if stored.Deployment != nil || stored.Staging == nil || stored.IdentityMapping.InstallationBinding != nil || stored.IdentityMapping.InstallationID != state.IdentityMapping.InstallationID || stored.IdentityMapping.Revision != "8" { + t.Fatal("uninstall failed to retain valid staged authority") + } + }) + } +} + +func pendingIdentityRecoveryFixtureV1(t *testing.T, location, variant string) (string, string, deploy.StateV1) { + t.Helper() + dir, tombstone, _ := pendingProviderUninstallRemovalFixture(t) + path := filepath.Join(tombstone, StateFileName) + content, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + state, err := deploy.DecodeStateV1(content) + if err != nil { + t.Fatal(err) + } + if variant != "ordinary" { + state = retainedIdentityRecordV1(t, tombstone, state, variant) + state.IdentityMapping.OwnerDir = dir + content, err = deploy.EncodeStateV1(state) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, content, 0o600); err != nil { + t.Fatal(err) + } + } + root := tombstone + if location == "control" { + control, err := providerUninstallControlV1(dir) + if err != nil { + t.Fatal(err) + } + if err := os.Mkdir(control, 0o700); err != nil { + t.Fatal(err) + } + if err := os.Rename(filepath.Join(tombstone, ".reploy"), filepath.Join(control, ".reploy")); err != nil { + t.Fatal(err) + } + root = control + } + if err := os.WriteFile(filepath.Join(tombstone, "managed-data"), []byte("keep"), 0o600); err != nil { + t.Fatal(err) + } + return dir, root, state +} + +func TestRetainedIdentityServiceAliasAndDirectRecoveryRejectBeforeEffectsV1(t *testing.T) { + for _, location := range []string{"tombstone", "control"} { + for _, variant := range []string{"empty", "pending", "revoked", "dormant"} { + t.Run(location+"/"+variant, func(t *testing.T) { + dir, root, state := pendingIdentityRecoveryFixtureV1(t, location, variant) + unitDir := t.TempDir() + installation := state.Deployment.Installation + unit := fmt.Sprintf("# Managed-By: reploy\n# Reploy-Service: %s\n# Reploy-Target: %s\n# Reploy-Compose-Project: %s\n", installation.Service, dir, installation.ComposeProject) + unitPath := filepath.Join(unitDir, installation.Service+".service") + if err := os.WriteFile(unitPath, []byte(unit), 0o640); err != nil { + t.Fatal(err) + } + before := identityGuardTreeV1(t, filepath.Dir(dir)) + _, err := uninstallProviderV1(UninstallOptions{ServiceName: installation.Service, RemoveDir: true}, providerUninstallPublicBackendV1{ + runtime: func() (StagedProviderBuildRuntimeV1, error) { return blueprintRuntimeFixtureV1(), nil }, + uninstall: func(context.Context, ProviderUninstallInputV1) error { + t.Fatal("service alias used ordinary uninstall") + return nil + }, + readState: readProviderUninstallStateV1, retryPending: retryPendingProviderUninstallRemovalV1, + recover: func(ctx context.Context, input ProviderUninstallRecoveryInputV1) error { + if input.RequestedDir != "" { + t.Fatal("test must resolve target from service unit") + } + return recoverMissingProviderUninstallV1(ctx, input, providerUninstallRecoveryBackendV1{unitDir: unitDir, apply: func(context.Context, providerUninstallRecoveryPlanV1, RunOptions) error { + t.Fatal("service recovery reached host effects") + return nil + }}) + }, + }) + requireRetainedIdentityRejectedV1(t, filepath.Dir(dir), before, err) + plan := providerUninstallRecoveryPlanV1{TargetDir: dir, Service: installation.Service, ComposeProject: installation.ComposeProject, UnitPath: unitPath} + err = applyProviderUninstallRecoveryV1(t.Context(), plan, RunOptions{}, providerUninstallRecoveryApplyBackendV1{}) + requireRetainedIdentityRejectedV1(t, filepath.Dir(dir), before, err) + after, err := os.ReadFile(unitPath) + if err != nil || string(after) != unit { + t.Fatal("rejected recovery changed service unit", err) + } + op, err := deploy.AcquireExistingOperationLock(t.Context(), root) + if err != nil { + t.Fatal("rejected recovery leaked lock", err) + } + if err := op.Unlock(); err != nil { + t.Fatal(err) + } + }) + } + } +} + +func TestRetainedIdentityServiceRecoveryRejectsUnknownOrConflictingStateV1(t *testing.T) { + for _, variant := range []string{"malformed", "foreign target", "foreign service", "foreign project", "conflicting roots"} { + t.Run(variant, func(t *testing.T) { + dir, root, state := pendingIdentityRecoveryFixtureV1(t, "tombstone", "ordinary") + installation := state.Deployment.Installation + switch variant { + case "malformed": + if err := os.WriteFile(filepath.Join(root, StateFileName), []byte(`{"schema":"unknown"}`), 0o600); err != nil { + t.Fatal(err) + } + case "conflicting roots": + control, err := providerUninstallControlV1(dir) + if err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Join(control, ".reploy"), 0o700); err != nil { + t.Fatal(err) + } + default: + switch variant { + case "foreign target": + state.Deployment.Installation.TargetDir += "-foreign" + case "foreign service": + state.Deployment.Installation.Service += "-foreign" + case "foreign project": + state.Deployment.Installation.ComposeProject += "-foreign" + } + content, err := deploy.EncodeStateV1(state) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, StateFileName), content, 0o600); err != nil { + t.Fatal(err) + } + } + before := identityGuardTreeV1(t, filepath.Dir(dir)) + plan := providerUninstallRecoveryPlanV1{TargetDir: dir, Service: installation.Service, ComposeProject: installation.ComposeProject} + err := withUnmappedPendingRemovalV1(t.Context(), plan, func() error { t.Fatal("unproved state reached recovery effects"); return nil }) + if err == nil { + t.Fatal("unknown/foreign/conflicting authority accepted") + } + if after := identityGuardTreeV1(t, filepath.Dir(dir)); !reflect.DeepEqual(before, after) { + t.Fatalf("recovery changed rejected retained state: %s", retainedIdentityTreeChangesV1(before, after)) + } + }) + } +} + +func TestRetainedPendingRemovalCannotEnrollNewMappingOwnerV1(t *testing.T) { + for _, location := range []string{"tombstone", "control"} { + t.Run(location, func(t *testing.T) { + _, root, state := pendingIdentityRecoveryFixtureV1(t, location, "ordinary") + state.Deployment.Installation.Scope = "user" + content, err := deploy.EncodeStateV1(state) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, StateFileName), content, 0o600); err != nil { + t.Fatal(err) + } + op, err := deploy.AcquireExistingOperationLock(t.Context(), root) + if err != nil { + t.Fatal(err) + } + defer op.Unlock() + before := identityGuardTreeV1(t, root) + input := deploy.IdentityMappingStateV1{Schema: deploy.IdentityMappingStateSchemaV1, OperatorUID: "1000", OperatorGID: "1000", Profile: "exact"} + if _, err := op.CommitIdentityMappingV1(state.Current, "", 0, input); err == nil { + t.Fatal("isolated installed state enrolled a mapping owner") + } + if _, err := op.CheckIdentityMappingHostV1(t.Context(), nil, nil); err == nil || !strings.Contains(err.Error(), "selected mapping owner") { + t.Fatalf("isolated target passed mapping host preflight: %v", err) + } + if after := identityGuardTreeV1(t, root); !reflect.DeepEqual(before, after) { + t.Fatalf("rejected isolated mapping changed state: %s", retainedIdentityTreeChangesV1(before, after)) + } + }) + } +} + +func TestRetainedIdentityTreeObservationAndModeControlV1(t *testing.T) { + dir, _, _ := retainedIdentityStageV1(t, "empty", false) + before := identityGuardTreeV1(t, dir) + after := identityGuardTreeV1(t, dir) + if !reflect.DeepEqual(before, after) { + t.Fatalf("observation alone changed retained snapshot: %s", retainedIdentityTreeChangesV1(before, after)) + } + path := filepath.Join(dir, "managed-data") + if err := os.Chmod(path, 0o400); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.Chmod(path, 0o600) }) + after = identityGuardTreeV1(t, dir) + key := strings.TrimPrefix(path, dir) + if before[key] == "" || after[key] == "" || strings.SplitN(before[key], ":", 2)[0] == strings.SplitN(after[key], ":", 2)[0] { + t.Fatal("snapshot did not detect an actual file-mode change") + } +} + +func TestRetainedIdentityServiceRecoveryKeepsOrdinaryStateLockedV1(t *testing.T) { + for _, location := range []string{"tombstone", "control"} { + t.Run(location, func(t *testing.T) { + dir, root, state := pendingIdentityRecoveryFixtureV1(t, location, "ordinary") + installation := state.Deployment.Installation + unitDir := t.TempDir() + unit := fmt.Sprintf("# Managed-By: reploy\n# Reploy-Service: %s\n# Reploy-Target: %s\n# Reploy-Compose-Project: %s\n", installation.Service, dir, installation.ComposeProject) + if err := os.WriteFile(filepath.Join(unitDir, installation.Service+".service"), []byte(unit), 0o640); err != nil { + t.Fatal(err) + } + called := false + err := recoverMissingProviderUninstallV1(t.Context(), ProviderUninstallRecoveryInputV1{Service: installation.Service, Runtime: blueprintRuntimeFixtureV1()}, providerUninstallRecoveryBackendV1{unitDir: unitDir, apply: func(context.Context, providerUninstallRecoveryPlanV1, RunOptions) error { + called = true + ctx, cancel := context.WithTimeout(t.Context(), 50*time.Millisecond) + defer cancel() + other, err := deploy.AcquireExistingOperationLock(ctx, root) + if err == nil { + _ = other.Unlock() + t.Fatal("pending state lock was released before effects") + } + if !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("unexpected competing lock error: %v", err) + } + return nil + }}) + if err != nil || !called { + t.Fatalf("ordinary recovery: called=%v err=%v", called, err) + } + op, err := deploy.AcquireExistingOperationLock(t.Context(), root) + if err != nil { + t.Fatal(err) + } + if err := op.Unlock(); err != nil { + t.Fatal(err) + } + }) + } +} diff --git a/internal/dockerdeploy/installed_build_publication.go b/internal/dockerdeploy/installed_build_publication.go index 00be74ed..1c6323a2 100644 --- a/internal/dockerdeploy/installed_build_publication.go +++ b/internal/dockerdeploy/installed_build_publication.go @@ -83,6 +83,12 @@ func publishInstalledBuildV1( if err := destinationOperation.RequireWritable(); err != nil { return deploy.StateV1{}, err } + if err := sourceOperation.RequireNoRetainedIdentityV1(); err != nil { + return deploy.StateV1{}, err + } + if err := destinationOperation.RequireNoRetainedIdentityV1(); err != nil { + return deploy.StateV1{}, err + } if backend.transferClosure == nil || backend.createReference == nil || backend.removeReference == nil { return deploy.StateV1{}, fmt.Errorf("publish installed build requires a complete backend") } diff --git a/internal/dockerdeploy/provider_install_prepare_destination.go b/internal/dockerdeploy/provider_install_prepare_destination.go index 0454f286..bd8dc309 100644 --- a/internal/dockerdeploy/provider_install_prepare_destination.go +++ b/internal/dockerdeploy/provider_install_prepare_destination.go @@ -52,6 +52,12 @@ func prepareProviderInstallDestinationWithV1( if err := locked.DestinationOperation.RequireWritable(); err != nil { return preparedProviderInstallFilesV1{}, err } + if err := locked.SourceOperation.RequireNoRetainedIdentityV1(); err != nil { + return preparedProviderInstallFilesV1{}, err + } + if err := locked.DestinationOperation.RequireNoRetainedIdentityV1(); err != nil { + return preparedProviderInstallFilesV1{}, err + } if backend.files == nil || backend.diskRequirements == nil || backend.preflight == nil || backend.prepare == nil { return preparedProviderInstallFilesV1{}, fmt.Errorf("prepare provider install destination requires a complete backend") } diff --git a/internal/dockerdeploy/provider_uninstall_backend.go b/internal/dockerdeploy/provider_uninstall_backend.go index 764be207..b5161cf8 100644 --- a/internal/dockerdeploy/provider_uninstall_backend.go +++ b/internal/dockerdeploy/provider_uninstall_backend.go @@ -98,11 +98,22 @@ func executeProviderUninstallWithV1( if _, pending, err := operation.ReadPendingBuild(); err != nil || pending { return fmt.Errorf("uninstall requires completed publication: %v", err) } + state, found, err := operation.ReadStateV1() + if err != nil || !found { + return fmt.Errorf("uninstall state is missing or invalid: %v", err) + } + if plan.RemoveDir { + if err := operation.RequireNoRetainedIdentityV1(); err != nil { + return err + } + } else if _, err := deploy.RetainedStagingAfterUninstallV1(state, plan.Installation); err != nil { + return err + } if err := cleanupHost(ctx, plan, options); err != nil { return err } if !plan.RemoveDir { - state, changed, err := operation.ClearInstallationStateV1(plan.Installation) + state, changed, err := operation.ClearInstallationStateV1(plan.Installation, state.IdentityMapping) if err != nil { return fmt.Errorf("mark provider deployment staged after uninstall: %w", err) } diff --git a/internal/dockerdeploy/provider_uninstall_pending_remove.go b/internal/dockerdeploy/provider_uninstall_pending_remove.go index 5ce1a91a..01f7c4f4 100644 --- a/internal/dockerdeploy/provider_uninstall_pending_remove.go +++ b/internal/dockerdeploy/provider_uninstall_pending_remove.go @@ -86,6 +86,9 @@ func retryPendingProviderUninstallRemovalWithV1( err = errors.Join(err, operation.Unlock()) } }() + if err := operation.RequireNoRetainedIdentityV1(); err != nil { + return ProviderUninstallResultV1{}, true, err + } state, stateFound, err := operation.ReadStateV1() if err != nil { return ProviderUninstallResultV1{}, true, fmt.Errorf("read pending deployment removal state: %w", err) @@ -291,7 +294,7 @@ func locatePendingProviderUninstallRemovalV1( func finalizePendingProviderUninstallRemovalV1( deploymentDir string, tombstone string, -) error { +) (err error) { expectedTombstone, err := providerUninstallTombstoneV1(deploymentDir) if err != nil { return err @@ -316,6 +319,28 @@ func finalizePendingProviderUninstallRemovalV1( if tombstoneStateFound && controlStateFound { return fmt.Errorf("pending deployment removal has state in both %s and %s", tombstone, control) } + stateRoot := control + if tombstoneStateFound { + stateRoot = tombstone + } + operation, err := deploy.AcquireExistingOperationLock(context.Background(), stateRoot) + if err != nil { + return err + } + defer func() { err = errors.Join(err, operation.Unlock()) }() + if err := operation.RequireNoRetainedIdentityV1(); err != nil { + return err + } + state, found, err := operation.ReadStateV1() + if err != nil || !found || state.Deployment == nil || state.Current == nil || state.Deployment.Installation.TargetDir != deploymentDir { + return errors.Join(fmt.Errorf("pending finalizer requires exact retained installed authority"), err) + } + // Windows cannot move or remove .reploy while these handles remain open. + // Isolated installed state cannot enroll a new mapping owner; validate it + // under the lock, then release before ordinary pending-directory cleanup. + if err := operation.Unlock(); err != nil { + return err + } if tombstoneStateFound { controlFound, err := requirePendingRemovalDirectoryV1(control) if err != nil { diff --git a/internal/dockerdeploy/provider_uninstall_recovery.go b/internal/dockerdeploy/provider_uninstall_recovery.go index d53df9bd..9f8ca233 100644 --- a/internal/dockerdeploy/provider_uninstall_recovery.go +++ b/internal/dockerdeploy/provider_uninstall_recovery.go @@ -10,6 +10,7 @@ import ( "time" "github.com/omry/reploy/internal/blueprint" + "github.com/omry/reploy/internal/deploy" ) type ProviderUninstallRecoveryInputV1 struct { @@ -49,7 +50,7 @@ type providerUninstallRecoveryApplyBackendV1 struct { func RecoverMissingProviderUninstallV1(ctx context.Context, input ProviderUninstallRecoveryInputV1) error { return recoverMissingProviderUninstallV1(ctx, input, providerUninstallRecoveryBackendV1{ unitDir: uninstallSystemdUnitDir, - apply: applyProviderUninstallRecovery, + apply: applyProviderUninstallRecoveryHostV1, }) } @@ -131,14 +132,16 @@ func recoverMissingProviderUninstallV1(ctx context.Context, input ProviderUninst if project == "" || dockerNameSlug(project, "") != project { return fmt.Errorf("Reploy service unit %s does not record a valid Compose project", unitSourcePath) } - return backend.apply(ctx, providerUninstallRecoveryPlanV1{ + plan := providerUninstallRecoveryPlanV1{ TargetDir: target, Service: service, UnitPath: unitPath, ComposeProject: project, PendingUnitRemoval: pendingUnitRemoval, - }, input.RunOptions) + } + return withUnmappedPendingRemovalV1(ctx, plan, func() error { return backend.apply(ctx, plan, input.RunOptions) }) } -func applyProviderUninstallRecovery(ctx context.Context, plan providerUninstallRecoveryPlanV1, options RunOptions) error { - return applyProviderUninstallRecoveryV1(ctx, plan, options, providerUninstallRecoveryApplyBackendV1{ +// The recovery entry holds the retained-state guard around this host callback. +func applyProviderUninstallRecoveryHostV1(ctx context.Context, plan providerUninstallRecoveryPlanV1, options RunOptions) error { + return applyProviderUninstallRecoveryEffectsV1(ctx, plan, options, providerUninstallRecoveryApplyBackendV1{ lookPath: uninstallLookPath, runHost: uninstallRunCommand, removeDockerProject: removeDockerComposeProjectByLabelV1, @@ -148,6 +151,41 @@ func applyProviderUninstallRecovery(ctx context.Context, plan providerUninstallR } func applyProviderUninstallRecoveryV1(ctx context.Context, plan providerUninstallRecoveryPlanV1, options RunOptions, backend providerUninstallRecoveryApplyBackendV1) error { + return withUnmappedPendingRemovalV1(ctx, plan, func() error { return applyProviderUninstallRecoveryEffectsV1(ctx, plan, options, backend) }) +} + +func withUnmappedPendingRemovalV1(ctx context.Context, plan providerUninstallRecoveryPlanV1, apply func() error) (err error) { + if ctx == nil { + return fmt.Errorf("pending uninstall recovery requires a context") + } + if err := ctx.Err(); err != nil { + return err + } + pending, found, err := locatePendingProviderUninstallRemovalV1(plan.TargetDir) + if err != nil { + return err + } + if !found { + return apply() + } + operation, err := deploy.AcquireExistingOperationLock(ctx, pending.StateRoot) + if err != nil { + return err + } + defer func() { err = errors.Join(err, operation.Unlock()) }() + if err := operation.RequireNoRetainedIdentityV1(); err != nil { + return err + } + state, found, err := operation.ReadStateV1() + if err != nil || !found || state.Deployment == nil || state.Current == nil || + state.Deployment.Installation.TargetDir != plan.TargetDir || state.Deployment.Installation.Service != plan.Service || state.Deployment.Installation.ComposeProject != plan.ComposeProject { + return errors.Join(fmt.Errorf("service recovery requires exact retained installed authority"), err) + } + return apply() +} + +// Only guarded entry points call the effect body; do not reacquire its lock. +func applyProviderUninstallRecoveryEffectsV1(ctx context.Context, plan providerUninstallRecoveryPlanV1, options RunOptions, backend providerUninstallRecoveryApplyBackendV1) error { if ctx == nil { return fmt.Errorf("apply provider uninstall recovery requires a context") } diff --git a/internal/dockerdeploy/provider_uninstall_remove_dir.go b/internal/dockerdeploy/provider_uninstall_remove_dir.go index 01b39d96..6461ced1 100644 --- a/internal/dockerdeploy/provider_uninstall_remove_dir.go +++ b/internal/dockerdeploy/provider_uninstall_remove_dir.go @@ -105,6 +105,9 @@ func removeProviderUninstallDeploymentWithV1( if err := ctx.Err(); err != nil { return err } + if err := operation.RequireNoRetainedIdentityV1(); err != nil { + return err + } state, found, err := operation.ReadStateV1() if err != nil || !found || state.Deployment == nil || state.Current == nil { diff --git a/internal/dockerdeploy/provider_uninstall_run.go b/internal/dockerdeploy/provider_uninstall_run.go index d9f0851a..c7b28985 100644 --- a/internal/dockerdeploy/provider_uninstall_run.go +++ b/internal/dockerdeploy/provider_uninstall_run.go @@ -80,6 +80,11 @@ func runProviderUninstallV1( err = errors.Join(err, releaseErr) }() + if input.RemoveDir { + if err := operation.RequireNoRetainedIdentityV1(); err != nil { + return err + } + } state, found, err := operation.ReadStateV1() if err != nil || !found || state.Deployment == nil || state.Current == nil || state.Deployment.Installation.TargetDir != deploymentDir { return errors.Join(fmt.Errorf("installed uninstall state is missing or invalid"), err) @@ -145,6 +150,9 @@ func runProviderUninstallV1( } if operation != previousOperation { if input.RemoveDir { + if err := operation.RequireNoRetainedIdentityV1(); err != nil { + return err + } if err := backend.recoverPending(ctx, operation, deploymentDir); err != nil { return fmt.Errorf("recover publication after waiting for uninstall: %w", err) } diff --git a/internal/dockerdeploy/staged_deployment_remove.go b/internal/dockerdeploy/staged_deployment_remove.go index d3e9dde2..bf83b27f 100644 --- a/internal/dockerdeploy/staged_deployment_remove.go +++ b/internal/dockerdeploy/staged_deployment_remove.go @@ -321,6 +321,9 @@ func removeStagedDeploymentV1( func readStagedDeploymentForRemovalV1( operation *deploy.OperationLock, ) (deploy.StateV1, string, error) { + if err := operation.RequireNoRetainedIdentityV1(); err != nil { + return deploy.StateV1{}, "", err + } state, found, err := operation.ReadStateV1() if err != nil { return deploy.StateV1{}, "", fmt.Errorf("read staged deployment for removal: %w", err)