Gunnar

nordisk/Skidbladnir

.nornir/k8s-unfold.md

# k8s-unfold — design

> **Class:** `design` · the Kubernetes target for the airgap pipeline's install
> step. Two halves, split by Skidbladnir's charter (**core stays sync + no shell;
> verbs as data; network clients behind features**).

## f0 — two halves

| half | where | sync? | deps |
|---|---|---|---|
| **renderer** | `src/k8s.rs` `KubernetesRenderer: ServiceRenderer` | **sync core** | none (hand-rolled YAML) |
| **applier** | `src/k8s_apply.rs` (feature `kubernetes`) | async | `kube` 0.99 + `k8s-openapi` 0.24 + `tokio`, rustls-tls (no OpenSSL/C) |

With the `kubernetes` feature **off**, the applier isn't compiled and neither
`kube` nor `tokio` enters the build — the lean offline core is unchanged (mirrors
`redfish`/`terraform`/`kvm`).

## f1 — renderer (data)

`ComponentSpec` (+ additive k8s fields `image`/`namespace`/`replicas`/`stateful`/
`storage`) → `to_service_spec` under `Backend::Kubernetes` → `KubernetesRenderer`
emits a `RenderedService`:
- **files** = manifests as data: **Deployment** (or **StatefulSet** when
  `stateful`), **Service** (when ports), **PVC** (when `storage`).
- **health probes** (readiness + liveness) render from the SAME `HealthProbe` the
  host `Lifecycle::health()` probes — **one knob, two views** (L5). `Tcp(port)` →
  `tcpSocket`; `Http(path)` → `httpGet {path, port=first socket}` (needs a declared
  socket for the port, same rule as the host HTTP probe — no socket ⇒ no probe);
  `None` ⇒ no probe (render stays byte-identical). Both readiness and liveness share
  the one action, so k8s gates Service routing AND restart-on-hang on the endpoint
  the operator already declared once.
- **env values** render through `yaml_scalar` (`k8s.rs`): a k8s `env.value` MUST be a
  string, so a numeric/boolean/null-looking value (`KNUT_FLIGHT_SCALE=1`) is quoted
  (`value: "1"`) — bare `value: 1` re-parses to int and the apiserver rejects the
  manifest. Unambiguous strings (URLs, `host:port`, paths) stay bare, so pre-existing
  renders are byte-identical.
- **verbs as data**: `enable` = server-side apply every manifest; `start`/`stop` =
  scale N/0; `status` = rollout-wait. Deterministic bytes (unit-tested).

**Example — the knut Arrow Flight server (N5):** `ComponentSpec::knut_flight`
(`product.rs`) renders a plain stateless **Deployment + Service** in namespace `knut`
on port 50055 (env-only config, TCP readiness/liveness). Rendered by
`cargo run --example render-knut-flight-k8s`; checked-in snapshot + a hand-written
`Namespace` + apply/prove/validate steps live in `deploy/knut-flight/`. The SAME spec
drives the systemd install (`cargo run --example install-knut-flight`), so the two
Runtime targets (Jera-systemd | Kube) never drift.

## f2 — applier (network, `kubernetes` feature)

`K8sApplier` drives those manifests+verbs over the real API — the
**systemd-over-D-Bus analog for k8s**, no `kubectl`/`helm`:
- `manifests()` — pure YAML → `DynamicObject` (unit-tested, no cluster).
- `apply()` — **server-side apply** (`PatchParams::apply("skidbladnir").force()`)
  each object, kind resolved via **discovery** (any GVK: Deployment/Service/PVC).
- `scale()` — patch `spec.replicas` (Deployment/StatefulSet).
- `wait_ready()` — poll `status.ready_replicas` to desired, with timeout.
- `delete()` — best-effort teardown.
- `k8s_install()` — thin entrypoint: apply → wait ready.
- `Client::try_default()` — kubeconfig (laptop) or in-cluster ServiceAccount (Job).

## Boundary

**Platform bring-up only** (workload/service/pvc + wait). The **app-data load**
(DB `COPY`, S3 objects, config) is **`tillsynia-admin`'s** job, not Skidbladnir's.

## Status

Renderer + applier both green: `cargo test` (default) 111 lib + 11 integration;
`cargo test --features kubernetes` 114 lib + 11. The `apply/scale/wait/delete`
**I/O is exercised only against a live cluster** — `k8s_live_apply_scale_delete`
is `#[ignore]` (run on the lab worker node: `cargo test --features kubernetes --
--ignored k8s_live`).