generated from mathias/template-go-web
feat(atlas): Phase C — live CD/deploy state on stage 07
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>
This commit is contained in:
@@ -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":[
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user