From 39fd9b9adc45b8634ac0feccd5c253cf17f29f1d Mon Sep 17 00:00:00 2001 From: Mathias Date: Mon, 20 Jul 2026 00:47:53 +0200 Subject: [PATCH] =?UTF-8?q?feat(atlas):=20hybrid=20substrate=20=E2=80=94?= =?UTF-8?q?=20live=20specs=20for=20cluster=20nodes,=20authored=20rest?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MergeSubstrate overlays live node specs onto the authored machine list by name: cluster nodes (koala) get fresh live specs, non-cluster machines (iguana/flamingo/ piguard) stay authored, new live nodes are appended. Test-first. Restores the full homelab machine list while keeping cluster nodes truthful. Co-Authored-By: Claude Opus 4.8 (1M context) --- internal/atlas/cluster.go | 27 +++++++++++++++++++++++++++ internal/atlas/cluster_test.go | 31 +++++++++++++++++++++++++++++++ internal/web/handler.go | 2 +- 3 files changed, 59 insertions(+), 1 deletion(-) diff --git a/internal/atlas/cluster.go b/internal/atlas/cluster.go index 46fda47..46b7618 100644 --- a/internal/atlas/cluster.go +++ b/internal/atlas/cluster.go @@ -52,6 +52,33 @@ func HostsFromNodes(nodesJSON []byte) ([]Host, error) { return hosts, 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 +// live-only nodes (new to the cluster) are appended. +func MergeSubstrate(authored, live []Host) []Host { + liveByName := make(map[string]Host, len(live)) + for _, h := range live { + liveByName[h.Name] = h + } + seen := make(map[string]bool, len(authored)) + out := make([]Host, 0, len(authored)+len(live)) + for _, a := range authored { + if l, ok := liveByName[a.Name]; ok { + out = append(out, l) + } else { + out = append(out, a) + } + seen[a.Name] = true + } + for _, l := range live { + if !seen[l.Name] { + out = append(out, l) + } + } + return out +} + // memGi converts a Kubernetes memory quantity in Ki (e.g. "67108864Ki") to a // rounded Gi string. Returns "" if unparseable. func memGi(ki string) string { diff --git a/internal/atlas/cluster_test.go b/internal/atlas/cluster_test.go index d242517..3fc4e01 100644 --- a/internal/atlas/cluster_test.go +++ b/internal/atlas/cluster_test.go @@ -31,6 +31,37 @@ func TestHostsFromNodes_DerivesSpecFromNodeStatus(t *testing.T) { } } +func TestMergeSubstrate_LiveOverridesAuthoredKeepsRestAppendsNew(t *testing.T) { + authored := []atlas.Host{ + {Name: "koala", Spec: "RTX 5070 · authored"}, + {Name: "iguana", Spec: "M2 Ultra · Ollama / mlx"}, + {Name: "piguard", Spec: "NGINX reverse-proxy · ntfy"}, + } + live := []atlas.Host{ + {Name: "koala", Spec: "amd64 · 16 cpu · 60Gi · 1× GPU · k3s v1.34.5"}, + {Name: "worker9", Spec: "arm64 · 4 cpu"}, + } + + got := atlas.MergeSubstrate(authored, live) + want := []atlas.Host{ + {Name: "koala", Spec: "amd64 · 16 cpu · 60Gi · 1× GPU · k3s v1.34.5"}, // live wins + {Name: "iguana", Spec: "M2 Ultra · Ollama / mlx"}, // authored kept + {Name: "piguard", Spec: "NGINX reverse-proxy · ntfy"}, // authored kept + {Name: "worker9", Spec: "arm64 · 4 cpu"}, // live-only appended + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("merge = %+v\nwant %+v", got, want) + } +} + +func TestMergeSubstrate_NoLiveReturnsAuthored(t *testing.T) { + authored := []atlas.Host{{Name: "koala", Spec: "authored"}} + got := atlas.MergeSubstrate(authored, nil) + if !reflect.DeepEqual(got, authored) { + t.Fatalf("merge with no live = %+v, want %+v", got, authored) + } +} + 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/web/handler.go b/internal/web/handler.go index 40d9260..87fda67 100644 --- a/internal/web/handler.go +++ b/internal/web/handler.go @@ -38,7 +38,7 @@ func NewHandler() http.Handler { return } if live := liveSubstrate(); len(live) > 0 { - a.Substrate = live + a.Substrate = atlas.MergeSubstrate(a.Substrate, live) } w.Header().Set("Content-Type", "application/json; charset=utf-8") _ = json.NewEncoder(w).Encode(a)