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");
}
}