carlos / platform Public

Merge GitHub main into amadan main, integrating both architecture pages

The two mains forked at 6a59497 because merges landed on the GitHub mirror
as well as the forge. amadan carried ~20 commits (restart selectors, status
--instances, the replication flag, adopt ownership, the build probe, region
host shape, config isolation, activator wake timing, arch-page-fits);
GitHub carried the path-routed purge fix and #387. Everything reconciles
mechanically except /fleet/architecture, where both sides had rebuilt the
same page.

They were not two versions of one change. They were two different solutions
to the same bug (discussion #9), and #387 predates the fixes:

- #387 keeps the alarm marks on the title's line and truncates the title
  horizontally to make room. Ported onto its structure, amadan's own
  regression test showed both flagship box names truncating — exactly the
  failure #9 caught in a browser, each name cut one character before the
  characters that tell the four boxes apart.
- #387 has no equivalent of the firing-alarm summary at all, so a page that
  knew something was on fire showed nothing above the fold.
- #387 routes a layer-skipping edge through the midpoint, which sits inside
  the skipped column, so the line runs under a node and the picture reads as
  a hop that does not exist.

So this keeps #387's two-estate split — the structural improvement — and
ports all three of arch-page-fits' fixes onto it:

- alarmRoom deleted, alarmRow (node bottom, below both baselines) added, and
  the title gets the node's full width back
- ArchView.Firing + firingAlarms, populated after layout across BOTH estates
  in draw order, with the header banner, the refresh control back at the top,
  and the sideways-scroll note
- layer-skipping edges routed around the columns they skip, via a lane below
  every node

Two test seams needed care rather than a straight copy. archlegibility_test
had the geometry inlined and three-segment-only on #387's side; it now uses
arch-page-fits' edgeSegments helper, which mirrors both routings — a test
modelling a path the renderer does not emit would pass while the picture
lied. And TestEveryEdgeReachesThePage counted `class="archedge"` with the
closing quote, so it saw none of the skip-routed edges; it now matches the
attribute's opening, which is what its own stated intent asks for.

Full suite green.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Paul Campbell pushed by paul@keymail.dev 0bac7b7dedbe8737b8e2f5831152253c147d293e parent 3efad49parent eb72128
17 files changed, +1258 −247
  • docs/superpowers/specs/2026-09-02-path-based-routing-design.md +29 −16
  • internal/awsinv/ec2.go +19 −0
  • internal/console/archestate.go +244 −0
  • internal/console/archestate_test.go +281 −0
  • internal/console/archfits_test.go +5 −1
  • internal/console/archgraph.go +97 −31
  • internal/console/archgraph_test.go +90 −34
  • internal/console/archlayout.go +41 −33
  • internal/console/archlayout_test.go +75 −44
  • internal/console/archlegibility_test.go +48 −43
  • internal/console/archpage.go +6 −1
  • internal/console/archpage_test.go +31 −0
  • internal/console/server.go +9 −3
  • internal/console/templates/architecture.html +53 −23
  • internal/edge/edge.go +19 −16
  • internal/edge/purge_header.go +55 −2
  • internal/edge/purge_path_test.go +156 −0

CI passed — run details

diff --git a/docs/superpowers/specs/2026-09-02-path-based-routing-design.md b/docs/superpowers/specs/2026-09-02-path-based-routing-design.md
index ea44193e0..c20c1375c 100644
--- a/docs/superpowers/specs/2026-09-02-path-based-routing-design.md
+++ b/docs/superpowers/specs/2026-09-02-path-based-routing-design.md
@@ -579,19 +579,32 @@ any of it.
target route are homed on the same box, and decide whether making it work
across boxes is wanted (a design change, not a fix).
-**Known gap: a path-routed app cannot purge its own cache.** The response
-cache keys on the REQUESTED hostname (`ti.to`), while `purgeModifier` is built
-from the resolved route — the TARGET's (`go.tito.io`). So a `Carlos-Purge`
-emitted by the Go app purges under `go.tito.io` and drops nothing; the entry
-lives under `ti.to`. The documented `Carlos-Purge-Host: ti.to` escape hatch
-does not help either: `resolvePurgeHost` requires the override to name a host
-in the same app, and path routing exists precisely to cross an app boundary.
-Aliases never hit this, because an alias maps to the primary's own route and
-the app comparison passes trivially.
-
-The bound: a path-routed response can go stale for at most the cache's max
-TTL, and only for responses that opted into caching with a positive
-`max-age`. Uncached responses are unaffected. Deliberately NOT fixed here —
-changing the purge tenancy model is unreviewed territory this feature did not
-design. Documented on `lookupUpstreamPath` and recorded here so a consumer
-knows before it relies on purge.
+**Resolved: a path-routed app purging its own cache.** Shipped as a known gap
+and closed straight after, once it became clear the platform might carry more
+than a two-app migration.
+
+The problem was real. The response cache keys on the REQUESTED hostname
+(`ti.to`), while `purgeModifier` is built from the resolved route — the
+TARGET's (`go.tito.io`). So a bare `Carlos-Purge` from the Go app purged under
+`go.tito.io` and dropped nothing, because the entry lives under `ti.to`. The
+documented `Carlos-Purge-Host: ti.to` escape hatch did not help either:
+`resolvePurgeHost` required the override to name a host in the same app, and
+path routing exists precisely to cross an app boundary. Aliases never hit
+this, because an alias maps to the primary's own route and the app comparison
+passes trivially.
+
+`resolvePurgeHost` now accepts a cross-app override when a prefix on the named
+host selects the serving route. That grants nothing new: the edge is already
+routing that host's traffic here, so this route serves pages under that
+hostname. **Account is still required**, and is checked at the edge rather than
+inherited from the reconcile pass — a path row written by an older binary or by
+hand must not reach another tenant's cache. The exception is scoped to the
+route the prefix map actually names, so "the front host path-routes somewhere"
+is not enough.
+
+**What an app must do:** emit `Carlos-Purge-Host` set to the `Host` header it
+received, unconditionally. The inbound Host is preserved end to end, so on an
+ordinary request that equals the serving route's own host and takes the
+existing own-host branch; on a path-routed one it names the front hostname and
+takes the new branch. One line of shared code, and nothing in the app needs to
+know whether it is path-routed.
diff --git a/internal/awsinv/ec2.go b/internal/awsinv/ec2.go
index c1a07d092..b26f3c138 100644
--- a/internal/awsinv/ec2.go
+++ b/internal/awsinv/ec2.go
@@ -4,6 +4,7 @@ import (
"context"
"fmt"
"strconv"
+ "strings"
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/service/ec2"
@@ -65,6 +66,24 @@ func ec2Resources(ctx context.Context, api EC2API, region string) (out []infravi
}
set(&r, "public_ip", aws.ToString(in.PublicIpAddress))
set(&r, "private_ip", aws.ToString(in.PrivateIpAddress))
+ // Which security groups and which instance role. Both come
+ // back on this same DescribeInstances call — no extra API
+ // call and no extra IAM action — and both are how
+ // /fleet/architecture tells a CARLOS security group and a
+ // CARLOS instance role from the account's other ones
+ // (console/archestate.go). GROUP IDS, not names: the
+ // firewall resource is keyed on the id, so the join is
+ // exact rather than a name match.
+ var groups []string
+ for _, g := range in.SecurityGroups {
+ if id := aws.ToString(g.GroupId); id != "" {
+ groups = append(groups, id)
+ }
+ }
+ set(&r, "security_groups", strings.Join(groups, ","))
+ if in.IamInstanceProfile != nil {
+ set(&r, "iam_profile", aws.ToString(in.IamInstanceProfile.Arn))
+ }
if in.LaunchTime != nil {
set(&r, "launched", in.LaunchTime.UTC().Format("2006-01-02"))
}
diff --git a/internal/console/archestate.go b/internal/console/archestate.go
new file mode 100644
index 000000000..2f49b5386
--- /dev/null
+++ b/internal/console/archestate.go
@@ -0,0 +1,244 @@
+// Which half of the account a resource belongs to.
+//
+// A CARLOS deployment is very often a guest in an account that was already
+// carrying something else — Tito's own account holds ~70 CARLOS resources
+// beside ~1,750 belonging to a Kubernetes estate that predates it, and the
+// two are deliberately kept in ONE account so the platform can reach the
+// legacy database to sync from. Drawn as one picture that is a hairball;
+// drawn as two it is two readable pictures. So /fleet/architecture splits
+// the page in half.
+//
+// This file decides the split, and one rule governs it:
+//
+// MEMBERSHIP IS ESTABLISHED, NEVER GUESSED. A resource is CARLOS because
+// the console can trace a path to it from something it knows first-hand —
+// a machine a box reported, or the deployment bucket it is configured
+// with — never because its name starts with "carlos-". A naming
+// convention is a convention: it holds until somebody renames something,
+// and then the page is confidently wrong with no way to tell.
+//
+// The consequence is that the join UNDER-reaches, and that is the honest
+// trade. Measured against the real Tito estate the closure reaches 44
+// resources; eight sibling regional deployment buckets are genuinely
+// unreachable from what this console knows and land in the other half. That
+// is not a bug to paper over with a name match — it is the page saying
+// exactly how much it can prove.
+//
+// Nothing is filtered and nothing is hidden: this is the same set of
+// resources archgraph.go has always drawn, dealt into two banks. A resource
+// the closure cannot reach is still on the page, still in a table, still
+// with its every attribute — just under the other heading.
+package console
+
+import (
+ "strings"
+
+ "github.com/carlosframework/platform/internal/infraview"
+)
+
+// Estate bits. A node can carry both: the account's one DNS zone genuinely
+// serves both halves, and drawing it in only one picture would make the
+// other picture's records resolve from nowhere.
+const (
+ estateCARLOS = 1 << iota
+ estateRest
+)
+
+// carlosResources is the membership closure: the set of inventory resource
+// ids the console can PROVE belong to the CARLOS deployment.
+//
+// It is seeded by the two things the console knows first-hand — the machines
+// its own boxes reported (claimed, built in BuildArch from each box's own
+// instance-metadata read) and the deployment bucket it is configured with —
+// and closed one hop outward along relationships the estate itself carries:
+//
+// machine -> its disks (disk.attached_to)
+// machine -> its addresses (ip.attached_to)
+// machine -> its alarms (alarm.target)
+// machine -> its security groups (machine.security_groups)
+// machine -> its instance role (machine.iam_profile)
+// address -> the records naming it, and those records' zone
+//
+// Every one of those is an id-to-id join except the last two, which are
+// described where they are made.
+func carlosResources(inv *infraview.Inventory, claimed map[string]string, bucket string) map[string]bool {
+ in := map[string]bool{}
+ if inv == nil {
+ return in
+ }
+
+ // Seed: the machines the boxes themselves reported. claimed is keyed by
+ // machine id, so this is an exact join and a box whose machine has since
+ // been terminated simply seeds nothing.
+ machines := map[string]bool{}
+ for _, m := range inv.ByKind(infraview.KindMachine) {
+ if claimed[m.ID] == "" {
+ continue
+ }
+ machines[m.ID] = true
+ in[m.ID] = true
+ }
+
+ // The deployment bucket, matched the way addBoxNodes matches it — by the
+ // name the console was configured with. An empty bucket claims nothing.
+ if bucket != "" {
+ for _, b := range inv.ByKind(infraview.KindBucket) {
+ if nameOf(b) == bucket {
+ in[b.ID] = true
+ }
+ }
+ }
+
+ // Security groups and instance roles come off the machine itself, which
+ // is why they are cheap: ec2:DescribeInstances already returns both, so
+ // closing this hop needed no new API call and no new IAM action.
+ groups := map[string]bool{} // security-group id
+ profiles := map[string]bool{} // instance-profile ARN tail
+ for _, m := range inv.ByKind(infraview.KindMachine) {
+ if !machines[m.ID] {
+ continue
+ }
+ for _, g := range strings.Split(m.Attr("security_groups"), ",") {
+ if g = strings.TrimSpace(g); g != "" {
+ groups[g] = true
+ }
+ }
+ if tail := arnTail(m.Attr("iam_profile")); tail != "" {
+ profiles[tail] = true
+ }
+ }
+
+ for _, r := range inv.Resources {
+ switch r.Kind {
+ case infraview.KindDisk:
+ if machines[r.Attr("attached_to")] {
+ in[r.ID] = true
+ }
+ case infraview.KindIP:
+ if machines[r.Attr("attached_to")] {
+ in[r.ID] = true
+ }
+ case infraview.KindAlarm:
+ if machines[r.Attr("target")] {
+ in[r.ID] = true
+ }
+ case infraview.KindFirewall:
+ // The firewall's own id, exactly as the machine named it. No
+ // name matching: DescribeInstances reports group IDs and
+ // DescribeSecurityGroups keys on the same ids.
+ if groups[r.ID] {
+ in[r.ID] = true
+ }
+ case infraview.KindIdentity:
+ // The ONE derived join on the page, and it is worth naming. An
+ // EC2 instance carries an instance PROFILE, and the profile's
+ // role is only recoverable through iam:ListInstanceProfiles —
+ // an action the console's read grant does not hold, and adding
+ // it would mean a terraform apply across every region box
+ // before this page could render at all. So the profile ARN and
+ // the role ARN are matched on their trailing name, within one
+ // account. Every tool that creates an instance role for us
+ // creates the pair under one name; EKS's do not match and
+ // correctly stay out. A wrong match here can only ever move a
+ // row from one visible band to the other — it can never hide
+ // one, which is what makes the shortcut affordable.
+ if tail := arnTail(r.ID); tail != "" && profiles[tail] {
+ in[r.ID] = true
+ }
+ }
+ }
+
+ // DNS last: a record is CARLOS when the address it resolves to is, and
+ // a zone is CARLOS when it holds such a record. recordTarget is the
+ // same resolution addRecordNodes draws with, so a record's estate and
+ // its arrow can never disagree.
+ zones := map[string]bool{}
+ for _, rec := range inv.ByKind(infraview.KindDNSRecord) {
+ t := recordTarget(inv, rec.Attr("value"), claimed)
+ switch {
+ case strings.HasPrefix(t, "box:"):
+ // targetOf only says "box:" for a machine a box reported, so
+ // this is CARLOS by construction.
+ case strings.HasPrefix(t, "ip:") && in[strings.TrimPrefix(t, "ip:")]:
+ default:
+ continue
+ }
+ in[rec.ID] = true
+ zones[rec.Attr("zone")] = true
+ }
+ for _, z := range inv.ByKind(infraview.KindDNSZone) {
+ if z.Name != "" && zones[z.Name] {
+ in[z.ID] = true
+ }
+ }
+ return in
+}
+
+// arnTail is the name at the end of an ARN — "carlos-tito-host-ie" out of
+// both arn:aws:iam::…:role/carlos-tito-host-ie and
+// arn:aws:iam::…:instance-profile/carlos-tito-host-ie. Empty for anything
+// that is not an ARN, which is how a provider that names identities some
+// other way declines this join rather than matching something wrong.
+func arnTail(arn string) string {
+ if !strings.HasPrefix(arn, "arn:") {
+ return ""
+ }
+ i := strings.LastIndex(arn, "/")
+ if i < 0 || i == len(arn)-1 {
+ return ""
+ }
+ return arn[i+1:]
+}
+
+// splitNodes deals the drawn nodes into the two banks.
+//
+// Most nodes answer directly: a box, its apps node and the deployment bucket
+// are CARLOS by definition; an IP, a record or an unclaimed machine is
+// whatever the closure said its resource is. Two kinds straddle, and both do
+// so legitimately — a DNS zone that answers for both halves, and an external
+// address named by records in both — so a node with no direct answer takes
+// the union of its neighbours' banks. That is what puts tito.io in both
+// pictures without either picture claiming to own it.
+//
+// A node that answers neither directly nor through a neighbour goes in BOTH
+// rather than neither: on this page an unplaceable thing is drawn twice, never
+// dropped.
+func splitNodes(nodes []Node, edges []Edge, in map[string]bool) map[string]int {
+ bank := make(map[string]int, len(nodes))
+ direct := make(map[string]bool, len(nodes))
+ for _, n := range nodes {
+ kind, ref, ok := strings.Cut(n.ID, ":")
+ if !ok {
+ continue
+ }
+ switch kind {
+ case "box", "apps", "bucket":
+ bank[n.ID], direct[n.ID] = estateCARLOS, true
+ case "ip", "record", "machine":
+ if in[ref] {
+ bank[n.ID] = estateCARLOS
+ } else {
+ bank[n.ID] = estateRest
+ }
+ direct[n.ID] = true
+ }
+ }
+ // One propagation pass is enough for the graph this is: every straddling
+ // node (zone, external) sits directly beside a record, and every record
+ // was placed above. The banks ACCUMULATE — a zone answering for both
+ // halves must end up carrying both bits, not whichever edge came first.
+ for _, e := range edges {
+ if !direct[e.From] {
+ bank[e.From] |= bank[e.To]
+ }
+ if !direct[e.To] {
+ bank[e.To] |= bank[e.From]
+ }
+ }
+ for _, n := range nodes {
+ if bank[n.ID] == 0 {
+ bank[n.ID] = estateCARLOS | estateRest
+ }
+ }
+ return bank
+}
diff --git a/internal/console/archestate_test.go b/internal/console/archestate_test.go
new file mode 100644
index 000000000..b74451603
--- /dev/null
+++ b/internal/console/archestate_test.go
@@ -0,0 +1,281 @@
+package console
+
+import (
+ "strings"
+ "testing"
+
+ "github.com/carlosframework/platform/internal/economics"
+ "github.com/carlosframework/platform/internal/infraview"
+ "github.com/carlosframework/platform/internal/status"
+)
+
+// sharedAccountFixture is the shape this whole feature exists for: one
+// account carrying a CARLOS deployment AND something else. It is modelled on
+// Tito's real account — a CARLOS box beside a Kubernetes estate, one DNS zone
+// answering for both, and a bucket named as if it were CARLOS's that the
+// console was never told about.
+func sharedAccountFixture() ArchInput {
+ inv := &infraview.Inventory{Provider: "aws", Account: "665106592142", Resources: []infraview.Resource{
+ // One zone, both estates. This is the coupling that makes the two
+ // halves one account rather than two.
+ {Kind: infraview.KindDNSZone, ID: "Z1", Name: "tito.io",
+ Attrs: map[string]string{"record_count": "202"}},
+
+ // --- the CARLOS half ------------------------------------------
+ {Kind: infraview.KindMachine, ID: "i-carlos", Name: "carlos-tito-ie", Region: "eu-west-1",
+ Attrs: map[string]string{
+ "type": "t4g.medium", "state": "running", "public_ip": "52.0.0.1",
+ "security_groups": "sg-carlos",
+ "iam_profile": "arn:aws:iam::665106592142:instance-profile/carlos-tito-host-ie",
+ }},
+ {Kind: infraview.KindIP, ID: "eip-carlos",
+ Attrs: map[string]string{"address": "52.0.0.1", "attached_to": "i-carlos"}},
+ {Kind: infraview.KindDisk, ID: "vol-carlos",
+ Attrs: map[string]string{"size_gb": "40", "attached_to": "i-carlos"}},
+ {Kind: infraview.KindFirewall, ID: "sg-carlos", Name: "carlos-tito-ie"},
+ {Kind: infraview.KindIdentity, ID: "arn:aws:iam::665106592142:role/carlos-tito-host-ie",
+ Name: "carlos-tito-host-ie", Attrs: map[string]string{"kind": "role"}},
+ {Kind: infraview.KindAlarm, ID: "alarm-carlos", Name: "auto-recover-ie",
+ Attrs: map[string]string{"target": "i-carlos", "state": "OK"}},
+ {Kind: infraview.KindDNSRecord, ID: "rec-carlos", Name: "go.tito.io",
+ Attrs: map[string]string{"zone": "tito.io", "type": "A", "value": "52.0.0.1"}},
+ {Kind: infraview.KindBucket, ID: "carlos-tito-platform", Name: "carlos-tito-platform"},
+
+ // --- everything else the account carries -----------------------
+ {Kind: infraview.KindMachine, ID: "i-eks", Name: "workers-a-03", Region: "eu-west-1",
+ Attrs: map[string]string{
+ "type": "m5.large", "state": "running", "public_ip": "52.0.0.9",
+ "security_groups": "sg-eks",
+ "iam_profile": "arn:aws:iam::665106592142:instance-profile/eks-2acb3b4e",
+ }},
+ {Kind: infraview.KindFirewall, ID: "sg-eks", Name: "eks-cluster-sg"},
+ {Kind: infraview.KindIdentity, ID: "arn:aws:iam::665106592142:role/eks-node-role",
+ Name: "eks-node-role", Attrs: map[string]string{"kind": "role"}},
+ {Kind: infraview.KindDNSRecord, ID: "rec-legacy", Name: "ti.to",
+ Attrs: map[string]string{"zone": "tito.io", "type": "A", "value": "52.0.0.9"}},
+ {Kind: infraview.KindNetwork, ID: "vpc-1", Name: "tito-production"},
+
+ // A bucket the console was NEVER told about, wearing the CARLOS
+ // name. It is the whole reason this file matches on joins rather
+ // than on names: nothing here can prove it belongs to CARLOS.
+ {Kind: infraview.KindBucket, ID: "carlos-tito-se-665106592142", Name: "carlos-tito-se-665106592142"},
+ }}
+ return ArchInput{
+ Inv: inv,
+ Bucket: "carlos-tito-platform",
+ Fleet: &status.Fleet{Boxes: []status.FleetBox{{Label: "carlos-tito-ie", Region: "eu-west-1"}}},
+ Hosts: []HostRow{{Label: "carlos-tito-ie", Region: "eu-west-1", Metrics: &status.BoxMetrics{
+ Release: "abc1234", InstanceID: "i-carlos",
+ }}},
+ Rates: economics.RateCard{Boxes: map[string]economics.BoxSpec{
+ "": {HourUSD: 0.02, Cores: 2, MemGB: 4, EBSGB: 40},
+ }, EBSGBMonthUSD: 0.088},
+ }
+}
+
+// The membership closure, one join at a time. Each of these is a hop the
+// console can PROVE; the failure this pins is a join quietly widening into a
+// name match, which would make every row on the page a guess.
+func TestEstateClosureReachesWhatItCanProve(t *testing.T) {
+ v := BuildArch(sharedAccountFixture())
+
+ // Keyed by (table, name) rather than name alone: on the real estate a
+ // security group is named after the box it guards, so "carlos-tito-ie"
+ // is both a machine and a firewall — and the fixture keeps that.
+ rows := bankOfEachRow(v)
+
+ for _, c := range []struct{ table, name, want, why string }{
+ {"Compute", "carlos-tito-ie", "CARLOS", "the box itself reported this machine id"},
+ {"Compute", "vol-carlos", "CARLOS", "a disk attached to a reported machine"},
+ {"Network", "eip-carlos", "CARLOS", "an address attached to a reported machine"},
+ {"Alarms", "auto-recover-ie", "CARLOS", "an alarm watching a reported machine"},
+ {"Network", "carlos-tito-ie", "CARLOS", "a security group the machine itself named"},
+ {"Identity", "carlos-tito-host-ie", "CARLOS", "the role behind the machine's instance profile"},
+ {"DNS", "go.tito.io", "CARLOS", "a record resolving to a CARLOS address"},
+ {"DNS", "tito.io", "CARLOS", "the zone holding such a record"},
+ {"Storage", "carlos-tito-platform", "CARLOS", "the deployment bucket the console is configured with"},
+
+ {"Compute", "workers-a-03", "rest", "a machine no box reports"},
+ {"Network", "eks-cluster-sg", "rest", "a security group no CARLOS machine names"},
+ {"Identity", "eks-node-role", "rest", "a role no CARLOS instance profile names"},
+ {"DNS", "ti.to", "rest", "a record resolving into the other estate"},
+ {"Network", "tito-production", "rest", "a VPC nothing joins it to CARLOS"},
+
+ // The load-bearing one. This bucket is named exactly as CARLOS's
+ // own buckets are, and it still lands in the other half, because
+ // the console cannot trace a path to it. A page that put it in
+ // CARLOS would be reading a naming convention, and the day
+ // somebody renames a bucket that page becomes confidently wrong.
+ {"Storage", "carlos-tito-se-665106592142", "rest", "a carlos-NAMED bucket the console cannot prove is its own"},
+ } {
+ if got := rows[c.table+"/"+c.name]; got != c.want {
+ t.Errorf("%s %s is in %q, want %q — %s", c.table, c.name, got, c.want, c.why)
+ }
+ }
+}
+
+// bankOfEachRow maps "<table>/<name>" to the bank its row was rendered in,
+// or "both"/"" when the split lost track of it.
+func bankOfEachRow(v ArchView) map[string]string {
+ out := map[string]string{}
+ for bank, groups := range map[string][]ResourceGroup{"CARLOS": v.CARLOS.Groups, "rest": v.Rest.Groups} {
+ for _, g := range groups {
+ for _, r := range g.Rows {
+ k := g.Title + "/" + r.Name
+ if out[k] != "" {
+ out[k] = "both"
+ continue
+ }
+ out[k] = bank
+ }
+ }
+ }
+ return out
+}
+
+// Nothing is filtered and nothing is hidden: this page GROUPS. Every
+// resource in the inventory appears exactly once across the two banks
+// together — never in neither (which would hide it) and never in both
+// (which would double-count the estate).
+func TestEveryResourceAppearsExactlyOnceAcrossBothBanks(t *testing.T) {
+ in := sharedAccountFixture()
+ v := BuildArch(in)
+
+ count := map[string]int{}
+ total := 0
+ for _, g := range append(append([]ResourceGroup{}, v.CARLOS.Groups...), v.Rest.Groups...) {
+ for _, r := range g.Rows {
+ count[g.Title+"/"+r.Name]++
+ total++
+ }
+ }
+ if total != len(in.Inv.Resources) {
+ t.Errorf("banks carry %d rows, inventory has %d resources", total, len(in.Inv.Resources))
+ }
+ for _, r := range in.Inv.Resources {
+ k := archGroupOf[r.Kind] + "/" + nameOf(r)
+ switch n := count[k]; n {
+ case 1:
+ case 0:
+ t.Errorf("%s (%s) is on neither bank — the split must group, never hide", nameOf(r), r.Kind)
+ default:
+ t.Errorf("%s (%s) appears %d times — the estate must not be double-counted", nameOf(r), r.Kind, n)
+ }
+ }
+}
+
+// The split is only honest if no relationship is lost to it. An edge whose
+// ends fell into different banks would be pruned out of both and vanish
+// silently, so the shape of the graph has to be such that none exists: a
+// node that genuinely serves both halves (the shared DNS zone) is DRAWN IN
+// BOTH pictures rather than picked for one.
+func TestNoEdgeSpansTheTwoBanks(t *testing.T) {
+ v := BuildArch(sharedAccountFixture())
+
+ bank := map[string]string{}
+ for _, n := range v.CARLOS.Nodes {
+ bank[n.ID] = "CARLOS"
+ }
+ for _, n := range v.Rest.Nodes {
+ if bank[n.ID] == "CARLOS" {
+ bank[n.ID] = "both"
+ continue
+ }
+ bank[n.ID] = "rest"
+ }
+ for _, e := range archEdges(v) {
+ f, to := bank[e.From], bank[e.To]
+ if f == "both" || to == "both" || f == to {
+ continue
+ }
+ t.Errorf("edge %s (%s) -> %s (%s) spans both banks and would be pruned out of both",
+ e.From, f, e.To, to)
+ }
+
+ // And the zone really is the node that straddles: it answers for both
+ // halves, so both pictures show where their records resolve FROM.
+ if bank["zone:Z1"] != "both" {
+ t.Errorf("the shared zone is in %q, want both pictures", bank["zone:Z1"])
+ }
+ // Drawn twice, listed once: a node is not a resource row.
+ if got := bankOfEachRow(v)["DNS/tito.io"]; got != "CARLOS" {
+ t.Errorf("the shared zone's table row is %q, want exactly one row, on the CARLOS side", got)
+ }
+}
+
+// Each picture is deterministic on its own, and neither depends on the
+// other: layout runs twice over disjoint node sets and the second run must
+// not be able to move the first.
+func TestBothPicturesAreDeterministic(t *testing.T) {
+ a := BuildArch(sharedAccountFixture())
+ b := BuildArch(sharedAccountFixture())
+ if renderBoth(a) != renderBoth(b) {
+ t.Fatal("two builds of the same shared account produced different SVG")
+ }
+ if string(RenderArchSVG(a.CARLOS)) == string(RenderArchSVG(a.Rest)) {
+ t.Fatal("fixture drift: the two pictures should differ, or this test proves nothing")
+ }
+}
+
+// An account with nothing but CARLOS in it — the ordinary deployment —
+// draws an empty second picture and no orphan rows, rather than inventing a
+// second estate out of whatever it could not join.
+func TestAnAccountWithOnlyCARLOSHasAnEmptySecondBank(t *testing.T) {
+ in := sharedAccountFixture()
+ kept := in.Inv.Resources[:0]
+ for _, r := range in.Inv.Resources {
+ switch r.ID {
+ case "i-eks", "sg-eks", "arn:aws:iam::665106592142:role/eks-node-role",
+ "rec-legacy", "vpc-1", "carlos-tito-se-665106592142":
+ continue
+ }
+ kept = append(kept, r)
+ }
+ in.Inv.Resources = kept
+
+ v := BuildArch(in)
+ for _, g := range v.Rest.Groups {
+ t.Errorf("nothing should be in the second bank, found %s: %+v", g.Title, g.Rows)
+ }
+ for _, n := range v.Rest.Nodes {
+ t.Errorf("nothing should be drawn in the second picture, found %s", n.ID)
+ }
+ if v.Rest.Width <= 0 || v.Rest.Height <= 0 {
+ t.Errorf("an empty second picture must still carry a positive viewBox: %dx%d", v.Rest.Width, v.Rest.Height)
+ }
+}
+
+// The one derived join on the page, pinned in both directions. An instance
+// profile's ARN and its role's ARN are matched on their trailing name; a
+// profile whose name matches no role must join nothing rather than reach for
+// the nearest thing.
+func TestInstanceProfileJoinMatchesOnlyItsOwnRole(t *testing.T) {
+ for _, c := range []struct{ arn, want string }{
+ {"arn:aws:iam::1:role/carlos-tito-host-ie", "carlos-tito-host-ie"},
+ {"arn:aws:iam::1:instance-profile/carlos-tito-host-ie", "carlos-tito-host-ie"},
+ {"arn:aws:iam::1:role/service-role/nested", "nested"},
+ {"carlos-tito-host-ie", ""},
+ {"arn:aws:iam::1:role/", ""},
+ {"", ""},
+ } {
+ if got := arnTail(c.arn); got != c.want {
+ t.Errorf("arnTail(%q) = %q, want %q", c.arn, got, c.want)
+ }
+ }
+
+ in := sharedAccountFixture()
+ for i := range in.Inv.Resources {
+ if in.Inv.Resources[i].ID == "i-carlos" {
+ in.Inv.Resources[i].Attrs["iam_profile"] =
+ "arn:aws:iam::665106592142:instance-profile/something-else"
+ }
+ }
+ v := BuildArch(in)
+ for _, g := range v.CARLOS.Groups {
+ for _, r := range g.Rows {
+ if strings.Contains(r.Name, "carlos-tito-host-ie") && g.Title == "Identity" {
+ t.Error("a role whose name no CARLOS instance profile carries must not join")
+ }
+ }
+ }
+}
diff --git a/internal/console/archfits_test.go b/internal/console/archfits_test.go
index db1a5354b..a484f3c88 100644
--- a/internal/console/archfits_test.go
+++ b/internal/console/archfits_test.go
@@ -25,7 +25,11 @@ func TestFlagshipBoxNamesSurviveTheirAlarms(t *testing.T) {
{Name: "carlos-flagship-ie-mem-low", State: "OK"},
{Name: "carlos-flagship-ie-reachability", State: "OK"},
}
- v := ArchView{Nodes: []Node{
+ // ArchEstate, not ArchView: #387 split the page into two estates drawn one
+ // above the other, and layout/RenderArchSVG each take one estate now.
+ // These three pins are about a box's own pixels, so they belong to an
+ // estate and are otherwise unchanged.
+ v := ArchEstate{Nodes: []Node{
{ID: "box:ie", Kind: "box", Title: "carlos-flagship-ie",
Subtitle: "t4g.medium running", Alarms: two},
{ID: "box:au", Kind: "box", Title: "carlos-flagship-au",
diff --git a/internal/console/archgraph.go b/internal/console/archgraph.go
index 5aafe0081..b5ab3c3a7 100644
--- a/internal/console/archgraph.go
+++ b/internal/console/archgraph.go
@@ -123,17 +123,34 @@ type ResourceGroup struct {
// ResourceRow is one resource in one of those tables.
type ResourceRow struct{ Name, NativeType, Region, Detail string }
-// ArchView is the whole page.
-type ArchView struct {
+// ArchEstate is one half of the page: its own picture and its own tables.
+// Nothing else in this type knows which half it is — layout and
+// RenderArchSVG take one of these and have no opinion about the other,
+// which is what makes running them twice free and each picture
+// independently deterministic.
+type ArchEstate struct {
Nodes []Node
Edges []Edge
Width, Height int
- Boxes []BoxCard
Groups []ResourceGroup
- TakenAt time.Time
- Account string
- Partial []string
- Missing string // "" | "never" | "ungranted" | "unreadable"
+}
+
+// ArchView is the whole page: two estates drawn one above the other, and
+// the facts that belong to neither.
+//
+// Rest is deliberately NOT called "legacy" anywhere a person can read it.
+// This console serves every CARLOS deployment, and what shares Tito's
+// account is a Kubernetes estate while what shares somebody else's will be
+// something else entirely. The page says "also in this account", which is
+// the only thing that is true in all of them.
+type ArchView struct {
+ CARLOS ArchEstate
+ Rest ArchEstate
+ Boxes []BoxCard
+ TakenAt time.Time
+ Account string
+ Partial []string
+ Missing string // "" | "never" | "ungranted" | "unreadable"
// Firing names every alarm currently in ALARM, with the node it watches,
// so the page can say so above the fold.
//
@@ -143,6 +160,9 @@ type ArchView struct {
// window, and the Alarms table is thousands of pixels down (discussion
// #9). A page that knows something is on fire has to say so where the
// reader already is.
+ //
+ // It spans BOTH estates: the reader wants to know something is firing,
+ // not which half of the page to look in.
Firing []string
}
@@ -191,22 +211,52 @@ func BuildArch(in ArchInput) ArchView {
}
}
- addDNSNodes(&v, in.Inv, claimed)
- addBoxNodes(&v, in)
- addMachineNodes(&v, in.Inv, claimed)
- addAlarmBadges(&v, in.Inv, claimed)
- v.Groups = resourceGroups(in.Inv)
+ // The whole graph is built once, exactly as it always was, and only then
+ // dealt into two banks. Building it whole is what keeps the joins in one
+ // place: a record still finds its IP, and an alarm still finds its box,
+ // without either pass having to know which half of the account it is
+ // looking at.
+ var whole ArchEstate
+ addDNSNodes(&whole, in.Inv, claimed)
+ addBoxNodes(&whole, v.Boxes, in)
+ addMachineNodes(&whole, in.Inv, claimed)
+ addAlarmBadges(&whole, in.Inv, claimed)
+
+ carlos := carlosResources(in.Inv, claimed, in.Bucket)
+ bank := splitNodes(whole.Nodes, whole.Edges, carlos)
+ for _, n := range whole.Nodes {
+ if bank[n.ID]&estateCARLOS != 0 {
+ v.CARLOS.Nodes = append(v.CARLOS.Nodes, n)
+ }
+ if bank[n.ID]&estateRest != 0 {
+ v.Rest.Nodes = append(v.Rest.Nodes, n)
+ }
+ }
+ // Both banks get every edge and each then prunes what it does not draw.
+ // An edge whose ends are in different banks therefore survives in
+ // neither — which is why archestate_test.go asserts no such edge exists,
+ // rather than this loop quietly deciding it does not matter.
+ v.CARLOS.Edges = append([]Edge(nil), whole.Edges...)
+ v.Rest.Edges = append([]Edge(nil), whole.Edges...)
+
+ v.CARLOS.Groups, v.Rest.Groups = splitResourceGroups(in.Inv, carlos)
+
// AFTER layout, not before: layout is what makes the node set final (it
// drops the satellite kinds the diagram lists in tables instead), so an
// edge is only knowably dangling once layout has run. Pruning earlier
// left the shipped ArchView carrying edges to nodes it does not draw.
- layout(&v)
- pruneDanglingEdges(&v)
+ for _, e := range []*ArchEstate{&v.CARLOS, &v.Rest} {
+ layout(e)
+ pruneDanglingEdges(e)
+ }
+
// After layout, for the same reason the pruning is: layout is what makes
- // the node set final, and a summary that named an alarm on a node the
- // diagram does not draw would send a reader looking for a mark that is
- // not there.
- v.Firing = firingAlarms(v.Nodes)
+ // the node set final, and a summary naming an alarm on a node the diagram
+ // does not draw would send a reader looking for a mark that is not there.
+ //
+ // Both estates, CARLOS first, so the order is the order the page draws
+ // them in — a firing alarm should be findable by reading downwards.
+ v.Firing = append(firingAlarms(v.CARLOS.Nodes), firingAlarms(v.Rest.Nodes)...)
return v
}
@@ -219,7 +269,7 @@ func BuildArch(in ArchInput) ArchView {
// terminated, would otherwise leave an arrow pointing at nothing — and an
// arrow pointing at nothing is exactly the kind of claim this page exists
// to avoid making.
-func pruneDanglingEdges(v *ArchView) {
+func pruneDanglingEdges(v *ArchEstate) {
drawn := make(map[string]bool, len(v.Nodes))
for _, n := range v.Nodes {
drawn[n.ID] = true
@@ -456,7 +506,7 @@ func buildBoxCards(in ArchInput) []BoxCard {
// does not own gets an external node — an arrow leaving the diagram, per
// spec §6.3 — rather than being dropped, because "this name points somewhere
// we do not run" is exactly the thing an operator opens this page to see.
-func addDNSNodes(v *ArchView, inv *infraview.Inventory, claimed map[string]string) {
+func addDNSNodes(v *ArchEstate, inv *infraview.Inventory, claimed map[string]string) {
if inv == nil {
return
}
@@ -502,7 +552,7 @@ type recordGroup struct {
// destination) and draws one node per group, sorted by each group's lowest
// record id — never by map iteration, which Go randomises on every run and
// this diagram must not.
-func addRecordNodes(v *ArchView, inv *infraview.Inventory, zones []infraview.Resource, claimed map[string]string) {
+func addRecordNodes(v *ArchEstate, inv *infraview.Inventory, zones []infraview.Resource, claimed map[string]string) {
groups := map[string]*recordGroup{}
for _, rec := range inv.ByKind(infraview.KindDNSRecord) {
zoneName := rec.Attr("zone")
@@ -604,8 +654,8 @@ func recordNodeText(g *recordGroup) (title, subtitle string) {
//
// One apps node per box, never one per instance: a fleet of a hundred
// tenants must still produce a diagram a person can read (spec §6.4).
-func addBoxNodes(v *ArchView, in ArchInput) {
- for _, b := range v.Boxes {
+func addBoxNodes(v *ArchEstate, boxes []BoxCard, in ArchInput) {
+ for _, b := range boxes {
boxID := "box:" + b.Label
v.Nodes = append(v.Nodes, Node{
ID: boxID, Kind: "box", Title: b.Label, Subtitle: boxSubtitle(b),
@@ -642,7 +692,7 @@ func addBoxNodes(v *ArchView, in ArchInput) {
// dark, is drift — and drift is only visible if it is drawn. A machine a box
// does report is already on the page as that box, and drawing it twice would
// say the estate holds two things where it holds one.
-func addMachineNodes(v *ArchView, inv *infraview.Inventory, claimed map[string]string) {
+func addMachineNodes(v *ArchEstate, inv *infraview.Inventory, claimed map[string]string) {
if inv == nil {
return
}
@@ -666,7 +716,7 @@ func addMachineNodes(v *ArchView, inv *infraview.Inventory, claimed map[string]s
// nothing this page draws is still listed in the resource groups, and one
// naming something the page DOES draw becomes a mark on that node rather
// than a node in the flow (see Node.Alarms for why).
-func addAlarmBadges(v *ArchView, inv *infraview.Inventory, claimed map[string]string) {
+func addAlarmBadges(v *ArchEstate, inv *infraview.Inventory, claimed map[string]string) {
if inv == nil {
return
}
@@ -715,15 +765,31 @@ var archGroupOf = map[string]string{
infraview.KindOther: "Other",
}
-// resourceGroups turns the whole inventory into the tables below the
-// diagram. EVERY resource appears exactly once, in inventory order, and an
-// empty group is omitted rather than rendered as a confident "none".
-func resourceGroups(inv *infraview.Inventory) []ResourceGroup {
+// splitResourceGroups deals the inventory's tables into the two banks. The
+// exactly-once promise below is now a promise across BOTH banks together:
+// every resource is in one of them, never in neither and never in both, and
+// archestate_test.go counts it.
+func splitResourceGroups(inv *infraview.Inventory, carlos map[string]bool) (inEstate, rest []ResourceGroup) {
if inv == nil {
- return nil
+ return nil, nil
}
- rows := map[string][]ResourceRow{}
+ var mine, theirs []infraview.Resource
for _, r := range inv.Resources {
+ if carlos[r.ID] {
+ mine = append(mine, r)
+ } else {
+ theirs = append(theirs, r)
+ }
+ }
+ return resourceGroups(mine), resourceGroups(theirs)
+}
+
+// resourceGroups turns a set of resources into the tables below one
+// diagram. EVERY resource appears exactly once, in inventory order, and an
+// empty group is omitted rather than rendered as a confident "none".
+func resourceGroups(resources []infraview.Resource) []ResourceGroup {
+ rows := map[string][]ResourceRow{}
+ for _, r := range resources {
g, ok := archGroupOf[r.Kind]
if !ok {
g = "Other"
diff --git a/internal/console/archgraph_test.go b/internal/console/archgraph_test.go
index fcc45852c..546835f1b 100644
--- a/internal/console/archgraph_test.go
+++ b/internal/console/archgraph_test.go
@@ -79,12 +79,59 @@ func TestArchJoinsDNSThroughIPToBox(t *testing.T) {
t.Errorf("Apps = %v, want [console]", box.Apps)
}
if !hasEdge(v, "record:r1", "ip:eipalloc-1") || !hasEdge(v, "ip:eipalloc-1", "box:carlos-eu-west-1") {
- t.Errorf("missing the DNS -> IP -> box chain in %+v", v.Edges)
+ t.Errorf("missing the DNS -> IP -> box chain in %+v", archEdges(v))
}
}
+// The page draws two estates (archestate.go), and almost every test in this
+// file is about a JOIN rather than about which half a thing landed in. These
+// three read the whole page, so those tests keep asking exactly what they
+// always asked. Which half is which is archestate_test.go's subject.
+//
+// Nodes are deduped by id because one legitimately appears in both pictures:
+// a DNS zone that answers for both halves of the account.
+func archNodes(v ArchView) []Node {
+ seen := map[string]bool{}
+ var out []Node
+ for _, e := range []ArchEstate{v.CARLOS, v.Rest} {
+ for _, n := range e.Nodes {
+ if !seen[n.ID] {
+ seen[n.ID] = true
+ out = append(out, n)
+ }
+ }
+ }
+ return out
+}
+
+// Edges are NOT deduped: no edge may span the two banks (archestate_test.go
+// pins that), so every edge belongs to exactly one of them and the
+// concatenation is the whole edge set with nothing counted twice.
+func archEdges(v ArchView) []Edge {
+ return append(append([]Edge(nil), v.CARLOS.Edges...), v.Rest.Edges...)
+}
+
+// archGroups merges the two banks' tables back into one table per title, so
+// a test asking "is this resource on the page" does not have to care which
+// half it is on.
+func archGroups(v ArchView) []ResourceGroup {
+ var out []ResourceGroup
+ at := map[string]int{}
+ for _, e := range []ArchEstate{v.CARLOS, v.Rest} {
+ for _, g := range e.Groups {
+ if i, ok := at[g.Title]; ok {
+ out[i].Rows = append(out[i].Rows, g.Rows...)
+ continue
+ }
+ at[g.Title] = len(out)
+ out = append(out, g)
+ }
+ }
+ return out
+}
+
func hasEdge(v ArchView, from, to string) bool {
- for _, e := range v.Edges {
+ for _, e := range archEdges(v) {
if e.From == from && e.To == to {
return true
}
@@ -93,7 +140,7 @@ func hasEdge(v ArchView, from, to string) bool {
}
func hasNode(v ArchView, id string) bool {
- for _, n := range v.Nodes {
+ for _, n := range archNodes(v) {
if n.ID == id {
return true
}
@@ -107,7 +154,7 @@ func TestArchShowsUnjoinedBothWays(t *testing.T) {
v := BuildArch(archFixture())
var found bool
- for _, n := range v.Nodes {
+ for _, n := range archNodes(v) {
if n.ID == "machine:i-999" {
found = true
if n.Subtitle == "" {
@@ -159,10 +206,10 @@ func TestArchDrawsRecordLeavingTheEstate(t *testing.T) {
})
v := BuildArch(in)
if !hasNode(v, "external:203.0.113.9") {
- t.Errorf("an address the estate does not own must be drawn leaving the diagram: %+v", v.Nodes)
+ t.Errorf("an address the estate does not own must be drawn leaving the diagram: %+v", archNodes(v))
}
if !hasEdge(v, "record:r2", "external:203.0.113.9") {
- t.Errorf("the record must still point somewhere: %+v", v.Edges)
+ t.Errorf("the record must still point somewhere: %+v", archEdges(v))
}
if !hasEdge(v, "zone:Z1", "record:r2") {
t.Error("a record must hang off its zone")
@@ -234,7 +281,7 @@ func TestArchNoDriftWhenCardMatchesEstate(t *testing.T) {
// Resources the graph has no opinion about must still reach the page.
func TestArchGroupsCarryOtherResources(t *testing.T) {
v := BuildArch(archFixture())
- for _, g := range v.Groups {
+ for _, g := range archGroups(v) {
for _, r := range g.Rows {
if r.NativeType == "sqs" {
return
@@ -253,7 +300,7 @@ func TestArchGroupsCarryUnknownKinds(t *testing.T) {
Attrs: map[string]string{"spin": "up", "charm": "yes"},
})
v := BuildArch(in)
- for _, g := range v.Groups {
+ for _, g := range archGroups(v) {
for _, r := range g.Rows {
if r.Name == "q-1" {
if r.Detail != "charm=yes, spin=up" {
@@ -360,7 +407,7 @@ func TestArchBucketEdgeOnlyWhenNamed(t *testing.T) {
v = BuildArch(in)
if !hasEdge(v, "box:carlos-eu-west-1", "bucket:carlos-data-ie") ||
!hasEdge(v, "box:pi-dublin", "bucket:carlos-data-ie") {
- t.Errorf("every reporting box edges to the deployment bucket: %+v", v.Edges)
+ t.Errorf("every reporting box edges to the deployment bucket: %+v", archEdges(v))
}
if !hasNode(v, "bucket:carlos-data-ie") {
t.Error("the deployment bucket must be a node")
@@ -378,13 +425,17 @@ func TestArchDrawsNoEdgeToAnUndrawnNode(t *testing.T) {
Attrs: map[string]string{"target": "i-gone"}},
)
v := BuildArch(in)
- drawn := map[string]bool{}
- for _, n := range v.Nodes {
- drawn[n.ID] = true
- }
- for _, e := range v.Edges {
- if !drawn[e.From] || !drawn[e.To] {
- t.Errorf("edge %+v points at a node this view does not draw", e)
+ // Per PICTURE, not across the two: an edge is only not-dangling if both
+ // its ends are drawn in the same diagram it is drawn in.
+ for _, est := range []ArchEstate{v.CARLOS, v.Rest} {
+ drawn := map[string]bool{}
+ for _, n := range est.Nodes {
+ drawn[n.ID] = true
+ }
+ for _, e := range est.Edges {
+ if !drawn[e.From] || !drawn[e.To] {
+ t.Errorf("edge %+v points at a node this picture does not draw", e)
+ }
}
}
if !hasNode(v, "ip:eipalloc-2") {
@@ -397,6 +448,14 @@ func TestArchDrawsNoEdgeToAnUndrawnNode(t *testing.T) {
// not the mechanism: BuildArch prunes dangling edges after layout, so by
// the time the renderer runs there is nothing left for the skip to catch.
// A build that quietly lost an edge to the skip would otherwise look fine.
+//
+// The count matches the class ATTRIBUTE's opening rather than the whole
+// attribute, because an edge is drawn one of two ways: an adjacent-layer edge
+// carries `class="archedge"` and a layer-skipping one `class="archedge
+// archedge-skip"` (RenderArchSVG). Matching the closed `archedge"` counted
+// only the first kind, so a page drawing every edge correctly still failed
+// this — and, worse, a page that genuinely LOST a skipping edge would have
+// passed once the numbers happened to line up.
func TestEveryEdgeReachesThePage(t *testing.T) {
in := archFixture()
in.Inv.Resources = append(in.Inv.Resources, infraview.Resource{
@@ -404,14 +463,9 @@ func TestEveryEdgeReachesThePage(t *testing.T) {
Attrs: map[string]string{"target": "i-123", "state": "OK", "metric": "StatusCheckFailed"},
})
v := BuildArch(in)
- out := string(RenderArchSVG(v))
- // Counted on the class PREFIX, not on `class="archedge"` exactly: a
- // layer-skipping edge is drawn with `archedge archedge-skip` (it routes
- // around the columns it skips rather than under the nodes in them), and
- // an exact match silently stopped counting those — which is this test's
- // own failure mode, an edge that reaches the page and is not counted.
- if got := strings.Count(out, `<path class="archedge`); got != len(v.Edges) {
- t.Errorf("page draws %d edges, view carries %d", got, len(v.Edges))
+ out := string(RenderArchSVG(v.CARLOS)) + string(RenderArchSVG(v.Rest))
+ if got := strings.Count(out, `<path class="archedge`); got != len(archEdges(v)) {
+ t.Errorf("page draws %d edges, view carries %d", got, len(archEdges(v)))
}
}
@@ -433,7 +487,7 @@ func TestArchGroupsRecordsBySameZoneAndTarget(t *testing.T) {
v := BuildArch(in)
recordNodes := 0
- for _, n := range v.Nodes {
+ for _, n := range archNodes(v) {
if n.Kind == "record" {
recordNodes++
}
@@ -445,15 +499,16 @@ func TestArchGroupsRecordsBySameZoneAndTarget(t *testing.T) {
// group, by string sort) rather than depending on input order — here
// that is "bulk-00", which sorts before "r1".
if !hasEdge(v, "record:bulk-00", "ip:eipalloc-1") {
- t.Errorf("grouped node must use its lowest-id member's id: %+v", v.Nodes)
+ t.Errorf("grouped node must use its lowest-id member's id: %+v", archNodes(v))
}
// Ruling 19 asked the grouped node to carry the count AND up to 3
// example names — pin the label text, not just the node count.
var recordNode *Node
- for i := range v.Nodes {
- if v.Nodes[i].Kind == "record" {
- recordNode = &v.Nodes[i]
+ nodes := archNodes(v)
+ for i := range nodes {
+ if nodes[i].Kind == "record" {
+ recordNode = &nodes[i]
}
}
if recordNode == nil {
@@ -468,7 +523,7 @@ func TestArchGroupsRecordsBySameZoneAndTarget(t *testing.T) {
}
var dnsRows int
- for _, g := range v.Groups {
+ for _, g := range archGroups(v) {
if g.Title == "DNS" {
dnsRows = len(g.Rows)
}
@@ -536,7 +591,7 @@ func TestArchCountsAreUnknownWithNoFleetRead(t *testing.T) {
t.Errorf("%s: counts claimed with no fleet read at all", b.Label)
}
}
- for _, n := range v.Nodes {
+ for _, n := range archNodes(v) {
if n.Kind == "apps" && strings.Contains(n.Subtitle, "static") {
t.Errorf("%s subtitle states counts nobody read: %q", n.ID, n.Subtitle)
}
@@ -544,9 +599,10 @@ func TestArchCountsAreUnknownWithNoFleetRead(t *testing.T) {
}
func nodeByID(v ArchView, id string) *Node {
- for i := range v.Nodes {
- if v.Nodes[i].ID == id {
- return &v.Nodes[i]
+ nodes := archNodes(v)
+ for i := range nodes {
+ if nodes[i].ID == id {
+ return &nodes[i]
}
}
return nil
diff --git a/internal/console/archlayout.go b/internal/console/archlayout.go
index 0e6260602..26d25e407 100644
--- a/internal/console/archlayout.go
+++ b/internal/console/archlayout.go
@@ -42,7 +42,7 @@ const (
satelliteKinds = "identity firewall network disk certificate other"
)
-func layout(v *ArchView) {
+func layout(v *ArchEstate) {
// Identities, firewalls, networks, disks and certificates are listed in
// the tables below the diagram rather than drawn: a graph that draws
// every IAM role is a hairball, and the resource groups already carry
@@ -171,6 +171,36 @@ func layout(v *ArchView) {
v.Height = padding*2 + maxRows*nodeH + (maxRows-1)*gapY
}
+// RenderArchSVG emits the diagram. Every string that came from a hostname,
+// a tag or a label goes through template.HTMLEscapeString — these are
+// operator-supplied values from a registry and a cloud account, and the one
+// thing this page must never do is execute one of them.
+//
+// width/height are emitted ALONGSIDE the viewBox, and they are what make the
+// picture readable. An <svg> carrying only a viewBox has no intrinsic size,
+// so a browser sizes it at 100% of its container and scales the drawing to
+// fit: a full estate is 1918 units wide and the console's main column is
+// ~1024px, which renders 13px node titles at ~7px and no scrollbar ever
+// appears, because the SVG never overflows anything. With an intrinsic size
+// the drawing renders 1:1 and .archwrap (overflow-x: auto) scrolls when the
+// viewport is narrower — legible everywhere, at the cost of a sideways
+// scroll on the widest graphs, which is the right trade for a diagram whose
+// labels are the content.
+// RenderArchSVG emits the diagram. Every string that came from a hostname,
+// a tag or a label goes through template.HTMLEscapeString — these are
+// operator-supplied values from a registry and a cloud account, and the one
+// thing this page must never do is execute one of them.
+//
+// width/height are emitted ALONGSIDE the viewBox, and they are what make the
+// picture readable. An <svg> carrying only a viewBox has no intrinsic size,
+// so a browser sizes it at 100% of its container and scales the drawing to
+// fit: a full estate is 1918 units wide and the console's main column is
+// ~1024px, which renders 13px node titles at ~7px and no scrollbar ever
+// appears, because the SVG never overflows anything. With an intrinsic size
+// the drawing renders 1:1 and .archwrap (overflow-x: auto) scrolls when the
+// viewport is narrower — legible everywhere, at the cost of a sideways
+// scroll on the widest graphs, which is the right trade for a diagram whose
+// labels are the content.
// The text-fitting budget. There are no font metrics in a server-rendered
// SVG, so the width of a string is estimated: for the ui-sans stack these
// nodes use, an average glyph runs a little over half its point size, and
@@ -207,16 +237,17 @@ func fitText(s string, px, avail int) string {
return string(r[:max-1]) + "…"
}
-// alarmRow is the y centre of the alarm marks: the node's BOTTOM edge, below
-// the subtitle's baseline, not the title's line.
+// alarmRow is the y centre of the alarm marks: near the node's BOTTOM edge,
+// below the subtitle's baseline, not on the title's line.
//
// They used to share the title's line, and the title had to stop before
-// running underneath them — `alarmRoom` reserved len(Alarms)*14 + 5 pixels of
-// it. With two alarms that left 133px of a 190px node, which at 13px and the
-// 0.58 glyph ratio is 17 characters, and every flagship box is named
-// `carlos-flagship-XX`: 18. Each one truncated exactly one character before
-// the character that tells it apart from the other three (discussion #9),
-// which is the worst possible place to run out of room.
+// running underneath them — an `alarmRoom` helper reserved
+// len(Alarms)*alarmStep + alarmR pixels of it. With the two alarms every
+// flagship box has carried since 2026-08-28 that left 133px of a 190px node,
+// which at 13px and the 0.58 glyph ratio is 17 characters, and every flagship
+// box is named `carlos-flagship-XX`: 18. Each one truncated exactly one
+// character before the character that tells it apart from the other three
+// (discussion #9), which is the worst available place to run out of room.
//
// The alarms did not need that space. A node is 64 tall and carries two
// baselines, at +24 and +44; a 5px mark centred at +56 clears the subtitle's
@@ -239,30 +270,7 @@ func nodeTitleText(n Node) string {
return out
}
-// RenderArchSVG emits the diagram. Every string that came from a hostname,
-// a tag or a label goes through template.HTMLEscapeString — these are
-// operator-supplied values from a registry and a cloud account, and the one
-// thing this page must never do is execute one of them.
-//
-// width/height are emitted ALONGSIDE the viewBox, and they are what make the
-// picture readable. An <svg> carrying only a viewBox has no intrinsic size,
-// so a browser sizes it at 100% of its container and scales the drawing to
-// fit: a full estate is 1918 units wide, which at ~1024px renders 13px node
-// titles at ~7px, with no scrollbar ever appearing because the SVG never
-// overflows anything. With an intrinsic size the drawing renders 1:1 and
-// .archwrap scrolls instead — legible everywhere, at the cost of a sideways
-// scroll on the widest graphs, which is the right trade for a diagram whose
-// labels are the content.
-//
-// What changed in #9: .archwrap is now sized to the VIEWPORT rather than the
-// 64rem content column (layout.html). The cap did not move with the window,
-// so 1024px of a 1918-unit drawing showed the DNS and the IPs and hid every
-// box, every app and the deployment bucket — 47% of the picture on every
-// desktop and 77% on mobile, always the same 47%. A page whose subject is
-// how a request reaches a process was hiding the process. It still scrolls
-// when even the viewport is narrower, and the page now says so under the
-// drawing rather than leaving a reader to guess the diagram continues.
-func RenderArchSVG(v ArchView) template.HTML {
+func RenderArchSVG(v ArchEstate) template.HTML {
var b strings.Builder
fmt.Fprintf(&b, `<svg class="archsvg" width="%d" height="%d" viewBox="0 0 %d %d" role="img" aria-label="Deployment architecture">`,
v.Width, v.Height, v.Width, v.Height)
diff --git a/internal/console/archlayout_test.go b/internal/console/archlayout_test.go
index a011c6bb8..f4899041a 100644
--- a/internal/console/archlayout_test.go
+++ b/internal/console/archlayout_test.go
@@ -12,13 +12,27 @@ import (
// reshuffles between refreshes cannot be read for change, which is most of
// what an operator opens it for.
func TestLayoutIsDeterministic(t *testing.T) {
- a := RenderArchSVG(BuildArch(archFixture()))
- b := RenderArchSVG(BuildArch(archFixture()))
+ a := renderBoth(BuildArch(archFixture()))
+ b := renderBoth(BuildArch(archFixture()))
if a != b {
t.Fatal("two builds of the same input produced different SVG")
}
}
+// renderBoth is the whole page's drawing: the CARLOS picture and the
+// account's other one. Determinism is a property of the PAGE, so every test
+// about it renders both — a split that made one picture stable by letting
+// the other reshuffle would be no improvement on one that reshuffled.
+func renderBoth(v ArchView) string {
+ return string(RenderArchSVG(v.CARLOS)) + "\n" + string(RenderArchSVG(v.Rest))
+}
+
+// eachEstate names the two pictures, so a test that must hold of both says
+// which one failed.
+func eachEstate(v ArchView) map[string]ArchEstate {
+ return map[string]ArchEstate{"CARLOS": v.CARLOS, "rest of the account": v.Rest}
+}
+
// Layer order is the story the page tells: DNS enters at the left, routers
// and boxes in the middle, apps at the right. A record pointing outside the
// estate must sit to the RIGHT of the record that names it — the diagram's
@@ -31,7 +45,7 @@ func TestLayoutOrdersLayers(t *testing.T) {
})
v := BuildArch(in)
x := map[string]int{}
- for _, n := range v.Nodes {
+ for _, n := range archNodes(v) {
x[n.ID] = n.X
}
if !(x["zone:Z1"] < x["record:r1"] && x["record:r1"] < x["ip:eipalloc-1"] &&
@@ -47,11 +61,15 @@ func TestLayoutOrdersLayers(t *testing.T) {
// between a growing fleet and an unreadable page.
func TestLayoutDoesNotOverlap(t *testing.T) {
v := BuildArch(archFixture())
- for i := range v.Nodes {
- for j := i + 1; j < len(v.Nodes); j++ {
- a, b := v.Nodes[i], v.Nodes[j]
- if a.X < b.X+b.W && b.X < a.X+a.W && a.Y < b.Y+b.H && b.Y < a.Y+a.H {
- t.Fatalf("%s overlaps %s", a.ID, b.ID)
+ // Per picture: two nodes in DIFFERENT diagrams sharing a coordinate is
+ // not an overlap, it is two diagrams.
+ for name, est := range eachEstate(v) {
+ for i := range est.Nodes {
+ for j := i + 1; j < len(est.Nodes); j++ {
+ a, b := est.Nodes[i], est.Nodes[j]
+ if a.X < b.X+b.W && b.X < a.X+a.W && a.Y < b.Y+b.H && b.Y < a.Y+a.H {
+ t.Fatalf("%s: %s overlaps %s", name, a.ID, b.ID)
+ }
}
}
}
@@ -63,7 +81,7 @@ func TestSVGIsSelfContainedAndEscaped(t *testing.T) {
in := archFixture()
in.Fleet.Boxes[0].Label = `evil"><script>alert(1)</script>`
in.Hosts[0].Label = in.Fleet.Boxes[0].Label
- out := string(RenderArchSVG(BuildArch(in)))
+ out := renderBoth(BuildArch(in))
if !strings.Contains(out, "viewBox=") {
t.Error("SVG must carry a viewBox so it scales with no JS")
}
@@ -77,15 +95,17 @@ func TestSVGIsSelfContainedAndEscaped(t *testing.T) {
// page an operator sees after this ships, not a contrived corner case.
func TestLayoutEmptyGraphHasValidViewBox(t *testing.T) {
v := BuildArch(ArchInput{})
- if len(v.Nodes) != 0 {
- t.Fatalf("this fixture is expected to draw no nodes at all, got %d", len(v.Nodes))
+ for name, est := range eachEstate(v) {
+ if len(est.Nodes) != 0 {
+ t.Fatalf("%s: expected to draw no nodes at all, got %d", name, len(est.Nodes))
+ }
+ if est.Width <= 0 || est.Height <= 0 {
+ t.Fatalf("%s: Width/Height must be positive even with zero nodes: %dx%d", name, est.Width, est.Height)
+ }
}
- if v.Width <= 0 || v.Height <= 0 {
- t.Fatalf("Width/Height must be positive even with zero nodes: %dx%d", v.Width, v.Height)
- }
- out := string(RenderArchSVG(v))
- if !strings.Contains(out, "viewBox=") {
- t.Error("SVG must carry a viewBox even when the graph is empty")
+ out := renderBoth(v)
+ if strings.Count(out, "viewBox=") != 2 {
+ t.Error("both SVGs must carry a viewBox even when the graph is empty")
}
if strings.Contains(out, `viewBox="0 0 -`) {
t.Error("viewBox must never carry a negative dimension")
@@ -101,15 +121,17 @@ func TestLayoutEmptyGraphHasValidViewBox(t *testing.T) {
// make it a diagram rather than a texture.
func TestSVGCarriesIntrinsicSize(t *testing.T) {
v := BuildArch(archFixture())
- out := string(RenderArchSVG(v))
- if want := fmt.Sprintf(`width="%d" height="%d"`, v.Width, v.Height); !strings.Contains(out, want) {
- t.Errorf("SVG must carry %s so it renders 1:1 and .archwrap scrolls; got:\n%s", want, out)
- }
- if !strings.Contains(out, fmt.Sprintf(`viewBox="0 0 %d %d"`, v.Width, v.Height)) {
- t.Error("the viewBox must still match the intrinsic size")
+ out := renderBoth(v)
+ for name, est := range eachEstate(v) {
+ if want := fmt.Sprintf(`width="%d" height="%d"`, est.Width, est.Height); !strings.Contains(out, want) {
+ t.Errorf("%s: SVG must carry %s so it renders 1:1 and .archwrap scrolls; got:\n%s", name, want, out)
+ }
+ if !strings.Contains(out, fmt.Sprintf(`viewBox="0 0 %d %d"`, est.Width, est.Height)) {
+ t.Errorf("%s: the viewBox must still match the intrinsic size", name)
+ }
}
// Deterministic with the attributes as it was without them.
- if out != string(RenderArchSVG(BuildArch(archFixture()))) {
+ if out != renderBoth(BuildArch(archFixture())) {
t.Error("two renders of the same input differ")
}
}
@@ -135,30 +157,35 @@ func archAlarmFixture() ArchInput {
func TestLayoutAlarmBearingEstateStaysClean(t *testing.T) {
v := BuildArch(archAlarmFixture())
- for _, n := range v.Nodes {
+ nodes := archNodes(v)
+ for _, n := range nodes {
if n.Kind == "alarm" {
t.Fatalf("an alarm must not be a node in the flow: %+v", n)
}
}
worn := map[string]int{}
- for _, n := range v.Nodes {
+ for _, n := range nodes {
worn[n.ID] = len(n.Alarms)
}
+ // One in each picture: the box's alarm is CARLOS, the alarm on a machine
+ // no box reports belongs with the rest of the account.
for _, id := range []string{"box:carlos-eu-west-1", "machine:i-999"} {
if worn[id] == 0 {
- t.Errorf("%s should wear the alarm that watches it: %+v", id, v.Nodes)
+ t.Errorf("%s should wear the alarm that watches it: %+v", id, nodes)
}
}
- for i := range v.Nodes {
- for j := i + 1; j < len(v.Nodes); j++ {
- a, b := v.Nodes[i], v.Nodes[j]
- if a.X < b.X+b.W && b.X < a.X+a.W && a.Y < b.Y+b.H && b.Y < a.Y+a.H {
- t.Fatalf("%s overlaps %s", a.ID, b.ID)
+ for name, est := range eachEstate(v) {
+ for i := range est.Nodes {
+ for j := i + 1; j < len(est.Nodes); j++ {
+ a, b := est.Nodes[i], est.Nodes[j]
+ if a.X < b.X+b.W && b.X < a.X+a.W && a.Y < b.Y+b.H && b.Y < a.Y+a.H {
+ t.Fatalf("%s: %s overlaps %s", name, a.ID, b.ID)
+ }
}
}
}
- if RenderArchSVG(v) != RenderArchSVG(BuildArch(archAlarmFixture())) {
+ if renderBoth(v) != renderBoth(BuildArch(archAlarmFixture())) {
t.Error("two builds of an alarm-bearing estate produced different SVG")
}
}
@@ -170,20 +197,22 @@ func TestLayoutAlarmBearingEstateStaysClean(t *testing.T) {
// rests entirely on RenderArchSVG's skip. Two halves: the two functions
// compose in that order, and a built view is already clean when it ships.
func TestPruneRunsAfterLayoutDropsSatellites(t *testing.T) {
- v := ArchView{
+ e := ArchEstate{
Nodes: []Node{
{ID: "box:a", Kind: "box", Title: "a"},
{ID: "identity:role", Kind: "identity", Title: "carlos-box-role"},
},
Edges: []Edge{{From: "box:a", To: "identity:role", Kind: "runs"}},
}
- layout(&v)
- pruneDanglingEdges(&v)
- if hasNode(v, "identity:role") {
- t.Fatal("an identity is table-only; layout must drop it")
+ layout(&e)
+ pruneDanglingEdges(&e)
+ for _, n := range e.Nodes {
+ if n.ID == "identity:role" {
+ t.Fatal("an identity is table-only; layout must drop it")
+ }
}
- if len(v.Edges) != 0 {
- t.Errorf("edges = %+v, want none — the only edge pointed at a node layout dropped", v.Edges)
+ if len(e.Edges) != 0 {
+ t.Errorf("edges = %+v, want none — the only edge pointed at a node layout dropped", e.Edges)
}
// And on a real build carrying both drawn alarms and a partial estate:
@@ -195,9 +224,11 @@ func TestPruneRunsAfterLayoutDropsSatellites(t *testing.T) {
Attrs: map[string]string{"target": "i-terminated", "state": "OK"},
})
built := BuildArch(in)
- before := len(built.Edges)
- pruneDanglingEdges(&built)
- if len(built.Edges) != before {
- t.Errorf("a shipped view still had %d dangling edges in it", before-len(built.Edges))
+ for name, est := range eachEstate(built) {
+ before := len(est.Edges)
+ pruneDanglingEdges(&est)
+ if len(est.Edges) != before {
+ t.Errorf("%s: a shipped view still had %d dangling edges in it", name, before-len(est.Edges))
+ }
}
}
diff --git a/internal/console/archlegibility_test.go b/internal/console/archlegibility_test.go
index 8f9a65218..fab5ef321 100644
--- a/internal/console/archlegibility_test.go
+++ b/internal/console/archlegibility_test.go
@@ -21,8 +21,11 @@ import (
// three-segment path for both would be checking geometry the renderer does
// not emit, and would pass while the picture lied.
func TestNoNodeSitsOnAnEdgePath(t *testing.T) {
- v := BuildArch(archFixture())
- assertNoNodeOnAnEdge(t, v)
+ // Per picture: a node in the other diagram cannot obscure this one's
+ // lines, and coordinates only mean anything inside one picture.
+ for name, est := range eachEstate(BuildArch(archFixture())) {
+ assertNoNodeOnAnEdge(t, name, est)
+ }
}
// TestLayerSkippingEdgeRoutesAroundTheColumnsItSkips is the case #9 caught in
@@ -37,7 +40,7 @@ func TestNoNodeSitsOnAnEdgePath(t *testing.T) {
// inbound-smtp… -> carlos-flagship-au -> kass -> bucket` — of which two hops
// are fiction.
func TestLayerSkippingEdgeRoutesAroundTheColumnsItSkips(t *testing.T) {
- v := ArchView{
+ est := ArchEstate{
Nodes: []Node{
{ID: "ip:13.55.0.44", Kind: "ip", Title: "13.55.0.44"},
{ID: "ext:mx", Kind: "external", Title: "10 inbound-smtp.eu-west-1.amazonaws.com"},
@@ -46,19 +49,45 @@ func TestLayerSkippingEdgeRoutesAroundTheColumnsItSkips(t *testing.T) {
// The one edge, skipping the external column the MX node occupies.
Edges: []Edge{{From: "ip:13.55.0.44", To: "box:au", Kind: "routes"}},
}
- layout(&v)
- assertNoNodeOnAnEdge(t, v)
+ layout(&est)
+ assertNoNodeOnAnEdge(t, "skip", est)
// And it is the skip routing that is drawn, not the plain one.
- out := string(RenderArchSVG(v))
+ out := string(RenderArchSVG(est))
if !strings.Contains(out, "archedge-skip") {
t.Errorf("a layer-skipping edge must be routed around, not straight through:\n%s", out)
}
}
+// seg is one axis-aligned run: `at` is the fixed coordinate, a..b the span.
+type seg struct{ at, a, b int }
+
+// edgeSegments is RenderArchSVG's routing, in the same two branches and with
+// the same numbers. Kept beside the renderer's own code deliberately: the
+// value of this whole file is that it checks what is DRAWN.
+func edgeSegments(v ArchEstate, from, to Node) (horizontal, vertical []seg) {
+ x1, y1 := from.X+from.W, from.Y+from.H/2
+ x2, y2 := to.X, to.Y+to.H/2
+ if archLayer[to.Kind]-archLayer[from.Kind] > 1 {
+ lane := v.Height - padding/2
+ return []seg{
+ {at: y1, a: x1, b: x1 + gapX/2},
+ {at: lane, a: x1 + gapX/2, b: x2 - gapX/2},
+ {at: y2, a: x2 - gapX/2, b: x2},
+ }, []seg{
+ {at: x1 + gapX/2, a: y1, b: lane},
+ {at: x2 - gapX/2, a: lane, b: y2},
+ }
+ }
+ mid := (x1 + x2) / 2
+ return []seg{{at: y1, a: x1, b: mid}, {at: y2, a: mid, b: x2}},
+ []seg{{at: mid, a: y1, b: y2}}
+}
+
// assertNoNodeOnAnEdge checks every drawn edge against every node that is not
-// one of its endpoints.
-func assertNoNodeOnAnEdge(t *testing.T, v ArchView) {
+// one of its endpoints. name says which picture, since the two estates are
+// checked separately and their coordinates are unrelated.
+func assertNoNodeOnAnEdge(t *testing.T, name string, v ArchEstate) {
t.Helper()
pos := map[string]Node{}
for _, n := range v.Nodes {
@@ -73,45 +102,20 @@ func assertNoNodeOnAnEdge(t *testing.T, v ArchView) {
}
for _, h := range hs {
if segmentHitsNode(h.a, h.b, h.at, n) {
- t.Errorf("edge %s -> %s runs horizontally through node %s (%d,%d %dx%d)",
- e.From, e.To, n.ID, n.X, n.Y, n.W, n.H)
+ t.Errorf("%s: edge %s -> %s runs horizontally through node %s (%d,%d %dx%d)",
+ name, e.From, e.To, n.ID, n.X, n.Y, n.W, n.H)
}
}
for _, vv := range vs {
if verticalHitsNode(vv.at, vv.a, vv.b, n) {
- t.Errorf("edge %s -> %s runs vertically through node %s (%d,%d %dx%d)",
- e.From, e.To, n.ID, n.X, n.Y, n.W, n.H)
+ t.Errorf("%s: edge %s -> %s runs vertically through node %s (%d,%d %dx%d)",
+ name, e.From, e.To, n.ID, n.X, n.Y, n.W, n.H)
}
}
}
}
}
-// seg is one axis-aligned run: `at` is the fixed coordinate, a..b the span.
-type seg struct{ at, a, b int }
-
-// edgeSegments is RenderArchSVG's routing, in the same two branches and with
-// the same numbers. Kept beside the renderer's own code deliberately: the
-// value of this whole file is that it checks what is DRAWN.
-func edgeSegments(v ArchView, from, to Node) (horizontal, vertical []seg) {
- x1, y1 := from.X+from.W, from.Y+from.H/2
- x2, y2 := to.X, to.Y+to.H/2
- if archLayer[to.Kind]-archLayer[from.Kind] > 1 {
- lane := v.Height - padding/2
- return []seg{
- {at: y1, a: x1, b: x1 + gapX/2},
- {at: lane, a: x1 + gapX/2, b: x2 - gapX/2},
- {at: y2, a: x2 - gapX/2, b: x2},
- }, []seg{
- {at: x1 + gapX/2, a: y1, b: lane},
- {at: x2 - gapX/2, a: lane, b: y2},
- }
- }
- mid := (x1 + x2) / 2
- return []seg{{at: y1, a: x1, b: mid}, {at: y2, a: mid, b: x2}},
- []seg{{at: mid, a: y1, b: y2}}
-}
-
func segmentHitsNode(xa, xb, y int, n Node) bool {
if xa > xb {
xa, xb = xb, xa
@@ -165,7 +169,7 @@ func TestAlarmsRideTheNodeTheyWatch(t *testing.T) {
t.Errorf("firing state not carried per alarm: %+v", box.Alarms)
}
- out := string(RenderArchSVG(v))
+ out := renderBoth(v)
if !strings.Contains(out, "archalarm-firing") {
t.Error("a firing alarm must render differently from a healthy one")
}
@@ -181,13 +185,14 @@ func TestSVGTextIsFittedToItsBox(t *testing.T) {
in := archFixture()
v := BuildArch(in)
long := "console, kass, keymail, platform-site, woodstar, status, carloku-site"
- for i := range v.Nodes {
- if v.Nodes[i].Kind == "apps" {
- v.Nodes[i].Title = long
- v.Nodes[i].Subtitle = "static 3 · resident 4 · hibernating 2 · awake 6 · asleep 3"
+ // apps nodes are CARLOS by definition — a box's apps belong to the box.
+ for i := range v.CARLOS.Nodes {
+ if v.CARLOS.Nodes[i].Kind == "apps" {
+ v.CARLOS.Nodes[i].Title = long
+ v.CARLOS.Nodes[i].Subtitle = "static 3 · resident 4 · hibernating 2 · awake 6 · asleep 3"
}
}
- out := string(RenderArchSVG(v))
+ out := renderBoth(v)
if strings.Contains(out, ">"+long+"<") {
t.Error("a title wider than its rect must be truncated, not drawn past the box")
}
diff --git a/internal/console/archpage.go b/internal/console/archpage.go
index ff809358f..2f1e351f1 100644
--- a/internal/console/archpage.go
+++ b/internal/console/archpage.go
@@ -95,7 +95,12 @@ func (s *Server) handleArchitecture(w http.ResponseWriter, r *http.Request) {
v.Partial = append(append([]string{}, v.Partial...), failed...)
}
p := page{Title: "Architecture", Address: addr, CSRF: raw,
- Arch: &v, ArchSVG: RenderArchSVG(v), ArchBoxesUnread: boxesUnread}
+ Arch: &v, ArchBoxesUnread: boxesUnread,
+ // One picture per estate, rendered here rather than in the template
+ // for the reason the single picture was: html/template cannot call
+ // RenderArchSVG, and the SVG must be built in Go so it stays
+ // deterministic and testable as a whole.
+ ArchSVG: RenderArchSVG(v.CARLOS), ArchRestSVG: RenderArchSVG(v.Rest)}
p.Flash, p.FlashErr = s.takeFlash(w, r)
s.render(w, http.StatusOK, "architecture", p)
}
diff --git a/internal/console/archpage_test.go b/internal/console/archpage_test.go
index 00a4e0d0e..9266b2836 100644
--- a/internal/console/archpage_test.go
+++ b/internal/console/archpage_test.go
@@ -224,6 +224,37 @@ func TestArchitectureDrawsConfiguredBucket(t *testing.T) {
}
}
+// The page is two estates, both headed and both drawn — including on a
+// deployment where the second one is empty, which is what most accounts
+// are. A page that dropped the second heading when it had nothing under it
+// would be the page quietly deciding the account holds nothing else, which
+// is a claim rather than a read.
+func TestArchitectureDrawsBothEstates(t *testing.T) {
+ fx := newConsoleFixture(t)
+ c := signInPaul(t, fx)
+ if err := fx.st.SetOperator("paul@keymail.dev", true); err != nil {
+ t.Fatal(err)
+ }
+ st, _, body := get(t, c, fx.ts.URL+"/fleet/architecture")
+ if st != 200 {
+ t.Fatalf("= %d, want 200", st)
+ }
+ if !strings.Contains(body, "<h2>CARLOS</h2>") {
+ t.Errorf("the CARLOS estate is not headed:\n%s", body)
+ }
+ if !strings.Contains(body, "<h2>Also in this account</h2>") {
+ t.Errorf("the second estate is not headed:\n%s", body)
+ }
+ // Estate-neutral wording: this console serves carloku, keymail and
+ // woodstar too, and "legacy" describes only Tito's own account.
+ if strings.Contains(body, "legacy") || strings.Contains(body, "Legacy") {
+ t.Errorf("the second bank must not be named for one deployment's history:\n%s", body)
+ }
+ if n := strings.Count(body, `class="archsvg"`); n != 2 {
+ t.Errorf("page draws %d diagrams, want 2 — one per estate", n)
+ }
+}
+
// A read that failed is SAID on the page. An empty Boxes section with
// nothing beside it would read as "this deployment has no boxes" — the one
// claim a page whose registry read just failed must not make.
diff --git a/internal/console/server.go b/internal/console/server.go
index 1acdcf8bd..6e6401caa 100644
--- a/internal/console/server.go
+++ b/internal/console/server.go
@@ -1249,9 +1249,14 @@ type page struct {
MetricAlerts []AlertRow
// /fleet/architecture (archgraph.go): the deployment's map. Arch is nil
- // only on paths this handler never takes; ArchSVG is pre-rendered in Go
- // rather than assembled in the template because the layout has to be
- // deterministic and testable as a whole.
+ // only on paths this handler never takes; the two SVGs are pre-rendered
+ // in Go rather than assembled in the template because the layout has to
+ // be deterministic and testable as a whole.
+ //
+ // Two of them because the page is two pictures: ArchSVG is the CARLOS
+ // deployment, ArchRestSVG is whatever else the account is carrying
+ // (archestate.go). Neither is ever the other's summary — both are drawn
+ // in full.
//
// ArchBoxesUnread is why the box list is empty: an empty Boxes with a
// failed fleet/host read behind it must say "could not be read", never
@@ -1259,6 +1264,7 @@ type page struct {
// alone — both cases are len 0 — so the handler carries it here.
Arch *ArchView
ArchSVG template.HTML
+ ArchRestSVG template.HTML
ArchBoxesUnread bool
// /fleet/economics: the operator unit-economics dashboard (Task 13),
diff --git a/internal/console/templates/architecture.html b/internal/console/templates/architecture.html
index 868d5163b..e247e4272 100644
--- a/internal/console/templates/architecture.html
+++ b/internal/console/templates/architecture.html
@@ -22,6 +22,18 @@
<input type="hidden" name="csrf" value="{{.CSRF}}">
<button type="submit">Refresh inventory</button>
</form>
+{{/* A firing alarm, said where the reader already is. The marks ride the
+ nodes they watch, which is right, but that put them off to the right of
+ a drawing wider than the window and left the Alarms table thousands of
+ pixels below — so the page could know something was on fire and show
+ nothing above the fold. Spans both estates: the reader wants to know
+ something is firing, not which half of the page to look in. */}}
+{{with .Arch}}{{if .Firing}}
+<p class="banner error">{{icon "triangle-alert"}}
+ {{len .Firing}} alarm{{if ne (len .Firing) 1}}s{{end}} firing:
+ {{range $i, $a := .Firing}}{{if $i}}, {{end}}{{$a}}{{end}}
+</p>
+{{end}}{{end}}
{{with .Arch}}
{{/* The three missing states are named apart, never collapsed into one
"nothing here": never-taken, ungranted and unreadable are three
@@ -36,29 +48,29 @@
<p class="small">Estate {{.Account}} &middot; inventoried {{fmtTime .TakenAt}}</p>
{{end}}
-{{/* A firing alarm, said where the reader already is. The marks ride the
- nodes they watch, which is right, but that put them off to the right of
- a drawing wider than the window and left the Alarms table thousands of
- pixels below — so the page could know something was on fire and show
- nothing above the fold. */}}
-{{if .Firing}}
-<p class="banner error">{{icon "triangle-alert"}}
- {{len .Firing}} alarm{{if ne (len .Firing) 1}}s{{end}} firing:
- {{range $i, $a := .Firing}}{{if $i}}, {{end}}{{$a}}{{end}}
-</p>
-{{end}}
+{{/* Two estates, one above the other, never one picture with a line
+ through it. The account holds a CARLOS deployment and, very often,
+ whatever was already there — Tito's own account carries a Kubernetes
+ estate CARLOS deliberately sits beside so it can reach the legacy
+ database. Drawn together that is one unreadable graph; drawn apart it
+ is two readable ones.
-<div class="archwrap">{{$.ArchSVG}}</div>
-{{/* Said out loud because nothing else says it. The drawing is as wide as
- the estate is deep, and on a narrow window the right-hand end — the
+ NOTHING IS FILTERED. Every node and every resource on the old single
+ page is still here, under one of the two headings — the split groups,
+ it does not hide (archestate.go). */}}
+{{/* Said out loud because nothing else says it. Each drawing is as wide as
+ its estate is deep, and on a narrow window the right-hand end — the
boxes, the apps, the bucket — is off-screen with no scrollbar visible
until you try. */}}
-<p class="small archscroll">The diagram scrolls sideways: DNS on the left, then the addresses, the boxes, their apps, and the deployment bucket.</p>
+<p class="small archscroll">The diagrams scroll sideways: DNS on the left, then the addresses, the boxes, their apps, and the deployment bucket.</p>
-<h2>Boxes</h2>
+<h2>CARLOS</h2>
+<div class="archwrap">{{$.ArchSVG}}</div>
+
+<h3>Boxes</h3>
{{range .Boxes}}
<section class="archbox">
- <h3>{{.Label}}{{if .Region}} <span class="small">{{.Region}}</span>{{end}}</h3>
+ <h4>{{.Label}}{{if .Region}} <span class="small">{{.Region}}</span>{{end}}</h4>
{{/* Each fact is its own span and the separators are drawn by CSS
(.fact + .fact::before), because every one of these is independently
absent — a deployment with no rate card entry has no size and no
@@ -99,12 +111,23 @@
{{else}}<p>No box has reported to this deployment yet.</p>{{end}}
{{end}}
-{{range .Groups}}
-<h2>{{.Title}}</h2>
-<div class="tablewrap"><table>
- <tr><th>Name</th><th>Type</th><th>Region</th><th>Detail</th></tr>
- {{range .Rows}}<tr><td>{{.Name}}</td><td><code>{{.NativeType}}</code></td><td>{{.Region}}</td><td class="small">{{.Detail}}</td></tr>{{end}}
-</table></div>
+{{range .CARLOS.Groups}}
+<h3>{{.Title}}</h3>
+{{template "archtable" .}}
+{{end}}
+
+{{/* The second bank is named for what is TRUE of it in every deployment,
+ not for what it happens to be here. This console serves every CARLOS
+ estate; "legacy" describes Tito's Kubernetes cluster and would be
+ wrong, and unexplained, on the next account. */}}
+<h2>Also in this account</h2>
+<p class="small">Everything else the account is carrying. The console has no
+first-hand knowledge of these — it can see them, and it cannot trace a path
+to any of them from a box it runs or the bucket it deploys from.</p>
+<div class="archwrap">{{$.ArchRestSVG}}</div>
+{{range .Rest.Groups}}
+<h3>{{.Title}}</h3>
+{{template "archtable" .}}
{{end}}
{{/* The inventory's own partial-read list: which service or region the
@@ -117,3 +140,10 @@
{{end}}
{{template "bottom" .}}{{end}}
+
+{{/* One resource table, rendered identically in both banks — the two halves
+ of this page differ in what they contain and in nothing else. */}}
+{{define "archtable"}}<div class="tablewrap"><table>
+ <tr><th>Name</th><th>Type</th><th>Region</th><th>Detail</th></tr>
+ {{range .Rows}}<tr><td>{{.Name}}</td><td><code>{{.NativeType}}</code></td><td>{{.Region}}</td><td class="small">{{.Detail}}</td></tr>{{end}}
+</table></div>{{end}}
diff --git a/internal/edge/edge.go b/internal/edge/edge.go
index 5d3ee5b39..4d86d5886 100644
--- a/internal/edge/edge.go
+++ b/internal/edge/edge.go
@@ -952,28 +952,31 @@ func (e *Edge) lookupUpstream(host string) (upstream, bool) {
// 2. The X-Carlos-Version response header reports the TARGET's release.
// Correct: it names the code that produced the response.
// 3. Purge tenancy is the TARGET's app and account (purgeModifier is built
-// from up.route). Correct as tenancy — but it is also a real GAP, see
-// below.
+// from up.route), while the response CACHE keys on the REQUESTED host.
+// Those two disagree, and closing that is what the path-routing branch
+// in resolvePurgeHost exists for — see below.
// 4. Activator keying and hibernation are the TARGET's (up.hibernating and
// up.route come from the same entry), so a request for a flipped prefix
// wakes the target instance, not the requested hostname's. Correct: the
// target is the process that has to be awake to answer.
//
-// The gap in (3): the response CACHE keys on the REQUESTED host
-// (CacheKey{Host: host} in ServeHTTP), while purgeModifier resolves the purge
-// host from the target's route. So a path-routed app emitting Carlos-Purge
-// purges under go.tito.io while its entry is stored under ti.to, and nothing
-// is dropped; the documented Carlos-Purge-Host: ti.to escape hatch does not
-// help either, because resolvePurgeHost requires the override to name a host
-// in the SAME app — and path routing exists precisely to cross an app
-// boundary. Aliases never hit this: an alias key maps to the primary's own
-// route, so the app comparison passes trivially.
+// How (3) is closed, and what an app must do. The cache keys on the requested
+// host (CacheKey{Host: host} in ServeHTTP) while purgeModifier is built from
+// the target's route, so a path-routed app emitting a bare Carlos-Purge still
+// purges under go.tito.io while its entry is stored under ti.to, and drops
+// nothing. The fix is the documented escape hatch: name the hostname the
+// request arrived on, Carlos-Purge-Host: ti.to. resolvePurgeHost used to
+// refuse that — it required the override to name a host in the SAME app, and
+// path routing exists precisely to cross an app boundary — and now allows it
+// when a prefix on the named host selects this route. Account is still
+// required. Aliases never hit any of this: an alias key maps to the primary's
+// own route, so the app comparison passes trivially.
//
-// The bound on that gap: a path-routed response can go stale for at most the
-// cache's max TTL, and only for responses that opted into caching with a
-// positive max-age. Uncached responses are unaffected. Widening the purge
-// tenancy model to follow a prefix map is a design change this feature did
-// not make; it is recorded as a known gap in design spec §11.
+// So an app can emit Carlos-Purge-Host set to the Host header it received,
+// unconditionally, from shared code. The inbound Host is preserved end to end,
+// so on an ordinary request that equals rt.Host and takes resolvePurgeHost's
+// own-host branch; on a path-routed one it names the front hostname and takes
+// the path-routing branch. Nothing in the app has to know which it is.
func (e *Edge) lookupUpstreamPath(host, urlPath string) (upstream, bool) {
m := e.current()
up, ok := lookupIn(m, host)
diff --git a/internal/edge/purge_header.go b/internal/edge/purge_header.go
index bdb75c250..1d8830132 100644
--- a/internal/edge/purge_header.go
+++ b/internal/edge/purge_header.go
@@ -389,16 +389,69 @@ func (e *Edge) resolvePurgeHost(rt registry.Route, declared []string) (string, b
// everywhere else on this box. A raw comparison never lets a purge cross a
// real account boundary, but it does split one account in two and refuse a
// purge between two routes that demonstrably share one key space.
+ // The path-routing exception, and the ONLY place an override crosses an
+ // app boundary.
+ //
+ // When host splits its traffic by path (route_paths) and one of its
+ // prefixes selects THIS route, the edge is already sending some of host's
+ // requests here — and caching the responses under host, because the cache
+ // key is the hostname the browser asked for while this proxy was built
+ // from the target's route. Without this branch the serving app cannot
+ // purge pages it is itself producing: its own Carlos-Purge lands under
+ // rt.Host and hits nothing, and naming host explicitly is refused by the
+ // App check below, because path routing exists precisely to cross an app
+ // boundary (Tito classic and Tito Go are two apps).
+ //
+ // Nothing is granted that is not already true: the edge demonstrably
+ // routes host's traffic to this route, so this route serves pages under
+ // that hostname.
+ //
+ // ACCOUNT is still required, and is checked HERE rather than inherited
+ // from the reconcile pass that writes path rows. That pass does enforce
+ // same-account on a target, but a row written by an older binary, or by
+ // hand, must not be able to reach another tenant's cache — the same
+ // defence-in-depth reasoning the staleness re-read above applies to the
+ // serving side.
+ //
+ // Scoped to the route the prefix map actually names: "host path-routes
+ // somewhere" is not enough, or any app in the account could purge host by
+ // naming it.
+ if rt.App != "" && pathRoutesTo(up, rt.Host) &&
+ release.RouteAccount(up.route) == release.RouteAccount(rt) {
+ return host, true
+ }
+ // App AND Account, both, and App must be a real one: a route with no App
+ // has no releases and no provable tenancy, so ""=="" is not a match — two
+ // unrelated App-less routes on one box must not be able to purge each
+ // other.
if rt.App == "" || up.route.App != rt.App ||
release.RouteAccount(up.route) != release.RouteAccount(rt) {
e.logPurgeIssue("override-tenancy", rt.Host, fmt.Sprintf(
- "%s (app %q, account %q) declared Carlos-Purge-Host %q, which belongs to app %q, account %q — a purge never crosses an app or account boundary; the whole declaration is ignored",
- rt.Host, rt.App, rt.Account, truncateForLog(host), up.route.App, up.route.Account))
+ "%s (app %q, account %q) declared Carlos-Purge-Host %q, which belongs to app %q, account %q — a purge crosses an app boundary only when %q path-routes to the serving route, and never crosses an account boundary; the whole declaration is ignored",
+ rt.Host, rt.App, rt.Account, truncateForLog(host), up.route.App, up.route.Account, truncateForLog(host)))
return "", false
}
return host, true
}
+// pathRoutesTo reports whether any prefix on up selects the route at target.
+//
+// A linear scan of the prefix map, deliberately, rather than a reverse index
+// folded at Reload. It runs only for a response that declares a cross-host
+// Carlos-Purge-Host — rare — and the map is capped at
+// domains.MaxPathsPerClaim, so the worst case is a few thousand string
+// compares on a path that already did a cache write. An index would cost a
+// field on every upstream and fold work on every reload to save that; revisit
+// if purge-declaring responses ever become the common case.
+func pathRoutesTo(up upstream, target string) bool {
+ for _, t := range up.paths {
+ if t == target {
+ return true
+ }
+ }
+ return false
+}
+
// validPurgeOverrideHost reports whether h is shaped like a host key in the
// edge's routing table: a bare hostname, nothing else.
//
diff --git a/internal/edge/purge_path_test.go b/internal/edge/purge_path_test.go
new file mode 100644
index 000000000..29d4d6b4d
--- /dev/null
+++ b/internal/edge/purge_path_test.go
@@ -0,0 +1,156 @@
+package edge
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ "github.com/carlosframework/platform/internal/registry"
+)
+
+// purgePathEdge stands up the shape path routing exists to create: a front
+// hostname whose prefix routes to a second route belonging to a DIFFERENT app
+// in the same account, plus a cache. The path row is written before New so
+// Reload folds it into the upstream map.
+func purgePathEdge(t *testing.T, front, target registry.Route, prefix string, h http.HandlerFunc) *Edge {
+ t.Helper()
+ backend := httptest.NewServer(h)
+ t.Cleanup(backend.Close)
+ reg := openReg(t)
+ for _, rt := range []registry.Route{front, target} {
+ rt.Kind, rt.Addr, rt.Status = "instance", backend.Listener.Addr().String(), "ready"
+ if err := reg.Upsert(rt); err != nil {
+ t.Fatalf("upsert %s: %v", rt.Host, err)
+ }
+ }
+ if err := reg.UpsertPath(registry.RoutePath{
+ Host: front.Host, Prefix: prefix, Target: target.Host,
+ Account: front.Account, App: front.App,
+ }); err != nil {
+ t.Fatalf("upsert path %s/%s: %v", front.Host, prefix, err)
+ }
+ e, err := New(reg, nil)
+ if err != nil {
+ t.Fatalf("New: %v", err)
+ }
+ e.EnableCache(NewCache(DefaultCacheMaxEntries, DefaultCacheMaxBodyBytes))
+ return e
+}
+
+// TestPurgeHostOverrideAllowedFromAPathRoutedHost is the hole this closes.
+//
+// A path-routed request is cached under the hostname the browser asked for
+// (ti.to), but it is served by the target's route (go.tito.example), and
+// purgeModifier is built from that route. So the app's own Carlos-Purge lands
+// under go.tito.example and hits nothing, and the documented escape hatch —
+// naming ti.to explicitly — used to be refused too, because the override
+// required the same app and path routing crosses apps by design.
+//
+// The request really is path-routed here rather than simulated: it arrives on
+// ti.to/paulca and reaches the target through the prefix map, so this pins the
+// composition and not just resolvePurgeHost in isolation.
+func TestPurgeHostOverrideAllowedFromAPathRoutedHost(t *testing.T) {
+ e := purgePathEdge(t,
+ route("ti.to", "classic", "tenanta"),
+ route("go.tito.example", "gonew", "tenanta"),
+ "paulca",
+ func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Carlos-Purge-Host", "ti.to")
+ w.Header().Set("Carlos-Purge", "/paulca/events/42")
+ w.WriteHeader(http.StatusOK)
+ })
+ seed(t, e.cache, "ti.to", "/paulca/events/42", "", "stale")
+ seed(t, e.cache, "ti.to", "/paulca/events/43", "", "untouched")
+
+ rec := httptest.NewRecorder()
+ e.ServeHTTP(rec, httptest.NewRequest("POST", "http://ti.to/paulca/admin", nil))
+
+ if cached(e.cache, "ti.to", "/paulca/events/42", "") {
+ t.Fatal("a path-routed app could not purge its own stale page under the hostname it is served on")
+ }
+ if !cached(e.cache, "ti.to", "/paulca/events/43", "") {
+ t.Fatal("/paulca/events/43 was purged but nothing named it")
+ }
+ if got := rec.Header().Get("Carlos-Purge-Host"); got != "" {
+ t.Fatalf("Carlos-Purge-Host reached the client: %q", got)
+ }
+}
+
+// TestPurgeHostOverrideFromPathRoutedHostStillNeedsTheAccount — the new branch
+// drops the same-APP requirement and nothing else. Account remains the
+// boundary, and the edge checks it here rather than trusting that the
+// reconcile pass already refused a cross-account path row: a row written by an
+// older binary, or by hand, must not be able to reach another tenant's cache.
+//
+// Both accounts must be valid sqids (^[a-z]{3,16}$) or release.RouteAccount
+// folds them both onto the built-in ops account and the test asserts a
+// boundary it never crossed.
+func TestPurgeHostOverrideFromPathRoutedHostStillNeedsTheAccount(t *testing.T) {
+ e := purgePathEdge(t,
+ route("ti.to", "classic", "tenanta"),
+ route("go.tito.example", "gonew", "tenantb"),
+ "paulca",
+ func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Carlos-Purge-Host", "ti.to")
+ w.Header().Set("Carlos-Purge", "/paulca/events/42")
+ w.WriteHeader(http.StatusOK)
+ })
+ seed(t, e.cache, "ti.to", "/paulca/events/42", "", "stale")
+
+ rec := httptest.NewRecorder()
+ e.ServeHTTP(rec, httptest.NewRequest("POST", "http://ti.to/paulca/admin", nil))
+
+ if !cached(e.cache, "ti.to", "/paulca/events/42", "") {
+ t.Fatal("a path row pointing across ACCOUNTS let the target purge the front host's cache")
+ }
+}
+
+// TestPurgeHostOverrideRefusedWhenNoPrefixPointsAtTheRoute — the exception is
+// scoped to the route the prefix map actually names. A second route in the
+// same account, serving nothing of ti.to's traffic, gets the ordinary
+// cross-app refusal.
+//
+// Without this, "the front host path-routes somewhere" would be enough, and
+// any app in the account could purge ti.to by naming it.
+func TestPurgeHostOverrideRefusedWhenNoPrefixPointsAtTheRoute(t *testing.T) {
+ backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Carlos-Purge-Host", "ti.to")
+ w.Header().Set("Carlos-Purge", "/paulca/events/42")
+ w.WriteHeader(http.StatusOK)
+ }))
+ t.Cleanup(backend.Close)
+
+ reg := openReg(t)
+ for _, rt := range []registry.Route{
+ route("ti.to", "classic", "tenanta"),
+ route("go.tito.example", "gonew", "tenanta"),
+ route("bystander.example", "other", "tenanta"),
+ } {
+ rt.Kind, rt.Addr, rt.Status = "instance", backend.Listener.Addr().String(), "ready"
+ if err := reg.Upsert(rt); err != nil {
+ t.Fatalf("upsert %s: %v", rt.Host, err)
+ }
+ }
+ // ti.to routes /paulca to go.tito.example — and to nothing else.
+ if err := reg.UpsertPath(registry.RoutePath{
+ Host: "ti.to", Prefix: "paulca", Target: "go.tito.example",
+ Account: "tenanta", App: "classic",
+ }); err != nil {
+ t.Fatal(err)
+ }
+ e, err := New(reg, nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ e.EnableCache(NewCache(DefaultCacheMaxEntries, DefaultCacheMaxBodyBytes))
+ seed(t, e.cache, "ti.to", "/paulca/events/42", "", "stale")
+
+ // The write arrives on the bystander's OWN host, so it is served by the
+ // bystander's route — which no prefix of ti.to names.
+ rec := httptest.NewRecorder()
+ e.ServeHTTP(rec, httptest.NewRequest("POST", "http://bystander.example/write", nil))
+
+ if !cached(e.cache, "ti.to", "/paulca/events/42", "") {
+ t.Fatal("a route no prefix points at purged ti.to — the exception is not scoped to the path target")
+ }
+}