1 Commits
Author SHA1 Message Date
mathiasandClaude Opus 4.8 3d1f76997b feat(atlas): Phase C — live CD/deploy state on stage 07
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
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
5 changed files with 115 additions and 4 deletions
+1 -1
View File
@@ -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":[
+60
View File
@@ -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).
+32
View File
@@ -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")
+5
View File
@@ -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 == "" {
+17 -3
View File
@@ -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
}