Merge branch 'reconcile-mains' into main
17 files changed,
+1258
−247
docs/superpowers/specs/2026-09-02-path-based-routing-design.md+29 −16internal/awsinv/ec2.go+19 −0internal/console/archestate.go+244 −0internal/console/archestate_test.go+281 −0internal/console/archfits_test.go+5 −1internal/console/archgraph.go+97 −31internal/console/archgraph_test.go+90 −34internal/console/archlayout.go+41 −33internal/console/archlayout_test.go+75 −44internal/console/archlegibility_test.go+48 −43internal/console/archpage.go+6 −1internal/console/archpage_test.go+31 −0internal/console/server.go+9 −3internal/console/templates/architecture.html+53 −23internal/edge/edge.go+19 −16internal/edge/purge_header.go+55 −2internal/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}} · 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") |
| + } |
| +} |