generated from mathias/template-go-web
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>
273 lines
7.0 KiB
Go
273 lines
7.0 KiB
Go
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
|
||
}
|