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

nordisk/Skidbladnir

src/k8s.rs

//! Kubernetes backend — the **sync, data-only** half of the k8s-unfold target.
//!
//! Mirrors the systemd/OpenRC renderers: [`KubernetesRenderer`] projects the
//! neutral [`ServiceSpec`] onto k8s **manifests as data** (a Deployment or
//! StatefulSet, a Service, and a PVC when the spec declares storage) plus the
//! lifecycle **verbs as data** (`apply` / `scale` / `rollout status` / `scale 0`).
//! It does **no I/O and no shelling** — same charter as the rest of `service.rs`.
//! The privileged/async side that drives these manifests over the Kubernetes API
//! (the "verbs, but against the apiserver, not `kubectl`" analog of systemd-over-
//! D-Bus) lands separately behind a `kubernetes` cargo feature.
//!
//! ONE builder: every manifest is a `serde_json::Value` — the same shape (and the
//! same `name_label`/`probe_action` helpers) [`crate::kube_runtime`] builds for a
//! rollout — and [`manifest_yaml`] prints it. The printer is **deterministic**
//! (keys sorted, fixed indentation) so the contract tests assert exact bytes, and
//! it **always double-quotes every string** with full escaping, so no value
//! (`123456`, `pw #x`, `*x`, `a\nb: c`, `.inf`, `on`) can re-parse as anything but
//! the string it is — no serde_yaml dep pulled into the lean core.

use serde_json::{Map, Value, json};

use crate::kube_runtime::{RolloutProbe, name_label, probe_action};
use crate::service::{
    Backend, HealthProbe, RenderedFile, RenderedService, ServiceRenderer, ServiceSpec,
};

/// Print a manifest `Value` as block YAML. Keys are sorted (so the output never
/// depends on `serde_json`'s map feature flags); strings are ALWAYS double-quoted
/// ([`yaml_string`]); numbers, booleans and null are bare. Empty maps/sequences
/// print in flow form (`{}` / `[]`).
pub(crate) fn manifest_yaml(v: &Value) -> String {
    let mut out = String::new();
    match v {
        Value::Object(m) if !m.is_empty() => emit_map(m, 0, &mut out),
        other => {
            out.push_str(&inline(other));
            out.push('\n');
        }
    }
    out
}

fn sorted(m: &Map<String, Value>) -> Vec<(&String, &Value)> {
    let mut v: Vec<_> = m.iter().collect();
    v.sort_by(|a, b| a.0.cmp(b.0));
    v
}

fn pad(out: &mut String, indent: usize) {
    out.extend(std::iter::repeat_n(' ', indent));
}

fn emit_map(m: &Map<String, Value>, indent: usize, out: &mut String) {
    for (k, v) in sorted(m) {
        pad(out, indent);
        emit_entry(k, v, indent, out);
    }
}

/// `key: value` where the key sits at column `indent` (already padded).
fn emit_entry(k: &str, v: &Value, indent: usize, out: &mut String) {
    out.push_str(&yaml_key(k));
    out.push(':');
    match v {
        Value::Object(m) if !m.is_empty() => {
            out.push('\n');
            emit_map(m, indent + 2, out);
        }
        Value::Array(a) if !a.is_empty() => {
            out.push('\n');
            emit_seq(a, indent + 2, out);
        }
        other => {
            out.push(' ');
            out.push_str(&inline(other));
            out.push('\n');
        }
    }
}

fn emit_seq(a: &[Value], indent: usize, out: &mut String) {
    for item in a {
        pad(out, indent);
        out.push_str("- ");
        match item {
            Value::Object(m) if !m.is_empty() => {
                // First entry rides the dash line; the rest align under it.
                let mut entries = sorted(m).into_iter();
                if let Some((k, v)) = entries.next() {
                    emit_entry(k, v, indent + 2, out);
                }
                for (k, v) in entries {
                    pad(out, indent + 2);
                    emit_entry(k, v, indent + 2, out);
                }
            }
            other => {
                out.push_str(&inline(other));
                out.push('\n');
            }
        }
    }
}

/// A value on one line: scalars, or a flow collection.
fn inline(v: &Value) -> String {
    match v {
        Value::Null => "null".into(),
        Value::Bool(b) => b.to_string(),
        Value::Number(n) => n.to_string(),
        Value::String(s) => yaml_string(s),
        Value::Array(a) => format!("[{}]", a.iter().map(inline).collect::<Vec<_>>().join(", ")),
        Value::Object(m) => format!(
            "{{{}}}",
            sorted(m)
                .into_iter()
                .map(|(k, v)| format!("{}: {}", yaml_key(k), inline(v)))
                .collect::<Vec<_>>()
                .join(", ")
        ),
    }
}

/// A YAML double-quoted scalar with FULL escaping: `"` and `\`, every C0 control,
/// DEL and the C1 range, the YAML-1.1 line separators (U+0085 NEL, U+2028, U+2029)
/// and the BOM / non-characters (U+FEFF, U+FFFE, U+FFFF). The escapes used
/// (`\n`, `\t`, `\r`, `\uXXXX`) are common to YAML 1.1, 1.2 and JSON, so every
/// parser kubectl or the apiserver might use reads back the exact string.
pub(crate) fn yaml_string(s: &str) -> String {
    let mut o = String::with_capacity(s.len() + 2);
    o.push('"');
    for c in s.chars() {
        match c {
            '"' => o.push_str("\\\""),
            '\\' => o.push_str("\\\\"),
            '\n' => o.push_str("\\n"),
            '\t' => o.push_str("\\t"),
            '\r' => o.push_str("\\r"),
            c if (c as u32) < 0x20
                || (0x7f..=0x9f).contains(&(c as u32))
                || matches!(
                    c,
                    '\u{2028}' | '\u{2029}' | '\u{feff}' | '\u{fffe}' | '\u{ffff}'
                ) =>
            {
                o.push_str(&format!("\\u{:04X}", c as u32));
            }
            c => o.push(c),
        }
    }
    o.push('"');
    o
}

/// A mapping key: bare only when it is a plain identifier-ish token that no YAML
/// 1.1 parser reads as a boolean or null (`on`, `Y`, `NO`, `null`, …); otherwise
/// quoted like any other string.
fn yaml_key(k: &str) -> String {
    let plain = k
        .chars()
        .next()
        .is_some_and(|c| c.is_ascii_alphabetic() || c == '_')
        && k.chars()
            .all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '.' | '/' | '-'))
        && !matches!(
            k.to_ascii_lowercase().as_str(),
            "y" | "n" | "yes" | "no" | "true" | "false" | "on" | "off" | "null"
        );
    if plain { k.to_string() } else { yaml_string(k) }
}

/// The Kubernetes renderer. Stateless — the target namespace and shape come from
/// the [`ServiceSpec`] (`namespace`, `replicas`, `stateful`, `storage`, `image`).
pub struct KubernetesRenderer;

impl KubernetesRenderer {
    fn namespace(spec: &ServiceSpec) -> &str {
        spec.namespace.as_deref().unwrap_or("default")
    }

    fn image(spec: &ServiceSpec) -> String {
        // A k8s workload needs an image; if the spec didn't set one, fall back to
        // `<name>:latest` (honest placeholder — the caller should set `.image`).
        spec.image
            .clone()
            .unwrap_or_else(|| format!("{}:latest", spec.name))
    }

    fn kind(spec: &ServiceSpec) -> &'static str {
        if spec.stateful {
            "StatefulSet"
        } else {
            "Deployment"
        }
    }

    /// Stinky #17: the name is an object name, a label value and a Service name, so it
    /// must be a DNS-1035 label (`[a-z]([-a-z0-9]*[a-z0-9])?`, ≤ 63); the namespace a
    /// DNS-1123 label; a referenced Secret a DNS-1123 subdomain.
    fn check_names(spec: &ServiceSpec) -> anyhow::Result<()> {
        fn label(s: &str, letter_first: bool) -> bool {
            let b = s.as_bytes();
            let inner = |c: &u8| c.is_ascii_lowercase() || c.is_ascii_digit() || *c == b'-';
            !b.is_empty()
                && b.len() <= 63
                && (b[0].is_ascii_lowercase() || (!letter_first && b[0].is_ascii_digit()))
                && b.iter().all(inner)
                && b[b.len() - 1] != b'-'
        }
        if !label(&spec.name, true) {
            anyhow::bail!(
                "k8s: service name `{}` is not a DNS-1035 label ([a-z]([-a-z0-9]*[a-z0-9])?, <= 63)",
                spec.name.escape_debug()
            );
        }
        if let Some(ns) = &spec.namespace {
            if !label(ns, false) {
                anyhow::bail!(
                    "k8s: namespace `{}` is not a DNS-1123 label",
                    ns.escape_debug()
                );
            }
        }
        for s in &spec.env_from_secrets {
            if s.len() > 253 || !s.split('.').all(|part| label(part, false)) {
                anyhow::bail!(
                    "k8s: secret `{}` is not a DNS-1123 subdomain",
                    s.escape_debug()
                );
            }
        }
        let mut keys = std::collections::BTreeSet::new();
        for f in &spec.config_files {
            let key = Self::config_key(&f.path);
            let ok = key.len() <= 253
                && key
                    .bytes()
                    .all(|c| c.is_ascii_alphanumeric() || matches!(c, b'-' | b'_' | b'.'));
            if !ok {
                anyhow::bail!(
                    "k8s: config file `{}` gives ConfigMap key `{}`, which is not [-._a-zA-Z0-9]+ (<= 253)",
                    f.path.escape_debug(),
                    key.escape_debug()
                );
            }
            if !keys.insert(key.clone()) {
                anyhow::bail!(
                    "k8s: two config files map to the same ConfigMap key `{key}`; rename one"
                );
            }
        }
        Ok(())
    }

    /// The `<name>-config` ConfigMap key for a config file: its absolute path with
    /// the leading `/` dropped and every other `/` turned into `_`
    /// (`/etc/bifrost/m.yml` → `etc_bifrost_m.yml`).
    fn config_key(path: &str) -> String {
        path.trim_start_matches('/').replace('/', "_")
    }

    /// Every [`ServiceSpec::config_files`] entry, keyed by [`Self::config_key`], as one
    /// ConfigMap the workload mounts.
    fn config_map(spec: &ServiceSpec) -> Value {
        let data: Map<String, Value> = spec
            .config_files
            .iter()
            .map(|f| (Self::config_key(&f.path), json!(f.contents)))
            .collect();
        json!({
            "apiVersion": "v1",
            "kind": "ConfigMap",
            "metadata": Self::metadata(spec, &format!("{}-config", spec.name)),
            "data": data,
        })
    }

    /// SHA-256 over every config file's path, mode and contents. Stamped on the pod
    /// template, so a changed file changes the template and `apply` rolls the
    /// workload; `subPath` mounts never see a ConfigMap update on their own.
    fn config_hash(spec: &ServiceSpec) -> String {
        let mut buf = Vec::new();
        for f in &spec.config_files {
            for part in [f.path.as_bytes(), f.mode.to_string().as_bytes(), f.contents.as_bytes()] {
                buf.extend_from_slice(&(part.len() as u64).to_le_bytes());
                buf.extend_from_slice(part);
            }
        }
        nornir_hash::sha256_hex(&buf)
    }

    fn metadata(spec: &ServiceSpec, name: &str) -> Value {
        json!({
            "name": name,
            "namespace": Self::namespace(spec),
            "labels": name_label(&spec.name),
        })
    }

    /// Project the neutral [`HealthProbe`] onto the [`RolloutProbe`] the shared
    /// [`probe_action`] builds (the SAME declared knowledge the host
    /// [`crate::service::Lifecycle::health`] probes — one home, two views, L5):
    /// - [`HealthProbe::None`] ⇒ no probe (the workload's own restart handles it).
    /// - [`HealthProbe::Tcp`] ⇒ `tcpSocket: { port: <port> }`.
    /// - [`HealthProbe::TcpAt`] ⇒ the SAME `tcpSocket: { port: <port> }`: the
    ///   declared host is a HOST-side bind address, while kubelet dials the pod's
    ///   own IP, and a `tcpSocket.host` would send the probe off-pod. The host
    ///   carried for the systemd projection is therefore deliberately dropped here.
    /// - [`HealthProbe::Http`] / [`HealthProbe::HttpAt`] ⇒ `httpGet: { path, port:
    ///   <first socket> }` (the host is dropped for the same reason as `TcpAt`);
    ///   like the host HTTP probe it needs a declared listening socket for the
    ///   port, so an HTTP probe with no socket renders no probe (documented limit,
    ///   mirroring `Lifecycle::health`'s hard error for the same shape).
    /// - [`HealthProbe::Banner`] ⇒ **no probe, deliberately**: the kubelet cannot read a
    ///   protocol greeting (`tcpSocket` never reads a byte), so rendering one would
    ///   silently downgrade the question to "something is bound". Named here rather
    ///   than dropped in silence.
    fn rollout_probe(spec: &ServiceSpec) -> RolloutProbe {
        match &spec.health {
            HealthProbe::None | HealthProbe::Banner { .. } => RolloutProbe::None,
            HealthProbe::Tcp(port) | HealthProbe::TcpAt { port, .. } => RolloutProbe::Tcp(*port),
            HealthProbe::Http(path) | HealthProbe::HttpAt { path, .. } => {
                match spec.sockets.first() {
                    Some(s) => RolloutProbe::Http {
                        path: path.clone(),
                        port: s.port,
                    },
                    None => RolloutProbe::None,
                }
            }
        }
    }

    /// The Deployment / StatefulSet as data.
    pub(crate) fn workload(spec: &ServiceSpec) -> Value {
        let name = &spec.name;
        let mut container = Map::new();
        container.insert("name".into(), json!(name));
        container.insert("image".into(), json!(Self::image(spec)));
        // The spec's `exec` IS the container entrypoint (`command`); an empty exec
        // means "run the image's own ENTRYPOINT" and renders no `command`.
        if !spec.exec.is_empty() {
            container.insert("command".into(), json!([spec.exec]));
        }
        // THE RESOURCE CEILING. Kubernetes HAS the real thing, so it is rendered:
        // `resources.limits.memory` IS the hard ceiling (the kubelet writes the
        // container cgroup's `memory.max`; exceed it and the container is OOM-killed
        // and restarted by its restartPolicy — exactly the `MemoryMax=` + `Restart=`
        // pair). `resources.requests.memory` carries the soft ceiling; stated
        // honestly, that is a near-miss and not a twin — requests is a SCHEDULING
        // guarantee (what the node reserves) rather than a throttle-and-reclaim point
        // like `memory.high`. It is the only soft-side number the pod API takes, and
        // naming the difference beats dropping the declaration.
        //
        // Absent entirely when nothing is declared.
        if let Some(m) = &spec.memory {
            let mut res = Map::new();
            if let Some(v) = m.k8s_limit() {
                res.insert("limits".into(), json!({ "memory": v }));
            }
            if let Some(v) = m.k8s_request() {
                res.insert("requests".into(), json!({ "memory": v }));
            }
            if !res.is_empty() {
                container.insert("resources".into(), Value::Object(res));
            }
        }
        // THE REACTION — deliberately NOT rendered, because the pod API has nowhere
        // honest to put it, and this comment is the note that says so.
        //
        // The ceiling above maps exactly (`limits.memory` IS `memory.max`). Its other
        // half does not. `OOMPolicy=` decides what the SERVICE MANAGER does to the unit
        // when the kernel reaps one of its processes, and Kubernetes splits that
        // decision across two places a manifest cannot reach:
        //
        // * `restartPolicy` is the RESTART axis (`ServiceSpec::restart`), not this one.
        //   It says whether a dead container comes back, never whether the container
        //   should have died — so it cannot express `continue`, the whole point of the
        //   forge case.
        // * whether the kernel reaps ONE process or the container's entire cgroup
        //   (`memory.oom.group`, which is what `OOMPolicy=kill` sets) is a KUBELET-wide
        //   setting on the node, not a per-container field. A manifest that wanted to
        //   declare it would be declaring something about the node it is scheduled on.
        //
        // Rendering the nearest-looking field would be the `--oom-kill-disable` trap:
        // a manifest that reads as if the reaction were declared while the node decides
        // it. So the declaration is carried, unrendered, and named here — a gap NAMED
        // beats a gap HIDDEN, the same posture the five init backends take in their own
        // output. See `ServiceSpec::oom_policy` for the full per-backend map.
        // WHICH POD THE KERNEL PICKS — not rendered, and this one is not even a gap
        // in the manifest: Kubernetes OWNS this field. The kubelet computes each
        // container's oom_score_adj from the pod's QoS class, which is itself derived
        // from the requests/limits above — Guaranteed pods get a strongly protective
        // score, BestEffort the most sacrificial, Burstable a value scaled by the
        // memory request. Writing a number here would be overwritten by the node.
        //
        // So the honest projection of a declared bias onto Kubernetes is not a field,
        // it is the `resources:` block that is already rendered: raising the memory
        // REQUEST is how a pod becomes harder to reap. Named rather than approximated.
        // See `ServiceSpec::oom_score_adjust`.

        // THE RATE LIMIT — also NOT rendered, and for a sharper reason than the OOM
        // reaction: Kubernetes has the behaviour but does not let a manifest set it.
        // A container that keeps failing goes into CrashLoopBackOff, whose backoff is
        // exponential (roughly 10s doubling to a 5-minute cap) and is compiled into
        // the kubelet — there is no per-pod burst, no per-pod window, and crucially no
        // GIVE-UP state at all under `restartPolicy: Always`: it retries forever, by
        // design, because the control loop's job is to converge.
        //
        // So the two declarations map to opposite fates here. `never_give_up()` is
        // what Kubernetes already does, and a `give_up_after(n, _)` cannot be honoured
        // by any field in this manifest — `backoffLimit` exists only on a Job, which
        // is a different workload kind, not a knob on this Deployment's pod template.
        // Approximating it would be the `--oom-kill-disable` trap again: a manifest
        // that reads as if the service will stop retrying when it never will.
        //
        // Named here rather than rendered. See `ServiceSpec::start_limit`.
        if !spec.args.is_empty() {
            container.insert("args".into(), json!(spec.args));
        }
        if !spec.sockets.is_empty() {
            let ports: Vec<Value> = spec
                .sockets
                .iter()
                .map(|s| json!({ "containerPort": s.port }))
                .collect();
            container.insert("ports".into(), Value::Array(ports));
        }
        if !spec.env.is_empty() {
            let env: Vec<Value> = spec
                .env
                .iter()
                .map(|(k, v)| json!({ "name": k, "value": v }))
                .collect();
            container.insert("env".into(), Value::Array(env));
        }
        // envFrom.secretRef: the spec's OWN inline secrets (`secret_env`, rendered
        // as a `<name>-secret` Secret manifest below) first, then any pre-existing
        // Secrets named in `env_from_secrets`. One `envFrom:` block so a pod can
        // consume both. Absent entirely when the spec carries no secrets at all.
        let mut env_from = Vec::new();
        if !spec.secret_env.is_empty() {
            env_from.push(json!({ "secretRef": { "name": format!("{name}-secret") } }));
        }
        for s in &spec.env_from_secrets {
            env_from.push(json!({ "secretRef": { "name": s } }));
        }
        if !env_from.is_empty() {
            container.insert("envFrom".into(), Value::Array(env_from));
        }
        // Health probes (readiness + liveness) from the SAME declared HealthProbe the
        // host lifecycle probes; both gate on the one declared endpoint.
        if let Some(action) = probe_action(&Self::rollout_probe(spec)) {
            container.insert("readinessProbe".into(), action.clone());
            container.insert("livenessProbe".into(), action);
        }
        let mut pod_spec = Map::new();
        let mut mounts = Vec::new();
        let mut volumes = Vec::new();
        if let Some(st) = &spec.storage {
            mounts.push(json!({ "name": "data", "mountPath": st.mount_path }));
            volumes.push(json!({
                "name": "data",
                "persistentVolumeClaim": { "claimName": format!("{name}-data") },
            }));
        }
        if !spec.config_files.is_empty() {
            let mut items = Vec::new();
            for f in &spec.config_files {
                let key = Self::config_key(&f.path);
                mounts.push(json!({
                    "name": "config",
                    "mountPath": f.path,
                    "subPath": key,
                    "readOnly": true,
                }));
                items.push(json!({ "key": key, "path": key, "mode": f.mode }));
            }
            volumes.push(json!({
                "name": "config",
                "configMap": { "name": format!("{name}-config"), "items": items },
            }));
        }
        if !mounts.is_empty() {
            container.insert("volumeMounts".into(), Value::Array(mounts));
            pod_spec.insert("volumes".into(), Value::Array(volumes));
        }
        pod_spec.insert("containers".into(), json!([Value::Object(container)]));

        let mut wl_spec = json!({
            "replicas": spec.replicas,
            "selector": { "matchLabels": name_label(name) },
            "template": {
                "metadata": { "labels": name_label(name) },
                "spec": Value::Object(pod_spec),
            },
        });
        if spec.stateful {
            wl_spec["serviceName"] = json!(name);
        }
        if !spec.config_files.is_empty() {
            wl_spec["template"]["metadata"]["annotations"] =
                json!({ "skidbladnir/config-sha256": Self::config_hash(spec) });
        }
        json!({
            "apiVersion": "apps/v1",
            "kind": Self::kind(spec),
            "metadata": Self::metadata(spec, name),
            "spec": wl_spec,
        })
    }

    /// Project the neutral [`ServiceSpec::secret_env`] onto a k8s `Secret` — the
    /// SAME private channel the systemd renderer routes `secret_env` to (a 0600
    /// `EnvironmentFile=`), here a `<name>-secret` Secret the workload consumes via
    /// `envFrom.secretRef`. Values go in `stringData` (given in the clear; the
    /// apiserver base64-encodes them at rest), so the manifest never inlines a
    /// secret into the world-readable Deployment env.
    fn secret(spec: &ServiceSpec) -> Value {
        let data: Map<String, Value> = spec
            .secret_env
            .iter()
            .map(|(k, v)| (k.clone(), json!(v)))
            .collect();
        json!({
            "apiVersion": "v1",
            "kind": "Secret",
            "metadata": Self::metadata(spec, &format!("{}-secret", spec.name)),
            "type": "Opaque",
            "stringData": data,
        })
    }

    fn service(spec: &ServiceSpec) -> Value {
        let ports: Vec<Value> = spec
            .sockets
            .iter()
            .map(|s| json!({ "port": s.port, "targetPort": s.port }))
            .collect();
        json!({
            "apiVersion": "v1",
            "kind": "Service",
            "metadata": Self::metadata(spec, &spec.name),
            "spec": {
                "selector": name_label(&spec.name),
                "ports": ports,
            },
        })
    }

    fn pvc(spec: &ServiceSpec, size: &str) -> Value {
        json!({
            "apiVersion": "v1",
            "kind": "PersistentVolumeClaim",
            "metadata": {
                "name": format!("{}-data", spec.name),
                "namespace": Self::namespace(spec),
            },
            "spec": {
                "accessModes": ["ReadWriteOnce"],
                "resources": { "requests": { "storage": size } },
            },
        })
    }
}

impl ServiceRenderer for KubernetesRenderer {
    fn backend(&self) -> Backend {
        Backend::Kubernetes
    }

    fn render(&self, spec: &ServiceSpec) -> anyhow::Result<RenderedService> {
        spec.validate()?;
        Self::check_names(spec)?;
        let ns = Self::namespace(spec).to_string();
        let name = spec.name.clone();
        let kind_lc = if spec.stateful {
            "statefulset"
        } else {
            "deployment"
        };

        let mut files = Vec::new();
        let mut objects = vec![format!("{kind_lc}/{name}")];
        // The `<name>-secret` Secret is applied BEFORE the workload that references
        // it (envFrom.secretRef), so the pod's env is satisfiable the moment it
        // schedules. Mode 0600 — it carries secret material (matches the systemd
        // env-file posture), though on k8s the real protection is the apiserver.
        if !spec.secret_env.is_empty() {
            files.push(RenderedFile {
                path: format!("{ns}/{name}-secret.yaml"),
                contents: manifest_yaml(&Self::secret(spec)),
                mode: 0o600,
            });
            objects.push(format!("secret/{name}-secret"));
        }
        // The ConfigMap, like the Secret, exists before the workload that mounts it.
        if !spec.config_files.is_empty() {
            files.push(RenderedFile {
                path: format!("{ns}/{name}-config.yaml"),
                contents: manifest_yaml(&Self::config_map(spec)),
                mode: 0o644,
            });
            objects.push(format!("configmap/{name}-config"));
        }
        files.push(RenderedFile {
            path: format!("{ns}/{name}-{kind_lc}.yaml"),
            contents: manifest_yaml(&Self::workload(spec)),
            mode: 0o644,
        });
        // Service only makes sense with ports.
        if !spec.sockets.is_empty() {
            files.push(RenderedFile {
                path: format!("{ns}/{name}-service.yaml"),
                contents: manifest_yaml(&Self::service(spec)),
                mode: 0o644,
            });
            objects.push(format!("service/{name}"));
        }
        if let Some(st) = &spec.storage {
            files.push(RenderedFile {
                path: format!("{ns}/{name}-pvc.yaml"),
                contents: manifest_yaml(&Self::pvc(spec, &st.size)),
                mode: 0o644,
            });
            objects.push(format!("persistentvolumeclaim/{name}-data"));
        }

        // Lifecycle verbs as DATA (the async applier maps these onto the k8s API —
        // NOT literal `kubectl`). `enable` = apply every manifest; `start`/`stop` =
        // scale; `status` = rollout status; `remove` = delete every object the
        // manifests create (the Secret included), by kind/name.
        let mut enable = vec!["apply".to_string(), "-n".into(), ns.clone()];
        for f in &files {
            enable.push("-f".into());
            enable.push(f.path.clone());
        }
        let target = format!("{kind_lc}/{name}");
        Ok(RenderedService {
            backend: Backend::Kubernetes,
            files,
            enable,
            start: vec![
                "scale".into(),
                "-n".into(),
                ns.clone(),
                target.clone(),
                format!("--replicas={}", spec.replicas),
            ],
            status: vec![
                "rollout".into(),
                "status".into(),
                "-n".into(),
                ns.clone(),
                target.clone(),
            ],
            stop: vec![
                "scale".into(),
                "-n".into(),
                ns.clone(),
                target,
                "--replicas=0".into(),
            ],
            remove: ["delete", "-n", &ns, "--ignore-not-found"]
                .into_iter()
                .map(String::from)
                .chain(objects)
                .collect(),
        })
    }
}

#[cfg(test)]
mod tests {
    /// Stinky #20: config files become a `<name>-config` ConfigMap, applied before
    /// the workload, mounted read-only at each path by `subPath`, with their hash on
    /// the pod template so a changed file rolls the workload; uninstall deletes it.
    #[test]
    fn config_files_render_as_a_configmap_mounted_by_subpath() {
        use crate::service::ServiceSpec;
        let spec = ServiceSpec::new("bifrost", "Bifrost", "/usr/local/bin/bifrost")
            .image("bifrost:1")
            .namespace("lake")
            .config_file("/etc/bifrost/m.yml", "graph: lake\n");
        let r = KubernetesRenderer.render(&spec).unwrap();
        assert_eq!(r.files[0].path, "lake/bifrost-config.yaml", "ConfigMap applied first");
        let cm = KubernetesRenderer::config_map(&spec);
        assert_eq!(cm["kind"], "ConfigMap");
        assert_eq!(cm["metadata"]["name"], "bifrost-config");
        assert_eq!(cm["data"]["etc_bifrost_m.yml"], "graph: lake\n");
        assert!(r.file_ending("bifrost-deployment.yaml").is_some());
        let dep = KubernetesRenderer::workload(&spec);
        let pod = &dep["spec"]["template"];
        let mount = &pod["spec"]["containers"][0]["volumeMounts"][0];
        assert_eq!(mount["mountPath"], "/etc/bifrost/m.yml");
        assert_eq!(mount["subPath"], "etc_bifrost_m.yml");
        assert_eq!(mount["readOnly"], true);
        let vol = &pod["spec"]["volumes"][0];
        assert_eq!(vol["configMap"]["name"], "bifrost-config");
        assert_eq!(vol["configMap"]["items"][0]["mode"], 0o644);
        let hash = pod["metadata"]["annotations"]["skidbladnir/config-sha256"].clone();
        assert!(hash.as_str().is_some_and(|h| h.len() == 64), "{hash}");
        assert!(r.remove.contains(&"configmap/bifrost-config".to_string()));

        let changed = spec.clone().config_file("/etc/bifrost/extra.yml", "x");
        let dep2 = KubernetesRenderer::workload(&changed);
        assert_ne!(dep2["spec"]["template"]["metadata"]["annotations"]["skidbladnir/config-sha256"], hash);

        let spaced = ServiceSpec::new("bifrost", "B", "/b").config_file("/etc/my m.yml", "x");
        assert!(KubernetesRenderer.render(&spaced).unwrap_err().to_string().contains("ConfigMap key"));
        let clash = ServiceSpec::new("bifrost", "B", "/b")
            .config_file("/etc/a/b", "x")
            .config_file("/etc/a_b", "y");
        assert!(KubernetesRenderer.render(&clash).unwrap_err().to_string().contains("same ConfigMap key"));
    }

    use super::*;
    use crate::service::{HealthProbe, ServiceSpec, Socket, Storage, renderer_for};

    #[test]
    fn renderer_for_returns_kubernetes() {
        let r = renderer_for(Backend::Kubernetes).expect("k8s renderer registered");
        assert_eq!(r.backend(), Backend::Kubernetes);
    }

    #[test]
    fn numeric_env_value_is_quoted_in_deployment() {
        let spec = ServiceSpec::new("svc", "d", "/unused")
            .socket(Socket::tcp(50055))
            .env("SCALE", "1")
            .env("ADDR", "0.0.0.0:50055");
        let dep = KubernetesRenderer
            .render(&spec)
            .unwrap()
            .file_ending("svc-deployment.yaml")
            .unwrap()
            .to_string();
        assert!(
            dep.contains("value: \"1\""),
            "numeric env value quoted:\n{dep}"
        );
        assert!(
            dep.contains("value: \"0.0.0.0:50055\""),
            "string env value is quoted too:\n{dep}"
        );
    }

    #[test]
    fn deployment_web_renders_deployment_and_service() {
        let spec = ServiceSpec::new("web", "Tillsynia web", "/unused")
            .image("registry.local:5000/njord-web:v1")
            .namespace("njord-lab")
            .replicas(2)
            .socket(Socket::tcp(3000));
        let r = KubernetesRenderer.render(&spec).unwrap();

        assert_eq!(r.backend, Backend::Kubernetes);
        // deployment + service, no pvc
        assert_eq!(r.files.len(), 2);
        let dep = r.file_ending("web-deployment.yaml").unwrap();
        assert!(dep.contains("kind: \"Deployment\""));
        assert!(dep.contains("namespace: \"njord-lab\""));
        assert!(dep.contains("replicas: 2"));
        assert!(dep.contains("image: \"registry.local:5000/njord-web:v1\""));
        assert!(dep.contains("- containerPort: 3000"));
        let svc = r.file_ending("web-service.yaml").unwrap();
        assert!(svc.contains("kind: \"Service\""));
        assert!(svc.contains("- port: 3000"));
        assert!(svc.contains("targetPort: 3000"));

        // verbs as data
        assert_eq!(r.enable[0], "apply");
        assert!(r.enable.contains(&"njord-lab".to_string()));
        assert_eq!(
            r.start,
            vec!["scale", "-n", "njord-lab", "deployment/web", "--replicas=2"]
        );
        assert_eq!(r.stop.last().unwrap(), "--replicas=0");
        assert_eq!(
            r.status,
            vec!["rollout", "status", "-n", "njord-lab", "deployment/web"]
        );
    }

    #[test]
    fn env_from_secret_renders_secretref() {
        let spec = ServiceSpec::new("web", "Tillsynia web", "/unused")
            .image("registry.local:5000/njord-web:v1")
            .namespace("test1")
            .env("BASE_URL", "https://test1.g11.se")
            .env_from_secret("test1-app")
            .env_from_secret("test1-extra")
            .socket(Socket::tcp(3000));
        let dep = KubernetesRenderer
            .render(&spec)
            .unwrap()
            .file_ending("web-deployment.yaml")
            .unwrap()
            .to_string();
        // inline env AND envFrom co-render
        assert!(
            dep.contains("value: \"https://test1.g11.se\""),
            "inline env still renders"
        );
        assert!(dep.contains("envFrom:"), "envFrom block present");
        assert!(
            dep.contains("secretRef:") && dep.contains("name: \"test1-app\""),
            "first secret"
        );
        assert!(
            dep.contains("name: \"test1-extra\""),
            "second secret (iterates)"
        );
    }

    #[test]
    fn env_from_secret_absent_when_unset() {
        let spec = ServiceSpec::new("web", "Tillsynia web", "/unused")
            .image("x:1")
            .namespace("test1")
            .socket(Socket::tcp(3000));
        let dep = KubernetesRenderer
            .render(&spec)
            .unwrap()
            .file_ending("web-deployment.yaml")
            .unwrap()
            .to_string();
        assert!(
            !dep.contains("envFrom:"),
            "no envFrom when none set (backward compat)"
        );
    }

    #[test]
    fn secret_env_renders_secret_manifest_and_secretref() {
        let spec = ServiceSpec::new("postgres", "Postgres", "/unused")
            .image("postgres:16")
            .namespace("njord-lab")
            .env("POSTGRES_USER", "njord")
            .secret_env("POSTGRES_PASSWORD", "s3cr3t")
            .socket(Socket::tcp(5432));
        let r = KubernetesRenderer.render(&spec).unwrap();
        // A dedicated Secret manifest is emitted, BEFORE the workload.
        assert_eq!(
            r.files[0].path, "njord-lab/postgres-secret.yaml",
            "secret applied first"
        );
        assert_eq!(r.files[0].mode, 0o600, "secret file mode is 0600");
        let sec = r.file_ending("postgres-secret.yaml").unwrap();
        assert!(sec.contains("kind: \"Secret\""));
        assert!(sec.contains("name: \"postgres-secret\""));
        assert!(sec.contains("namespace: \"njord-lab\""));
        assert!(sec.contains("type: \"Opaque\""));
        assert!(sec.contains("stringData:"));
        assert!(
            sec.contains("POSTGRES_PASSWORD: \"s3cr3t\""),
            "secret value in stringData"
        );
        // The workload references it via envFrom.secretRef and does NOT inline it.
        let dep = r.file_ending("postgres-deployment.yaml").unwrap();
        assert!(dep.contains("envFrom:"));
        assert!(dep.contains("secretRef:") && dep.contains("name: \"postgres-secret\""));
        assert!(
            !dep.contains("s3cr3t"),
            "secret value must NOT be inline in the workload env"
        );
        // Public env still renders inline.
        assert!(dep.contains("- name: \"POSTGRES_USER\""));
        assert!(dep.contains("value: \"njord\""));
    }

    #[test]
    fn secret_env_and_env_from_secrets_share_one_envfrom_block() {
        let spec = ServiceSpec::new("web", "web", "/unused")
            .image("web:1")
            .namespace("test1")
            .secret_env("TOKEN", "abc")
            .env_from_secret("external-app")
            .socket(Socket::tcp(3000));
        let dep = KubernetesRenderer
            .render(&spec)
            .unwrap()
            .file_ending("web-deployment.yaml")
            .unwrap()
            .to_string();
        assert_eq!(
            dep.matches("envFrom:").count(),
            1,
            "exactly one envFrom block"
        );
        // The spec's own <name>-secret comes first, then the external secret.
        let own = dep.find("name: \"web-secret\"").expect("own secret ref");
        let ext = dep
            .find("name: \"external-app\"")
            .expect("external secret ref");
        assert!(
            own < ext,
            "own <name>-secret is listed before external secrets"
        );
    }

    #[test]
    fn no_secret_env_omits_secret_manifest() {
        let spec = ServiceSpec::new("web", "web", "/unused")
            .image("web:1")
            .namespace("test1")
            .socket(Socket::tcp(3000));
        let r = KubernetesRenderer.render(&spec).unwrap();
        assert!(
            r.file_ending("web-secret.yaml").is_none(),
            "no Secret manifest when no secret_env"
        );
        let dep = r.file_ending("web-deployment.yaml").unwrap();
        assert!(
            !dep.contains("envFrom:"),
            "no envFrom when no secrets (backward compat)"
        );
    }

    /// Stinky #21: uninstall deletes EVERY object the manifests created — the
    /// Secret included — by kind/name, so it works after the files are gone.
    #[test]
    fn remove_deletes_every_object_including_the_secret() {
        let spec = ServiceSpec::new("postgres", "Postgres", "/unused")
            .image("postgres:16")
            .namespace("njord-lab")
            .stateful(true)
            .secret_env("POSTGRES_PASSWORD", "s3cr3t")
            .socket(Socket::tcp(5432))
            .storage(Storage::new("8Gi", "/var/lib/postgresql/data"));
        let r = KubernetesRenderer.render(&spec).unwrap();
        assert_eq!(
            r.remove,
            [
                "delete",
                "-n",
                "njord-lab",
                "--ignore-not-found",
                "statefulset/postgres",
                "secret/postgres-secret",
                "service/postgres",
                "persistentvolumeclaim/postgres-data",
            ]
        );
        assert_eq!(r.files.len(), r.remove.len() - 4, "one object per manifest");
    }

    #[test]
    fn stateful_postgres_renders_statefulset_and_pvc() {
        let spec = ServiceSpec::new("postgres", "Postgres", "/unused")
            .image("postgres:16")
            .namespace("njord-lab")
            .stateful(true)
            .socket(Socket::tcp(5432))
            .storage(Storage::new("8Gi", "/var/lib/postgresql/data"));
        let r = KubernetesRenderer.render(&spec).unwrap();

        assert_eq!(r.files.len(), 3); // statefulset + service + pvc
        let ss = r.file_ending("postgres-statefulset.yaml").unwrap();
        assert!(ss.contains("kind: \"StatefulSet\""));
        assert!(ss.contains("serviceName: \"postgres\""));
        assert!(ss.contains("mountPath: \"/var/lib/postgresql/data\""));
        assert!(ss.contains("claimName: \"postgres-data\""));
        let pvc = r.file_ending("postgres-pvc.yaml").unwrap();
        assert!(pvc.contains("kind: \"PersistentVolumeClaim\""));
        assert!(pvc.contains("storage: \"8Gi\""));
        assert_eq!(r.start[3], "statefulset/postgres");
    }

    #[test]
    fn env_and_args_render_when_present() {
        let spec = ServiceSpec::new("web", "w", "/unused")
            .image("img:1")
            .arg("--serve")
            .env("BASE_URL", "https://lab.g11.se")
            .socket(Socket::tcp(3000));
        let dep = KubernetesRenderer
            .render(&spec)
            .unwrap()
            .file_ending("web-deployment.yaml")
            .unwrap()
            .to_string();
        assert!(dep.contains("args:\n            - \"--serve\""));
        assert!(dep.contains("- name: \"BASE_URL\""));
        assert!(dep.contains("value: \"https://lab.g11.se\""));
    }

    /// Defaults & honest fallbacks: no `namespace` ⇒ `default` (and the manifest
    /// file paths sit under `default/`); no `image` ⇒ the `<name>:latest`
    /// placeholder; `replicas` defaults to `1` (via the registered renderer path).
    #[test]
    fn default_namespace_and_image_fallback_and_replicas() {
        let spec = ServiceSpec::new("web", "w", "/unused").socket(Socket::tcp(80));
        let r = renderer_for(Backend::Kubernetes)
            .unwrap()
            .render(&spec)
            .unwrap();
        let dep = r.file_ending("web-deployment.yaml").unwrap();
        assert!(
            dep.contains("namespace: \"default\""),
            "no namespace ⇒ default"
        );
        assert!(
            dep.contains("image: \"web:latest\""),
            "no image ⇒ <name>:latest placeholder"
        );
        assert!(dep.contains("replicas: 1"), "replicas defaults to 1");
        // The default namespace also prefixes the emitted file paths + the verbs.
        assert_eq!(r.files[0].path, "default/web-deployment.yaml");
        assert!(r.enable.contains(&"default".to_string()));
        assert_eq!(
            r.start,
            vec!["scale", "-n", "default", "deployment/web", "--replicas=1"]
        );
    }

    /// A spec with NO sockets renders ONLY the workload (a headless job/worker has
    /// nothing to expose) — the Service manifest is omitted, not emitted empty. The
    /// lifecycle verbs are still produced.
    #[test]
    fn no_sockets_omits_service_manifest() {
        let spec = ServiceSpec::new("worker", "batch worker", "/unused")
            .image("worker:1")
            .namespace("jobs");
        let r = KubernetesRenderer.render(&spec).unwrap();
        assert_eq!(r.files.len(), 1, "only the workload, no Service");
        assert!(r.file_ending("worker-deployment.yaml").is_some());
        assert!(
            r.file_ending("worker-service.yaml").is_none(),
            "no ports ⇒ no Service"
        );
        assert_eq!(
            r.status,
            vec!["rollout", "status", "-n", "jobs", "deployment/worker"]
        );
    }

    /// A declared TCP [`HealthProbe`] (the SAME knob the host `Lifecycle::health`
    /// probes) projects onto k8s `readinessProbe` + `livenessProbe` `tcpSocket`
    /// blocks — so k8s gates Service routing + restart on the same endpoint. RED if
    /// the renderer drops the health knob (as it did before this slice).
    #[test]
    fn tcp_health_renders_readiness_and_liveness_tcp_socket() {
        let spec = ServiceSpec::new("web", "w", "/unused")
            .image("img:1")
            .namespace("ns")
            .socket(Socket::tcp(7070))
            .health(HealthProbe::Tcp(7070));
        let dep = KubernetesRenderer
            .render(&spec)
            .unwrap()
            .file_ending("web-deployment.yaml")
            .unwrap()
            .to_string();
        assert!(
            dep.contains("readinessProbe:"),
            "readiness probe rendered from health"
        );
        assert!(
            dep.contains("livenessProbe:"),
            "liveness probe rendered from health"
        );
        // Both actions are the tcpSocket form (two occurrences: readiness + liveness).
        assert_eq!(
            dep.matches("tcpSocket:").count(),
            2,
            "both probes use tcpSocket"
        );
        assert!(
            dep.contains("            tcpSocket:\n              port: 7070\n"),
            "tcpSocket targets the declared health port"
        );
        // No httpGet leaked into a TCP probe.
        assert!(
            !dep.contains("httpGet:"),
            "TCP health must not render httpGet"
        );
    }

    /// A host-carrying [`HealthProbe::TcpAt`] renders the SAME `tcpSocket` block as
    /// the port-only [`HealthProbe::Tcp`]: the host on the probe is the address the
    /// service binds ON A HOST, while kubelet dials the pod's own IP — a
    /// `tcpSocket.host` here would aim the probe off-pod. So the k8s render is
    /// byte-identical, and only the systemd/host projection uses the host.
    #[test]
    fn tcp_at_health_renders_the_same_tcp_socket_as_a_port_only_probe() {
        let render = |health: HealthProbe| {
            let spec = ServiceSpec::new("web", "w", "/unused")
                .image("img:1")
                .namespace("ns")
                .socket(Socket::tcp(7070))
                .health(health);
            KubernetesRenderer
                .render(&spec)
                .unwrap()
                .file_ending("web-deployment.yaml")
                .unwrap()
                .to_string()
        };
        assert_eq!(
            render(HealthProbe::tcp_at("100.115.167.95", 7070)),
            render(HealthProbe::Tcp(7070)),
            "the pod-internal probe is the port; the bound host is host-side only"
        );
    }

    /// A declared HTTP [`HealthProbe`] projects onto `httpGet` with the path AND the
    /// first declared socket's port (same port-resolution rule as the host HTTP
    /// probe). RED if the path is dropped or the port isn't taken from the socket.
    #[test]
    fn http_health_renders_httpget_with_path_and_socket_port() {
        let spec = ServiceSpec::new("api", "a", "/unused")
            .image("img:1")
            .namespace("ns")
            .socket(Socket::tcp(8080))
            .health(HealthProbe::Http("/healthz".into()));
        let dep = KubernetesRenderer
            .render(&spec)
            .unwrap()
            .file_ending("api-deployment.yaml")
            .unwrap()
            .to_string();
        assert_eq!(
            dep.matches("httpGet:").count(),
            2,
            "readiness + liveness httpGet"
        );
        assert!(
            dep.contains(
                "            httpGet:\n              path: \"/healthz\"\n              port: 8080\n"
            ),
            "httpGet carries the path and the first socket's port, got:\n{dep}"
        );
        // A relative path is normalised to a leading slash (mirrors host http_probe).
        let rel = ServiceSpec::new("api", "a", "/unused")
            .image("img:1")
            .socket(Socket::tcp(9000))
            .health(HealthProbe::Http("readyz".into()));
        let dep2 = KubernetesRenderer
            .render(&rel)
            .unwrap()
            .file_ending("api-deployment.yaml")
            .unwrap()
            .to_string();
        assert!(
            dep2.contains("path: \"/readyz\""),
            "relative probe path gets a leading slash"
        );
    }

    /// Honest limit, mirroring `Lifecycle::health`: an HTTP probe needs a declared
    /// socket for the port, so an HTTP health with NO socket renders no probe (rather
    /// than an invalid portless httpGet). `HealthProbe::None` likewise renders none.
    #[test]
    fn http_health_without_socket_and_none_render_no_probe() {
        let no_socket = ServiceSpec::new("api", "a", "/unused")
            .image("img:1")
            .health(HealthProbe::Http("/healthz".into()));
        let dep = KubernetesRenderer
            .render(&no_socket)
            .unwrap()
            .file_ending("api-deployment.yaml")
            .unwrap()
            .to_string();
        assert!(
            !dep.contains("Probe:"),
            "HTTP probe with no socket renders no probe"
        );

        let none = ServiceSpec::new("web", "w", "/unused")
            .image("img:1")
            .socket(Socket::tcp(80));
        let dep_none = KubernetesRenderer
            .render(&none)
            .unwrap()
            .file_ending("web-deployment.yaml")
            .unwrap()
            .to_string();
        assert!(
            !dep_none.contains("Probe:"),
            "HealthProbe::None renders no probe (backward compat)"
        );
    }

    /// Determinism contract: the module promises byte-stable YAML so manifests are
    /// diffable/GitOps-committable. Assert the EXACT bytes of a minimal Deployment
    /// (key order, indentation, trailing newline) so any accidental reordering is a
    /// hard regression, not a silent drift.
    #[test]
    fn deployment_yaml_is_byte_exact() {
        let spec = ServiceSpec::new("web", "Web", "/bin/web")
            .image("img:1")
            .namespace("ns")
            .replicas(2)
            .socket(Socket::tcp(8080));
        let dep = KubernetesRenderer
            .render(&spec)
            .unwrap()
            .file_ending("web-deployment.yaml")
            .unwrap()
            .to_string();
        let expected = "\
apiVersion: \"apps/v1\"
kind: \"Deployment\"
metadata:
  labels:
    app.kubernetes.io/name: \"web\"
  name: \"web\"
  namespace: \"ns\"
spec:
  replicas: 2
  selector:
    matchLabels:
      app.kubernetes.io/name: \"web\"
  template:
    metadata:
      labels:
        app.kubernetes.io/name: \"web\"
    spec:
      containers:
        - command:
            - \"/bin/web\"
          image: \"img:1\"
          name: \"web\"
          ports:
            - containerPort: 8080
";
        assert_eq!(dep, expected, "k8s Deployment YAML must be byte-stable");
    }

    /// Stinky #17: names land in object names, label values and file paths; k8s
    /// accepts only DNS labels there, so anything else is refused up front.
    #[test]
    fn k8s_refuses_names_that_are_not_dns_labels() {
        for name in ["Web", "web_1", "1web", "-web", &"a".repeat(64)] {
            let spec = ServiceSpec::new(name, "d", "");
            assert!(KubernetesRenderer.render(&spec).is_err(), "name {name:?}");
        }
        for ns in ["Prod", "a.b", "", &"n".repeat(64)] {
            let spec = ServiceSpec::new("web", "d", "").namespace(ns);
            assert!(KubernetesRenderer.render(&spec).is_err(), "namespace {ns:?}");
        }
        let spec = ServiceSpec::new("web", "d", "").env_from_secret("Bad Secret");
        assert!(KubernetesRenderer.render(&spec).is_err());
        let spec = ServiceSpec::new("web-1", "d", "").namespace("prod-2").env_from_secret("app.creds");
        assert!(KubernetesRenderer.render(&spec).is_ok());
    }

    /// Stinky #5: Secret `stringData` was `{k}: {v}` raw — `123456` became an int
    /// (apiserver rejects), `pw #x` was cut at the comment, `*x` read as an alias and
    /// a newline injected a new key. Every value must reach the cluster as the SAME
    /// string, and a YAML-1.1 boolean-looking key (`ON`, `N`) must stay a string key.
    #[test]
    fn secret_values_are_always_quoted_strings() {
        let spec = ServiceSpec::new("svc", "d", "")
            .secret_env("N", "123456")
            .secret_env("H", "pw #x")
            .secret_env("A", "*x")
            .secret_env("I", "a\nb: c")
            .secret_env("ON", "yes");
        let r = KubernetesRenderer.render(&spec).unwrap();
        let sec = r.file_ending("svc-secret.yaml").unwrap();
        assert!(sec.contains("  \"N\": \"123456\"\n"), "{sec}");
        assert!(sec.contains("  H: \"pw #x\"\n"), "{sec}");
        assert!(sec.contains("  A: \"*x\"\n"), "{sec}");
        assert!(sec.contains("  I: \"a\\nb: c\"\n"), "{sec}");
        assert!(sec.contains("  \"ON\": \"yes\"\n"), "{sec}");
        assert!(
            !sec.contains("\nb: c"),
            "newline must not inject a key:\n{sec}"
        );
    }

    /// Stinky #18: env values with `: `, ` #`, a newline, a leading `- `, a C0
    /// control char or `.inf` were left bare and re-parsed as something else.
    #[test]
    fn env_values_are_always_quoted_with_full_escaping() {
        let spec = ServiceSpec::new("svc", "d", "")
            .env("A", "x: y")
            .env("B", "x #c")
            .env("C", "l1\nl2")
            .env("D", "- item")
            .env("E", "u\u{1f}v\u{85}w\u{feff}")
            .env("F", ".inf")
            .env("G", "q\"b\\s");
        let dep = KubernetesRenderer
            .render(&spec)
            .unwrap()
            .file_ending("svc-deployment.yaml")
            .unwrap()
            .to_string();
        for want in [
            "value: \"x: y\"",
            "value: \"x #c\"",
            "value: \"l1\\nl2\"",
            "value: \"- item\"",
            "value: \"u\\u001Fv\\u0085w\\uFEFF\"",
            "value: \".inf\"",
            "value: \"q\\\"b\\\\s\"",
        ] {
            assert!(dep.contains(want), "missing {want}:\n{dep}");
        }
    }

    /// Stinky #19: `args` and `mountPath` were written raw.
    #[test]
    fn args_and_mount_path_are_quoted() {
        let spec = ServiceSpec::new("svc", "d", "")
            .arg("--x: y")
            .arg("#c")
            .storage(Storage::new("1Gi", "*m"));
        let dep = KubernetesRenderer
            .render(&spec)
            .unwrap()
            .file_ending("svc-deployment.yaml")
            .unwrap()
            .to_string();
        assert!(dep.contains("- \"--x: y\"\n"), "{dep}");
        assert!(dep.contains("- \"#c\"\n"), "{dep}");
        assert!(dep.contains("mountPath: \"*m\"\n"), "{dep}");
    }

    /// Stinky #27: `spec.exec` was silently dropped. A non-empty exec is the
    /// container `command`; an empty exec means "the image's own entrypoint".
    #[test]
    fn exec_renders_as_container_command_when_set() {
        let with = ServiceSpec::new("svc", "d", "/usr/local/bin/bifrost").arg("serve");
        let dep = KubernetesRenderer
            .render(&with)
            .unwrap()
            .file_ending("svc-deployment.yaml")
            .unwrap()
            .to_string();
        assert!(
            dep.contains("          command:\n            - \"/usr/local/bin/bifrost\"\n"),
            "{dep}"
        );
        let without = ServiceSpec::new("svc", "d", "").arg("serve");
        let dep = KubernetesRenderer
            .render(&without)
            .unwrap()
            .file_ending("svc-deployment.yaml")
            .unwrap()
            .to_string();
        assert!(!dep.contains("command:"), "{dep}");
    }

    /// Stinky #27: ONE builder — the sync renderer's probe block is the SAME value
    /// `kube_runtime` builds for a rollout, not a second hand-rolled copy.
    #[test]
    fn probes_come_from_the_shared_kube_runtime_builder() {
        use crate::kube_runtime::{RolloutProbe, probe_action};
        let spec = ServiceSpec::new("api", "a", "")
            .socket(Socket::tcp(8080))
            .health(HealthProbe::Http("healthz".into()));
        let w = KubernetesRenderer::workload(&spec);
        let c = &w["spec"]["template"]["spec"]["containers"][0];
        let want = probe_action(&RolloutProbe::Http {
            path: "healthz".into(),
            port: 8080,
        })
        .unwrap();
        assert_eq!(c["readinessProbe"], want);
        assert_eq!(c["livenessProbe"], want);
    }

    /// Every rendered manifest parses back (with a real YAML parser) to exactly
    /// the value the builder produced — the strings survive as strings.
    #[cfg(feature = "kubernetes")]
    #[test]
    fn every_manifest_round_trips_through_a_yaml_parser() {
        let spec = ServiceSpec::new("svc", "d", "/bin/x")
            .arg("--x: y")
            .arg("#c")
            .env("A", "123")
            .env("B", "l1\nl2 #c")
            .secret_env("ON", "*x")
            .secret_env("N", "123456")
            .socket(Socket::tcp(80))
            .health(HealthProbe::Tcp(80))
            .stateful(true)
            .storage(Storage::new("1Gi", "*m"));
        let r = KubernetesRenderer.render(&spec).unwrap();
        for f in &r.files {
            let parsed: serde_json::Value = serde_yaml::from_str(&f.contents)
                .unwrap_or_else(|e| panic!("{}: {e}\n{}", f.path, f.contents));
            assert!(parsed["kind"].is_string(), "{}", f.path);
            assert_eq!(manifest_yaml(&parsed), f.contents, "{}", f.path);
        }
        let sec: serde_json::Value =
            serde_yaml::from_str(r.file_ending("svc-secret.yaml").unwrap()).unwrap();
        assert_eq!(sec["stringData"]["ON"], "*x");
        assert_eq!(sec["stringData"]["N"], "123456");
        let ss: serde_json::Value =
            serde_yaml::from_str(r.file_ending("svc-statefulset.yaml").unwrap()).unwrap();
        let c = &ss["spec"]["template"]["spec"]["containers"][0];
        assert_eq!(c["args"], serde_json::json!(["--x: y", "#c"]));
        assert_eq!(c["command"], serde_json::json!(["/bin/x"]));
        assert_eq!(c["env"][1]["value"], "l1\nl2 #c");
        assert_eq!(c["volumeMounts"][0]["mountPath"], "*m");
    }
}