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 d5758eed..c01cf141 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)