diff --git a/internal/atlas/atlas.json b/internal/atlas/atlas.json index 6c2def8..541376a 100644 --- a/internal/atlas/atlas.json +++ b/internal/atlas/atlas.json @@ -36,7 +36,7 @@ {"t":"assessor-loop ledger","d":"Attestation ledger (audit trail) + brain session_log on completion.","tags":["audit package"]} ]}, {"no":"STAGE 06","title":"PR → CI","path":"Gitea Actions · cd.yml (live)","generate":"ci-jobs","nodes":[]}, - {"no":"STAGE 07","cls":"cd","title":"CD → pod","path":"Flux GitOps → k3s","nodes":[ + {"no":"STAGE 07","cls":"cd","title":"CD → pod","path":"Flux GitOps → k3s","generate":"deploy-state","nodes":[ {"t":"Deploy on green","pill":"var(--green)","d":"Flux reconciles image → k3s pod on koala. Push ≠ deploy: bump tag in mathias/infra.","tags":["ntfy on deploy"]} ]}, {"no":"STAGE 08","cls":"telos","title":"Loop back","path":"→ TELOS (feedback bus)","nodes":[ diff --git a/internal/atlas/cluster.go b/internal/atlas/cluster.go index 0f3fabb..0e107b4 100644 --- a/internal/atlas/cluster.go +++ b/internal/atlas/cluster.go @@ -53,6 +53,66 @@ func HostsFromNodes(nodesJSON []byte) ([]Host, error) { 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). diff --git a/internal/atlas/cluster_test.go b/internal/atlas/cluster_test.go index a541d49..3e9313e 100644 --- a/internal/atlas/cluster_test.go +++ b/internal/atlas/cluster_test.go @@ -98,6 +98,38 @@ func TestNamespaceSummary_CapsWithMore(t *testing.T) { } } +func TestDeployState_ParsesImageAndReplicas(t *testing.T) { + dep := []byte(`{"spec":{"replicas":2,"template":{"spec":{"containers":[ + {"name":"cad-atlas","image":"localhost:5000/cad-atlas:3ff922a"}]}}}, + "status":{"readyReplicas":1,"replicas":2}}`) + + d, err := atlas.DeployState(dep) + if err != nil { + t.Fatalf("DeployState: %v", err) + } + if d.Image != "localhost:5000/cad-atlas:3ff922a" || d.Ready != 1 || d.Desired != 2 { + t.Fatalf("deploy = %+v", d) + } + if d.Tag() != "3ff922a" { + t.Fatalf("tag = %q, want 3ff922a", d.Tag()) + } +} + +func TestDeployNode_GreenOnlyWhenFullyReady(t *testing.T) { + ready := atlas.DeployNode(atlas.Deploy{Image: "localhost:5000/cad-atlas:abc", Ready: 1, Desired: 1}) + if ready.Pill != "var(--green)" { + t.Fatalf("ready pill = %q, want green", ready.Pill) + } + if ready.Title != "◆ deployed · cad-atlas:abc · 1/1 ready" { + t.Fatalf("title = %q", ready.Title) + } + + rolling := atlas.DeployNode(atlas.Deploy{Image: "x/cad-atlas:def", Ready: 0, Desired: 1}) + if rolling.Pill != "var(--amber)" { + t.Fatalf("rolling pill = %q, want amber", rolling.Pill) + } +} + func TestHostsFromNodes_ErrorsOnBadJSON(t *testing.T) { if _, err := atlas.HostsFromNodes([]byte("{not json")); err == nil { t.Fatal("expected error on bad JSON, got nil") diff --git a/internal/cluster/client.go b/internal/cluster/client.go index 9355fef..e92ba0c 100644 --- a/internal/cluster/client.go +++ b/internal/cluster/client.go @@ -24,6 +24,11 @@ func Nodes() ([]byte, error) { return get("/api/v1/nodes") } // Namespaces returns the raw /api/v1/namespaces JSON from the in-cluster API server. func Namespaces() ([]byte, error) { return get("/api/v1/namespaces") } +// Deployment returns the raw JSON for the cad-atlas Deployment (its own live state). +func Deployment() ([]byte, error) { + return get("/apis/apps/v1/namespaces/cad-atlas/deployments/cad-atlas") +} + 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 92f9822..f9f21f2 100644 --- a/internal/web/handler.go +++ b/internal/web/handler.go @@ -55,6 +55,14 @@ func NewHandler() http.Handler { } } } + if ld.deploy != nil { + node := atlas.DeployNode(*ld.deploy) + for i := range a.Stages { + if a.Stages[i].Generate == "deploy-state" { + a.Stages[i].Nodes = append([]atlas.Node{node}, a.Stages[i].Nodes...) + } + } + } w.Header().Set("Content-Type", "application/json; charset=utf-8") _ = json.NewEncoder(w).Encode(a) }) @@ -63,9 +71,10 @@ func NewHandler() http.Handler { // liveOverlay holds cluster-sourced substrate facts (node specs + namespace line). type liveOverlayData struct { - hosts []atlas.Host - ns string - run *atlas.RunSummary + hosts []atlas.Host + ns string + run *atlas.RunSummary + deploy *atlas.Deploy } // live cache: query the cluster at most once per TTL; fall back to the authored @@ -102,6 +111,11 @@ func liveOverlay() liveOverlayData { d.run = &r } } + if raw, err := cluster.Deployment(); err == nil { + if dep, err := atlas.DeployState(raw); err == nil { + d.deploy = &dep + } + } liveCache = d return d }