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