carlos / platform Public

console: wire tenant-email MailProvisioner and DNSWriter

Constructs a real AWS SES/IAM Provisioner when CARLOS_MAIL_REGION is
set, and a Route53 DNS Writer when CARLOS_MAIL_PLATFORM_SUFFIX is
additionally set — the last code step before the tenant-email feature
can be exercised against real AWS. With neither set (every deployment
today), both fields stay nil and no AWS call happens at boot, exactly
as before.

mailBackends is the new seam: each half constructs independently on
its own coordinate, and a construction failure (bad region, missing
credentials, an AWS outage) is logged loudly and leaves the field nil
rather than crashing console boot — deliberately unlike storeBackend's
log.Fatal, since a store's coordinates get baked into a real bucket
estate on first use while mail's failure mode is just "email stays
unavailable," which mailProvisionerGate already reports as a clean
404.

Updated the two doc comments that described the feature as
deliberately unwired (main.go's package comment and mailDeployment's),
plus mailBackends' own new comment explaining the fatal/non-fatal
asymmetry with storeBackend.

Tests: mailbackends_test.go exercises mailBackends directly via
injected constructor seams (no live AWS calls) — no-env nil-both,
provisioner/writer construction failure not crashing, and writer
staying nil without a platform suffix even when a provisioner is
configured.
Paul Campbell pushed by paul@keymail.dev 3fe64b74cc484f75d8669c1732e6f71fece96370 parent bbaf09c
2 files changed, +244 −26
  • cmd/console/mailbackends_test.go +128 −0
  • cmd/console/main.go +116 −26
diff --git a/cmd/console/mailbackends_test.go b/cmd/console/mailbackends_test.go
new file mode 100644
index 000000000..dd890792a
--- /dev/null
+++ b/cmd/console/mailbackends_test.go
@@ -0,0 +1,128 @@
+package main
+
+import (
+ "context"
+ "errors"
+ "testing"
+
+ "github.com/carlosframework/platform/internal/dnsrec"
+ "github.com/carlosframework/platform/internal/mail"
+)
+
+// failIfCalled builds a newProvisioner/newWriter seam that fails the test if
+// it is ever invoked — used to prove mailBackends does not make a
+// construction call (and therefore no network I/O) when the coordinate that
+// would justify it is unset.
+
+func failProvisionerIfCalled(t *testing.T) func(context.Context, string) (mail.Provisioner, error) {
+ t.Helper()
+ return func(ctx context.Context, region string) (mail.Provisioner, error) {
+ t.Fatalf("newProvisioner called with region %q — mailBackends must not construct a Provisioner when Region is unset", region)
+ return nil, nil
+ }
+}
+
+func failWriterIfCalled(t *testing.T) func(context.Context) (dnsrec.Writer, error) {
+ t.Helper()
+ return func(ctx context.Context) (dnsrec.Writer, error) {
+ t.Fatal("newWriter called — mailBackends must not construct a DNSWriter when PlatformSuffix is unset")
+ return nil, nil
+ }
+}
+
+// TestMailBackendsNoEnvNilBoth is property 1 from the wiring brief: with no
+// mail coordinates configured (the shape of every deployment before this
+// change, and every deployment today that has not set CARLOS_MAIL_REGION /
+// CARLOS_MAIL_PLATFORM_SUFFIX), both fields come back nil and NEITHER
+// constructor is invoked — no AWS call happens at boot.
+func TestMailBackendsNoEnvNilBoth(t *testing.T) {
+ provisioner, writer := mailBackends(mail.Deployment{}, failProvisionerIfCalled(t), failWriterIfCalled(t))
+ if provisioner != nil {
+ t.Errorf("provisioner = %v, want nil", provisioner)
+ }
+ if writer != nil {
+ t.Errorf("writer = %v, want nil", writer)
+ }
+}
+
+// TestMailBackendsProvisionerFailureDoesNotCrash is property 2: a region IS
+// configured, but construction fails (bad region, no AWS credentials, an
+// outage). Boot must not crash — mailBackends must return normally with a
+// nil provisioner rather than calling log.Fatal (which would os.Exit this
+// very test process).
+func TestMailBackendsProvisionerFailureDoesNotCrash(t *testing.T) {
+ dep := mail.Deployment{Region: "eu-west-1"}
+ failer := func(ctx context.Context, region string) (mail.Provisioner, error) {
+ return nil, errors.New("boom: no AWS credentials on this box")
+ }
+ provisioner, writer := mailBackends(dep, failer, failWriterIfCalled(t))
+ if provisioner != nil {
+ t.Errorf("provisioner = %v, want nil after a construction failure", provisioner)
+ }
+ if writer != nil {
+ t.Errorf("writer = %v, want nil — PlatformSuffix was unset regardless of the provisioner outcome", writer)
+ }
+ // Reaching this line at all is part of what is under test: log.Fatal
+ // inside mailBackends would have os.Exit'd the test binary before this
+ // assertion ever ran.
+}
+
+// TestMailBackendsWriterFailureDoesNotCrash is the DNS-writer half of
+// property 2: a platform suffix is configured, but Route53 writer
+// construction fails. Boot must not crash — nil writer, no exit.
+func TestMailBackendsWriterFailureDoesNotCrash(t *testing.T) {
+ dep := mail.Deployment{PlatformSuffix: "oncarlos.com"}
+ failer := func(ctx context.Context) (dnsrec.Writer, error) {
+ return nil, errors.New("boom: route53 unreachable")
+ }
+ provisioner, writer := mailBackends(dep, failProvisionerIfCalled(t), failer)
+ if provisioner != nil {
+ t.Errorf("provisioner = %v, want nil — Region was unset regardless of the writer outcome", provisioner)
+ }
+ if writer != nil {
+ t.Errorf("writer = %v, want nil after a construction failure", writer)
+ }
+}
+
+// TestMailBackendsWriterNilWithoutSuffix is property 3: a region IS
+// configured (so the provisioner constructs), but PlatformSuffix is not —
+// the writer must stay nil, and newWriter must not even be called, whether
+// or not the provisioner side succeeds.
+func TestMailBackendsWriterNilWithoutSuffix(t *testing.T) {
+ dep := mail.Deployment{Region: "eu-west-1"}
+ newProvisioner := func(ctx context.Context, region string) (mail.Provisioner, error) {
+ return mail.NewMemProvisioner("123456789012"), nil
+ }
+ provisioner, writer := mailBackends(dep, newProvisioner, failWriterIfCalled(t))
+ if provisioner == nil {
+ t.Error("provisioner = nil, want the constructed MemProvisioner — Region was set")
+ }
+ if writer != nil {
+ t.Errorf("writer = %v, want nil — PlatformSuffix was unset", writer)
+ }
+}
+
+// TestMailBackendsBothConfiguredBothConstructed is the positive path: both
+// coordinates set, both constructors succeed, both fields come back
+// populated with exactly what the constructor returned.
+func TestMailBackendsBothConfiguredBothConstructed(t *testing.T) {
+ dep := mail.Deployment{Region: "eu-west-1", PlatformSuffix: "oncarlos.com"}
+ wantP := mail.NewMemProvisioner("123456789012")
+ wantW := dnsrec.NewMemWriter()
+ newProvisioner := func(ctx context.Context, region string) (mail.Provisioner, error) {
+ if region != "eu-west-1" {
+ t.Errorf("newProvisioner region = %q, want eu-west-1", region)
+ }
+ return wantP, nil
+ }
+ newWriter := func(ctx context.Context) (dnsrec.Writer, error) {
+ return wantW, nil
+ }
+ provisioner, writer := mailBackends(dep, newProvisioner, newWriter)
+ if provisioner != mail.Provisioner(wantP) {
+ t.Errorf("provisioner = %v, want the constructed MemProvisioner", provisioner)
+ }
+ if writer != dnsrec.Writer(wantW) {
+ t.Errorf("writer = %v, want the constructed MemWriter", writer)
+ }
+}
diff --git a/cmd/console/main.go b/cmd/console/main.go
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