| 1 | package hostops |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "encoding/json" |
| 6 | "reflect" |
| 7 | "time" |
| 8 | |
| 9 | "github.com/carlosframework/platform/internal/hostclaim" |
| 10 | "github.com/carlosframework/platform/internal/park" |
| 11 | "github.com/carlosframework/platform/internal/provision" |
| 12 | "github.com/carlosframework/platform/internal/release" |
| 13 | ) |
| 14 | |
| 15 | type lifecycleRecordKey struct{} |
| 16 | type lifecycleRecordPermit struct{ Observation hostclaim.Observed } |
| 17 | |
| 18 | // setLifecycleRecordMove carries the exact claim observation into the record CAS. |
| 19 | // A caller retries from its operation guard after any changed observation. |
| 20 | func setLifecycleRecordMove(ctx context.Context, s release.Store, o hostclaim.Observed, mut func(*provision.Record) error) (*provision.Record, error) { |
| 21 | if o.Absent || o.Claim == nil || o.Claim.Holder == nil || o.ETag == "" || mut == nil { |
| 22 | return nil, ErrIdentity |
| 23 | } |
| 24 | h := o.Claim.Holder |
| 25 | return provision.SetRecordMove(context.WithValue(provision.WithRecordSequence(ctx), lifecycleRecordKey{}, lifecycleRecordPermit{o}), s, h.Account, h.App, o.Host, mut) |
| 26 | } |
| 27 | |
| 28 | func (s conditionalRecordStore) putLifecycle(ctx context.Context, key string, body []byte, etag string, p lifecycleRecordPermit) (string, error) { |
| 29 | o := p.Observation |
| 30 | if o.Absent || o.Claim == nil || o.Claim.Holder == nil || o.ETag == "" || etag == "" || key != o.Claim.Holder.Key { |
| 31 | return "", ErrIdentity |
| 32 | } |
| 33 | if !operationHomeMatches(s.homeRegion, o.Claim) { |
| 34 | return "", ErrIdentity |
| 35 | } |
| 36 | switch o.Claim.Op.Kind { |
| 37 | case hostclaim.KindMoving, hostclaim.KindLanding, hostclaim.KindRestoring, hostclaim.KindReverting, hostclaim.KindRelocating: |
| 38 | default: |
| 39 | return "", ErrPending |
| 40 | } |
| 41 | old, err := ReadRecord(ctx, s.raw, key) |
| 42 | if err != nil { |
| 43 | return "", err |
| 44 | } |
| 45 | if old.ETag != etag { |
| 46 | return "", release.ErrCASConflict |
| 47 | } |
| 48 | if !matches(old, o.Claim.Holder) { |
| 49 | return "", ErrIdentity |
| 50 | } |
| 51 | var before, next map[string]json.RawMessage |
| 52 | if json.Unmarshal(old.Body, &before) != nil || json.Unmarshal(body, &next) != nil { |
| 53 | return "", ErrIdentity |
| 54 | } |
| 55 | type rollback struct { |
| 56 | ID string `json:"id"` |
| 57 | RequestedAt *time.Time `json:"requested_at"` |
| 58 | TargetPhase string `json:"target_phase"` |
| 59 | SourcePhase string `json:"source_phase"` |
| 60 | ReleaseDeadline *time.Time `json:"release_deadline"` |
| 61 | } |
| 62 | type move struct { |
| 63 | CutPhase string `json:"cut_phase"` |
| 64 | FlippedAt *time.Time `json:"flipped_at"` |
| 65 | CommittedAt *time.Time `json:"committed_at"` |
| 66 | LandingDeadline *time.Time `json:"landing_deadline"` |
| 67 | Rollback *rollback `json:"rollback"` |
| 68 | Relocation json.RawMessage `json:"relocation"` |
| 69 | ID string `json:"id"` |
| 70 | Incarnation string `json:"incarnation"` |
| 71 | From string `json:"from"` |
| 72 | To string `json:"to"` |
| 73 | State string `json:"state"` |
| 74 | } |
| 75 | var a, b move |
| 76 | if json.Unmarshal(next["move"], &b) != nil || b.ID != o.Claim.Op.ID || b.Incarnation != o.Claim.Holder.Incarnation { |
| 77 | return "", ErrIdentity |
| 78 | } |
| 79 | if raw := before["move"]; len(raw) > 0 && string(raw) != "null" { |
| 80 | if json.Unmarshal(raw, &a) != nil || a.ID != b.ID || a.Incarnation != b.Incarnation || a.From != b.From || a.To != b.To { |
| 81 | return "", ErrIdentity |
| 82 | } |
| 83 | } |
| 84 | if a.Rollback != nil && (b.Rollback == nil || a.Rollback.ID != b.Rollback.ID) { |
| 85 | return "", ErrIdentity |
| 86 | } |
| 87 | if len(a.Relocation) > 0 && string(a.Relocation) != "null" && (!reflect.DeepEqual(a.Relocation, b.Relocation) || b.State != "relocating") { |
| 88 | return "", ErrIdentity |
| 89 | } |
| 90 | var from, to string |
| 91 | 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) { |
| 92 | return "", ErrIdentity |
| 93 | } |
| 94 | if from != to { |
| 95 | 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"]) |
| 96 | 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 |
| 97 | // The rollback region is the original record's Move.From (a.From == |
| 98 | // b.From above), never claim input: the claim carries no source region. |
| 99 | if !flip && !restore { |
| 100 | return "", ErrIdentity |
| 101 | } |
| 102 | } |
| 103 | // The provision writer owns this field; WriteRecord validates its exact |
| 104 | // base and prepared counter at the final conditional Put. |
| 105 | delete(before, "record_seq") |
| 106 | delete(next, "record_seq") |
| 107 | delete(before, "move") |
| 108 | delete(next, "move") |
| 109 | delete(before, "region") |
| 110 | delete(next, "region") |
| 111 | // Compare decoded values, so indentation is immaterial but every other field is held. |
| 112 | var av, bv any |
| 113 | ar, _ := json.Marshal(before) |
| 114 | br, _ := json.Marshal(next) |
| 115 | if json.Unmarshal(ar, &av) != nil || json.Unmarshal(br, &bv) != nil || !reflect.DeepEqual(av, bv) { |
| 116 | return "", ErrIdentity |
| 117 | } |
| 118 | fresh, err := hostclaim.Read(ctx, s.raw, o.Host) |
| 119 | if err != nil { |
| 120 | return "", err |
| 121 | } |
| 122 | if fresh.Absent || fresh.ETag != o.ETag { |
| 123 | return "", hostclaim.ErrConflict |
| 124 | } |
| 125 | h := o.Claim.Holder |
| 126 | appID, id := o.Claim.Op.Intent["app_id"], o.Claim.Op.Intent["admission_id"] |
| 127 | // The move registration is named by the move's own op id (RegisterMove); |
| 128 | // any other id is drainable while the move still holds the claim. |
| 129 | if id == "" || id != o.Claim.Op.ID { |
| 130 | return "", ErrIdentity |
| 131 | } |
| 132 | admission, err := ReadAdmission(ctx, s.raw, h.Account, h.App, appID) |
| 133 | if err != nil { |
| 134 | return "", err |
| 135 | } |
| 136 | reg, ok := admission.Value.Registrations[id] |
| 137 | if !ok || reg.Kind != "move" || reg.AppID != appID || reg.Epoch != admission.Value.Epoch || reg.Host != o.Host || reg.Incarnation != h.Incarnation { |
| 138 | return "", ErrPending |
| 139 | } |
| 140 | // Accepted work drains under closed admission; expiry does not revoke it. |
| 141 | MarkAppMutationIssued(ctx) |
| 142 | return putSequencedRecord(ctx, s.raw, key, body, etag) |
| 143 | } |
| 144 | |
| 145 | func sameMoveJournal(a, b []byte) bool { |
| 146 | var before, after map[string]any |
| 147 | if json.Unmarshal(a, &before) != nil || json.Unmarshal(b, &after) != nil { |
| 148 | return false |
| 149 | } |
| 150 | return reflect.DeepEqual(before["move"], after["move"]) |
| 151 | } |
| 152 | |