9 Commits
Author SHA1 Message Date
mathiasandClaude Opus 4.8 995e428eba feat(atlas): Phase C — recent-runs timeline
CD / Detect unsubstituted template (push) Successful in 0s
CD / Lint / Test / Vet (push) Successful in 4s
CD / Build & Import (push) Successful in 14s
CD / Deploy via GitOps (push) Has been skipped
A strip of the last 12 runs (aggregate pass/fail/running per run) below the
substrate ribbon, coloured, newest-first, run # on hover. Parsed from the same
Gitea /actions/tasks fetch (RecentRuns, test-first; State refactored to share
the aggregate). No new RBAC. Hidden when no live data.

Verified: build/vet/lint(0)/test green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 07:45:59 +02:00
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
mathiasandClaude Opus 4.8 3ff922a4d5 feat(atlas): per-job CI status + build-injected version in the UI
CD / Detect unsubstituted template (push) Successful in 1s
CD / Lint / Test / Vet (push) Successful in 5s
CD / Build & Import (push) Successful in 18s
CD / Deploy via GitOps (push) Has been skipped
Phase C: the CI stage now shows the latest run's per-JOB status (Lint/Test,
Build, Deploy, …), each coloured by outcome, from the Gitea /actions/tasks
per-job entries — replacing the static cd.yml job-id list when live.
LatestRunJobs/RunNodes + RunSummary.State aggregate, all test-first.

Version: injected at build via -ldflags from `git describe --tags` (CI checkout
now fetch-depth:0 + --build-arg), served in /api/atlas.json, shown in the header.
No more hand-maintained version label drifting from the git tag.

Verified: build/vet/lint(0)/test green; version ldflag served correctly.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 07:21:52 +02:00
mathiasandClaude Opus 4.8 02a23a02dd feat(atlas): Phase C increment 1 — live CI runs on the pipeline
CD / Detect unsubstituted template (push) Successful in 1s
CD / Lint / Test / Vet (push) Successful in 6s
CD / Build & Import (push) Successful in 14s
CD / Deploy via GitOps (push) Successful in 0s
The CI stage now shows the pipeline's latest real execution, read live from the
in-cluster Gitea Actions API (public read, no token, gitea-http.gitea.svc):
"▶ run #N · <state>" coloured by outcome, prepended to the generated job list.
LatestRun + RunNode built test-first; cached in the same 30s liveOverlay; falls
back cleanly when Gitea is unreachable.

assessor-loop ledger / session_log deferred: no live CAD data exists for this repo
yet (brain confirms). Gitea run history is the real available trace.

Verified: build/vet/lint(0)/test green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 01:03:03 +02:00
mathiasandClaude Opus 4.8 633ba153f2 feat(atlas): Phase B tail — live namespaces + trim inline fallback
CD / Detect unsubstituted template (push) Successful in 1s
CD / Lint / Test / Vet (push) Successful in 5s
CD / Build & Import (push) Successful in 14s
CD / Deploy via GitOps (push) Successful in 1s
NamespaceSummary (test-first) renders the substrate ns line from live cluster
namespaces (system-filtered, sorted, capped). Handler now overlays both node
specs and the ns line via a single cached liveOverlay(). Removed the inline
SUBSTRATE/STAGES/NS data — /api/atlas.json is the single source; the page shows
an error banner on fetch failure instead of stale data.

GPU model naming intentionally NOT done: koala's node has no GPU product label
(only nvidia.com/gpu count), so "1× GPU" is the API's truth — naming the RTX 5070
would need GPU-feature-discovery, out of scope.

Verified: build/vet/lint(0)/test green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 00:56:40 +02:00
mathiasandClaude Opus 4.8 39fd9b9adc feat(atlas): hybrid substrate — live specs for cluster nodes, authored rest
CD / Detect unsubstituted template (push) Successful in 1s
CD / Lint / Test / Vet (push) Successful in 6s
CD / Build & Import (push) Successful in 14s
CD / Deploy via GitOps (push) Successful in 1s
MergeSubstrate overlays live node specs onto the authored machine list by name:
cluster nodes (koala) get fresh live specs, non-cluster machines (iguana/flamingo/
piguard) stay authored, new live nodes are appended. Test-first. Restores the full
homelab machine list while keeping cluster nodes truthful.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 00:47:53 +02:00
mathiasandClaude Opus 4.8 74dffc1ff3 feat(atlas): Phase B — substrate from the live cluster
CD / Detect unsubstituted template (push) Successful in 1s
CD / Lint / Test / Vet (push) Successful in 5s
CD / Build & Import (push) Successful in 14s
CD / Deploy via GitOps (push) Successful in 0s
The substrate machines are now rendered from the live k3s nodes (arch, cpu,
memory, GPU, k3s version), read in-cluster via the mounted ServiceAccount with a
stdlib HTTP client (no client-go) and a 30s cache; falls back to the authored
substrate whenever the cluster is unreachable. Placement per the homelab decision
"in-cluster only for in-workload ops" — a running web app rendering live data fits.

HostsFromNodes parser built test-first. RBAC (read-only node-reader SA) shipped in
infra k3s/apps/cad-atlas/.

Verified: go build/vet/lint(0)/test green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 00:43:20 +02:00
mathiasandClaude Opus 4.8 a9c72d6ca8 feat(atlas): Phase B — data-driven /api/atlas.json, CI stage generated from cd.yml
CD / Detect unsubstituted template (push) Successful in 0s
CD / Lint / Test / Vet (push) Successful in 5s
CD / Build & Import (push) Successful in 14s
CD / Deploy via GitOps (push) Successful in 1s
The atlas no longer hand-maintains its content. Authored data lives in one place
(internal/atlas/atlas.json); the Go layer overlays sourced facts and serves the
result at /api/atlas.json; the frontend fetches + renders (inline arrays kept only
as an offline fallback). First generated source: the CI/CD stage's nodes are parsed
from the repo's own .gitea/workflows/cd.yml — so the viz shows the pipeline that
actually runs (guard/check/build/deploy), dropping the aspirational var-go/oath-gate
node that isn't wired yet. That's the point: it can't drift from the real pipeline.

New internal/atlas package (JobsFromWorkflow, Build) built test-first. Adds
gopkg.in/yaml.v3 (justified: parsing the workflow YAML; stdlib has no YAML).

Verified: go build/vet/lint(0)/test green; /api/atlas.json → 9 stages, CI = real
jobs; frontend renders from the fetch (screenshot).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 00:34:06 +02:00
mathiasandClaude Opus 4.8 c3fda9b26b docs: wire INFRA_DEPLOY_KEY — CI now auto-deploys (#2)
CD / Detect unsubstituted template (push) Successful in 1s
CD / Lint / Test / Vet (push) Successful in 5s
CD / Build & Import (push) Successful in 12s
CD / Deploy via GitOps (push) Successful in 0s
Deploy job's INFRA_DEPLOY_KEY (write deploy-key cad-atlas-ci on infra) is set;
future main pushes auto-bump the infra image tag → Flux rolls. Also records the
live Authentik-gated endpoint. This commit is itself the first auto-deploy test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 23:57:01 +02:00
22 changed files with 1221 additions and 74 deletions
+5
View File
@@ -62,18 +62,23 @@ jobs:
image-tag: ${{ steps.meta.outputs.sha-tag }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # full history + tags so `git describe` sees the SemVer tag
- name: Derive image tags
id: meta
run: |
SHA=$(git rev-parse --short HEAD)
VERSION=$(git describe --tags --always --dirty)
echo "sha-tag=${SHA}" >> "$GITHUB_OUTPUT"
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
- name: Build and push to local registry
run: |
REGISTRY="localhost:5000"
REF="${REGISTRY}/${{ env.IMAGE }}:${{ steps.meta.outputs.sha-tag }}"
buildah build \
--build-arg VERSION="${{ steps.meta.outputs.version }}" \
--label "org.opencontainers.image.revision=${{ github.sha }}" \
-t ${REF} \
-t ${REGISTRY}/${{ env.IMAGE }}:latest \
+4 -1
View File
@@ -5,7 +5,10 @@ RUN go install github.com/a-h/templ/cmd/templ@latest
COPY go.mod ./
RUN go mod download
COPY . .
RUN templ generate && CGO_ENABLED=0 go build -trimpath -ldflags='-s -w' -o /out/app ./cmd/cad-atlas
ARG VERSION=dev
RUN templ generate && CGO_ENABLED=0 go build -trimpath \
-ldflags="-s -w -X git.d-ma.be/mathias/cad-atlas/internal/version.Value=${VERSION}" \
-o /out/app ./cmd/cad-atlas
FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=build /out/app /app
+4 -3
View File
@@ -28,9 +28,10 @@ Oath holds. Methodology: brain `wiki/homelab/decisions/inception-sprint-and-oath
## Deployment
Live on koala k3s: namespace `cad-atlas`, 1 replica, served via ClusterIP `cad-atlas:80 → :8080`
(manifests in `mathias/infra` `k3s/apps/cad-atlas/`, reconciled by Flux). First deploy was
GitOps-direct; the CI `deploy` job auto-deploys future pushes once `INFRA_DEPLOY_KEY` is set (#2).
Live at **https://atlas.d-ma.be** (Authentik forward-auth — authed users only) on koala k3s:
namespace `cad-atlas`, 1 replica, `cad-atlas:80 → :8080` (manifests in `mathias/infra`
`k3s/apps/cad-atlas/`, reconciled by Flux). CI `deploy` job **auto-deploys** on every main push —
`INFRA_DEPLOY_KEY` (write deploy-key `cad-atlas-ci` on infra) is wired (#2 done).
## The honesty rule
+12
View File
@@ -0,0 +1,12 @@
// Package cadatlas embeds module-root build artifacts that live outside any
// subpackage directory. go:embed cannot reach parent directories, so the CI
// workflow — which the atlas parses to generate its CI/CD stage from the real
// pipeline — is embedded here at the module root.
package cadatlas
import _ "embed"
// CDWorkflow is the repo's own Gitea Actions workflow, embedded at build time.
//
//go:embed .gitea/workflows/cd.yml
var CDWorkflow []byte
+2
View File
@@ -3,3 +3,5 @@ module git.d-ma.be/mathias/cad-atlas
go 1.26
require github.com/a-h/templ v0.3.1020
require gopkg.in/yaml.v3 v3.0.1
+4
View File
@@ -2,3 +2,7 @@ github.com/a-h/templ v0.3.1020 h1:ypAT/L5ySWEnZ6Zft/5yfoWXYYkhFNvEFOeeqecg4tw=
github.com/a-h/templ v0.3.1020/go.mod h1:A2DlK61v+K+NRoGnhmYbNYVmtYHcFO5/AisMvBdDxTM=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+46
View File
@@ -0,0 +1,46 @@
{
"substrate": [
{"n":"koala","k":"RTX 5070 · k3s control-plane · Gitea · LiteLLM :30401 · llama-swap :31234 · searxng"},
{"n":"iguana","k":"M2 Ultra · Ollama / mlx"},
{"n":"flamingo","k":"daily driver · ~/dev"},
{"n":"piguard","k":"NGINX reverse-proxy · ntfy"}
],
"ns": "Tailscale mesh · ns: ai-stack · supervisor(→brain) · gitea-mcp · infra-mcp · council",
"stages": [
{"no":"STAGE 00","title":"Signals","path":"→ mathias/signals","nodes":[
{"t":"Applied AI Radar","d":"Daily Tier-1 + weekly Tier-2 deep pass. Verified-primary bar (paper/benchmark/code/named-lab).","tags":["cron · daily/weekly","→ signals #126+"]},
{"t":"Manual capture","d":"claude.ai strategic drop · brain capture tool.","tags":["ad-hoc"]},
{"t":"Aspirational surfaces","pill":"var(--dim)","d":"Telegram / voice / URL → inbox. NOT built.","tags":["gap"]}
]},
{"no":"STAGE 01","cls":"telos","title":"TELOS","path":"wiki/telos/","nodes":[
{"t":"Intention substrate","pill":"var(--violet)","d":"Mission · goals · problems · strategies · status. Every downstream item traces to a goal.","tags":["brain_query wing=telos"]}
]},
{"no":"STAGE 02","title":"Strategic session","path":"claude.ai frontier + brain MCP","nodes":[
{"t":"Design · ADRs · specs","d":"Human + frontier model. ISC acceptance criteria written here.","tags":["Define / converge"]},
{"t":"🏛️ LLM Council","cls":"council","pill":"var(--violet)","d":"fan-out → anonymous cross-review → chairman synth. glm-4.7-flash · qwen36-35b · gemma4-31b (chair).","tags":["hard strategic Q","chat.d-ma.be"]},
{"t":"Autoresearch Council","cls":"council","pill":"var(--violet)","d":"Sibling pipe — ratifies research before the gate.","tags":["proposed: → standalone svc"]}
]},
{"no":"STAGE 03","title":"Spec → Gitea issue","path":"agent-ready contract","nodes":[
{"t":"Contract enforced","d":"Binary ISC · declared risk tier · reg-risk assessment · no open human deps.","tags":["LOW / MED / HIGH"]},
{"t":"Admission controller","d":"Ed25519-sign issue body at creation (#36). Verify sig + PR alignment at infra boundary.","tags":["chain of custody"]},
{"t":"⚖️ var-go Oath","cls":"oath","pill":"var(--gold)","d":"Acceptance contract embedded in the issue as a var fenced block. Exactly one — zero/multiple fail closed. Prose → typed steps; failures anchored to byte spans.","tags":["swedsl · var-go","defined here → enforced @06"]}
]},
{"no":"STAGE 04","cls":"gate","title":"Human dispatch gate","path":"the only checkpoint","nodes":[
{"t":"Human triggers execution","cls":"gateway","pill":"var(--amber)","d":"Ratify proposed-plan + risk tier, then dispatch.","gate":true},
{"t":"Session-Dispatch bridge","cls":"bridge","pill":"var(--blue)","d":"claude.ai MCP → gitea:workflow_run_trigger → cad-dispatch.yml → agentsquad. The final design→execution bridge.","tags":["workflow_dispatch"]}
]},
{"no":"STAGE 05","cls":"exec","title":"Execute · agentsquad","path":"koala · cmd/agentsquad-serve","nodes":[
{"t":"Task API","pill":"var(--coral)","d":"POST /tasks → job id · GET /tasks/{id}. taskqueue + serve (v0.12+).","tags":["single agentsquad.yaml"]},
{"t":"Executor + reviewer loop","cls":"win","pill":"var(--coral)","d":"ADK Go + LiteLLM. Frontier models (local qwen spirals). Reviewer on distinct tier — echo-chamber prevention.","risk":true},
{"t":"dma-cli · routing + scope","cls":"bridge","pill":"var(--blue)","d":"Harness-config arm: routes agents to the right LLM backend. Three-layer scope policy + confirmation gate = CAD guardrail.","tags":["backend routing","scope guardrail"]},
{"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","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":[
{"t":"Close the loop","pill":"var(--violet)","d":"session_log + attestation → brain. Score deploy outcome vs originating goal. (arc partly manual — improvement target.)","tags":["continuous"]}
]}
]
}
+48
View File
@@ -0,0 +1,48 @@
package atlas_test
import (
"reflect"
"testing"
"git.d-ma.be/mathias/cad-atlas/internal/atlas"
)
func TestBuild_OverlaysCIStageNodesFromWorkflow(t *testing.T) {
atlasJSON := []byte(`{
"substrate": [{"n":"koala","k":"gpu"}],
"stages": [
{"no":"STAGE 06","title":"PR → CI","generate":"ci-jobs","nodes":[]},
{"no":"STAGE 01","title":"TELOS","nodes":[{"t":"Intention substrate"}]}
]}`)
workflow := []byte("jobs:\n check:\n a: 1\n build:\n b: 2\n deploy:\n c: 3\n")
a, err := atlas.Build(atlasJSON, workflow)
if err != nil {
t.Fatalf("Build: %v", err)
}
// CI stage nodes generated from the workflow's jobs, in order.
var titles []string
for _, n := range a.Stages[0].Nodes {
titles = append(titles, n.Title)
}
if want := []string{"check", "build", "deploy"}; !reflect.DeepEqual(titles, want) {
t.Fatalf("CI stage nodes = %v, want %v", titles, want)
}
// Non-generated stage is untouched.
if a.Stages[1].Nodes[0].Title != "Intention substrate" {
t.Fatalf("authored stage was altered: %+v", a.Stages[1])
}
// Substrate preserved.
if len(a.Substrate) != 1 || a.Substrate[0].Name != "koala" {
t.Fatalf("substrate not preserved: %+v", a.Substrate)
}
}
func TestBuild_ErrorsOnBadAtlasJSON(t *testing.T) {
if _, err := atlas.Build([]byte("{not json"), []byte("jobs:\n x:\n a: 1\n")); err == nil {
t.Fatal("expected error on bad atlas JSON, got nil")
}
}
+208
View File
@@ -0,0 +1,208 @@
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
}
// 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
}
+137
View File
@@ -0,0 +1,137 @@
package atlas_test
import (
"reflect"
"strings"
"testing"
"git.d-ma.be/mathias/cad-atlas/internal/atlas"
)
func TestHostsFromNodes_DerivesSpecFromNodeStatus(t *testing.T) {
nodes := []byte(`{"items":[
{"metadata":{"name":"koala"},
"status":{"capacity":{"cpu":"16","memory":"67108864Ki","nvidia.com/gpu":"1"},
"nodeInfo":{"architecture":"amd64","kubeletVersion":"v1.31.4+k3s1"}}},
{"metadata":{"name":"worker2"},
"status":{"capacity":{"cpu":"8","memory":"33554432Ki"},
"nodeInfo":{"architecture":"arm64","kubeletVersion":"v1.30.0+k3s1"}}}
]}`)
hosts, err := atlas.HostsFromNodes(nodes)
if err != nil {
t.Fatalf("HostsFromNodes: %v", err)
}
want := []atlas.Host{
{Name: "koala", Spec: "amd64 · 16 cpu · 64Gi · 1× GPU · k3s v1.31.4"},
{Name: "worker2", Spec: "arm64 · 8 cpu · 32Gi · k3s v1.30.0"},
}
if !reflect.DeepEqual(hosts, want) {
t.Fatalf("hosts = %+v\nwant %+v", hosts, want)
}
}
func TestMergeSubstrate_LiveOverridesAuthoredKeepsRestAppendsNew(t *testing.T) {
authored := []atlas.Host{
{Name: "koala", Spec: "RTX 5070 · authored"},
{Name: "iguana", Spec: "M2 Ultra · Ollama / mlx"},
{Name: "piguard", Spec: "NGINX reverse-proxy · ntfy"},
}
live := []atlas.Host{
{Name: "koala", Spec: "amd64 · 16 cpu · 60Gi · 1× GPU · k3s v1.34.5"},
{Name: "worker9", Spec: "arm64 · 4 cpu"},
}
got := atlas.MergeSubstrate(authored, live)
want := []atlas.Host{
{Name: "koala", Spec: "amd64 · 16 cpu · 60Gi · 1× GPU · k3s v1.34.5"}, // live wins
{Name: "iguana", Spec: "M2 Ultra · Ollama / mlx"}, // authored kept
{Name: "piguard", Spec: "NGINX reverse-proxy · ntfy"}, // authored kept
{Name: "worker9", Spec: "arm64 · 4 cpu"}, // live-only appended
}
if !reflect.DeepEqual(got, want) {
t.Fatalf("merge = %+v\nwant %+v", got, want)
}
}
func TestMergeSubstrate_NoLiveReturnsAuthored(t *testing.T) {
authored := []atlas.Host{{Name: "koala", Spec: "authored"}}
got := atlas.MergeSubstrate(authored, nil)
if !reflect.DeepEqual(got, authored) {
t.Fatalf("merge with no live = %+v, want %+v", got, authored)
}
}
func TestNamespaceSummary_FiltersSystemSortsAndJoins(t *testing.T) {
ns := []byte(`{"items":[
{"metadata":{"name":"gitea"}},
{"metadata":{"name":"kube-system"}},
{"metadata":{"name":"ai-stack"}},
{"metadata":{"name":"default"}},
{"metadata":{"name":"flux-system"}},
{"metadata":{"name":"brain"}}
]}`)
got, err := atlas.NamespaceSummary(ns)
if err != nil {
t.Fatalf("NamespaceSummary: %v", err)
}
if want := "ns: ai-stack · brain · gitea"; got != want {
t.Fatalf("summary = %q, want %q", got, want)
}
}
func TestNamespaceSummary_CapsWithMore(t *testing.T) {
var items []string
for i := 0; i < 15; i++ {
items = append(items, `{"metadata":{"name":"app`+string(rune('a'+i))+`"}}`)
}
ns := []byte(`{"items":[` + strings.Join(items, ",") + `]}`)
got, err := atlas.NamespaceSummary(ns)
if err != nil {
t.Fatalf("NamespaceSummary: %v", err)
}
if !strings.HasSuffix(got, "· +3 more") {
t.Fatalf("expected cap suffix, got %q", got)
}
}
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")
}
}
+15
View File
@@ -0,0 +1,15 @@
package atlas
import _ "embed"
// DataJSON is the authored atlas content (substrate + stage narrative).
// Sourced/generated facts are overlaid onto it at Build time.
//
//go:embed atlas.json
var DataJSON []byte
// Default builds the atlas from the embedded authored data, overlaying the
// given CI workflow (for the stage marked generate:ci-jobs).
func Default(workflow []byte) (Atlas, error) {
return Build(DataJSON, workflow)
}
+68
View File
@@ -0,0 +1,68 @@
package atlas
import (
"encoding/json"
"fmt"
)
// Host is a substrate machine/entry (koala, iguana, …).
type Host struct {
Name string `json:"n"`
Spec string `json:"k"`
}
// Node is a card within a stage.
type Node struct {
Title string `json:"t"`
Desc string `json:"d,omitempty"`
Pill string `json:"pill,omitempty"`
Cls string `json:"cls,omitempty"`
Tags []string `json:"tags,omitempty"`
Risk bool `json:"risk,omitempty"`
Gate bool `json:"gate,omitempty"`
}
// Stage is one column of the pipeline. When Generate is set, its Nodes are
// derived from a source at Build time rather than taken from the authored data.
type Stage struct {
No string `json:"no"`
Title string `json:"title"`
Path string `json:"path,omitempty"`
Cls string `json:"cls,omitempty"`
Generate string `json:"generate,omitempty"`
Nodes []Node `json:"nodes"`
}
// Atlas is the full data model the frontend renders.
type Atlas struct {
Version string `json:"version,omitempty"`
Substrate []Host `json:"substrate"`
NS string `json:"ns,omitempty"`
Timeline []RunDot `json:"timeline,omitempty"`
Stages []Stage `json:"stages"`
}
// Build unmarshals the authored atlas JSON and overlays generated facts from
// real sources, so sourced parts can't drift. Currently: any stage marked
// `"generate":"ci-jobs"` gets its Nodes replaced by the workflow's job list.
func Build(atlasJSON, workflow []byte) (Atlas, error) {
var a Atlas
if err := json.Unmarshal(atlasJSON, &a); err != nil {
return Atlas{}, fmt.Errorf("parse atlas data: %w", err)
}
for i := range a.Stages {
if a.Stages[i].Generate != "ci-jobs" {
continue
}
jobs, err := JobsFromWorkflow(workflow)
if err != nil {
return Atlas{}, fmt.Errorf("stage %s: %w", a.Stages[i].No, err)
}
nodes := make([]Node, 0, len(jobs))
for _, j := range jobs {
nodes = append(nodes, Node{Title: j})
}
a.Stages[i].Nodes = nodes
}
return a, nil
}
+153
View File
@@ -0,0 +1,153 @@
package atlas
import (
"encoding/json"
"fmt"
)
// Job is one job within a workflow run (a Gitea Actions "task").
type Job struct {
Name string
Status string
Conclusion string
}
// State is the effective outcome: conclusion if set, else status.
func (j Job) State() string {
if j.Conclusion != "" {
return j.Conclusion
}
return j.Status
}
// RunSummary is the newest workflow run and its per-job outcomes.
type RunSummary struct {
Number int
SHA string
Title string
Jobs []Job
}
// State aggregates the jobs: failure if any failed, running if any not yet
// succeeded, else success.
func (s RunSummary) State() string {
return aggregateState(s.Jobs)
}
// RunDot is one run's aggregate outcome for the recent-runs timeline.
type RunDot struct {
Number int `json:"number"`
State string `json:"state"`
}
// RecentRuns parses a Gitea `/actions/tasks` response (per-job, newest first)
// into up to n most-recent runs with their aggregate outcome, newest first.
func RecentRuns(tasksJSON []byte, n int) ([]RunDot, error) {
var resp struct {
Tasks []struct {
RunNumber int `json:"run_number"`
Status string `json:"status"`
Conclusion string `json:"conclusion"`
} `json:"workflow_runs"`
}
if err := json.Unmarshal(tasksJSON, &resp); err != nil {
return nil, fmt.Errorf("parse tasks: %w", err)
}
var order []int
jobsByRun := map[int][]Job{}
for _, t := range resp.Tasks {
if _, seen := jobsByRun[t.RunNumber]; !seen {
order = append(order, t.RunNumber)
}
jobsByRun[t.RunNumber] = append(jobsByRun[t.RunNumber], Job{Status: t.Status, Conclusion: t.Conclusion})
}
dots := make([]RunDot, 0, n)
for _, rn := range order {
if len(dots) >= n {
break
}
dots = append(dots, RunDot{Number: rn, State: aggregateState(jobsByRun[rn])})
}
return dots, nil
}
// aggregateState folds per-job outcomes into a run outcome.
func aggregateState(jobs []Job) string {
allSucceeded := true
for _, j := range jobs {
switch j.State() {
case "failure", "cancelled", "error":
return "failure"
case "success":
default:
allSucceeded = false
}
}
if allSucceeded {
return "success"
}
return "running"
}
// LatestRunJobs parses a Gitea `/actions/tasks` response (per-job entries,
// newest first) and returns the newest run with its jobs in pipeline order.
func LatestRunJobs(tasksJSON []byte) (RunSummary, error) {
var resp struct {
Tasks []struct {
RunNumber int `json:"run_number"`
Name string `json:"name"`
Status string `json:"status"`
Conclusion string `json:"conclusion"`
SHA string `json:"head_sha"`
Title string `json:"display_title"`
} `json:"workflow_runs"`
}
if err := json.Unmarshal(tasksJSON, &resp); err != nil {
return RunSummary{}, fmt.Errorf("parse tasks: %w", err)
}
if len(resp.Tasks) == 0 {
return RunSummary{}, fmt.Errorf("no workflow tasks")
}
latest := resp.Tasks[0]
s := RunSummary{Number: latest.RunNumber, SHA: latest.SHA, Title: latest.Title}
for _, t := range resp.Tasks {
if t.RunNumber == latest.RunNumber {
s.Jobs = append(s.Jobs, Job{Name: t.Name, Status: t.Status, Conclusion: t.Conclusion})
}
}
// Gitea lists newest (last-finished) first; reverse to pipeline order.
for i, j := 0, len(s.Jobs)-1; i < j; i, j = i+1, j-1 {
s.Jobs[i], s.Jobs[j] = s.Jobs[j], s.Jobs[i]
}
return s, nil
}
// RunNodes renders a run as stage nodes: a summary node followed by one node
// per job, each coloured by outcome.
func RunNodes(s RunSummary) []Node {
sha := s.SHA
if len(sha) > 7 {
sha = sha[:7]
}
nodes := []Node{{
Title: fmt.Sprintf("▶ run #%d · %s", s.Number, s.State()),
Desc: s.Title,
Pill: statePill(s.State()),
Tags: []string{"live · Gitea Actions", sha},
}}
for _, j := range s.Jobs {
nodes = append(nodes, Node{Title: j.Name, Pill: statePill(j.State())})
}
return nodes
}
func statePill(state string) string {
switch state {
case "success":
return "var(--green)"
case "failure", "cancelled", "error":
return "var(--coral)"
}
return "var(--amber)"
}
+96
View File
@@ -0,0 +1,96 @@
package atlas_test
import (
"testing"
"git.d-ma.be/mathias/cad-atlas/internal/atlas"
)
func TestLatestRunJobs_GroupsNewestRunReversedToPipelineOrder(t *testing.T) {
// Gitea returns tasks newest-first (deploy finished last → appears first).
tasks := []byte(`{"workflow_runs":[
{"run_number":28,"name":"Deploy via GitOps","status":"success","head_sha":"633ba153f26abc","display_title":"feat: x"},
{"run_number":28,"name":"Build & Import","status":"success"},
{"run_number":28,"name":"Lint / Test / Vet","status":"success"},
{"run_number":27,"name":"Deploy via GitOps","status":"failure"}
]}`)
s, err := atlas.LatestRunJobs(tasks)
if err != nil {
t.Fatalf("LatestRunJobs: %v", err)
}
if s.Number != 28 || s.SHA != "633ba153f26abc" || s.Title != "feat: x" {
t.Fatalf("summary = %+v", s)
}
// only run 28's jobs, reversed to pipeline order (Lint → Build → Deploy)
got := []string{}
for _, j := range s.Jobs {
got = append(got, j.Name)
}
want := []string{"Lint / Test / Vet", "Build & Import", "Deploy via GitOps"}
if len(got) != 3 || got[0] != want[0] || got[2] != want[2] {
t.Fatalf("jobs = %v, want %v", got, want)
}
if s.State() != "success" {
t.Fatalf("state = %q, want success", s.State())
}
}
func TestRunSummary_StateFailsIfAnyJobFailed(t *testing.T) {
s := atlas.RunSummary{Jobs: []atlas.Job{
{Status: "success"}, {Status: "completed", Conclusion: "failure"},
}}
if s.State() != "failure" {
t.Fatalf("state = %q, want failure", s.State())
}
}
func TestRunNodes_SummaryThenPerJobColoured(t *testing.T) {
s := atlas.RunSummary{Number: 28, SHA: "633ba153f26", Title: "feat: x", Jobs: []atlas.Job{
{Name: "Lint / Test / Vet", Status: "success"},
{Name: "Deploy via GitOps", Status: "failure"},
}}
nodes := atlas.RunNodes(s)
if len(nodes) != 3 {
t.Fatalf("want 3 nodes (summary + 2 jobs), got %d", len(nodes))
}
if nodes[0].Title != "▶ run #28 · failure" || nodes[0].Pill != "var(--coral)" {
t.Fatalf("summary node = %+v", nodes[0])
}
if nodes[1].Title != "Lint / Test / Vet" || nodes[1].Pill != "var(--green)" {
t.Fatalf("job node 1 = %+v", nodes[1])
}
if nodes[2].Pill != "var(--coral)" {
t.Fatalf("failed job pill = %q", nodes[2].Pill)
}
}
func TestRecentRuns_GroupsRunsNewestFirstWithAggregateState(t *testing.T) {
tasks := []byte(`{"workflow_runs":[
{"run_number":28,"name":"Deploy","status":"success"},
{"run_number":28,"name":"Build","status":"success"},
{"run_number":27,"name":"Deploy","status":"completed","conclusion":"failure"},
{"run_number":26,"name":"Build","status":"running"},
{"run_number":25,"name":"Deploy","status":"success"}
]}`)
dots, err := atlas.RecentRuns(tasks, 3)
if err != nil {
t.Fatalf("RecentRuns: %v", err)
}
if len(dots) != 3 {
t.Fatalf("want 3 dots (capped), got %d: %+v", len(dots), dots)
}
want := []atlas.RunDot{{28, "success"}, {27, "failure"}, {26, "running"}}
for i := range want {
if dots[i] != want[i] {
t.Fatalf("dot[%d] = %+v, want %+v", i, dots[i], want[i])
}
}
}
func TestLatestRunJobs_ErrorsWhenEmpty(t *testing.T) {
if _, err := atlas.LatestRunJobs([]byte(`{"workflow_runs":[]}`)); err == nil {
t.Fatal("expected error on empty, got nil")
}
}
+48
View File
@@ -0,0 +1,48 @@
// Package atlas builds the CAD Atlas data model, deriving parts from real
// sources (the repo's own CI workflow, infra manifests, the live cluster)
// so the visualization can't drift from reality.
package atlas
import (
"fmt"
"gopkg.in/yaml.v3"
)
// JobsFromWorkflow extracts the job names, in document order, from a Gitea
// Actions / GitHub Actions workflow YAML. Used to generate the CI/CD stage of
// the atlas from the pipeline that actually runs, rather than hand-authoring it.
func JobsFromWorkflow(workflow []byte) ([]string, error) {
var doc yaml.Node
if err := yaml.Unmarshal(workflow, &doc); err != nil {
return nil, fmt.Errorf("parse workflow: %w", err)
}
if len(doc.Content) == 0 || doc.Content[0].Kind != yaml.MappingNode {
return nil, fmt.Errorf("workflow: expected a top-level mapping")
}
jobs := mappingValue(doc.Content[0], "jobs")
if jobs == nil {
return nil, fmt.Errorf("workflow: no jobs block")
}
if jobs.Kind != yaml.MappingNode {
return nil, fmt.Errorf("workflow: jobs is not a mapping")
}
// A mapping node stores keys and values as alternating Content entries;
// keys are the even indices, in document order.
names := make([]string, 0, len(jobs.Content)/2)
for i := 0; i+1 < len(jobs.Content); i += 2 {
names = append(names, jobs.Content[i].Value)
}
return names, nil
}
// mappingValue returns the value node for key in a YAML mapping node, or nil.
func mappingValue(m *yaml.Node, key string) *yaml.Node {
for i := 0; i+1 < len(m.Content); i += 2 {
if m.Content[i].Value == key {
return m.Content[i+1]
}
}
return nil
}
+45
View File
@@ -0,0 +1,45 @@
package atlas_test
import (
"reflect"
"testing"
"git.d-ma.be/mathias/cad-atlas/internal/atlas"
)
func TestJobsFromWorkflow_ReturnsJobNamesInDocumentOrder(t *testing.T) {
workflow := []byte(`
name: CD
"on":
push:
branches: [main]
jobs:
guard:
runs-on: self-hosted
check:
needs: guard
runs-on: self-hosted
build:
needs: [guard, check]
runs-on: self-hosted
deploy:
needs: [guard, build]
runs-on: self-hosted
`)
jobs, err := atlas.JobsFromWorkflow(workflow)
if err != nil {
t.Fatalf("JobsFromWorkflow: %v", err)
}
want := []string{"guard", "check", "build", "deploy"}
if !reflect.DeepEqual(jobs, want) {
t.Fatalf("jobs = %v, want %v", jobs, want)
}
}
func TestJobsFromWorkflow_ErrorsOnMalformedYAML(t *testing.T) {
if _, err := atlas.JobsFromWorkflow([]byte("jobs: [this is: not valid")); err == nil {
t.Fatal("expected error on malformed YAML, got nil")
}
}
+74
View File
@@ -0,0 +1,74 @@
// Package cluster reads live k3s state from inside a pod via the Kubernetes
// API, using the mounted ServiceAccount credentials. No client-go: the queries
// are read-only and few, so stdlib net/http keeps the dependency surface small.
package cluster
import (
"crypto/tls"
"crypto/x509"
"fmt"
"io"
"net/http"
"os"
"time"
)
const (
tokenPath = "/var/run/secrets/kubernetes.io/serviceaccount/token" //nolint:gosec // well-known in-cluster path, not a secret literal
caPath = "/var/run/secrets/kubernetes.io/serviceaccount/ca.crt"
)
// Nodes returns the raw /api/v1/nodes JSON from the in-cluster API server.
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 == "" {
return nil, fmt.Errorf("not in-cluster: KUBERNETES_SERVICE_HOST unset")
}
token, err := os.ReadFile(tokenPath)
if err != nil {
return nil, fmt.Errorf("read sa token: %w", err)
}
ca, err := os.ReadFile(caPath)
if err != nil {
return nil, fmt.Errorf("read ca: %w", err)
}
pool := x509.NewCertPool()
if !pool.AppendCertsFromPEM(ca) {
return nil, fmt.Errorf("invalid cluster CA cert")
}
client := &http.Client{
Timeout: 5 * time.Second,
Transport: &http.Transport{
TLSClientConfig: &tls.Config{RootCAs: pool, MinVersion: tls.VersionTLS12},
},
}
req, err := http.NewRequest(http.MethodGet, "https://"+host+":"+port+path, nil)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+string(token))
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer func() { _ = resp.Body.Close() }()
body, err := io.ReadAll(io.LimitReader(resp.Body, 8<<20))
if err != nil {
return nil, err
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("k8s API GET %s: %s", path, resp.Status)
}
return body, nil
}
+39
View File
@@ -0,0 +1,39 @@
// Package gitea reads the repo's own Gitea Actions run history from the
// in-cluster Gitea service (public read — no token), so the atlas can show the
// pipeline's live executions.
package gitea
import (
"fmt"
"io"
"net/http"
"os"
"time"
)
// base is the in-cluster Gitea service by default; override with GITEA_BASE.
func base() string {
if b := os.Getenv("GITEA_BASE"); b != "" {
return b
}
return "http://gitea-http.gitea.svc.cluster.local:3000"
}
// Runs returns the raw /actions/tasks JSON for mathias/cad-atlas (newest first).
func Runs() ([]byte, error) {
url := base() + "/api/v1/repos/mathias/cad-atlas/actions/tasks?limit=50"
client := &http.Client{Timeout: 5 * time.Second}
resp, err := client.Get(url) //nolint:noctx // short-lived, timeout on the client
if err != nil {
return nil, err
}
defer func() { _ = resp.Body.Close() }()
body, err := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
if err != nil {
return nil, err
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("gitea runs: %s", resp.Status)
}
return body, nil
}
+6
View File
@@ -0,0 +1,6 @@
// Package version holds the build version, injected at build time via
// -ldflags "-X .../internal/version.Value=$(git describe --tags --always)".
package version
// Value is the build version. Defaults to "dev" for local/un-injected builds.
var Value = "dev"
+102 -8
View File
@@ -1,20 +1,28 @@
package web
import (
"context"
_ "embed"
"encoding/json"
"net/http"
"sync"
"time"
cadatlas "git.d-ma.be/mathias/cad-atlas"
"git.d-ma.be/mathias/cad-atlas/internal/atlas"
"git.d-ma.be/mathias/cad-atlas/internal/cluster"
"git.d-ma.be/mathias/cad-atlas/internal/gitea"
"git.d-ma.be/mathias/cad-atlas/internal/version"
)
// atlasHTML is the Phase-A static hero visualization. Phase C replaces this
// self-contained file with a Templ view hydrated from live CAD trace data
// (assessor-loop ledger, session_log, Gitea run API, Flux events).
// atlasHTML is the Phase-A/B static shell. It fetches /api/atlas.json at load
// and renders from that data (no inline arrays), so the content is sourced.
//
//go:embed static/cad-atlas.html
var atlasHTML []byte
// NewHandler serves the CAD Atlas. Root ("/") returns the static atlas;
// /api/hello is a leftover template probe kept until Phase C wires real endpoints.
// NewHandler serves the CAD Atlas: the shell at "/", and the sourced data at
// "/api/atlas.json" (authored data + CI stage generated from the real cd.yml +
// substrate overlaid from the live cluster when reachable).
func NewHandler() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
@@ -25,8 +33,94 @@ func NewHandler() http.Handler {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = w.Write(atlasHTML)
})
mux.HandleFunc("/api/hello", func(w http.ResponseWriter, r *http.Request) {
_ = Hello("world").Render(context.Background(), w)
mux.HandleFunc("/api/atlas.json", func(w http.ResponseWriter, r *http.Request) {
a, err := atlas.Default(cadatlas.CDWorkflow)
if err != nil {
http.Error(w, "atlas build failed", http.StatusInternalServerError)
return
}
a.Version = version.Value
ld := liveOverlay()
if len(ld.hosts) > 0 {
a.Substrate = atlas.MergeSubstrate(a.Substrate, ld.hosts)
}
if ld.ns != "" {
a.NS = "Tailscale mesh · " + ld.ns
}
a.Timeline = ld.timeline
if ld.run != nil {
nodes := atlas.RunNodes(*ld.run)
for i := range a.Stages {
if a.Stages[i].Generate == "ci-jobs" {
a.Stages[i].Nodes = nodes
}
}
}
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)
})
return mux
}
// liveOverlay holds cluster-sourced substrate facts (node specs + namespace line).
type liveOverlayData struct {
hosts []atlas.Host
ns string
run *atlas.RunSummary
deploy *atlas.Deploy
timeline []atlas.RunDot
}
// live cache: query the cluster at most once per TTL; fall back to the authored
// substrate/ns (zero values) whenever the cluster is unreachable.
var (
liveMu sync.Mutex
liveCache liveOverlayData
liveAt time.Time
)
const liveTTL = 30 * time.Second
func liveOverlay() liveOverlayData {
liveMu.Lock()
defer liveMu.Unlock()
if !liveAt.IsZero() && time.Since(liveAt) < liveTTL {
return liveCache
}
liveAt = time.Now()
var d liveOverlayData
if raw, err := cluster.Nodes(); err == nil {
if hosts, err := atlas.HostsFromNodes(raw); err == nil {
d.hosts = hosts
}
}
if raw, err := cluster.Namespaces(); err == nil {
if s, err := atlas.NamespaceSummary(raw); err == nil {
d.ns = s
}
}
if raw, err := gitea.Runs(); err == nil {
if r, err := atlas.LatestRunJobs(raw); err == nil {
d.run = &r
}
if dots, err := atlas.RecentRuns(raw, 12); err == nil {
d.timeline = dots
}
}
if raw, err := cluster.Deployment(); err == nil {
if dep, err := atlas.DeployState(raw); err == nil {
d.deploy = &dep
}
}
liveCache = d
return d
}
+40
View File
@@ -1,10 +1,13 @@
package web
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"git.d-ma.be/mathias/cad-atlas/internal/atlas"
)
func TestRootServesAtlas(t *testing.T) {
@@ -27,6 +30,43 @@ func TestRootServesAtlas(t *testing.T) {
}
}
func TestAtlasJSON_ServesAtlasWithGeneratedCIStage(t *testing.T) {
srv := httptest.NewServer(NewHandler())
defer srv.Close()
resp, err := http.Get(srv.URL + "/api/atlas.json")
if err != nil {
t.Fatalf("GET /api/atlas.json: %v", err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d, want 200", resp.StatusCode)
}
var a atlas.Atlas
if err := json.NewDecoder(resp.Body).Decode(&a); err != nil {
t.Fatalf("decode atlas: %v", err)
}
if len(a.Stages) == 0 {
t.Fatal("atlas has no stages")
}
// The generate:ci-jobs stage must be populated from the real cd.yml jobs.
got := map[string]bool{}
for _, s := range a.Stages {
if s.No == "STAGE 06" {
for _, n := range s.Nodes {
got[n.Title] = true
}
}
}
for _, want := range []string{"guard", "check", "build", "deploy"} {
if !got[want] {
t.Fatalf("CI stage missing generated job %q (got %v)", want, got)
}
}
}
func TestUnknownPath404(t *testing.T) {
srv := httptest.NewServer(NewHandler())
defer srv.Close()
+65 -62
View File
@@ -52,6 +52,13 @@
.host .k{color:var(--dim);font-size:11px}
.host.mesh{border-style:dashed;color:var(--dim)}
.tl{display:none;gap:5px;align-items:center;flex-wrap:wrap;
padding:8px 22px;border-bottom:1px solid var(--line);background:var(--panel)}
.tl .lbl{color:var(--dim);font-size:11px;letter-spacing:1.5px;margin-right:4px}
.rundot{width:22px;height:16px;border-radius:4px;border:1px solid rgba(0,0,0,.35);
display:inline-flex;align-items:center;justify-content:center;
font-size:9px;color:#08121f;font-weight:600}
.scroll{overflow-x:auto;padding:24px 22px 20px}
.track{position:relative;display:flex;align-items:flex-start;min-width:max-content}
svg.spine{position:absolute;left:0;top:0;z-index:0;pointer-events:none;overflow:visible}
@@ -114,7 +121,7 @@
<body>
<header>
<h1><b>CAD</b> Atlas · From Signal to Pod</h1>
<span class="sub mono">one human gate · everything up- and downstream is agents · <em>v0.3 static snapshot (→ live in Phase C)</em></span>
<span class="sub mono">one human gate · everything up- and downstream is agents · <em id="ver">dev</em></span>
<div class="controls">
<button id="replay"><span class="dot"></span> Replay</button>
<button id="slowmo">Slow-mo · <span id="slowState">off</span></button>
@@ -122,6 +129,7 @@
</header>
<div class="substrate" id="substrate"><span class="lbl mono">SUBSTRATE</span></div>
<div class="tl mono" id="timeline"></div>
<div class="scroll">
<div class="track" id="track">
@@ -145,69 +153,28 @@
<footer class="mono">
CAD → CI → CD · intent→specify→dispatch · build→test→validate · deploy→ship.
Dashed violet = feedback bus (stage 08 → TELOS: deploy outcome scored vs originating goal).
Data: static inventory from <code>brain</code> (2026-07-19). Phase C swaps these arrays for live reads of
<code>assessor-loop</code> ledger · <code>session_log</code> · Gitea run API · Flux events.
Data served from <code>/api/atlas.json</code> (authored <code>atlas.json</code> + CI stage from the live <code>cd.yml</code> + substrate from the live cluster nodes).
Phase C: <code>Gitea run API</code> live (latest run on the CI stage). Pending: <code>assessor-loop</code> ledger · <code>session_log</code> · per-job status · Flux events.
</footer>
<script>
const SUBSTRATE=[
{n:"koala", k:"RTX 5070 · k3s control-plane · Gitea · LiteLLM :30401 · llama-swap :31234 · searxng"},
{n:"iguana", k:"M2 Ultra · Ollama / mlx"},
{n:"flamingo",k:"daily driver · ~/dev"},
{n:"piguard",k:"NGINX reverse-proxy · ntfy"},
];
const NS="Tailscale mesh · ns: ai-stack · supervisor(→brain) · gitea-mcp · infra-mcp · council";
const STAGES=[
{no:"STAGE 00",cls:"",title:"Signals",path:"→ mathias/signals",nodes:[
{t:"Applied AI Radar",d:"Daily Tier-1 + weekly Tier-2 deep pass. Verified-primary bar (paper/benchmark/code/named-lab).",tags:["cron · daily/weekly","→ signals #126+"]},
{t:"Manual capture",d:"claude.ai strategic drop · brain capture tool.",tags:["ad-hoc"]},
{t:"Aspirational surfaces",pill:"var(--dim)",d:"Telegram / voice / URL → inbox. NOT built.",tags:["gap"]},
]},
{no:"STAGE 01",cls:"telos",title:"TELOS",path:"wiki/telos/",nodes:[
{t:"Intention substrate",pill:"var(--violet)",d:"Mission · goals · problems · strategies · status. Every downstream item traces to a goal.",tags:["brain_query wing=telos"]},
]},
{no:"STAGE 02",cls:"",title:"Strategic session",path:"claude.ai frontier + brain MCP",nodes:[
{t:"Design · ADRs · specs",d:"Human + frontier model. ISC acceptance criteria written here.",tags:["Define / converge"]},
{t:"🏛️ LLM Council",cls:"council",pill:"var(--violet)",d:"fan-out → anonymous cross-review → chairman synth. glm-4.7-flash · qwen36-35b · gemma4-31b (chair).",tags:["hard strategic Q","chat.d-ma.be"]},
{t:"Autoresearch Council",cls:"council",pill:"var(--violet)",d:"Sibling pipe — ratifies research before the gate.",tags:["proposed: → standalone svc"]},
]},
{no:"STAGE 03",cls:"",title:"Spec → Gitea issue",path:"agent-ready contract",nodes:[
{t:"Contract enforced",d:"Binary ISC · declared risk tier · reg-risk assessment · no open human deps.",tags:["LOW / MED / HIGH"]},
{t:"Admission controller",d:"Ed25519-sign issue body at creation (#36). Verify sig + PR alignment at infra boundary.",tags:["chain of custody"]},
{t:"⚖️ var-go Oath",cls:"oath",pill:"var(--gold)",d:"Acceptance contract embedded in the issue as a ```var fenced block. Exactly one — zero/multiple fail closed. Prose → typed steps; failures anchored to byte spans.",tags:["swedsl · var-go","defined here → enforced @06"]},
]},
{no:"STAGE 04",cls:"gate",title:"Human dispatch gate",path:"the only checkpoint",nodes:[
{t:"Human triggers execution",cls:"gateway",pill:"var(--amber)",d:"Ratify proposed-plan + risk tier, then dispatch.",gate:true},
{t:"Session-Dispatch bridge",cls:"bridge",pill:"var(--blue)",d:"claude.ai MCP → gitea:workflow_run_trigger → cad-dispatch.yml → agentsquad. The final design→execution bridge.",tags:["workflow_dispatch"]},
]},
{no:"STAGE 05",cls:"exec",title:"Execute · agentsquad",path:"koala · cmd/agentsquad-serve",nodes:[
{t:"Task API",pill:"var(--coral)",d:"POST /tasks → job id · GET /tasks/{id}. taskqueue + serve (v0.12+).",tags:["single agentsquad.yaml"]},
{t:"Executor + reviewer loop",cls:"win",pill:"var(--coral)",d:"ADK Go + LiteLLM. Frontier models (local qwen spirals). Reviewer on distinct tier — echo-chamber prevention.",risk:true},
{t:"dma-cli · routing + scope",cls:"bridge",pill:"var(--blue)",d:"Harness-config arm: routes agents to the right LLM backend. Three-layer scope policy + confirmation gate = CAD guardrail.",tags:["backend routing","scope guardrail"]},
{t:"assessor-loop ledger",d:"Attestation ledger (audit trail) + brain session_log on completion.",tags:["audit package"]},
]},
{no:"STAGE 06",cls:"",title:"PR → CI",path:"Gitea Actions",nodes:[
{t:"PR + label",d:"Gitea PR · agent-done / agent-blocked label.",tags:[]},
{t:"Mechanical gate",d:"go test · vet · lint · govulncheck. ISC verified mechanically.",tags:["green = proceed"]},
{t:"⚖️ var-go/oath gate",cls:"oath",pill:"var(--gold)",d:"cmd/vargo-gate runs in CI → posts commit status context=var-go/oath. Authoritative FLOOR: failed Oath blocks regardless of reviewer approval (#55 anti-rubber-stamp).",tags:["branch-protection req","enforces @03 Oath"]},
]},
{no:"STAGE 07",cls:"cd",title:"CD → pod",path:"Flux GitOps → k3s",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:[
{t:"Close the loop",pill:"var(--violet)",d:"session_log + attestation → brain. Score deploy outcome vs originating goal. (arc partly manual — improvement target.)",tags:["continuous"]},
]},
];
const sub=document.getElementById('substrate');
SUBSTRATE.forEach(h=>{const el=document.createElement('div');el.className='host';
el.innerHTML=`<b>${h.n}</b><span class="k mono">${h.k}</span>`;sub.appendChild(el);});
const mesh=document.createElement('div');mesh.className='host mesh mono';mesh.textContent=NS;sub.appendChild(mesh);
// Single source of truth: /api/atlas.json (Go app — authored atlas.json + CI
// stage from the live cd.yml + substrate from the live cluster). These start
// empty and are filled by init()'s fetch; on failure the page shows an error
// banner rather than stale inline data.
let SUBSTRATE=[], NS="", STAGES=[], TIMELINE=[];
const track=document.getElementById('track');
const stageEls=[];
STAGES.forEach(s=>{
let stageEls=[];
function renderAtlas(){
const sub=document.getElementById('substrate');
sub.querySelectorAll('.host').forEach(el=>el.remove());
SUBSTRATE.forEach(h=>{const el=document.createElement('div');el.className='host';
el.innerHTML=`<b>${h.n}</b><span class="k mono">${h.k}</span>`;sub.appendChild(el);});
const mesh=document.createElement('div');mesh.className='host mesh mono';mesh.textContent=NS;sub.appendChild(mesh);
stageEls=[];
track.querySelectorAll('.stage').forEach(el=>el.remove());
STAGES.forEach(s=>{
const st=document.createElement('div');st.className='stage '+s.cls;
let h=`<div class="no mono">${s.no}</div><h2>${s.title}</h2><div class="path mono">${s.path||''}</div>`;
s.nodes.forEach(n=>{
@@ -219,7 +186,22 @@ STAGES.forEach(s=>{
h+=`<div class="node ${n.cls||''}">${inner}</div>`;
});
st.innerHTML=h;track.appendChild(st);stageEls.push(st);
});
});
}
function renderTimeline(){
const tl=document.getElementById('timeline');
if(!TIMELINE.length){tl.style.display='none';return;}
tl.innerHTML='<span class="lbl">RECENT RUNS</span>';
TIMELINE.forEach(d=>{
const c=d.state==='success'?'var(--green)':(d.state==='failure'?'var(--coral)':'var(--amber)');
const el=document.createElement('span');
el.className='rundot';el.style.background=c;
el.title='run #'+d.number+' · '+d.state;el.textContent=d.number;
tl.appendChild(el);
});
tl.style.display='flex';
}
/* ---- geometry ---- */
const spine=document.getElementById('spine'), spinePath=document.getElementById('spinePath'),
@@ -265,8 +247,29 @@ document.getElementById('replay').onclick=replay;
document.getElementById('slowmo').onclick=e=>{slow=!slow;e.currentTarget.classList.toggle('on',slow);
document.getElementById('slowState').textContent=slow?'on':'off';replay();};
window.addEventListener('resize',()=>{clearTimeout(window._r);window._r=setTimeout(replay,150);});
window.addEventListener('load',replay);
build();
async function init(){
try{
const r=await fetch('/api/atlas.json');
if(!r.ok) throw new Error('atlas.json '+r.status);
const data=await r.json();
if(Array.isArray(data.substrate)) SUBSTRATE=data.substrate;
if(typeof data.ns==='string') NS=data.ns;
if(Array.isArray(data.stages)) STAGES=data.stages;
if(Array.isArray(data.timeline)) TIMELINE=data.timeline;
if(data.version) document.getElementById('ver').textContent=data.version;
}catch(e){
console.error('atlas: failed to load /api/atlas.json —',e);
document.getElementById('track').insertAdjacentHTML('beforeend',
'<div class="stage"><div class="no mono">ERROR</div>'+
'<h2 style="color:var(--coral)">Data unavailable</h2>'+
'<div class="path mono">/api/atlas.json failed to load</div></div>');
return;
}
renderAtlas();
renderTimeline();
replay();
}
window.addEventListener('load',init);
</script>
</body>
</html>