Skip to content

Commit 2d2ce91

Browse files
committed
Second half of the kata config agent changes
Signed-off-by: Erick Bourgeois <erick@jeb.ca>
1 parent 50f14d6 commit 2d2ce91

40 files changed

Lines changed: 2799 additions & 329 deletions

.claude/CHANGELOG.md

Lines changed: 211 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,217 @@ The format is based on the regulated environment requirements:
99

1010
---
1111

12+
## [2026-06-10 11:30] - ADR 0005: remove spec.kata.destPath — fixed host path, H-1 gate closed
13+
14+
**Author:** Erick Bourgeois
15+
16+
### Changed
17+
- `docs/adr/0005-remove-kata-destpath-fixed-host-path.md`: NEW — remove the
18+
user-configurable `destPath` entirely rather than allowlist it; the drop-in
19+
destination is the compile-time constant `/etc/k0s/containerd.d/kata.toml`
20+
(the real k0s containerd import dir per docs.k0sproject.io/stable/runtime —
21+
the previous `container.d` default was a typo k0s never read). Index updated.
22+
- `src/crd.rs`: `KataConfig.dest_path` field, its schema fn, and its default
23+
removed; `deny_unknown_fields` makes a lingering `destPath` a hard admission
24+
error. Spec docs updated.
25+
- `src/constants.rs`: new `KATA_CONFIG_DEST_PATH` + `KATA_CONFIG_DEST_BASE`;
26+
`KATA_CONFIG_APPLIED_ANNOTATION` doc rewritten (bare hash, no path).
27+
- `src/kata_config_agent.rs`: NEW `confine_dest_path` — fail-closed
28+
defense-in-depth resolver (`/etc/k0s/` slash-aware prefix + `.toml` suffix,
29+
lexical `..`/`.`/empty-segment rejection, `canonicalize(parent)` must stay
30+
under the canonicalized base — defeats symlinked-directory escapes) run
31+
before EVERY host write and tear-down unlink; `KataRef.dest_path` removed
32+
(legacy annotations with the field still parse, path ignored);
33+
`AppliedRecord`/`parse_applied_record` deleted — the applied annotation is a
34+
bare content hash a forged value cannot weaponize.
35+
- `src/bin/kata_config_agent.rs`: deliver + tear-down both target the fixed
36+
constant through `confine_dest_path`; tear-down no longer needs a recorded
37+
path (always reconciles the fixed path to absent); `host_path` helper gone.
38+
- `src/reconcilers/helpers.rs`: ref-annotation builder no longer emits
39+
`destPath` — no Node annotation carries a host path.
40+
- `deploy/kata-config-agent/daemonset.yaml`: hostPath mount narrowed from `/`
41+
to `/etc/k0s` (`type: Directory` fail-fast); comments rewritten.
42+
- Tests (all four suites): fixtures drop `dest_path`; new coverage — schema has
43+
no destPath property, serde rejects a lingering destPath, legacy-annotation
44+
tolerance, 10 `confine_dest_path` tests (traversal, symlink escape, prefix
45+
sibling `/etc/k0s.evil`, non-toml, relative, curdir), ref-annotation
46+
carries-no-path assertions. CRDs + api.md regenerated (crddoc kata section
47+
rewritten).
48+
- `src/labels.rs``src/labels_tests.rs`: inline `mod tests` body moved out —
49+
the last violation of the separate-`_tests.rs` rule; `.claude/CLAUDE.md`
50+
hardened to state the rule is exception-free for ALL targets including
51+
`src/bin/*.rs`, with only the three-line `#[path]` declaration allowed in
52+
source files.
53+
- Docs: concepts page (annotation-contract table, diagrams, security table,
54+
ruled-out list), operator guide (fixed-path prereqs/steps/troubleshooting),
55+
scheduled-machine spec row, example manifest — all rewritten for the fixed
56+
path; `docs/architecture/calm/architecture.json` `host-path-containment`
57+
control + agent/flow descriptions updated (`make calm-validate` clean,
58+
diagrams re-rendered).
59+
60+
### Why
61+
Closes the H-1 latent-High gate from the 2026-06-09 security roadmap, stronger
62+
than originally specified: with the agent running privileged on the host, a
63+
CRD-supplied path was a node-root-via-CRD vector (write any host file, unlink
64+
any host file via a forged applied annotation). Per operator decision the
65+
capability is removed, not validated — no path-shaped attacker input exists in
66+
the contract at all, and the writable host surface shrinks to /etc/k0s.
67+
68+
### Impact
69+
- [x] Breaking change (pre-release: any SM setting kata.destPath now fails admission)
70+
- [ ] Requires cluster rollout
71+
- [ ] Config change only
72+
- [ ] Documentation only
73+
74+
## [2026-06-10 09:30] - Agent pod-security exception boundary VAP (ADR 0004)
75+
76+
**Author:** Erick Bourgeois
77+
78+
### Changed
79+
- `docs/adr/0004-agent-pod-security-exception-boundary-vap.md`: new ADR — the
80+
privileged node agents need a PSA/Gatekeeper/Kyverno exemption for
81+
`5spot-system` on the workload cluster; since Kubernetes admission is
82+
conjunctive (no VAP can override another engine's deny), the exemption lives
83+
in the baseline engine and we ship a deny-by-default compensating VAP behind
84+
it. Index updated in `docs/adr/README.md`.
85+
- `deploy/admission/agent-pod-security-policy.yaml`: new `ValidatingAdmissionPolicy`
86+
(`5spot-agent-pod-security`, k8s ≥ 1.30, `failurePolicy: Fail`) — identity-pinned
87+
allowlist over pods in `5spot-system`: `hostPID`/`hostPath`/explicit-root
88+
restricted to the two agent ServiceAccounts; `privileged` to the kata agent only;
89+
hostPath clamped per agent (kata: `/`; reclaim: `/proc`, `/etc/machine-id`);
90+
capability adds clamped to `NET_ADMIN` on the reclaim agent;
91+
`hostNetwork`/`hostIPC` denied for everyone; compensating controls
92+
(readOnlyRootFilesystem on privileged containers, pod seccomp RuntimeDefault)
93+
made mandatory; ephemeral containers may never be risky. Header documents the
94+
PSA/Gatekeeper/Kyverno exemption recipes this policy pairs with.
95+
- `deploy/admission/agent-pod-security-binding.yaml`: binding scoped to
96+
`5spot-system` via `namespaceSelector`, `validationActions: [Deny]`.
97+
- `docs/architecture/calm/architecture.json`: `agent-pod-security-boundary`
98+
control added to the workload-cluster API server node (NIST AC-6/CM-5);
99+
`make calm-validate` clean, diagrams re-rendered.
100+
- `docs/src/security/admission-validation.md`: new "Agent Pod-Security Exception
101+
Boundary" section (posture table, conjunctive-admission explanation, per-engine
102+
exemption recipes); deployment list/commands extended to the fourth policy pair.
103+
- `docs/src/security/threat-model.md`: new threat E5 (abuse of the namespace-wide
104+
exemption) marked mitigated by ADR 0004; mitigations-summary row added.
105+
106+
### Why
107+
The agents' privileged posture (ADR 0003) cannot pass a pod-security baseline,
108+
and the only exemption mechanisms those engines offer are namespace-wide. An
109+
unguarded exemption would let anything in `5spot-system` run privileged. The
110+
paired deny-by-default VAP makes the "only our two agents, only their exact
111+
posture" claim machine-enforced and auditable. Policy logic verified by
112+
simulating all 12 CEL validations against the real DaemonSet pod templates
113+
(admitted) and 9 rogue/escalation scenarios (each denied by the intended rule).
114+
115+
### Impact
116+
- [ ] Breaking change
117+
- [ ] Requires cluster rollout
118+
- [x] Config change only (new admission manifests; operator applies with the
119+
baseline-engine exemption as a paired deployment)
120+
- [ ] Documentation only
121+
122+
## [2026-06-09 21:30] - Kata config delivery Phase 5 — agent metrics, integration test, docs, security pass
123+
124+
**Author:** Erick Bourgeois
125+
126+
### Changed
127+
- `src/metrics.rs`: six new agent metrics — `fivespot_kata_config_writes_total`,
128+
`…_deletes_total`, `…_drift_corrected_total`, `…_restarts_total`,
129+
`…_sync_errors_total`, and the `…_last_sync_timestamp_seconds` staleness gauge —
130+
plus their recorder fns and a shared `serve_metrics(port)` (the controller's
131+
private metrics server moved here so both binaries serve one implementation).
132+
- `src/metrics_tests.rs`: 6 new tests covering every recorder (counter increments,
133+
drift/write coupling, unchanged-tick gauge-only semantics), serialized on a
134+
lock because the kata counters are label-less process-globals.
135+
- `src/kata_config_agent.rs` + `_tests.rs`: new pure `is_drift_correction`
136+
classifier (a rewrite of already-applied content is drift, not a rollout) + 4 tests.
137+
- `src/bin/kata_config_agent.rs`: `--metrics-port` (env `METRICS_PORT`, default
138+
8080), metrics server spawned in loop mode, and recording wired into
139+
`reconcile_once` (write/delete/unchanged/restart) and the retry path (errors).
140+
- `src/main.rs`: `run_metrics_server` replaced by `metrics::serve_metrics`.
141+
- `tests/integration_kata_config.rs`: NEW — pins the ADR 0002 annotation contract
142+
(controller patch builder ↔ agent parser, field-for-field), runs the full
143+
host-file lifecycle (provision → restart-once → drift-heal-without-restart →
144+
rollout-restart → tear-down) against a tempdir with a counting executor, and
145+
adds an `--ignored` real-Node round-trip mirroring `integration_emergency_reclaim.rs`.
146+
- `src/reconcilers/mod.rs`: re-export the two kata patch builders for the test.
147+
- `deploy/kata-config-agent/daemonset.yaml`: `prometheus.io/*` scrape annotations,
148+
named `metrics` containerPort 8080, `METRICS_PORT` env.
149+
- `deploy/kata-config-agent/kustomization.yaml`: NEW — `kubectl apply -k` entry
150+
point mirroring `deploy/node-agent/`.
151+
- `examples/scheduledmachine-kata.yaml`: NEW — full `spec.kata` example with the
152+
source-ConfigMap shape in a comment.
153+
- `.trivyignore`: justify KSV-0001 (allowPrivilegeEscalation cannot be false with
154+
privileged: true — API server rejects the combination) and KSV-0003/0004
155+
(drop: [ALL] is a kernel no-op under privileged); `trivy config deploy/` now 0 findings.
156+
- Docs: NEW `docs/src/concepts/kata-config-delivery.md` (architecture, mermaid
157+
delivery chain + sequence, restart-guard table, security posture) and NEW
158+
`docs/src/guides/kata-config.md` (operator walkthrough + troubleshooting);
159+
`spec.kata` row in `concepts/scheduled-machine.md`; agent metrics table in
160+
`operations/monitoring.md`; nav entries (new Guides section) in `mkdocs.yml`;
161+
feature bullet in `README.md`.
162+
- Stale-comment sweep: `src/bin/crddoc.rs` emitted the pre-pivot `kataConfigRef:`
163+
field name into the generated API example — fixed and `make crddoc` re-run;
164+
pre-pivot "per-node ConfigMap projection" comments rewritten in
165+
`src/constants.rs`, `src/reconcilers/helpers.rs`, `src/reconcilers/scheduled_machine.rs`.
166+
- `docs/architecture/calm/architecture.json`: agent node gains its `/metrics`
167+
HTTP interface; least-privilege control text corrected to get-only
168+
ConfigMap/Secret RBAC (KSV-0113); data-asset wording aligned to the per-tick
169+
GET (no watcher). `make calm-validate` clean, diagrams re-rendered.
170+
171+
### Why
172+
Closes Phase 5 (final phase) of the per-node Kata config delivery roadmap:
173+
observability for the node agent, an integration test pinning the single
174+
load-bearing controller↔agent contract, user-facing concept + operator docs,
175+
and a clean security scan with every privileged-posture finding justified.
176+
177+
### Impact
178+
- [ ] Breaking change
179+
- [ ] Requires cluster rollout
180+
- [ ] Config change only
181+
- [x] Documentation only (agent/manifests not yet released)
182+
183+
## [2026-06-09 19:30] - Kata config delivery Phase 4 — nsenter host k0s-service restart
184+
185+
**Author:** Erick Bourgeois
186+
187+
### Changed
188+
- `src/kata_config_agent.rs`: add the Phase 4 restart-orchestration units (ADR
189+
0003)`nsenter_restart_argv` (builds `nsenter -t 1 -m -u -i -n -p -- systemctl
190+
restart <service>`), the `RestartExecutor` trait, `AppliedRecord` ({destPath,
191+
hash} carried in the `kata-config-applied` annotation) + `parse_applied_record`,
192+
`intended_hash_for`, `needs_restart`, and `restart_if_needed`. `ABSENT_HASH_MARKER`
193+
marks the torn-down state.
194+
- `src/kata_config_agent_tests.rs`: 14 new tests — exact nsenter command line,
195+
`AppliedRecord` serde round-trip + garbage→None, `intended_hash_for` per outcome,
196+
the `needs_restart` guard (no-prior / stale / match), and `restart_if_needed`
197+
short-circuit / once / error-propagation via a counting `RestartExecutor` fake.
198+
- `src/bin/kata_config_agent.rs`: wire the restart into `reconcile_once` — after a
199+
sync, when the on-host content hash differs from the last-applied hash, record the
200+
new `AppliedRecord` **before** restarting via the concrete `NsenterRestartExecutor`.
201+
`read_node_state`/`record_applied` now use the compact `{destPath, hash}` record
202+
(replacing the bare-destPath value); unparseable records degrade to "not applied"
203+
(re-apply + restart, the safe direction).
204+
- `docs/adr/0003-*.md`: refine the applied annotation to the `{destPath, hash}` record.
205+
- `docs/architecture/calm/architecture.json`: align the agent node, the workload-API
206+
relationship, and the delivery-flow step prose to the compact record; `make
207+
calm-validate` clean, `make calm-diagrams` re-rendered.
208+
209+
### Why
210+
Completes the load-bearing piece of per-node kata delivery: after writing the
211+
drop-in the agent must bounce the host k0s service so containerd reloads it.
212+
Done in-pod via `nsenter` into host PID 1 (ADR 0003). The applied-hash record,
213+
written before the restart, is the restart-loop guard — the restart SIGKILLs the
214+
agent, and on the next loop a matching hash makes it a no-op (single-cycle
215+
convergence). Drift correction rewrites the file but does not re-restart.
216+
217+
### Impact
218+
- [ ] Breaking change
219+
- [ ] Requires cluster rollout
220+
- [ ] Config change only
221+
- [x] Documentation only (agent/manifests not yet released)
222+
12223
## [2026-06-09 18:45] - Tighten kata-config-agent secret RBAC to get-only (KSV-0113)
13224

14225
**Author:** Erick Bourgeois

.claude/CLAUDE.md

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -251,8 +251,12 @@ This project follows strict Test-Driven Development practices. You MUST follow t
251251
4. **Refactoring Code:** Update test names and assertions to match refactored code
252252

253253
**Test File Organization:**
254-
- **CRITICAL**: ALWAYS place tests in separate `_tests.rs` files
255-
- NEVER embed large test modules directly in source files
254+
- **CRITICAL**: ALWAYS place tests in separate `_tests.rs` files — this is a
255+
HARD REQUIREMENT with NO exceptions for ANY target: library modules,
256+
`src/bin/*.rs` binaries, and submodules alike
257+
- NEVER write an inline `mod tests { ... }` body in a source file — not even
258+
for one small test; the source file carries only the three-line declaration
259+
`#[cfg(test)] #[path = "foo_tests.rs"] mod tests;`
256260
- Follow the pattern: `foo.rs``foo_tests.rs`
257261

258262
### Rust Style Guidelines

.trivyignore

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -233,6 +233,28 @@ AVD-KSV-0017
233233
# node-scoped ServiceAccount.
234234
AVD-KSV-0106
235235

236+
# KSV-0001 — Container 'agent' should set allowPrivilegeEscalation to false.
237+
# Impossible under privileged: true (KSV-0017 above, ADR 0003): the Kubernetes
238+
# API server REJECTS a pod that sets privileged: true together with
239+
# allowPrivilegeEscalation: false ("cannot set `allowPrivilegeEscalation` to
240+
# false and `privileged` to true"). The field is therefore set to true
241+
# explicitly rather than left implicit, so the manifest states the effective
242+
# posture honestly. Bounded by the same controls justified for KSV-0017:
243+
# opt-in nodeSelector, readOnlyRootFilesystem, seccomp RuntimeDefault, a
244+
# shell-less single-purpose binary, and a narrow node-scoped ServiceAccount.
245+
AVD-KSV-0001
246+
247+
# KSV-0003 / KSV-0004 — Container 'agent' should add 'ALL' to
248+
# securityContext.capabilities.drop. Same rationale as KSV-0106 above: under
249+
# privileged: true the container holds the full capability set regardless of
250+
# any drop list, so `drop: [ALL]` would be a cosmetic no-op that misrepresents
251+
# the container's effective privileges in an audit. The real bound on the
252+
# escalation is the KSV-0017 control set (opt-in nodeSelector,
253+
# readOnlyRootFilesystem, seccomp RuntimeDefault, single-purpose binary,
254+
# node-scoped ServiceAccount), not a capability list the kernel ignores.
255+
AVD-KSV-0003
256+
AVD-KSV-0004
257+
236258
# ─────────────────────────────────────────────────────────────────────────────
237259
# RBAC — deploy/kata-config-agent/rbac.yaml
238260
# ─────────────────────────────────────────────────────────────────────────────

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ A cloud-native Kubernetes controller for managing time-based machine scheduling
5555
- 🎯 **Priority-based** - Resource distribution across controller instances
5656
- 🚨 **Kill switch** - Emergency immediate removal capability
5757
- 🏷️ **User-defined Node taints** - Declare taints in `spec.nodeTaints`; the controller applies them once the Node is Ready, tracks ownership, and reconciles drift
58+
- 📦 **Kata config delivery** - Declare a containerd drop-in source in `spec.kata`; an opt-in node agent writes it to the host and restarts the k0s service once per change ([docs](docs/src/concepts/kata-config-delivery.md))
5859
- 📊 **Multi-instance** - Horizontal scaling with consistent hashing
5960
- 🔍 **Full observability** - Prometheus metrics and health checks
6061

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
# ValidatingAdmissionPolicyBinding — activates 5spot-agent-pod-security,
2+
# scoped ONLY to the 5spot-system namespace on the WORKLOAD cluster (ADR 0004).
3+
#
4+
# The namespaceSelector is the scoping mechanism: the policy's CEL assumes it
5+
# is evaluating pods inside the namespace the baseline engines (PSA /
6+
# Gatekeeper / Kyverno) have exempted for the 5-Spot node agents. Binding it
7+
# wider would deny ordinary workloads elsewhere; binding it narrower (e.g. an
8+
# objectSelector on agent labels) would defeat the purpose — the policy exists
9+
# precisely to catch the pods that are NOT the agents.
10+
#
11+
# Apply after the ValidatingAdmissionPolicy (against the workload cluster
12+
# kubeconfig):
13+
# kubectl apply -f deploy/admission/agent-pod-security-policy.yaml
14+
# kubectl apply -f deploy/admission/agent-pod-security-binding.yaml
15+
apiVersion: admissionregistration.k8s.io/v1
16+
kind: ValidatingAdmissionPolicyBinding
17+
metadata:
18+
name: 5spot-agent-pod-security-binding
19+
labels:
20+
app.kubernetes.io/name: 5spot
21+
app.kubernetes.io/component: admission
22+
app.kubernetes.io/part-of: child-cluster
23+
app.kubernetes.io/managed-by: 5spot-controller
24+
spec:
25+
policyName: 5spot-agent-pod-security
26+
27+
# Deny rejects the request with HTTP 422. During initial rollout you can
28+
# switch to [Deny, Audit] (or just [Audit]) to observe matches in the audit
29+
# log before enforcing.
30+
validationActions: [Deny]
31+
32+
matchResources:
33+
namespaceSelector:
34+
matchLabels:
35+
kubernetes.io/metadata.name: 5spot-system

0 commit comments

Comments
 (0)