infra+console: steering IAM surface + custom-domain guidance
The tofu half: a steering_zone_ids list var (region-host, same bare-Z-id validation as route53_zone_ids) and the inline policy it gates. The action list is exactly what internal/steering's route53API interface calls — ChangeResourceRecordSets (conditioned to A + UPSERT/DELETE, which is all converge ever writes) and ListResourceRecordSets on the named zones, ListHealthChecks/CreateHealthCheck globally, and DeleteHealthCheck/ChangeTagsForResource/ListTagsForResources on the healthcheck ARN space. Two review carries are in here: the action is ListTagsForResources (PLURAL — the singular grants the batched call nothing, and every pass would abort AccessDenied), and UpdateHealthCheck is not granted because nothing calls it. Route53 supports no tag conditions on health checks; the comment says so plainly rather than implying the carlos-steering tag is an IAM bound when it is only a code one. The console half: custom-domain guidance gains the CNAME-to-edge.<apps-domain> option beside the A record, on a deployment that steers. Apexes keep A-records with the spec's honesty rule stated in the sentence (round-robin, no latency bias); apex-ness comes from the public suffix list, so example.co.uk is an apex and app.example.com is not. Threaded as a DNSTargets pair (edge IPs + steering hostname) so the app page, Settings and the /domains API cannot drift. Inert everywhere today: the hostname is derived from CARLOS_STEERING_ZONE_ID — the same read, with the same validation, the converger performs — so no deployment currently sets it, and every guidance string stays byte-for-byte what it was. The module change is `tofu validate` + `tofu fmt` clean locally; there is no test harness for tofu.
10 files changed,
+311
−36
cmd/console/main.go+25 −0infra/modules/README.md+2 −0infra/modules/region-host/main.tf+73 −0infra/modules/region-host/variables.tf+10 −0internal/console/apidomains.go+1 −1internal/console/appsettings.go+1 −1internal/console/server.go+41 −2internal/console/server_test.go+63 −4internal/console/view.go+86 −19internal/console/view_test.go+9 −9
diff --git a/cmd/console/main.go b/cmd/console/main.go| index e10202bca..6ffc45649 100644 |
| --- a/cmd/console/main.go |
| +++ b/cmd/console/main.go |
| @@ -67,6 +67,7 @@ import ( |
| "github.com/carlosframework/platform/internal/objstore" |
| "github.com/carlosframework/platform/internal/provision" |
| "github.com/carlosframework/platform/internal/release" |
| + "github.com/carlosframework/platform/internal/steering" |
| ) |
| func main() { |
| @@ -246,6 +247,7 @@ func serve() { |
| EdgeIPs: edgeIPs(), |
| SecretsPub: secretsPub(), |
| AppsDomain: appsDomain(), |
| + SteeringHost: steeringHost(), |
| RedirectHosts: redirectHosts(), |
| DenyDomains: denyDomains(), |
| StoreProvisioner: storeProvisioner, |
| @@ -346,6 +348,29 @@ func secretsPub() string { |
| // DNS record would produce records the reconcile pass skips — and protects |
| // — forever. Unset is a legal deployment: the door answers 503 naming the |
| // store-mode operator act. |
| +// steeringHost derives this deployment's DNS-steering hostname — |
| +// edge.<apps-domain>, the name the steering converger keeps one latency A |
| +// record per armed edge on (multi-region ingress spec, Part 4) — for the |
| +// console's custom-domain guidance. |
| +// |
| +// The armed test is steering.SteeringZoneID(): the SAME read, with the same |
| +// validation, that the converger itself performs on the box |
| +// (internal/host/steering_loop.go). That is deliberate — it is what stops |
| +// this console advertising a CNAME target no pass maintains. Unset (every |
| +// deployment today) or malformed leaves this "" and every guidance string |
| +// stays byte-for-byte the A-record sentence it has always been. |
| +func steeringHost() string { |
| + if steering.SteeringZoneID() == "" { |
| + return "" |
| + } |
| + d := appsDomain() |
| + if d == "" { |
| + log.Print("console: CARLOS_STEERING_ZONE_ID is set but PLATFORM_APPS_DOMAIN is not — no steering hostname to advertise; custom-domain guidance keeps the A-record form") |
| + return "" |
| + } |
| + return "edge." + d |
| +} |
| + |
| func appsDomain() string { |
| d := strings.ToLower(strings.TrimSpace(os.Getenv("PLATFORM_APPS_DOMAIN"))) |
| if d == "" { |
diff --git a/infra/modules/README.md b/infra/modules/README.md| index 8d43b6a22..b8de86a3a 100644 |
| --- a/infra/modules/README.md |
| +++ b/infra/modules/README.md |
| @@ -65,6 +65,8 @@ bucket, so a wrong-region wake fails loudly at the IAM layer. |
| | `deployment_bucket_arn` | ARN of the deployment bucket. | |
| | `data_bucket_arn` | ARN of THIS region's data bucket. | |
| | `ssm_parameter_arn` | SSM parameter ARN pattern the host may read, e.g. `arn:aws:ssm:<region>:<account>:parameter/carlos/*`. | |
| +| `route53_zone_ids` | Hosted zones the box may publish ACME DNS-01 challenge TXTs into (wildcard certs). Bare `Z...` ids; empty = no Route53 access. | |
| +| `steering_zone_ids` | Hosted zones the DNS-steering converger may write latency A records into, plus the health checks that withdraw a dead edge. Bare `Z...` ids; empty = no steering grant (the default — steering also needs `CARLOS_STEERING_ZONE_ID` in the box env). | |
| | `ami_name_pattern` | AMI name filter (default `debian-13-arm64-*`). | |
| | `ami_owner` | AMI owner account (default `136693071363`, Debian). | |
| | `enable_auto_recovery` | Arm the EC2 auto-recovery alarm (HA ladder rung 0) — a system-status-check failure recovers the instance onto healthy hardware, same instance id, EIP and volumes. Default `true`; set `false` only where an external supervisor owns recovery. | |
diff --git a/infra/modules/region-host/main.tf b/infra/modules/region-host/main.tf| index 1f5187efe..3fa21d42a 100644 |
| --- a/infra/modules/region-host/main.tf |
| +++ b/infra/modules/region-host/main.tf |
| @@ -143,6 +143,79 @@ resource "aws_iam_role_policy" "host_route53" { |
| }) |
| } |
| +# DNS steering (internal/steering/route53.go, run as a housekeeper duty on |
| +# whichever box holds the lease): latency A records per armed edge, each bound |
| +# to a Route53 health check on that edge's EIP. Every action here is one the |
| +# converger's route53API interface actually calls — nothing is granted "for |
| +# later", and nothing it calls is missing (a missing action is not a partial |
| +# feature: the whole pass aborts AccessDenied and no record ever converges). |
| +resource "aws_iam_role_policy" "host_steering" { |
| + count = length(var.steering_zone_ids) > 0 ? 1 : 0 |
| + name = "${var.iam_name}-steering" |
| + role = aws_iam_role.host.id |
| + policy = jsonencode({ |
| + Version = "2012-10-17" |
| + Statement = [ |
| + { |
| + # The write. Conditioned as tightly as the converger's own behaviour |
| + # allows: it only ever UPSERTs or DELETEs A records (desiredRecord is a |
| + # latency A set; converge builds no other type or action), so an apex |
| + # NS/SOA/MX in a steering zone is out of this role's reach by IAM and |
| + # not merely by code. |
| + Effect = "Allow" |
| + Action = ["route53:ChangeResourceRecordSets"] |
| + Resource = [for z in var.steering_zone_ids : "arn:aws:route53:::hostedzone/${z}"] |
| + Condition = { |
| + "ForAllValues:StringEquals" = { |
| + "route53:ChangeResourceRecordSetsRecordTypes" = ["A"] |
| + "route53:ChangeResourceRecordSetsActions" = ["UPSERT", "DELETE"] |
| + } |
| + } |
| + }, |
| + { |
| + # The read, split out because ListResourceRecordSets supports none of |
| + # the condition keys above — folded into one statement they would be |
| + # decoration on this action, which is worse than saying so here. |
| + Effect = "Allow" |
| + Action = ["route53:ListResourceRecordSets"] |
| + Resource = [for z in var.steering_zone_ids : "arn:aws:route53:::hostedzone/${z}"] |
| + }, |
| + { |
| + # Health checks are NOT zone-scoped and Route53 supports NO |
| + # tag-based condition keys on them (the service authorization |
| + # reference lists no condition keys for the healthcheck resource |
| + # type, and CreateHealthCheck takes no tags at all — the converger |
| + # tags in a second call, ChangeTagsForResource). Said plainly rather |
| + # than dressed up: within this account, this role may create, tag, |
| + # read the tags of, and delete ANY Route53 health check. The |
| + # carlos-steering=<zone-id> tag is the converger's OWN ownership |
| + # rule (ownedHealthChecks / gcHealthChecks refuse to delete anything |
| + # else); it is a code bound, not an IAM one, and it cannot be made |
| + # one here. |
| + # |
| + # ListHealthChecks and CreateHealthCheck have no resource ARN to name |
| + # at all, hence "*" for the pair; the per-check operations are held to |
| + # the healthcheck ARN space. |
| + Effect = "Allow" |
| + Action = ["route53:ListHealthChecks", "route53:CreateHealthCheck"] |
| + Resource = "*" |
| + }, |
| + { |
| + Effect = "Allow" |
| + Action = [ |
| + "route53:DeleteHealthCheck", |
| + "route53:ChangeTagsForResource", |
| + # PLURAL — the batched form (ListTagsForResources) is what the |
| + # converger calls, ten checks at a time. The singular |
| + # ListTagsForResource grants it nothing. |
| + "route53:ListTagsForResources", |
| + ] |
| + Resource = "arn:aws:route53:::healthcheck/*" |
| + }, |
| + ] |
| + }) |
| +} |
| + |
| # Cost Explorer read for the economics dashboard's daily bill pull |
| # (internal/console/cepull.go). Off by default: only the box that runs the |
| # console needs it, and ce:GetCostAndUsage has no resource-level scoping — |
diff --git a/infra/modules/region-host/variables.tf b/infra/modules/region-host/variables.tf| index 23e1156e1..3bfd76587 100644 |
| --- a/infra/modules/region-host/variables.tf |
| +++ b/infra/modules/region-host/variables.tf |
| @@ -77,6 +77,16 @@ variable "route53_zone_ids" { |
| } |
| } |
| +variable "steering_zone_ids" { |
| + description = "Hosted-zone IDs the DNS-steering converger (internal/steering, a housekeeper duty) may write latency A records into, and whose health checks it may manage. Empty = no steering grant, which is every deployment that leaves CARLOS_STEERING_ZONE_ID unset. Normally ONE id: the zone holding the apps domain and any steered custom domains." |
| + type = list(string) |
| + default = [] |
| + validation { |
| + condition = alltrue([for z in var.steering_zone_ids : can(regex("^Z[A-Z0-9]+$", z))]) |
| + error_message = "steering_zone_ids entries must be bare hosted-zone ids (Z...), not /hostedzone/-prefixed paths or wildcards." |
| + } |
| +} |
| + |
| variable "enable_auto_recovery" { |
| description = "Arm an EC2 auto-recovery alarm on the host (HA ladder rung 0)." |
| type = bool |
diff --git a/internal/console/apidomains.go b/internal/console/apidomains.go| index 7d06eedcf..0b1ee0fe4 100644 |
| --- a/internal/console/apidomains.go |
| +++ b/internal/console/apidomains.go |
| @@ -593,7 +593,7 @@ func (s *Server) handleAPIDomainList(w http.ResponseWriter, r *http.Request) { |
| item.CertExpiresAt = reading.CertExpiresAt |
| } |
| if item.DNSStatus != "pointing" { |
| - item.Guidance = domainGuidance(c.Host, s.cfg.EdgeIPs) |
| + item.Guidance = domainGuidance(c.Host, s.dnsTargets()) |
| } |
| // Each stamp is a live, sequential, uncancellable-per-call DNS round |
| // trip on the request path (lookupCNAME) — capped at |
diff --git a/internal/console/appsettings.go b/internal/console/appsettings.go| index 35d55cb6b..c4cb18b2d 100644 |
| --- a/internal/console/appsettings.go |
| +++ b/internal/console/appsettings.go |
| @@ -256,7 +256,7 @@ func (s *Server) settingsDomains(ctx context.Context, acct *Account, app string, |
| out[i].Claimed = true |
| continue |
| } |
| - out = append(out, claimDomainView(c.Host, s.cfg.EdgeIPs)) |
| + out = append(out, claimDomainView(c.Host, s.dnsTargets())) |
| } |
| return out |
| } |
diff --git a/internal/console/server.go b/internal/console/server.go| index fd581946d..92d3a132c 100644 |
| --- a/internal/console/server.go |
| +++ b/internal/console/server.go |
| @@ -520,6 +520,16 @@ type Config struct { |
| // guessing. Normalized (lowercase, trimmed) into Server.delegationZone at |
| // construction, same as RedirectHosts is normalized into `redirects`. |
| DelegationZone string |
| + // SteeringHost is the deployment's DNS-steering hostname — the |
| + // edge.<apps-domain> name the steering converger keeps one latency A |
| + // record per armed edge on (multi-region ingress spec, Part 4). Set only |
| + // where the deployment actually steers: cmd/console derives it from |
| + // CARLOS_STEERING_ZONE_ID (the same variable the converger reads, so the |
| + // console cannot advertise a hostname no pass maintains) plus |
| + // PLATFORM_APPS_DOMAIN. "" — every deployment today — leaves custom-domain |
| + // guidance byte-for-byte what it has always been. Normalized (lowercase, |
| + // trimmed, no trailing dot) into Server.steeringHost at construction. |
| + SteeringHost string |
| } |
| // Server is the console's http.Handler. |
| @@ -606,6 +616,34 @@ type Server struct { |
| // post-construction. |
| delegationZone string |
| lookupCNAME func(ctx context.Context, host string) (string, error) |
| + // steeringHost is cfg.SteeringHost, normalized once here — see |
| + // dnsTargets, the one reader. |
| + steeringHost string |
| +} |
| + |
| +// dnsTargets is what every custom-domain guidance string in this console is |
| +// built from (view.go's domainGuidance): the deployment's edge addresses and |
| +// its steering hostname, read from ONE place so the app page, the Settings |
| +// page and the /domains API can never disagree about where a member should |
| +// point DNS. |
| +func (s *Server) dnsTargets() DNSTargets { |
| + return DNSTargets{EdgeIPs: s.cfg.EdgeIPs, SteeringHost: s.steeringHost} |
| +} |
| + |
| +// normalizeSteeringHost lowercases, trims and shape-validates the steering |
| +// hostname (Config.SteeringHost). Same posture as normalizeDelegationZone |
| +// below and for the same reason: a value that is not a legal hostname |
| +// degrades to "" — guidance keeps the A-record form, which always works — |
| +// rather than being pasted verbatim into a sentence every member of this |
| +// deployment is shown. Loud, because a deployment that armed steering and |
| +// typo'd the variable would otherwise mislead silently. |
| +func normalizeSteeringHost(raw string) string { |
| + h := strings.ToLower(strings.TrimSpace(strings.TrimSuffix(strings.TrimSpace(raw), "."))) |
| + if h != "" && !provision.ValidHostShape(h) { |
| + log.Printf("console: steering hostname %q is not a valid domain — custom-domain guidance keeps the A-record form", raw) |
| + return "" |
| + } |
| + return h |
| } |
| // normalizeDelegationZone lowercases, trims, and shape-validates raw (from |
| @@ -679,6 +717,7 @@ func NewServer(cfg Config, st *Store) (*Server, error) { |
| // goroutines and buffers without bound. |
| shipSem: make(chan struct{}, 2), |
| delegationZone: normalizeDelegationZone(cfg.DelegationZone), |
| + steeringHost: normalizeSteeringHost(cfg.SteeringHost), |
| lookupCNAME: net.DefaultResolver.LookupCNAME, |
| } |
| if s.km != nil { |
| @@ -1922,7 +1961,7 @@ func (s *Server) handleAccountHome(w http.ResponseWriter, r *http.Request) { |
| return |
| } |
| if len(apps) > 0 { |
| - views, err := Assemble(s.cfg.Registry, s.cfg.Releases, s.cfg.ReleasePub, acct.Sqid, apps, s.cfg.EdgeIPs) |
| + views, err := Assemble(s.cfg.Registry, s.cfg.Releases, s.cfg.ReleasePub, acct.Sqid, apps, s.dnsTargets()) |
| if err != nil { |
| log.Printf("console: assemble: %v", err) |
| s.renderMessage(w, r, http.StatusInternalServerError, "Error", "Could not read platform state. Please try again.") |
| @@ -2057,7 +2096,7 @@ func (s *Server) appPage(w http.ResponseWriter, r *http.Request, tab string) (pa |
| // bucket hiccup there is deliberately non-fatal, and this is where it gets |
| // another chance. Best-effort — a page view never fails on it. |
| s.ensureAccountManifest(r.Context(), acct) |
| - views, err := Assemble(s.cfg.Registry, s.cfg.Releases, s.cfg.ReleasePub, acct.Sqid, []string{app}, s.cfg.EdgeIPs) |
| + views, err := Assemble(s.cfg.Registry, s.cfg.Releases, s.cfg.ReleasePub, acct.Sqid, []string{app}, s.dnsTargets()) |
| if err != nil || len(views) != 1 { |
| log.Printf("console: assemble %s: %v", app, err) |
| s.renderMessage(w, r, http.StatusInternalServerError, "Error", "Could not read platform state. Please try again.") |
diff --git a/internal/console/server_test.go b/internal/console/server_test.go| index 3d57f1c67..8c2376a43 100644 |
| --- a/internal/console/server_test.go |
| +++ b/internal/console/server_test.go |
| @@ -1418,12 +1418,12 @@ func TestAppPageBackfillsAccountManifest(t *testing.T) { |
| // type from the platform flag, unknown DNS as "" (rendered em-dash), |
| // guidance ONLY for non-pointing custom domains, canary labeling preserved. |
| func TestDomainViewStates(t *testing.T) { |
| - edge := []string{"203.0.113.7", "198.51.100.9"} |
| + edge := DNSTargets{EdgeIPs: []string{"203.0.113.7", "198.51.100.9"}} |
| renew := time.Date(2026, 10, 12, 3, 0, 0, 0, time.UTC).Unix() |
| cases := []struct { |
| name string |
| rt registry.Route |
| - ips []string |
| + dns DNSTargets |
| want DomainView |
| }{ |
| {"platform pointing ready with cert", |
| @@ -1457,19 +1457,78 @@ func TestDomainViewStates(t *testing.T) { |
| DomainView{Domain: "rc.kass.bab.oncarlos.com", Type: "platform · canary/rc", DNS: "pointing", TLS: "ready"}}, |
| {"no known edge IPs: guidance still helps, without inventing an address", |
| registry.Route{Host: "kass.training", Status: "ready", DNSStatus: "elsewhere"}, |
| - nil, |
| + DNSTargets{}, |
| DomainView{Domain: "kass.training", Type: "custom", DNS: "elsewhere", TLS: "ready", |
| Guidance: "Point an A record for kass.training at this deployment's edge IP. Checks run every few minutes."}}, |
| } |
| for _, c := range cases { |
| t.Run(c.name, func(t *testing.T) { |
| - if got := domainView(c.rt, c.ips); got != c.want { |
| + if got := domainView(c.rt, c.dns); got != c.want { |
| t.Errorf("domainView:\n got %+v\n want %+v", got, c.want) |
| } |
| }) |
| } |
| } |
| +// TestDomainGuidanceSteering pins the custom-domain attach guidance across |
| +// the steering axis (multi-region ingress spec, Part 4): |
| +// |
| +// - no steering hostname (every deployment today): byte-for-byte the |
| +// sentence this console has always shown — the whole inertness claim; |
| +// - steering armed + a subdomain: the CNAME to edge.<apps-domain> is the |
| +// recommended form, with the A record still offered; |
| +// - steering armed + an APEX: no CNAME is offered at all (it is illegal |
| +// there) and the round-robin-without-latency-bias truth is stated — |
| +// the spec's honesty rule. Apex-ness comes from the public suffix list, |
| +// so a three-label apex (example.co.uk) is an apex and a three-label |
| +// subdomain (app.example.com) is not. |
| +func TestDomainGuidanceSteering(t *testing.T) { |
| + ips := []string{"203.0.113.7", "198.51.100.9"} |
| + steered := DNSTargets{EdgeIPs: ips, SteeringHost: "edge.oncarlos.com"} |
| + cases := []struct { |
| + name, host string |
| + dns DNSTargets |
| + want string |
| + }{ |
| + {"unsteered deployment is untouched", "kass.training", DNSTargets{EdgeIPs: ips}, |
| + "Point an A record for kass.training at 203.0.113.7, 198.51.100.9. Checks run every few minutes."}, |
| + {"unsteered, no edge IPs", "kass.training", DNSTargets{}, |
| + "Point an A record for kass.training at this deployment's edge IP. Checks run every few minutes."}, |
| + {"steered subdomain is offered the CNAME", "www.kass.training", steered, |
| + "Point a CNAME for www.kass.training at edge.oncarlos.com — visitors then reach the nearest healthy edge, and a failed edge is withdrawn automatically. An A record at 203.0.113.7, 198.51.100.9 works too, but round-robins without latency bias. Checks run every few minutes."}, |
| + {"steered apex keeps A records, honestly", "kass.training", steered, |
| + "Point an A record for kass.training at 203.0.113.7, 198.51.100.9. kass.training is an apex domain and cannot carry a CNAME, so its A records round-robin across the edges without latency bias. Checks run every few minutes."}, |
| + {"multi-label public suffix is still an apex", "kass.co.uk", steered, |
| + "Point an A record for kass.co.uk at 203.0.113.7, 198.51.100.9. kass.co.uk is an apex domain and cannot carry a CNAME, so its A records round-robin across the edges without latency bias. Checks run every few minutes."}, |
| + {"three labels under a plain TLD is a subdomain", "app.kass.co.uk", steered, |
| + "Point a CNAME for app.kass.co.uk at edge.oncarlos.com — visitors then reach the nearest healthy edge, and a failed edge is withdrawn automatically. An A record at 203.0.113.7, 198.51.100.9 works too, but round-robins without latency bias. Checks run every few minutes."}, |
| + } |
| + for _, c := range cases { |
| + t.Run(c.name, func(t *testing.T) { |
| + if got := domainGuidance(c.host, c.dns); got != c.want { |
| + t.Errorf("domainGuidance(%q):\n got %q\n want %q", c.host, got, c.want) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +// TestSteeringHostNormalization: the console never pastes an unusable |
| +// steering hostname into member-facing guidance — a malformed value degrades |
| +// to the A-record form rather than into the sentence. |
| +func TestSteeringHostNormalization(t *testing.T) { |
| + for raw, want := range map[string]string{ |
| + "": "", |
| + " Edge.OnCarlos.com ": "edge.oncarlos.com", |
| + "edge.oncarlos.com.": "edge.oncarlos.com", |
| + "*.oncarlos.com": "", |
| + "not a host": "", |
| + } { |
| + if got := normalizeSteeringHost(raw); got != want { |
| + t.Errorf("normalizeSteeringHost(%q) = %q, want %q", raw, got, want) |
| + } |
| + } |
| +} |
| + |
| // TestAppPageRendersDomains: the app page's Domains table renders from |
| // registry state — platform + custom rows, em-dash unknowns, and the |
| // guidance row beneath a non-pointing custom domain. |
diff --git a/internal/console/view.go b/internal/console/view.go| index 1bb2eedb2..2ed4c95d6 100644 |
| --- a/internal/console/view.go |
| +++ b/internal/console/view.go |
| @@ -17,6 +17,8 @@ import ( |
| "sync" |
| "time" |
| + "golang.org/x/net/publicsuffix" |
| + |
| "github.com/carlosframework/platform/internal/registry" |
| "github.com/carlosframework/platform/internal/release" |
| "github.com/carlosframework/platform/internal/status" |
| @@ -79,30 +81,94 @@ type DomainView struct { |
| Claimed bool |
| } |
| -// domainGuidance is the A-record instruction a custom host that is not |
| -// pointing at this deployment yet gets shown — shared by domainView (a |
| -// routed custom host) and claimDomainView (a claim with no route yet), so |
| +// DNSTargets is where this deployment tells a member to point a custom |
| +// domain. Two facts, threaded together because the guidance is one |
| +// sentence and needs both: the edge addresses an A record names, and — |
| +// only on a deployment that STEERS (multi-region ingress spec, Part 4) — |
| +// the steering hostname a non-apex host may CNAME to instead. |
| +type DNSTargets struct { |
| + // EdgeIPs is PLATFORM_EDGE_IPS, read at console start. nil is legal: |
| + // guidance then says "this deployment's edge IP" rather than inventing |
| + // an address. |
| + // |
| + // Once a deployment steers, this list must name EVERY armed edge's EIP, |
| + // not just the local box's: the DNS check that stamps "pointing" |
| + // compares a resolved address against it, and a steered name resolves |
| + // to whichever edge the resolver is nearest. |
| + EdgeIPs []string |
| + // SteeringHost is the deployment's steering hostname (edge.<apps-domain>, |
| + // itself latency-routed with one record per armed edge). "" — every |
| + // deployment that does not set CARLOS_STEERING_ZONE_ID, which is all of |
| + // them today — keeps the guidance byte-for-byte what it has always been. |
| + SteeringHost string |
| +} |
| + |
| +// domainGuidance is the point-your-DNS-here instruction a custom host that |
| +// is not pointing at this deployment yet gets shown — shared by domainView |
| +// (a routed custom host) and claimDomainView (a claim with no route yet), so |
| // the two rows never drift into different wording for the same instruction. |
| -func domainGuidance(host string, edgeIPs []string) string { |
| +// |
| +// With steering armed the CNAME is the RECOMMENDED form for a subdomain: |
| +// it inherits the steering hostname's latency records and its health |
| +// checks, so visitors reach the nearest healthy edge and a dead edge stops |
| +// being handed out within ~90s. An apex cannot carry a CNAME at all (the |
| +// spec's honesty rule, Part 4: "apexes cannot CNAME, so apex guidance |
| +// remains A-records-to-all-EIPs — round-robin without latency bias, |
| +// documented as such in attach guidance"), so an apex is told the truth |
| +// about what its A records do rather than being offered an impossible |
| +// option. Apex-ness is decided by the public suffix list, not by counting |
| +// labels: "example.co.uk" is an apex and "app.example.com" is not, and both |
| +// have three. |
| +func domainGuidance(host string, t DNSTargets) string { |
| target := "this deployment's edge IP" |
| - if len(edgeIPs) > 0 { |
| - target = strings.Join(edgeIPs, ", ") |
| + if len(t.EdgeIPs) > 0 { |
| + target = strings.Join(t.EdgeIPs, ", ") |
| + } |
| + aRecord := "Point an A record for " + host + " at " + target + "." |
| + switch { |
| + case t.SteeringHost == "": |
| + // No steering: exactly the sentence this deployment has always shown. |
| + case isApexDomain(host): |
| + aRecord += " " + host + " is an apex domain and cannot carry a CNAME, so its A records round-robin across the edges without latency bias." |
| + default: |
| + aRecord = "Point a CNAME for " + host + " at " + t.SteeringHost + |
| + " — visitors then reach the nearest healthy edge, and a failed edge is withdrawn automatically." + |
| + " An A record at " + target + " works too, but round-robins without latency bias." |
| + } |
| + return aRecord + " Checks run every few minutes." |
| +} |
| + |
| +// isApexDomain reports whether host is the registrable domain itself — the |
| +// one place a CNAME is illegal (RFC 1034: a CNAME cannot coexist with the |
| +// SOA and NS records an apex must carry). golang.org/x/net/publicsuffix is |
| +// already in this module's dependency set (the edge's IDNA path pulls |
| +// x/net), and the PSL is the only correct answer here: label counting calls |
| +// "example.co.uk" a subdomain and would hand an apex a CNAME it cannot use. |
| +// |
| +// An unknown suffix (a name whose TLD is not in the list — a test domain, |
| +// an internal name) is treated as an APEX: the conservative answer keeps |
| +// guidance to the A-record form, which always works, instead of proposing a |
| +// CNAME that might be refused by the member's registrar. |
| +func isApexDomain(host string) bool { |
| + h := strings.ToLower(strings.TrimSuffix(strings.TrimSpace(host), ".")) |
| + etld1, err := publicsuffix.EffectiveTLDPlusOne(h) |
| + if err != nil { |
| + return true |
| } |
| - return "Point an A record for " + host + " at " + target + ". Checks run every few minutes." |
| + return etld1 == h |
| } |
| // claimDomainView folds a domain claim that has no colocated registry row |
| // yet into its own Domains placeholder row — an alias claim (which never |
| // gets a route row; see domainattach.go) or a first-route claim before the |
| // provisioning reconciler has materialized its route. |
| -func claimDomainView(host string, edgeIPs []string) DomainView { |
| - return DomainView{Domain: host, Type: "custom · attaching", Claimed: true, Guidance: domainGuidance(host, edgeIPs)} |
| +func claimDomainView(host string, t DNSTargets) DomainView { |
| + return DomainView{Domain: host, Type: "custom · attaching", Claimed: true, Guidance: domainGuidance(host, t)} |
| } |
| -// domainView folds one registry route into its Domains row. edgeIPs is the |
| -// console's PLATFORM_EDGE_IPS (nil = unknown; guidance then names "this |
| -// deployment's edge IP" rather than inventing an address). |
| -func domainView(rt registry.Route, edgeIPs []string) DomainView { |
| +// domainView folds one registry route into its Domains row. dns carries the |
| +// deployment's edge addresses and steering hostname (see DNSTargets). |
| +func domainView(rt registry.Route, dns DNSTargets) DomainView { |
| typ := "custom" |
| if rt.Platform { |
| typ = "platform" |
| @@ -125,7 +191,7 @@ func domainView(rt registry.Route, edgeIPs []string) DomainView { |
| // Guidance only for custom + non-pointing: platform domains are ours to |
| // fix, and a pointing custom domain needs no pointing instructions. |
| if !rt.Platform && rt.DNSStatus != "pointing" { |
| - dv.Guidance = domainGuidance(rt.Host, edgeIPs) |
| + dv.Guidance = domainGuidance(rt.Host, dns) |
| } |
| return dv |
| } |
| @@ -346,10 +412,11 @@ func openRegistry(path string) (*registry.Registry, error) { |
| // Assemble builds the view for the given (owned) apps: routes from the |
| // registry at regPath, channels from the release store s verified against |
| -// pub, Domains rows with guidance targeting edgeIPs (nil = unknown). Every |
| -// app in apps gets an AppView, even an empty one; per-channel failures |
| -// degrade to ChannelView.Err. |
| -func Assemble(regPath string, s release.Store, pub ed25519.PublicKey, acct string, apps []string, edgeIPs []string) ([]AppView, error) { |
| +// pub, Domains rows with guidance targeting dns (the zero value = neither |
| +// edge addresses nor a steering hostname known). Every app in apps gets an |
| +// AppView, even an empty one; per-channel failures degrade to |
| +// ChannelView.Err. |
| +func Assemble(regPath string, s release.Store, pub ed25519.PublicKey, acct string, apps []string, dns DNSTargets) ([]AppView, error) { |
| ctx := context.Background() |
| reg, err := openRegistry(regPath) |
| if err != nil { |
| @@ -432,7 +499,7 @@ func Assemble(regPath string, s release.Store, pub ed25519.PublicKey, acct strin |
| Caps: capsFor, |
| Unit: rt.Unit, Backing: rt.Backing, Provisioned: rt.Provisioned, |
| }) |
| - domainsByApp[rt.App] = append(domainsByApp[rt.App], domainView(rt, edgeIPs)) |
| + domainsByApp[rt.App] = append(domainsByApp[rt.App], domainView(rt, dns)) |
| } |
| now := time.Now() |
| sorted := append([]string(nil), apps...) |
diff --git a/internal/console/view_test.go b/internal/console/view_test.go| index 81d5bfd9f..efad811c0 100644 |
| --- a/internal/console/view_test.go |
| +++ b/internal/console/view_test.go |
| @@ -153,7 +153,7 @@ func TestAssemble(t *testing.T) { |
| if err := release.SetProduction(context.Background(), store, priv, release.OpsAccount, "kass", true, "test"); err != nil { |
| t.Fatal(err) |
| } |
| - views, err := Assemble(regPath, store, pub, release.OpsAccount, []string{"kass"}, nil) |
| + views, err := Assemble(regPath, store, pub, release.OpsAccount, []string{"kass"}, DNSTargets{}) |
| if err != nil { |
| t.Fatal(err) |
| } |
| @@ -209,7 +209,7 @@ func TestAssembleCanaries(t *testing.T) { |
| Status: "ready", App: "kass", Channel: "canary/x", Canary: true}); err != nil { |
| t.Fatal(err) |
| } |
| - views, err := Assemble(regPath, store, pub, release.OpsAccount, []string{"kass"}, nil) |
| + views, err := Assemble(regPath, store, pub, release.OpsAccount, []string{"kass"}, DNSTargets{}) |
| if err != nil { |
| t.Fatal(err) |
| } |
| @@ -231,7 +231,7 @@ func TestAssembleCanaries(t *testing.T) { |
| func TestAssembleFiltersToGivenApps(t *testing.T) { |
| regPath, store, _, pub := buildFixtures(t) |
| - views, err := Assemble(regPath, store, pub, release.OpsAccount, []string{"kass"}, nil) |
| + views, err := Assemble(regPath, store, pub, release.OpsAccount, []string{"kass"}, DNSTargets{}) |
| if err != nil { |
| t.Fatal(err) |
| } |
| @@ -249,7 +249,7 @@ func TestAssembleFiltersToGivenApps(t *testing.T) { |
| func TestAssembleEmptyAppStillListed(t *testing.T) { |
| regPath, store, _, pub := buildFixtures(t) |
| - views, err := Assemble(regPath, store, pub, release.OpsAccount, []string{"ghost", "kass"}, nil) |
| + views, err := Assemble(regPath, store, pub, release.OpsAccount, []string{"ghost", "kass"}, DNSTargets{}) |
| if err != nil { |
| t.Fatal(err) |
| } |
| @@ -267,7 +267,7 @@ func TestAssembleDegradesBadChannel(t *testing.T) { |
| if _, err := store.Put(context.Background(), release.ChannelKey(release.OpsAccount, "kass", "beta"), []byte("not a signed pointer"), ""); err != nil { |
| t.Fatal(err) |
| } |
| - views, err := Assemble(regPath, store, pub, release.OpsAccount, []string{"kass"}, nil) |
| + views, err := Assemble(regPath, store, pub, release.OpsAccount, []string{"kass"}, DNSTargets{}) |
| if err != nil { |
| t.Fatalf("Assemble must not fail on a bad channel: %v", err) |
| } |
| @@ -296,7 +296,7 @@ func TestAssembleCarriesProcessOwnership(t *testing.T) { |
| Unit: "titogo@demo.service", Backing: registry.BackingUnit, Provisioned: true}); err != nil { |
| t.Fatal(err) |
| } |
| - views, err := Assemble(regPath, store, pub, release.OpsAccount, []string{"kass"}, nil) |
| + views, err := Assemble(regPath, store, pub, release.OpsAccount, []string{"kass"}, DNSTargets{}) |
| if err != nil { |
| t.Fatal(err) |
| } |
| @@ -339,7 +339,7 @@ func TestMismatchListsRoutes(t *testing.T) { |
| t.Fatal(err) |
| } |
| } |
| - views, err := Assemble(regPath, store, pub, release.OpsAccount, []string{"mismatchlist"}, nil) |
| + views, err := Assemble(regPath, store, pub, release.OpsAccount, []string{"mismatchlist"}, DNSTargets{}) |
| if err != nil { |
| t.Fatal(err) |
| } |
| @@ -379,7 +379,7 @@ func TestMismatchAllRoutes(t *testing.T) { |
| t.Fatal(err) |
| } |
| } |
| - views, err := Assemble(regPath, store, pub, release.OpsAccount, []string{"mismatchall"}, nil) |
| + views, err := Assemble(regPath, store, pub, release.OpsAccount, []string{"mismatchall"}, DNSTargets{}) |
| if err != nil { |
| t.Fatal(err) |
| } |
| @@ -412,7 +412,7 @@ func TestMismatchIgnoresPlatformRoutes(t *testing.T) { |
| t.Fatal(err) |
| } |
| } |
| - views, err := Assemble(regPath, store, pub, release.OpsAccount, []string{"mismatchplatform"}, nil) |
| + views, err := Assemble(regPath, store, pub, release.OpsAccount, []string{"mismatchplatform"}, DNSTargets{}) |
| if err != nil { |
| t.Fatal(err) |
| } |