Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/Skidbladnir

.nornir/kubernetes-target-design.md

kubernetes-target — design (the three-target ServiceSpec)

Class: design · Function axis: f6 (install) · sibling of k8s-unfold.md (which describes the renderer + applier that already ship) and install-design.md (the backend tree). This chapter states the shape we ship next: what ServiceSpec must gain so that Kubernetes is an honest third target family rather than a bolted-on eleventh backend. Alternatives considered and rejected live in kubernetes-target-reasoning.md.


f0 — the frame

Kubernetes and the vetra/Njord deployer solved the same problems. They are not rivals here; they are two completed answers we can read off each other. Skidbladnir already ships a Kubernetes renderer (src/k8s.rs) and applier (src/k8s_apply.rs). The question this chapter answers is not "should we support Kubernetes" — we do — but:

Which capabilities did Kubernetes standardise that our neutral ServiceSpec still cannot express, which ones can it already express under another name, and which ones must we refuse on purpose?

The governing constraint is L5 and the honest-degrade rule: a knob only one backend can honour is a design smell. Every field added below is either renderable on a clear majority of the eleven backends, or it lands with a declared, observable degrade — never a silent drop.


f1 — what the Njord deployer actually does

Read read-only from /home/rickard/talos-njord/ (L10 — nothing was edited). The Njord deploy is two tiers, and it is important that they are separate:

Tier A — the node (Talos machine configuration)

The unit of deployment is a whole machine, described declaratively in one YAML document and pushed over the Talos API.

aspect how Njord does it citation
unit one v1alpha1 machineconfig per node (cp1-full.yaml, w1.yaml) HANDOFF-NEXT-MACHINE.md:17-22
identity of a node the cluster CA + join token embedded in the config file — "w1.yaml … This IS the join credential" worker-join/README-ADD-SLAVE.md "Files in this package"
install talosctl apply-config --insecure -n W1_IP -f w1.yaml → installs + reboots worker-join/README-ADD-SLAVE.md step 5
upgrade re-apply the config; mode selects the disruption (auto / no-reboot / reboot / staged / try, the last auto-reverting after 1 min) talos.dev editing-machine-configuration
convergence Talos controllers "continuously run a reconcile loop" until current state == desired state same source
disk selection by WWID, not /dev/sdX — "sd-letters renumber when the boot-USB is pulled" HANDOFF-NEXT-MACHINE.md:12-14
join / ordering workers join automatically; "No bootstrap on workers" — the control plane is the only ordered step HANDOFF-NEXT-MACHINE.md:65
failure semantics an unbounded stall. A stale vfat filesystem on the data disk made phase 7/9 mountUserDisks fail and "the node sat in booting forever" HANDOFF-NEXT-MACHINE.md:45-51

That last row is the single most instructive thing in the whole bundle: the deployer's failure mode is wait forever with no deadline. Kubernetes has progressDeadlineSeconds for exactly this shape of bug; Skidbladnir has nothing for it either (see §4 row 15).

Tier B — the application (Helm release)

The unit of deployment is a release: one chart plus one values file.

aspect how Njord does it citation
unit a Helm release, configured by njord-new-values.yaml (57 lines) njord-new-values.yaml
composition app chart + vendored subcharts postgresql and minio, each toggled by enabled: true and configured in the same values file njord-new-values.yaml:20-43
images pinned to a LAN registry 192.168.1.144:5000/njord-web, with global.security.allowInsecureImages: true — a hand-rolled offline mirror njord-new-values.yaml:1-8
config plain values (app.baseUrl, app.rustLog, app.s3.bucket) templated into a ConfigMap/env by the chart njord-new-values.yaml:11-17
secrets by reference: secrets.existingSecret: njord — the chart does not own the values njord-new-values.yaml:18-19
secret shape flat KEY=VALUE env (DATABASE_URL, S3_ACCESS_KEY, S3_SECRET_KEY, SMTP_*, plus per-subchart postgresql/minio credential keys) migrate/old-secrets.env (key names only)
storage per-subchart persistence.enabled + size (8Gi PG, 10Gi MinIO), bound to the default local-path StorageClass on the NVMe njord-new-values.yaml:29-31,41-43; HANDOFF-NEXT-MACHINE.md:15
ingress / TLS ingress.className: nginx + cert-manager.io/cluster-issuer annotation + a named TLS secret njord-new-values.yaml:44-56
ordering implicit: Helm subchart ordering + helm --wait. There is no declared dependency graph between app and Postgres —
environment drift the promotion from the old KVM to Talos is a values diff (migrate/old-helm-values.yaml vs njord-new-values.yaml: NodePort→Ingress, persistence off→on, registry 10.5.0.1:5000→192.168.1.144:5000) both files
operational knowledge encoded as an annotation with a comment: nginx defaults client_max_body_size to 1m, so uploads 413 → proxy-body-size: "50m" njord-new-values.yaml:49-52

The shape of the whole thing: one declarative document per tier, applied by a tool that reconciles, with secrets referenced rather than embedded, and the hard-won operational facts living as comments beside the knob they fix. That is the same shape as a LakeSpec + ComponentSpec. The correspondence is real.


f2 — the Kubernetes side (established from the docs, not from memory)

Only the facts the table depends on, each with its source.


f3 — what ServiceSpec can express today

src/service.rs:185 — the whole neutral vocabulary:

name · description · exec · args · user · group · scope · working_dir · env · after · needs · restart · restart_sec · sockets · health · agent · and the k8s-only quintet image · namespace · replicas · stateful · storage · env_from_secrets.

Renders to 11 backends (renderer_for, all Some — but the Option return is retained deliberately so a future backend can land as an honest None). Output is a RenderedService: files: Vec<RenderedFile> (path + contents + unix mode) plus enable/start/status/stop argv as data. Lifecycle drives the systemd limb over D-Bus and probes health() itself. orchestrate::start_waves Kahn-topo-sorts products into parallel waves. install::{install,update,uninstall,rollback}_product own the generation lifecycle over layout's atomic symlink swap.

Three facts about today that the table below turns on:

  1. The neutral spec has no secret axis. SystemdServiceSpec has secret_environment + a validate() that refuses secrets without a mode-0600 EnvironmentFile= (src/systemd.rs:156-170,262) — but to_systemd_spec only ever calls with_env. A secret placed in ServiceSpec::env is rendered inline into the world-readable 0644 unit.
  2. There is no hook tier at all, at either level (container or release).
  3. Every renderer's limits are documented in a doc comment and observable nowhere. OCI silently drops restart_sec; dinit silently drops group; Windows silently drops working_dir; Kubernetes silently drops exec, user, after, needs, restart, restart_sec, working_dir and scope.

f4 — THE CORRESPONDENCE TABLE

Verdicts: (a) already expressed, spelled differently · (b) a real gap to ADD · (c) genuinely Kubernetes-only, correctly out of scope.

# capability Kubernetes Njord deployer Skidbladnir ServiceSpec today verdict
1 unit of deployment Pod, owned by Deployment/StatefulSet/DaemonSet Helm release (chart+values); Talos machineconfig per node ServiceSpec / ComponentSpec, collected by LakeSpec (a)
2 declarative desired state manifest + apiserver values file + machineconfig LakeSpec + RenderedService (a)
3 node/platform provisioning none — assumes nodes exist Talos apply-config, WWID disk selector, bootstrap unfold (KVM · libvirt · Redfish · Terraform · Talos) (a) — ours is broader
4 container lifecycle hooks lifecycle.postStart / preStop, exec|httpGet|sleep, at-least-once, preStop blocks SIGTERM Helm pre-install/post-install Jobs; Bitnami subchart init nothing (b) — headline
5 release-level hooks (Helm's 9 hooks + weights + delete policies) used implicitly via subcharts install::*_product has fixed phases, no hook points (b)
6 health: three distinct questions startupProbe / readinessProbe / livenessProbe chart defaults ONE HealthProbe, rendered as both readiness and liveness (k8s.rs:71-74) (b)
7 probe tuning initialDelay/period/timeout/failureThreshold/successThreshold values hardcoded 2 s connect timeout, single attempt; k8s render emits none (b)
8 graceful shutdown window terminationGracePeriodSeconds (30 s), SIGTERM→grace→SIGKILL pod default nothing — SysV reuses restart_sec as its stop retry (service.rs:721-725) (b)
9 ordered start within one unit initContainers — sequential, run-to-completion, retried until success subchart init containers ExecStartPre= only via the systemd limb, not in the neutral spec (b) — folds into row 4
10 ordered start across units none, deliberately — no cross-workload ordering primitive helm --wait + subchart order (implicit) orchestrate::start_waves Kahn topo over after/needs (a) — and we are stronger; see §6
11 config projection ConfigMap → env / envFrom / read-only volume; 1 MiB; volume auto-syncs, env does not values → templated ConfigMap env: Vec<(K,V)> inline + per-backend 0600 env file; no file projection (b) (file half) / (a) (env half)
12 secret projection Secret → env (never rotates) or volume (defaultMode, syncs ~1 min); immutable secrets.existingSecret: njord — by reference secret_environment exists on SystemdServiceSpec only; neutral spec leaks secrets into a 0644 unit (b) — sharpest
13 secret rotation without redeploy volume-mounted Secret re-syncs in place ditto (chart owns nothing) none — a host env file is env-var-shaped: rewrite + restart (c) for in-place; (b) for the by-reference form
14 workload identity ServiceAccount + projected bound token; runAsUser/runAsGroup default SA + existingSecret ensure_system_user (dedicated uid per service) + identity::ensure UUIDv7 — not rendered to k8s at all (b)
15 rollout deadline / failure detection progressDeadlineSeconds → ProgressDeadlineExceeded; minReadySeconds none — observed to stall forever (HANDOFF:45-51) none (b)
16 restart supervision restartPolicy + kubelet CrashLoopBackOff (exponential, fixed) ditto RestartPolicy + restart_sec fixed backoff; no burst cap (systemd's StartLimitBurst= is richer than k8s) (b-minor)
17 version rollback rollout undo --to-revision, revisionHistoryLimit (replica-wise) helm rollback (release revisions) layout date-stamped generations + atomic symlink swap + rollback_product (a) — different mechanism, same guarantee
18 rolling update pacing maxSurge / maxUnavailable / Recreate chart default n/a — one host runs one process (c)
19 horizontal replicas spec.replicas, HPA replicaCount: 1 replicas exists but is k8s-only and silently ignored elsewhere (c) — keep, but declare the degrade
20 PodDisruptionBudget voluntary-disruption budget via Eviction API not used — (c)
21 readiness gates third-party conditions injected into pod status not used — (c)
22 Operator / CRD / control loop reconcile forever, desired vs actual Talos controllers do exactly this at node level one-shot converge; no agent on the target (the charter) (c) — but adopt the idempotency contract, §6
23 ordinal identity + per-replica storage StatefulSet ordinals, stable DNS, volumeClaimTemplates PG/MinIO subcharts stateful + one shared storage PVC — not volumeClaimTemplates, and the Service is not headless (b) — renderer correctness
24 one-per-node workload DaemonSet not used — (c)
25 run-to-completion workload Job / CronJob Helm hook Jobs RestartPolicy::No — unrepresentable on a Deployment (b) — route to a Job
26 airgap: sealed, verified transport none — images are pulled over a network LAN registry …144:5000 + allowInsecureImages: true archive::BundleManifest SHA-256/entry + fetch::verify_on_receipt (a) — k8s has no answer; see §6
27 offline registry mirroring imagePullPolicy at best the LAN registry above MachineConfig::registry_mirrors (talos-unfold.md) (a)
28 honest degrade reporting n/a (a manifest field is either valid or rejected) n/a doc comments only — nothing observable (b) — cross-cutting

f5 — the (b) rows, as spec changes

Eight additions. Each is stated with its render on every family, so the honest-degrade set is visible before a line is written.

B1 — secret_env on the neutral spec (row 12 — do this first)

/// Sensitive KEY=VALUE entries. NEVER rendered into a world-readable file;
/// every backend writes these only to its mode-0600 private env file.
pub secret_env: Vec<(String, String)>,
/// Secrets carried BY REFERENCE (the Njord `existingSecret` shape): the bundle
/// names them, the target already holds the values.
pub secret_refs: Vec<String>,

B2 — lifecycle hooks (rows 4, 9)

pub enum HookAction { Exec { argv: Vec<String> }, Http { path: String }, Sleep(u32) }
pub struct Hooks {
    pub pre_start:  Vec<HookAction>,   // ordered, run-to-completion, must succeed
    pub post_start: Vec<HookAction>,   // concurrent with the main process
    pub pre_stop:   Vec<HookAction>,   // must complete before the TERM signal
    pub post_stop:  Vec<HookAction>,
}

The four slots are chosen because they are the intersection of what systemd and Kubernetes both name, and the union is renderable widely:

backend pre_start post_start pre_stop post_stop
systemd (both scopes) ExecStartPre= ExecStartPost= ExecStop= ExecStopPost=
Quadlet [Service] ExecStartPre= ExecStartPost= ExecStop= ExecStopPost=
OpenRC start_pre() start_post() stop_pre() stop_post()
SysV prologue in start) epilogue in start) prologue in stop) epilogue in stop)
runit run prologue ✗ (no post-spawn slot) ✗ finish
s6-rc run prologue ✗ ✗ finish
dinit ✗ (textual description has no exec slots) ✗ ✗ ✗
OCI wrapped into the entrypoint argv ✗ ✗ ✗
Windows SCM ✗ ✗ ✗ ✗
Kubernetes initContainers (sequential, run-to-completion — the exact semantics) lifecycle.postStart lifecycle.preStop ✗ (no post-termination hook)

Seven of eleven honour pre_start; the rest degrade, declared. Note the two semantic facts that must be documented on the type, because they are not intuitive and both docs state them explicitly: post_start runs concurrently with the main process, not after it is ready, and pre_stop must complete before SIGTERM is sent and is charged against the shutdown window (B3).

B3 — stop_timeout_sec (row 8)

The most portable knob in this whole document — ten of eleven backends have a native slot, and it fixes an existing conflation (SysV currently derives its --retry TERM/N/KILL from restart_sec, which is a start backoff).

systemd/Quadlet TimeoutStopSec= · OpenRC retry= · SysV --retry TERM/N/KILL · runit/s6-rc timeout-kill · dinit stop-timeout · OCI --stop-timeout · Kubernetes terminationGracePeriodSeconds · Windows SCM ✗ (declared).

B4 — split the health axis (rows 6, 7)

pub startup:   HealthProbe,     // "has it finished coming up"
pub health:    HealthProbe,     // liveness — KEPT, same field, back-compatible
pub readiness: HealthProbe,     // "should traffic be routed here"
pub probe:     ProbeTiming,     // initial_delay / period / timeout / failures

Kubernetes standardised a distinction we conflate, and it is a real distinction: started ≠ alive ≠ ready for traffic. Honest per family:

readiness is the one field here that is nearly (c). It stays because the Kubernetes target is a first-class family and a Service's endpoint gating is not an exotic corner — but it must be recorded as unexpressible everywhere else.

B5 — config_files (row 11)

pub struct ConfigFile { pub path: String, pub contents: String, pub mode: u32 }
pub config_files: Vec<ConfigFile>,

Skidbladnir can emit files (RenderedFile) but a spec cannot ask for one. Every host backend writes it directly; Kubernetes emits a ConfigMap + a read-only volumeMount (note the 1 MiB ceiling — a ConfigFile above it must be a declared degrade, not a truncation). Universally expressible.

B6 — uid / workload identity (row 14)

pub uid: Option<u32>,   // pin the numeric identity, not just the name

L5 says every service runs as its own dedicated identity, and ensure_system_user allocates one in [100, 1000). Kubernetes' twin is numeric: securityContext.runAsUser / runAsGroup / fsGroup. Pinning the uid in the spec makes one declared identity, two projections — POSIX ownership on a host, runAsUser in a Pod. Alongside it the k8s renderer should emit a ServiceAccount named for the service plus serviceAccountName, because the constellation's law "every service runs as its own dedicated user" has an exact Kubernetes spelling and we currently render the namespace default SA.

B7 — start_timeout_sec + restart burst cap (rows 15, 16)

The Njord bundle's worst failure was an unbounded stall. Kubernetes bounds it with progressDeadlineSeconds; systemd bounds the retry storm with StartLimitIntervalSec=/StartLimitBurst= (which Kubernetes has no equivalent for — CrashLoopBackOff is not configurable). Add both; render start_timeout_sec to TimeoutStartSec= / progressDeadlineSeconds / --wait timeouts, and the burst cap to systemd/Quadlet/OpenRC (respawn_max) with a declared degrade elsewhere.

B8 — RenderedService::degraded (row 28 — cross-cutting, and it unblocks the rest)

/// What THIS backend could not express from the spec. Data, not a doc comment.
pub degraded: Vec<Degrade>,          // { field: &'static str, reason: String }

Today every renderer's limits are prose. A caller cannot discover that OCI dropped restart_sec, that dinit dropped group, or that Kubernetes dropped after, needs, user, exec and working_dir. L4 says no behaviour ships without a way to observe it — and this is behaviour: it is the difference between an install that will work and one that quietly will not.

This is also the mechanism that makes every other (b) row safe to land: a knob may be added the moment its degrade is declarable, and the render-coverage matrix (Backend::all()) gains a second axis — not just "does it render" but "what did it lose".


f6 — the (c) rows, stated plainly

We should not do these. Each is correct-to-refuse for a specific reason, not for lack of effort.

And the mirror image — what the deployer/Skidbladnir does that Kubernetes has no answer for

Worth stating as loudly as the gaps:

  1. Cross-service dependency ordering (row 10). Kubernetes has no cross-workload ordering primitive; it is a deliberate design position (make services tolerate a missing dependency). Skidbladnir's start_waves Kahn topo sort over after/needs is a capability Kubernetes declines to offer, and the Njord chart works around its absence with helm --wait and subchart order. The Kubernetes renderer must not pretend to have it — see §7.
  2. Sealed, content-addressed, verify-on-receipt transport (row 26). Kubernetes assumes a reachable registry. Njord hand-rolls the offline case with a LAN registry and allowInsecureImages: true — which is exactly the hole archive::BundleManifest + fetch::verify_on_receipt fills.
  3. Node provisioning (row 3). Kubernetes starts where unfold finishes.
  4. A restart burst cap (row 16). systemd's StartLimitBurst= has no Kubernetes equivalent; CrashLoopBackOff is not tunable.

f7 — Kubernetes as the third target: the field map

ServiceSpec → manifests, stated field by field. ● renders today · ○ should render (missing) · ✗ honest degrade, must be recorded via B8.

ServiceSpec field Kubernetes manifest field state
name metadata.name, app.kubernetes.io/name label, selector.matchLabels ●
description metadata.annotations["skidbladnir.io/description"] ○
exec containers[0].command ○ — currently dropped, though the OCI renderer honours it as --entrypoint; the two container-family backends disagree
args containers[0].args ●
user / group securityContext.runAsUser / runAsGroup (numeric — needs B6 uid) and a ServiceAccount + serviceAccountName ○
scope — (no per-user scope in a cluster) ✗
working_dir containers[0].workingDir ○ (trivial)
env env[].name/value ●
secret_env (B1) a Secret manifest + envFrom.secretRef ○
secret_refs (B1) envFrom.secretRef — today's env_from_secrets ●
config_files (B5) ConfigMap + read-only volumeMount (1 MiB cap) ○
after / needs — Kubernetes has no cross-workload ordering ✗ — see below
restart pod restartPolicy; a Deployment forces Always ✗ (OnFailure/No unrepresentable)
restart_sec — (CrashLoopBackOff is fixed) ✗
sockets[].port containerPort + Service port/targetPort ●
sockets[].stream Service/port protocol: TCP|UDP ○ (currently dropped)
health livenessProbe ● (shared action with readiness)
readiness (B4) readinessProbe ● (shares health's action today)
startup (B4) startupProbe ○
probe timing (B4) initialDelaySeconds/periodSeconds/timeoutSeconds/failureThreshold ○
hooks.pre_start (B2) initContainers ○
hooks.post_start / pre_stop (B2) lifecycle.postStart / lifecycle.preStop ○
hooks.post_stop (B2) — no post-termination hook exists ✗
stop_timeout_sec (B3) terminationGracePeriodSeconds ○
start_timeout_sec (B7) progressDeadlineSeconds ○
agent (Fat/Thin) — metadata only, by design ✗ (intended)
image containers[0].image ●
namespace metadata.namespace ●
replicas spec.replicas ●
stateful kind: StatefulSet + serviceName ●
storage should be volumeClaimTemplates on a StatefulSet; a standalone PVC only for a Deployment ● but wrong shape for stateful

The four honest None/degrade cases that need naming

  1. after/needs on Kubernetes. Two acceptable renders, and one forbidden. Forbidden: dropping them silently, which is what happens today. Acceptable A: record the degrade (B8) and leave ordering to orchestrate::start_waves, which drives the applier, not the manifest — this is the honest one, because it keeps the ordering guarantee where it actually holds. Acceptable B: render a pre_start init-container wait-gate per dependency (initContainers retry until success — the semantics match). Choose A as the default and B as opt-in, because A does not require the dependency to be resolvable by DNS from inside the pod.
  2. RestartPolicy::No + no sockets ⇒ this is a Job, not a Deployment. A Deployment cannot express "run once and stop". Render a Job, or degrade loudly. (Row 25.)
  3. stateful + storage. A StatefulSet with replicas > 1 sharing one ReadWriteOnce PVC does not work; the documented shape is volumeClaimTemplates (one PVC per ordinal) plus a headless Service (clusterIP: None), which the StatefulSet docs require for its stable network identity. Today k8s.rs emits a standalone PVC and a normal Service. Correct this before anyone runs replicas > 1 stateful.
  4. scope: User. There is no per-user scope in a cluster. Degrade, recorded.

Keep renderer_for's Option

All eleven backends resolve to Some today, and the Option return is retained on purpose. It stays. The Job backend (case 2 above), and any future family, must be free to land as an honest None rather than as a renderer that emits something plausible and wrong. None and degraded are the same virtue at two granularities: None = "this backend cannot express this spec at all"; degraded = "it can, but it lost these fields".


f8 — the three targets

Eleven backends, three families. The family — not the backend — is where a degrade decision belongs, because the three differ in all three things that matter at once: who the service is, what supervises it, and how config reaches it.

family backends identity supervision config delivery the gap it crosses
T1 · host-init systemd-system, systemd-user, OpenRC, SysV, runit, s6-rc, dinit, Windows SCM POSIX uid from ensure_system_user (+ uid, B6) the host init a mode-0600 file on disk a machine boundary
T2 · container-host OCI (podman), Quadlet container uid inside an image the container runtime (OCI) / systemd (Quadlet) --env-file / Environment= an image boundary
T3 · cluster Kubernetes ServiceAccount + runAsUser kubelet + a controller reconciling ConfigMap / Secret projection an API boundary

Why exactly these three, and not two or four:

Ship Backend::family() -> TargetFamily alongside Backend::slug(). It gives the render-coverage matrix its coarse axis, gives degraded its natural grouping, and gives this document's rule a machine-checkable form: a new ServiceSpec field must render on at least one backend in each of the three families, or be declared family-scoped at the point of definition.


Status

Design only — nothing implemented by this chapter. The Kubernetes renderer + applier already ship and are green (k8s-unfold.md: 111 lib + 11 integration by default, 114 + 11 with --features kubernetes). Suggested landing order, cheapest-first and each independently useful:

B8 (degraded — unblocks honest landing of everything else) → B1 (secret_env — closes a real leak) → B3 (stop_timeout_sec — 10 of 11 backends) → B2 (hooks) → B4 (probe split) → B6 (uid + ServiceAccount) → B5 (config_files) → B7 (deadlines) → the four §7 renderer corrections.

Every one is additive (L2): existing specs render byte-identically when the new fields are at their defaults, which is what keeps the exact-bytes contract tests in k8s.rs and service.rs meaningful rather than in the way.