| 1 | package maintenanceguard |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "errors" |
| 6 | "math" |
| 7 | "os" |
| 8 | "slices" |
| 9 | ) |
| 10 | |
| 11 | type GuardSnapshot struct { |
| 12 | repository *Repository |
| 13 | value Guard |
| 14 | etag string |
| 15 | exists bool |
| 16 | } |
| 17 | |
| 18 | func (s GuardSnapshot) Exists() bool { return s.exists } |
| 19 | func (s GuardSnapshot) Value() Guard { return cloneGuard(s.value) } |
| 20 | func (s GuardSnapshot) Projection() (Projection, error) { |
| 21 | if !s.exists { |
| 22 | return Projection{}, ErrInvalid |
| 23 | } |
| 24 | raw, e := guardBytes(s.value) |
| 25 | if e != nil { |
| 26 | return Projection{}, e |
| 27 | } |
| 28 | return Projection{Version, s.value.Generation, digest(raw)}, nil |
| 29 | } |
| 30 | func (r *Repository) ReadGuard(ctx context.Context, host string) (GuardSnapshot, error) { |
| 31 | key, e := GuardKey(host) |
| 32 | if e != nil { |
| 33 | return GuardSnapshot{}, e |
| 34 | } |
| 35 | raw, etag, e := r.store.Get(ctx, key) |
| 36 | if errors.Is(e, os.ErrNotExist) { |
| 37 | return GuardSnapshot{repository: r}, nil |
| 38 | } |
| 39 | if e != nil { |
| 40 | return GuardSnapshot{}, e |
| 41 | } |
| 42 | var g Guard |
| 43 | if decode(raw, &g) != nil || !g.valid() || g.Identity.Host != host || !cleanETag(etag) { |
| 44 | return GuardSnapshot{}, ErrInvalid |
| 45 | } |
| 46 | if e = r.checkGuardReferences(ctx, g); e != nil { |
| 47 | return GuardSnapshot{}, e |
| 48 | } |
| 49 | return GuardSnapshot{r, cloneGuard(g), etag, true}, nil |
| 50 | } |
| 51 | func (r *Repository) guardClaim(ctx context.Context, c ClaimSnapshot, g Guard) error { |
| 52 | if c.value.Kind != MaintenanceOperation || c.value.Account != g.Identity.Account || c.value.App != g.Identity.App || c.value.OperationID != g.OperationID { |
| 53 | return ErrConflict |
| 54 | } |
| 55 | return r.currentClaim(ctx, c) |
| 56 | } |
| 57 | func (r *Repository) writeGuard(ctx context.Context, c ClaimSnapshot, g Guard, etag string) (GuardSnapshot, error) { |
| 58 | if e := r.guardClaim(ctx, c, g); e != nil { |
| 59 | return GuardSnapshot{}, e |
| 60 | } |
| 61 | raw, e := guardBytes(g) |
| 62 | if e != nil { |
| 63 | return GuardSnapshot{}, e |
| 64 | } |
| 65 | if e = r.checkGuardReferences(ctx, g); e != nil { |
| 66 | return GuardSnapshot{}, e |
| 67 | } |
| 68 | key, _ := GuardKey(g.Identity.Host) |
| 69 | next, e := r.store.Put(ctx, key, raw, etag) |
| 70 | if e != nil { |
| 71 | return GuardSnapshot{}, e |
| 72 | } |
| 73 | if !cleanETag(next) { |
| 74 | return GuardSnapshot{}, ErrUnavailable |
| 75 | } |
| 76 | // There is no cross-object atomicity. A lost claim leaves the durable guard |
| 77 | // in place, but the caller receives no successful execution/resume handoff. |
| 78 | if e = r.currentClaim(ctx, c); e != nil { |
| 79 | return GuardSnapshot{}, e |
| 80 | } |
| 81 | return GuardSnapshot{r, cloneGuard(g), next, true}, nil |
| 82 | } |
| 83 | func (r *Repository) CreateGuard(ctx context.Context, c ClaimSnapshot, g Guard) (GuardSnapshot, error) { |
| 84 | if !g.valid() || g.Generation != 1 || g.Phase != Requested || g.Evidence != nil { |
| 85 | return GuardSnapshot{}, ErrInvalid |
| 86 | } |
| 87 | return r.writeGuard(ctx, c, g, "") |
| 88 | } |
| 89 | func newAttempt(old, next Guard) bool { |
| 90 | return old.Request.ID != next.Request.ID && old.Attempt.ID != next.Attempt.ID && next.Evidence == nil |
| 91 | } |
| 92 | func samePins(old, next Guard) bool { |
| 93 | return old.RecordRevision == next.RecordRevision && old.ArtifactVersion == next.ArtifactVersion && old.ArtifactDigest == next.ArtifactDigest && old.ConfigDigest == next.ConfigDigest && slices.Equal(old.RequiredContracts, next.RequiredContracts) |
| 94 | } |
| 95 | func validAdvance(old, next Guard) bool { |
| 96 | if !next.valid() || old.Generation == math.MaxInt64 || next.Generation != old.Generation+1 || next.Identity != old.Identity || next.OwnerBox != old.OwnerBox || !containsAll(next.RequiredContracts, old.RequiredContracts) { |
| 97 | return false |
| 98 | } |
| 99 | if old.Phase == Resumed { |
| 100 | return next.Phase == Requested && next.OperationID != old.OperationID && newAttempt(old, next) |
| 101 | } |
| 102 | if next.OperationID != old.OperationID { |
| 103 | return false |
| 104 | } |
| 105 | if next.Phase == Requested { |
| 106 | return (old.Phase == Succeeded || old.Phase == Failed || old.Phase == Ambiguous) && newAttempt(old, next) |
| 107 | } |
| 108 | if next.Phase == ResumeRequested { |
| 109 | return (old.Phase == Fenced || old.Phase == Succeeded || old.Phase == Failed || old.Phase == Ambiguous) && newAttempt(old, next) |
| 110 | } |
| 111 | if old.Request != next.Request || old.Attempt != next.Attempt || !samePins(old, next) { |
| 112 | return false |
| 113 | } |
| 114 | switch old.Phase { |
| 115 | case Requested: |
| 116 | return next.Phase == Quiescing || next.Phase == Failed || next.Phase == Ambiguous |
| 117 | case Quiescing: |
| 118 | return next.Phase == Fenced || next.Phase == Failed || next.Phase == Ambiguous |
| 119 | case Fenced: |
| 120 | return next.Phase == Executing || next.Phase == Failed || next.Phase == Ambiguous |
| 121 | case Executing: |
| 122 | return next.Phase == Succeeded || next.Phase == Failed || next.Phase == Ambiguous |
| 123 | case ResumeRequested: |
| 124 | return next.Phase == Resumed || next.Phase == Failed || next.Phase == Ambiguous |
| 125 | } |
| 126 | return false |
| 127 | } |
| 128 | |
| 129 | // AdvanceGuard validates continuity and state progression, then performs one |
| 130 | // CAS. Fresh owner approval, stopped-process/lease proofs and checker semantics |
| 131 | // remain caller preconditions; valid references alone do not prove them. |
| 132 | func (r *Repository) AdvanceGuard(ctx context.Context, c ClaimSnapshot, old GuardSnapshot, next Guard) (GuardSnapshot, error) { |
| 133 | if old.repository != r || !old.exists || !cleanETag(old.etag) || !validAdvance(old.value, next) { |
| 134 | return GuardSnapshot{}, ErrConflict |
| 135 | } |
| 136 | return r.writeGuard(ctx, c, next, old.etag) |
| 137 | } |
| 138 | |
| 139 | type ServingDecision string |
| 140 | |
| 141 | const ( |
| 142 | Unmanaged ServingDecision = "unmanaged" |
| 143 | Managed ServingDecision = "managed" |
| 144 | Denied ServingDecision = "denied" |
| 145 | ) |
| 146 | |
| 147 | // CheckServing is a denial check, not a lease or process-health permit. The |
| 148 | // caller supplies contracts from an already verified signed release manifest, |
| 149 | // verifies the current record identity, and repeats this check after lease |
| 150 | // acquisition before starting or seeding anything. Legacy unmanaged records may |
| 151 | // omit Record: both barriers must be absent before that omission is accepted. |
| 152 | func (r *Repository) CheckServing(ctx context.Context, identity Identity, projection *Projection, verifiedContracts []string) (ServingDecision, error) { |
| 153 | if !identity.locationValid() || !ValidContracts(verifiedContracts) { |
| 154 | return Denied, ErrDenied |
| 155 | } |
| 156 | s, e := r.ReadGuard(ctx, identity.Host) |
| 157 | if e != nil { |
| 158 | return Denied, ErrDenied |
| 159 | } |
| 160 | if !s.exists { |
| 161 | if projection == nil { |
| 162 | return Unmanaged, nil |
| 163 | } |
| 164 | return Denied, ErrDenied |
| 165 | } |
| 166 | if !identity.valid() || projection == nil || !projection.valid() || s.value.Identity != identity || s.value.Phase != Resumed || !containsAll(verifiedContracts, s.value.RequiredContracts) { |
| 167 | return Denied, ErrDenied |
| 168 | } |
| 169 | expected, e := s.Projection() |
| 170 | if e != nil || *projection != expected { |
| 171 | return Denied, ErrDenied |
| 172 | } |
| 173 | return Managed, nil |
| 174 | } |
| 175 | |