package hostops import ( "context" "encoding/json" "reflect" "time" "github.com/carlosframework/platform/internal/hostclaim" "github.com/carlosframework/platform/internal/park" "github.com/carlosframework/platform/internal/provision" "github.com/carlosframework/platform/internal/release" ) type lifecycleRecordKey struct{} type lifecycleRecordPermit struct{ Observation hostclaim.Observed } // setLifecycleRecordMove carries the exact claim observation into the record CAS. // A caller retries from its operation guard after any changed observation. func setLifecycleRecordMove(ctx context.Context, s release.Store, o hostclaim.Observed, mut func(*provision.Record) error) (*provision.Record, error) { if o.Absent || o.Claim == nil || o.Claim.Holder == nil || o.ETag == "" || mut == nil { return nil, ErrIdentity } h := o.Claim.Holder return provision.SetRecordMove(context.WithValue(provision.WithRecordSequence(ctx), lifecycleRecordKey{}, lifecycleRecordPermit{o}), s, h.Account, h.App, o.Host, mut) } func (s conditionalRecordStore) putLifecycle(ctx context.Context, key string, body []byte, etag string, p lifecycleRecordPermit) (string, error) { o := p.Observation if o.Absent || o.Claim == nil || o.Claim.Holder == nil || o.ETag == "" || etag == "" || key != o.Claim.Holder.Key { return "", ErrIdentity } if !operationHomeMatches(s.homeRegion, o.Claim) { return "", ErrIdentity } switch o.Claim.Op.Kind { case hostclaim.KindMoving, hostclaim.KindLanding, hostclaim.KindRestoring, hostclaim.KindReverting, hostclaim.KindRelocating: default: return "", ErrPending } old, err := ReadRecord(ctx, s.raw, key) if err != nil { return "", err } if old.ETag != etag { return "", release.ErrCASConflict } if !matches(old, o.Claim.Holder) { return "", ErrIdentity } var before, next map[string]json.RawMessage if json.Unmarshal(old.Body, &before) != nil || json.Unmarshal(body, &next) != nil { return "", ErrIdentity } type rollback struct { ID string `json:"id"` RequestedAt *time.Time `json:"requested_at"` TargetPhase string `json:"target_phase"` SourcePhase string `json:"source_phase"` ReleaseDeadline *time.Time `json:"release_deadline"` } type move struct { CutPhase string `json:"cut_phase"` FlippedAt *time.Time `json:"flipped_at"` CommittedAt *time.Time `json:"committed_at"` LandingDeadline *time.Time `json:"landing_deadline"` Rollback *rollback `json:"rollback"` Relocation json.RawMessage `json:"relocation"` ID string `json:"id"` Incarnation string `json:"incarnation"` From string `json:"from"` To string `json:"to"` State string `json:"state"` } var a, b move if json.Unmarshal(next["move"], &b) != nil || b.ID != o.Claim.Op.ID || b.Incarnation != o.Claim.Holder.Incarnation { return "", ErrIdentity } if raw := before["move"]; len(raw) > 0 && string(raw) != "null" { if json.Unmarshal(raw, &a) != nil || a.ID != b.ID || a.Incarnation != b.Incarnation || a.From != b.From || a.To != b.To { return "", ErrIdentity } } if a.Rollback != nil && (b.Rollback == nil || a.Rollback.ID != b.Rollback.ID) { return "", ErrIdentity } if len(a.Relocation) > 0 && string(a.Relocation) != "null" && (!reflect.DeepEqual(a.Relocation, b.Relocation) || b.State != "relocating") { return "", ErrIdentity } var from, to string if (len(before["region"]) != 0 && json.Unmarshal(before["region"], &from) != nil) || (len(next["region"]) != 0 && json.Unmarshal(next["region"], &to) != nil) || !provision.ValidRegion(from) || !provision.ValidRegion(to) { return "", ErrIdentity } if from != to { flip := o.Claim.Op.Kind == hostclaim.KindMoving && o.Claim.Op.Phase == "sealed" && a.State == "cutting" && a.CutPhase == "archived" && a.FlippedAt == nil && a.CommittedAt == nil && a.Rollback == nil && b.State == "landing" && b.CutPhase == "flipped" && b.FlippedAt != nil && b.LandingDeadline != nil && b.LandingDeadline.After(*b.FlippedAt) && b.Rollback == nil && from == b.From && to == b.To && park.PlacementMatches(o.Claim.Op.Intent["home_region"], to, o.Claim.Op.Intent["to"]) restore := o.Claim.Op.Kind == hostclaim.KindLanding && o.Claim.Op.Phase != "committed" && a.State == "landing" && a.CommittedAt == nil && a.Rollback == nil && b.State == "rolling_back" && b.CommittedAt == nil && b.Rollback != nil && b.Rollback.ID != "" && b.Rollback.RequestedAt != nil && b.Rollback.TargetPhase == "releasing" && b.Rollback.SourcePhase == "waiting" && b.Rollback.ReleaseDeadline != nil && b.Rollback.ReleaseDeadline.Equal(b.Rollback.RequestedAt.Add(15*time.Minute)) && from == b.To && to == b.From // The rollback region is the original record's Move.From (a.From == // b.From above), never claim input: the claim carries no source region. if !flip && !restore { return "", ErrIdentity } } // The provision writer owns this field; WriteRecord validates its exact // base and prepared counter at the final conditional Put. delete(before, "record_seq") delete(next, "record_seq") delete(before, "move") delete(next, "move") delete(before, "region") delete(next, "region") // Compare decoded values, so indentation is immaterial but every other field is held. var av, bv any ar, _ := json.Marshal(before) br, _ := json.Marshal(next) if json.Unmarshal(ar, &av) != nil || json.Unmarshal(br, &bv) != nil || !reflect.DeepEqual(av, bv) { return "", ErrIdentity } fresh, err := hostclaim.Read(ctx, s.raw, o.Host) if err != nil { return "", err } if fresh.Absent || fresh.ETag != o.ETag { return "", hostclaim.ErrConflict } h := o.Claim.Holder appID, id := o.Claim.Op.Intent["app_id"], o.Claim.Op.Intent["admission_id"] // The move registration is named by the move's own op id (RegisterMove); // any other id is drainable while the move still holds the claim. if id == "" || id != o.Claim.Op.ID { return "", ErrIdentity } admission, err := ReadAdmission(ctx, s.raw, h.Account, h.App, appID) if err != nil { return "", err } reg, ok := admission.Value.Registrations[id] if !ok || reg.Kind != "move" || reg.AppID != appID || reg.Epoch != admission.Value.Epoch || reg.Host != o.Host || reg.Incarnation != h.Incarnation { return "", ErrPending } // Accepted work drains under closed admission; expiry does not revoke it. MarkAppMutationIssued(ctx) return putSequencedRecord(ctx, s.raw, key, body, etag) } func sameMoveJournal(a, b []byte) bool { var before, after map[string]any if json.Unmarshal(a, &before) != nil || json.Unmarshal(b, &after) != nil { return false } return reflect.DeepEqual(before["move"], after["move"]) }