From 1f7c9ab1ebdd6a06fca8a6bb357304380d89a734 Mon Sep 17 00:00:00 2001 From: Mathias Date: Mon, 20 Jul 2026 07:56:42 +0200 Subject: [PATCH] =?UTF-8?q?feat(atlas):=20Phase=20C=20=E2=80=94=20live=20F?= =?UTF-8?q?lux=20reconcile=20status=20on=20stage=2007?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- internal/atlas/cluster.go | 64 ++++++++++++++++++++++++++++++++++ internal/atlas/cluster_test.go | 28 +++++++++++++++ internal/cluster/client.go | 6 ++++ internal/web/handler.go | 18 ++++++++-- 4 files changed, 113 insertions(+), 3 deletions(-) diff --git a/internal/atlas/cluster.go b/internal/atlas/cluster.go index 0e107b4..da76eb9 100644 --- a/internal/atlas/cluster.go +++ b/internal/atlas/cluster.go @@ -53,6 +53,70 @@ func HostsFromNodes(nodesJSON []byte) ([]Host, error) { 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:" into "main@". +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 diff --git a/internal/atlas/cluster_test.go b/internal/atlas/cluster_test.go index 3e9313e..2d759ce 100644 --- a/internal/atlas/cluster_test.go +++ b/internal/atlas/cluster_test.go @@ -98,6 +98,34 @@ func TestNamespaceSummary_CapsWithMore(t *testing.T) { } } +func TestFluxStatus_ParsesReadyReasonAndShortRevision(t *testing.T) { + k := []byte(`{"status":{ + "conditions":[{"type":"Ready","status":"True","reason":"ReconciliationSucceeded","message":"Applied revision: main@sha1:7a51d1d13944"}], + "lastAppliedRevision":"main@sha1:7a51d1d13944c481b04fc434d9a2aca2a25632e4"}}`) + + f, err := atlas.FluxStatus(k) + if err != nil { + t.Fatalf("FluxStatus: %v", err) + } + if !f.Ready || f.Reason != "ReconciliationSucceeded" || f.Revision != "main@7a51d1d" { + t.Fatalf("flux = %+v", f) + } +} + +func TestFluxNode_GreenWhenReadyCoralWhenNot(t *testing.T) { + ready := atlas.FluxNode(atlas.Flux{Ready: true, Reason: "ReconciliationSucceeded", Revision: "main@7a51d1d"}) + if ready.Pill != "var(--green)" { + t.Fatalf("ready pill = %q", ready.Pill) + } + if ready.Title != "⟳ Flux · reconciled · main@7a51d1d" { + t.Fatalf("title = %q", ready.Title) + } + notReady := atlas.FluxNode(atlas.Flux{Ready: false, Reason: "BuildFailed"}) + if notReady.Pill != "var(--coral)" { + t.Fatalf("not-ready pill = %q", notReady.Pill) + } +} + func TestDeployState_ParsesImageAndReplicas(t *testing.T) { dep := []byte(`{"spec":{"replicas":2,"template":{"spec":{"containers":[ {"name":"cad-atlas","image":"localhost:5000/cad-atlas:3ff922a"}]}}}, diff --git a/internal/cluster/client.go b/internal/cluster/client.go index e92ba0c..cf5bcb8 100644 --- a/internal/cluster/client.go +++ b/internal/cluster/client.go @@ -29,6 +29,12 @@ func Deployment() ([]byte, error) { return get("/apis/apps/v1/namespaces/cad-atlas/deployments/cad-atlas") } +// FluxKustomization returns the raw JSON for the Flux `apps` Kustomization that +// reconciles this repo's manifests. +func FluxKustomization() ([]byte, error) { + return get("/apis/kustomize.toolkit.fluxcd.io/v1/namespaces/flux-system/kustomizations/apps") +} + func get(path string) ([]byte, error) { host, port := os.Getenv("KUBERNETES_SERVICE_HOST"), os.Getenv("KUBERNETES_SERVICE_PORT") if host == "" || port == "" { diff --git a/internal/web/handler.go b/internal/web/handler.go index 4b21775..2cd0263 100644 --- a/internal/web/handler.go +++ b/internal/web/handler.go @@ -56,11 +56,17 @@ func NewHandler() http.Handler { } } } - if ld.deploy != nil { - node := atlas.DeployNode(*ld.deploy) + if ld.deploy != nil || ld.flux != nil { + var live []atlas.Node + if ld.deploy != nil { + live = append(live, atlas.DeployNode(*ld.deploy)) + } + if ld.flux != nil { + live = append(live, atlas.FluxNode(*ld.flux)) + } for i := range a.Stages { if a.Stages[i].Generate == "deploy-state" { - a.Stages[i].Nodes = append([]atlas.Node{node}, a.Stages[i].Nodes...) + a.Stages[i].Nodes = append(live, a.Stages[i].Nodes...) } } } @@ -76,6 +82,7 @@ type liveOverlayData struct { ns string run *atlas.RunSummary deploy *atlas.Deploy + flux *atlas.Flux timeline []atlas.RunDot } @@ -121,6 +128,11 @@ func liveOverlay() liveOverlayData { d.deploy = &dep } } + if raw, err := cluster.FluxKustomization(); err == nil { + if f, err := atlas.FluxStatus(raw); err == nil { + d.flux = &f + } + } liveCache = d return d }