plan: fleet architecture diagram — 9 tasks, infraview seam to PR
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 file changed,
+2794
−0
docs/superpowers/plans/2026-08-19-fleet-architecture-diagram.md+2794 −0
diff --git a/docs/superpowers/plans/2026-08-19-fleet-architecture-diagram.md b/docs/superpowers/plans/2026-08-19-fleet-architecture-diagram.md| new file mode 100644 |
| index 000000000..cbb4b1901 |
| --- /dev/null |
| +++ b/docs/superpowers/plans/2026-08-19-fleet-architecture-diagram.md |
| @@ -0,0 +1,2794 @@ |
| +# Fleet Architecture Diagram Implementation Plan |
| + |
| +> **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. |
| + |
| +**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. |
| + |
| +**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. |
| + |
| +**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`. |
| + |
| +**Spec:** `docs/superpowers/specs/2026-08-19-fleet-architecture-diagram-design.md` |
| + |
| +## Global Constraints |
| + |
| +- **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). |
| +- **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. |
| +- **Cross-compile matters:** CI also runs `GOOS=darwin go build ./...`. Nothing in these packages may assume Linux. |
| +- **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. |
| +- **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. |
| +- **Never read or render secret material:** no access keys, no IAM policy documents, no bucket contents, no instance user-data, no SSM parameters. |
| +- **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. |
| +- **Every change lands through a PR.** No local merge to `main`, no direct commit to `main`. |
| +- **Commit at the end of every task**, message body ending with: |
| + `Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>` |
| + |
| +--- |
| + |
| +## File Structure |
| + |
| +| File | Responsibility | |
| +|---|---| |
| +| `internal/infraview/infraview.go` | `Resource`, `Inventory`, `Provider`, kind constants, `InventoryKey`. No cloud SDK. | |
| +| `internal/infraview/infraview_test.go` | Round-trip, additive-parse, unknown-kind tests. | |
| +| `internal/awsinv/awsinv.go` | `Provider` implementation: assembly, region loop, `Partial`, auth classification. | |
| +| `internal/awsinv/clients.go` | Narrow per-service interfaces + the real client factory. The only AWS SDK imports. | |
| +| `internal/awsinv/ec2.go` | EC2 → `machine`/`disk`/`ip`/`firewall`/`network` mappings. | |
| +| `internal/awsinv/rest.go` | S3, Route53, IAM, CloudWatch, ACM mappings + the tagging catch-all. | |
| +| `internal/awsinv/*_test.go` | Fake clients, one test per mapping, failure-to-`Partial` tests. | |
| +| `internal/console/infrapass.go` | Hourly pass, CAS write of `infra/inventory.json`, auth latch, refresh single-flight. | |
| +| `internal/console/infrapass_test.go` | Latch, transient-retry, CAS-write tests. | |
| +| `internal/console/archgraph.go` | Joins + view model (`ArchView`, `BoxCard`, `ResourceGroup`, `Graph`). | |
| +| `internal/console/archlayout.go` | Layering, ordering, SVG emission. | |
| +| `internal/console/archgraph_test.go`, `archlayout_test.go` | Join, cost/drift, determinism tests. | |
| +| `internal/console/archpage.go` | `handleArchitecture`, `handleArchitectureRefresh`. | |
| +| `internal/console/archpage_test.go` | Gate, content, refresh-door tests. | |
| +| `internal/console/templates/architecture.html` | The page. | |
| +| `internal/console/server.go` | Route registration, `page` fields, `Server` seam field. | |
| +| `internal/console/templates/fleet.html` | One link to the new page. | |
| +| `cmd/console/main.go` | Start the pass. | |
| +| `infra/modules/region-host/main.tf` | Read-only inventory IAM grants. | |
| + |
| +--- |
| + |
| +### Task 1: `internal/infraview` — the provider-neutral inventory |
| + |
| +**Files:** |
| +- Create: `internal/infraview/infraview.go` |
| +- Test: `internal/infraview/infraview_test.go` |
| + |
| +**Interfaces:** |
| +- Consumes: nothing. |
| +- 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`. |
| + |
| +- [ ] **Step 1: Write the failing test** |
| + |
| +```go |
| +package infraview |
| + |
| +import ( |
| + "encoding/json" |
| + "testing" |
| + "time" |
| +) |
| + |
| +// An inventory written before a field existed must still parse, and a kind |
| +// no reader knows must survive as itself rather than being dropped — the |
| +// repo's additive wire-shape rule applied to a bucket object. |
| +func TestInventoryParsesAdditively(t *testing.T) { |
| + old := []byte(`{ |
| + "provider":"aws","account":"1234","taken_at":"2026-08-19T10:00:00Z", |
| + "resources":[ |
| + {"provider":"aws","kind":"machine","native_type":"ec2:instance", |
| + "id":"i-123","name":"carlos-eu-west-1","region":"eu-west-1", |
| + "attrs":{"type":"t4g.medium","public_ip":"52.0.0.1"}}, |
| + {"provider":"aws","kind":"quantum_widget","native_type":"qc:widget","id":"q-1"} |
| + ], |
| + "future_field":"ignored" |
| + }`) |
| + var inv Inventory |
| + if err := json.Unmarshal(old, &inv); err != nil { |
| + t.Fatalf("parse: %v", err) |
| + } |
| + if inv.Provider != "aws" || inv.Account != "1234" { |
| + t.Fatalf("header: %+v", inv) |
| + } |
| + if !inv.TakenAt.Equal(time.Date(2026, 8, 19, 10, 0, 0, 0, time.UTC)) { |
| + t.Fatalf("taken_at: %v", inv.TakenAt) |
| + } |
| + if len(inv.Resources) != 2 { |
| + t.Fatalf("resources: %d, want 2 (an unknown kind must not be dropped)", len(inv.Resources)) |
| + } |
| + if got := inv.Resources[1].Kind; got != "quantum_widget" { |
| + t.Fatalf("unknown kind = %q, want it carried verbatim", got) |
| + } |
| +} |
| + |
| +func TestByKindAndFind(t *testing.T) { |
| + inv := Inventory{Resources: []Resource{ |
| + {Kind: KindMachine, ID: "i-1", Attrs: map[string]string{"public_ip": "52.0.0.1"}}, |
| + {Kind: KindMachine, ID: "i-2", Attrs: map[string]string{"public_ip": "52.0.0.2"}}, |
| + {Kind: KindBucket, ID: "carlos-data-ie"}, |
| + }} |
| + if got := len(inv.ByKind(KindMachine)); got != 2 { |
| + t.Fatalf("ByKind(machine) = %d, want 2", got) |
| + } |
| + if got := inv.Find(KindMachine, "public_ip", "52.0.0.2"); got == nil || got.ID != "i-2" { |
| + t.Fatalf("Find = %+v, want i-2", got) |
| + } |
| + if got := inv.Find(KindMachine, "public_ip", "10.0.0.9"); got != nil { |
| + t.Fatalf("Find(miss) = %+v, want nil", got) |
| + } |
| +} |
| + |
| +// A resource with no Attrs map must not panic any accessor — an inventory |
| +// read from an older writer is full of them. |
| +func TestFindNilAttrs(t *testing.T) { |
| + inv := Inventory{Resources: []Resource{{Kind: KindMachine, ID: "i-1"}}} |
| + if got := inv.Find(KindMachine, "public_ip", "52.0.0.1"); got != nil { |
| + t.Fatalf("Find over nil Attrs = %+v, want nil", got) |
| + } |
| +} |
| +``` |
| + |
| +- [ ] **Step 2: Run test to verify it fails** |
| + |
| +Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/infraview/ -count=1` |
| +Expected: FAIL — the package does not compile (`undefined: Inventory`). |
| + |
| +- [ ] **Step 3: Write minimal implementation** |
| + |
| +```go |
| +// Package infraview is what an infrastructure inventory IS, in terms no |
| +// cloud provider owns. It is deliberately free of every SDK: the AWS |
| +// implementation (internal/awsinv) imports this package, never the other |
| +// way round, so the console page that renders an inventory can be pointed |
| +// at a second provider by writing one file rather than by rewriting the |
| +// page. |
| +// |
| +// The wire shape here is written to a production bucket |
| +// (InventoryKey), so it obeys the repo's additive rule: every field is |
| +// optional, unknown fields parse, and an unknown Kind is carried through |
| +// as itself. A reader that drops what it does not recognise would make the |
| +// page LESS useful exactly where it is most useful — the resource nobody |
| +// wrote code for is the one worth seeing. |
| +package infraview |
| + |
| +import ( |
| + "context" |
| + "time" |
| +) |
| + |
| +// InventoryKey is where a deployment's inventory snapshot lives in its own |
| +// bucket. One object, overwritten in place: this is a current-state |
| +// snapshot, not a history, and a history would need a retention policy |
| +// nobody has asked for. |
| +const InventoryKey = "infra/inventory.json" |
| + |
| +// The kind vocabulary. Small and neutral on purpose — a provider maps its |
| +// own types into these and carries the truth in NativeType, so the page's |
| +// layout rules ("machines sit in the box layer") never have to learn a new |
| +// cloud's nouns. KindOther is not a failure: it is the honest home for a |
| +// discovered resource this vocabulary has no word for. |
| +const ( |
| + KindMachine = "machine" |
| + KindIP = "ip" |
| + KindDisk = "disk" |
| + KindFirewall = "firewall" |
| + KindNetwork = "network" |
| + KindBucket = "bucket" |
| + KindDNSZone = "dns_zone" |
| + KindDNSRecord = "dns_record" |
| + KindIdentity = "identity" |
| + KindAlarm = "alarm" |
| + KindCertificate = "certificate" |
| + KindOther = "other" |
| +) |
| + |
| +// Resource is one thing a provider is carrying. Attrs is stringly on |
| +// purpose: a typed struct per kind would need a migration every time a |
| +// provider exposes a new field, and this object must stay parseable by |
| +// readers written before that field existed. Keys the console renders are |
| +// documented where it reads them; keys it does not know are shown verbatim. |
| +type Resource struct { |
| + Provider string `json:"provider"` |
| + Kind string `json:"kind"` |
| + NativeType string `json:"native_type,omitempty"` |
| + ID string `json:"id"` |
| + Name string `json:"name,omitempty"` |
| + Region string `json:"region,omitempty"` |
| + Attrs map[string]string `json:"attrs,omitempty"` |
| + Tags map[string]string `json:"tags,omitempty"` |
| +} |
| + |
| +// Attr reads one attribute without the caller having to nil-check the map. |
| +func (r Resource) Attr(key string) string { |
| + if r.Attrs == nil { |
| + return "" |
| + } |
| + return r.Attrs[key] |
| +} |
| + |
| +// Inventory is one provider's estate at one moment. Partial names the reads |
| +// that FAILED, in words a person can act on ("security groups in eu-west-1: |
| +// throttled"). It exists because "no security groups" and "the security |
| +// group read failed" are different sentences and only one of them may be |
| +// rendered as a fact. |
| +type Inventory struct { |
| + Provider string `json:"provider"` |
| + Account string `json:"account,omitempty"` |
| + TakenAt time.Time `json:"taken_at"` |
| + Resources []Resource `json:"resources"` |
| + Partial []string `json:"partial,omitempty"` |
| +} |
| + |
| +// ByKind returns every resource of one kind, in inventory order. |
| +func (i Inventory) ByKind(kind string) []Resource { |
| + var out []Resource |
| + for _, r := range i.Resources { |
| + if r.Kind == kind { |
| + out = append(out, r) |
| + } |
| + } |
| + return out |
| +} |
| + |
| +// Find returns the first resource of kind whose attr equals value, or nil. |
| +// value == "" never matches: an empty attribute is absence, and matching on |
| +// it would join every machine with no public IP to every record with no |
| +// value. |
| +func (i Inventory) Find(kind, attr, value string) *Resource { |
| + if value == "" { |
| + return nil |
| + } |
| + for idx := range i.Resources { |
| + if i.Resources[idx].Kind == kind && i.Resources[idx].Attr(attr) == value { |
| + return &i.Resources[idx] |
| + } |
| + } |
| + return nil |
| +} |
| + |
| +// Provider is one cloud (or one bare-metal estate) that can describe |
| +// itself. Implementations return construction-class failures as an error — |
| +// no credential, no reachable API — and everything else through |
| +// Inventory.Partial, because a page that can show eleven resource classes |
| +// must show eleven. |
| +type Provider interface { |
| + Inventory(ctx context.Context) (Inventory, error) |
| +} |
| +``` |
| + |
| +- [ ] **Step 4: Run tests to verify they pass** |
| + |
| +Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/infraview/ -count=1` |
| +Expected: PASS (3 tests). |
| + |
| +- [ ] **Step 5: Gate and commit** |
| + |
| +```bash |
| +GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go build ./... && \ |
| +GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go vet ./... && \ |
| +GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./... -count=1 && \ |
| +gofmt -l internal/infraview |
| +git add internal/infraview |
| +git commit -m "infraview: provider-neutral infrastructure inventory shape |
| + |
| +Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>" |
| +``` |
| + |
| +--- |
| + |
| +### Task 2: `internal/awsinv` — client seam and EC2 mappings |
| + |
| +**Files:** |
| +- Create: `internal/awsinv/clients.go`, `internal/awsinv/ec2.go` |
| +- Test: `internal/awsinv/ec2_test.go` |
| +- Modify: `go.mod` (add `service/ec2`, `service/cloudwatch`, `service/acm`, `service/resourcegroupstaggingapi`) |
| + |
| +**Interfaces:** |
| +- Consumes: `infraview.Resource`, the `Kind*` constants. |
| +- Produces: `awsinv.Clients` struct, `awsinv.Factory` func type, `awsinv.ec2Resources(ctx, EC2API, region string) ([]infraview.Resource, []string)` returning resources and failure lines. |
| + |
| +- [ ] **Step 1: Add the SDK dependencies** |
| + |
| +```bash |
| +GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod go get \ |
| + github.com/aws/aws-sdk-go-v2/service/ec2 \ |
| + github.com/aws/aws-sdk-go-v2/service/cloudwatch \ |
| + github.com/aws/aws-sdk-go-v2/service/acm \ |
| + github.com/aws/aws-sdk-go-v2/service/resourcegroupstaggingapi |
| +``` |
| + |
| +(`GOPROXY=off` must NOT be set for this one step; it is the only step in the plan that fetches.) |
| + |
| +- [ ] **Step 2: Write the failing test** |
| + |
| +```go |
| +package awsinv |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "testing" |
| + |
| + "github.com/aws/aws-sdk-go-v2/aws" |
| + "github.com/aws/aws-sdk-go-v2/service/ec2" |
| + ec2types "github.com/aws/aws-sdk-go-v2/service/ec2/types" |
| + "github.com/carlosframework/platform/internal/infraview" |
| +) |
| + |
| +// fakeEC2 answers whatever the test hands it; a non-nil err field for a |
| +// call makes that ONE call fail, which is how the partial-failure rule |
| +// below is proven. |
| +type fakeEC2 struct { |
| + instances *ec2.DescribeInstancesOutput |
| + volumes *ec2.DescribeVolumesOutput |
| + addresses *ec2.DescribeAddressesOutput |
| + groups *ec2.DescribeSecurityGroupsOutput |
| + vpcs *ec2.DescribeVpcsOutput |
| + subnets *ec2.DescribeSubnetsOutput |
| + regions *ec2.DescribeRegionsOutput |
| + errVols error |
| +} |
| + |
| +func (f *fakeEC2) DescribeInstances(context.Context, *ec2.DescribeInstancesInput, ...func(*ec2.Options)) (*ec2.DescribeInstancesOutput, error) { |
| + return f.instances, nil |
| +} |
| +func (f *fakeEC2) DescribeVolumes(context.Context, *ec2.DescribeVolumesInput, ...func(*ec2.Options)) (*ec2.DescribeVolumesOutput, error) { |
| + if f.errVols != nil { |
| + return nil, f.errVols |
| + } |
| + return f.volumes, nil |
| +} |
| +func (f *fakeEC2) DescribeAddresses(context.Context, *ec2.DescribeAddressesInput, ...func(*ec2.Options)) (*ec2.DescribeAddressesOutput, error) { |
| + return f.addresses, nil |
| +} |
| +func (f *fakeEC2) DescribeSecurityGroups(context.Context, *ec2.DescribeSecurityGroupsInput, ...func(*ec2.Options)) (*ec2.DescribeSecurityGroupsOutput, error) { |
| + return f.groups, nil |
| +} |
| +func (f *fakeEC2) DescribeVpcs(context.Context, *ec2.DescribeVpcsInput, ...func(*ec2.Options)) (*ec2.DescribeVpcsOutput, error) { |
| + return f.vpcs, nil |
| +} |
| +func (f *fakeEC2) DescribeSubnets(context.Context, *ec2.DescribeSubnetsInput, ...func(*ec2.Options)) (*ec2.DescribeSubnetsOutput, error) { |
| + return f.subnets, nil |
| +} |
| +func (f *fakeEC2) DescribeRegions(context.Context, *ec2.DescribeRegionsInput, ...func(*ec2.Options)) (*ec2.DescribeRegionsOutput, error) { |
| + return f.regions, nil |
| +} |
| + |
| +func find(rs []infraview.Resource, kind, id string) *infraview.Resource { |
| + for i := range rs { |
| + if rs[i].Kind == kind && rs[i].ID == id { |
| + return &rs[i] |
| + } |
| + } |
| + return nil |
| +} |
| + |
| +func TestEC2InstanceMapping(t *testing.T) { |
| + f := &fakeEC2{instances: &ec2.DescribeInstancesOutput{ |
| + Reservations: []ec2types.Reservation{{Instances: []ec2types.Instance{{ |
| + InstanceId: aws.String("i-123"), |
| + InstanceType: ec2types.InstanceTypeT4gMedium, |
| + State: &ec2types.InstanceState{Name: ec2types.InstanceStateNameRunning}, |
| + Placement: &ec2types.Placement{AvailabilityZone: aws.String("eu-west-1a")}, |
| + PublicIpAddress: aws.String("52.0.0.1"), |
| + PrivateIpAddress: aws.String("10.0.0.5"), |
| + Tags: []ec2types.Tag{{Key: aws.String("Name"), Value: aws.String("carlos-eu-west-1")}}, |
| + }}}}, |
| + }} |
| + |
| + rs, partial := ec2Resources(context.Background(), f, "eu-west-1") |
| + if len(partial) != 0 { |
| + t.Fatalf("partial = %v, want none", partial) |
| + } |
| + m := find(rs, infraview.KindMachine, "i-123") |
| + if m == nil { |
| + t.Fatalf("no machine in %+v", rs) |
| + } |
| + if m.Name != "carlos-eu-west-1" { |
| + t.Errorf("Name = %q, want the Name tag", m.Name) |
| + } |
| + if m.Region != "eu-west-1" || m.NativeType != "ec2:instance" { |
| + t.Errorf("region/type = %q/%q", m.Region, m.NativeType) |
| + } |
| + for k, want := range map[string]string{ |
| + "type": "t4g.medium", "state": "running", "az": "eu-west-1a", |
| + "public_ip": "52.0.0.1", "private_ip": "10.0.0.5", |
| + } { |
| + if got := m.Attr(k); got != want { |
| + t.Errorf("attr %s = %q, want %q", k, got, want) |
| + } |
| + } |
| +} |
| + |
| +// One failing call costs its own resources and nothing else — the rule the |
| +// whole inventory depends on, since a page that shows eleven of twelve |
| +// classes is worth far more than one that shows none. |
| +func TestEC2PartialFailureCostsOnlyItsOwnCall(t *testing.T) { |
| + f := &fakeEC2{ |
| + instances: &ec2.DescribeInstancesOutput{Reservations: []ec2types.Reservation{{ |
| + Instances: []ec2types.Instance{{InstanceId: aws.String("i-123")}}, |
| + }}}, |
| + errVols: errors.New("Throttling: rate exceeded"), |
| + } |
| + rs, partial := ec2Resources(context.Background(), f, "eu-west-1") |
| + if find(rs, infraview.KindMachine, "i-123") == nil { |
| + t.Fatalf("a failed volume read must not cost the instances") |
| + } |
| + if len(partial) != 1 { |
| + t.Fatalf("partial = %v, want exactly the volumes line", partial) |
| + } |
| + if !contains(partial[0], "volumes") || !contains(partial[0], "eu-west-1") { |
| + t.Errorf("partial line %q must name what failed and where", partial[0]) |
| + } |
| +} |
| + |
| +func contains(s, sub string) bool { return len(s) >= len(sub) && (stringIndex(s, sub) >= 0) } |
| +func stringIndex(s, sub string) int { |
| + for i := 0; i+len(sub) <= len(s); i++ { |
| + if s[i:i+len(sub)] == sub { |
| + return i |
| + } |
| + } |
| + return -1 |
| +} |
| +``` |
| + |
| +- [ ] **Step 3: Run test to verify it fails** |
| + |
| +Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/awsinv/ -count=1` |
| +Expected: FAIL — `undefined: ec2Resources`. |
| + |
| +- [ ] **Step 4: Write `clients.go`** |
| + |
| +```go |
| +// Package awsinv discovers what an AWS account is carrying and reports it |
| +// as an internal/infraview.Inventory. It is the ONLY package in this repo |
| +// that imports an AWS SDK for inventory purposes, which is the whole point |
| +// of the seam: the console page that renders an estate never sees an AWS |
| +// type, so a second provider is a new package rather than a rewrite. |
| +// |
| +// Every call here is read-only. The IAM policy this package needs |
| +// (infra/modules/region-host/main.tf) contains no write action, so that |
| +// property is enforced by the credential and not by this comment. |
| +package awsinv |
| + |
| +import ( |
| + "context" |
| + |
| + "github.com/aws/aws-sdk-go-v2/aws" |
| + "github.com/aws/aws-sdk-go-v2/service/acm" |
| + "github.com/aws/aws-sdk-go-v2/service/cloudwatch" |
| + "github.com/aws/aws-sdk-go-v2/service/ec2" |
| + "github.com/aws/aws-sdk-go-v2/service/iam" |
| + "github.com/aws/aws-sdk-go-v2/service/resourcegroupstaggingapi" |
| + "github.com/aws/aws-sdk-go-v2/service/route53" |
| + "github.com/aws/aws-sdk-go-v2/service/s3" |
| +) |
| + |
| +// The narrow interfaces exist so the mappings can be tested against fakes |
| +// with no network and no credential. Each lists exactly the calls this |
| +// package makes and nothing else — an interface that named the whole |
| +// client would be an invitation to reach for a write. |
| +type EC2API interface { |
| + DescribeInstances(context.Context, *ec2.DescribeInstancesInput, ...func(*ec2.Options)) (*ec2.DescribeInstancesOutput, error) |
| + DescribeVolumes(context.Context, *ec2.DescribeVolumesInput, ...func(*ec2.Options)) (*ec2.DescribeVolumesOutput, error) |
| + DescribeAddresses(context.Context, *ec2.DescribeAddressesInput, ...func(*ec2.Options)) (*ec2.DescribeAddressesOutput, error) |
| + DescribeSecurityGroups(context.Context, *ec2.DescribeSecurityGroupsInput, ...func(*ec2.Options)) (*ec2.DescribeSecurityGroupsOutput, error) |
| + DescribeVpcs(context.Context, *ec2.DescribeVpcsInput, ...func(*ec2.Options)) (*ec2.DescribeVpcsOutput, error) |
| + DescribeSubnets(context.Context, *ec2.DescribeSubnetsInput, ...func(*ec2.Options)) (*ec2.DescribeSubnetsOutput, error) |
| + DescribeRegions(context.Context, *ec2.DescribeRegionsInput, ...func(*ec2.Options)) (*ec2.DescribeRegionsOutput, error) |
| +} |
| + |
| +type S3API interface { |
| + ListBuckets(context.Context, *s3.ListBucketsInput, ...func(*s3.Options)) (*s3.ListBucketsOutput, error) |
| + GetBucketLocation(context.Context, *s3.GetBucketLocationInput, ...func(*s3.Options)) (*s3.GetBucketLocationOutput, error) |
| + GetBucketVersioning(context.Context, *s3.GetBucketVersioningInput, ...func(*s3.Options)) (*s3.GetBucketVersioningOutput, error) |
| + GetBucketEncryption(context.Context, *s3.GetBucketEncryptionInput, ...func(*s3.Options)) (*s3.GetBucketEncryptionOutput, error) |
| + GetPublicAccessBlock(context.Context, *s3.GetPublicAccessBlockInput, ...func(*s3.Options)) (*s3.GetPublicAccessBlockOutput, error) |
| +} |
| + |
| +type Route53API interface { |
| + ListHostedZones(context.Context, *route53.ListHostedZonesInput, ...func(*route53.Options)) (*route53.ListHostedZonesOutput, error) |
| + ListResourceRecordSets(context.Context, *route53.ListResourceRecordSetsInput, ...func(*route53.Options)) (*route53.ListResourceRecordSetsOutput, error) |
| +} |
| + |
| +type IAMAPI interface { |
| + ListRoles(context.Context, *iam.ListRolesInput, ...func(*iam.Options)) (*iam.ListRolesOutput, error) |
| + ListUsers(context.Context, *iam.ListUsersInput, ...func(*iam.Options)) (*iam.ListUsersOutput, error) |
| + ListAttachedRolePolicies(context.Context, *iam.ListAttachedRolePoliciesInput, ...func(*iam.Options)) (*iam.ListAttachedRolePoliciesOutput, error) |
| +} |
| + |
| +type CloudWatchAPI interface { |
| + DescribeAlarms(context.Context, *cloudwatch.DescribeAlarmsInput, ...func(*cloudwatch.Options)) (*cloudwatch.DescribeAlarmsOutput, error) |
| +} |
| + |
| +type ACMAPI interface { |
| + ListCertificates(context.Context, *acm.ListCertificatesInput, ...func(*acm.Options)) (*acm.ListCertificatesOutput, error) |
| +} |
| + |
| +type TaggingAPI interface { |
| + GetResources(context.Context, *resourcegroupstaggingapi.GetResourcesInput, ...func(*resourcegroupstaggingapi.Options)) (*resourcegroupstaggingapi.GetResourcesOutput, error) |
| +} |
| + |
| +// Clients is one region's worth of read-only clients. S3/Route53/IAM are |
| +// global services; the assembly calls them once, from the home region's |
| +// Clients, rather than once per region. |
| +type Clients struct { |
| + EC2 EC2API |
| + S3 S3API |
| + Route53 Route53API |
| + IAM IAMAPI |
| + CloudWatch CloudWatchAPI |
| + ACM ACMAPI |
| + Tagging TaggingAPI |
| +} |
| + |
| +// Factory builds the clients for one region. Tests inject a fake; the real |
| +// one below rebases a single resolved aws.Config onto each region so the |
| +// credential chain resolves exactly once. |
| +type Factory func(region string) Clients |
| + |
| +func factoryFor(cfg aws.Config) Factory { |
| + return func(region string) Clients { |
| + c := cfg.Copy() |
| + if region != "" { |
| + c.Region = region |
| + } |
| + return Clients{ |
| + EC2: ec2.NewFromConfig(c), |
| + S3: s3.NewFromConfig(c), |
| + Route53: route53.NewFromConfig(c), |
| + IAM: iam.NewFromConfig(c), |
| + CloudWatch: cloudwatch.NewFromConfig(c), |
| + ACM: acm.NewFromConfig(c), |
| + Tagging: resourcegroupstaggingapi.NewFromConfig(c), |
| + } |
| + } |
| +} |
| +``` |
| + |
| +- [ ] **Step 5: Write `ec2.go`** |
| + |
| +```go |
| +package awsinv |
| + |
| +import ( |
| + "context" |
| + "fmt" |
| + "strconv" |
| + |
| + "github.com/aws/aws-sdk-go-v2/aws" |
| + "github.com/aws/aws-sdk-go-v2/service/ec2" |
| + ec2types "github.com/aws/aws-sdk-go-v2/service/ec2/types" |
| + "github.com/carlosframework/platform/internal/infraview" |
| +) |
| + |
| +// res builds one Resource with the fields every mapping sets the same way, |
| +// so a new mapping cannot forget the provider stamp. |
| +func res(kind, nativeType, id, name, region string) infraview.Resource { |
| + return infraview.Resource{ |
| + Provider: "aws", Kind: kind, NativeType: nativeType, |
| + ID: id, Name: name, Region: region, Attrs: map[string]string{}, |
| + } |
| +} |
| + |
| +// set writes an attribute only when it has a value: an absent key and an |
| +// empty string are different claims, and the joins treat "" as absence. |
| +func set(r *infraview.Resource, key, value string) { |
| + if value != "" { |
| + r.Attrs[key] = value |
| + } |
| +} |
| + |
| +func tagsOf(tags []ec2types.Tag) map[string]string { |
| + if len(tags) == 0 { |
| + return nil |
| + } |
| + out := map[string]string{} |
| + for _, t := range tags { |
| + out[aws.ToString(t.Key)] = aws.ToString(t.Value) |
| + } |
| + return out |
| +} |
| + |
| +// ec2Resources maps one region's EC2 surface. Each call contributes its own |
| +// resources and, on failure, one line to partial — never an error: this |
| +// function's whole contract is that a throttled volumes call costs the |
| +// volumes and nothing else. |
| +func ec2Resources(ctx context.Context, api EC2API, region string) (out []infraview.Resource, partial []string) { |
| + fail := func(what string, err error) { |
| + partial = append(partial, fmt.Sprintf("%s in %s: %v", what, region, err)) |
| + } |
| + |
| + if o, err := api.DescribeInstances(ctx, &ec2.DescribeInstancesInput{}); err != nil { |
| + fail("instances", err) |
| + } else { |
| + for _, rv := range o.Reservations { |
| + for _, in := range rv.Instances { |
| + tags := tagsOf(in.Tags) |
| + r := res(infraview.KindMachine, "ec2:instance", aws.ToString(in.InstanceId), tags["Name"], region) |
| + r.Tags = tags |
| + set(&r, "type", string(in.InstanceType)) |
| + if in.State != nil { |
| + set(&r, "state", string(in.State.Name)) |
| + } |
| + if in.Placement != nil { |
| + set(&r, "az", aws.ToString(in.Placement.AvailabilityZone)) |
| + } |
| + set(&r, "public_ip", aws.ToString(in.PublicIpAddress)) |
| + set(&r, "private_ip", aws.ToString(in.PrivateIpAddress)) |
| + if in.LaunchTime != nil { |
| + set(&r, "launched", in.LaunchTime.UTC().Format("2006-01-02")) |
| + } |
| + out = append(out, r) |
| + } |
| + } |
| + } |
| + |
| + if o, err := api.DescribeVolumes(ctx, &ec2.DescribeVolumesInput{}); err != nil { |
| + fail("volumes", err) |
| + } else { |
| + for _, v := range o.Volumes { |
| + r := res(infraview.KindDisk, "ec2:volume", aws.ToString(v.VolumeId), tagsOf(v.Tags)["Name"], region) |
| + r.Tags = tagsOf(v.Tags) |
| + if v.Size != nil { |
| + set(&r, "size_gb", strconv.Itoa(int(*v.Size))) |
| + } |
| + set(&r, "volume_type", string(v.VolumeType)) |
| + for _, a := range v.Attachments { |
| + set(&r, "attached_to", aws.ToString(a.InstanceId)) |
| + } |
| + out = append(out, r) |
| + } |
| + } |
| + |
| + if o, err := api.DescribeAddresses(ctx, &ec2.DescribeAddressesInput{}); err != nil { |
| + fail("elastic IPs", err) |
| + } else { |
| + for _, a := range o.Addresses { |
| + r := res(infraview.KindIP, "ec2:eip", aws.ToString(a.AllocationId), tagsOf(a.Tags)["Name"], region) |
| + r.Tags = tagsOf(a.Tags) |
| + set(&r, "address", aws.ToString(a.PublicIp)) |
| + set(&r, "attached_to", aws.ToString(a.InstanceId)) |
| + out = append(out, r) |
| + } |
| + } |
| + |
| + if o, err := api.DescribeSecurityGroups(ctx, &ec2.DescribeSecurityGroupsInput{}); err != nil { |
| + fail("security groups", err) |
| + } else { |
| + for _, g := range o.SecurityGroups { |
| + r := res(infraview.KindFirewall, "ec2:security-group", aws.ToString(g.GroupId), aws.ToString(g.GroupName), region) |
| + r.Tags = tagsOf(g.Tags) |
| + set(&r, "ingress", summarizePerms(g.IpPermissions)) |
| + set(&r, "egress", summarizePerms(g.IpPermissionsEgress)) |
| + out = append(out, r) |
| + } |
| + } |
| + |
| + if o, err := api.DescribeVpcs(ctx, &ec2.DescribeVpcsInput{}); err != nil { |
| + fail("VPCs", err) |
| + } else { |
| + for _, v := range o.Vpcs { |
| + r := res(infraview.KindNetwork, "ec2:vpc", aws.ToString(v.VpcId), tagsOf(v.Tags)["Name"], region) |
| + r.Tags = tagsOf(v.Tags) |
| + set(&r, "cidr", aws.ToString(v.CidrBlock)) |
| + out = append(out, r) |
| + } |
| + } |
| + |
| + if o, err := api.DescribeSubnets(ctx, &ec2.DescribeSubnetsInput{}); err != nil { |
| + fail("subnets", err) |
| + } else { |
| + for _, s := range o.Subnets { |
| + r := res(infraview.KindNetwork, "ec2:subnet", aws.ToString(s.SubnetId), tagsOf(s.Tags)["Name"], region) |
| + r.Tags = tagsOf(s.Tags) |
| + set(&r, "cidr", aws.ToString(s.CidrBlock)) |
| + set(&r, "az", aws.ToString(s.AvailabilityZone)) |
| + set(&r, "vpc", aws.ToString(s.VpcId)) |
| + out = append(out, r) |
| + } |
| + } |
| + return out, partial |
| +} |
| + |
| +// summarizePerms renders a rule set as "tcp/22 from 10.0.0.0/8 · tcp/443 |
| +// from 0.0.0.0/0" — a sentence an operator reads, never the raw JSON, which |
| +// would be both unreadable and a wall of detail the page does not want. |
| +func summarizePerms(perms []ec2types.IpPermission) string { |
| + out := "" |
| + for _, p := range perms { |
| + proto := aws.ToString(p.IpProtocol) |
| + if proto == "-1" { |
| + proto = "all" |
| + } |
| + port := "all" |
| + if p.FromPort != nil { |
| + port = strconv.Itoa(int(*p.FromPort)) |
| + if p.ToPort != nil && *p.ToPort != *p.FromPort { |
| + port += "-" + strconv.Itoa(int(*p.ToPort)) |
| + } |
| + } |
| + for _, rng := range p.IpRanges { |
| + if out != "" { |
| + out += " · " |
| + } |
| + out += fmt.Sprintf("%s/%s from %s", proto, port, aws.ToString(rng.CidrIp)) |
| + } |
| + } |
| + return out |
| +} |
| +``` |
| + |
| +- [ ] **Step 6: Run tests to verify they pass** |
| + |
| +Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/awsinv/ -count=1 -v` |
| +Expected: PASS — `TestEC2InstanceMapping`, `TestEC2PartialFailureCostsOnlyItsOwnCall`. |
| + |
| +- [ ] **Step 7: Gate and commit** |
| + |
| +```bash |
| +GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go build ./... && \ |
| +GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go vet ./... && \ |
| +GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./... -count=1 && \ |
| +gofmt -l internal/awsinv |
| +git add go.mod go.sum internal/awsinv |
| +git commit -m "awsinv: read-only client seam and EC2 mappings |
| + |
| +Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>" |
| +``` |
| + |
| +--- |
| + |
| +### Task 3: `internal/awsinv` — remaining services, catch-all, assembly |
| + |
| +**Files:** |
| +- Create: `internal/awsinv/rest.go`, `internal/awsinv/awsinv.go` |
| +- Test: `internal/awsinv/rest_test.go`, `internal/awsinv/awsinv_test.go` |
| + |
| +**Interfaces:** |
| +- Consumes: `Clients`, `Factory`, `ec2Resources` (Task 2), `infraview.Inventory`. |
| +- 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`. |
| + |
| +- [ ] **Step 1: Write the failing assembly test** |
| + |
| +```go |
| +package awsinv |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "testing" |
| + |
| + "github.com/aws/aws-sdk-go-v2/aws" |
| + "github.com/aws/aws-sdk-go-v2/service/ec2" |
| + ec2types "github.com/aws/aws-sdk-go-v2/service/ec2/types" |
| + "github.com/aws/aws-sdk-go-v2/service/resourcegroupstaggingapi" |
| + rgtypes "github.com/aws/aws-sdk-go-v2/service/resourcegroupstaggingapi/types" |
| + "github.com/carlosframework/platform/internal/infraview" |
| +) |
| + |
| +type fakeTagging struct { |
| + out *resourcegroupstaggingapi.GetResourcesOutput |
| + err error |
| +} |
| + |
| +func (f *fakeTagging) GetResources(context.Context, *resourcegroupstaggingapi.GetResourcesInput, ...func(*resourcegroupstaggingapi.Options)) (*resourcegroupstaggingapi.GetResourcesOutput, error) { |
| + return f.out, f.err |
| +} |
| + |
| +// A resource the typed mappings already produced must NOT appear twice |
| +// because the catch-all also saw it; anything the catch-all alone knows |
| +// about must appear as "other" rather than vanishing. |
| +func TestCatchAllDedupesAgainstTypedResources(t *testing.T) { |
| + factory := func(region string) Clients { |
| + return Clients{ |
| + EC2: &fakeEC2{instances: &ec2.DescribeInstancesOutput{ |
| + Reservations: []ec2types.Reservation{{Instances: []ec2types.Instance{{ |
| + InstanceId: aws.String("i-123"), |
| + }}}}, |
| + }}, |
| + Tagging: &fakeTagging{out: &resourcegroupstaggingapi.GetResourcesOutput{ |
| + ResourceTagMappingList: []rgtypes.ResourceTagMapping{ |
| + {ResourceARN: aws.String("arn:aws:ec2:eu-west-1:1234:instance/i-123")}, |
| + {ResourceARN: aws.String("arn:aws:sqs:eu-west-1:1234:some-queue")}, |
| + }, |
| + }}, |
| + } |
| + } |
| + p := newWithFactory(factory, "eu-west-1", []string{"eu-west-1"}, "1234") |
| + |
| + inv, err := p.Inventory(context.Background()) |
| + if err != nil { |
| + t.Fatalf("Inventory: %v", err) |
| + } |
| + machines := inv.ByKind(infraview.KindMachine) |
| + if len(machines) != 1 { |
| + t.Fatalf("machines = %d, want 1 (the catch-all must not duplicate a typed resource)", len(machines)) |
| + } |
| + others := inv.ByKind(infraview.KindOther) |
| + if len(others) != 1 || others[0].NativeType != "sqs" { |
| + t.Fatalf("others = %+v, want one sqs resource", others) |
| + } |
| + if inv.Provider != "aws" || inv.Account != "1234" || inv.TakenAt.IsZero() { |
| + t.Fatalf("header not stamped: %+v", inv) |
| + } |
| +} |
| + |
| +// Auth-shaped failure with nothing discovered is the one case that must |
| +// reach the caller as an error — it is what latches the console's pass off |
| +// so an ungranted deployment says "not granted" instead of erroring hourly. |
| +func TestAuthShapedWithNoResourcesIsAnError(t *testing.T) { |
| + authErr := &fakeAPIErr{code: "AccessDenied"} |
| + factory := func(region string) Clients { |
| + return Clients{ |
| + EC2: &fakeEC2Failing{err: authErr}, |
| + Tagging: &fakeTagging{err: authErr}, |
| + } |
| + } |
| + p := newWithFactory(factory, "eu-west-1", []string{"eu-west-1"}, "") |
| + if _, err := p.Inventory(context.Background()); err == nil { |
| + t.Fatal("want an error when every read is denied and nothing was discovered") |
| + } |
| +} |
| + |
| +// The same denial, but something WAS discovered: the inventory is returned |
| +// with the failures named in Partial, never thrown away. |
| +func TestPartialSurvivesWhenSomethingWasDiscovered(t *testing.T) { |
| + factory := func(region string) Clients { |
| + return Clients{ |
| + EC2: &fakeEC2{instances: &ec2.DescribeInstancesOutput{ |
| + Reservations: []ec2types.Reservation{{Instances: []ec2types.Instance{{ |
| + InstanceId: aws.String("i-123"), |
| + }}}}, |
| + errVols: errors.New("AccessDenied: not authorized"), |
| + }}, |
| + Tagging: &fakeTagging{err: errors.New("AccessDenied: not authorized")}, |
| + } |
| + } |
| + p := newWithFactory(factory, "eu-west-1", []string{"eu-west-1"}, "") |
| + inv, err := p.Inventory(context.Background()) |
| + if err != nil { |
| + t.Fatalf("Inventory: %v", err) |
| + } |
| + if len(inv.Partial) == 0 { |
| + t.Fatal("failures must be named in Partial") |
| + } |
| +} |
| +``` |
| + |
| +Add the two small fakes this test needs to `rest_test.go`: |
| + |
| +```go |
| +type fakeAPIErr struct{ code string } |
| + |
| +func (e *fakeAPIErr) Error() string { return e.code } |
| +func (e *fakeAPIErr) ErrorCode() string { return e.code } |
| +func (e *fakeAPIErr) ErrorMessage() string { return e.code } |
| +func (e *fakeAPIErr) ErrorFault() smithy.ErrorFault { return smithy.FaultClient } |
| + |
| +// fakeEC2Failing fails every call with the same error. |
| +type fakeEC2Failing struct{ err error } |
| + |
| +func (f *fakeEC2Failing) DescribeInstances(context.Context, *ec2.DescribeInstancesInput, ...func(*ec2.Options)) (*ec2.DescribeInstancesOutput, error) { |
| + return nil, f.err |
| +} |
| +// … the remaining six EC2API methods, each `return nil, f.err` |
| +``` |
| + |
| +- [ ] **Step 2: Run test to verify it fails** |
| + |
| +Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/awsinv/ -count=1` |
| +Expected: FAIL — `undefined: newWithFactory`. |
| + |
| +- [ ] **Step 3: Write `rest.go`** |
| + |
| +Mappings, each following `ec2Resources`' contract exactly (own resources, own `partial` line, never an error): |
| + |
| +```go |
| +package awsinv |
| + |
| +import ( |
| + "context" |
| + "fmt" |
| + "strconv" |
| + "strings" |
| + |
| + "github.com/aws/aws-sdk-go-v2/aws" |
| + "github.com/aws/aws-sdk-go-v2/service/acm" |
| + "github.com/aws/aws-sdk-go-v2/service/cloudwatch" |
| + "github.com/aws/aws-sdk-go-v2/service/iam" |
| + "github.com/aws/aws-sdk-go-v2/service/resourcegroupstaggingapi" |
| + "github.com/aws/aws-sdk-go-v2/service/route53" |
| + "github.com/aws/aws-sdk-go-v2/service/s3" |
| + "github.com/carlosframework/platform/internal/infraview" |
| +) |
| + |
| +// joinedRecordTypes are the record types that can participate in the |
| +// DNS -> IP -> machine join, and so the only ones materialised as their own |
| +// Resource. Everything else is counted into the zone's record_count: a zone |
| +// can hold thousands of records and this object is read on every page load. |
| +var joinedRecordTypes = map[string]bool{"A": true, "AAAA": true} |
| + |
| +func s3Resources(ctx context.Context, api S3API) (out []infraview.Resource, partial []string) { |
| + o, err := api.ListBuckets(ctx, &s3.ListBucketsInput{}) |
| + if err != nil { |
| + return nil, []string{fmt.Sprintf("buckets: %v", err)} |
| + } |
| + for _, b := range o.Buckets { |
| + name := aws.ToString(b.Name) |
| + r := res(infraview.KindBucket, "s3:bucket", name, name, "") |
| + if loc, err := api.GetBucketLocation(ctx, &s3.GetBucketLocationInput{Bucket: b.Name}); err == nil { |
| + region := string(loc.LocationConstraint) |
| + if region == "" { |
| + region = "us-east-1" // the API's documented empty-means-us-east-1 |
| + } |
| + r.Region = region |
| + } |
| + // Each of the three posture reads is best-effort per bucket: a |
| + // bucket whose versioning cannot be read still appears, with the |
| + // attribute absent rather than a false "off". "off" is a claim. |
| + if v, err := api.GetBucketVersioning(ctx, &s3.GetBucketVersioningInput{Bucket: b.Name}); err == nil { |
| + set(&r, "versioning", strings.ToLower(string(v.Status))) |
| + } |
| + if _, err := api.GetBucketEncryption(ctx, &s3.GetBucketEncryptionInput{Bucket: b.Name}); err == nil { |
| + set(&r, "encryption", "on") |
| + } |
| + if p, err := api.GetPublicAccessBlock(ctx, &s3.GetPublicAccessBlockInput{Bucket: b.Name}); err == nil && p.PublicAccessBlockConfiguration != nil { |
| + c := p.PublicAccessBlockConfiguration |
| + if aws.ToBool(c.BlockPublicAcls) && aws.ToBool(c.BlockPublicPolicy) { |
| + set(&r, "public_access_block", "on") |
| + } else { |
| + set(&r, "public_access_block", "partial") |
| + } |
| + } |
| + out = append(out, r) |
| + } |
| + return out, partial |
| +} |
| + |
| +func route53Resources(ctx context.Context, api Route53API) (out []infraview.Resource, partial []string) { |
| + zones, err := api.ListHostedZones(ctx, &route53.ListHostedZonesInput{}) |
| + if err != nil { |
| + return nil, []string{fmt.Sprintf("hosted zones: %v", err)} |
| + } |
| + for _, z := range zones.HostedZones { |
| + id := aws.ToString(z.Id) |
| + name := strings.TrimSuffix(aws.ToString(z.Name), ".") |
| + zr := res(infraview.KindDNSZone, "route53:hosted-zone", id, name, "") |
| + if z.Config != nil && z.Config.PrivateZone { |
| + set(&zr, "private", "yes") |
| + } |
| + count := 0 |
| + var next *string |
| + for { |
| + recs, err := api.ListResourceRecordSets(ctx, &route53.ListResourceRecordSetsInput{ |
| + HostedZoneId: z.Id, StartRecordName: next, |
| + }) |
| + if err != nil { |
| + partial = append(partial, fmt.Sprintf("records in %s: %v", name, err)) |
| + break |
| + } |
| + for _, rec := range recs.ResourceRecordSets { |
| + count++ |
| + rtype := string(rec.Type) |
| + if !joinedRecordTypes[rtype] { |
| + continue |
| + } |
| + rname := strings.TrimSuffix(aws.ToString(rec.Name), ".") |
| + for _, v := range rec.ResourceRecords { |
| + rr := res(infraview.KindDNSRecord, "route53:record", rname+"/"+rtype+"/"+aws.ToString(v.Value), rname, "") |
| + set(&rr, "zone", name) |
| + set(&rr, "type", rtype) |
| + set(&rr, "value", aws.ToString(v.Value)) |
| + out = append(out, rr) |
| + } |
| + if rec.AliasTarget != nil { |
| + rr := res(infraview.KindDNSRecord, "route53:alias", rname+"/ALIAS", rname, "") |
| + set(&rr, "zone", name) |
| + set(&rr, "type", "ALIAS") |
| + set(&rr, "value", strings.TrimSuffix(aws.ToString(rec.AliasTarget.DNSName), ".")) |
| + out = append(out, rr) |
| + } |
| + } |
| + if !recs.IsTruncated { |
| + break |
| + } |
| + next = recs.NextRecordName |
| + } |
| + set(&zr, "record_count", strconv.Itoa(count)) |
| + out = append(out, zr) |
| + } |
| + return out, partial |
| +} |
| + |
| +// iamResources reports identities as NAMES and a count of attached |
| +// policies. Policy documents are deliberately never read: this page is a |
| +// map, not a credential viewer (spec §4). |
| +func iamResources(ctx context.Context, api IAMAPI) (out []infraview.Resource, partial []string) { |
| + if o, err := api.ListRoles(ctx, &iam.ListRolesInput{}); err != nil { |
| + partial = append(partial, fmt.Sprintf("IAM roles: %v", err)) |
| + } else { |
| + for _, role := range o.Roles { |
| + name := aws.ToString(role.RoleName) |
| + r := res(infraview.KindIdentity, "iam:role", aws.ToString(role.Arn), name, "") |
| + set(&r, "kind", "role") |
| + if pol, err := api.ListAttachedRolePolicies(ctx, &iam.ListAttachedRolePoliciesInput{RoleName: role.RoleName}); err == nil { |
| + set(&r, "attached_policies", strconv.Itoa(len(pol.AttachedPolicies))) |
| + } |
| + out = append(out, r) |
| + } |
| + } |
| + if o, err := api.ListUsers(ctx, &iam.ListUsersInput{}); err != nil { |
| + partial = append(partial, fmt.Sprintf("IAM users: %v", err)) |
| + } else { |
| + for _, u := range o.Users { |
| + r := res(infraview.KindIdentity, "iam:user", aws.ToString(u.Arn), aws.ToString(u.UserName), "") |
| + set(&r, "kind", "user") |
| + out = append(out, r) |
| + } |
| + } |
| + return out, partial |
| +} |
| + |
| +func alarmResources(ctx context.Context, api CloudWatchAPI, region string) (out []infraview.Resource, partial []string) { |
| + o, err := api.DescribeAlarms(ctx, &cloudwatch.DescribeAlarmsInput{}) |
| + if err != nil { |
| + return nil, []string{fmt.Sprintf("alarms in %s: %v", region, err)} |
| + } |
| + for _, a := range o.MetricAlarms { |
| + r := res(infraview.KindAlarm, "cloudwatch:alarm", aws.ToString(a.AlarmArn), aws.ToString(a.AlarmName), region) |
| + set(&r, "state", string(a.StateValue)) |
| + set(&r, "metric", aws.ToString(a.MetricName)) |
| + for _, d := range a.Dimensions { |
| + if aws.ToString(d.Name) == "InstanceId" { |
| + set(&r, "target", aws.ToString(d.Value)) |
| + } |
| + } |
| + out = append(out, r) |
| + } |
| + return out, partial |
| +} |
| + |
| +func certResources(ctx context.Context, api ACMAPI, region string) (out []infraview.Resource, partial []string) { |
| + o, err := api.ListCertificates(ctx, &acm.ListCertificatesInput{}) |
| + if err != nil { |
| + return nil, []string{fmt.Sprintf("certificates in %s: %v", region, err)} |
| + } |
| + for _, c := range o.CertificateSummaryList { |
| + r := res(infraview.KindCertificate, "acm:certificate", aws.ToString(c.CertificateArn), aws.ToString(c.DomainName), region) |
| + set(&r, "domain", aws.ToString(c.DomainName)) |
| + set(&r, "status", string(c.Status)) |
| + if c.NotAfter != nil { |
| + set(&r, "expires", c.NotAfter.UTC().Format("2006-01-02")) |
| + } |
| + out = append(out, r) |
| + } |
| + return out, partial |
| +} |
| + |
| +// catchAll is the one genuinely AWS-shaped trick in this package: one call |
| +// that returns every TAGGABLE resource in the region, so a resource type |
| +// nobody wrote a mapping for is still visible. seen holds the ids the typed |
| +// mappings already produced — an ARN whose id is in it is skipped, so the |
| +// catch-all only ever ADDS. |
| +func catchAll(ctx context.Context, api TaggingAPI, region string, seen map[string]bool) (out []infraview.Resource, partial []string) { |
| + var token *string |
| + for { |
| + o, err := api.GetResources(ctx, &resourcegroupstaggingapi.GetResourcesInput{PaginationToken: token}) |
| + if err != nil { |
| + return out, append(partial, fmt.Sprintf("resource inventory in %s: %v", region, err)) |
| + } |
| + for _, m := range o.ResourceTagMappingList { |
| + arn := aws.ToString(m.ResourceARN) |
| + service, id := parseARN(arn) |
| + if id == "" || seen[id] || seen[arn] { |
| + continue |
| + } |
| + r := res(infraview.KindOther, service, arn, id, region) |
| + for _, t := range m.Tags { |
| + if r.Tags == nil { |
| + r.Tags = map[string]string{} |
| + } |
| + r.Tags[aws.ToString(t.Key)] = aws.ToString(t.Value) |
| + } |
| + out = append(out, r) |
| + } |
| + if o.PaginationToken == nil || *o.PaginationToken == "" { |
| + return out, partial |
| + } |
| + token = o.PaginationToken |
| + } |
| +} |
| + |
| +// parseARN pulls the service and the trailing resource id out of an ARN: |
| +// arn:aws:ec2:eu-west-1:1234:instance/i-123 -> ("ec2", "i-123"). |
| +func parseARN(arn string) (service, id string) { |
| + parts := strings.SplitN(arn, ":", 6) |
| + if len(parts) < 6 { |
| + return "", "" |
| + } |
| + service = parts[2] |
| + tail := parts[5] |
| + if i := strings.LastIndexAny(tail, "/:"); i >= 0 { |
| + tail = tail[i+1:] |
| + } |
| + return service, tail |
| +} |
| +``` |
| + |
| +- [ ] **Step 4: Write `awsinv.go` (assembly)** |
| + |
| +```go |
| +package awsinv |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "fmt" |
| + "time" |
| + |
| + awsconfig "github.com/aws/aws-sdk-go-v2/config" |
| + "github.com/aws/aws-sdk-go-v2/service/ec2" |
| + "github.com/aws/aws-sdk-go-v2/service/sts" |
| + "github.com/aws/smithy-go" |
| + "github.com/carlosframework/platform/internal/infraview" |
| +) |
| + |
| +// credentialProbe is how long New waits for the credential chain to answer |
| +// before deciding this deployment has none. Five seconds, matching |
| +// cepull.go's own bounded probe: on a box with an instance role it is |
| +// instant, and on a laptop with no credential at all the chain can |
| +// otherwise hang on IMDS. |
| +const credentialProbe = 5 * time.Second |
| + |
| +// Provider is the AWS implementation of infraview.Provider. |
| +type Provider struct { |
| + factory Factory |
| + home string // the region whose clients answer the global services |
| + regions []string // every region to inventory |
| + account string |
| + now func() time.Time // test seam |
| +} |
| + |
| +// New resolves the credential chain once, discovers the account's |
| +// opted-in regions, and returns a Provider. Every failure here is |
| +// construction-class — no config, no credential, no reachable EC2 — and the |
| +// caller (the console pass) treats an auth-shaped one as "this deployment |
| +// has no inventory grant", latching the pass off rather than retrying |
| +// hourly forever. |
| +func New(ctx context.Context) (*Provider, error) { |
| + cfg, err := awsconfig.LoadDefaultConfig(ctx) |
| + if err != nil { |
| + return nil, fmt.Errorf("awsinv: aws config: %w", err) |
| + } |
| + probeCtx, cancel := context.WithTimeout(ctx, credentialProbe) |
| + defer cancel() |
| + if _, err := cfg.Credentials.Retrieve(probeCtx); err != nil { |
| + return nil, fmt.Errorf("awsinv: no usable credential: %w", err) |
| + } |
| + factory := factoryFor(cfg) |
| + home := cfg.Region |
| + if home == "" { |
| + home = "us-east-1" |
| + } |
| + |
| + regions := []string{home} |
| + if o, err := factory(home).EC2.DescribeRegions(ctx, &ec2.DescribeRegionsInput{}); err != nil { |
| + if IsAuthShaped(err) { |
| + return nil, fmt.Errorf("awsinv: describe regions: %w", err) |
| + } |
| + // Not auth-shaped: inventory the home region alone rather than |
| + // nothing. The missing regions surface in Partial at assembly. |
| + } else { |
| + regions = regions[:0] |
| + for _, r := range o.Regions { |
| + if r.RegionName != nil { |
| + regions = append(regions, *r.RegionName) |
| + } |
| + } |
| + } |
| + |
| + account := "" |
| + if id, err := sts.NewFromConfig(cfg).GetCallerIdentity(ctx, &sts.GetCallerIdentityInput{}); err == nil && id.Account != nil { |
| + account = *id.Account |
| + } |
| + return &Provider{factory: factory, home: home, regions: regions, account: account, now: time.Now}, nil |
| +} |
| + |
| +// newWithFactory is New's test twin: no credential chain, no network. |
| +func newWithFactory(f Factory, home string, regions []string, account string) *Provider { |
| + return &Provider{factory: f, home: home, regions: regions, account: account, now: time.Now} |
| +} |
| + |
| +// Inventory walks every region's typed mappings, then the catch-all, then |
| +// the global services once from the home region. |
| +// |
| +// The error contract: an error means "this deployment cannot inventory |
| +// itself at all". Anything less — a throttled call, one dead region, a |
| +// service the role cannot read — lands in Partial and the inventory is |
| +// still returned, because a page showing eleven of twelve resource classes |
| +// is worth far more than an error. |
| +func (p *Provider) Inventory(ctx context.Context) (infraview.Inventory, error) { |
| + inv := infraview.Inventory{Provider: "aws", Account: p.account, TakenAt: p.now().UTC()} |
| + seen := map[string]bool{} |
| + authDenied := false |
| + |
| + note := func(lines []string) { |
| + inv.Partial = append(inv.Partial, lines...) |
| + } |
| + add := func(rs []infraview.Resource) { |
| + for _, r := range rs { |
| + seen[r.ID] = true |
| + inv.Resources = append(inv.Resources, r) |
| + } |
| + } |
| + |
| + for _, region := range p.regions { |
| + c := p.factory(region) |
| + rs, partial := ec2Resources(ctx, c.EC2, region) |
| + add(rs) |
| + note(partial) |
| + |
| + rs, partial = alarmResources(ctx, c.CloudWatch, region) |
| + add(rs) |
| + note(partial) |
| + |
| + rs, partial = certResources(ctx, c.ACM, region) |
| + add(rs) |
| + note(partial) |
| + |
| + rs, partial = catchAll(ctx, c.Tagging, region, seen) |
| + add(rs) |
| + note(partial) |
| + } |
| + |
| + global := p.factory(p.home) |
| + rs, partial := s3Resources(ctx, global.S3) |
| + add(rs) |
| + note(partial) |
| + |
| + rs, partial = route53Resources(ctx, global.Route53) |
| + add(rs) |
| + note(partial) |
| + |
| + rs, partial = iamResources(ctx, global.IAM) |
| + add(rs) |
| + note(partial) |
| + |
| + for _, line := range inv.Partial { |
| + if looksDenied(line) { |
| + authDenied = true |
| + break |
| + } |
| + } |
| + if len(inv.Resources) == 0 && authDenied { |
| + return inv, errors.New("awsinv: every read was denied — this deployment has no inventory grant") |
| + } |
| + return inv, nil |
| +} |
| + |
| +// IsAuthShaped is cepull.go's isAuthShapedError, exported here so the |
| +// console classifies THIS package's construction errors by the same rule it |
| +// classifies Cost Explorer's. One rule, one list of codes. |
| +func IsAuthShaped(err error) bool { |
| + var apiErr smithy.APIError |
| + if !errors.As(err, &apiErr) { |
| + return false |
| + } |
| + return looksDenied(apiErr.ErrorCode()) |
| +} |
| + |
| +func looksDenied(s string) bool { |
| + for _, shape := range []string{ |
| + "AccessDenied", "UnauthorizedOperation", "UnrecognizedClient", |
| + "InvalidClientTokenId", "InvalidAccessKeyId", "AuthFailure", "ExpiredToken", |
| + } { |
| + if strings.Contains(s, shape) { |
| + return true |
| + } |
| + } |
| + return false |
| +} |
| +``` |
| + |
| +- [ ] **Step 5: Run tests to verify they pass** |
| + |
| +Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/awsinv/ -count=1 -v` |
| +Expected: PASS — all five tests, including the three from this task. |
| + |
| +- [ ] **Step 6: Add the remaining mapping tests** |
| + |
| +One 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: |
| + |
| +```go |
| +type fakeS3 struct { |
| + buckets *s3.ListBucketsOutput |
| + vers *s3.GetBucketVersioningOutput |
| +} |
| + |
| +func (f *fakeS3) ListBuckets(context.Context, *s3.ListBucketsInput, ...func(*s3.Options)) (*s3.ListBucketsOutput, error) { |
| + return f.buckets, nil |
| +} |
| +func (f *fakeS3) GetBucketLocation(context.Context, *s3.GetBucketLocationInput, ...func(*s3.Options)) (*s3.GetBucketLocationOutput, error) { |
| + return &s3.GetBucketLocationOutput{LocationConstraint: s3types.BucketLocationConstraintEuWest1}, nil |
| +} |
| +func (f *fakeS3) GetBucketVersioning(context.Context, *s3.GetBucketVersioningInput, ...func(*s3.Options)) (*s3.GetBucketVersioningOutput, error) { |
| + return f.vers, nil |
| +} |
| +func (f *fakeS3) GetBucketEncryption(context.Context, *s3.GetBucketEncryptionInput, ...func(*s3.Options)) (*s3.GetBucketEncryptionOutput, error) { |
| + return nil, errors.New("no encryption configured") |
| +} |
| +func (f *fakeS3) GetPublicAccessBlock(context.Context, *s3.GetPublicAccessBlockInput, ...func(*s3.Options)) (*s3.GetPublicAccessBlockOutput, error) { |
| + return nil, errors.New("no public access block") |
| +} |
| + |
| +// A bucket whose posture reads fail still appears — with the attribute |
| +// ABSENT, never a false "off". "Off" is a claim this read did not earn. |
| +func TestS3BucketMapping(t *testing.T) { |
| + f := &fakeS3{ |
| + buckets: &s3.ListBucketsOutput{Buckets: []s3types.Bucket{{Name: aws.String("carlos-data-ie")}}}, |
| + vers: &s3.GetBucketVersioningOutput{Status: s3types.BucketVersioningStatusEnabled}, |
| + } |
| + rs, partial := s3Resources(context.Background(), f) |
| + if len(partial) != 0 || len(rs) != 1 { |
| + t.Fatalf("rs=%+v partial=%v", rs, partial) |
| + } |
| + b := rs[0] |
| + if b.Kind != infraview.KindBucket || b.Name != "carlos-data-ie" || b.Region != "eu-west-1" { |
| + t.Errorf("bucket = %+v", b) |
| + } |
| + if b.Attr("versioning") != "enabled" { |
| + t.Errorf("versioning = %q, want enabled", b.Attr("versioning")) |
| + } |
| + if _, ok := b.Attrs["encryption"]; ok { |
| + t.Errorf("a failed encryption read must leave the attribute absent, not claim a value") |
| + } |
| +} |
| +``` |
| + |
| +Then the remaining ones, same shape: |
| +`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 `("", "")`). |
| + |
| +- [ ] **Step 7: Run the full package tests** |
| + |
| +Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/awsinv/ -count=1` |
| +Expected: PASS. |
| + |
| +- [ ] **Step 8: Gate and commit** |
| + |
| +```bash |
| +GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go build ./... && \ |
| +GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go vet ./... && \ |
| +GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./... -count=1 && \ |
| +gofmt -l internal/awsinv |
| +git add internal/awsinv go.mod go.sum |
| +git commit -m "awsinv: S3/Route53/IAM/CloudWatch/ACM mappings, tagging catch-all, assembly |
| + |
| +Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>" |
| +``` |
| + |
| +--- |
| + |
| +### Task 4: The console inventory pass |
| + |
| +**Files:** |
| +- Create: `internal/console/infrapass.go`, `internal/console/infrapass_test.go` |
| +- Modify: `internal/console/server.go` (Server fields), `cmd/console/main.go:273` (start the pass) |
| + |
| +**Interfaces:** |
| +- Consumes: `infraview.Inventory`, `infraview.InventoryKey`, `awsinv.New`, `awsinv.IsAuthShaped`, `release.Store`. |
| +- 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)`. |
| + |
| +- [ ] **Step 1: Write the failing test** |
| + |
| +```go |
| +package console |
| + |
| +import ( |
| + "context" |
| + "encoding/json" |
| + "errors" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/infraview" |
| +) |
| + |
| +// The pass writes the snapshot where the page reads it, and loadInventory |
| +// reads back exactly what was written. |
| +func TestInfraPassWritesSnapshot(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + fx.srv.inventory = func(context.Context) (infraview.Inventory, error) { |
| + return infraview.Inventory{ |
| + Provider: "aws", Account: "1234", TakenAt: time.Now().UTC(), |
| + Resources: []infraview.Resource{{Provider: "aws", Kind: infraview.KindMachine, ID: "i-1"}}, |
| + }, nil |
| + } |
| + if err := fx.srv.infraPass(context.Background()); err != nil { |
| + t.Fatalf("infraPass: %v", err) |
| + } |
| + body, _, err := fx.rel.Get(context.Background(), infraview.InventoryKey) |
| + if err != nil { |
| + t.Fatalf("read back: %v", err) |
| + } |
| + var inv infraview.Inventory |
| + if err := json.Unmarshal(body, &inv); err != nil { |
| + t.Fatalf("parse: %v", err) |
| + } |
| + if len(inv.Resources) != 1 || inv.Resources[0].ID != "i-1" { |
| + t.Fatalf("stored = %+v", inv) |
| + } |
| + got, why := fx.srv.loadInventory(context.Background()) |
| + if got == nil || why != "" { |
| + t.Fatalf("loadInventory = %+v, %q; want the snapshot", got, why) |
| + } |
| +} |
| + |
| +// An auth-shaped failure latches the pass off for the process after ONE |
| +// attempt: "this deployment has no inventory grant" is a permanent answer, |
| +// not something to re-ask AWS every hour forever. |
| +func TestInfraPassLatchesOnAuthShapedFailure(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + calls := 0 |
| + fx.srv.inventory = func(context.Context) (infraview.Inventory, error) { |
| + calls++ |
| + return infraview.Inventory{}, &fakeAPIError{code: "AccessDenied"} |
| + } |
| + if err := fx.srv.infraPass(context.Background()); err == nil { |
| + t.Fatal("want an error from the denied pass") |
| + } |
| + if err := fx.srv.infraPass(context.Background()); err == nil { |
| + t.Fatal("want an error from the latched pass") |
| + } |
| + if calls != 1 { |
| + t.Fatalf("provider called %d times, want 1 — the latch must stop the second attempt", calls) |
| + } |
| + if _, why := fx.srv.loadInventory(context.Background()); why != "ungranted" { |
| + t.Fatalf("loadInventory reason = %q, want %q", why, "ungranted") |
| + } |
| +} |
| + |
| +// A transient failure is NOT the latch: CE throttles under load and a blip |
| +// must not silently kill inventory until the next redeploy. |
| +func TestInfraPassRetriesTransientFailure(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + calls := 0 |
| + fx.srv.inventory = func(context.Context) (infraview.Inventory, error) { |
| + calls++ |
| + return infraview.Inventory{}, errors.New("Throttling: rate exceeded") |
| + } |
| + _ = fx.srv.infraPass(context.Background()) |
| + _ = fx.srv.infraPass(context.Background()) |
| + if calls != 2 { |
| + t.Fatalf("provider called %d times, want 2 — a throttle must not latch", calls) |
| + } |
| +} |
| + |
| +// With no snapshot ever written, the page must be told "never", never |
| +// handed an empty inventory that would render as "this account has no |
| +// resources". |
| +func TestLoadInventoryNeverTaken(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + inv, why := fx.srv.loadInventory(context.Background()) |
| + if inv != nil || why != "never" { |
| + t.Fatalf("loadInventory = %+v, %q; want nil, \"never\"", inv, why) |
| + } |
| +} |
| +``` |
| + |
| +Add `fakeAPIError` to `infrapass_test.go` (the same four-method `smithy.APIError` shape as Task 3's `fakeAPIErr`). |
| + |
| +- [ ] **Step 2: Run test to verify it fails** |
| + |
| +Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/console/ -run TestInfraPass -count=1` |
| +Expected: FAIL — `fx.srv.inventory undefined`. |
| + |
| +- [ ] **Step 3: Add the Server fields** |
| + |
| +In `internal/console/server.go`, beside the existing `ceCosts`/`ceDisabled`/`ceWarnOnce` seam fields on the `Server` struct: |
| + |
| +```go |
| + // The infrastructure inventory seam (infrapass.go), built exactly like |
| + // the Cost Explorer seam above it: tests set inventory to a fake, the |
| + // real path lazily constructs an awsinv.Provider once. infraDisabled |
| + // latches an auth-shaped failure off for the process — a deployment |
| + // with no inventory IAM grant answers "ungranted" once, not hourly. |
| + inventory func(ctx context.Context) (infraview.Inventory, error) |
| + infraOnce sync.Once |
| + infraProvider infraview.Provider |
| + infraDisabled bool |
| + infraRefreshing atomic.Bool |
| +``` |
| + |
| +- [ ] **Step 4: Write `infrapass.go`** |
| + |
| +```go |
| +// The infrastructure inventory pass: once an hour, ask the deployment's |
| +// cloud provider what it is carrying and write the answer to |
| +// infraview.InventoryKey in the deployment bucket, where |
| +// /fleet/architecture reads it. |
| +// |
| +// The failure posture is cepull.go's, verbatim and for the same reasons. |
| +// Construction-class failures — no AWS config, no usable credential, an |
| +// AccessDenied-shaped answer to the first calls — latch the pass off for |
| +// the process after a single log line. There is no "the IAM grant might |
| +// appear later" retry worth making, and a deployment without the grant must |
| +// show "not granted" rather than filling its log with an hourly error. |
| +// Everything else — throttling, a 5xx, a network blip — logs and the next |
| +// tick tries again. |
| +package console |
| + |
| +import ( |
| + "context" |
| + "encoding/json" |
| + "errors" |
| + "fmt" |
| + "log" |
| + "os" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/awsinv" |
| + "github.com/carlosframework/platform/internal/infraview" |
| +) |
| + |
| +// infraPassInterval matches economicsPassInterval: an estate changes on the |
| +// timescale of a deploy, not a request, and the refresh button covers the |
| +// moment an operator actually needs it now. |
| +const infraPassInterval = time.Hour |
| + |
| +// infraPassTimeout bounds one whole inventory — every region, every |
| +// service. A pass that hangs must not hold the goroutine until the process |
| +// restarts. |
| +const infraPassTimeout = 2 * time.Minute |
| + |
| +// StartInfraPass runs infraPass immediately and then hourly until ctx is |
| +// done — StartEconomicsPass's shape (economicspass.go:38). |
| +func (s *Server) StartInfraPass(ctx context.Context) { |
| + if s.cfg.Releases == nil { |
| + return // no bucket, nowhere to write, nothing to do |
| + } |
| + t := time.NewTicker(infraPassInterval) |
| + defer t.Stop() |
| + for { |
| + if err := s.infraPass(ctx); err != nil { |
| + log.Printf("console: infra pass: %v", err) |
| + } |
| + select { |
| + case <-ctx.Done(): |
| + return |
| + case <-t.C: |
| + } |
| + } |
| +} |
| + |
| +// resolveInventory is the seam: the test fake when one is set, otherwise a |
| +// lazily constructed awsinv.Provider reused for the life of the process. |
| +func (s *Server) resolveInventory(ctx context.Context) (infraview.Inventory, error) { |
| + if s.inventory != nil { |
| + return s.inventory(ctx) |
| + } |
| + if s.infraDisabled { |
| + return infraview.Inventory{}, errors.New("console: infra inventory disabled for this process") |
| + } |
| + s.infraOnce.Do(func() { |
| + p, err := awsinv.New(ctx) |
| + if err != nil { |
| + // Construction failed. Auth-shaped means no grant: latch. |
| + // Anything else means this process cannot build a provider at |
| + // all, which is equally permanent for this process. |
| + log.Printf("console: infra inventory unavailable: %v", err) |
| + s.infraDisabled = true |
| + return |
| + } |
| + s.infraProvider = p |
| + }) |
| + if s.infraProvider == nil { |
| + s.infraDisabled = true |
| + return infraview.Inventory{}, errors.New("console: no infrastructure provider") |
| + } |
| + return s.infraProvider.Inventory(ctx) |
| +} |
| + |
| +// infraPass takes one inventory and stores it. An auth-shaped failure |
| +// latches; everything else is returned for the caller to log and retry. |
| +func (s *Server) infraPass(ctx context.Context) error { |
| + if s.infraDisabled { |
| + return errors.New("console: infra inventory disabled for this process") |
| + } |
| + ctx, cancel := context.WithTimeout(ctx, infraPassTimeout) |
| + defer cancel() |
| + |
| + inv, err := s.resolveInventory(ctx) |
| + if err != nil { |
| + if awsinv.IsAuthShaped(err) || isAuthShapedError(err) { |
| + s.infraDisabled = true |
| + return fmt.Errorf("console: infra inventory not granted (disabled for this process): %w", err) |
| + } |
| + return fmt.Errorf("console: infra inventory: %w", err) |
| + } |
| + return s.putInventory(ctx, inv) |
| +} |
| + |
| +// putInventory CAS-writes the snapshot — upsertBill's read-etag-then-put |
| +// (economicspass.go:942), with os.ErrNotExist meaning "first ever write". |
| +func (s *Server) putInventory(ctx context.Context, inv infraview.Inventory) error { |
| + if s.cfg.Releases == nil { |
| + return nil |
| + } |
| + _, etag, err := s.cfg.Releases.Get(ctx, infraview.InventoryKey) |
| + switch { |
| + case err == nil: |
| + case errors.Is(err, os.ErrNotExist): |
| + etag = "" |
| + default: |
| + return fmt.Errorf("console: inventory: read: %w", err) |
| + } |
| + body, err := json.Marshal(inv) |
| + if err != nil { |
| + return fmt.Errorf("console: inventory: marshal: %w", err) |
| + } |
| + if _, err := s.cfg.Releases.Put(ctx, infraview.InventoryKey, body, etag); err != nil { |
| + return fmt.Errorf("console: inventory: write: %w", err) |
| + } |
| + return nil |
| +} |
| + |
| +// loadInventory reads the stored snapshot for the page. The second return |
| +// is why there is nothing to show, and it distinguishes the three states |
| +// that must never be rendered as one: "never" (no pass has ever succeeded), |
| +// "ungranted" (this process latched off), and a read error. "no resources" |
| +// is a claim; none of these three earn it. |
| +func (s *Server) loadInventory(ctx context.Context) (*infraview.Inventory, string) { |
| + if s.cfg.Releases == nil { |
| + return nil, "never" |
| + } |
| + body, _, err := s.cfg.Releases.Get(ctx, infraview.InventoryKey) |
| + switch { |
| + case errors.Is(err, os.ErrNotExist): |
| + if s.infraDisabled { |
| + return nil, "ungranted" |
| + } |
| + return nil, "never" |
| + case err != nil: |
| + log.Printf("console: inventory read: %v", err) |
| + return nil, "unreadable" |
| + } |
| + var inv infraview.Inventory |
| + if err := json.Unmarshal(body, &inv); err != nil { |
| + log.Printf("console: inventory parse: %v", err) |
| + return nil, "unreadable" |
| + } |
| + return &inv, "" |
| +} |
| +``` |
| + |
| +- [ ] **Step 5: Run tests to verify they pass** |
| + |
| +Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/console/ -run TestInfraPass -count=1 -v` and `-run TestLoadInventory` |
| +Expected: PASS — four tests. |
| + |
| +- [ ] **Step 6: Start the pass** |
| + |
| +In `cmd/console/main.go`, beside line 273: |
| + |
| +```go |
| + go srv.StartEconomicsPass(context.Background()) |
| + go srv.StartInfraPass(context.Background()) |
| +``` |
| + |
| +- [ ] **Step 7: Gate and commit** |
| + |
| +```bash |
| +GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go build ./... && \ |
| +GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go vet ./... && \ |
| +GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./... -count=1 && \ |
| +gofmt -l internal/console cmd/console |
| +git add internal/console/infrapass.go internal/console/infrapass_test.go internal/console/server.go cmd/console/main.go |
| +git commit -m "console: hourly infrastructure inventory pass |
| + |
| +Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>" |
| +``` |
| + |
| +--- |
| + |
| +### Task 5: The graph joins, box cards and cost/drift |
| + |
| +**Files:** |
| +- Create: `internal/console/archgraph.go`, `internal/console/archgraph_test.go` |
| + |
| +**Interfaces:** |
| +- Consumes: `infraview.Inventory`, `status.Fleet`, `HostRow` (`view.go:679`), `registry.Route`, `economics.RateCard`, `economics.HoursPerMonth`. |
| +- Produces: |
| + |
| +```go |
| +type ArchInput struct { |
| + Inv *infraview.Inventory |
| + Fleet *status.Fleet |
| + Hosts []HostRow |
| + Routes []registry.Route |
| + Rates economics.RateCard |
| +} |
| +type KV struct{ K, V string } |
| +type Node struct { |
| + ID, Kind, Title, Subtitle string |
| + Detail []KV |
| + X, Y, W, H int |
| +} |
| +type Edge struct{ From, To, Kind string } |
| +type BoxCard struct { |
| + Label, Region, InstanceType, State string |
| + Cores, MemGB, EBSGB float64 |
| + DiscoveredDiskGB float64 |
| + USDMonth float64 |
| + Apps []string |
| + Static, Resident, Hibernating int |
| + Awake, Asleep *int |
| + Release, Load string |
| + Stale bool |
| + Dark string |
| + Drift []string |
| + CloudID string // "" = no cloud machine behind this box |
| +} |
| +type ResourceGroup struct { |
| + Title string |
| + Rows []ResourceRow |
| +} |
| +type ResourceRow struct{ Name, NativeType, Region, Detail string } |
| +type ArchView struct { |
| + Nodes []Node |
| + Edges []Edge |
| + Width, Height int |
| + Boxes []BoxCard |
| + Groups []ResourceGroup |
| + TakenAt time.Time |
| + Account string |
| + Partial []string |
| + Missing string // "" | "never" | "ungranted" | "unreadable" |
| +} |
| +func BuildArch(in ArchInput) ArchView |
| +``` |
| + |
| +- [ ] **Step 1: Write the failing join test** |
| + |
| +```go |
| +package console |
| + |
| +import ( |
| + "testing" |
| + |
| + "github.com/carlosframework/platform/internal/economics" |
| + "github.com/carlosframework/platform/internal/infraview" |
| + "github.com/carlosframework/platform/internal/registry" |
| + "github.com/carlosframework/platform/internal/status" |
| +) |
| + |
| +func archFixture() ArchInput { |
| + inv := &infraview.Inventory{Provider: "aws", Account: "1234", Resources: []infraview.Resource{ |
| + {Provider: "aws", Kind: infraview.KindDNSZone, ID: "Z1", Name: "carloku.com", |
| + Attrs: map[string]string{"record_count": "12"}}, |
| + {Provider: "aws", Kind: infraview.KindDNSRecord, ID: "r1", Name: "console.carloku.com", |
| + Attrs: map[string]string{"zone": "carloku.com", "type": "A", "value": "52.0.0.1"}}, |
| + {Provider: "aws", Kind: infraview.KindIP, ID: "eipalloc-1", |
| + Attrs: map[string]string{"address": "52.0.0.1", "attached_to": "i-123"}}, |
| + {Provider: "aws", Kind: infraview.KindMachine, ID: "i-123", Name: "carlos-eu-west-1", Region: "eu-west-1", |
| + Attrs: map[string]string{"type": "t4g.medium", "state": "running", "public_ip": "52.0.0.1"}}, |
| + {Provider: "aws", Kind: infraview.KindDisk, ID: "vol-1", |
| + Attrs: map[string]string{"size_gb": "40", "attached_to": "i-123"}}, |
| + {Provider: "aws", Kind: infraview.KindMachine, ID: "i-999", Name: "mystery-box", Region: "eu-west-1", |
| + Attrs: map[string]string{"type": "t4g.nano", "state": "running"}}, |
| + {Provider: "aws", Kind: infraview.KindBucket, ID: "carlos-data-ie", Name: "carlos-data-ie"}, |
| + {Provider: "aws", Kind: infraview.KindOther, NativeType: "sqs", ID: "arn:aws:sqs:eu-west-1:1234:q", Name: "q"}, |
| + }} |
| + return ArchInput{ |
| + Inv: inv, |
| + Fleet: &status.Fleet{Boxes: []status.FleetBox{ |
| + {Label: "carlos-eu-west-1", Region: "eu-west-1"}, |
| + {Label: "pi-dublin", Region: "home"}, |
| + }}, |
| + Hosts: []HostRow{ |
| + {Label: "carlos-eu-west-1", Region: "eu-west-1", Metrics: &status.BoxMetrics{ |
| + Release: "abc1234", |
| + RouteVersions: map[string]string{"console.carloku.com": "abc1234"}, |
| + }}, |
| + {Label: "pi-dublin", Region: "home", Metrics: &status.BoxMetrics{}}, |
| + }, |
| + Routes: []registry.Route{ |
| + {Host: "console.carloku.com", App: "console", Kind: "instance", Status: "ready", Version: "abc1234"}, |
| + }, |
| + Rates: economics.RateCard{Boxes: map[string]economics.BoxSpec{ |
| + "": {HourUSD: 0.02, Cores: 2, MemGB: 4, EBSGB: 30}, |
| + "carlos-eu-west-1": {HourUSD: 0.0336, Cores: 2, MemGB: 4, EBSGB: 30}, |
| + }, EBSGBMonthUSD: 0.088}, |
| + } |
| +} |
| + |
| +// The join that makes the picture true: a DNS record's value is an EIP's |
| +// address, that EIP is attached to a machine, and that machine's Name tag |
| +// is a reporting box's label. |
| +func TestArchJoinsDNSThroughIPToBox(t *testing.T) { |
| + v := BuildArch(archFixture()) |
| + |
| + var box *BoxCard |
| + for i := range v.Boxes { |
| + if v.Boxes[i].Label == "carlos-eu-west-1" { |
| + box = &v.Boxes[i] |
| + } |
| + } |
| + if box == nil { |
| + t.Fatal("no card for carlos-eu-west-1") |
| + } |
| + if box.CloudID != "i-123" { |
| + t.Errorf("CloudID = %q, want i-123 (Name tag matched to box label)", box.CloudID) |
| + } |
| + if box.InstanceType != "t4g.medium" { |
| + t.Errorf("InstanceType = %q", box.InstanceType) |
| + } |
| + if box.DiscoveredDiskGB != 40 { |
| + t.Errorf("DiscoveredDiskGB = %v, want 40", box.DiscoveredDiskGB) |
| + } |
| + if len(box.Apps) != 1 || box.Apps[0] != "console" { |
| + t.Errorf("Apps = %v, want [console]", box.Apps) |
| + } |
| + if !hasEdge(v, "record:r1", "ip:eipalloc-1") || !hasEdge(v, "ip:eipalloc-1", "box:carlos-eu-west-1") { |
| + t.Errorf("missing the DNS -> IP -> box chain in %+v", v.Edges) |
| + } |
| +} |
| + |
| +func hasEdge(v ArchView, from, to string) bool { |
| + for _, e := range v.Edges { |
| + if e.From == from && e.To == to { |
| + return true |
| + } |
| + } |
| + return false |
| +} |
| + |
| +// Unjoined in either direction is DRAWN, never hidden — the rule most of |
| +// this page's value rests on. |
| +func TestArchShowsUnjoinedBothWays(t *testing.T) { |
| + v := BuildArch(archFixture()) |
| + |
| + var found bool |
| + for _, n := range v.Nodes { |
| + if n.ID == "machine:i-999" { |
| + found = true |
| + if n.Subtitle == "" { |
| + t.Error("an unclaimed machine must say so") |
| + } |
| + } |
| + } |
| + if !found { |
| + t.Error("a machine no box reports must still be drawn (it is drift)") |
| + } |
| + |
| + var pi *BoxCard |
| + for i := range v.Boxes { |
| + if v.Boxes[i].Label == "pi-dublin" { |
| + pi = &v.Boxes[i] |
| + } |
| + } |
| + if pi == nil { |
| + t.Fatal("a box with no cloud machine must still get a card — it is a BYO box, not an error") |
| + } |
| + if pi.CloudID != "" { |
| + t.Errorf("pi CloudID = %q, want empty", pi.CloudID) |
| + } |
| +} |
| + |
| +// Cost is the rate card's, so this page and /fleet/economics can never |
| +// disagree; drift is flagged, not silently priced away. |
| +func TestArchCostAndDrift(t *testing.T) { |
| + in := archFixture() |
| + v := BuildArch(in) |
| + var box BoxCard |
| + for _, b := range v.Boxes { |
| + if b.Label == "carlos-eu-west-1" { |
| + box = b |
| + } |
| + } |
| + want := 0.0336*economics.HoursPerMonth + 30*0.088 |
| + if diff := box.USDMonth - want; diff > 0.001 || diff < -0.001 { |
| + t.Errorf("USDMonth = %v, want %v (rate card, not a second cost model)", box.USDMonth, want) |
| + } |
| + // The card says 30 GB of EBS; the estate has 40. That is drift. |
| + if len(box.Drift) == 0 { |
| + t.Error("a 30 GB rate-card entry against a 40 GB discovered disk must raise drift") |
| + } |
| + |
| + // A reporting box with no rate-card entry of its own is also drift: |
| + // it is being priced by the default spec, which is a guess. |
| + var pi BoxCard |
| + for _, b := range v.Boxes { |
| + if b.Label == "pi-dublin" { |
| + pi = b |
| + } |
| + } |
| + if len(pi.Drift) == 0 { |
| + t.Error("a box priced by the default spec must say so") |
| + } |
| +} |
| + |
| +// Resources the graph has no opinion about must still reach the page. |
| +func TestArchGroupsCarryOtherResources(t *testing.T) { |
| + v := BuildArch(archFixture()) |
| + for _, g := range v.Groups { |
| + for _, r := range g.Rows { |
| + if r.NativeType == "sqs" { |
| + return |
| + } |
| + } |
| + } |
| + t.Fatal("an unmapped resource must appear in the resource groups") |
| +} |
| + |
| +// No inventory at all: the CARLOS half still renders and Missing says which |
| +// of the three states applies. |
| +func TestArchWithoutInventory(t *testing.T) { |
| + in := archFixture() |
| + in.Inv = nil |
| + v := BuildArch(in) |
| + if v.Missing == "" { |
| + t.Error("a nil inventory must set Missing") |
| + } |
| + if len(v.Boxes) != 2 { |
| + t.Errorf("boxes = %d, want the CARLOS half to render regardless", len(v.Boxes)) |
| + } |
| +} |
| +``` |
| + |
| +- [ ] **Step 2: Run test to verify it fails** |
| + |
| +Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/console/ -run TestArch -count=1` |
| +Expected: FAIL — `undefined: BuildArch`. |
| + |
| +- [ ] **Step 3: Write `archgraph.go`** |
| + |
| +Implement `BuildArch` in this order, each step a small named helper so the file stays readable: |
| + |
| +1. `machineFor(inv, label)` — case-insensitive match of `machine.Name` against the box label; returns `*infraview.Resource`. |
| +2. `diskGBFor(inv, machineID)` — sum of `disk.size_gb` where `attached_to` == machine id. |
| +3. `appsFor(host HostRow, routes []registry.Route)` — the box's `Metrics.RouteVersions` keys looked up in `routes` by `Host`; distinct app names, sorted. |
| +4. `boxCost` and `driftFor`, in full — these two carry the page's money claims, so they are given rather than described: |
| + |
| +```go |
| +// boxCost prices one box the way /fleet/economics prices it and no other |
| +// way: the rate card's own spec for that label, falling back to the "" |
| +// default exactly as economics.AllocateBox does. explicit is false when the |
| +// fallback was used, which is itself worth telling the operator — a box |
| +// priced by the default spec is priced by a guess. |
| +func boxCost(rc economics.RateCard, label string) (usd float64, spec economics.BoxSpec, explicit bool) { |
| + spec, explicit = rc.Boxes[label] |
| + if !explicit { |
| + spec = rc.Boxes[""] |
| + } |
| + usd = spec.HourUSD*economics.HoursPerMonth + spec.EBSGB*rc.EBSGBMonthUSD |
| + return usd, spec, explicit |
| +} |
| + |
| +// driftFor names every way the rate card and the estate disagree about this |
| +// box. Two checks only — the two this page can make honestly. Comparing the |
| +// instance TYPE against the card's cores/memory would need a type->shape |
| +// table, which is a second source of truth and out of scope (spec §10). |
| +func driftFor(card BoxCard, spec economics.BoxSpec, explicit bool) []string { |
| + var out []string |
| + if !explicit { |
| + out = append(out, "priced by the default spec — no rate-card entry for this box") |
| + } |
| + if card.DiscoveredDiskGB > 0 && spec.EBSGB > 0 { |
| + if d := card.DiscoveredDiskGB - spec.EBSGB; d >= 1 || d <= -1 { |
| + out = append(out, fmt.Sprintf("rate card says %.0f GB of EBS, the estate has %.0f GB", |
| + spec.EBSGB, card.DiscoveredDiskGB)) |
| + } |
| + } |
| + return out |
| +} |
| +``` |
| +6. 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>`): |
| + - `zone → record` for each record whose `zone` attr matches. |
| + - `record → ip` via `inv.Find(KindIP, "address", record.Attr("value"))`. |
| + - `ip → box` when the IP's `attached_to` machine is a box's `CloudID`; `ip → machine:<id>` when it is not. |
| + - `record → external:<value>` when the value matches no IP and no machine — an arrow leaving the diagram, per spec §6.3. |
| + - `box → apps:<label>` carrying the instance counts as the node subtitle. |
| + - `box → bucket:<deployment bucket>` for every reporting box (the one bucket relationship the console can state as fact). |
| + - `alarm → box` when the alarm's `target` attr is that box's `CloudID`. |
| +7. `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. |
| +8. `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). |
| + |
| +The 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). |
| + |
| +- [ ] **Step 4: Run tests to verify they pass** |
| + |
| +Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/console/ -run TestArch -count=1 -v` |
| +Expected: PASS — five tests. |
| + |
| +- [ ] **Step 5: Gate and commit** |
| + |
| +```bash |
| +GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go build ./... && \ |
| +GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go vet ./... && \ |
| +GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./... -count=1 && \ |
| +gofmt -l internal/console |
| +git add internal/console/archgraph.go internal/console/archgraph_test.go |
| +git commit -m "console: architecture graph joins, box cards, rate-card cost and drift |
| + |
| +Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>" |
| +``` |
| + |
| +--- |
| + |
| +### Task 6: Deterministic layout and SVG |
| + |
| +**Files:** |
| +- Create: `internal/console/archlayout.go`, `internal/console/archlayout_test.go` |
| +- Modify: `internal/console/archgraph.go` (call `layout` at the end of `BuildArch`) |
| + |
| +**Interfaces:** |
| +- Consumes: `ArchView`, `Node`, `Edge` (Task 5). |
| +- Produces: `layout(v *ArchView)` (sets every `Node.X/Y/W/H` and `ArchView.Width/Height`), `RenderArchSVG(v ArchView) template.HTML`. |
| + |
| +- [ ] **Step 1: Write the failing test** |
| + |
| +```go |
| +package console |
| + |
| +import ( |
| + "strings" |
| + "testing" |
| +) |
| + |
| +// The same fleet must draw the same picture every time. A diagram that |
| +// reshuffles between refreshes cannot be read for change, which is most of |
| +// what an operator opens it for. |
| +func TestLayoutIsDeterministic(t *testing.T) { |
| + a := RenderArchSVG(BuildArch(archFixture())) |
| + b := RenderArchSVG(BuildArch(archFixture())) |
| + if a != b { |
| + t.Fatal("two builds of the same input produced different SVG") |
| + } |
| +} |
| + |
| +// Layer order is the story the page tells: DNS enters at the left, routers |
| +// and boxes in the middle, apps at the right. |
| +func TestLayoutOrdersLayers(t *testing.T) { |
| + v := BuildArch(archFixture()) |
| + x := map[string]int{} |
| + for _, n := range v.Nodes { |
| + x[n.ID] = n.X |
| + } |
| + if !(x["zone:Z1"] < x["record:r1"] && x["record:r1"] < x["ip:eipalloc-1"] && |
| + x["ip:eipalloc-1"] < x["box:carlos-eu-west-1"] && x["box:carlos-eu-west-1"] < x["apps:carlos-eu-west-1"]) { |
| + t.Fatalf("layers out of order: %+v", x) |
| + } |
| +} |
| + |
| +// No node may overlap another: the layout is the only thing standing |
| +// between a growing fleet and an unreadable page. |
| +func TestLayoutDoesNotOverlap(t *testing.T) { |
| + v := BuildArch(archFixture()) |
| + for i := range v.Nodes { |
| + for j := i + 1; j < len(v.Nodes); j++ { |
| + a, b := v.Nodes[i], v.Nodes[j] |
| + if a.X < b.X+b.W && b.X < a.X+a.W && a.Y < b.Y+b.H && b.Y < a.Y+a.H { |
| + t.Fatalf("%s overlaps %s", a.ID, b.ID) |
| + } |
| + } |
| + } |
| +} |
| + |
| +// The SVG must scale without script and must not carry raw user strings |
| +// unescaped. |
| +func TestSVGIsSelfContainedAndEscaped(t *testing.T) { |
| + in := archFixture() |
| + in.Fleet.Boxes[0].Label = `evil"><script>alert(1)</script>` |
| + in.Hosts[0].Label = in.Fleet.Boxes[0].Label |
| + out := string(RenderArchSVG(BuildArch(in))) |
| + if !strings.Contains(out, "viewBox=") { |
| + t.Error("SVG must carry a viewBox so it scales with no JS") |
| + } |
| + if strings.Contains(out, "<script") { |
| + t.Error("a box label must never reach the SVG unescaped") |
| + } |
| +} |
| +``` |
| + |
| +- [ ] **Step 2: Run test to verify it fails** |
| + |
| +Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/console/ -run TestLayout -count=1` |
| +Expected: FAIL — `undefined: RenderArchSVG`. |
| + |
| +- [ ] **Step 3: Write `archlayout.go`** |
| + |
| +```go |
| +// Layout and SVG emission for /fleet/architecture. Deterministic by |
| +// construction: layer by kind, order within a layer by one barycentre sweep |
| +// each way, then a stable sort by node id. The same fleet must produce a |
| +// byte-identical picture on every refresh — a diagram that reshuffles |
| +// cannot be read for change. |
| +// |
| +// No JavaScript and no layout library: the console vendors its icons and |
| +// loads nothing from a CDN, and a server-computed layout is the only way to |
| +// keep that promise while still drawing a graph. |
| +package console |
| + |
| +import ( |
| + "fmt" |
| + "html/template" |
| + "sort" |
| + "strings" |
| +) |
| + |
| +// Layer assignment by node kind — the story the page tells, left to right. |
| +var archLayer = map[string]int{ |
| + "zone": 0, "record": 1, "ip": 2, "router": 3, |
| + "box": 4, "machine": 4, "apps": 5, "bucket": 6, |
| +} |
| + |
| +const ( |
| + nodeW = 190 |
| + nodeH = 64 |
| + gapX = 90 |
| + gapY = 26 |
| + padding = 24 |
| + satelliteKinds = "alarm identity firewall network disk certificate other" |
| +) |
| + |
| +func layout(v *ArchView) { |
| + // Satellites (alarms, identities, firewalls…) are listed in the cards |
| + // below the diagram rather than drawn: a graph that draws every IAM role |
| + // is a hairball, and the resource groups already carry them in full. |
| + kept := v.Nodes[:0] |
| + for _, n := range v.Nodes { |
| + if strings.Contains(satelliteKinds, n.Kind) { |
| + continue |
| + } |
| + kept = append(kept, n) |
| + } |
| + v.Nodes = kept |
| + |
| + byLayer := map[int][]int{} |
| + for i := range v.Nodes { |
| + l := archLayer[v.Nodes[i].Kind] |
| + byLayer[l] = append(byLayer[l], i) |
| + } |
| + // Stable order first, so the barycentre sweeps start from a fixed |
| + // arrangement and cannot depend on map iteration order anywhere. |
| + for l := range byLayer { |
| + idx := byLayer[l] |
| + sort.Slice(idx, func(a, b int) bool { return v.Nodes[idx[a]].ID < v.Nodes[idx[b]].ID }) |
| + } |
| + // One sweep forward and one back, ordering each layer by the mean row of |
| + // its neighbours in the adjacent layer — the standard crossing |
| + // reduction, and enough for a graph this shape. Ties break on node id so |
| + // the result cannot depend on map or slice iteration order anywhere. |
| + row := map[string]int{} |
| + for _, idx := range byLayer { |
| + for r, i := range idx { |
| + row[v.Nodes[i].ID] = r |
| + } |
| + } |
| + neighbours := func(id string, forward bool) []string { |
| + var out []string |
| + for _, e := range v.Edges { |
| + if forward && e.To == id { |
| + out = append(out, e.From) |
| + } |
| + if !forward && e.From == id { |
| + out = append(out, e.To) |
| + } |
| + } |
| + return out |
| + } |
| + sweep := func(forward bool) { |
| + layers := make([]int, 0, len(byLayer)) |
| + for l := range byLayer { |
| + layers = append(layers, l) |
| + } |
| + sort.Ints(layers) |
| + if !forward { |
| + for i, j := 0, len(layers)-1; i < j; i, j = i+1, j-1 { |
| + layers[i], layers[j] = layers[j], layers[i] |
| + } |
| + } |
| + for _, l := range layers { |
| + idx := byLayer[l] |
| + bary := map[string]float64{} |
| + for _, i := range idx { |
| + id := v.Nodes[i].ID |
| + ns := neighbours(id, forward) |
| + if len(ns) == 0 { |
| + bary[id] = float64(row[id]) |
| + continue |
| + } |
| + sum := 0 |
| + for _, n := range ns { |
| + sum += row[n] |
| + } |
| + bary[id] = float64(sum) / float64(len(ns)) |
| + } |
| + sort.SliceStable(idx, func(a, b int) bool { |
| + ia, ib := v.Nodes[idx[a]].ID, v.Nodes[idx[b]].ID |
| + if bary[ia] != bary[ib] { |
| + return bary[ia] < bary[ib] |
| + } |
| + return ia < ib |
| + }) |
| + for r, i := range idx { |
| + row[v.Nodes[i].ID] = r |
| + } |
| + } |
| + } |
| + sweep(true) |
| + sweep(false) |
| + |
| + maxRows := 0 |
| + for l, idx := range byLayer { |
| + for row, i := range idx { |
| + v.Nodes[i].W, v.Nodes[i].H = nodeW, nodeH |
| + v.Nodes[i].X = padding + l*(nodeW+gapX) |
| + v.Nodes[i].Y = padding + row*(nodeH+gapY) |
| + } |
| + if len(idx) > maxRows { |
| + maxRows = len(idx) |
| + } |
| + } |
| + layers := 0 |
| + for l := range byLayer { |
| + if l+1 > layers { |
| + layers = l + 1 |
| + } |
| + } |
| + v.Width = padding*2 + layers*nodeW + (layers-1)*gapX |
| + v.Height = padding*2 + maxRows*nodeH + (maxRows-1)*gapY |
| +} |
| + |
| +// RenderArchSVG emits the diagram. Every string that came from a hostname, |
| +// a tag or a label goes through template.HTMLEscapeString — these are |
| +// operator-supplied values from a registry and a cloud account, and the one |
| +// thing this page must never do is execute one of them. |
| +func RenderArchSVG(v ArchView) template.HTML { |
| + var b strings.Builder |
| + fmt.Fprintf(&b, `<svg class="archsvg" viewBox="0 0 %d %d" role="img" aria-label="Deployment architecture">`, v.Width, v.Height) |
| + pos := map[string]Node{} |
| + for _, n := range v.Nodes { |
| + pos[n.ID] = n |
| + } |
| + // Edges first so nodes paint over them. |
| + for _, e := range v.Edges { |
| + from, okF := pos[e.From] |
| + to, okT := pos[e.To] |
| + if !okF || !okT { |
| + continue // a satellite edge whose node the layout dropped |
| + } |
| + x1, y1 := from.X+from.W, from.Y+from.H/2 |
| + x2, y2 := to.X, to.Y+to.H/2 |
| + mid := (x1 + x2) / 2 |
| + fmt.Fprintf(&b, `<path class="archedge" d="M%d %d H%d V%d H%d" />`, x1, y1, mid, y2, x2) |
| + } |
| + for _, n := range v.Nodes { |
| + fmt.Fprintf(&b, `<g class="archnode archnode-%s">`, template.HTMLEscapeString(n.Kind)) |
| + fmt.Fprintf(&b, `<rect x="%d" y="%d" width="%d" height="%d" rx="8" />`, n.X, n.Y, n.W, n.H) |
| + fmt.Fprintf(&b, `<text x="%d" y="%d" class="archtitle">%s</text>`, |
| + n.X+12, n.Y+24, template.HTMLEscapeString(n.Title)) |
| + if n.Subtitle != "" { |
| + fmt.Fprintf(&b, `<text x="%d" y="%d" class="archsub">%s</text>`, |
| + n.X+12, n.Y+44, template.HTMLEscapeString(n.Subtitle)) |
| + } |
| + b.WriteString(`</g>`) |
| + } |
| + b.WriteString(`</svg>`) |
| + return template.HTML(b.String()) |
| +} |
| +``` |
| + |
| +Call `layout(&v)` as the last statement of `BuildArch` before it returns. |
| + |
| +- [ ] **Step 4: Run tests to verify they pass** |
| + |
| +Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/console/ -run "TestLayout|TestSVG|TestArch" -count=1 -v` |
| +Expected: PASS. |
| + |
| +- [ ] **Step 5: Gate and commit** |
| + |
| +```bash |
| +GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go build ./... && \ |
| +GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go vet ./... && \ |
| +GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./... -count=1 && \ |
| +gofmt -l internal/console |
| +git add internal/console/archlayout.go internal/console/archlayout_test.go internal/console/archgraph.go |
| +git commit -m "console: deterministic architecture layout and inline SVG |
| + |
| +Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>" |
| +``` |
| + |
| +--- |
| + |
| +### Task 7: The page, the gate and the refresh door |
| + |
| +**Files:** |
| +- Create: `internal/console/archpage.go`, `internal/console/archpage_test.go`, `internal/console/templates/architecture.html` |
| +- 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) |
| + |
| +**Interfaces:** |
| +- Consumes: `BuildArch`, `ArchView`, `RenderArchSVG`, `loadInventory`, `FleetView`, `HostRows`, `openRegistry`, the active scenario loader used by `handleEconomics`. |
| +- Produces: `(*Server).handleArchitecture`, `(*Server).handleArchitectureRefresh`, `page.Arch *ArchView`, `page.ArchSVG template.HTML`. |
| + |
| +- [ ] **Step 1: Write the failing test** |
| + |
| +```go |
| +package console |
| + |
| +import ( |
| + "net/http" |
| + "net/url" |
| + "strings" |
| + "testing" |
| +) |
| + |
| +// TestFleetOperatorOnly's shape against /fleet/architecture: signed out, |
| +// signed in but not an operator, and a revoked operator all get the exact |
| +// 404 an unrouted path gets — byte-identical, so the route's existence is |
| +// not probeable. |
| +func TestArchitectureOperatorOnly(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + |
| + out := newBrowser(t) |
| + ghostStatus, _, ghost := get(t, out, fx.ts.URL+"/this-route-does-not-exist") |
| + if ghostStatus != http.StatusNotFound { |
| + t.Fatalf("sanity: missing route = %d", ghostStatus) |
| + } |
| + if st, _, body := get(t, out, fx.ts.URL+"/fleet/architecture"); st != http.StatusNotFound || body != ghost { |
| + t.Fatalf("signed-out = %d %q, want the same 404 as a missing route", st, body) |
| + } |
| + owner := signInPaul(t, fx) |
| + if st, _, body := get(t, owner, fx.ts.URL+"/fleet/architecture"); st != http.StatusNotFound || body != ghost { |
| + t.Fatalf("non-operator = %d %q, want the same 404", st, body) |
| + } |
| + if err := fx.st.SetOperator("paul@keymail.dev", true); err != nil { |
| + t.Fatal(err) |
| + } |
| + st, _, body := get(t, owner, fx.ts.URL+"/fleet/architecture") |
| + if st != 200 || !strings.Contains(body, "archsvg") { |
| + t.Fatalf("operator view = %d, want 200 with the diagram:\n%s", st, body) |
| + } |
| + if err := fx.st.SetOperator("paul@keymail.dev", false); err != nil { |
| + t.Fatal(err) |
| + } |
| + if st, _, body := get(t, owner, fx.ts.URL+"/fleet/architecture"); st != http.StatusNotFound || body != ghost { |
| + t.Fatalf("revoked = %d %q, want the same 404", st, body) |
| + } |
| +} |
| + |
| +// With no inventory ever taken, the page still renders and says WHICH of |
| +// the three states it is in — never a confident "no resources". |
| +func TestArchitecturePageWithoutInventory(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + c := signInPaul(t, fx) |
| + if err := fx.st.SetOperator("paul@keymail.dev", true); err != nil { |
| + t.Fatal(err) |
| + } |
| + st, _, body := get(t, c, fx.ts.URL+"/fleet/architecture") |
| + if st != 200 { |
| + t.Fatalf("= %d, want 200", st) |
| + } |
| + if !strings.Contains(body, "No inventory has been taken yet") { |
| + t.Errorf("page must name the never-taken state:\n%s", body) |
| + } |
| + if strings.Contains(body, "no resources") { |
| + t.Errorf("page must not claim an empty estate it never read") |
| + } |
| +} |
| + |
| +// The refresh door: operator-gated, CSRF-checked, POST-only. |
| +func TestArchitectureRefreshDoor(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + c := signInPaul(t, fx) |
| + if err := fx.st.SetOperator("paul@keymail.dev", true); err != nil { |
| + t.Fatal(err) |
| + } |
| + called := 0 |
| + fx.srv.inventory = func(ctx context.Context) (infraview.Inventory, error) { |
| + called++ |
| + return infraview.Inventory{Provider: "aws", TakenAt: time.Now().UTC()}, nil |
| + } |
| + |
| + // No CSRF token: refused, and the pass must not have run. |
| + st, _, _ := postForm(t, c, fx.ts.URL+"/fleet/architecture/refresh", url.Values{}) |
| + if st != http.StatusForbidden { |
| + t.Fatalf("no-CSRF refresh = %d, want 403", st) |
| + } |
| + if called != 0 { |
| + t.Fatalf("a refused refresh must not run the pass") |
| + } |
| + |
| + // With the token: runs once and redirects back to the page. |
| + csrf := sessionToken(t, fx, c) |
| + st, loc, body := postForm(t, c, fx.ts.URL+"/fleet/architecture/refresh", url.Values{"csrf": {csrf}}) |
| + if st != http.StatusSeeOther || loc != "/fleet/architecture" { |
| + t.Fatalf("refresh = %d -> %q, want 303 back to the page:\n%s", st, loc, body) |
| + } |
| + if called != 1 { |
| + t.Fatalf("pass ran %d times, want 1", called) |
| + } |
| +} |
| +``` |
| + |
| +Helpers 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. |
| + |
| +- [ ] **Step 2: Run test to verify it fails** |
| + |
| +Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/console/ -run TestArchitecture -count=1` |
| +Expected: FAIL — 404 on every request; the route is not registered. |
| + |
| +- [ ] **Step 3: Write `archpage.go`** |
| + |
| +```go |
| +// GET /fleet/architecture — the operator's map of the deployment: how a |
| +// request reaches a process, and everything the account is carrying that |
| +// makes that possible. |
| +// |
| +// The gate is handleFleet's, verbatim (server.go): session -> IsOperator -> |
| +// bare http.NotFound on EVERY failure including the store error. A styled |
| +// refusal would confirm the route exists, and this page names hostnames, |
| +// instance ids and the shape of the estate. |
| +// |
| +// Every read soft-fails on its own. The inventory, the fleet aggregate, the |
| +// per-box snapshots, the registry and the rate card are five independent |
| +// reads; any one of them having a bad moment costs its own section and a |
| +// line in the footer, never the page. |
| +package console |
| + |
| +import ( |
| + "log" |
| + "net/http" |
| + |
| + "github.com/carlosframework/platform/internal/economics" |
| +) |
| + |
| +func (s *Server) handleArchitecture(w http.ResponseWriter, r *http.Request) { |
| + addr, raw, ok := s.operatorOnly(w, r) |
| + if !ok { |
| + return |
| + } |
| + in := ArchInput{} |
| + inv, missing := s.loadInventory(r.Context()) |
| + in.Inv = inv |
| + |
| + if f, err := FleetView(r.Context(), s.cfg.Releases); err != nil { |
| + log.Printf("console: architecture: fleet view: %v", err) |
| + } else { |
| + in.Fleet = f |
| + } |
| + if hosts, err := HostRows(r.Context(), s.cfg.Releases, time.Now()); err != nil { |
| + log.Printf("console: architecture: hosts: %v", err) |
| + } else { |
| + in.Hosts = hosts |
| + } |
| + if reg, err := openRegistry(s.cfg.Registry); err != nil { |
| + log.Printf("console: architecture: registry: %v", err) |
| + } else if routes, err := reg.List(); err != nil { |
| + log.Printf("console: architecture: routes: %v", err) |
| + } else { |
| + in.Routes = routes |
| + } |
| + // The rate card comes from the SAME loader /fleet/economics uses |
| + // (economicsview.go:748), so the two pages cannot disagree about what a |
| + // box costs. Its warning is already surfaced on that page; here a bad |
| + // scenario simply means the zero rate card, and every box then reports |
| + // drift — which is the honest answer, not a silent $0. |
| + sc, _ := s.loadActiveScenario(r.Context()) |
| + in.Rates = sc.RateCard |
| + |
| + v := BuildArch(in) |
| + if missing != "" { |
| + v.Missing = missing |
| + } |
| + p := page{Title: "Architecture", Address: addr, CSRF: raw, Arch: &v, ArchSVG: RenderArchSVG(v)} |
| + s.render(w, http.StatusOK, "architecture", p) |
| +} |
| + |
| +// handleArchitectureRefresh runs one inventory now. Single-flighted |
| +// (infraRefreshing): a leaned-on button must not stack passes against a |
| +// rate-limited API. It deliberately does NOT clear the auth latch — a |
| +// deployment without the IAM grant gets the same honest answer however many |
| +// times the button is pressed. |
| +func (s *Server) handleArchitectureRefresh(w http.ResponseWriter, r *http.Request) { |
| + if r.Method != http.MethodPost { |
| + http.NotFound(w, r) |
| + return |
| + } |
| + _, raw, ok := s.operatorOnly(w, r) |
| + if !ok { |
| + return |
| + } |
| + if subtle.ConstantTimeCompare([]byte(r.FormValue("csrf")), []byte(raw)) != 1 { |
| + s.renderMessage(w, r, http.StatusForbidden, "Refused", "Cross-site request blocked.") |
| + return |
| + } |
| + if !s.infraRefreshing.CompareAndSwap(false, true) { |
| + s.setFlash(w, "err", "An inventory is already running.") |
| + http.Redirect(w, r, "/fleet/architecture", http.StatusSeeOther) |
| + return |
| + } |
| + defer s.infraRefreshing.Store(false) |
| + |
| + if err := s.infraPass(r.Context()); err != nil { |
| + log.Printf("console: architecture refresh: %v", err) |
| + s.setFlash(w, "err", "The inventory could not be refreshed.") |
| + } else { |
| + s.setFlash(w, "ok", "Inventory refreshed.") |
| + } |
| + http.Redirect(w, r, "/fleet/architecture", http.StatusSeeOther) |
| +} |
| +``` |
| + |
| +Extract `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. |
| + |
| +`s.setFlash(w, kind, msg)` takes `"ok"` or `"err"` as its kind (`appdomains.go:57`). |
| + |
| +- [ ] **Step 4: Register the routes** |
| + |
| +In `internal/console/server.go` beside line 690: |
| + |
| +```go |
| + s.mux.HandleFunc("GET /fleet/architecture", s.handleArchitecture) |
| + s.mux.HandleFunc("POST /fleet/architecture/refresh", s.handleArchitectureRefresh) |
| +``` |
| + |
| +And on the `page` struct, beside `Metrics`: |
| + |
| +```go |
| + // /fleet/architecture (archgraph.go): the deployment's map. Arch is nil |
| + // only on paths this handler never takes; ArchSVG is pre-rendered in Go |
| + // rather than assembled in the template because the layout has to be |
| + // deterministic and testable as a whole. |
| + Arch *ArchView |
| + ArchSVG template.HTML |
| +``` |
| + |
| +- [ ] **Step 5: Write the template** |
| + |
| +`internal/console/templates/architecture.html`: |
| + |
| +```html |
| +{{define "architecture"}}{{template "top" .}} |
| +<h1>Architecture</h1> |
| +{{with .Arch}} |
| +<p class="small"> |
| + {{if .Missing}} |
| + {{if eq .Missing "never"}}No inventory has been taken yet — the first pass runs within the hour. |
| + {{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. |
| + {{else}}The inventory snapshot could not be read.{{end}} |
| + {{else}} |
| + Estate {{.Account}} · inventoried {{fmtTime .TakenAt}} |
| + {{end}} |
| +</p> |
| + |
| +<div class="archwrap">{{$.ArchSVG}}</div> |
| + |
| +<h2>Boxes</h2> |
| +{{range .Boxes}} |
| +<section class="card"> |
| + <h3>{{.Label}}{{if .Region}} <span class="small">{{.Region}}</span>{{end}}</h3> |
| + <p class="small"> |
| + {{if .InstanceType}}{{.InstanceType}} · {{end}} |
| + {{if .MemGB}}{{.MemGB}} GB · {{end}} |
| + {{if .DiscoveredDiskGB}}{{.DiscoveredDiskGB}} GB disk · {{end}} |
| + {{if .USDMonth}}${{printf "%.0f" .USDMonth}}/mo{{end}} |
| + {{if not .CloudID}} · no cloud machine (bring-your-own box){{end}} |
| + </p> |
| + <p class="small">Apps: {{if .Apps}}{{range .Apps}}<code>{{.}}</code> {{end}}{{else}}none{{end}}</p> |
| + <p class="small"> |
| + static {{.Static}} · resident {{.Resident}} · hibernating {{.Hibernating}} · |
| + awake {{intOrDash .Awake}} · asleep {{intOrDash .Asleep}} |
| + </p> |
| + {{range .Drift}}<p class="small chip-warn">{{.}}</p>{{end}} |
| +</section> |
| +{{end}} |
| + |
| +{{range .Groups}} |
| +<h2>{{.Title}}</h2> |
| +<div class="tablewrap"><table> |
| + <tr><th>Name</th><th>Type</th><th>Region</th><th>Detail</th></tr> |
| + {{range .Rows}}<tr><td>{{.Name}}</td><td><code>{{.NativeType}}</code></td><td>{{.Region}}</td><td class="small">{{.Detail}}</td></tr>{{end}} |
| +</table></div> |
| +{{end}} |
| + |
| +{{if .Partial}} |
| +<h2>Reads that failed</h2> |
| +<ul class="small">{{range .Partial}}<li>{{.}}</li>{{end}}</ul> |
| +{{end}} |
| +{{end}} |
| + |
| +<form method="post" action="/fleet/architecture/refresh"> |
| + <input type="hidden" name="csrf" value="{{.CSRF}}"> |
| + <button type="submit">Refresh inventory</button> |
| +</form> |
| +{{template "bottom" .}}{{end}} |
| +``` |
| + |
| +(Check the closing template name the other operator pages use — `fleet.html`'s last line — and match it.) |
| + |
| +Add to `layout.html`'s style block, beside the other page-specific rules: |
| + |
| +```css |
| + /* /fleet/architecture: the diagram. Colours come from the same tokens as |
| + the rest of the console so it themes light and dark with no second |
| + palette to keep in sync. */ |
| + .archwrap { overflow-x: auto; margin: 1rem 0 2rem; } |
| + .archsvg { width: 100%; min-width: 720px; height: auto; } |
| + .archsvg rect { fill: var(--accent-soft); stroke: var(--rule); } |
| + .archsvg .archnode-box rect { stroke: var(--accent); } |
| + .archsvg .archtitle { fill: var(--ink); font: 600 13px var(--sans); } |
| + .archsvg .archsub { fill: var(--muted); font: 400 11px var(--sans); } |
| + .archsvg .archedge { fill: none; stroke: var(--rule); stroke-width: 1.5; } |
| +``` |
| + |
| +- [ ] **Step 6: Link it from the fleet page** |
| + |
| +At the top of `internal/console/templates/fleet.html`, after the `<h1>`: |
| + |
| +```html |
| +<p class="small"><a href="/fleet/architecture">See the architecture →</a></p> |
| +``` |
| + |
| +- [ ] **Step 7: Run tests to verify they pass** |
| + |
| +Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/console/ -count=1` |
| +Expected: PASS — the three new tests and every existing console test. |
| + |
| +- [ ] **Step 8: Gate and commit** |
| + |
| +```bash |
| +GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go build ./... && \ |
| +GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go vet ./... && \ |
| +GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./... -count=1 && \ |
| +GOOS=darwin GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go build ./... && \ |
| +gofmt -l internal/console |
| +git add internal/console cmd/console |
| +git commit -m "console: /fleet/architecture page, gate and refresh door |
| + |
| +Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>" |
| +``` |
| + |
| +--- |
| + |
| +### Task 8: The IAM grant and the deploy note |
| + |
| +**Files:** |
| +- Modify: `infra/modules/region-host/main.tf` (the box role's `aws_iam_role_policy`) |
| +- Modify: `docs/superpowers/specs/2026-08-19-fleet-architecture-diagram-design.md` (mark the grant's status) |
| + |
| +**Interfaces:** |
| +- Consumes: nothing in Go. |
| +- Produces: the read-only inventory statement the pass needs. |
| + |
| +- [ ] **Step 1: Read the existing policy** |
| + |
| +Run: `sed -n '90,160p' infra/modules/region-host/main.tf` |
| +Note the existing `aws_iam_role_policy` document's shape and its `Statement` list — the new statement is appended to it, in its style. |
| + |
| +- [ ] **Step 2: Add the statement** |
| + |
| +```hcl |
| + # Infrastructure inventory (2026-08-19-fleet-architecture-diagram-design.md |
| + # §8): what the console's hourly infra pass needs to draw |
| + # /fleet/architecture. Read-only in the strictest sense — every action |
| + # here is a List/Describe/Get of a resource's SHAPE. No write action, |
| + # no s3:GetObject, and deliberately no iam:GetPolicyVersion: the page |
| + # shows that a role exists and how many policies are attached to it, |
| + # never what those policies say. |
| + { |
| + Effect = "Allow" |
| + Action = [ |
| + "ec2:DescribeInstances", |
| + "ec2:DescribeVolumes", |
| + "ec2:DescribeAddresses", |
| + "ec2:DescribeSecurityGroups", |
| + "ec2:DescribeVpcs", |
| + "ec2:DescribeSubnets", |
| + "ec2:DescribeRegions", |
| + "tag:GetResources", |
| + "s3:ListAllMyBuckets", |
| + "s3:GetBucketLocation", |
| + "s3:GetBucketVersioning", |
| + "s3:GetEncryptionConfiguration", |
| + "s3:GetBucketPublicAccessBlock", |
| + "route53:ListHostedZones", |
| + "route53:ListResourceRecordSets", |
| + "iam:ListRoles", |
| + "iam:ListUsers", |
| + "iam:ListAttachedRolePolicies", |
| + "cloudwatch:DescribeAlarms", |
| + "acm:ListCertificates", |
| + "sts:GetCallerIdentity", |
| + ] |
| + Resource = "*" |
| + }, |
| +``` |
| + |
| +(`Resource = "*"` is required: every action listed is an account-wide List/Describe that AWS does not scope to a resource ARN.) |
| + |
| +- [ ] **Step 3: Validate the module** |
| + |
| +Run: `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). |
| +Expected: clean. |
| + |
| +- [ ] **Step 4: Record the rollout status in the spec** |
| + |
| +Append to §8 of the spec: |
| + |
| +```markdown |
| +**Status (2026-08-19):** the module statement is in this repo as of this |
| +branch. The flagship box runs a role provisioned from pinfra, so the grant |
| +reaches production only when the matching pinfra change is applied — until |
| +then `/fleet/architecture` renders the CARLOS half and reports the estate |
| +as ungranted, which is the designed state and not a fault. |
| +``` |
| + |
| +- [ ] **Step 5: Commit** |
| + |
| +```bash |
| +git add infra/modules/region-host/main.tf docs/superpowers/specs/2026-08-19-fleet-architecture-diagram-design.md |
| +git commit -m "infra: read-only inventory grant for the architecture page |
| + |
| +Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>" |
| +``` |
| + |
| +--- |
| + |
| +### Task 9: Branch finish |
| + |
| +- [ ] **Step 1: Full gate, including the cross-compile CI runs** |
| + |
| +```bash |
| +GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go build ./... && \ |
| +GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go vet ./... && \ |
| +GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./... -count=1 && \ |
| +GOOS=darwin GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go build ./... && \ |
| +gofmt -l internal/infraview internal/awsinv internal/console cmd/console |
| +``` |
| + |
| +Expected: every command clean, `gofmt -l` printing nothing. |
| + |
| +- [ ] **Step 2: Confirm no AWS write reached the tree** |
| + |
| +```bash |
| +grep -rnE "\b(Create|Put|Delete|Modify|Terminate|Run|Attach|Detach|Tag|Untag)[A-Z]" internal/awsinv/*.go | grep -v _test |
| +``` |
| + |
| +Expected: no output. Any hit is a mutating SDK call that must not be here. |
| + |
| +- [ ] **Step 3: Push the branch and open the PR** |
| + |
| +```bash |
| +git push -u origin "$(git branch --show-current)" |
| +gh pr create --title "Fleet architecture diagram: /fleet/architecture over a provider-neutral inventory" --body "$(cat <<'BODY' |
| +Adds the operator page the spec describes: how a request reaches a process, |
| +drawn over a live inventory of what the deployment's cloud account is |
| +carrying, with each box's size, cost, apps and instance counts. |
| + |
| +- `internal/infraview` — provider-neutral inventory shape (no SDK) |
| +- `internal/awsinv` — the AWS implementation; the only SDK importer, read-only throughout |
| +- `internal/console/infrapass.go` — hourly pass to `infra/inventory.json`, `cepull.go`'s latch posture |
| +- `internal/console/archgraph.go` / `archlayout.go` — joins, cost/drift, deterministic SVG |
| +- `GET /fleet/architecture` + `POST /fleet/architecture/refresh`, `handleFleet`'s gate |
| +- `infra/modules/region-host` — the read-only grant (needs a matching pinfra apply to reach flagship) |
| + |
| +Spec: `docs/superpowers/specs/2026-08-19-fleet-architecture-diagram-design.md` |
| +Plan: `docs/superpowers/plans/2026-08-19-fleet-architecture-diagram.md` |
| + |
| +Until the pinfra grant is applied the page renders the CARLOS half and |
| +reports the estate as ungranted — the designed state, covered by a test. |
| + |
| +🤖 Generated with [Claude Code](https://claude.com/claude-code) |
| +BODY |
| +)" |
| +``` |
| + |
| +- [ ] **Step 4: Report what remains** |
| + |
| +State 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. |