Files
riotpiao.com/docs/PLAN-atlas.md
T

328 lines
17 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Implementation Plan: `atlas` — Interactive Cluster Visualization
**Status**: For review — no code written yet
**Companion ADR**: [ADR-0001](adr/ADR-0001-atlas-cluster-visualization.md)
**Supersedes**: `PLAN.md`, `IMPLEMENTATION.md` (Homarr + Terraform — abandoned)
---
## 1. Scope
### In scope
| ID | Surface | Description |
|---|---|---|
| B | Cluster topology | Live node → namespace → workload graph, health-colored, drill-down |
| E | Delivery tree | Argo CD app-of-apps as a sync-wave-ordered DAG, live sync animation |
| C | Terminal | Read-only, enum-dispatched cluster queries in the browser |
| D | Chat | Streaming ChatGPT-style session against the `reasoning` model |
### Out of scope (v1)
- Any write operation against the cluster
- LLM tool-calling / agentic loops against live infrastructure
- Forgejo CI half of the pipeline view (`forgejo-gitea` is currently stuck `Init:0/3`)
- Log streaming to the browser (Loki content is unredactable in practice)
- Authenticated / operator-only views — Grafana already serves that need
### Non-goals
- Replacing Grafana
- Multi-cluster support
- Historical / time-travel views
---
## 2. Phase 0 — Unblock (blocking; nothing ships until done)
Verified problems, in the order they must be fixed:
| # | Problem | Evidence |
|---|---|---|
| 0.1 | `portfolio` namespace is empty; `portfolio` and `auth-infra` Applications do not exist in the cluster | `kubectl get pods -n portfolio` → no resources; neither name appears in `kubectl get app -n argocd` |
| 0.2 | `infra/argocd-apps.yaml` `repoURL` = `forgejo.riotpiao.homelab.com` — NXDOMAIN | `dig` |
| 0.3 | Deployment image `forgejo.riotpiao.homelab.com/rock/portfolio:latest` — dead host, and `:latest` + `imagePullPolicy: IfNotPresent` means a pushed image will never roll out | `infra/portfolio/base/deployment.yaml` |
| 0.4 | `forgejo-gitea` stuck `Init:0/3` for 3h — no image builds possible | `kubectl get pods -n cicd` |
| 0.5 | `riotpiao.com` returns HTTP 403 from the Cloudflare edge; no origin headers present | `curl -I https://riotpiao.com` |
| 0.6 | No test framework installed — TDD is impossible as the repo stands | `package.json` has no test script or runner |
| 0.7 | `sms` Application Degraded (`macos-bluebubbles` Pending 3h); `longhorn-config` OutOfSync | `kubectl get app -n argocd` |
**Decisions required from you before 0.10.3 can be actioned** — see ADR open questions 1 and 2.
**Actions**
1. Resolve which GitOps repo owns the portfolio; delete or correct the losing manifest
2. Point `repoURL` and the image reference at real hostnames
3. Replace `:latest` with a commit-SHA tag; set `imagePullPolicy: IfNotPresent` (correct once tags are immutable)
4. Diagnose `forgejo-gitea` init containers — read the actual init container logs before changing anything
5. Diagnose the apex 403 — check the tunnel's Public Hostnames list and Cloudflare WAF events; the event log names the blocking rule
6. Add Vitest + Testing Library + `msw`; add `test` and `test:watch` scripts
7. Triage 0.7 separately — unrelated to this work, but the delivery tree will render both as red on day one
**Verify**
```bash
kubectl get pods -n portfolio # 2/2 Running
curl -sS -o /dev/null -w '%{http_code}\n' https://riotpiao.com # 200
pnpm test # runner executes, 0 tests, exit 0
```
---
## 3. Architecture
```
kube API (client-go informers) ─┐
Prometheus /api/v1/query ├──> atlas (Go, ns: portfolio, RO ServiceAccount)
Argo CD Application CRs ┘ │
├─ snapshot (in-memory, redacted at write)
├─ Redis pub/sub (kmsvc-redis-master.sqs:6379)
└─ HTTP
GET /api/topology
GET /api/delivery
GET /api/stream (SSE)
POST /api/exec
POST /api/chat (SSE)
└──> reasoning-predictor.llm-serving:80
```
Event-driven, per project architectural preference: informers push to a reducer, the reducer publishes deltas to Redis, SSE handlers subscribe. No request-triggered upstream calls anywhere in the read path.
Snapshot is redacted **at write time**, not at serialization time. A field that never enters the snapshot cannot leak from any surface.
### Language
Go, for `client-go` informers and because it matches the rest of the platform. Follow the repo's Go skill set (`go-naming`, `go-concurrency`, `go-error-handling`, `go-context`) — notably: every upstream call carries a context, no naked returns, no `_ =` on errors.
---
## 4. API contract
Envelope for all non-stream responses:
```json
{ "data": { }, "meta": { "snapshotAge": 3.2, "generation": 88412 } }
```
Errors follow RFC 9457 (`application/problem+json`):
```json
{ "type": "https://riotpiao.com/errors/rate-limited",
"title": "Rate limit exceeded",
"status": 429, "detail": "12 of 12 messages used", "retryAfter": 3600 }
```
| Method | Path | Auth | Limit | Response |
|---|---|---|---|---|
| GET | `/api/topology` | none | 60/min/IP | Nodes, namespaces, workload summaries |
| GET | `/api/delivery` | none | 60/min/IP | Argo apps, wave-grouped; resource children lazy |
| GET | `/api/delivery/{app}/resources` | none | 60/min/IP | Virtualized child list for one app |
| GET | `/api/stream` | session cookie | 2 concurrent/IP | SSE deltas, `topology` + `delivery` event types |
| POST | `/api/exec` | session cookie | 20/min/session | Enum command result |
| POST | `/api/chat` | session + Turnstile | 12/day/session, 6 global concurrent | SSE token stream |
**Pagination**: `/api/delivery/{app}/resources` is cursor-paginated at 100 items. `prometheus` has 68 resources today, but `homelab-root`'s tree will grow.
**Payload budget**: topology response capped at 256 KB, delivery at 256 KB. Exceeding the cap truncates and sets `meta.truncated: true` — never a silent drop.
---
## 5. Security
Mapped against OWASP Top 10 (2021). Every item is a Phase gate, not a wish list.
### A01 Broken Access Control
- `atlas` ServiceAccount: one ClusterRole, verbs `get,list,watch` only, explicit resource list. **No `secrets`. No `*`. No wildcards on apiGroups.**
- NetworkPolicy on `atlas`: egress restricted to kube API, `prometheus-operated.monitoring`, `reasoning-predictor.llm-serving`, `kmsvc-redis-master.sqs`. Ingress from `ingress-nginx` only.
- Test: an integration test asserting the SA receives 403 on `get secrets` in every namespace.
### A02 Cryptographic Failures
- Session cookie: signed (HMAC), `HttpOnly`, `Secure`, `SameSite=Lax`, 24h expiry. No PII in the payload — a random session ID only.
- Signing key from a Kubernetes Secret via SOPS (`sops-secrets` app already exists), never an env literal in a manifest.
### A03 Injection
The primary risk on surface C. Mitigation is structural, not filtering:
- Input parses to a closed command enum. Anything unmatched is rejected before any lookup.
- Namespace and resource-name arguments are validated by **set membership against the current snapshot**, not by regex or escaping.
- No shell, no `exec`, no `kubectl` binary present in the container image.
- Test: fuzz the parser; assert every input outside the allowlist returns a rejection and performs zero upstream calls.
### A04 Insecure Design — information disclosure
The core risk of the whole project. Redaction allowlist, enforced by DTO construction:
**Emitted**: name, namespace, kind, phase, ready counts, restart count, age, node name, health status, sync status, sync wave, an explicit label subset.
**Never emitted**: container env, container args, image digests, image tags, `spec.source.repoURL`, full `spec.source.path`, annotations, pod IPs, cluster IPs, Secret names, `status.conditions[].message`, node internal IPs.
Specific known leaks in current data:
- `reasoning` container args disclose the entire model and quantization strategy
- `spec.source.repoURL` discloses a private GitHub repository
- 21 `Secret` resources appear in Argo trees — render **kind and count only, never names**; `sops-secrets` included
- `status.conditions[].message` echoes raw errors containing internal hostnames — emit condition **type** only
Test: golden test asserting the serialized snapshot contains none of the denied field names, run against a fixture captured from the real cluster.
### A05 Security Misconfiguration
- Container: `runAsNonRoot`, read-only root filesystem, all capabilities dropped, `seccompProfile: RuntimeDefault`
- Security headers on all responses: `Content-Security-Policy` (no `unsafe-inline`), `X-Content-Type-Options: nosniff`, `Referrer-Policy: no-referrer`, `Strict-Transport-Security`
- CORS: same-origin only. No wildcard.
### A07 Authentication Failures
- Anonymous by design; Cloudflare Turnstile gates the first chat message
- Session rotation on issue; no session fixation vector since there is no login
### A08 Software and Data Integrity
- Image tags are commit SHAs, never `:latest` (fixes Phase 0.3)
- `pnpm audit` and `govulncheck` in CI, failing the build on high severity
### A09 Logging and Monitoring
- Structured logs: every rejected `/api/exec` input, every rate-limit trip, every chat queue rejection
- Prometheus metrics from `atlas`: `atlas_chat_concurrent`, `atlas_chat_queue_depth`, `atlas_ratelimit_rejections_total`, `atlas_snapshot_age_seconds`
- Alertmanager rule: chat queue saturated > 5 min, snapshot age > 60s
### A10 SSRF
- `atlas` calls a fixed, compile-time list of upstream URLs. No user input reaches any URL construction, on any path.
### LLM-specific: prompt injection
- System prompt is a compile-time constant, unreachable by user input
- Cluster snapshot digest is injected in a delimited block explicitly labelled untrusted data
- User message is always last
- **No tool-calling.** The model reads a pre-built digest and cannot query anything. This removes the entire agentic attack surface for v1.
- Output capped at `max_tokens: 1500` — DeepSeek-R1 will otherwise reason for minutes
---
## 6. Rate limiting
Sized against the verified hard ceiling: **8 concurrent sequences** (`--max-num-seqs=4` × 2 replicas), single GPU node.
**Tier 1 — Cloudflare edge.** WAF, Bot Fight Mode, per-IP rate rules, Turnstile before first chat message. Free, and stops scripted abuse before it reaches your hardware.
**Tier 2 — Kong.** `rate-limiting` plugin, `policy: redis` against `kmsvc-redis-master.sqs:6379` so counters are cluster-wide rather than per-pod (Kong runs 2 replicas — a local policy would silently double every limit). Two profiles: generous for topology and delivery, tight for chat.
**Tier 3 — `atlas`, the tier that actually protects the GPU.**
| Control | Value | Rationale |
|---|---|---|
| Global chat semaphore | 6 | Leaves 2 of 8 sequence slots as operator headroom |
| Queue depth | 20, then reject with `429` | A visible queue beats a wall; an unbounded queue beats nothing |
| Per-session budget | 12 messages / 24h | Enough to explore, not enough to farm |
| Per-request timeout | 120s hard, server-side | Independent of client behaviour |
| Disconnect handling | cancel upstream immediately | **Critical** — a walked-away tab holding 1 of 8 slots is a real outage |
Queue position is streamed to the client as SSE `{"type":"queue","position":N}` events, so waiting is legible rather than a hang.
---
## 7. Streaming
SSE throughout — unidirectional server→client fits every surface, including token streaming. WebSockets are not justified; nothing flows client→server mid-stream.
- Keepalive comment frame every 30s (idle SSE connections die at proxies)
- `Last-Event-ID` supported on `/api/stream` for resumable topology/delivery deltas; chat is not resumable
- `req.Context()` threaded to the upstream vLLM request so client abort cancels it — this is the mechanism that enforces the Tier 3 disconnect rule
- Backpressure: bounded per-client channel; a slow consumer is dropped rather than allowed to grow memory
- Chat events: `{"type":"reasoning"|"content"|"queue"|"done"|"error"}`. `--reasoning-parser=deepseek_r1` already separates `reasoning_content` from `content` — render thinking in a collapsible block. That block **is** the demo.
**Context budget** (16384 total): system prompt ~300, snapshot digest capped at 2000, `max_tokens` 1500, leaving ~12500 for history. History is truncated oldest-first to fit. The digest is a compact rendering, never raw JSON.
---
## 8. Frontend
- **Topology (B)**: React Flow, force layout. Node → namespace → workload.
- **Delivery (E)**: React Flow, wave columns left→right from the existing `sync-wave` annotations (0→8). 31 app nodes — an ideal size for a readable DAG. Click an app → side panel with a `react-arborist` virtualized resource tree, children lazy-loaded. Live sync animation `OutOfSync → Syncing → Synced` driven by the Application watch. Push a commit during a demo and the wave cascades — that is the moment worth engineering for.
- **Terminal (C)**: reuse [components/InteractiveTerminal.tsx](../components/InteractiveTerminal.tsx). Command set: `get nodes`, `get pods <ns>`, `get apps`, `top nodes`, `describe pod <ns> <name>`, `help`.
- **Chat (D)**: new component. Collapsible reasoning block, queue position, streaming tokens.
Rendering budget: ~550 resources total across the tree. Lazy expansion plus virtualization is required, not optional. Target: initial delivery view interactive in < 1.5s on a cold load.
**Also in scope**: delete the fabricated statistics in [app/page.tsx](../app/page.tsx) — "40% CPU reduction", "99.2% uptime", "Mission-critical", "60% latency cut" — and either wire each card to a real number from `/api/topology` or remove the claim. Five of the six cards link to routes that do not exist (`/infrastructure`, `/systems`, `/llm`, `/kafka`, `/opensource`).
---
## 9. Phases, TDD-first
Each phase is RED → GREEN → REFACTOR. Tests named before implementation exists.
### Phase 1 — `atlas` core (~4 days)
RED
- `redact_test.go`: golden test — serialized snapshot contains no denied field, against a real-cluster fixture
- `rbac_test.go`: SA receives 403 on `get secrets`
- `snapshot_test.go`: informer event produces the expected delta
GREEN: ClusterRole, informers, reducer, DTO construction, Redis publish.
Verify: `kubectl auth can-i get secrets --as=system:serviceaccount:portfolio:atlas``no`.
### Phase 2 — Surface B (~3 days)
RED
- `stream_test.go`: SSE emits a delta within 5s of a pod state change
- `topology.test.tsx`: graph re-renders on delta without a full reload
Verify: delete a pod, observe the graph update in < 5s without reloading.
### Phase 3 — Surface E (~3 days)
RED
- `delivery_test.go`: apps group correctly by `sync-wave`; Secret names absent from output; `repoURL` absent from output
- `delivery.test.tsx`: 550-node tree renders under the frame budget with virtualization on
Verify: trigger an Argo sync, observe wave-ordered animation.
### Phase 4 — Surface C (~2 days)
RED
- `exec_parse_test.go`: fuzz corpus — every non-allowlisted input rejects and performs zero upstream calls
- `exec_test.go`: unknown namespace rejects on snapshot membership, not regex
Verify: attempt injection payloads against `/api/exec`; all rejected, all logged.
### Phase 5 — Surface D + rate limiter, one PR (~5 days)
RED
- `ratelimit_test.go`: 7th concurrent chat queues rather than reaching vLLM
- `disconnect_test.go`: client abort cancels the upstream request
- `budget_test.go`: 13th message in 24h returns 429 with `Retry-After`
- `injection_test.go`: snapshot content cannot alter system-prompt behaviour
- `context_test.go`: history truncation keeps total tokens under 16384
Verify: load test at 20 concurrent clients — GPU sequence usage never exceeds 6, no upstream 5xx, queue drains.
**Total: ~17 working days.** Rate limiter ships in the same PR as chat, never after.
---
## 10. Risks
| Risk | Impact | Mitigation |
|---|---|---|
| Redaction miss leaks internal detail | High | Allowlist by DTO construction; golden test; manual review of every new field |
| `worker-1` fails | Chat and all inference down | Out of scope to fix; degrade chat gracefully to "offline", never a hang |
| 8-slot ceiling makes chat feel broken under traffic | Medium | Visible queue position; consider disabling chat and shipping B/E/C only |
| Argo CD CRD schema changes | Low | Allowlist construction means new fields are ignored by default |
| `atlas` compromised | High | Read-only SA, NetworkPolicy egress restriction, no write verbs anywhere |
| Cost of GPU inference for anonymous visitors | Medium | Per-session daily budget; Turnstile; edge WAF |
---
## 11. Decisions needed before Phase 1
1. **Which GitOps repo owns the portfolio** — GitHub (`riotpiao.homelab.com`) or Forgejo? Blocks 0.10.3.
2. **Apex 403 cause** — tunnel route, WAF rule, or no origin? Blocks 0.5.
3. **Is `homarr` still wanted?** Deployed and healthy, but from the abandoned plan.
4. **Does chat stay in v1?** Given the 8-slot ceiling, shipping B + E + C first and treating D as a separate decision is defensible.
5. **Where does `atlas` live** — this repo, or the homelab repo? Follows from decision 1.