carlos / platform Public

infraview: provider-neutral infrastructure inventory shape

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Paul Campbell pushed by paul@keymail.dev ee4aaa551cd7638516245e8182bd00cebc2b563e parent 70241ab
2 files changed, +183 −0
  • internal/infraview/infraview.go +118 −0
  • internal/infraview/infraview_test.go +65 −0
diff --git a/internal/infraview/infraview.go b/internal/infraview/infraview.go
new file mode 100644
index 000000000..f0070cff2
--- /dev/null
+++ b/internal/infraview/infraview.go
@@ -0,0 +1,118 @@
+// 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)
+}
diff --git a/internal/infraview/infraview_test.go b/internal/infraview/infraview_test.go
new file mode 100644
index 000000000..9a92165a4
--- /dev/null
+++ b/internal/infraview/infraview_test.go
@@ -0,0 +1,65 @@
+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)
+ }
+}