carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1package maintenanceguard
2
3import (
4 "context"
5 "encoding/json"
6 "errors"
7 "math"
8 "os"
9)
10
11type OperationKind string
12
13const (
14 MaintenanceOperation OperationKind = "maintenance"
15 TrashOperation OperationKind = "trash"
16 RestoreOperation OperationKind = "restore"
17 PurgeOperation OperationKind = "purge"
18)
19
20func (k OperationKind) valid() bool {
21 return k == MaintenanceOperation || k == TrashOperation || k == RestoreOperation || k == PurgeOperation
22}
23
24type Lifecycle struct {
25 Version int `json:"version"`
26 Account string `json:"account"`
27 App string `json:"app"`
28 Generation int64 `json:"generation"`
29 State string `json:"state"`
30 OperationID string `json:"operation_id"`
31 Kind OperationKind `json:"kind"`
32 ClaimGeneration int64 `json:"claim_generation"`
33 CompletionDigest string `json:"completion_digest,omitempty"`
34}
35
36func (l Lifecycle) valid() bool {
37 if _, e := appBase(l.Account, l.App); e != nil {
38 return false
39 }
40 if l.Version != Version || l.Generation <= 0 || !ValidID(l.OperationID) || !l.Kind.valid() || l.ClaimGeneration <= 0 {
41 return false
42 }
43 switch l.State {
44 case "claimed":
45 return l.Generation == l.ClaimGeneration && l.CompletionDigest == ""
46 case "idle":
47 return l.ClaimGeneration < math.MaxInt64 && l.Generation == l.ClaimGeneration+1 && ValidDigest(l.CompletionDigest)
48 }
49 return false
50}
51
52type claimIntent struct {
53 Version int `json:"version"`
54 Account string `json:"account"`
55 App string `json:"app"`
56 OperationID string `json:"operation_id"`
57 Kind OperationKind `json:"kind"`
58 Generation int64 `json:"generation"`
59}
60
61func intent(l Lifecycle) claimIntent {
62 return claimIntent{Version, l.Account, l.App, l.OperationID, l.Kind, l.ClaimGeneration}
63}
64func claimKey(l Lifecycle, completed bool) string {
65 p, _ := appBase(l.Account, l.App)
66 suffix := "claim"
67 if completed {
68 suffix = "completion"
69 }
70 return p + "operations/" + l.OperationID + "/" + suffix + ".json"
71}
72
73type claimCompletion struct {
74 Claim claimIntent `json:"claim"`
75 Generation int64 `json:"generation"`
76}
77
78type ClaimSnapshot struct {
79 repository *Repository
80 value Lifecycle
81 etag string
82 exists bool
83}
84
85func (s ClaimSnapshot) Exists() bool { return s.exists }
86func (s ClaimSnapshot) Value() Lifecycle { return s.value }
87func (r *Repository) ReadLifecycle(ctx context.Context, account, app string) (ClaimSnapshot, error) {
88 key, e := LifecycleKey(account, app)
89 if e != nil {
90 return ClaimSnapshot{}, e
91 }
92 raw, etag, e := r.store.Get(ctx, key)
93 if errors.Is(e, os.ErrNotExist) {
94 return ClaimSnapshot{repository: r, value: Lifecycle{Account: account, App: app}}, nil
95 }
96 if e != nil {
97 return ClaimSnapshot{}, e
98 }
99 var l Lifecycle
100 if decode(raw, &l) != nil || !l.valid() || l.Account != account || l.App != app || !cleanETag(etag) {
101 return ClaimSnapshot{}, ErrInvalid
102 }
103 b, _, e := r.store.Get(ctx, claimKey(l, false))
104 var i claimIntent
105 if e != nil || decode(b, &i) != nil || i != intent(l) {
106 return ClaimSnapshot{}, ErrUnavailable
107 }
108 if l.State == "idle" {
109 b, _, e = r.store.Get(ctx, claimKey(l, true))
110 var c claimCompletion
111 if e != nil || decode(b, &c) != nil || c.Claim != i || c.Generation != l.Generation || digest(b) != l.CompletionDigest {
112 return ClaimSnapshot{}, ErrUnavailable
113 }
114 }
115 return ClaimSnapshot{r, l, etag, true}, nil
116}
117
118// AcquireClaim excludes competing app lifecycle operations. The caller must
119// authorize the operation first; acquisition alone cannot start maintenance.
120// Immutable intent may survive a lost CAS, but grants no claim or side effect.
121func (r *Repository) AcquireClaim(ctx context.Context, account, app, id string, kind OperationKind) (ClaimSnapshot, error) {
122 if !ValidID(id) || !kind.valid() {
123 return ClaimSnapshot{}, ErrInvalid
124 }
125 old, e := r.ReadLifecycle(ctx, account, app)
126 if e != nil {
127 return ClaimSnapshot{}, e
128 }
129 if old.exists && old.value.State != "idle" {
130 return ClaimSnapshot{}, ErrBusy
131 }
132 generation := int64(1)
133 if old.exists {
134 if old.value.Generation == math.MaxInt64 {
135 return ClaimSnapshot{}, ErrConflict
136 }
137 generation = old.value.Generation + 1
138 }
139 l := Lifecycle{Version: Version, Account: account, App: app, Generation: generation, State: "claimed", OperationID: id, Kind: kind, ClaimGeneration: generation}
140 b, _ := json.Marshal(intent(l))
141 if e = r.putImmutable(ctx, claimKey(l, false), b); e != nil {
142 return ClaimSnapshot{}, e
143 }
144 raw, _ := json.Marshal(l)
145 key, _ := LifecycleKey(account, app)
146 etag, e := r.store.Put(ctx, key, raw, old.etag)
147 if e != nil {
148 return ClaimSnapshot{}, e
149 }
150 if !cleanETag(etag) {
151 return ClaimSnapshot{}, ErrUnavailable
152 }
153 return ClaimSnapshot{r, l, etag, true}, nil
154}
155
156// RecoverClaim observes only the exact still-held operation. It does not expire
157// a claim, release it, reapprove it, or rerun any command.
158func (r *Repository) RecoverClaim(ctx context.Context, account, app, id string, kind OperationKind) (ClaimSnapshot, error) {
159 if !ValidID(id) || !kind.valid() {
160 return ClaimSnapshot{}, ErrInvalid
161 }
162 s, e := r.ReadLifecycle(ctx, account, app)
163 if e != nil {
164 return ClaimSnapshot{}, e
165 }
166 if !s.exists || s.value.State != "claimed" || s.value.OperationID != id || s.value.Kind != kind {
167 return ClaimSnapshot{}, ErrConflict
168 }
169 return s, nil
170}
171func (r *Repository) currentClaim(ctx context.Context, s ClaimSnapshot) error {
172 if s.repository != r || !s.exists || s.value.State != "claimed" || !s.value.valid() || !cleanETag(s.etag) {
173 return ErrInvalid
174 }
175 now, e := r.ReadLifecycle(ctx, s.value.Account, s.value.App)
176 if e != nil {
177 return e
178 }
179 if !now.exists || now.etag != s.etag || now.value != s.value {
180 return ErrConflict
181 }
182 return nil
183}
184
185// ReleaseClaim requires the caller's completed-operation proof. Maintenance
186// callers must first verify both resumed projections and healthy serving under
187// the reviewed lease boundary. This CAS does not establish those facts itself.
188func (r *Repository) ReleaseClaim(ctx context.Context, s ClaimSnapshot) (ClaimSnapshot, error) {
189 if e := r.currentClaim(ctx, s); e != nil {
190 return ClaimSnapshot{}, e
191 }
192 if s.value.Generation == math.MaxInt64 {
193 return ClaimSnapshot{}, ErrConflict
194 }
195 l := s.value
196 l.Generation++
197 l.State = "idle"
198 completion, _ := json.Marshal(claimCompletion{intent(l), l.Generation})
199 if e := r.putImmutable(ctx, claimKey(l, true), completion); e != nil {
200 return ClaimSnapshot{}, e
201 }
202 l.CompletionDigest = digest(completion)
203 raw, _ := json.Marshal(l)
204 key, _ := LifecycleKey(l.Account, l.App)
205 etag, e := r.store.Put(ctx, key, raw, s.etag)
206 if e != nil {
207 return ClaimSnapshot{}, e
208 }
209 if !cleanETag(etag) {
210 return ClaimSnapshot{}, ErrUnavailable
211 }
212 return ClaimSnapshot{r, l, etag, true}, nil
213}
214