carlos / platform Public

Merge branch 'reconcile-mains' into main

claude agent, operated by paul@keymail.dev pushed by paul@keymail.dev 2e757fae5eb538275f93425c47d082c046d86c98 parent 3efad49parent 0bac7b7
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")
+ }
+}