carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1# Fleet Architecture Diagram Implementation Plan
2
3> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4
5**Goal:** Give operators one page — `/fleet/architecture` — that draws how a request reaches a process (DNS → IP → box → app) over a live, provider-neutral inventory of everything the deployment's cloud account has stood up, with each box's size, cost, apps and instance counts.
6
7**Architecture:** A provider seam (`internal/infraview`) defines what an inventory is in cloud-neutral terms; `internal/awsinv` is the only package that imports an AWS SDK and implements it. An hourly console pass writes `infra/inventory.json` to the deployment bucket. `internal/console/archgraph.go` joins that snapshot with the fleet aggregate, the per-box snapshots and the registry, lays the result out deterministically, and emits inline SVG. The page is operator-gated with `handleFleet`'s exact 404 shape.
8
9**Tech Stack:** Go 1.x, `aws-sdk-go-v2` (ec2, s3, route53, iam, cloudwatch, acm, resourcegroupstaggingapi — the first four already vendored), `html/template`, `net/http` ServeMux, SQLite registry, S3-backed `release.Store`.
10
11**Spec:** `docs/superpowers/specs/2026-08-19-fleet-architecture-diagram-design.md`
12
13## Global Constraints
14
15- **Gate before any commit:** `go build ./...`, `go vet ./...`, `go test ./... -count=1` all clean, every package. `gofmt -l` clean for files you touch (three pre-existing unformatted files elsewhere are left alone).
16- **Sandbox env for go commands:** `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off`. A "read-only file system" error from the toolchain means the sandbox needs disabling for that command.
17- **Cross-compile matters:** CI also runs `GOOS=darwin go build ./...`. Nothing in these packages may assume Linux.
18- **Wire shapes are additive.** `infra/inventory.json` is written to a production bucket. Every field is additive, unknown fields must parse, and an unknown `Kind` renders as `other` rather than being dropped.
19- **No AWS mutation, ever.** No call in `internal/awsinv` may create, modify, tag or delete anything. The IAM policy in Task 8 contains no write action.
20- **Never read or render secret material:** no access keys, no IAM policy documents, no bucket contents, no instance user-data, no SSM parameters.
21- **Operator gate shape is fixed:** session → `IsOperator` → bare `http.NotFound` on *every* failure path including the store error (`internal/console/server.go:1720`). Never `renderMessage` for a gate failure — a styled page confirms the route exists.
22- **Every change lands through a PR.** No local merge to `main`, no direct commit to `main`.
23- **Commit at the end of every task**, message body ending with:
24 `Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>`
25
26---
27
28## File Structure
29
30| File | Responsibility |
31|---|---|
32| `internal/infraview/infraview.go` | `Resource`, `Inventory`, `Provider`, kind constants, `InventoryKey`. No cloud SDK. |
33| `internal/infraview/infraview_test.go` | Round-trip, additive-parse, unknown-kind tests. |
34| `internal/awsinv/awsinv.go` | `Provider` implementation: assembly, region loop, `Partial`, auth classification. |
35| `internal/awsinv/clients.go` | Narrow per-service interfaces + the real client factory. The only AWS SDK imports. |
36| `internal/awsinv/ec2.go` | EC2 → `machine`/`disk`/`ip`/`firewall`/`network` mappings. |
37| `internal/awsinv/rest.go` | S3, Route53, IAM, CloudWatch, ACM mappings + the tagging catch-all. |
38| `internal/awsinv/*_test.go` | Fake clients, one test per mapping, failure-to-`Partial` tests. |
39| `internal/console/infrapass.go` | Hourly pass, CAS write of `infra/inventory.json`, auth latch, refresh single-flight. |
40| `internal/console/infrapass_test.go` | Latch, transient-retry, CAS-write tests. |
41| `internal/console/archgraph.go` | Joins + view model (`ArchView`, `BoxCard`, `ResourceGroup`, `Graph`). |
42| `internal/console/archlayout.go` | Layering, ordering, SVG emission. |
43| `internal/console/archgraph_test.go`, `archlayout_test.go` | Join, cost/drift, determinism tests. |
44| `internal/console/archpage.go` | `handleArchitecture`, `handleArchitectureRefresh`. |
45| `internal/console/archpage_test.go` | Gate, content, refresh-door tests. |
46| `internal/console/templates/architecture.html` | The page. |
47| `internal/console/server.go` | Route registration, `page` fields, `Server` seam field. |
48| `internal/console/templates/fleet.html` | One link to the new page. |
49| `cmd/console/main.go` | Start the pass. |
50| `infra/modules/region-host/main.tf` | Read-only inventory IAM grants. |
51
52---
53
54### Task 1: `internal/infraview` — the provider-neutral inventory
55
56**Files:**
57- Create: `internal/infraview/infraview.go`
58- Test: `internal/infraview/infraview_test.go`
59
60**Interfaces:**
61- Consumes: nothing.
62- Produces: `infraview.Resource`, `infraview.Inventory`, `infraview.Provider`, the `Kind*` constants, `infraview.InventoryKey` (= `"infra/inventory.json"`), `Inventory.ByKind(kind string) []Resource`, `Inventory.Find(kind, attr, value string) *Resource`.
63
64- [ ] **Step 1: Write the failing test**
65
66```go
67package infraview
68
69import (
70 "encoding/json"
71 "testing"
72 "time"
73)
74
75// An inventory written before a field existed must still parse, and a kind
76// no reader knows must survive as itself rather than being dropped — the
77// repo's additive wire-shape rule applied to a bucket object.
78func TestInventoryParsesAdditively(t *testing.T) {
79 old := []byte(`{
80 "provider":"aws","account":"1234","taken_at":"2026-08-19T10:00:00Z",
81 "resources":[
82 {"provider":"aws","kind":"machine","native_type":"ec2:instance",
83 "id":"i-123","name":"carlos-eu-west-1","region":"eu-west-1",
84 "attrs":{"type":"t4g.medium","public_ip":"52.0.0.1"}},
85 {"provider":"aws","kind":"quantum_widget","native_type":"qc:widget","id":"q-1"}
86 ],
87 "future_field":"ignored"
88 }`)
89 var inv Inventory
90 if err := json.Unmarshal(old, &inv); err != nil {
91 t.Fatalf("parse: %v", err)
92 }
93 if inv.Provider != "aws" || inv.Account != "1234" {
94 t.Fatalf("header: %+v", inv)
95 }
96 if !inv.TakenAt.Equal(time.Date(2026, 8, 19, 10, 0, 0, 0, time.UTC)) {
97 t.Fatalf("taken_at: %v", inv.TakenAt)
98 }
99 if len(inv.Resources) != 2 {
100 t.Fatalf("resources: %d, want 2 (an unknown kind must not be dropped)", len(inv.Resources))
101 }
102 if got := inv.Resources[1].Kind; got != "quantum_widget" {
103 t.Fatalf("unknown kind = %q, want it carried verbatim", got)
104 }
105}
106
107func TestByKindAndFind(t *testing.T) {
108 inv := Inventory{Resources: []Resource{
109 {Kind: KindMachine, ID: "i-1", Attrs: map[string]string{"public_ip": "52.0.0.1"}},
110 {Kind: KindMachine, ID: "i-2", Attrs: map[string]string{"public_ip": "52.0.0.2"}},
111 {Kind: KindBucket, ID: "carlos-data-ie"},
112 }}
113 if got := len(inv.ByKind(KindMachine)); got != 2 {
114 t.Fatalf("ByKind(machine) = %d, want 2", got)
115 }
116 if got := inv.Find(KindMachine, "public_ip", "52.0.0.2"); got == nil || got.ID != "i-2" {
117 t.Fatalf("Find = %+v, want i-2", got)
118 }
119 if got := inv.Find(KindMachine, "public_ip", "10.0.0.9"); got != nil {
120 t.Fatalf("Find(miss) = %+v, want nil", got)
121 }
122}
123
124// A resource with no Attrs map must not panic any accessor — an inventory
125// read from an older writer is full of them.
126func TestFindNilAttrs(t *testing.T) {
127 inv := Inventory{Resources: []Resource{{Kind: KindMachine, ID: "i-1"}}}
128 if got := inv.Find(KindMachine, "public_ip", "52.0.0.1"); got != nil {
129 t.Fatalf("Find over nil Attrs = %+v, want nil", got)
130 }
131}
132```
133
134- [ ] **Step 2: Run test to verify it fails**
135
136Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/infraview/ -count=1`
137Expected: FAIL — the package does not compile (`undefined: Inventory`).
138
139- [ ] **Step 3: Write minimal implementation**
140
141```go
142// Package infraview is what an infrastructure inventory IS, in terms no
143// cloud provider owns. It is deliberately free of every SDK: the AWS
144// implementation (internal/awsinv) imports this package, never the other
145// way round, so the console page that renders an inventory can be pointed
146// at a second provider by writing one file rather than by rewriting the
147// page.
148//
149// The wire shape here is written to a production bucket
150// (InventoryKey), so it obeys the repo's additive rule: every field is
151// optional, unknown fields parse, and an unknown Kind is carried through
152// as itself. A reader that drops what it does not recognise would make the
153// page LESS useful exactly where it is most useful — the resource nobody
154// wrote code for is the one worth seeing.
155package infraview
156
157import (
158 "context"
159 "time"
160)
161
162// InventoryKey is where a deployment's inventory snapshot lives in its own
163// bucket. One object, overwritten in place: this is a current-state
164// snapshot, not a history, and a history would need a retention policy
165// nobody has asked for.
166const InventoryKey = "infra/inventory.json"
167
168// The kind vocabulary. Small and neutral on purpose — a provider maps its
169// own types into these and carries the truth in NativeType, so the page's
170// layout rules ("machines sit in the box layer") never have to learn a new
171// cloud's nouns. KindOther is not a failure: it is the honest home for a
172// discovered resource this vocabulary has no word for.
173const (
174 KindMachine = "machine"
175 KindIP = "ip"
176 KindDisk = "disk"
177 KindFirewall = "firewall"
178 KindNetwork = "network"
179 KindBucket = "bucket"
180 KindDNSZone = "dns_zone"
181 KindDNSRecord = "dns_record"
182 KindIdentity = "identity"
183 KindAlarm = "alarm"
184 KindCertificate = "certificate"
185 KindOther = "other"
186)
187
188// Resource is one thing a provider is carrying. Attrs is stringly on
189// purpose: a typed struct per kind would need a migration every time a
190// provider exposes a new field, and this object must stay parseable by
191// readers written before that field existed. Keys the console renders are
192// documented where it reads them; keys it does not know are shown verbatim.
193type Resource struct {
194 Provider string `json:"provider"`
195 Kind string `json:"kind"`
196 NativeType string `json:"native_type,omitempty"`
197 ID string `json:"id"`
198 Name string `json:"name,omitempty"`
199 Region string `json:"region,omitempty"`
200 Attrs map[string]string `json:"attrs,omitempty"`
201 Tags map[string]string `json:"tags,omitempty"`
202}
203
204// Attr reads one attribute without the caller having to nil-check the map.
205func (r Resource) Attr(key string) string {
206 if r.Attrs == nil {
207 return ""
208 }
209 return r.Attrs[key]
210}
211
212// Inventory is one provider's estate at one moment. Partial names the reads
213// that FAILED, in words a person can act on ("security groups in eu-west-1:
214// throttled"). It exists because "no security groups" and "the security
215// group read failed" are different sentences and only one of them may be
216// rendered as a fact.
217type Inventory struct {
218 Provider string `json:"provider"`
219 Account string `json:"account,omitempty"`
220 TakenAt time.Time `json:"taken_at"`
221 Resources []Resource `json:"resources"`
222 Partial []string `json:"partial,omitempty"`
223}
224
225// ByKind returns every resource of one kind, in inventory order.
226func (i Inventory) ByKind(kind string) []Resource {
227 var out []Resource
228 for _, r := range i.Resources {
229 if r.Kind == kind {
230 out = append(out, r)
231 }
232 }
233 return out
234}
235
236// Find returns the first resource of kind whose attr equals value, or nil.
237// value == "" never matches: an empty attribute is absence, and matching on
238// it would join every machine with no public IP to every record with no
239// value.
240func (i Inventory) Find(kind, attr, value string) *Resource {
241 if value == "" {
242 return nil
243 }
244 for idx := range i.Resources {
245 if i.Resources[idx].Kind == kind && i.Resources[idx].Attr(attr) == value {
246 return &i.Resources[idx]
247 }
248 }
249 return nil
250}
251
252// Provider is one cloud (or one bare-metal estate) that can describe
253// itself. Implementations return construction-class failures as an error —
254// no credential, no reachable API — and everything else through
255// Inventory.Partial, because a page that can show eleven resource classes
256// must show eleven.
257type Provider interface {
258 Inventory(ctx context.Context) (Inventory, error)
259}
260```
261
262- [ ] **Step 4: Run tests to verify they pass**
263
264Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/infraview/ -count=1`
265Expected: PASS (3 tests).
266
267- [ ] **Step 5: Gate and commit**
268
269```bash
270GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go build ./... && \
271GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go vet ./... && \
272GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./... -count=1 && \
273gofmt -l internal/infraview
274git add internal/infraview
275git commit -m "infraview: provider-neutral infrastructure inventory shape
276
277Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>"
278```
279
280---
281
282### Task 2: `internal/awsinv` — client seam and EC2 mappings
283
284**Files:**
285- Create: `internal/awsinv/clients.go`, `internal/awsinv/ec2.go`
286- Test: `internal/awsinv/ec2_test.go`
287- Modify: `go.mod` (add `service/ec2`, `service/cloudwatch`, `service/acm`, `service/resourcegroupstaggingapi`)
288
289**Interfaces:**
290- Consumes: `infraview.Resource`, the `Kind*` constants.
291- Produces: `awsinv.Clients` struct, `awsinv.Factory` func type, `awsinv.ec2Resources(ctx, EC2API, region string) ([]infraview.Resource, []string)` returning resources and failure lines.
292
293- [ ] **Step 1: Add the SDK dependencies**
294
295```bash
296GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod go get \
297 github.com/aws/aws-sdk-go-v2/service/ec2 \
298 github.com/aws/aws-sdk-go-v2/service/cloudwatch \
299 github.com/aws/aws-sdk-go-v2/service/acm \
300 github.com/aws/aws-sdk-go-v2/service/resourcegroupstaggingapi
301```
302
303(`GOPROXY=off` must NOT be set for this one step; it is the only step in the plan that fetches.)
304
305- [ ] **Step 2: Write the failing test**
306
307```go
308package awsinv
309
310import (
311 "context"
312 "errors"
313 "testing"
314
315 "github.com/aws/aws-sdk-go-v2/aws"
316 "github.com/aws/aws-sdk-go-v2/service/ec2"
317 ec2types "github.com/aws/aws-sdk-go-v2/service/ec2/types"
318 "github.com/carlosframework/platform/internal/infraview"
319)
320
321// fakeEC2 answers whatever the test hands it; a non-nil err field for a
322// call makes that ONE call fail, which is how the partial-failure rule
323// below is proven.
324type fakeEC2 struct {
325 instances *ec2.DescribeInstancesOutput
326 volumes *ec2.DescribeVolumesOutput
327 addresses *ec2.DescribeAddressesOutput
328 groups *ec2.DescribeSecurityGroupsOutput
329 vpcs *ec2.DescribeVpcsOutput
330 subnets *ec2.DescribeSubnetsOutput
331 regions *ec2.DescribeRegionsOutput
332 errVols error
333}
334
335func (f *fakeEC2) DescribeInstances(context.Context, *ec2.DescribeInstancesInput, ...func(*ec2.Options)) (*ec2.DescribeInstancesOutput, error) {
336 return f.instances, nil
337}
338func (f *fakeEC2) DescribeVolumes(context.Context, *ec2.DescribeVolumesInput, ...func(*ec2.Options)) (*ec2.DescribeVolumesOutput, error) {
339 if f.errVols != nil {
340 return nil, f.errVols
341 }
342 return f.volumes, nil
343}
344func (f *fakeEC2) DescribeAddresses(context.Context, *ec2.DescribeAddressesInput, ...func(*ec2.Options)) (*ec2.DescribeAddressesOutput, error) {
345 return f.addresses, nil
346}
347func (f *fakeEC2) DescribeSecurityGroups(context.Context, *ec2.DescribeSecurityGroupsInput, ...func(*ec2.Options)) (*ec2.DescribeSecurityGroupsOutput, error) {
348 return f.groups, nil
349}
350func (f *fakeEC2) DescribeVpcs(context.Context, *ec2.DescribeVpcsInput, ...func(*ec2.Options)) (*ec2.DescribeVpcsOutput, error) {
351 return f.vpcs, nil
352}
353func (f *fakeEC2) DescribeSubnets(context.Context, *ec2.DescribeSubnetsInput, ...func(*ec2.Options)) (*ec2.DescribeSubnetsOutput, error) {
354 return f.subnets, nil
355}
356func (f *fakeEC2) DescribeRegions(context.Context, *ec2.DescribeRegionsInput, ...func(*ec2.Options)) (*ec2.DescribeRegionsOutput, error) {
357 return f.regions, nil
358}
359
360func find(rs []infraview.Resource, kind, id string) *infraview.Resource {
361 for i := range rs {
362 if rs[i].Kind == kind && rs[i].ID == id {
363 return &rs[i]
364 }
365 }
366 return nil
367}
368
369func TestEC2InstanceMapping(t *testing.T) {
370 f := &fakeEC2{instances: &ec2.DescribeInstancesOutput{
371 Reservations: []ec2types.Reservation{{Instances: []ec2types.Instance{{
372 InstanceId: aws.String("i-123"),
373 InstanceType: ec2types.InstanceTypeT4gMedium,
374 State: &ec2types.InstanceState{Name: ec2types.InstanceStateNameRunning},
375 Placement: &ec2types.Placement{AvailabilityZone: aws.String("eu-west-1a")},
376 PublicIpAddress: aws.String("52.0.0.1"),
377 PrivateIpAddress: aws.String("10.0.0.5"),
378 Tags: []ec2types.Tag{{Key: aws.String("Name"), Value: aws.String("carlos-eu-west-1")}},
379 }}}},
380 }}
381
382 rs, partial := ec2Resources(context.Background(), f, "eu-west-1")
383 if len(partial) != 0 {
384 t.Fatalf("partial = %v, want none", partial)
385 }
386 m := find(rs, infraview.KindMachine, "i-123")
387 if m == nil {
388 t.Fatalf("no machine in %+v", rs)
389 }
390 if m.Name != "carlos-eu-west-1" {
391 t.Errorf("Name = %q, want the Name tag", m.Name)
392 }
393 if m.Region != "eu-west-1" || m.NativeType != "ec2:instance" {
394 t.Errorf("region/type = %q/%q", m.Region, m.NativeType)
395 }
396 for k, want := range map[string]string{
397 "type": "t4g.medium", "state": "running", "az": "eu-west-1a",
398 "public_ip": "52.0.0.1", "private_ip": "10.0.0.5",
399 } {
400 if got := m.Attr(k); got != want {
401 t.Errorf("attr %s = %q, want %q", k, got, want)
402 }
403 }
404}
405
406// One failing call costs its own resources and nothing else — the rule the
407// whole inventory depends on, since a page that shows eleven of twelve
408// classes is worth far more than one that shows none.
409func TestEC2PartialFailureCostsOnlyItsOwnCall(t *testing.T) {
410 f := &fakeEC2{
411 instances: &ec2.DescribeInstancesOutput{Reservations: []ec2types.Reservation{{
412 Instances: []ec2types.Instance{{InstanceId: aws.String("i-123")}},
413 }}},
414 errVols: errors.New("Throttling: rate exceeded"),
415 }
416 rs, partial := ec2Resources(context.Background(), f, "eu-west-1")
417 if find(rs, infraview.KindMachine, "i-123") == nil {
418 t.Fatalf("a failed volume read must not cost the instances")
419 }
420 if len(partial) != 1 {
421 t.Fatalf("partial = %v, want exactly the volumes line", partial)
422 }
423 if !contains(partial[0], "volumes") || !contains(partial[0], "eu-west-1") {
424 t.Errorf("partial line %q must name what failed and where", partial[0])
425 }
426}
427
428func contains(s, sub string) bool { return len(s) >= len(sub) && (stringIndex(s, sub) >= 0) }
429func stringIndex(s, sub string) int {
430 for i := 0; i+len(sub) <= len(s); i++ {
431 if s[i:i+len(sub)] == sub {
432 return i
433 }
434 }
435 return -1
436}
437```
438
439- [ ] **Step 3: Run test to verify it fails**
440
441Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/awsinv/ -count=1`
442Expected: FAIL — `undefined: ec2Resources`.
443
444- [ ] **Step 4: Write `clients.go`**
445
446```go
447// Package awsinv discovers what an AWS account is carrying and reports it
448// as an internal/infraview.Inventory. It is the ONLY package in this repo
449// that imports an AWS SDK for inventory purposes, which is the whole point
450// of the seam: the console page that renders an estate never sees an AWS
451// type, so a second provider is a new package rather than a rewrite.
452//
453// Every call here is read-only. The IAM policy this package needs
454// (infra/modules/region-host/main.tf) contains no write action, so that
455// property is enforced by the credential and not by this comment.
456package awsinv
457
458import (
459 "context"
460
461 "github.com/aws/aws-sdk-go-v2/aws"
462 "github.com/aws/aws-sdk-go-v2/service/acm"
463 "github.com/aws/aws-sdk-go-v2/service/cloudwatch"
464 "github.com/aws/aws-sdk-go-v2/service/ec2"
465 "github.com/aws/aws-sdk-go-v2/service/iam"
466 "github.com/aws/aws-sdk-go-v2/service/resourcegroupstaggingapi"
467 "github.com/aws/aws-sdk-go-v2/service/route53"
468 "github.com/aws/aws-sdk-go-v2/service/s3"
469)
470
471// The narrow interfaces exist so the mappings can be tested against fakes
472// with no network and no credential. Each lists exactly the calls this
473// package makes and nothing else — an interface that named the whole
474// client would be an invitation to reach for a write.
475type EC2API interface {
476 DescribeInstances(context.Context, *ec2.DescribeInstancesInput, ...func(*ec2.Options)) (*ec2.DescribeInstancesOutput, error)
477 DescribeVolumes(context.Context, *ec2.DescribeVolumesInput, ...func(*ec2.Options)) (*ec2.DescribeVolumesOutput, error)
478 DescribeAddresses(context.Context, *ec2.DescribeAddressesInput, ...func(*ec2.Options)) (*ec2.DescribeAddressesOutput, error)
479 DescribeSecurityGroups(context.Context, *ec2.DescribeSecurityGroupsInput, ...func(*ec2.Options)) (*ec2.DescribeSecurityGroupsOutput, error)
480 DescribeVpcs(context.Context, *ec2.DescribeVpcsInput, ...func(*ec2.Options)) (*ec2.DescribeVpcsOutput, error)
481 DescribeSubnets(context.Context, *ec2.DescribeSubnetsInput, ...func(*ec2.Options)) (*ec2.DescribeSubnetsOutput, error)
482 DescribeRegions(context.Context, *ec2.DescribeRegionsInput, ...func(*ec2.Options)) (*ec2.DescribeRegionsOutput, error)
483}
484
485type S3API interface {
486 ListBuckets(context.Context, *s3.ListBucketsInput, ...func(*s3.Options)) (*s3.ListBucketsOutput, error)
487 GetBucketLocation(context.Context, *s3.GetBucketLocationInput, ...func(*s3.Options)) (*s3.GetBucketLocationOutput, error)
488 GetBucketVersioning(context.Context, *s3.GetBucketVersioningInput, ...func(*s3.Options)) (*s3.GetBucketVersioningOutput, error)
489 GetBucketEncryption(context.Context, *s3.GetBucketEncryptionInput, ...func(*s3.Options)) (*s3.GetBucketEncryptionOutput, error)
490 GetPublicAccessBlock(context.Context, *s3.GetPublicAccessBlockInput, ...func(*s3.Options)) (*s3.GetPublicAccessBlockOutput, error)
491}
492
493type Route53API interface {
494 ListHostedZones(context.Context, *route53.ListHostedZonesInput, ...func(*route53.Options)) (*route53.ListHostedZonesOutput, error)
495 ListResourceRecordSets(context.Context, *route53.ListResourceRecordSetsInput, ...func(*route53.Options)) (*route53.ListResourceRecordSetsOutput, error)
496}
497
498type IAMAPI interface {
499 ListRoles(context.Context, *iam.ListRolesInput, ...func(*iam.Options)) (*iam.ListRolesOutput, error)
500 ListUsers(context.Context, *iam.ListUsersInput, ...func(*iam.Options)) (*iam.ListUsersOutput, error)
501 ListAttachedRolePolicies(context.Context, *iam.ListAttachedRolePoliciesInput, ...func(*iam.Options)) (*iam.ListAttachedRolePoliciesOutput, error)
502}
503
504type CloudWatchAPI interface {
505 DescribeAlarms(context.Context, *cloudwatch.DescribeAlarmsInput, ...func(*cloudwatch.Options)) (*cloudwatch.DescribeAlarmsOutput, error)
506}
507
508type ACMAPI interface {
509 ListCertificates(context.Context, *acm.ListCertificatesInput, ...func(*acm.Options)) (*acm.ListCertificatesOutput, error)
510}
511
512type TaggingAPI interface {
513 GetResources(context.Context, *resourcegroupstaggingapi.GetResourcesInput, ...func(*resourcegroupstaggingapi.Options)) (*resourcegroupstaggingapi.GetResourcesOutput, error)
514}
515
516// Clients is one region's worth of read-only clients. S3/Route53/IAM are
517// global services; the assembly calls them once, from the home region's
518// Clients, rather than once per region.
519type Clients struct {
520 EC2 EC2API
521 S3 S3API
522 Route53 Route53API
523 IAM IAMAPI
524 CloudWatch CloudWatchAPI
525 ACM ACMAPI
526 Tagging TaggingAPI
527}
528
529// Factory builds the clients for one region. Tests inject a fake; the real
530// one below rebases a single resolved aws.Config onto each region so the
531// credential chain resolves exactly once.
532type Factory func(region string) Clients
533
534func factoryFor(cfg aws.Config) Factory {
535 return func(region string) Clients {
536 c := cfg.Copy()
537 if region != "" {
538 c.Region = region
539 }
540 return Clients{
541 EC2: ec2.NewFromConfig(c),
542 S3: s3.NewFromConfig(c),
543 Route53: route53.NewFromConfig(c),
544 IAM: iam.NewFromConfig(c),
545 CloudWatch: cloudwatch.NewFromConfig(c),
546 ACM: acm.NewFromConfig(c),
547 Tagging: resourcegroupstaggingapi.NewFromConfig(c),
548 }
549 }
550}
551```
552
553- [ ] **Step 5: Write `ec2.go`**
554
555```go
556package awsinv
557
558import (
559 "context"
560 "fmt"
561 "strconv"
562
563 "github.com/aws/aws-sdk-go-v2/aws"
564 "github.com/aws/aws-sdk-go-v2/service/ec2"
565 ec2types "github.com/aws/aws-sdk-go-v2/service/ec2/types"
566 "github.com/carlosframework/platform/internal/infraview"
567)
568
569// res builds one Resource with the fields every mapping sets the same way,
570// so a new mapping cannot forget the provider stamp.
571func res(kind, nativeType, id, name, region string) infraview.Resource {
572 return infraview.Resource{
573 Provider: "aws", Kind: kind, NativeType: nativeType,
574 ID: id, Name: name, Region: region, Attrs: map[string]string{},
575 }
576}
577
578// set writes an attribute only when it has a value: an absent key and an
579// empty string are different claims, and the joins treat "" as absence.
580func set(r *infraview.Resource, key, value string) {
581 if value != "" {
582 r.Attrs[key] = value
583 }
584}
585
586func tagsOf(tags []ec2types.Tag) map[string]string {
587 if len(tags) == 0 {
588 return nil
589 }
590 out := map[string]string{}
591 for _, t := range tags {
592 out[aws.ToString(t.Key)] = aws.ToString(t.Value)
593 }
594 return out
595}
596
597// ec2Resources maps one region's EC2 surface. Each call contributes its own
598// resources and, on failure, one line to partial — never an error: this
599// function's whole contract is that a throttled volumes call costs the
600// volumes and nothing else.
601func ec2Resources(ctx context.Context, api EC2API, region string) (out []infraview.Resource, partial []string) {
602 fail := func(what string, err error) {
603 partial = append(partial, fmt.Sprintf("%s in %s: %v", what, region, err))
604 }
605
606 if o, err := api.DescribeInstances(ctx, &ec2.DescribeInstancesInput{}); err != nil {
607 fail("instances", err)
608 } else {
609 for _, rv := range o.Reservations {
610 for _, in := range rv.Instances {
611 tags := tagsOf(in.Tags)
612 r := res(infraview.KindMachine, "ec2:instance", aws.ToString(in.InstanceId), tags["Name"], region)
613 r.Tags = tags
614 set(&r, "type", string(in.InstanceType))
615 if in.State != nil {
616 set(&r, "state", string(in.State.Name))
617 }
618 if in.Placement != nil {
619 set(&r, "az", aws.ToString(in.Placement.AvailabilityZone))
620 }
621 set(&r, "public_ip", aws.ToString(in.PublicIpAddress))
622 set(&r, "private_ip", aws.ToString(in.PrivateIpAddress))
623 if in.LaunchTime != nil {
624 set(&r, "launched", in.LaunchTime.UTC().Format("2006-01-02"))
625 }
626 out = append(out, r)
627 }
628 }
629 }
630
631 if o, err := api.DescribeVolumes(ctx, &ec2.DescribeVolumesInput{}); err != nil {
632 fail("volumes", err)
633 } else {
634 for _, v := range o.Volumes {
635 r := res(infraview.KindDisk, "ec2:volume", aws.ToString(v.VolumeId), tagsOf(v.Tags)["Name"], region)
636 r.Tags = tagsOf(v.Tags)
637 if v.Size != nil {
638 set(&r, "size_gb", strconv.Itoa(int(*v.Size)))
639 }
640 set(&r, "volume_type", string(v.VolumeType))
641 for _, a := range v.Attachments {
642 set(&r, "attached_to", aws.ToString(a.InstanceId))
643 }
644 out = append(out, r)
645 }
646 }
647
648 if o, err := api.DescribeAddresses(ctx, &ec2.DescribeAddressesInput{}); err != nil {
649 fail("elastic IPs", err)
650 } else {
651 for _, a := range o.Addresses {
652 r := res(infraview.KindIP, "ec2:eip", aws.ToString(a.AllocationId), tagsOf(a.Tags)["Name"], region)
653 r.Tags = tagsOf(a.Tags)
654 set(&r, "address", aws.ToString(a.PublicIp))
655 set(&r, "attached_to", aws.ToString(a.InstanceId))
656 out = append(out, r)
657 }
658 }
659
660 if o, err := api.DescribeSecurityGroups(ctx, &ec2.DescribeSecurityGroupsInput{}); err != nil {
661 fail("security groups", err)
662 } else {
663 for _, g := range o.SecurityGroups {
664 r := res(infraview.KindFirewall, "ec2:security-group", aws.ToString(g.GroupId), aws.ToString(g.GroupName), region)
665 r.Tags = tagsOf(g.Tags)
666 set(&r, "ingress", summarizePerms(g.IpPermissions))
667 set(&r, "egress", summarizePerms(g.IpPermissionsEgress))
668 out = append(out, r)
669 }
670 }
671
672 if o, err := api.DescribeVpcs(ctx, &ec2.DescribeVpcsInput{}); err != nil {
673 fail("VPCs", err)
674 } else {
675 for _, v := range o.Vpcs {
676 r := res(infraview.KindNetwork, "ec2:vpc", aws.ToString(v.VpcId), tagsOf(v.Tags)["Name"], region)
677 r.Tags = tagsOf(v.Tags)
678 set(&r, "cidr", aws.ToString(v.CidrBlock))
679 out = append(out, r)
680 }
681 }
682
683 if o, err := api.DescribeSubnets(ctx, &ec2.DescribeSubnetsInput{}); err != nil {
684 fail("subnets", err)
685 } else {
686 for _, s := range o.Subnets {
687 r := res(infraview.KindNetwork, "ec2:subnet", aws.ToString(s.SubnetId), tagsOf(s.Tags)["Name"], region)
688 r.Tags = tagsOf(s.Tags)
689 set(&r, "cidr", aws.ToString(s.CidrBlock))
690 set(&r, "az", aws.ToString(s.AvailabilityZone))
691 set(&r, "vpc", aws.ToString(s.VpcId))
692 out = append(out, r)
693 }
694 }
695 return out, partial
696}
697
698// summarizePerms renders a rule set as "tcp/22 from 10.0.0.0/8 · tcp/443
699// from 0.0.0.0/0" — a sentence an operator reads, never the raw JSON, which
700// would be both unreadable and a wall of detail the page does not want.
701func summarizePerms(perms []ec2types.IpPermission) string {
702 out := ""
703 for _, p := range perms {
704 proto := aws.ToString(p.IpProtocol)
705 if proto == "-1" {
706 proto = "all"
707 }
708 port := "all"
709 if p.FromPort != nil {
710 port = strconv.Itoa(int(*p.FromPort))
711 if p.ToPort != nil && *p.ToPort != *p.FromPort {
712 port += "-" + strconv.Itoa(int(*p.ToPort))
713 }
714 }
715 for _, rng := range p.IpRanges {
716 if out != "" {
717 out += " · "
718 }
719 out += fmt.Sprintf("%s/%s from %s", proto, port, aws.ToString(rng.CidrIp))
720 }
721 }
722 return out
723}
724```
725
726- [ ] **Step 6: Run tests to verify they pass**
727
728Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/awsinv/ -count=1 -v`
729Expected: PASS — `TestEC2InstanceMapping`, `TestEC2PartialFailureCostsOnlyItsOwnCall`.
730
731- [ ] **Step 7: Gate and commit**
732
733```bash
734GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go build ./... && \
735GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go vet ./... && \
736GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./... -count=1 && \
737gofmt -l internal/awsinv
738git add go.mod go.sum internal/awsinv
739git commit -m "awsinv: read-only client seam and EC2 mappings
740
741Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>"
742```
743
744---
745
746### Task 3: `internal/awsinv` — remaining services, catch-all, assembly
747
748**Files:**
749- Create: `internal/awsinv/rest.go`, `internal/awsinv/awsinv.go`
750- Test: `internal/awsinv/rest_test.go`, `internal/awsinv/awsinv_test.go`
751
752**Interfaces:**
753- Consumes: `Clients`, `Factory`, `ec2Resources` (Task 2), `infraview.Inventory`.
754- Produces: `awsinv.New(ctx context.Context) (*Provider, error)`, `(*Provider).Inventory(ctx) (infraview.Inventory, error)`, and for tests `newWithFactory(f Factory, home string, regions []string, account string) *Provider`.
755
756- [ ] **Step 1: Write the failing assembly test**
757
758```go
759package awsinv
760
761import (
762 "context"
763 "errors"
764 "testing"
765
766 "github.com/aws/aws-sdk-go-v2/aws"
767 "github.com/aws/aws-sdk-go-v2/service/ec2"
768 ec2types "github.com/aws/aws-sdk-go-v2/service/ec2/types"
769 "github.com/aws/aws-sdk-go-v2/service/resourcegroupstaggingapi"
770 rgtypes "github.com/aws/aws-sdk-go-v2/service/resourcegroupstaggingapi/types"
771 "github.com/carlosframework/platform/internal/infraview"
772)
773
774type fakeTagging struct {
775 out *resourcegroupstaggingapi.GetResourcesOutput
776 err error
777}
778
779func (f *fakeTagging) GetResources(context.Context, *resourcegroupstaggingapi.GetResourcesInput, ...func(*resourcegroupstaggingapi.Options)) (*resourcegroupstaggingapi.GetResourcesOutput, error) {
780 return f.out, f.err
781}
782
783// A resource the typed mappings already produced must NOT appear twice
784// because the catch-all also saw it; anything the catch-all alone knows
785// about must appear as "other" rather than vanishing.
786func TestCatchAllDedupesAgainstTypedResources(t *testing.T) {
787 factory := func(region string) Clients {
788 return Clients{
789 EC2: &fakeEC2{instances: &ec2.DescribeInstancesOutput{
790 Reservations: []ec2types.Reservation{{Instances: []ec2types.Instance{{
791 InstanceId: aws.String("i-123"),
792 }}}},
793 }},
794 Tagging: &fakeTagging{out: &resourcegroupstaggingapi.GetResourcesOutput{
795 ResourceTagMappingList: []rgtypes.ResourceTagMapping{
796 {ResourceARN: aws.String("arn:aws:ec2:eu-west-1:1234:instance/i-123")},
797 {ResourceARN: aws.String("arn:aws:sqs:eu-west-1:1234:some-queue")},
798 },
799 }},
800 }
801 }
802 p := newWithFactory(factory, "eu-west-1", []string{"eu-west-1"}, "1234")
803
804 inv, err := p.Inventory(context.Background())
805 if err != nil {
806 t.Fatalf("Inventory: %v", err)
807 }
808 machines := inv.ByKind(infraview.KindMachine)
809 if len(machines) != 1 {
810 t.Fatalf("machines = %d, want 1 (the catch-all must not duplicate a typed resource)", len(machines))
811 }
812 others := inv.ByKind(infraview.KindOther)
813 if len(others) != 1 || others[0].NativeType != "sqs" {
814 t.Fatalf("others = %+v, want one sqs resource", others)
815 }
816 if inv.Provider != "aws" || inv.Account != "1234" || inv.TakenAt.IsZero() {
817 t.Fatalf("header not stamped: %+v", inv)
818 }
819}
820
821// Auth-shaped failure with nothing discovered is the one case that must
822// reach the caller as an error — it is what latches the console's pass off
823// so an ungranted deployment says "not granted" instead of erroring hourly.
824func TestAuthShapedWithNoResourcesIsAnError(t *testing.T) {
825 authErr := &fakeAPIErr{code: "AccessDenied"}
826 factory := func(region string) Clients {
827 return Clients{
828 EC2: &fakeEC2Failing{err: authErr},
829 Tagging: &fakeTagging{err: authErr},
830 }
831 }
832 p := newWithFactory(factory, "eu-west-1", []string{"eu-west-1"}, "")
833 if _, err := p.Inventory(context.Background()); err == nil {
834 t.Fatal("want an error when every read is denied and nothing was discovered")
835 }
836}
837
838// The same denial, but something WAS discovered: the inventory is returned
839// with the failures named in Partial, never thrown away.
840func TestPartialSurvivesWhenSomethingWasDiscovered(t *testing.T) {
841 factory := func(region string) Clients {
842 return Clients{
843 EC2: &fakeEC2{instances: &ec2.DescribeInstancesOutput{
844 Reservations: []ec2types.Reservation{{Instances: []ec2types.Instance{{
845 InstanceId: aws.String("i-123"),
846 }}}},
847 errVols: errors.New("AccessDenied: not authorized"),
848 }},
849 Tagging: &fakeTagging{err: errors.New("AccessDenied: not authorized")},
850 }
851 }
852 p := newWithFactory(factory, "eu-west-1", []string{"eu-west-1"}, "")
853 inv, err := p.Inventory(context.Background())
854 if err != nil {
855 t.Fatalf("Inventory: %v", err)
856 }
857 if len(inv.Partial) == 0 {
858 t.Fatal("failures must be named in Partial")
859 }
860}
861```
862
863Add the two small fakes this test needs to `rest_test.go`:
864
865```go
866type fakeAPIErr struct{ code string }
867
868func (e *fakeAPIErr) Error() string { return e.code }
869func (e *fakeAPIErr) ErrorCode() string { return e.code }
870func (e *fakeAPIErr) ErrorMessage() string { return e.code }
871func (e *fakeAPIErr) ErrorFault() smithy.ErrorFault { return smithy.FaultClient }
872
873// fakeEC2Failing fails every call with the same error.
874type fakeEC2Failing struct{ err error }
875
876func (f *fakeEC2Failing) DescribeInstances(context.Context, *ec2.DescribeInstancesInput, ...func(*ec2.Options)) (*ec2.DescribeInstancesOutput, error) {
877 return nil, f.err
878}
879// … the remaining six EC2API methods, each `return nil, f.err`
880```
881
882- [ ] **Step 2: Run test to verify it fails**
883
884Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/awsinv/ -count=1`
885Expected: FAIL — `undefined: newWithFactory`.
886
887- [ ] **Step 3: Write `rest.go`**
888
889Mappings, each following `ec2Resources`' contract exactly (own resources, own `partial` line, never an error):
890
891```go
892package awsinv
893
894import (
895 "context"
896 "fmt"
897 "strconv"
898 "strings"
899
900 "github.com/aws/aws-sdk-go-v2/aws"
901 "github.com/aws/aws-sdk-go-v2/service/acm"
902 "github.com/aws/aws-sdk-go-v2/service/cloudwatch"
903 "github.com/aws/aws-sdk-go-v2/service/iam"
904 "github.com/aws/aws-sdk-go-v2/service/resourcegroupstaggingapi"
905 "github.com/aws/aws-sdk-go-v2/service/route53"
906 "github.com/aws/aws-sdk-go-v2/service/s3"
907 "github.com/carlosframework/platform/internal/infraview"
908)
909
910// joinedRecordTypes are the record types that can participate in the
911// DNS -> IP -> machine join, and so the only ones materialised as their own
912// Resource. Everything else is counted into the zone's record_count: a zone
913// can hold thousands of records and this object is read on every page load.
914var joinedRecordTypes = map[string]bool{"A": true, "AAAA": true}
915
916func s3Resources(ctx context.Context, api S3API) (out []infraview.Resource, partial []string) {
917 o, err := api.ListBuckets(ctx, &s3.ListBucketsInput{})
918 if err != nil {
919 return nil, []string{fmt.Sprintf("buckets: %v", err)}
920 }
921 for _, b := range o.Buckets {
922 name := aws.ToString(b.Name)
923 r := res(infraview.KindBucket, "s3:bucket", name, name, "")
924 if loc, err := api.GetBucketLocation(ctx, &s3.GetBucketLocationInput{Bucket: b.Name}); err == nil {
925 region := string(loc.LocationConstraint)
926 if region == "" {
927 region = "us-east-1" // the API's documented empty-means-us-east-1
928 }
929 r.Region = region
930 }
931 // Each of the three posture reads is best-effort per bucket: a
932 // bucket whose versioning cannot be read still appears, with the
933 // attribute absent rather than a false "off". "off" is a claim.
934 if v, err := api.GetBucketVersioning(ctx, &s3.GetBucketVersioningInput{Bucket: b.Name}); err == nil {
935 set(&r, "versioning", strings.ToLower(string(v.Status)))
936 }
937 if _, err := api.GetBucketEncryption(ctx, &s3.GetBucketEncryptionInput{Bucket: b.Name}); err == nil {
938 set(&r, "encryption", "on")
939 }
940 if p, err := api.GetPublicAccessBlock(ctx, &s3.GetPublicAccessBlockInput{Bucket: b.Name}); err == nil && p.PublicAccessBlockConfiguration != nil {
941 c := p.PublicAccessBlockConfiguration
942 if aws.ToBool(c.BlockPublicAcls) && aws.ToBool(c.BlockPublicPolicy) {
943 set(&r, "public_access_block", "on")
944 } else {
945 set(&r, "public_access_block", "partial")
946 }
947 }
948 out = append(out, r)
949 }
950 return out, partial
951}
952
953func route53Resources(ctx context.Context, api Route53API) (out []infraview.Resource, partial []string) {
954 zones, err := api.ListHostedZones(ctx, &route53.ListHostedZonesInput{})
955 if err != nil {
956 return nil, []string{fmt.Sprintf("hosted zones: %v", err)}
957 }
958 for _, z := range zones.HostedZones {
959 id := aws.ToString(z.Id)
960 name := strings.TrimSuffix(aws.ToString(z.Name), ".")
961 zr := res(infraview.KindDNSZone, "route53:hosted-zone", id, name, "")
962 if z.Config != nil && z.Config.PrivateZone {
963 set(&zr, "private", "yes")
964 }
965 count := 0
966 var next *string
967 for {
968 recs, err := api.ListResourceRecordSets(ctx, &route53.ListResourceRecordSetsInput{
969 HostedZoneId: z.Id, StartRecordName: next,
970 })
971 if err != nil {
972 partial = append(partial, fmt.Sprintf("records in %s: %v", name, err))
973 break
974 }
975 for _, rec := range recs.ResourceRecordSets {
976 count++
977 rtype := string(rec.Type)
978 if !joinedRecordTypes[rtype] {
979 continue
980 }
981 rname := strings.TrimSuffix(aws.ToString(rec.Name), ".")
982 for _, v := range rec.ResourceRecords {
983 rr := res(infraview.KindDNSRecord, "route53:record", rname+"/"+rtype+"/"+aws.ToString(v.Value), rname, "")
984 set(&rr, "zone", name)
985 set(&rr, "type", rtype)
986 set(&rr, "value", aws.ToString(v.Value))
987 out = append(out, rr)
988 }
989 if rec.AliasTarget != nil {
990 rr := res(infraview.KindDNSRecord, "route53:alias", rname+"/ALIAS", rname, "")
991 set(&rr, "zone", name)
992 set(&rr, "type", "ALIAS")
993 set(&rr, "value", strings.TrimSuffix(aws.ToString(rec.AliasTarget.DNSName), "."))
994 out = append(out, rr)
995 }
996 }
997 if !recs.IsTruncated {
998 break
999 }
1000 next = recs.NextRecordName
1001 }
1002 set(&zr, "record_count", strconv.Itoa(count))
1003 out = append(out, zr)
1004 }
1005 return out, partial
1006}
1007
1008// iamResources reports identities as NAMES and a count of attached
1009// policies. Policy documents are deliberately never read: this page is a
1010// map, not a credential viewer (spec §4).
1011func iamResources(ctx context.Context, api IAMAPI) (out []infraview.Resource, partial []string) {
1012 if o, err := api.ListRoles(ctx, &iam.ListRolesInput{}); err != nil {
1013 partial = append(partial, fmt.Sprintf("IAM roles: %v", err))
1014 } else {
1015 for _, role := range o.Roles {
1016 name := aws.ToString(role.RoleName)
1017 r := res(infraview.KindIdentity, "iam:role", aws.ToString(role.Arn), name, "")
1018 set(&r, "kind", "role")
1019 if pol, err := api.ListAttachedRolePolicies(ctx, &iam.ListAttachedRolePoliciesInput{RoleName: role.RoleName}); err == nil {
1020 set(&r, "attached_policies", strconv.Itoa(len(pol.AttachedPolicies)))
1021 }
1022 out = append(out, r)
1023 }
1024 }
1025 if o, err := api.ListUsers(ctx, &iam.ListUsersInput{}); err != nil {
1026 partial = append(partial, fmt.Sprintf("IAM users: %v", err))
1027 } else {
1028 for _, u := range o.Users {
1029 r := res(infraview.KindIdentity, "iam:user", aws.ToString(u.Arn), aws.ToString(u.UserName), "")
1030 set(&r, "kind", "user")
1031 out = append(out, r)
1032 }
1033 }
1034 return out, partial
1035}
1036
1037func alarmResources(ctx context.Context, api CloudWatchAPI, region string) (out []infraview.Resource, partial []string) {
1038 o, err := api.DescribeAlarms(ctx, &cloudwatch.DescribeAlarmsInput{})
1039 if err != nil {
1040 return nil, []string{fmt.Sprintf("alarms in %s: %v", region, err)}
1041 }
1042 for _, a := range o.MetricAlarms {
1043 r := res(infraview.KindAlarm, "cloudwatch:alarm", aws.ToString(a.AlarmArn), aws.ToString(a.AlarmName), region)
1044 set(&r, "state", string(a.StateValue))
1045 set(&r, "metric", aws.ToString(a.MetricName))
1046 for _, d := range a.Dimensions {
1047 if aws.ToString(d.Name) == "InstanceId" {
1048 set(&r, "target", aws.ToString(d.Value))
1049 }
1050 }
1051 out = append(out, r)
1052 }
1053 return out, partial
1054}
1055
1056func certResources(ctx context.Context, api ACMAPI, region string) (out []infraview.Resource, partial []string) {
1057 o, err := api.ListCertificates(ctx, &acm.ListCertificatesInput{})
1058 if err != nil {
1059 return nil, []string{fmt.Sprintf("certificates in %s: %v", region, err)}
1060 }
1061 for _, c := range o.CertificateSummaryList {
1062 r := res(infraview.KindCertificate, "acm:certificate", aws.ToString(c.CertificateArn), aws.ToString(c.DomainName), region)
1063 set(&r, "domain", aws.ToString(c.DomainName))
1064 set(&r, "status", string(c.Status))
1065 if c.NotAfter != nil {
1066 set(&r, "expires", c.NotAfter.UTC().Format("2006-01-02"))
1067 }
1068 out = append(out, r)
1069 }
1070 return out, partial
1071}
1072
1073// catchAll is the one genuinely AWS-shaped trick in this package: one call
1074// that returns every TAGGABLE resource in the region, so a resource type
1075// nobody wrote a mapping for is still visible. seen holds the ids the typed
1076// mappings already produced — an ARN whose id is in it is skipped, so the
1077// catch-all only ever ADDS.
1078func catchAll(ctx context.Context, api TaggingAPI, region string, seen map[string]bool) (out []infraview.Resource, partial []string) {
1079 var token *string
1080 for {
1081 o, err := api.GetResources(ctx, &resourcegroupstaggingapi.GetResourcesInput{PaginationToken: token})
1082 if err != nil {
1083 return out, append(partial, fmt.Sprintf("resource inventory in %s: %v", region, err))
1084 }
1085 for _, m := range o.ResourceTagMappingList {
1086 arn := aws.ToString(m.ResourceARN)
1087 service, id := parseARN(arn)
1088 if id == "" || seen[id] || seen[arn] {
1089 continue
1090 }
1091 r := res(infraview.KindOther, service, arn, id, region)
1092 for _, t := range m.Tags {
1093 if r.Tags == nil {
1094 r.Tags = map[string]string{}
1095 }
1096 r.Tags[aws.ToString(t.Key)] = aws.ToString(t.Value)
1097 }
1098 out = append(out, r)
1099 }
1100 if o.PaginationToken == nil || *o.PaginationToken == "" {
1101 return out, partial
1102 }
1103 token = o.PaginationToken
1104 }
1105}
1106
1107// parseARN pulls the service and the trailing resource id out of an ARN:
1108// arn:aws:ec2:eu-west-1:1234:instance/i-123 -> ("ec2", "i-123").
1109func parseARN(arn string) (service, id string) {
1110 parts := strings.SplitN(arn, ":", 6)
1111 if len(parts) < 6 {
1112 return "", ""
1113 }
1114 service = parts[2]
1115 tail := parts[5]
1116 if i := strings.LastIndexAny(tail, "/:"); i >= 0 {
1117 tail = tail[i+1:]
1118 }
1119 return service, tail
1120}
1121```
1122
1123- [ ] **Step 4: Write `awsinv.go` (assembly)**
1124
1125```go
1126package awsinv
1127
1128import (
1129 "context"
1130 "errors"
1131 "fmt"
1132 "time"
1133
1134 awsconfig "github.com/aws/aws-sdk-go-v2/config"
1135 "github.com/aws/aws-sdk-go-v2/service/ec2"
1136 "github.com/aws/aws-sdk-go-v2/service/sts"
1137 "github.com/aws/smithy-go"
1138 "github.com/carlosframework/platform/internal/infraview"
1139)
1140
1141// credentialProbe is how long New waits for the credential chain to answer
1142// before deciding this deployment has none. Five seconds, matching
1143// cepull.go's own bounded probe: on a box with an instance role it is
1144// instant, and on a laptop with no credential at all the chain can
1145// otherwise hang on IMDS.
1146const credentialProbe = 5 * time.Second
1147
1148// Provider is the AWS implementation of infraview.Provider.
1149type Provider struct {
1150 factory Factory
1151 home string // the region whose clients answer the global services
1152 regions []string // every region to inventory
1153 account string
1154 now func() time.Time // test seam
1155}
1156
1157// New resolves the credential chain once, discovers the account's
1158// opted-in regions, and returns a Provider. Every failure here is
1159// construction-class — no config, no credential, no reachable EC2 — and the
1160// caller (the console pass) treats an auth-shaped one as "this deployment
1161// has no inventory grant", latching the pass off rather than retrying
1162// hourly forever.
1163func New(ctx context.Context) (*Provider, error) {
1164 cfg, err := awsconfig.LoadDefaultConfig(ctx)
1165 if err != nil {
1166 return nil, fmt.Errorf("awsinv: aws config: %w", err)
1167 }
1168 probeCtx, cancel := context.WithTimeout(ctx, credentialProbe)
1169 defer cancel()
1170 if _, err := cfg.Credentials.Retrieve(probeCtx); err != nil {
1171 return nil, fmt.Errorf("awsinv: no usable credential: %w", err)
1172 }
1173 factory := factoryFor(cfg)
1174 home := cfg.Region
1175 if home == "" {
1176 home = "us-east-1"
1177 }
1178
1179 regions := []string{home}
1180 if o, err := factory(home).EC2.DescribeRegions(ctx, &ec2.DescribeRegionsInput{}); err != nil {
1181 if IsAuthShaped(err) {
1182 return nil, fmt.Errorf("awsinv: describe regions: %w", err)
1183 }
1184 // Not auth-shaped: inventory the home region alone rather than
1185 // nothing. The missing regions surface in Partial at assembly.
1186 } else {
1187 regions = regions[:0]
1188 for _, r := range o.Regions {
1189 if r.RegionName != nil {
1190 regions = append(regions, *r.RegionName)
1191 }
1192 }
1193 }
1194
1195 account := ""
1196 if id, err := sts.NewFromConfig(cfg).GetCallerIdentity(ctx, &sts.GetCallerIdentityInput{}); err == nil && id.Account != nil {
1197 account = *id.Account
1198 }
1199 return &Provider{factory: factory, home: home, regions: regions, account: account, now: time.Now}, nil
1200}
1201
1202// newWithFactory is New's test twin: no credential chain, no network.
1203func newWithFactory(f Factory, home string, regions []string, account string) *Provider {
1204 return &Provider{factory: f, home: home, regions: regions, account: account, now: time.Now}
1205}
1206
1207// Inventory walks every region's typed mappings, then the catch-all, then
1208// the global services once from the home region.
1209//
1210// The error contract: an error means "this deployment cannot inventory
1211// itself at all". Anything less — a throttled call, one dead region, a
1212// service the role cannot read — lands in Partial and the inventory is
1213// still returned, because a page showing eleven of twelve resource classes
1214// is worth far more than an error.
1215func (p *Provider) Inventory(ctx context.Context) (infraview.Inventory, error) {
1216 inv := infraview.Inventory{Provider: "aws", Account: p.account, TakenAt: p.now().UTC()}
1217 seen := map[string]bool{}
1218 authDenied := false
1219
1220 note := func(lines []string) {
1221 inv.Partial = append(inv.Partial, lines...)
1222 }
1223 add := func(rs []infraview.Resource) {
1224 for _, r := range rs {
1225 seen[r.ID] = true
1226 inv.Resources = append(inv.Resources, r)
1227 }
1228 }
1229
1230 for _, region := range p.regions {
1231 c := p.factory(region)
1232 rs, partial := ec2Resources(ctx, c.EC2, region)
1233 add(rs)
1234 note(partial)
1235
1236 rs, partial = alarmResources(ctx, c.CloudWatch, region)
1237 add(rs)
1238 note(partial)
1239
1240 rs, partial = certResources(ctx, c.ACM, region)
1241 add(rs)
1242 note(partial)
1243
1244 rs, partial = catchAll(ctx, c.Tagging, region, seen)
1245 add(rs)
1246 note(partial)
1247 }
1248
1249 global := p.factory(p.home)
1250 rs, partial := s3Resources(ctx, global.S3)
1251 add(rs)
1252 note(partial)
1253
1254 rs, partial = route53Resources(ctx, global.Route53)
1255 add(rs)
1256 note(partial)
1257
1258 rs, partial = iamResources(ctx, global.IAM)
1259 add(rs)
1260 note(partial)
1261
1262 for _, line := range inv.Partial {
1263 if looksDenied(line) {
1264 authDenied = true
1265 break
1266 }
1267 }
1268 if len(inv.Resources) == 0 && authDenied {
1269 return inv, errors.New("awsinv: every read was denied — this deployment has no inventory grant")
1270 }
1271 return inv, nil
1272}
1273
1274// IsAuthShaped is cepull.go's isAuthShapedError, exported here so the
1275// console classifies THIS package's construction errors by the same rule it
1276// classifies Cost Explorer's. One rule, one list of codes.
1277func IsAuthShaped(err error) bool {
1278 var apiErr smithy.APIError
1279 if !errors.As(err, &apiErr) {
1280 return false
1281 }
1282 return looksDenied(apiErr.ErrorCode())
1283}
1284
1285func looksDenied(s string) bool {
1286 for _, shape := range []string{
1287 "AccessDenied", "UnauthorizedOperation", "UnrecognizedClient",
1288 "InvalidClientTokenId", "InvalidAccessKeyId", "AuthFailure", "ExpiredToken",
1289 } {
1290 if strings.Contains(s, shape) {
1291 return true
1292 }
1293 }
1294 return false
1295}
1296```
1297
1298- [ ] **Step 5: Run tests to verify they pass**
1299
1300Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/awsinv/ -count=1 -v`
1301Expected: PASS — all five tests, including the three from this task.
1302
1303- [ ] **Step 6: Add the remaining mapping tests**
1304
1305One focused test per mapping in `rest_test.go`, each with a fake returning one item and asserting the produced `Resource`. `TestS3BucketMapping` is the pattern the rest follow:
1306
1307```go
1308type fakeS3 struct {
1309 buckets *s3.ListBucketsOutput
1310 vers *s3.GetBucketVersioningOutput
1311}
1312
1313func (f *fakeS3) ListBuckets(context.Context, *s3.ListBucketsInput, ...func(*s3.Options)) (*s3.ListBucketsOutput, error) {
1314 return f.buckets, nil
1315}
1316func (f *fakeS3) GetBucketLocation(context.Context, *s3.GetBucketLocationInput, ...func(*s3.Options)) (*s3.GetBucketLocationOutput, error) {
1317 return &s3.GetBucketLocationOutput{LocationConstraint: s3types.BucketLocationConstraintEuWest1}, nil
1318}
1319func (f *fakeS3) GetBucketVersioning(context.Context, *s3.GetBucketVersioningInput, ...func(*s3.Options)) (*s3.GetBucketVersioningOutput, error) {
1320 return f.vers, nil
1321}
1322func (f *fakeS3) GetBucketEncryption(context.Context, *s3.GetBucketEncryptionInput, ...func(*s3.Options)) (*s3.GetBucketEncryptionOutput, error) {
1323 return nil, errors.New("no encryption configured")
1324}
1325func (f *fakeS3) GetPublicAccessBlock(context.Context, *s3.GetPublicAccessBlockInput, ...func(*s3.Options)) (*s3.GetPublicAccessBlockOutput, error) {
1326 return nil, errors.New("no public access block")
1327}
1328
1329// A bucket whose posture reads fail still appears — with the attribute
1330// ABSENT, never a false "off". "Off" is a claim this read did not earn.
1331func TestS3BucketMapping(t *testing.T) {
1332 f := &fakeS3{
1333 buckets: &s3.ListBucketsOutput{Buckets: []s3types.Bucket{{Name: aws.String("carlos-data-ie")}}},
1334 vers: &s3.GetBucketVersioningOutput{Status: s3types.BucketVersioningStatusEnabled},
1335 }
1336 rs, partial := s3Resources(context.Background(), f)
1337 if len(partial) != 0 || len(rs) != 1 {
1338 t.Fatalf("rs=%+v partial=%v", rs, partial)
1339 }
1340 b := rs[0]
1341 if b.Kind != infraview.KindBucket || b.Name != "carlos-data-ie" || b.Region != "eu-west-1" {
1342 t.Errorf("bucket = %+v", b)
1343 }
1344 if b.Attr("versioning") != "enabled" {
1345 t.Errorf("versioning = %q, want enabled", b.Attr("versioning"))
1346 }
1347 if _, ok := b.Attrs["encryption"]; ok {
1348 t.Errorf("a failed encryption read must leave the attribute absent, not claim a value")
1349 }
1350}
1351```
1352
1353Then the remaining ones, same shape:
1354`TestS3BucketMapping` (name, region, versioning/encryption/PAB attrs), `TestRoute53RecordsMaterialiseOnlyJoinable` (an A record becomes a `dns_record`; a TXT record only bumps `record_count`), `TestIAMNeverCarriesPolicyDocuments` (assert no attr value contains `"Statement"`), `TestAlarmTargetsInstanceDimension`, `TestCertMapping`, `TestParseARN` (table-driven, including a malformed ARN returning `("", "")`).
1355
1356- [ ] **Step 7: Run the full package tests**
1357
1358Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/awsinv/ -count=1`
1359Expected: PASS.
1360
1361- [ ] **Step 8: Gate and commit**
1362
1363```bash
1364GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go build ./... && \
1365GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go vet ./... && \
1366GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./... -count=1 && \
1367gofmt -l internal/awsinv
1368git add internal/awsinv go.mod go.sum
1369git commit -m "awsinv: S3/Route53/IAM/CloudWatch/ACM mappings, tagging catch-all, assembly
1370
1371Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>"
1372```
1373
1374---
1375
1376### Task 4: The console inventory pass
1377
1378**Files:**
1379- Create: `internal/console/infrapass.go`, `internal/console/infrapass_test.go`
1380- Modify: `internal/console/server.go` (Server fields), `cmd/console/main.go:273` (start the pass)
1381
1382**Interfaces:**
1383- Consumes: `infraview.Inventory`, `infraview.InventoryKey`, `awsinv.New`, `awsinv.IsAuthShaped`, `release.Store`.
1384- Produces: `(*Server).StartInfraPass(ctx context.Context)`, `(*Server).infraPass(ctx context.Context) error`, `(*Server).loadInventory(ctx context.Context) (*infraview.Inventory, string)` where the string is a human-readable reason when the inventory is nil (`"never"`, `"ungranted"`, or a read error), and the `Server` seam field `inventory func(ctx context.Context) (infraview.Inventory, error)`.
1385
1386- [ ] **Step 1: Write the failing test**
1387
1388```go
1389package console
1390
1391import (
1392 "context"
1393 "encoding/json"
1394 "errors"
1395 "testing"
1396 "time"
1397
1398 "github.com/carlosframework/platform/internal/infraview"
1399)
1400
1401// The pass writes the snapshot where the page reads it, and loadInventory
1402// reads back exactly what was written.
1403func TestInfraPassWritesSnapshot(t *testing.T) {
1404 fx := newConsoleFixture(t)
1405 fx.srv.inventory = func(context.Context) (infraview.Inventory, error) {
1406 return infraview.Inventory{
1407 Provider: "aws", Account: "1234", TakenAt: time.Now().UTC(),
1408 Resources: []infraview.Resource{{Provider: "aws", Kind: infraview.KindMachine, ID: "i-1"}},
1409 }, nil
1410 }
1411 if err := fx.srv.infraPass(context.Background()); err != nil {
1412 t.Fatalf("infraPass: %v", err)
1413 }
1414 body, _, err := fx.rel.Get(context.Background(), infraview.InventoryKey)
1415 if err != nil {
1416 t.Fatalf("read back: %v", err)
1417 }
1418 var inv infraview.Inventory
1419 if err := json.Unmarshal(body, &inv); err != nil {
1420 t.Fatalf("parse: %v", err)
1421 }
1422 if len(inv.Resources) != 1 || inv.Resources[0].ID != "i-1" {
1423 t.Fatalf("stored = %+v", inv)
1424 }
1425 got, why := fx.srv.loadInventory(context.Background())
1426 if got == nil || why != "" {
1427 t.Fatalf("loadInventory = %+v, %q; want the snapshot", got, why)
1428 }
1429}
1430
1431// An auth-shaped failure latches the pass off for the process after ONE
1432// attempt: "this deployment has no inventory grant" is a permanent answer,
1433// not something to re-ask AWS every hour forever.
1434func TestInfraPassLatchesOnAuthShapedFailure(t *testing.T) {
1435 fx := newConsoleFixture(t)
1436 calls := 0
1437 fx.srv.inventory = func(context.Context) (infraview.Inventory, error) {
1438 calls++
1439 return infraview.Inventory{}, &fakeAPIError{code: "AccessDenied"}
1440 }
1441 if err := fx.srv.infraPass(context.Background()); err == nil {
1442 t.Fatal("want an error from the denied pass")
1443 }
1444 if err := fx.srv.infraPass(context.Background()); err == nil {
1445 t.Fatal("want an error from the latched pass")
1446 }
1447 if calls != 1 {
1448 t.Fatalf("provider called %d times, want 1 — the latch must stop the second attempt", calls)
1449 }
1450 if _, why := fx.srv.loadInventory(context.Background()); why != "ungranted" {
1451 t.Fatalf("loadInventory reason = %q, want %q", why, "ungranted")
1452 }
1453}
1454
1455// A transient failure is NOT the latch: CE throttles under load and a blip
1456// must not silently kill inventory until the next redeploy.
1457func TestInfraPassRetriesTransientFailure(t *testing.T) {
1458 fx := newConsoleFixture(t)
1459 calls := 0
1460 fx.srv.inventory = func(context.Context) (infraview.Inventory, error) {
1461 calls++
1462 return infraview.Inventory{}, errors.New("Throttling: rate exceeded")
1463 }
1464 _ = fx.srv.infraPass(context.Background())
1465 _ = fx.srv.infraPass(context.Background())
1466 if calls != 2 {
1467 t.Fatalf("provider called %d times, want 2 — a throttle must not latch", calls)
1468 }
1469}
1470
1471// With no snapshot ever written, the page must be told "never", never
1472// handed an empty inventory that would render as "this account has no
1473// resources".
1474func TestLoadInventoryNeverTaken(t *testing.T) {
1475 fx := newConsoleFixture(t)
1476 inv, why := fx.srv.loadInventory(context.Background())
1477 if inv != nil || why != "never" {
1478 t.Fatalf("loadInventory = %+v, %q; want nil, \"never\"", inv, why)
1479 }
1480}
1481```
1482
1483Add `fakeAPIError` to `infrapass_test.go` (the same four-method `smithy.APIError` shape as Task 3's `fakeAPIErr`).
1484
1485- [ ] **Step 2: Run test to verify it fails**
1486
1487Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/console/ -run TestInfraPass -count=1`
1488Expected: FAIL — `fx.srv.inventory undefined`.
1489
1490- [ ] **Step 3: Add the Server fields**
1491
1492In `internal/console/server.go`, beside the existing `ceCosts`/`ceDisabled`/`ceWarnOnce` seam fields on the `Server` struct:
1493
1494```go
1495 // The infrastructure inventory seam (infrapass.go), built exactly like
1496 // the Cost Explorer seam above it: tests set inventory to a fake, the
1497 // real path lazily constructs an awsinv.Provider once. infraDisabled
1498 // latches an auth-shaped failure off for the process — a deployment
1499 // with no inventory IAM grant answers "ungranted" once, not hourly.
1500 inventory func(ctx context.Context) (infraview.Inventory, error)
1501 infraOnce sync.Once
1502 infraProvider infraview.Provider
1503 infraDisabled bool
1504 infraRefreshing atomic.Bool
1505```
1506
1507- [ ] **Step 4: Write `infrapass.go`**
1508
1509```go
1510// The infrastructure inventory pass: once an hour, ask the deployment's
1511// cloud provider what it is carrying and write the answer to
1512// infraview.InventoryKey in the deployment bucket, where
1513// /fleet/architecture reads it.
1514//
1515// The failure posture is cepull.go's, verbatim and for the same reasons.
1516// Construction-class failures — no AWS config, no usable credential, an
1517// AccessDenied-shaped answer to the first calls — latch the pass off for
1518// the process after a single log line. There is no "the IAM grant might
1519// appear later" retry worth making, and a deployment without the grant must
1520// show "not granted" rather than filling its log with an hourly error.
1521// Everything else — throttling, a 5xx, a network blip — logs and the next
1522// tick tries again.
1523package console
1524
1525import (
1526 "context"
1527 "encoding/json"
1528 "errors"
1529 "fmt"
1530 "log"
1531 "os"
1532 "time"
1533
1534 "github.com/carlosframework/platform/internal/awsinv"
1535 "github.com/carlosframework/platform/internal/infraview"
1536)
1537
1538// infraPassInterval matches economicsPassInterval: an estate changes on the
1539// timescale of a deploy, not a request, and the refresh button covers the
1540// moment an operator actually needs it now.
1541const infraPassInterval = time.Hour
1542
1543// infraPassTimeout bounds one whole inventory — every region, every
1544// service. A pass that hangs must not hold the goroutine until the process
1545// restarts.
1546const infraPassTimeout = 2 * time.Minute
1547
1548// StartInfraPass runs infraPass immediately and then hourly until ctx is
1549// done — StartEconomicsPass's shape (economicspass.go:38).
1550func (s *Server) StartInfraPass(ctx context.Context) {
1551 if s.cfg.Releases == nil {
1552 return // no bucket, nowhere to write, nothing to do
1553 }
1554 t := time.NewTicker(infraPassInterval)
1555 defer t.Stop()
1556 for {
1557 if err := s.infraPass(ctx); err != nil {
1558 log.Printf("console: infra pass: %v", err)
1559 }
1560 select {
1561 case <-ctx.Done():
1562 return
1563 case <-t.C:
1564 }
1565 }
1566}
1567
1568// resolveInventory is the seam: the test fake when one is set, otherwise a
1569// lazily constructed awsinv.Provider reused for the life of the process.
1570func (s *Server) resolveInventory(ctx context.Context) (infraview.Inventory, error) {
1571 if s.inventory != nil {
1572 return s.inventory(ctx)
1573 }
1574 if s.infraDisabled {
1575 return infraview.Inventory{}, errors.New("console: infra inventory disabled for this process")
1576 }
1577 s.infraOnce.Do(func() {
1578 p, err := awsinv.New(ctx)
1579 if err != nil {
1580 // Construction failed. Auth-shaped means no grant: latch.
1581 // Anything else means this process cannot build a provider at
1582 // all, which is equally permanent for this process.
1583 log.Printf("console: infra inventory unavailable: %v", err)
1584 s.infraDisabled = true
1585 return
1586 }
1587 s.infraProvider = p
1588 })
1589 if s.infraProvider == nil {
1590 s.infraDisabled = true
1591 return infraview.Inventory{}, errors.New("console: no infrastructure provider")
1592 }
1593 return s.infraProvider.Inventory(ctx)
1594}
1595
1596// infraPass takes one inventory and stores it. An auth-shaped failure
1597// latches; everything else is returned for the caller to log and retry.
1598func (s *Server) infraPass(ctx context.Context) error {
1599 if s.infraDisabled {
1600 return errors.New("console: infra inventory disabled for this process")
1601 }
1602 ctx, cancel := context.WithTimeout(ctx, infraPassTimeout)
1603 defer cancel()
1604
1605 inv, err := s.resolveInventory(ctx)
1606 if err != nil {
1607 if awsinv.IsAuthShaped(err) || isAuthShapedError(err) {
1608 s.infraDisabled = true
1609 return fmt.Errorf("console: infra inventory not granted (disabled for this process): %w", err)
1610 }
1611 return fmt.Errorf("console: infra inventory: %w", err)
1612 }
1613 return s.putInventory(ctx, inv)
1614}
1615
1616// putInventory CAS-writes the snapshot — upsertBill's read-etag-then-put
1617// (economicspass.go:942), with os.ErrNotExist meaning "first ever write".
1618func (s *Server) putInventory(ctx context.Context, inv infraview.Inventory) error {
1619 if s.cfg.Releases == nil {
1620 return nil
1621 }
1622 _, etag, err := s.cfg.Releases.Get(ctx, infraview.InventoryKey)
1623 switch {
1624 case err == nil:
1625 case errors.Is(err, os.ErrNotExist):
1626 etag = ""
1627 default:
1628 return fmt.Errorf("console: inventory: read: %w", err)
1629 }
1630 body, err := json.Marshal(inv)
1631 if err != nil {
1632 return fmt.Errorf("console: inventory: marshal: %w", err)
1633 }
1634 if _, err := s.cfg.Releases.Put(ctx, infraview.InventoryKey, body, etag); err != nil {
1635 return fmt.Errorf("console: inventory: write: %w", err)
1636 }
1637 return nil
1638}
1639
1640// loadInventory reads the stored snapshot for the page. The second return
1641// is why there is nothing to show, and it distinguishes the three states
1642// that must never be rendered as one: "never" (no pass has ever succeeded),
1643// "ungranted" (this process latched off), and a read error. "no resources"
1644// is a claim; none of these three earn it.
1645func (s *Server) loadInventory(ctx context.Context) (*infraview.Inventory, string) {
1646 if s.cfg.Releases == nil {
1647 return nil, "never"
1648 }
1649 body, _, err := s.cfg.Releases.Get(ctx, infraview.InventoryKey)
1650 switch {
1651 case errors.Is(err, os.ErrNotExist):
1652 if s.infraDisabled {
1653 return nil, "ungranted"
1654 }
1655 return nil, "never"
1656 case err != nil:
1657 log.Printf("console: inventory read: %v", err)
1658 return nil, "unreadable"
1659 }
1660 var inv infraview.Inventory
1661 if err := json.Unmarshal(body, &inv); err != nil {
1662 log.Printf("console: inventory parse: %v", err)
1663 return nil, "unreadable"
1664 }
1665 return &inv, ""
1666}
1667```
1668
1669- [ ] **Step 5: Run tests to verify they pass**
1670
1671Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/console/ -run TestInfraPass -count=1 -v` and `-run TestLoadInventory`
1672Expected: PASS — four tests.
1673
1674- [ ] **Step 6: Start the pass**
1675
1676In `cmd/console/main.go`, beside line 273:
1677
1678```go
1679 go srv.StartEconomicsPass(context.Background())
1680 go srv.StartInfraPass(context.Background())
1681```
1682
1683- [ ] **Step 7: Gate and commit**
1684
1685```bash
1686GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go build ./... && \
1687GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go vet ./... && \
1688GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./... -count=1 && \
1689gofmt -l internal/console cmd/console
1690git add internal/console/infrapass.go internal/console/infrapass_test.go internal/console/server.go cmd/console/main.go
1691git commit -m "console: hourly infrastructure inventory pass
1692
1693Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>"
1694```
1695
1696---
1697
1698### Task 5: The graph joins, box cards and cost/drift
1699
1700**Files:**
1701- Create: `internal/console/archgraph.go`, `internal/console/archgraph_test.go`
1702
1703**Interfaces:**
1704- Consumes: `infraview.Inventory`, `status.Fleet`, `HostRow` (`view.go:679`), `registry.Route`, `economics.RateCard`, `economics.HoursPerMonth`.
1705- Produces:
1706
1707```go
1708type ArchInput struct {
1709 Inv *infraview.Inventory
1710 Fleet *status.Fleet
1711 Hosts []HostRow
1712 Routes []registry.Route
1713 Rates economics.RateCard
1714}
1715type KV struct{ K, V string }
1716type Node struct {
1717 ID, Kind, Title, Subtitle string
1718 Detail []KV
1719 X, Y, W, H int
1720}
1721type Edge struct{ From, To, Kind string }
1722type BoxCard struct {
1723 Label, Region, InstanceType, State string
1724 Cores, MemGB, EBSGB float64
1725 DiscoveredDiskGB float64
1726 USDMonth float64
1727 Apps []string
1728 Static, Resident, Hibernating int
1729 Awake, Asleep *int
1730 Release, Load string
1731 Stale bool
1732 Dark string
1733 Drift []string
1734 CloudID string // "" = no cloud machine behind this box
1735}
1736type ResourceGroup struct {
1737 Title string
1738 Rows []ResourceRow
1739}
1740type ResourceRow struct{ Name, NativeType, Region, Detail string }
1741type ArchView struct {
1742 Nodes []Node
1743 Edges []Edge
1744 Width, Height int
1745 Boxes []BoxCard
1746 Groups []ResourceGroup
1747 TakenAt time.Time
1748 Account string
1749 Partial []string
1750 Missing string // "" | "never" | "ungranted" | "unreadable"
1751}
1752func BuildArch(in ArchInput) ArchView
1753```
1754
1755- [ ] **Step 1: Write the failing join test**
1756
1757```go
1758package console
1759
1760import (
1761 "testing"
1762
1763 "github.com/carlosframework/platform/internal/economics"
1764 "github.com/carlosframework/platform/internal/infraview"
1765 "github.com/carlosframework/platform/internal/registry"
1766 "github.com/carlosframework/platform/internal/status"
1767)
1768
1769func archFixture() ArchInput {
1770 inv := &infraview.Inventory{Provider: "aws", Account: "1234", Resources: []infraview.Resource{
1771 {Provider: "aws", Kind: infraview.KindDNSZone, ID: "Z1", Name: "carloku.com",
1772 Attrs: map[string]string{"record_count": "12"}},
1773 {Provider: "aws", Kind: infraview.KindDNSRecord, ID: "r1", Name: "console.carloku.com",
1774 Attrs: map[string]string{"zone": "carloku.com", "type": "A", "value": "52.0.0.1"}},
1775 {Provider: "aws", Kind: infraview.KindIP, ID: "eipalloc-1",
1776 Attrs: map[string]string{"address": "52.0.0.1", "attached_to": "i-123"}},
1777 {Provider: "aws", Kind: infraview.KindMachine, ID: "i-123", Name: "carlos-eu-west-1", Region: "eu-west-1",
1778 Attrs: map[string]string{"type": "t4g.medium", "state": "running", "public_ip": "52.0.0.1"}},
1779 {Provider: "aws", Kind: infraview.KindDisk, ID: "vol-1",
1780 Attrs: map[string]string{"size_gb": "40", "attached_to": "i-123"}},
1781 {Provider: "aws", Kind: infraview.KindMachine, ID: "i-999", Name: "mystery-box", Region: "eu-west-1",
1782 Attrs: map[string]string{"type": "t4g.nano", "state": "running"}},
1783 {Provider: "aws", Kind: infraview.KindBucket, ID: "carlos-data-ie", Name: "carlos-data-ie"},
1784 {Provider: "aws", Kind: infraview.KindOther, NativeType: "sqs", ID: "arn:aws:sqs:eu-west-1:1234:q", Name: "q"},
1785 }}
1786 return ArchInput{
1787 Inv: inv,
1788 Fleet: &status.Fleet{Boxes: []status.FleetBox{
1789 {Label: "carlos-eu-west-1", Region: "eu-west-1"},
1790 {Label: "pi-dublin", Region: "home"},
1791 }},
1792 Hosts: []HostRow{
1793 {Label: "carlos-eu-west-1", Region: "eu-west-1", Metrics: &status.BoxMetrics{
1794 Release: "abc1234",
1795 RouteVersions: map[string]string{"console.carloku.com": "abc1234"},
1796 }},
1797 {Label: "pi-dublin", Region: "home", Metrics: &status.BoxMetrics{}},
1798 },
1799 Routes: []registry.Route{
1800 {Host: "console.carloku.com", App: "console", Kind: "instance", Status: "ready", Version: "abc1234"},
1801 },
1802 Rates: economics.RateCard{Boxes: map[string]economics.BoxSpec{
1803 "": {HourUSD: 0.02, Cores: 2, MemGB: 4, EBSGB: 30},
1804 "carlos-eu-west-1": {HourUSD: 0.0336, Cores: 2, MemGB: 4, EBSGB: 30},
1805 }, EBSGBMonthUSD: 0.088},
1806 }
1807}
1808
1809// The join that makes the picture true: a DNS record's value is an EIP's
1810// address, that EIP is attached to a machine, and that machine's Name tag
1811// is a reporting box's label.
1812func TestArchJoinsDNSThroughIPToBox(t *testing.T) {
1813 v := BuildArch(archFixture())
1814
1815 var box *BoxCard
1816 for i := range v.Boxes {
1817 if v.Boxes[i].Label == "carlos-eu-west-1" {
1818 box = &v.Boxes[i]
1819 }
1820 }
1821 if box == nil {
1822 t.Fatal("no card for carlos-eu-west-1")
1823 }
1824 if box.CloudID != "i-123" {
1825 t.Errorf("CloudID = %q, want i-123 (Name tag matched to box label)", box.CloudID)
1826 }
1827 if box.InstanceType != "t4g.medium" {
1828 t.Errorf("InstanceType = %q", box.InstanceType)
1829 }
1830 if box.DiscoveredDiskGB != 40 {
1831 t.Errorf("DiscoveredDiskGB = %v, want 40", box.DiscoveredDiskGB)
1832 }
1833 if len(box.Apps) != 1 || box.Apps[0] != "console" {
1834 t.Errorf("Apps = %v, want [console]", box.Apps)
1835 }
1836 if !hasEdge(v, "record:r1", "ip:eipalloc-1") || !hasEdge(v, "ip:eipalloc-1", "box:carlos-eu-west-1") {
1837 t.Errorf("missing the DNS -> IP -> box chain in %+v", v.Edges)
1838 }
1839}
1840
1841func hasEdge(v ArchView, from, to string) bool {
1842 for _, e := range v.Edges {
1843 if e.From == from && e.To == to {
1844 return true
1845 }
1846 }
1847 return false
1848}
1849
1850// Unjoined in either direction is DRAWN, never hidden — the rule most of
1851// this page's value rests on.
1852func TestArchShowsUnjoinedBothWays(t *testing.T) {
1853 v := BuildArch(archFixture())
1854
1855 var found bool
1856 for _, n := range v.Nodes {
1857 if n.ID == "machine:i-999" {
1858 found = true
1859 if n.Subtitle == "" {
1860 t.Error("an unclaimed machine must say so")
1861 }
1862 }
1863 }
1864 if !found {
1865 t.Error("a machine no box reports must still be drawn (it is drift)")
1866 }
1867
1868 var pi *BoxCard
1869 for i := range v.Boxes {
1870 if v.Boxes[i].Label == "pi-dublin" {
1871 pi = &v.Boxes[i]
1872 }
1873 }
1874 if pi == nil {
1875 t.Fatal("a box with no cloud machine must still get a card — it is a BYO box, not an error")
1876 }
1877 if pi.CloudID != "" {
1878 t.Errorf("pi CloudID = %q, want empty", pi.CloudID)
1879 }
1880}
1881
1882// Cost is the rate card's, so this page and /fleet/economics can never
1883// disagree; drift is flagged, not silently priced away.
1884func TestArchCostAndDrift(t *testing.T) {
1885 in := archFixture()
1886 v := BuildArch(in)
1887 var box BoxCard
1888 for _, b := range v.Boxes {
1889 if b.Label == "carlos-eu-west-1" {
1890 box = b
1891 }
1892 }
1893 want := 0.0336*economics.HoursPerMonth + 30*0.088
1894 if diff := box.USDMonth - want; diff > 0.001 || diff < -0.001 {
1895 t.Errorf("USDMonth = %v, want %v (rate card, not a second cost model)", box.USDMonth, want)
1896 }
1897 // The card says 30 GB of EBS; the estate has 40. That is drift.
1898 if len(box.Drift) == 0 {
1899 t.Error("a 30 GB rate-card entry against a 40 GB discovered disk must raise drift")
1900 }
1901
1902 // A reporting box with no rate-card entry of its own is also drift:
1903 // it is being priced by the default spec, which is a guess.
1904 var pi BoxCard
1905 for _, b := range v.Boxes {
1906 if b.Label == "pi-dublin" {
1907 pi = b
1908 }
1909 }
1910 if len(pi.Drift) == 0 {
1911 t.Error("a box priced by the default spec must say so")
1912 }
1913}
1914
1915// Resources the graph has no opinion about must still reach the page.
1916func TestArchGroupsCarryOtherResources(t *testing.T) {
1917 v := BuildArch(archFixture())
1918 for _, g := range v.Groups {
1919 for _, r := range g.Rows {
1920 if r.NativeType == "sqs" {
1921 return
1922 }
1923 }
1924 }
1925 t.Fatal("an unmapped resource must appear in the resource groups")
1926}
1927
1928// No inventory at all: the CARLOS half still renders and Missing says which
1929// of the three states applies.
1930func TestArchWithoutInventory(t *testing.T) {
1931 in := archFixture()
1932 in.Inv = nil
1933 v := BuildArch(in)
1934 if v.Missing == "" {
1935 t.Error("a nil inventory must set Missing")
1936 }
1937 if len(v.Boxes) != 2 {
1938 t.Errorf("boxes = %d, want the CARLOS half to render regardless", len(v.Boxes))
1939 }
1940}
1941```
1942
1943- [ ] **Step 2: Run test to verify it fails**
1944
1945Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/console/ -run TestArch -count=1`
1946Expected: FAIL — `undefined: BuildArch`.
1947
1948- [ ] **Step 3: Write `archgraph.go`**
1949
1950Implement `BuildArch` in this order, each step a small named helper so the file stays readable:
1951
19521. `machineFor(inv, label)` — case-insensitive match of `machine.Name` against the box label; returns `*infraview.Resource`.
19532. `diskGBFor(inv, machineID)` — sum of `disk.size_gb` where `attached_to` == machine id.
19543. `appsFor(host HostRow, routes []registry.Route)` — the box's `Metrics.RouteVersions` keys looked up in `routes` by `Host`; distinct app names, sorted.
19554. `boxCost` and `driftFor`, in full — these two carry the page's money claims, so they are given rather than described:
1956
1957```go
1958// boxCost prices one box the way /fleet/economics prices it and no other
1959// way: the rate card's own spec for that label, falling back to the ""
1960// default exactly as economics.AllocateBox does. explicit is false when the
1961// fallback was used, which is itself worth telling the operator — a box
1962// priced by the default spec is priced by a guess.
1963func boxCost(rc economics.RateCard, label string) (usd float64, spec economics.BoxSpec, explicit bool) {
1964 spec, explicit = rc.Boxes[label]
1965 if !explicit {
1966 spec = rc.Boxes[""]
1967 }
1968 usd = spec.HourUSD*economics.HoursPerMonth + spec.EBSGB*rc.EBSGBMonthUSD
1969 return usd, spec, explicit
1970}
1971
1972// driftFor names every way the rate card and the estate disagree about this
1973// box. Two checks only — the two this page can make honestly. Comparing the
1974// instance TYPE against the card's cores/memory would need a type->shape
1975// table, which is a second source of truth and out of scope (spec §10).
1976func driftFor(card BoxCard, spec economics.BoxSpec, explicit bool) []string {
1977 var out []string
1978 if !explicit {
1979 out = append(out, "priced by the default spec — no rate-card entry for this box")
1980 }
1981 if card.DiscoveredDiskGB > 0 && spec.EBSGB > 0 {
1982 if d := card.DiscoveredDiskGB - spec.EBSGB; d >= 1 || d <= -1 {
1983 out = append(out, fmt.Sprintf("rate card says %.0f GB of EBS, the estate has %.0f GB",
1984 spec.EBSGB, card.DiscoveredDiskGB))
1985 }
1986 }
1987 return out
1988}
1989```
19906. Node and edge construction, ids namespaced by kind (`zone:Z1`, `record:r1`, `ip:eipalloc-1`, `box:<label>`, `machine:<id>` for an unclaimed machine, `apps:<label>`, `bucket:<name>`):
1991 - `zone → record` for each record whose `zone` attr matches.
1992 - `record → ip` via `inv.Find(KindIP, "address", record.Attr("value"))`.
1993 - `ip → box` when the IP's `attached_to` machine is a box's `CloudID`; `ip → machine:<id>` when it is not.
1994 - `record → external:<value>` when the value matches no IP and no machine — an arrow leaving the diagram, per spec §6.3.
1995 - `box → apps:<label>` carrying the instance counts as the node subtitle.
1996 - `box → bucket:<deployment bucket>` for every reporting box (the one bucket relationship the console can state as fact).
1997 - `alarm → box` when the alarm's `target` attr is that box's `CloudID`.
19987. `Groups` — compute (`machine`, `disk`), network (`ip`, `firewall`, `network`), storage (`bucket`), DNS (`dns_zone`, `dns_record`), identity, alarms, certificates, other. Each row's `Detail` is the resource's own attrs rendered `k=v`, sorted by key, so an attribute no one wrote code for still appears.
19998. `Missing`, `TakenAt`, `Account`, `Partial` copied from the inventory (with `Missing` set by the caller when `Inv == nil`; `BuildArch` sets `"never"` when handed nil and no reason).
2000
2001The counts on the apps node come from the box's `status.FleetBox.Routes` (`Static`/`Resident`/`Hibernating`, and the `*int` `Awake`/`Asleep` rendered as "unknown" when nil).
2002
2003- [ ] **Step 4: Run tests to verify they pass**
2004
2005Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/console/ -run TestArch -count=1 -v`
2006Expected: PASS — five tests.
2007
2008- [ ] **Step 5: Gate and commit**
2009
2010```bash
2011GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go build ./... && \
2012GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go vet ./... && \
2013GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./... -count=1 && \
2014gofmt -l internal/console
2015git add internal/console/archgraph.go internal/console/archgraph_test.go
2016git commit -m "console: architecture graph joins, box cards, rate-card cost and drift
2017
2018Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>"
2019```
2020
2021---
2022
2023### Task 6: Deterministic layout and SVG
2024
2025**Files:**
2026- Create: `internal/console/archlayout.go`, `internal/console/archlayout_test.go`
2027- Modify: `internal/console/archgraph.go` (call `layout` at the end of `BuildArch`)
2028
2029**Interfaces:**
2030- Consumes: `ArchView`, `Node`, `Edge` (Task 5).
2031- Produces: `layout(v *ArchView)` (sets every `Node.X/Y/W/H` and `ArchView.Width/Height`), `RenderArchSVG(v ArchView) template.HTML`.
2032
2033- [ ] **Step 1: Write the failing test**
2034
2035```go
2036package console
2037
2038import (
2039 "strings"
2040 "testing"
2041)
2042
2043// The same fleet must draw the same picture every time. A diagram that
2044// reshuffles between refreshes cannot be read for change, which is most of
2045// what an operator opens it for.
2046func TestLayoutIsDeterministic(t *testing.T) {
2047 a := RenderArchSVG(BuildArch(archFixture()))
2048 b := RenderArchSVG(BuildArch(archFixture()))
2049 if a != b {
2050 t.Fatal("two builds of the same input produced different SVG")
2051 }
2052}
2053
2054// Layer order is the story the page tells: DNS enters at the left, routers
2055// and boxes in the middle, apps at the right.
2056func TestLayoutOrdersLayers(t *testing.T) {
2057 v := BuildArch(archFixture())
2058 x := map[string]int{}
2059 for _, n := range v.Nodes {
2060 x[n.ID] = n.X
2061 }
2062 if !(x["zone:Z1"] < x["record:r1"] && x["record:r1"] < x["ip:eipalloc-1"] &&
2063 x["ip:eipalloc-1"] < x["box:carlos-eu-west-1"] && x["box:carlos-eu-west-1"] < x["apps:carlos-eu-west-1"]) {
2064 t.Fatalf("layers out of order: %+v", x)
2065 }
2066}
2067
2068// No node may overlap another: the layout is the only thing standing
2069// between a growing fleet and an unreadable page.
2070func TestLayoutDoesNotOverlap(t *testing.T) {
2071 v := BuildArch(archFixture())
2072 for i := range v.Nodes {
2073 for j := i + 1; j < len(v.Nodes); j++ {
2074 a, b := v.Nodes[i], v.Nodes[j]
2075 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 {
2076 t.Fatalf("%s overlaps %s", a.ID, b.ID)
2077 }
2078 }
2079 }
2080}
2081
2082// The SVG must scale without script and must not carry raw user strings
2083// unescaped.
2084func TestSVGIsSelfContainedAndEscaped(t *testing.T) {
2085 in := archFixture()
2086 in.Fleet.Boxes[0].Label = `evil"><script>alert(1)</script>`
2087 in.Hosts[0].Label = in.Fleet.Boxes[0].Label
2088 out := string(RenderArchSVG(BuildArch(in)))
2089 if !strings.Contains(out, "viewBox=") {
2090 t.Error("SVG must carry a viewBox so it scales with no JS")
2091 }
2092 if strings.Contains(out, "<script") {
2093 t.Error("a box label must never reach the SVG unescaped")
2094 }
2095}
2096```
2097
2098- [ ] **Step 2: Run test to verify it fails**
2099
2100Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/console/ -run TestLayout -count=1`
2101Expected: FAIL — `undefined: RenderArchSVG`.
2102
2103- [ ] **Step 3: Write `archlayout.go`**
2104
2105```go
2106// Layout and SVG emission for /fleet/architecture. Deterministic by
2107// construction: layer by kind, order within a layer by one barycentre sweep
2108// each way, then a stable sort by node id. The same fleet must produce a
2109// byte-identical picture on every refresh — a diagram that reshuffles
2110// cannot be read for change.
2111//
2112// No JavaScript and no layout library: the console vendors its icons and
2113// loads nothing from a CDN, and a server-computed layout is the only way to
2114// keep that promise while still drawing a graph.
2115package console
2116
2117import (
2118 "fmt"
2119 "html/template"
2120 "sort"
2121 "strings"
2122)
2123
2124// Layer assignment by node kind — the story the page tells, left to right.
2125var archLayer = map[string]int{
2126 "zone": 0, "record": 1, "ip": 2, "router": 3,
2127 "box": 4, "machine": 4, "apps": 5, "bucket": 6,
2128}
2129
2130const (
2131 nodeW = 190
2132 nodeH = 64
2133 gapX = 90
2134 gapY = 26
2135 padding = 24
2136 satelliteKinds = "alarm identity firewall network disk certificate other"
2137)
2138
2139func layout(v *ArchView) {
2140 // Satellites (alarms, identities, firewalls…) are listed in the cards
2141 // below the diagram rather than drawn: a graph that draws every IAM role
2142 // is a hairball, and the resource groups already carry them in full.
2143 kept := v.Nodes[:0]
2144 for _, n := range v.Nodes {
2145 if strings.Contains(satelliteKinds, n.Kind) {
2146 continue
2147 }
2148 kept = append(kept, n)
2149 }
2150 v.Nodes = kept
2151
2152 byLayer := map[int][]int{}
2153 for i := range v.Nodes {
2154 l := archLayer[v.Nodes[i].Kind]
2155 byLayer[l] = append(byLayer[l], i)
2156 }
2157 // Stable order first, so the barycentre sweeps start from a fixed
2158 // arrangement and cannot depend on map iteration order anywhere.
2159 for l := range byLayer {
2160 idx := byLayer[l]
2161 sort.Slice(idx, func(a, b int) bool { return v.Nodes[idx[a]].ID < v.Nodes[idx[b]].ID })
2162 }
2163 // One sweep forward and one back, ordering each layer by the mean row of
2164 // its neighbours in the adjacent layer — the standard crossing
2165 // reduction, and enough for a graph this shape. Ties break on node id so
2166 // the result cannot depend on map or slice iteration order anywhere.
2167 row := map[string]int{}
2168 for _, idx := range byLayer {
2169 for r, i := range idx {
2170 row[v.Nodes[i].ID] = r
2171 }
2172 }
2173 neighbours := func(id string, forward bool) []string {
2174 var out []string
2175 for _, e := range v.Edges {
2176 if forward && e.To == id {
2177 out = append(out, e.From)
2178 }
2179 if !forward && e.From == id {
2180 out = append(out, e.To)
2181 }
2182 }
2183 return out
2184 }
2185 sweep := func(forward bool) {
2186 layers := make([]int, 0, len(byLayer))
2187 for l := range byLayer {
2188 layers = append(layers, l)
2189 }
2190 sort.Ints(layers)
2191 if !forward {
2192 for i, j := 0, len(layers)-1; i < j; i, j = i+1, j-1 {
2193 layers[i], layers[j] = layers[j], layers[i]
2194 }
2195 }
2196 for _, l := range layers {
2197 idx := byLayer[l]
2198 bary := map[string]float64{}
2199 for _, i := range idx {
2200 id := v.Nodes[i].ID
2201 ns := neighbours(id, forward)
2202 if len(ns) == 0 {
2203 bary[id] = float64(row[id])
2204 continue
2205 }
2206 sum := 0
2207 for _, n := range ns {
2208 sum += row[n]
2209 }
2210 bary[id] = float64(sum) / float64(len(ns))
2211 }
2212 sort.SliceStable(idx, func(a, b int) bool {
2213 ia, ib := v.Nodes[idx[a]].ID, v.Nodes[idx[b]].ID
2214 if bary[ia] != bary[ib] {
2215 return bary[ia] < bary[ib]
2216 }
2217 return ia < ib
2218 })
2219 for r, i := range idx {
2220 row[v.Nodes[i].ID] = r
2221 }
2222 }
2223 }
2224 sweep(true)
2225 sweep(false)
2226
2227 maxRows := 0
2228 for l, idx := range byLayer {
2229 for row, i := range idx {
2230 v.Nodes[i].W, v.Nodes[i].H = nodeW, nodeH
2231 v.Nodes[i].X = padding + l*(nodeW+gapX)
2232 v.Nodes[i].Y = padding + row*(nodeH+gapY)
2233 }
2234 if len(idx) > maxRows {
2235 maxRows = len(idx)
2236 }
2237 }
2238 layers := 0
2239 for l := range byLayer {
2240 if l+1 > layers {
2241 layers = l + 1
2242 }
2243 }
2244 v.Width = padding*2 + layers*nodeW + (layers-1)*gapX
2245 v.Height = padding*2 + maxRows*nodeH + (maxRows-1)*gapY
2246}
2247
2248// RenderArchSVG emits the diagram. Every string that came from a hostname,
2249// a tag or a label goes through template.HTMLEscapeString — these are
2250// operator-supplied values from a registry and a cloud account, and the one
2251// thing this page must never do is execute one of them.
2252func RenderArchSVG(v ArchView) template.HTML {
2253 var b strings.Builder
2254 fmt.Fprintf(&b, `<svg class="archsvg" viewBox="0 0 %d %d" role="img" aria-label="Deployment architecture">`, v.Width, v.Height)
2255 pos := map[string]Node{}
2256 for _, n := range v.Nodes {
2257 pos[n.ID] = n
2258 }
2259 // Edges first so nodes paint over them.
2260 for _, e := range v.Edges {
2261 from, okF := pos[e.From]
2262 to, okT := pos[e.To]
2263 if !okF || !okT {
2264 continue // a satellite edge whose node the layout dropped
2265 }
2266 x1, y1 := from.X+from.W, from.Y+from.H/2
2267 x2, y2 := to.X, to.Y+to.H/2
2268 mid := (x1 + x2) / 2
2269 fmt.Fprintf(&b, `<path class="archedge" d="M%d %d H%d V%d H%d" />`, x1, y1, mid, y2, x2)
2270 }
2271 for _, n := range v.Nodes {
2272 fmt.Fprintf(&b, `<g class="archnode archnode-%s">`, template.HTMLEscapeString(n.Kind))
2273 fmt.Fprintf(&b, `<rect x="%d" y="%d" width="%d" height="%d" rx="8" />`, n.X, n.Y, n.W, n.H)
2274 fmt.Fprintf(&b, `<text x="%d" y="%d" class="archtitle">%s</text>`,
2275 n.X+12, n.Y+24, template.HTMLEscapeString(n.Title))
2276 if n.Subtitle != "" {
2277 fmt.Fprintf(&b, `<text x="%d" y="%d" class="archsub">%s</text>`,
2278 n.X+12, n.Y+44, template.HTMLEscapeString(n.Subtitle))
2279 }
2280 b.WriteString(`</g>`)
2281 }
2282 b.WriteString(`</svg>`)
2283 return template.HTML(b.String())
2284}
2285```
2286
2287Call `layout(&v)` as the last statement of `BuildArch` before it returns.
2288
2289- [ ] **Step 4: Run tests to verify they pass**
2290
2291Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/console/ -run "TestLayout|TestSVG|TestArch" -count=1 -v`
2292Expected: PASS.
2293
2294- [ ] **Step 5: Gate and commit**
2295
2296```bash
2297GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go build ./... && \
2298GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go vet ./... && \
2299GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./... -count=1 && \
2300gofmt -l internal/console
2301git add internal/console/archlayout.go internal/console/archlayout_test.go internal/console/archgraph.go
2302git commit -m "console: deterministic architecture layout and inline SVG
2303
2304Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>"
2305```
2306
2307---
2308
2309### Task 7: The page, the gate and the refresh door
2310
2311**Files:**
2312- Create: `internal/console/archpage.go`, `internal/console/archpage_test.go`, `internal/console/templates/architecture.html`
2313- Modify: `internal/console/server.go` (routes at :688-693, `page` struct), `internal/console/templates/fleet.html` (one link), `internal/console/templates/layout.html` (the page's CSS block)
2314
2315**Interfaces:**
2316- Consumes: `BuildArch`, `ArchView`, `RenderArchSVG`, `loadInventory`, `FleetView`, `HostRows`, `openRegistry`, the active scenario loader used by `handleEconomics`.
2317- Produces: `(*Server).handleArchitecture`, `(*Server).handleArchitectureRefresh`, `page.Arch *ArchView`, `page.ArchSVG template.HTML`.
2318
2319- [ ] **Step 1: Write the failing test**
2320
2321```go
2322package console
2323
2324import (
2325 "net/http"
2326 "net/url"
2327 "strings"
2328 "testing"
2329)
2330
2331// TestFleetOperatorOnly's shape against /fleet/architecture: signed out,
2332// signed in but not an operator, and a revoked operator all get the exact
2333// 404 an unrouted path gets — byte-identical, so the route's existence is
2334// not probeable.
2335func TestArchitectureOperatorOnly(t *testing.T) {
2336 fx := newConsoleFixture(t)
2337
2338 out := newBrowser(t)
2339 ghostStatus, _, ghost := get(t, out, fx.ts.URL+"/this-route-does-not-exist")
2340 if ghostStatus != http.StatusNotFound {
2341 t.Fatalf("sanity: missing route = %d", ghostStatus)
2342 }
2343 if st, _, body := get(t, out, fx.ts.URL+"/fleet/architecture"); st != http.StatusNotFound || body != ghost {
2344 t.Fatalf("signed-out = %d %q, want the same 404 as a missing route", st, body)
2345 }
2346 owner := signInPaul(t, fx)
2347 if st, _, body := get(t, owner, fx.ts.URL+"/fleet/architecture"); st != http.StatusNotFound || body != ghost {
2348 t.Fatalf("non-operator = %d %q, want the same 404", st, body)
2349 }
2350 if err := fx.st.SetOperator("paul@keymail.dev", true); err != nil {
2351 t.Fatal(err)
2352 }
2353 st, _, body := get(t, owner, fx.ts.URL+"/fleet/architecture")
2354 if st != 200 || !strings.Contains(body, "archsvg") {
2355 t.Fatalf("operator view = %d, want 200 with the diagram:\n%s", st, body)
2356 }
2357 if err := fx.st.SetOperator("paul@keymail.dev", false); err != nil {
2358 t.Fatal(err)
2359 }
2360 if st, _, body := get(t, owner, fx.ts.URL+"/fleet/architecture"); st != http.StatusNotFound || body != ghost {
2361 t.Fatalf("revoked = %d %q, want the same 404", st, body)
2362 }
2363}
2364
2365// With no inventory ever taken, the page still renders and says WHICH of
2366// the three states it is in — never a confident "no resources".
2367func TestArchitecturePageWithoutInventory(t *testing.T) {
2368 fx := newConsoleFixture(t)
2369 c := signInPaul(t, fx)
2370 if err := fx.st.SetOperator("paul@keymail.dev", true); err != nil {
2371 t.Fatal(err)
2372 }
2373 st, _, body := get(t, c, fx.ts.URL+"/fleet/architecture")
2374 if st != 200 {
2375 t.Fatalf("= %d, want 200", st)
2376 }
2377 if !strings.Contains(body, "No inventory has been taken yet") {
2378 t.Errorf("page must name the never-taken state:\n%s", body)
2379 }
2380 if strings.Contains(body, "no resources") {
2381 t.Errorf("page must not claim an empty estate it never read")
2382 }
2383}
2384
2385// The refresh door: operator-gated, CSRF-checked, POST-only.
2386func TestArchitectureRefreshDoor(t *testing.T) {
2387 fx := newConsoleFixture(t)
2388 c := signInPaul(t, fx)
2389 if err := fx.st.SetOperator("paul@keymail.dev", true); err != nil {
2390 t.Fatal(err)
2391 }
2392 called := 0
2393 fx.srv.inventory = func(ctx context.Context) (infraview.Inventory, error) {
2394 called++
2395 return infraview.Inventory{Provider: "aws", TakenAt: time.Now().UTC()}, nil
2396 }
2397
2398 // No CSRF token: refused, and the pass must not have run.
2399 st, _, _ := postForm(t, c, fx.ts.URL+"/fleet/architecture/refresh", url.Values{})
2400 if st != http.StatusForbidden {
2401 t.Fatalf("no-CSRF refresh = %d, want 403", st)
2402 }
2403 if called != 0 {
2404 t.Fatalf("a refused refresh must not run the pass")
2405 }
2406
2407 // With the token: runs once and redirects back to the page.
2408 csrf := sessionToken(t, fx, c)
2409 st, loc, body := postForm(t, c, fx.ts.URL+"/fleet/architecture/refresh", url.Values{"csrf": {csrf}})
2410 if st != http.StatusSeeOther || loc != "/fleet/architecture" {
2411 t.Fatalf("refresh = %d -> %q, want 303 back to the page:\n%s", st, loc, body)
2412 }
2413 if called != 1 {
2414 t.Fatalf("pass ran %d times, want 1", called)
2415 }
2416}
2417```
2418
2419Helpers used above already exist: `sessionToken(t, fx, c)` and `postForm(t, c, url, values) (status int, location, body string)` — see `appdomains_test.go:27`. The test file imports `context`, `net/url`, `time` and `internal/infraview` on top of the usual set.
2420
2421- [ ] **Step 2: Run test to verify it fails**
2422
2423Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/console/ -run TestArchitecture -count=1`
2424Expected: FAIL — 404 on every request; the route is not registered.
2425
2426- [ ] **Step 3: Write `archpage.go`**
2427
2428```go
2429// GET /fleet/architecture — the operator's map of the deployment: how a
2430// request reaches a process, and everything the account is carrying that
2431// makes that possible.
2432//
2433// The gate is handleFleet's, verbatim (server.go): session -> IsOperator ->
2434// bare http.NotFound on EVERY failure including the store error. A styled
2435// refusal would confirm the route exists, and this page names hostnames,
2436// instance ids and the shape of the estate.
2437//
2438// Every read soft-fails on its own. The inventory, the fleet aggregate, the
2439// per-box snapshots, the registry and the rate card are five independent
2440// reads; any one of them having a bad moment costs its own section and a
2441// line in the footer, never the page.
2442package console
2443
2444import (
2445 "log"
2446 "net/http"
2447
2448 "github.com/carlosframework/platform/internal/economics"
2449)
2450
2451func (s *Server) handleArchitecture(w http.ResponseWriter, r *http.Request) {
2452 addr, raw, ok := s.operatorOnly(w, r)
2453 if !ok {
2454 return
2455 }
2456 in := ArchInput{}
2457 inv, missing := s.loadInventory(r.Context())
2458 in.Inv = inv
2459
2460 if f, err := FleetView(r.Context(), s.cfg.Releases); err != nil {
2461 log.Printf("console: architecture: fleet view: %v", err)
2462 } else {
2463 in.Fleet = f
2464 }
2465 if hosts, err := HostRows(r.Context(), s.cfg.Releases, time.Now()); err != nil {
2466 log.Printf("console: architecture: hosts: %v", err)
2467 } else {
2468 in.Hosts = hosts
2469 }
2470 if reg, err := openRegistry(s.cfg.Registry); err != nil {
2471 log.Printf("console: architecture: registry: %v", err)
2472 } else if routes, err := reg.List(); err != nil {
2473 log.Printf("console: architecture: routes: %v", err)
2474 } else {
2475 in.Routes = routes
2476 }
2477 // The rate card comes from the SAME loader /fleet/economics uses
2478 // (economicsview.go:748), so the two pages cannot disagree about what a
2479 // box costs. Its warning is already surfaced on that page; here a bad
2480 // scenario simply means the zero rate card, and every box then reports
2481 // drift — which is the honest answer, not a silent $0.
2482 sc, _ := s.loadActiveScenario(r.Context())
2483 in.Rates = sc.RateCard
2484
2485 v := BuildArch(in)
2486 if missing != "" {
2487 v.Missing = missing
2488 }
2489 p := page{Title: "Architecture", Address: addr, CSRF: raw, Arch: &v, ArchSVG: RenderArchSVG(v)}
2490 s.render(w, http.StatusOK, "architecture", p)
2491}
2492
2493// handleArchitectureRefresh runs one inventory now. Single-flighted
2494// (infraRefreshing): a leaned-on button must not stack passes against a
2495// rate-limited API. It deliberately does NOT clear the auth latch — a
2496// deployment without the IAM grant gets the same honest answer however many
2497// times the button is pressed.
2498func (s *Server) handleArchitectureRefresh(w http.ResponseWriter, r *http.Request) {
2499 if r.Method != http.MethodPost {
2500 http.NotFound(w, r)
2501 return
2502 }
2503 _, raw, ok := s.operatorOnly(w, r)
2504 if !ok {
2505 return
2506 }
2507 if subtle.ConstantTimeCompare([]byte(r.FormValue("csrf")), []byte(raw)) != 1 {
2508 s.renderMessage(w, r, http.StatusForbidden, "Refused", "Cross-site request blocked.")
2509 return
2510 }
2511 if !s.infraRefreshing.CompareAndSwap(false, true) {
2512 s.setFlash(w, "err", "An inventory is already running.")
2513 http.Redirect(w, r, "/fleet/architecture", http.StatusSeeOther)
2514 return
2515 }
2516 defer s.infraRefreshing.Store(false)
2517
2518 if err := s.infraPass(r.Context()); err != nil {
2519 log.Printf("console: architecture refresh: %v", err)
2520 s.setFlash(w, "err", "The inventory could not be refreshed.")
2521 } else {
2522 s.setFlash(w, "ok", "Inventory refreshed.")
2523 }
2524 http.Redirect(w, r, "/fleet/architecture", http.StatusSeeOther)
2525}
2526```
2527
2528Extract `operatorOnly(w, r) (addr, raw string, ok bool)` from `handleFleet` — the four-line gate, unchanged in behaviour — and have `handleFleet`, `handleFleetMetrics`, `handleEconomics` and both new handlers call it, so there is exactly one copy of the shape the tests pin. Extracting it must not change a single response: run the existing `TestFleetOperatorOnly` and `TestEconomicsOperatorOnly` before and after.
2529
2530`s.setFlash(w, kind, msg)` takes `"ok"` or `"err"` as its kind (`appdomains.go:57`).
2531
2532- [ ] **Step 4: Register the routes**
2533
2534In `internal/console/server.go` beside line 690:
2535
2536```go
2537 s.mux.HandleFunc("GET /fleet/architecture", s.handleArchitecture)
2538 s.mux.HandleFunc("POST /fleet/architecture/refresh", s.handleArchitectureRefresh)
2539```
2540
2541And on the `page` struct, beside `Metrics`:
2542
2543```go
2544 // /fleet/architecture (archgraph.go): the deployment's map. Arch is nil
2545 // only on paths this handler never takes; ArchSVG is pre-rendered in Go
2546 // rather than assembled in the template because the layout has to be
2547 // deterministic and testable as a whole.
2548 Arch *ArchView
2549 ArchSVG template.HTML
2550```
2551
2552- [ ] **Step 5: Write the template**
2553
2554`internal/console/templates/architecture.html`:
2555
2556```html
2557{{define "architecture"}}{{template "top" .}}
2558<h1>Architecture</h1>
2559{{with .Arch}}
2560<p class="small">
2561 {{if .Missing}}
2562 {{if eq .Missing "never"}}No inventory has been taken yet — the first pass runs within the hour.
2563 {{else if eq .Missing "ungranted"}}This deployment has no infrastructure read grant, so the cloud half of this page is unavailable. The boxes below are what CARLOS itself reports.
2564 {{else}}The inventory snapshot could not be read.{{end}}
2565 {{else}}
2566 Estate {{.Account}} · inventoried {{fmtTime .TakenAt}}
2567 {{end}}
2568</p>
2569
2570<div class="archwrap">{{$.ArchSVG}}</div>
2571
2572<h2>Boxes</h2>
2573{{range .Boxes}}
2574<section class="card">
2575 <h3>{{.Label}}{{if .Region}} <span class="small">{{.Region}}</span>{{end}}</h3>
2576 <p class="small">
2577 {{if .InstanceType}}{{.InstanceType}} · {{end}}
2578 {{if .MemGB}}{{.MemGB}} GB · {{end}}
2579 {{if .DiscoveredDiskGB}}{{.DiscoveredDiskGB}} GB disk · {{end}}
2580 {{if .USDMonth}}${{printf "%.0f" .USDMonth}}/mo{{end}}
2581 {{if not .CloudID}} · no cloud machine (bring-your-own box){{end}}
2582 </p>
2583 <p class="small">Apps: {{if .Apps}}{{range .Apps}}<code>{{.}}</code> {{end}}{{else}}none{{end}}</p>
2584 <p class="small">
2585 static {{.Static}} · resident {{.Resident}} · hibernating {{.Hibernating}} ·
2586 awake {{intOrDash .Awake}} · asleep {{intOrDash .Asleep}}
2587 </p>
2588 {{range .Drift}}<p class="small chip-warn">{{.}}</p>{{end}}
2589</section>
2590{{end}}
2591
2592{{range .Groups}}
2593<h2>{{.Title}}</h2>
2594<div class="tablewrap"><table>
2595 <tr><th>Name</th><th>Type</th><th>Region</th><th>Detail</th></tr>
2596 {{range .Rows}}<tr><td>{{.Name}}</td><td><code>{{.NativeType}}</code></td><td>{{.Region}}</td><td class="small">{{.Detail}}</td></tr>{{end}}
2597</table></div>
2598{{end}}
2599
2600{{if .Partial}}
2601<h2>Reads that failed</h2>
2602<ul class="small">{{range .Partial}}<li>{{.}}</li>{{end}}</ul>
2603{{end}}
2604{{end}}
2605
2606<form method="post" action="/fleet/architecture/refresh">
2607 <input type="hidden" name="csrf" value="{{.CSRF}}">
2608 <button type="submit">Refresh inventory</button>
2609</form>
2610{{template "bottom" .}}{{end}}
2611```
2612
2613(Check the closing template name the other operator pages use — `fleet.html`'s last line — and match it.)
2614
2615Add to `layout.html`'s style block, beside the other page-specific rules:
2616
2617```css
2618 /* /fleet/architecture: the diagram. Colours come from the same tokens as
2619 the rest of the console so it themes light and dark with no second
2620 palette to keep in sync. */
2621 .archwrap { overflow-x: auto; margin: 1rem 0 2rem; }
2622 .archsvg { width: 100%; min-width: 720px; height: auto; }
2623 .archsvg rect { fill: var(--accent-soft); stroke: var(--rule); }
2624 .archsvg .archnode-box rect { stroke: var(--accent); }
2625 .archsvg .archtitle { fill: var(--ink); font: 600 13px var(--sans); }
2626 .archsvg .archsub { fill: var(--muted); font: 400 11px var(--sans); }
2627 .archsvg .archedge { fill: none; stroke: var(--rule); stroke-width: 1.5; }
2628```
2629
2630- [ ] **Step 6: Link it from the fleet page**
2631
2632At the top of `internal/console/templates/fleet.html`, after the `<h1>`:
2633
2634```html
2635<p class="small"><a href="/fleet/architecture">See the architecture →</a></p>
2636```
2637
2638- [ ] **Step 7: Run tests to verify they pass**
2639
2640Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/console/ -count=1`
2641Expected: PASS — the three new tests and every existing console test.
2642
2643- [ ] **Step 8: Gate and commit**
2644
2645```bash
2646GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go build ./... && \
2647GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go vet ./... && \
2648GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./... -count=1 && \
2649GOOS=darwin GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go build ./... && \
2650gofmt -l internal/console
2651git add internal/console cmd/console
2652git commit -m "console: /fleet/architecture page, gate and refresh door
2653
2654Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>"
2655```
2656
2657---
2658
2659### Task 8: The IAM grant and the deploy note
2660
2661**Files:**
2662- Modify: `infra/modules/region-host/main.tf` (the box role's `aws_iam_role_policy`)
2663- Modify: `docs/superpowers/specs/2026-08-19-fleet-architecture-diagram-design.md` (mark the grant's status)
2664
2665**Interfaces:**
2666- Consumes: nothing in Go.
2667- Produces: the read-only inventory statement the pass needs.
2668
2669- [ ] **Step 1: Read the existing policy**
2670
2671Run: `sed -n '90,160p' infra/modules/region-host/main.tf`
2672Note the existing `aws_iam_role_policy` document's shape and its `Statement` list — the new statement is appended to it, in its style.
2673
2674- [ ] **Step 2: Add the statement**
2675
2676```hcl
2677 # Infrastructure inventory (2026-08-19-fleet-architecture-diagram-design.md
2678 # §8): what the console's hourly infra pass needs to draw
2679 # /fleet/architecture. Read-only in the strictest sense — every action
2680 # here is a List/Describe/Get of a resource's SHAPE. No write action,
2681 # no s3:GetObject, and deliberately no iam:GetPolicyVersion: the page
2682 # shows that a role exists and how many policies are attached to it,
2683 # never what those policies say.
2684 {
2685 Effect = "Allow"
2686 Action = [
2687 "ec2:DescribeInstances",
2688 "ec2:DescribeVolumes",
2689 "ec2:DescribeAddresses",
2690 "ec2:DescribeSecurityGroups",
2691 "ec2:DescribeVpcs",
2692 "ec2:DescribeSubnets",
2693 "ec2:DescribeRegions",
2694 "tag:GetResources",
2695 "s3:ListAllMyBuckets",
2696 "s3:GetBucketLocation",
2697 "s3:GetBucketVersioning",
2698 "s3:GetEncryptionConfiguration",
2699 "s3:GetBucketPublicAccessBlock",
2700 "route53:ListHostedZones",
2701 "route53:ListResourceRecordSets",
2702 "iam:ListRoles",
2703 "iam:ListUsers",
2704 "iam:ListAttachedRolePolicies",
2705 "cloudwatch:DescribeAlarms",
2706 "acm:ListCertificates",
2707 "sts:GetCallerIdentity",
2708 ]
2709 Resource = "*"
2710 },
2711```
2712
2713(`Resource = "*"` is required: every action listed is an account-wide List/Describe that AWS does not scope to a resource ARN.)
2714
2715- [ ] **Step 3: Validate the module**
2716
2717Run: `cd infra/modules/region-host && tofu fmt -check && tofu validate` (or `terraform` if that is what is installed; if neither is present, run `tofu fmt -check` only and note it).
2718Expected: clean.
2719
2720- [ ] **Step 4: Record the rollout status in the spec**
2721
2722Append to §8 of the spec:
2723
2724```markdown
2725**Status (2026-08-19):** the module statement is in this repo as of this
2726branch. The flagship box runs a role provisioned from pinfra, so the grant
2727reaches production only when the matching pinfra change is applied — until
2728then `/fleet/architecture` renders the CARLOS half and reports the estate
2729as ungranted, which is the designed state and not a fault.
2730```
2731
2732- [ ] **Step 5: Commit**
2733
2734```bash
2735git add infra/modules/region-host/main.tf docs/superpowers/specs/2026-08-19-fleet-architecture-diagram-design.md
2736git commit -m "infra: read-only inventory grant for the architecture page
2737
2738Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>"
2739```
2740
2741---
2742
2743### Task 9: Branch finish
2744
2745- [ ] **Step 1: Full gate, including the cross-compile CI runs**
2746
2747```bash
2748GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go build ./... && \
2749GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go vet ./... && \
2750GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./... -count=1 && \
2751GOOS=darwin GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go build ./... && \
2752gofmt -l internal/infraview internal/awsinv internal/console cmd/console
2753```
2754
2755Expected: every command clean, `gofmt -l` printing nothing.
2756
2757- [ ] **Step 2: Confirm no AWS write reached the tree**
2758
2759```bash
2760grep -rnE "\b(Create|Put|Delete|Modify|Terminate|Run|Attach|Detach|Tag|Untag)[A-Z]" internal/awsinv/*.go | grep -v _test
2761```
2762
2763Expected: no output. Any hit is a mutating SDK call that must not be here.
2764
2765- [ ] **Step 3: Push the branch and open the PR**
2766
2767```bash
2768git push -u origin "$(git branch --show-current)"
2769gh pr create --title "Fleet architecture diagram: /fleet/architecture over a provider-neutral inventory" --body "$(cat <<'BODY'
2770Adds the operator page the spec describes: how a request reaches a process,
2771drawn over a live inventory of what the deployment's cloud account is
2772carrying, with each box's size, cost, apps and instance counts.
2773
2774- `internal/infraview` — provider-neutral inventory shape (no SDK)
2775- `internal/awsinv` — the AWS implementation; the only SDK importer, read-only throughout
2776- `internal/console/infrapass.go` — hourly pass to `infra/inventory.json`, `cepull.go`'s latch posture
2777- `internal/console/archgraph.go` / `archlayout.go` — joins, cost/drift, deterministic SVG
2778- `GET /fleet/architecture` + `POST /fleet/architecture/refresh`, `handleFleet`'s gate
2779- `infra/modules/region-host` — the read-only grant (needs a matching pinfra apply to reach flagship)
2780
2781Spec: `docs/superpowers/specs/2026-08-19-fleet-architecture-diagram-design.md`
2782Plan: `docs/superpowers/plans/2026-08-19-fleet-architecture-diagram.md`
2783
2784Until the pinfra grant is applied the page renders the CARLOS half and
2785reports the estate as ungranted — the designed state, covered by a test.
2786
2787🤖 Generated with [Claude Code](https://claude.com/claude-code)
2788BODY
2789)"
2790```
2791
2792- [ ] **Step 4: Report what remains**
2793
2794State plainly in the handoff: the page has **not been seen in a browser**, and the AWS half has **not run against a real account** until the pinfra grant lands. Neither is a claim the tests can make.
2795