package console import ( "context" "encoding/json" "errors" "fmt" "time" "github.com/carlosframework/platform/internal/hostops" "github.com/carlosframework/platform/internal/mail" "github.com/carlosframework/platform/internal/release" ) // claimMailGate adapts the typed control writer's shared gate to the mail // package. The lease travels in the context; possession is its token. type claimMailGate struct{ w *hostops.ControlWriter } var _ mail.ClaimMailGate = claimMailGate{} func (g claimMailGate) AcquireClaimMailGate(ctx context.Context, owner mail.ClaimMailOwner, scope mail.ClaimMailScope) (context.Context, string, error) { l, err := g.w.AcquireMailGate(ctx, hostops.MailGateOwner(owner), hostops.MailGateScope(scope)) if err != nil { if errors.Is(err, hostops.ErrPending) { return nil, "", errors.Join(mail.ErrClaimMailPending, err) } return nil, "", err } return hostops.WithMailGateLease(ctx, l), l.ID, nil } func (g claimMailGate) CheckClaimMailGate(held context.Context) error { l, ok := hostops.MailGateLeaseFrom(held) if !ok { return mail.ErrClaimMailPending } return g.w.CheckMailGate(held, l) } func (g claimMailGate) ReleaseClaimMailGate(held context.Context) error { l, ok := hostops.MailGateLeaseFrom(held) if !ok { return mail.ErrClaimMailPending } return g.w.ReleaseMailGate(held, l) } // claimMailAdmitter is the required production admission provider. Each // affected app gets a durable effect under a NON-EXPIRING operation: its // own app-mutation operation, or the purge operation named by the gate // scope for the purging app itself. A failed call after an effect leaves // that effect uncertain and the operation held, so purge cannot pass it // (review P2-4). Operations end only after the gate release CAS. type claimMailAdmitter struct { localAppID func(context.Context, string, string) (string, error) data release.Store control *hostops.ControlWriter owner hostops.RegistrationOwner } var _ mail.ClaimMailAdmitter = claimMailAdmitter{} type claimMailMember struct { handle *hostops.OperationHandle began bool // this admission began the operation and must end it settle func(string, error) error } type claimMailAdmission struct { ctx context.Context members []claimMailMember settled bool failed bool } func (m claimMailAdmitter) AdmitClaimMailApps(ctx context.Context, hold mail.ClaimMailHold, apps []mail.ClaimMailApp) (mail.ClaimMailAdmission, error) { if m.control == nil || m.data == nil || m.localAppID == nil || hold.Gate == "" || len(apps) == 0 { return nil, mail.ErrClaimMailPending } lease, ok := hostops.MailGateLeaseFrom(ctx) if !ok || lease.ID != hold.Gate { return nil, mail.ErrClaimMailPending } scope, scoped := mail.ClaimMailScopeFrom(ctx) // Resolve every local incarnation before recording any member or admitting // any app. Recovery of the exact purge member uses its journal-bound ID. expected := make(map[mail.ClaimMailApp]string, len(apps)) for _, app := range apps { var id string var err error if scoped && scope.Account == app.Account && scope.App == app.App && scope.Operation != "" { id = scope.AppID } else { id, err = m.localAppID(ctx, app.Account, app.App) } if err != nil { return nil, err } if id == "" { return nil, hostops.ErrIdentity } if _, err = hostops.ReadAdmission(ctx, m.data, app.Account, app.App, id); err != nil { return nil, err } expected[app] = id } adm := &claimMailAdmission{ctx: ctx} abandon := func(cause error) (mail.ClaimMailAdmission, error) { // Nothing was called yet: every issued effect settles "not-applied" // and every operation this admission began ends. var errs []error for i := len(adm.members) - 1; i >= 0; i-- { mem := adm.members[i] if mem.settle != nil { if err := mem.settle("", hostops.ErrNotApplied); err != nil && !errors.Is(err, hostops.ErrNotApplied) { errs = append(errs, err) } } if mem.began { if err := mem.handle.FinishMutation(context.WithoutCancel(ctx)); err != nil { errs = append(errs, err) } } } return nil, errors.Join(append(errs, cause)...) } for _, app := range apps { a, err := hostops.ReadAdmission(ctx, m.data, app.Account, app.App, expected[app]) if err != nil { return abandon(err) } appID := a.Value.AppID var mem claimMailMember purgeMember := scoped && scope.Account == app.Account && scope.App == app.App && scope.AppID == appID && scope.Operation != "" // Review P1-2: an app-mutation member's effect ID IS its operation ID, // so RecoverMutation finds it. A purge member's effect is named by the // gate (the purge op owns its own ID). var opID, effectID string if purgeMember { opID, effectID = scope.Operation, "mail-"+hold.Gate } else { if opID, err = claimEffectID(); err != nil { return abandon(err) } effectID = opID } // Durable membership BEFORE the op or effect exists: the gate // reconciler can then find every effect this hold could issue. if err = m.control.RecordMailGateMember(ctx, lease, hostops.MailGateMember{Account: app.Account, App: app.App, AppID: appID, Operation: opID, Effect: effectID}); err != nil { return abandon(err) } if purgeMember { if mem.handle, err = m.control.ResumeExclusive(ctx, app.Account, app.App, appID, opID, hostops.OpPurging, m.owner); err != nil { return abandon(err) } } else { op := hostops.AppOperation{ID: opID, Kind: hostops.OpAppMutation, AppID: appID, Phase: "issued", Intent: map[string]string{"action": "mail", "gate": hold.Gate}} if mem.handle, err = m.control.BeginExclusive(ctx, app.Account, app.App, appID, op, m.owner); err != nil { return abandon(errors.Join(mail.ErrClaimMailPending, err)) } mem.began = true } adm.members = append(adm.members, mem) issued, settle, err := mem.handle.Runner(m.now).Issue(adm.ctx, hostops.Effect{ID: effectID, Resource: "mail-gate:" + hold.Gate, Action: "mail", Inputs: map[string]string{"gate": hold.Gate}}) if err != nil { return abandon(err) } adm.members[len(adm.members)-1].settle = settle adm.ctx = issued // permits accumulate across the whole app set } return adm, nil } func (m claimMailAdmitter) now() time.Time { return time.Now() } func (a *claimMailAdmission) Context() context.Context { return a.ctx } // Settle records this admission's outcome only. attempts are the surfaces // THIS admission's call reached (review P2-9); a failure after any attempt // settles uncertain WITH that record, which is the reconciler's readback // list. No caller-supplied boolean decides anything. func (a *claimMailAdmission) Settle(ctx context.Context, callErr error, attempts []hostops.MailAttempt) error { if a.settled { return hostops.ErrContract } a.settled = true result, cause := "joined", error(nil) switch { case callErr == nil: case len(attempts) == 0: cause = fmt.Errorf("%w: %v", hostops.ErrNotApplied, callErr) default: cause = callErr result = hostops.MailAttemptsResult(attempts) a.failed = true } var errs []error for i := len(a.members) - 1; i >= 0; i-- { err := a.members[i].settle(result, cause) if err != nil && !errors.Is(err, hostops.ErrNotApplied) && (cause == nil || !errors.Is(err, cause)) { errs = append(errs, err) a.failed = true } } return errors.Join(errs...) } // Release is called only after the gate release CAS succeeded. func (a *claimMailAdmission) Release(ctx context.Context) error { if !a.settled || a.failed { return mail.ErrClaimMailPending } var errs []error for _, mem := range a.members { if mem.began { errs = append(errs, mem.handle.FinishMutation(ctx)) } } return errors.Join(errs...) } // claimMailStore is the only store enabled mail mutations receive. Flag-off // returns the original handle. A misconfigured enabled console gets a store // that refuses every call, never the bare data handle. func (s *Server) claimMailStore() release.Store { if !s.cfg.HostClaim { return s.cfg.Releases } if s.cfg.ClaimAuthority == nil || s.cfg.ClaimAuthority.Control == nil || s.cfg.ClaimPurge == nil { return refusingStore{hostops.ErrContract} } o := s.cfg.ClaimPurge.Owner admitter := claimMailAdmitter{data: s.cfg.Releases, control: s.cfg.ClaimAuthority.Control, owner: o, localAppID: s.mutationAppID} store, err := mail.NewClaimMailStore(s.cfg.Releases, claimMailGate{w: s.cfg.ClaimAuthority.Control}, admitter, mail.ClaimMailOwner{Console: o.Console, Enrollment: o.Enrollment, Controller: o.Controller}) if err != nil { return refusingStore{err} } return store } // claimMailReconcileMinHeld only avoids fencing a holder that is probably // still inside its call. It is not a safety bound: issued effects are never // settled by ReconcileMailGate whatever the age. const claimMailReconcileMinHeld = 10 * time.Minute // claimMailIdentityReader is the reconciler's typed SES readback: a READ // through the console's mail provisioner (IdentityStatus is unguarded). type claimMailIdentityReader struct{ p mail.Provisioner } var _ hostops.MailIdentityReader = claimMailIdentityReader{} func (r claimMailIdentityReader) MailIdentityState(ctx context.Context, region, domain string) (json.RawMessage, error) { if r.p == nil { return nil, hostops.ErrMailUnproven } st, err := r.p.IdentityStatus(ctx, region, domain) if err != nil { return nil, err } return json.Marshal(st) } // ReconcileClaimMailGate is the operator/sweep entry for a retained gate // (review P1-2): fence, read back, settle, free; then end the member // app-mutation operations through RecoverMutation (gate no longer held). // ErrMailUnproven names surfaces with no readback; the gate stays held. func (s *Server) ReconcileClaimMailGate(ctx context.Context, gateID string) (hostops.MailGateReconciliation, error) { if !s.cfg.HostClaim || s.cfg.ClaimAuthority == nil || s.cfg.ClaimAuthority.Control == nil { return hostops.MailGateReconciliation{}, hostops.ErrContract } w := s.cfg.ClaimAuthority.Control rec, err := w.ReconcileMailGate(ctx, gateID, claimMailReconcileMinHeld, claimMailIdentityReader{p: s.cfg.MailProvisioner}) if err != nil { return rec, err } var errs []error for _, m := range rec.Members { if m.Operation != m.Effect { continue // a purge member: the purge op continues on its own } if err := w.RecoverMutation(ctx, m.Account, m.App, m.AppID, m.Operation); err != nil && !errors.Is(err, hostops.ErrPending) { errs = append(errs, err) } } return rec, errors.Join(errs...) } type refusingStore struct{ err error } func (r refusingStore) Get(context.Context, string) ([]byte, string, error) { return nil, "", r.err } func (r refusingStore) Put(context.Context, string, []byte, string) (string, error) { return "", r.err } func (r refusingStore) List(context.Context, string) ([]string, error) { return nil, r.err } func (r refusingStore) ListDirs(context.Context, string) ([]string, error) { return nil, r.err } func (r refusingStore) Delete(context.Context, string) error { return r.err }