Files
cad-atlas/internal/atlas/cluster.go
T
mathiasandClaude Opus 4.8 3d1f76997b
CD / Lint / Test / Vet (push) Successful in 4s
CD / Build & Import (push) Successful in 13s
CD / Detect unsubstituted template (push) Successful in 0s
CD / Deploy via GitOps (push) Has been skipped
feat(atlas): Phase C — live CD/deploy state on stage 07
Stage 07 now shows the pod's own live deploy state — deployed image tag +
N/M replicas ready, green only when the rollout is fully ready — read from the
k8s Deployment in-cluster (new namespaced deployments-read Role). DeployState/
DeployNode test-first; prepended to the authored CD narrative, fallback-safe.

Verified: build/vet/lint(0)/test green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 07:39:01 +02:00

209 lines
5.5 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
}
// Deploy is the live state of a Kubernetes Deployment.
type Deploy struct {
Image string
Ready int
Desired int
}
// Tag is the image tag (substring after the last ":").
func (d Deploy) Tag() string {
if i := strings.LastIndex(d.Image, ":"); i >= 0 {
return d.Image[i+1:]
}
return d.Image
}
// DeployState parses a Kubernetes Deployment object into its live state.
func DeployState(deployJSON []byte) (Deploy, error) {
var dep struct {
Spec struct {
Replicas int `json:"replicas"`
Template struct {
Spec struct {
Containers []struct {
Image string `json:"image"`
} `json:"containers"`
} `json:"spec"`
} `json:"template"`
} `json:"spec"`
Status struct {
ReadyReplicas int `json:"readyReplicas"`
} `json:"status"`
}
if err := json.Unmarshal(deployJSON, &dep); err != nil {
return Deploy{}, fmt.Errorf("parse deployment: %w", err)
}
d := Deploy{Ready: dep.Status.ReadyReplicas, Desired: dep.Spec.Replicas}
if len(dep.Spec.Template.Spec.Containers) > 0 {
d.Image = dep.Spec.Template.Spec.Containers[0].Image
}
return d, nil
}
// DeployNode renders the live deploy state as a stage node (green when the
// rollout is fully ready, amber otherwise).
func DeployNode(d Deploy) Node {
pill := "var(--amber)"
if d.Desired > 0 && d.Ready == d.Desired {
pill = "var(--green)"
}
img := d.Image
if i := strings.LastIndex(img, "/"); i >= 0 {
img = img[i+1:] // drop registry host
}
return Node{
Title: fmt.Sprintf("◆ deployed · %s · %d/%d ready", img, d.Ready, d.Desired),
Pill: pill,
Tags: []string{"live · k8s"},
}
}
// 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
}