From 633ba153f263c807d41314d6bedd11ee7e4a8027 Mon Sep 17 00:00:00 2001 From: Mathias Date: Mon, 20 Jul 2026 00:56:40 +0200 Subject: [PATCH] =?UTF-8?q?feat(atlas):=20Phase=20B=20tail=20=E2=80=94=20l?= =?UTF-8?q?ive=20namespaces=20+=20trim=20inline=20fallback?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit NamespaceSummary (test-first) renders the substrate ns line from live cluster namespaces (system-filtered, sorted, capped). Handler now overlays both node specs and the ns line via a single cached liveOverlay(). Removed the inline SUBSTRATE/STAGES/NS data — /api/atlas.json is the single source; the page shows an error banner on fetch failure instead of stale data. GPU model naming intentionally NOT done: koala's node has no GPU product label (only nvidia.com/gpu count), so "1× GPU" is the API's truth — naming the RTX 5070 would need GPU-feature-discovery, out of scope. Verified: build/vet/lint(0)/test green. Co-Authored-By: Claude Opus 4.8 (1M context) --- internal/atlas/cluster.go | 42 +++++++++++++++++++ internal/atlas/cluster_test.go | 36 ++++++++++++++++ internal/cluster/client.go | 3 ++ internal/web/handler.go | 60 +++++++++++++++----------- internal/web/static/cad-atlas.html | 67 +++++++----------------------- 5 files changed, 131 insertions(+), 77 deletions(-) diff --git a/internal/atlas/cluster.go b/internal/atlas/cluster.go index 46b7618..0f3fabb 100644 --- a/internal/atlas/cluster.go +++ b/internal/atlas/cluster.go @@ -4,6 +4,7 @@ import ( "encoding/json" "fmt" "math" + "sort" "strconv" "strings" ) @@ -52,6 +53,47 @@ func HostsFromNodes(nodesJSON []byte) ([]Host, error) { return hosts, nil } +// NamespaceSummary parses a Kubernetes `/api/v1/namespaces` list into a compact +// "ns: a · b · c" line for the substrate, dropping system namespaces, sorting, +// and capping the count (with "+N more" when it overflows). +func NamespaceSummary(nsJSON []byte) (string, error) { + var list struct { + Items []struct { + Metadata struct { + Name string `json:"name"` + } `json:"metadata"` + } `json:"items"` + } + if err := json.Unmarshal(nsJSON, &list); err != nil { + return "", fmt.Errorf("parse namespaces: %w", err) + } + + var names []string + for _, it := range list.Items { + n := it.Metadata.Name + if strings.HasPrefix(n, "kube-") || n == "default" || n == "flux-system" { + continue + } + names = append(names, n) + } + sort.Strings(names) + + const maxShown = 12 + more := 0 + if len(names) > maxShown { + more = len(names) - maxShown + names = names[:maxShown] + } + if len(names) == 0 { + return "ns: (none)", nil + } + s := "ns: " + strings.Join(names, " · ") + if more > 0 { + s += " · +" + strconv.Itoa(more) + " more" + } + return s, nil +} + // MergeSubstrate overlays live node specs onto the authored substrate: an // authored host is replaced by the live node of the same name (fresh specs), // authored-only machines (non-cluster: iguana/flamingo/piguard) are kept, and diff --git a/internal/atlas/cluster_test.go b/internal/atlas/cluster_test.go index 3fc4e01..a541d49 100644 --- a/internal/atlas/cluster_test.go +++ b/internal/atlas/cluster_test.go @@ -2,6 +2,7 @@ package atlas_test import ( "reflect" + "strings" "testing" "git.d-ma.be/mathias/cad-atlas/internal/atlas" @@ -62,6 +63,41 @@ func TestMergeSubstrate_NoLiveReturnsAuthored(t *testing.T) { } } +func TestNamespaceSummary_FiltersSystemSortsAndJoins(t *testing.T) { + ns := []byte(`{"items":[ + {"metadata":{"name":"gitea"}}, + {"metadata":{"name":"kube-system"}}, + {"metadata":{"name":"ai-stack"}}, + {"metadata":{"name":"default"}}, + {"metadata":{"name":"flux-system"}}, + {"metadata":{"name":"brain"}} + ]}`) + + got, err := atlas.NamespaceSummary(ns) + if err != nil { + t.Fatalf("NamespaceSummary: %v", err) + } + if want := "ns: ai-stack · brain · gitea"; got != want { + t.Fatalf("summary = %q, want %q", got, want) + } +} + +func TestNamespaceSummary_CapsWithMore(t *testing.T) { + var items []string + for i := 0; i < 15; i++ { + items = append(items, `{"metadata":{"name":"app`+string(rune('a'+i))+`"}}`) + } + ns := []byte(`{"items":[` + strings.Join(items, ",") + `]}`) + + got, err := atlas.NamespaceSummary(ns) + if err != nil { + t.Fatalf("NamespaceSummary: %v", err) + } + if !strings.HasSuffix(got, "· +3 more") { + t.Fatalf("expected cap suffix, got %q", got) + } +} + func TestHostsFromNodes_ErrorsOnBadJSON(t *testing.T) { if _, err := atlas.HostsFromNodes([]byte("{not json")); err == nil { t.Fatal("expected error on bad JSON, got nil") diff --git a/internal/cluster/client.go b/internal/cluster/client.go index ab8ae6a..9355fef 100644 --- a/internal/cluster/client.go +++ b/internal/cluster/client.go @@ -21,6 +21,9 @@ const ( // Nodes returns the raw /api/v1/nodes JSON from the in-cluster API server. func Nodes() ([]byte, error) { return get("/api/v1/nodes") } +// Namespaces returns the raw /api/v1/namespaces JSON from the in-cluster API server. +func Namespaces() ([]byte, error) { return get("/api/v1/namespaces") } + func get(path string) ([]byte, error) { host, port := os.Getenv("KUBERNETES_SERVICE_HOST"), os.Getenv("KUBERNETES_SERVICE_PORT") if host == "" || port == "" { diff --git a/internal/web/handler.go b/internal/web/handler.go index 87fda67..09722ef 100644 --- a/internal/web/handler.go +++ b/internal/web/handler.go @@ -37,8 +37,12 @@ func NewHandler() http.Handler { http.Error(w, "atlas build failed", http.StatusInternalServerError) return } - if live := liveSubstrate(); len(live) > 0 { - a.Substrate = atlas.MergeSubstrate(a.Substrate, live) + ld := liveOverlay() + if len(ld.hosts) > 0 { + a.Substrate = atlas.MergeSubstrate(a.Substrate, ld.hosts) + } + if ld.ns != "" { + a.NS = "Tailscale mesh · " + ld.ns } w.Header().Set("Content-Type", "application/json; charset=utf-8") _ = json.NewEncoder(w).Encode(a) @@ -46,33 +50,41 @@ func NewHandler() http.Handler { return mux } -// substrate cache: query the cluster at most once per TTL; fall back to the -// authored substrate (returns nil) whenever the cluster is unreachable. +// liveOverlay holds cluster-sourced substrate facts (node specs + namespace line). +type liveOverlayData struct { + hosts []atlas.Host + ns string +} + +// live cache: query the cluster at most once per TTL; fall back to the authored +// substrate/ns (zero values) whenever the cluster is unreachable. var ( - subMu sync.Mutex - subCache []atlas.Host - subAt time.Time + liveMu sync.Mutex + liveCache liveOverlayData + liveAt time.Time ) -const substrateTTL = 30 * time.Second +const liveTTL = 30 * time.Second -func liveSubstrate() []atlas.Host { - subMu.Lock() - defer subMu.Unlock() - if !subAt.IsZero() && time.Since(subAt) < substrateTTL { - return subCache +func liveOverlay() liveOverlayData { + liveMu.Lock() + defer liveMu.Unlock() + if !liveAt.IsZero() && time.Since(liveAt) < liveTTL { + return liveCache } - subAt = time.Now() - raw, err := cluster.Nodes() - if err != nil { - subCache = nil - return nil + liveAt = time.Now() + + var d liveOverlayData + if raw, err := cluster.Nodes(); err == nil { + if hosts, err := atlas.HostsFromNodes(raw); err == nil { + d.hosts = hosts + } } - hosts, err := atlas.HostsFromNodes(raw) - if err != nil { - subCache = nil - return nil + if raw, err := cluster.Namespaces(); err == nil { + if s, err := atlas.NamespaceSummary(raw); err == nil { + d.ns = s + } } - subCache = hosts - return hosts + liveCache = d + return d } diff --git a/internal/web/static/cad-atlas.html b/internal/web/static/cad-atlas.html index adfe255..baee860 100644 --- a/internal/web/static/cad-atlas.html +++ b/internal/web/static/cad-atlas.html @@ -114,7 +114,7 @@

CAD Atlas · From Signal to Pod

- one human gate · everything up- and downstream is agents · v0.5 · data-driven (CI + substrate from the live cluster) + one human gate · everything up- and downstream is agents · v0.6 · live cluster (nodes + namespaces) · single-source /api/atlas.json
@@ -150,57 +150,11 @@