Files
cad-atlas/internal/atlas/cluster.go
T
mathiasandClaude Opus 4.8 1f7c9ab1eb
CD / Detect unsubstituted template (push) Successful in 0s
CD / Lint / Test / Vet (push) Successful in 4s
CD / Build & Import (push) Successful in 14s
CD / Deploy via GitOps (push) Has been skipped
feat(atlas): Phase C — live Flux reconcile status on stage 07
Stage 07 now also shows the Flux `apps` Kustomization state — reconciled/failed
+ last-applied revision (main@shortsha) — read in-cluster (new read-only Role in
flux-system). Sits alongside the live deploy node. FluxStatus/FluxNode test-first,
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:56:42 +02:00

273 lines
7.0 KiB
Go
Raw 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
}
// Flux is the reconciliation state of a Flux Kustomization.
type Flux struct {
Ready bool
Reason string
Revision string
}
// FluxStatus parses a Flux Kustomization object into its reconcile state.
func FluxStatus(kustJSON []byte) (Flux, error) {
var k struct {
Status struct {
Conditions []struct {
Type string `json:"type"`
Status string `json:"status"`
Reason string `json:"reason"`
} `json:"conditions"`
LastAppliedRevision string `json:"lastAppliedRevision"`
} `json:"status"`
}
if err := json.Unmarshal(kustJSON, &k); err != nil {
return Flux{}, fmt.Errorf("parse kustomization: %w", err)
}
f := Flux{Revision: shortRev(k.Status.LastAppliedRevision)}
for _, c := range k.Status.Conditions {
if c.Type == "Ready" {
f.Ready = c.Status == "True"
f.Reason = c.Reason
}
}
return f, nil
}
// FluxNode renders the Flux reconcile state as a stage node.
func FluxNode(f Flux) Node {
if f.Ready {
return Node{
Title: "⟳ Flux · reconciled · " + f.Revision,
Pill: "var(--green)",
Tags: []string{"live · k8s"},
}
}
return Node{
Title: "⟳ Flux · " + f.Reason,
Pill: "var(--coral)",
Tags: []string{"live · k8s"},
}
}
// shortRev turns a Flux revision "main@sha1:<full>" into "main@<short>".
func shortRev(rev string) string {
at := strings.Index(rev, "@")
if at < 0 {
return rev
}
branch, sha := rev[:at], rev[at+1:]
if c := strings.LastIndex(sha, ":"); c >= 0 {
sha = sha[c+1:]
}
if len(sha) > 7 {
sha = sha[:7]
}
return branch + "@" + sha
}
// 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
}