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