carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

1package hostops
2
3import (
4 "context"
5 "crypto/sha256"
6 "encoding/hex"
7 "encoding/json"
8 "errors"
9 "fmt"
10 "os"
11 "slices"
12 "strings"
13 "time"
14)
15
16// Fenced reconciliation of a retained mail-resource gate (review P1-2).
17//
18// A gated mail call that fails after an effect leaves every member effect
19// "uncertain" and the gate held. Nothing here asks a caller whether the call
20// worked. The settle that recorded "uncertain" also recorded, in the effect's
21// result, exactly which surfaces the call attempted (MailAttempt). The
22// reconciler:
23//
24// 1. fences the gate: one CAS bumps its revision and clears its token, so no
25// lease of the old holder passes CheckMailGate (every mail backend and
26// mail-gated bucket write checks it) and no new holder can acquire;
27// 2. reads back every attempted surface it has read authority for, from the
28// writer's own raw bucket and a typed SES identity reader;
29// 3. refuses (ErrMailUnproven, gate stays held and fenced) if any attempted
30// surface has no readback (DNS, IAM, tenants, configuration sets), if any
31// member effect is still "issued" (the issuer may be mid-call), or if a
32// result carries no attempt record;
33// 4. settles each uncertain effect complete with the evidence digest, each
34// merely prepared one "superseded", then frees the gate with the full
35// evidence stored on the gate object.
36//
37// Member operations end afterwards through RecoverMutation, which requires
38// the gate to no longer hold their gate ID: ops still end only after the
39// gate's release CAS.
40
41// ErrMailUnproven is a reconciliation the writer cannot prove from readback.
42// It wraps ErrPending: the gate and every member stay held.
43var ErrMailUnproven = fmt.Errorf("%w: mail gate reconciliation lacks readback evidence", ErrPending)
44
45// MailAttempt is one mutating surface a gated mail call reached (it was not
46// refused before effect). Surfaces: "store" (Key), "ses-identity" (Region,
47// Domain), and the surfaces with no readback: "ses-tenant", "ses-config-set",
48// "iam" (Name), "dns" (Name).
49type MailAttempt struct {
50 Surface string `json:"surface"`
51 Method string `json:"method"`
52 Key string `json:"key,omitempty"`
53 Region string `json:"region,omitempty"`
54 Domain string `json:"domain,omitempty"`
55 Name string `json:"name,omitempty"`
56}
57
58const mailAttemptsPrefix = "mail-attempts:"
59
60// MailAttemptsResult encodes the attempts into an uncertain effect's result.
61func MailAttemptsResult(attempts []MailAttempt) string {
62 body, err := json.Marshal(normalizeAttempts(attempts))
63 if err != nil {
64 return ""
65 }
66 return mailAttemptsPrefix + string(body)
67}
68
69func parseMailAttempts(result string) ([]MailAttempt, bool) {
70 rest, ok := strings.CutPrefix(result, mailAttemptsPrefix)
71 if !ok {
72 return nil, false
73 }
74 var out []MailAttempt
75 dec := json.NewDecoder(strings.NewReader(rest))
76 dec.DisallowUnknownFields()
77 if err := dec.Decode(&out); err != nil {
78 return nil, false
79 }
80 return out, true
81}
82
83func normalizeAttempts(in []MailAttempt) []MailAttempt {
84 out := slices.Clone(in)
85 slices.SortFunc(out, func(a, b MailAttempt) int {
86 return strings.Compare(a.Surface+"\x00"+a.Method+"\x00"+a.Key+"\x00"+a.Region+"\x00"+a.Domain+"\x00"+a.Name,
87 b.Surface+"\x00"+b.Method+"\x00"+b.Key+"\x00"+b.Region+"\x00"+b.Domain+"\x00"+b.Name)
88 })
89 return slices.Compact(out)
90}
91
92// MailIdentityReader is the typed SES readback. The console adapts its RAW
93// mail provisioner's IdentityStatus (a read) and returns its canonical JSON.
94type MailIdentityReader interface {
95 MailIdentityState(ctx context.Context, region, domain string) (json.RawMessage, error)
96}
97
98// MailReadback is one surface's observed state after the fence.
99type MailReadback struct {
100 Attempt MailAttempt `json:"attempt"`
101 Absent bool `json:"absent,omitempty"`
102 ETag string `json:"etag,omitempty"`
103 SHA256 string `json:"sha256,omitempty"`
104 State json.RawMessage `json:"state,omitempty"`
105}
106
107// MailGateReconciliation is the evidence a reconciliation recorded.
108type MailGateReconciliation struct {
109 Gate string `json:"gate"`
110 Revision uint64 `json:"revision"` // the fenced revision
111 Members []MailGateMember `json:"members"`
112 Readback []MailReadback `json:"readback"`
113 At time.Time `json:"at"`
114 Digest string `json:"digest"` // sha256 of the record with Digest empty
115}
116
117// ReconcileMailGate fences, reads back and settles the retained gate gateID.
118// minHeld is liveness only (do not interrupt a holder that is probably still
119// running); it is never the safety argument and never settles an issued
120// effect. Idempotent: a gate already reconciled for gateID returns its
121// record.
122func (w *ControlWriter) ReconcileMailGate(ctx context.Context, gateID string, minHeld time.Duration, ses MailIdentityReader) (MailGateReconciliation, error) {
123 if err := w.ready(); err != nil {
124 return MailGateReconciliation{}, err
125 }
126 if gateID == "" {
127 return MailGateReconciliation{}, ErrIdentity
128 }
129 g, tag, err := w.readMailGate(ctx)
130 if err != nil {
131 return MailGateReconciliation{}, err
132 }
133 if g.State != "held" || g.ID != gateID {
134 if g.Reconciled != nil && g.Reconciled.Gate == gateID {
135 return *g.Reconciled, nil
136 }
137 return MailGateReconciliation{}, ErrPending
138 }
139 // 1. Fence.
140 if !g.Reconciling {
141 if w.now().Before(g.HeldAt.Add(minHeld)) {
142 return MailGateReconciliation{}, ErrPending
143 }
144 g.Revision++
145 g.TokenSHA256, g.Reconciling = "", true
146 body, err := json.Marshal(g)
147 if err != nil {
148 return MailGateReconciliation{}, err
149 }
150 if tag, err = w.raw.Put(ctx, MailGateKey, body, tag); err != nil {
151 return MailGateReconciliation{}, err
152 }
153 }
154 fenced := g.Revision
155
156 // 2/3. Classify members and collect attempts.
157 var attempts []MailAttempt
158 for _, m := range g.Members {
159 a, err := w.read(ctx, m.Account, m.App, m.AppID)
160 if err != nil {
161 return MailGateReconciliation{}, err
162 }
163 e, ok := a.Value.Effects[m.Effect]
164 if !ok {
165 continue // admitted, never prepared: nothing to settle
166 }
167 if e.OperationID != m.Operation || e.AppID != m.AppID {
168 return MailGateReconciliation{}, ErrIdentity
169 }
170 switch e.State {
171 case "complete", "prepared":
172 case "uncertain":
173 // A readback cannot exclude a request arriving after the client's
174 // timeout. Keep durable ownership until quiescence can be proved.
175 return MailGateReconciliation{}, fmt.Errorf("%w: %s/%s has no remote quiescence proof", ErrMailUnproven, m.Account, m.App)
176 default:
177 // issued: the issuer may still be inside its call. No issuer
178 // death proof exists; it settles or stays (no age settlement).
179 return MailGateReconciliation{}, fmt.Errorf("%w: %s/%s effect %s is still issued", ErrMailUnproven, m.Account, m.App, m.Effect)
180 }
181 }
182 attempts = normalizeAttempts(attempts)
183 var unproven []string
184 readback := make([]MailReadback, 0, len(attempts))
185 for _, at := range attempts {
186 rb := MailReadback{Attempt: at}
187 switch at.Surface {
188 case "store":
189 body, etag, err := w.raw.Get(ctx, at.Key)
190 switch {
191 case errors.Is(err, os.ErrNotExist):
192 rb.Absent = true
193 case err != nil:
194 return MailGateReconciliation{}, err
195 default:
196 sum := sha256.Sum256(body)
197 rb.ETag, rb.SHA256 = etag, hex.EncodeToString(sum[:])
198 }
199 case "ses-identity":
200 if ses == nil || at.Region == "" || at.Domain == "" {
201 unproven = append(unproven, at.Surface+":"+at.Method+":"+at.Domain)
202 continue
203 }
204 state, err := ses.MailIdentityState(ctx, at.Region, at.Domain)
205 if err != nil {
206 return MailGateReconciliation{}, err
207 }
208 rb.State = state
209 default:
210 unproven = append(unproven, at.Surface+":"+at.Method+":"+at.Name)
211 continue
212 }
213 readback = append(readback, rb)
214 }
215 if len(unproven) != 0 {
216 return MailGateReconciliation{}, fmt.Errorf("%w: no readback authority for %s", ErrMailUnproven, strings.Join(unproven, ", "))
217 }
218 rec := MailGateReconciliation{Gate: gateID, Revision: fenced, Members: slices.Clone(g.Members), Readback: readback, At: w.now().UTC()}
219 body, err := json.Marshal(rec)
220 if err != nil {
221 return MailGateReconciliation{}, err
222 }
223 sum := sha256.Sum256(body)
224 rec.Digest = hex.EncodeToString(sum[:])
225
226 // 4. Settle members, then free the gate.
227 for _, m := range g.Members {
228 if _, err := w.retryAdmission(ctx, m.Account, m.App, m.AppID, AdmissionObserved{}, func(v *AppAdmission) error {
229 e, ok := v.Effects[m.Effect]
230 if !ok {
231 return nil
232 }
233 switch e.State {
234 case "complete":
235 return nil
236 case "prepared":
237 e.State, e.ResultReference = "complete", "superseded"
238 case "uncertain":
239 e.State, e.ResultReference = "complete", "mail-reconciled:sha256:"+rec.Digest
240 default:
241 return ErrPending
242 }
243 v.Effects[m.Effect] = e
244 return nil
245 }); err != nil {
246 return MailGateReconciliation{}, err
247 }
248 }
249 g, tag, err = w.readMailGate(ctx)
250 if err != nil {
251 return MailGateReconciliation{}, err
252 }
253 if g.State != "held" || g.ID != gateID || !g.Reconciling || g.Revision != fenced {
254 return MailGateReconciliation{}, ErrPending
255 }
256 g.Revision++
257 g.State, g.TokenSHA256, g.HeldAt, g.Members, g.Reconciling, g.Reconciled = "free", "", time.Time{}, nil, false, &rec
258 if body, err = json.Marshal(g); err != nil {
259 return MailGateReconciliation{}, err
260 }
261 if _, err = w.raw.Put(ctx, MailGateKey, body, tag); err != nil {
262 return MailGateReconciliation{}, err
263 }
264 return rec, nil
265}
266