Files
cad-atlas/internal/atlas/cluster.go
T
mathiasandClaude Opus 4.8 633ba153f2
CD / Detect unsubstituted template (push) Successful in 1s
CD / Lint / Test / Vet (push) Successful in 5s
CD / Build & Import (push) Successful in 14s
CD / Deploy via GitOps (push) Successful in 1s
feat(atlas): Phase B tail — live namespaces + trim inline fallback
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) <noreply@anthropic.com>
2026-07-20 00:56:40 +02:00

149 lines
3.9 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package atlas
import (
"encoding/json"
"fmt"
"math"
"sort"
"strconv"
"strings"
)
// HostsFromNodes parses a Kubernetes `/api/v1/nodes` list response into
// substrate Host entries, so the atlas machines reflect the live cluster.
func HostsFromNodes(nodesJSON []byte) ([]Host, error) {
var list struct {
Items []struct {
Metadata struct {
Name string `json:"name"`
} `json:"metadata"`
Status struct {
Capacity map[string]string `json:"capacity"`
NodeInfo struct {
Architecture string `json:"architecture"`
KubeletVersion string `json:"kubeletVersion"`
} `json:"nodeInfo"`
} `json:"status"`
} `json:"items"`
}
if err := json.Unmarshal(nodesJSON, &list); err != nil {
return nil, fmt.Errorf("parse nodes: %w", err)
}
hosts := make([]Host, 0, len(list.Items))
for _, it := range list.Items {
var parts []string
if a := it.Status.NodeInfo.Architecture; a != "" {
parts = append(parts, a)
}
if c := it.Status.Capacity["cpu"]; c != "" {
parts = append(parts, c+" cpu")
}
if gi := memGi(it.Status.Capacity["memory"]); gi != "" {
parts = append(parts, gi+"Gi")
}
if g := it.Status.Capacity["nvidia.com/gpu"]; g != "" && g != "0" {
parts = append(parts, g+"× GPU")
}
if v := k3sVersion(it.Status.NodeInfo.KubeletVersion); v != "" {
parts = append(parts, v)
}
hosts = append(hosts, Host{Name: it.Metadata.Name, Spec: strings.Join(parts, " · ")})
}
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
// 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 {
n, err := strconv.ParseFloat(strings.TrimSuffix(ki, "Ki"), 64)
if err != nil {
return ""
}
return strconv.Itoa(int(math.Round(n / 1048576)))
}
// k3sVersion trims a kubeletVersion's build metadata, labelling k3s builds.
// "v1.31.4+k3s1" → "k3s v1.31.4"; "v1.31.4" → "v1.31.4".
func k3sVersion(kubelet string) string {
if kubelet == "" {
return ""
}
if i := strings.Index(kubelet, "+"); i >= 0 {
ver, suffix := kubelet[:i], kubelet[i+1:]
if strings.Contains(suffix, "k3s") {
return "k3s " + ver
}
return ver
}
return kubelet
}