| index 169e3f604..4d08020e9 100644 |
| --- a/cmd/console/main.go |
| +++ b/cmd/console/main.go |
| @@ -52,11 +52,18 @@ |
| // addresses; narrows the fleet-operator bit further for the pause/cap |
| // door only — see mailOperators' doc comment), CARLOS_MAIL_REGION, |
| // CARLOS_MAIL_PLATFORM_SUFFIX, CARLOS_MAIL_DMARC_RUA and |
| -// CARLOS_MAIL_SNS_TOPIC_ARN. All five are read regardless of whether a |
| -// mail Provisioner is wired (none is, yet — see mailDeployment's doc |
| -// comment): every email door 404s until that infra work lands, same |
| +// CARLOS_MAIL_SNS_TOPIC_ARN. All five are read regardless of whether a mail |
| +// Provisioner is wired. CARLOS_MAIL_REGION set turns it on: mailBackends |
| +// constructs a real AWS SES/IAM Provisioner, and every email door stops |
| +// 404ing (mailProvisionerGate); unset leaves the Provisioner nil, same |
| // "config costs nothing to hold ahead of the backend" reasoning as |
| -// CARLOS_STORE_OPERATORS above. |
| +// CARLOS_STORE_OPERATORS above. CARLOS_MAIL_PLATFORM_SUFFIX set additionally |
| +// turns on a Route53 DNS Writer, so CARLOS publishes DNS for domains under |
| +// that suffix itself rather than handing the member records to publish by |
| +// hand. Unlike object storage, a construction failure for either (bad |
| +// region, no AWS credentials, an AWS outage) is logged and leaves the |
| +// field nil rather than stopping the console from booting — see |
| +// mailBackends' doc comment. |
| // |
| // grant/revoke/owners need only CONSOLE_DB. |
| package main |
| @@ -75,6 +82,7 @@ import ( |
| |
| "github.com/carlosframework/platform/internal/appconfig" |
| "github.com/carlosframework/platform/internal/console" |
| + "github.com/carlosframework/platform/internal/dnsrec" |
| "github.com/carlosframework/platform/internal/mail" |
| "github.com/carlosframework/platform/internal/objstore" |
| "github.com/carlosframework/platform/internal/provision" |
| @@ -248,6 +256,8 @@ func serve() { |
| } |
| st := openConsoleDB() |
| storeProvisioner, storeDeployment := storeBackend() |
| + mailDep := mailDeployment() |
| + mailProvisioner, dnsWriter := mailBackends(mailDep, mail.NewAWSProvisioner, dnsrec.NewRoute53Writer) |
| srv, err := console.NewServer(console.Config{ |
| Origin: origin, |
| Keymail: envOr("CONSOLE_KEYMAIL", "https://keymail.dev"), |
| @@ -269,18 +279,28 @@ func serve() { |
| // Deliberately independent of PLATFORM_APPS_DOMAIN (#167's blind |
| // spot), so it is read raw here rather than folded into appsDomain(). |
| DelegationZone: os.Getenv("CARLOS_ACME_DELEGATION_ZONE"), |
| - // MailDeployment/MailOperators (tenant-email task 17, review round |
| - // 1 I4): MailProvisioner and DNSWriter are NOT wired here yet — a |
| - // real AWS SES/IAM Provisioner and DNS writer are separate infra |
| - // work this command does not yet do, so every email door still |
| - // 404s in this binary (mailProvisionerGate) regardless of the two |
| - // fields below. Wiring them now anyway is what lets an operator |
| - // set CARLOS_MAIL_OPERATORS ahead of that work landing, same as |
| - // storeOperators' own reasoning, and means the pause door's |
| - // authority list and the mail deployment coordinates do not become |
| - // a SECOND thing to remember to wire the day the Provisioner does. |
| - MailDeployment: mailDeployment(), |
| - MailOperators: mailOperators(), |
| + // MailDeployment/MailOperators/MailProvisioner/DNSWriter |
| + // (tenant-email task 17): mailBackends above constructs the last |
| + // two, and only when the corresponding coordinate is configured — |
| + // CARLOS_MAIL_REGION for the SES/IAM Provisioner, |
| + // CARLOS_MAIL_PLATFORM_SUFFIX for the Route53 Writer. An unset |
| + // deployment (every deployment as of this change) gets both nil, |
| + // exactly as before this wiring: no AWS call at boot, every email |
| + // door still 404s (mailProvisionerGate). Once a region is set, |
| + // mailProvisionerGate opens and the doors route through a real SES |
| + // estate; a construction failure there (bad region, missing |
| + // credentials, an AWS outage) is logged loudly and leaves the |
| + // field nil rather than failing console boot — see mailBackends' |
| + // own doc comment for why that direction, unlike storeBackend's, |
| + // is deliberate. A nil DNSWriter with a platform suffix set is |
| + // also a supported, non-degraded shape on its own: platform email |
| + // domains emit records for the member to publish instead of |
| + // CARLOS publishing them (mailDeploymentFor's doc comment in |
| + // internal/console/apimail.go). |
| + MailDeployment: mailDep, |
| + MailOperators: mailOperators(), |
| + MailProvisioner: mailProvisioner, |
| + DNSWriter: dnsWriter, |
| }, st) |
| if err != nil { |
| log.Fatal(err) |
| @@ -509,16 +529,23 @@ func mailOperators() []string { |
| // under would repeat the exact class of bug #167 already cost this |
| // codebase once, just for a new pair of variables. |
| // |
| -// NOTHING HERE IS FATAL, unlike storeBackend's coordinates. A store |
| -// deployment booted with the wrong region stamps that region into bucket |
| -// names and IAM policies it then actually creates — a real, misplaced |
| -// estate. A mail Deployment with a blank region or suffix cannot do that: |
| -// MailProvisioner is not wired by this command yet (see this function's |
| -// own doc comment history / the task-17 review), so |
| -// mailProvisionerGate's 404 already refuses every email door regardless |
| -// of what these env vars hold. Once a real Provisioner is wired, the |
| -// coordinates below become load-bearing the same way storeBackend's are — |
| -// that wiring should promote the same fields to fatal-if-missing then. |
| +// NOTHING HERE IS FATAL, unlike storeBackend's coordinates, and — unlike |
| +// the reasoning that used to sit in this comment — that stays true even |
| +// now that mailBackends actually constructs a Provisioner and DNS writer |
| +// from Region and PlatformSuffix. A store deployment booted with the wrong |
| +// region stamps that region into bucket names and IAM policies it then |
| +// actually creates — a real, misplaced estate the operator is stuck with. |
| +// A blank or wrong mail Region/PlatformSuffix cannot do that: blank simply |
| +// leaves mailBackends' constructors uncalled (MailProvisioner/DNSWriter |
| +// stay nil, mailProvisionerGate's 404 covers the rest, same as a |
| +// deployment with no mail estate at all), and a WRONG-but-set region fails |
| +// the SES/STS call inside NewAWSProvisioner instead of silently creating |
| +// anything — mailBackends logs that failure and leaves the field nil |
| +// rather than crashing console boot (unlike storeBackend's |
| +// log.Fatal — see mailBackends' own doc comment for why mail chose the |
| +// other direction). Garbage still costs an operator a debugging session, |
| +// just never an AWS estate in the wrong place, which is the asymmetry that |
| +// keeps these fields non-fatal where storeBackend's are not. |
| func mailDeployment() mail.Deployment { |
| return mail.Deployment{ |
| Region: strings.TrimSpace(os.Getenv("CARLOS_MAIL_REGION")), |
| @@ -528,6 +555,69 @@ func mailDeployment() mail.Deployment { |
| } |
| } |
| |
| +// mailBackends constructs the tenant-email Provisioner and DNS Writer from |
| +// dep's coordinates — the wiring storeBackend has had since issue #55, now |
| +// extended to mail (task 17, "the last code step before the feature can be |
| +// exercised against real AWS"). newProvisioner and newWriter are seams for |
| +// tests: production always passes mail.NewAWSProvisioner and |
| +// dnsrec.NewRoute53Writer, a test passes a fake that never touches the |
| +// network. |
| +// |
| +// Each half switches on ITS OWN coordinate, independently: |
| +// |
| +// - dep.Region == "" means no SES estate this deployment owns — leave |
| +// MailProvisioner nil, call newProvisioner not at all. Every email door |
| +// keeps 404ing via mailProvisionerGate, exactly as before this function |
| +// existed. |
| +// - dep.PlatformSuffix == "" means no zone CARLOS owns to publish into — |
| +// leave DNSWriter nil, call newWriter not at all. Platform domains then |
| +// emit records for the member to publish themselves instead of CARLOS |
| +// publishing them, which is a supported shape |
| +// (mailDeploymentFor's doc comment in internal/console/apimail.go), not |
| +// a degraded one — so a deployment can have a Provisioner and no |
| +// Writer on purpose. |
| +// |
| +// A CONSTRUCTION FAILURE IS NOT FATAL, unlike storeBackend's log.Fatal on |
| +// the same kind of error. Both constructors do network I/O (SES/STS, |
| +// Route53) and can fail for reasons that have nothing to do with whether |
| +// the console itself is healthy: no AWS credentials on this box, a bad |
| +// region, an AWS outage. storeBackend's coordinates get baked into bucket |
| +// names and IAM policies the moment they are used, so a console that boots |
| +// with the wrong ones would go on to create a real, misplaced estate — |
| +// log.Fatal there stops that before it happens. Mail has no equivalent: a |
| +// failed construction here simply means email stays unavailable, which |
| +// mailProvisionerGate already reports as a clean 404 to every caller. A |
| +// console that refuses to start at all because SES was briefly |
| +// unreachable would take down every OTHER door — apps, domains, routes, |
| +// billing — over one feature's dependency, which is a far worse outage |
| +// than email alone being down. So: log loudly, naming what failed and the |
| +// consequence, leave the field nil, and keep booting. |
| +func mailBackends( |
| + dep mail.Deployment, |
| + newProvisioner func(context.Context, string) (mail.Provisioner, error), |
| + newWriter func(context.Context) (dnsrec.Writer, error), |
| +) (mail.Provisioner, dnsrec.Writer) { |
| + var provisioner mail.Provisioner |
| + if dep.Region != "" { |
| + p, err := newProvisioner(context.Background(), dep.Region) |
| + if err != nil { |
| + log.Printf("console: email doors will refuse — SES provisioner unavailable (region %s): %v", dep.Region, err) |
| + } else { |
| + provisioner = p |
| + } |
| + } |
| + var writer dnsrec.Writer |
| + if dep.PlatformSuffix != "" { |
| + w, err := newWriter(context.Background()) |
| + if err != nil { |
| + log.Printf("console: platform email domains will emit records for the member instead of CARLOS publishing them — Route53 DNS writer unavailable: %v", err) |
| + } else { |
| + writer = w |
| + } |
| + } |
| + return provisioner, writer |
| +} |
| + |
| // edgeIPs parses PLATFORM_EDGE_IPS (comma-separated) — the SAME variable the |
| // agent's domains reconcile loop reads, so the console's point-your-DNS-here |
| // guidance and the agent's DNS health checks can never name different |