feat(atlas): hybrid substrate — live specs for cluster nodes, authored rest
CD / Detect unsubstituted template (push) Successful in 1s
CD / Lint / Test / Vet (push) Successful in 6s
CD / Build & Import (push) Successful in 14s
CD / Deploy via GitOps (push) Successful in 1s

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) <noreply@anthropic.com>
This commit is contained in:
2026-07-20 00:47:53 +02:00
co-authored by Claude Opus 4.8
parent 74dffc1ff3
commit 39fd9b9adc
3 changed files with 59 additions and 1 deletions
+27
View File
@@ -52,6 +52,33 @@ func HostsFromNodes(nodesJSON []byte) ([]Host, error) {
return hosts, nil 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 // memGi converts a Kubernetes memory quantity in Ki (e.g. "67108864Ki") to a
// rounded Gi string. Returns "" if unparseable. // rounded Gi string. Returns "" if unparseable.
func memGi(ki string) string { func memGi(ki string) string {
+31
View File
@@ -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) { func TestHostsFromNodes_ErrorsOnBadJSON(t *testing.T) {
if _, err := atlas.HostsFromNodes([]byte("{not json")); err == nil { if _, err := atlas.HostsFromNodes([]byte("{not json")); err == nil {
t.Fatal("expected error on bad JSON, got nil") t.Fatal("expected error on bad JSON, got nil")
+1 -1
View File
@@ -38,7 +38,7 @@ func NewHandler() http.Handler {
return return
} }
if live := liveSubstrate(); len(live) > 0 { 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") w.Header().Set("Content-Type", "application/json; charset=utf-8")
_ = json.NewEncoder(w).Encode(a) _ = json.NewEncoder(w).Encode(a)