carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

1package mail
2
3import (
4 "context"
5 "errors"
6 "fmt"
7 "io"
8 "slices"
9 "sort"
10 "sync"
11 "sync/atomic"
12 "time"
13
14 "github.com/carlosframework/platform/internal/hostops"
15 "github.com/carlosframework/platform/internal/release"
16)
17
18const releaseTimeout = 30 * time.Second
19
20var ErrClaimMailPending = errors.New("mail: lifecycle resource operation pending")
21
22type ClaimMailOwner struct{ Console, Enrollment, Controller string }
23type ClaimMailScope struct{ Account, App, AppID, Operation string }
24
25// ClaimMailApp names one app a gated mail call can affect.
26type ClaimMailApp struct{ Account, App string }
27
28// ClaimMailHold identifies the held shared gate an admission belongs to.
29type ClaimMailHold struct {
30 Gate string
31 Owner ClaimMailOwner
32}
33
34// ClaimMailGate is the shared resource gate. The production implementation
35// is the admission engine's typed control writer; this package never sees
36// the gate's storage key and cannot write it.
37type ClaimMailGate interface {
38 // AcquireClaimMailGate takes the free gate and returns a context that
39 // carries the lease (the backend and bucket boundaries check it).
40 AcquireClaimMailGate(ctx context.Context, owner ClaimMailOwner, scope ClaimMailScope) (held context.Context, gateID string, err error)
41 // CheckClaimMailGate proves held still owns the exact held revision.
42 CheckClaimMailGate(held context.Context) error
43 // ReleaseClaimMailGate frees exactly the revision held wrote.
44 ReleaseClaimMailGate(held context.Context) error
45}
46
47// ClaimMailAdmission is one outermost gated call's durable app authority.
48type ClaimMailAdmission interface {
49 // Context carries a live permit for every admitted app.
50 Context() context.Context
51 // Settle runs once, after the call joined: it revokes the permits and
52 // records each app effect complete (no error, or no attempt reached a
53 // mutating surface) or uncertain (an error after an attempt). attempts
54 // is exactly what THIS admission's call reached, never another
55 // admission's under the same gate (review P2-9); an uncertain effect
56 // records them for the fenced reconciler.
57 Settle(ctx context.Context, callErr error, attempts []hostops.MailAttempt) error
58 // Release runs only after the shared gate's release CAS succeeded. It
59 // ends each app's mutation operation. An uncertain effect is never
60 // released; it waits for fenced reconciliation.
61 Release(ctx context.Context) error
62}
63
64// ClaimMailAdmitter is called at most once per outermost gated call, while
65// the gate is held, before the call's first backend, SES, DNS or store
66// mutation. apps is complete, sorted and de-duplicated. A refusal leaves
67// every admission unchanged.
68type ClaimMailAdmitter interface {
69 AdmitClaimMailApps(ctx context.Context, hold ClaimMailHold, apps []ClaimMailApp) (ClaimMailAdmission, error)
70}
71
72// ClaimMailStore marks the enabled mail path. Its inner store is the
73// console's DATA boundary, never a raw bucket. Named private fields only.
74type ClaimMailStore struct {
75 data release.Store
76 gate ClaimMailGate
77 admitter ClaimMailAdmitter
78 owner ClaimMailOwner
79}
80
81var (
82 _ release.Store = ClaimMailStore{}
83 _ release.ConditionalDeleter = ClaimMailStore{}
84 _ release.Opener = ClaimMailStore{}
85 _ release.ModLister = ClaimMailStore{}
86 _ release.PageLister = ClaimMailStore{}
87 _ release.SizeLister = ClaimMailStore{}
88 _ release.BucketListDirs = ClaimMailStore{}
89)
90
91func NewClaimMailStore(data release.Store, gate ClaimMailGate, admitter ClaimMailAdmitter, owner ClaimMailOwner) (ClaimMailStore, error) {
92 if data == nil || gate == nil || admitter == nil || owner.Console == "" || owner.Enrollment == "" || owner.Controller == "" {
93 return ClaimMailStore{}, ErrClaimMailPending
94 }
95 return ClaimMailStore{data: data, gate: gate, admitter: admitter, owner: owner}, nil
96}
97
98func (s ClaimMailStore) Get(ctx context.Context, key string) ([]byte, string, error) {
99 return s.data.Get(ctx, key)
100}
101func (s ClaimMailStore) List(ctx context.Context, prefix string) ([]string, error) {
102 return s.data.List(ctx, prefix)
103}
104func (s ClaimMailStore) ListDirs(ctx context.Context, prefix string) ([]string, error) {
105 return s.data.ListDirs(ctx, prefix)
106}
107func (s ClaimMailStore) BucketShapedListDirs() bool { return release.HasBucketListDirs(s.data) }
108func (s ClaimMailStore) Open(ctx context.Context, key string) (io.ReadCloser, string, error) {
109 return release.OpenObject(ctx, s.data, key)
110}
111func (s ClaimMailStore) ListMod(ctx context.Context, prefix string) ([]release.KeyMod, error) {
112 if l, ok := s.data.(release.ModLister); ok {
113 return l.ListMod(ctx, prefix)
114 }
115 return nil, ErrClaimMailPending
116}
117func (s ClaimMailStore) ListPage(ctx context.Context, prefix, after string, limit int) ([]string, error) {
118 if l, ok := s.data.(release.PageLister); ok {
119 return l.ListPage(ctx, prefix, after, limit)
120 }
121 return nil, ErrClaimMailPending
122}
123func (s ClaimMailStore) ListSizes(ctx context.Context, prefix string) ([]release.KeySize, error) {
124 if l, ok := s.data.(release.SizeLister); ok {
125 return l.ListSizes(ctx, prefix)
126 }
127 return nil, ErrClaimMailPending
128}
129func (s ClaimMailStore) Put(ctx context.Context, key string, body []byte, tag string) (string, error) {
130 etag, err := s.data.Put(ctx, key, body, tag)
131 markClaimMailEffect(ctx, hostops.MailAttempt{Surface: "store", Method: "Put", Key: key}, err)
132 return etag, err
133}
134func (s ClaimMailStore) Delete(ctx context.Context, key string) error {
135 err := s.data.Delete(ctx, key)
136 markClaimMailEffect(ctx, hostops.MailAttempt{Surface: "store", Method: "Delete", Key: key}, err)
137 return err
138}
139func (s ClaimMailStore) DeleteIfMatch(ctx context.Context, key, etag string) error {
140 err := release.DeleteIfMatch(ctx, s.data, key, etag)
141 markClaimMailEffect(ctx, hostops.MailAttempt{Surface: "store", Method: "DeleteIfMatch", Key: key}, err)
142 return err
143}
144
145type claimMailToken struct{}
146type claimMailScopeKey struct{}
147type claimMailCapsKey struct{}
148type claimMailAttemptsKey struct{}
149type claimMailCaps map[ClaimMailApp]struct{}
150
151type claimMailHeld struct {
152 ID string
153 Owner ClaimMailOwner
154 // Unadmitted: a mutating surface was reached under the gate outside any
155 // admission (no durable effect records it). The gate is then retained
156 // on failure, since nothing else can say what happened.
157 Unadmitted atomic.Bool
158 Retain atomic.Bool // an effect is unsettled: keep the gate
159 mu sync.Mutex
160 releases []func(context.Context) error
161}
162
163// claimMailAttempts is ONE admission's record of the surfaces its call
164// reached. It travels in the admitted context, so nested gated calls add to
165// the outermost admission and a later admission starts empty.
166type claimMailAttempts struct {
167 mu sync.Mutex
168 list []hostops.MailAttempt
169 err error
170}
171
172func (a *claimMailAttempts) snapshot() ([]hostops.MailAttempt, error) {
173 a.mu.Lock()
174 defer a.mu.Unlock()
175 return slices.Clone(a.list), a.err
176}
177
178// markClaimMailDirty records an attempt on an unnamed surface (kept for the
179// existing claim_gate_test.go fixture). Outside an admission it marks the
180// hold Unadmitted; inside one it makes that admission's failure uncertain
181// with an attempt the reconciler cannot read back.
182func markClaimMailDirty(ctx context.Context) {
183 markClaimMailEffect(ctx, hostops.MailAttempt{Surface: "unknown", Method: "unknown"}, nil)
184}
185
186// markClaimMailEffect records that a mutation reached its surface, unless a
187// boundary refused it before any effect. An unknown error may have acted.
188func markClaimMailEffect(ctx context.Context, at hostops.MailAttempt, err error) {
189 if err != nil && hostops.RefusedBeforeEffect(err) {
190 return
191 }
192 if rec, ok := ctx.Value(claimMailAttemptsKey{}).(*claimMailAttempts); ok {
193 rec.mu.Lock()
194 rec.list = append(rec.list, at)
195 rec.err = errors.Join(rec.err, err)
196 rec.mu.Unlock()
197 return
198 }
199 if held, ok := ctx.Value(claimMailToken{}).(*claimMailHeld); ok {
200 held.Unadmitted.Store(true)
201 }
202}
203
204func WithClaimMailScope(ctx context.Context, scope ClaimMailScope) context.Context {
205 return context.WithValue(ctx, claimMailScopeKey{}, scope)
206}
207func ClaimMailScopeFrom(ctx context.Context) (ClaimMailScope, bool) {
208 scope, ok := ctx.Value(claimMailScopeKey{}).(ClaimMailScope)
209 return scope, ok
210}
211
212// claimMailPlan says which apps one gated call can affect. Apps come from the
213// arguments (the proposed members). Domain adds the persisted members of
214// that domain (fresh read) and the app named on this account's global claim
215// for it. AppDomains adds the same for every domain that app refers to.
216//
217// The claim's app is part of the persisted set because the claim is what
218// the backend boundary binds an SES identity and its DNS to, and the claim
219// write is scoped to it: an EnsureDomain re-run by a sibling app, a
220// VerifyDomain stamp or a RemoveDomain release all touch it. Resolving it
221// here admits it before any SES, DNS or store call, instead of letting the
222// boundary refuse halfway through a walk. A claim held by another account
223// adds nothing: the walk refuses or lapses it, and a takeover's write needs
224// that account's authority, which no admission here grants.
225//
226// Refresh adds what a downstream RefreshPolicies or EnsureUser will need. A
227// policy is written from the app's granted domains, and the backend boundary
228// authorizes each granted domain against ALL its members. So every verified
229// domain naming a refreshed app (and Domain itself, which may verify during
230// the call) contributes its members and its claim app, and those members are
231// refreshed in turn: the closure is the TRANSITIVE fixed point across shared
232// domains (D1/A, D2/A+B, D3/B+C admits A, B and C). An unverified domain other
233// than Domain grants nothing and adds nothing. Without it a nested call
234// refuses after the SES and domain effects of the outer call, retaining the
235// shared gate.
236type claimMailPlan struct {
237 Apps []string
238 Domain string
239 AppDomains string
240 Refresh bool
241}
242
243func claimPutDomainPlan(rec *DomainRecord) claimMailPlan {
244 if rec == nil {
245 return claimMailPlan{}
246 }
247 return claimMailPlan{Apps: rec.Apps, Domain: rec.Domain}
248}
249
250func (p claimMailPlan) resolve(ctx context.Context, s release.Store, acct string) ([]ClaimMailApp, error) {
251 if !release.ValidAccount(acct) {
252 return nil, fmt.Errorf("mail: invalid account %q", acct)
253 }
254 set := map[string]struct{}{}
255 for _, app := range p.Apps {
256 set[app] = struct{}{}
257 }
258 claimApp := func(domain string) error {
259 c, err := getDomainClaim(ctx, s, domain)
260 if err != nil {
261 return err
262 }
263 if c != nil && c.Account == acct && c.App != "" {
264 set[c.App] = struct{}{}
265 }
266 return nil
267 }
268 if p.Domain != "" {
269 if !ValidDomain(p.Domain) {
270 return nil, fmt.Errorf("mail: invalid domain %q", p.Domain)
271 }
272 rec, _, err := GetDomain(ctx, s, acct, p.Domain)
273 if err != nil {
274 return nil, err
275 }
276 if rec != nil {
277 for _, app := range rec.Apps {
278 set[app] = struct{}{}
279 }
280 }
281 if err = claimApp(p.Domain); err != nil {
282 return nil, err
283 }
284 }
285 if p.AppDomains != "" {
286 // A complete listing: a malformed record aborts the census.
287 recs, err := StrictListDomains(ctx, s, acct)
288 if err != nil {
289 return nil, err
290 }
291 for _, rec := range recs {
292 if slices.Contains(rec.Apps, p.AppDomains) {
293 for _, app := range rec.Apps {
294 set[app] = struct{}{}
295 }
296 if err = claimApp(rec.Domain); err != nil {
297 return nil, err
298 }
299 }
300 }
301 }
302 if p.Refresh {
303 refreshed := map[string]struct{}{}
304 for _, app := range p.Apps {
305 refreshed[app] = struct{}{}
306 }
307 if p.Domain != "" {
308 rec, _, err := GetDomain(ctx, s, acct, p.Domain)
309 if err != nil {
310 return nil, err
311 }
312 if rec != nil {
313 for _, app := range rec.Apps {
314 refreshed[app] = struct{}{}
315 }
316 }
317 }
318 // A complete listing: an unreadable or malformed record aborts.
319 recs, err := StrictListDomains(ctx, s, acct)
320 if err != nil {
321 return nil, err
322 }
323 // Fixed point: a domain in the closure pulls all its members in, and a
324 // newly pulled member can in turn use another relevant domain, so
325 // repeat until a pass adds no app. Each domain is taken at most once.
326 taken := map[string]struct{}{}
327 for changed := true; changed; {
328 changed = false
329 for _, rec := range recs {
330 if _, done := taken[rec.Domain]; done {
331 continue
332 }
333 if len(rec.VerifiedARNs()) == 0 && rec.Domain != p.Domain {
334 continue
335 }
336 touched := false
337 for _, app := range rec.Apps {
338 if _, ok := refreshed[app]; ok {
339 touched = true
340 break
341 }
342 }
343 if !touched {
344 continue
345 }
346 taken[rec.Domain] = struct{}{}
347 for _, app := range rec.Apps {
348 set[app] = struct{}{}
349 if _, ok := refreshed[app]; !ok {
350 refreshed[app] = struct{}{}
351 changed = true
352 }
353 }
354 if err = claimApp(rec.Domain); err != nil {
355 return nil, err
356 }
357 }
358 }
359 }
360 names := make([]string, 0, len(set))
361 for app := range set {
362 if !release.ValidApp(app) {
363 return nil, fmt.Errorf("mail: invalid app %q", app)
364 }
365 names = append(names, app)
366 }
367 sort.Strings(names)
368 out := make([]ClaimMailApp, len(names))
369 for i, app := range names {
370 out[i] = ClaimMailApp{Account: acct, App: app}
371 }
372 return out, nil
373}
374
375// claimMailAdmitted must run inside WithClaimMailResource on a marked store.
376func claimMailAdmitted(ctx context.Context, s release.Store, acct string, plan claimMailPlan, call func(context.Context) error) error {
377 held, ok := ctx.Value(claimMailToken{}).(*claimMailHeld)
378 if !ok {
379 return ErrClaimMailPending
380 }
381 marked, ok := s.(ClaimMailStore)
382 if !ok || marked.admitter == nil {
383 return ErrClaimMailPending
384 }
385 apps, err := plan.resolve(ctx, s, acct)
386 if err != nil {
387 return err
388 }
389 if installed, nested := ctx.Value(claimMailCapsKey{}).(claimMailCaps); nested {
390 // Nested: only the preflighted set is usable. No admission read, no
391 // second hook call, no widening.
392 for _, a := range apps {
393 if _, covered := installed[a]; !covered {
394 return ErrClaimMailPending
395 }
396 }
397 return call(ctx)
398 }
399 if len(apps) == 0 {
400 return ErrClaimMailPending
401 }
402 caps := claimMailCaps{}
403 for _, a := range apps {
404 caps[a] = struct{}{}
405 }
406 admission, err := marked.admitter.AdmitClaimMailApps(ctx, ClaimMailHold{Gate: held.ID, Owner: held.Owner}, slices.Clone(apps))
407 if err != nil {
408 return err
409 }
410 if admission == nil || admission.Context() == nil {
411 return ErrClaimMailPending
412 }
413 // The admitted context descends from ctx, so it still carries the held
414 // gate token and the gate lease.
415 rec := &claimMailAttempts{}
416 admitted := context.WithValue(context.WithValue(admission.Context(), claimMailCapsKey{}, caps), claimMailAttemptsKey{}, rec)
417 result := call(admitted)
418 attempts, effectErr := rec.snapshot()
419 result = errors.Join(result, effectErr)
420 if err := admission.Settle(ctx, result, attempts); err != nil {
421 // An unsettled effect retains both the app authority and the gate.
422 held.Retain.Store(true)
423 return errors.Join(result, err)
424 }
425 if result != nil && len(attempts) != 0 {
426 held.Retain.Store(true)
427 return result
428 }
429 held.mu.Lock()
430 held.releases = append(held.releases, admission.Release)
431 held.mu.Unlock()
432 return result
433}
434
435// WithClaimMailResource serializes one enabled mail mutation under the
436// shared gate. An unmarked store runs the original body with no gate,
437// admission or domain read. The gate is released only when no effect is
438// retained; the app authorities are released only after that release CAS.
439func WithClaimMailResource(ctx context.Context, s release.Store, call func(context.Context) error) error {
440 marked, enabled := s.(ClaimMailStore)
441 if !enabled {
442 return call(ctx)
443 }
444 if marked.gate == nil || marked.admitter == nil {
445 return ErrClaimMailPending
446 }
447 if nested, ok := ctx.Value(claimMailToken{}).(*claimMailHeld); ok {
448 if nested.Owner != marked.owner {
449 return ErrClaimMailPending
450 }
451 if err := marked.gate.CheckClaimMailGate(ctx); err != nil {
452 return err
453 }
454 return call(ctx)
455 }
456 scope, _ := ClaimMailScopeFrom(ctx)
457 heldCtx, gateID, err := marked.gate.AcquireClaimMailGate(ctx, marked.owner, scope)
458 if err != nil {
459 return err
460 }
461 held := &claimMailHeld{ID: gateID, Owner: marked.owner}
462 result := call(context.WithValue(heldCtx, claimMailToken{}, held))
463 if held.Retain.Load() || (result != nil && held.Unadmitted.Load()) {
464 // Retained for fenced reconciliation; no timer or heartbeat frees it.
465 return result
466 }
467 saveCtx, cancel := context.WithTimeout(context.WithoutCancel(heldCtx), releaseTimeout)
468 defer cancel()
469 if err = marked.gate.ReleaseClaimMailGate(saveCtx); err != nil {
470 // The gate stays held, so its app authorities stay held too.
471 return errors.Join(result, err)
472 }
473 held.mu.Lock()
474 releases := held.releases
475 held.mu.Unlock()
476 var errs []error
477 for _, end := range releases {
478 errs = append(errs, end(saveCtx))
479 }
480 return errors.Join(append(errs, result)...)
481}
482