src/provision.rs
//! Reusable **postgres (StatefulSet backend) provisioner** — the kube-rs
//! stand-up of a postgres primary (password Secret + headless/ClusterIP Services
//! + StatefulSet + ServiceAccount) modelled **field-for-field on the live render
//! of the bitnami `postgresql` OCI subchart**, so a consumer that used to lean on
//! `helm`'s OCI fetch + Go/Sprig engine to render that subchart can bring the same
//! database up over the typed kube API with **no shell, no helm, no OCI pull**.
//!
//! Extracted from the Njord deployer (issue #170) as the generic half. `helm
//! template` could not render `postgresql` without helm's own OCI fetch + engine,
//! and that was THE documented blocker keeping one `helm upgrade --install` on a
//! consumer's apply path. This module removes it: it stands the postgres
//! StatefulSet + Services + Secret up directly, modelled on the captured live
//! render (image `bitnamilegacy/postgresql:17.6.0`, chart `postgresql-16.7.27`),
//! so a bring-up into an EMPTY namespace raises the database identically to the
//! way an existing helm-provisioned slot's did. The consumer keeps only its own
//! render + credential sourcing and passes the field `manager` it wants stamped.
//!
//! **Stateful create-if-absent, never rewrite.** The password Secret and the
//! StatefulSet are POSTed only when missing; an existing database is left
//! untouched (rewriting the Secret would rotate the password out from under a
//! running postgres, and the StatefulSet's selector/volumeClaimTemplates are
//! immutable anyway). The two Services ARE server-side-applied — they carry no
//! state and reconciling them is safe. For an existing slot the whole call is
//! therefore an idempotent no-op that just confirms presence.
#[cfg(feature = "kubernetes")]
use anyhow::{Context, Result};
#[cfg(feature = "kubernetes")]
use k8s_openapi::api::apps::v1::StatefulSet;
#[cfg(feature = "kubernetes")]
use k8s_openapi::api::core::v1::{Secret, Service, ServiceAccount};
#[cfg(feature = "kubernetes")]
use kube::Client;
#[cfg(feature = "kubernetes")]
use kube::api::{Api, Patch, PatchParams, PostParams};
use serde_json::{Value, json};
/// The postgres container image — the captured live bitnami render's image, held
/// here because "modelled field-for-field on the bitnami render" makes it
/// intrinsic to this provisioner (not a caller knob).
const PG_IMAGE: &str = "docker.io/bitnamilegacy/postgresql:17.6.0-debian-12-r4";
/// Postgres credentials for a slot — the app user's password (the DB the app
/// connects as) and the postgres superuser password. Sourced by the caller (from
/// its site vault); modelled on the bitnami Secret which carries exactly these
/// two keys.
#[derive(Debug, Clone)]
pub struct PgCreds {
/// `POSTGRES_USER` — the app role (bitnami `auth.username`, e.g. `njord`).
pub username: String,
/// The app role's password → Secret key `password`.
pub password: String,
/// The superuser password → Secret key `postgres-password`.
pub postgres_password: String,
/// `POSTGRES_DATABASE`.
pub database: String,
}
/// Provision (create-if-absent) postgres for `release` in `ns`, stamping
/// `manager` as both the field manager (server-side apply) and the
/// `app.kubernetes.io/managed-by` label.
///
/// `storage` is the PVC size (bitnami `primary.persistence.size`, e.g. `8Gi`).
/// With `dry_run` the call touches nothing and returns a single PREVIEW line.
/// Returns human-readable lines describing what was created vs already present.
#[cfg(feature = "kubernetes")]
pub async fn postgres(
client: &Client,
ns: &str,
release: &str,
creds: &PgCreds,
storage: &str,
manager: &str,
dry_run: bool,
) -> Result<Vec<String>> {
let name = format!("{release}-postgresql");
let hl = format!("{release}-postgresql-hl");
let mut lines = Vec::new();
if dry_run {
lines.push(format!(
"PREVIEW · postgres: skulle säkra Secret/{name}, Service/{name}, Service/{hl} och StatefulSet/{name} \
(skapa-om-saknas; en befintlig databas rörs inte), image bitnamilegacy/postgresql:17.6.0, PVC {storage}"
));
return Ok(lines);
}
// ── Secret (create-if-absent — NEVER rewrite a live password) ─────────────
let secrets: Api<Secret> = Api::namespaced(client.clone(), ns);
if secrets
.get_opt(&name)
.await
.context("slå upp postgres-Secret")?
.is_some()
{
lines.push(format!("postgres: Secret/{name} finns redan (rörs inte)"));
} else {
let sec: Secret = serde_json::from_value(json!({
"apiVersion": "v1",
"kind": "Secret",
"metadata": { "name": name, "namespace": ns, "labels": pg_labels(release, manager) },
"type": "Opaque",
"stringData": {
"password": creds.password,
"postgres-password": creds.postgres_password,
},
}))?;
secrets
.create(&PostParams::default(), &sec)
.await
.with_context(|| format!("skapa Secret/{name}"))?;
lines.push(format!("postgres: Secret/{name} skapad"));
}
// ── Services (server-side-apply — stateless, safe to reconcile) ───────────
let svcs: Api<Service> = Api::namespaced(client.clone(), ns);
let pp = PatchParams::apply(manager).force();
let clusterip: Service = serde_json::from_value(pg_service(release, ns, false, manager))?;
svcs.patch(&name, &pp, &Patch::Apply(&clusterip))
.await
.with_context(|| format!("apply Service/{name}"))?;
let headless: Service = serde_json::from_value(pg_service(release, ns, true, manager))?;
svcs.patch(&hl, &pp, &Patch::Apply(&headless))
.await
.with_context(|| format!("apply Service/{hl}"))?;
lines.push(format!("postgres: Service/{name} + Service/{hl} i takt"));
// ── StatefulSet (create-if-absent — immutable selector/volumeClaimTemplates) ─
let sts: Api<StatefulSet> = Api::namespaced(client.clone(), ns);
if sts
.get_opt(&name)
.await
.context("slå upp postgres-StatefulSet")?
.is_some()
{
lines.push(format!(
"postgres: StatefulSet/{name} finns redan (rörs inte — databasen är stateful)"
));
} else {
let obj: StatefulSet =
serde_json::from_value(pg_statefulset(release, ns, creds, storage, manager))?;
sts.create(&PostParams::default(), &obj)
.await
.with_context(|| format!("skapa StatefulSet/{name}"))?;
lines.push(format!("postgres: StatefulSet/{name} skapad (image bitnamilegacy/postgresql:17.6.0, PVC {storage})"));
}
Ok(lines)
}
fn pg_labels(release: &str, manager: &str) -> Value {
json!({
"app.kubernetes.io/instance": release,
"app.kubernetes.io/managed-by": manager,
"app.kubernetes.io/name": "postgresql",
"app.kubernetes.io/version": "17.6.0",
"helm.sh/chart": "postgresql-16.7.27",
})
}
fn pg_selector(release: &str) -> Value {
json!({
"app.kubernetes.io/component": "primary",
"app.kubernetes.io/instance": release,
"app.kubernetes.io/name": "postgresql",
})
}
pub fn pg_service(release: &str, ns: &str, headless: bool, manager: &str) -> Value {
let name = if headless {
format!("{release}-postgresql-hl")
} else {
format!("{release}-postgresql")
};
let mut labels = pg_labels(release, manager);
labels
.as_object_mut()
.unwrap()
.insert("app.kubernetes.io/component".into(), json!("primary"));
let mut spec = json!({
"type": "ClusterIP",
"ports": [{ "name": "tcp-postgresql", "port": 5432, "targetPort": "tcp-postgresql", "protocol": "TCP" }],
"selector": pg_selector(release),
});
if headless {
let so = spec.as_object_mut().unwrap();
so.insert("clusterIP".into(), json!("None"));
so.insert("publishNotReadyAddresses".into(), json!(true));
} else {
spec.as_object_mut()
.unwrap()
.insert("sessionAffinity".into(), json!("None"));
}
json!({
"apiVersion": "v1",
"kind": "Service",
"metadata": { "name": name, "namespace": ns, "labels": labels },
"spec": spec,
})
}
/// The primary StatefulSet, modelled on the captured live `<release>-postgresql`.
pub fn pg_statefulset(
release: &str,
ns: &str,
creds: &PgCreds,
storage: &str,
manager: &str,
) -> Value {
let name = format!("{release}-postgresql");
let hl = format!("{release}-postgresql-hl");
let mut labels = pg_labels(release, manager);
labels
.as_object_mut()
.unwrap()
.insert("app.kubernetes.io/component".into(), json!("primary"));
json!({
"apiVersion": "apps/v1",
"kind": "StatefulSet",
"metadata": { "name": name, "namespace": ns, "labels": labels },
"spec": {
"replicas": 1,
"serviceName": hl,
"podManagementPolicy": "OrderedReady",
"persistentVolumeClaimRetentionPolicy": { "whenDeleted": "Retain", "whenScaled": "Retain" },
"selector": { "matchLabels": pg_selector(release) },
"updateStrategy": { "type": "RollingUpdate", "rollingUpdate": { "partition": 0 } },
"template": {
"metadata": { "name": name, "labels": labels },
"spec": {
"serviceAccountName": name,
"automountServiceAccountToken": false,
"affinity": {
"podAntiAffinity": {
"preferredDuringSchedulingIgnoredDuringExecution": [{
"weight": 1,
"podAffinityTerm": {
"topologyKey": "kubernetes.io/hostname",
"labelSelector": { "matchLabels": pg_selector(release) },
},
}],
},
},
"securityContext": { "fsGroup": 1001, "fsGroupChangePolicy": "Always" },
"containers": [{
"name": "postgresql",
"image": PG_IMAGE,
"imagePullPolicy": "IfNotPresent",
"securityContext": {
"allowPrivilegeEscalation": false,
"privileged": false,
"readOnlyRootFilesystem": true,
"runAsGroup": 1001,
"runAsNonRoot": true,
"runAsUser": 1001,
"capabilities": { "drop": ["ALL"] },
"seLinuxOptions": {},
"seccompProfile": { "type": "RuntimeDefault" },
},
"env": [
{ "name": "BITNAMI_DEBUG", "value": "false" },
{ "name": "POSTGRESQL_PORT_NUMBER", "value": "5432" },
{ "name": "POSTGRESQL_VOLUME_DIR", "value": "/bitnami/postgresql" },
{ "name": "PGDATA", "value": "/bitnami/postgresql/data" },
// BÅDA konventionerna, och det är ingen slarvighet.
//
// PG_IMAGE är bitnamis, och bitnamis entrypoint läser
// `POSTGRESQL_*`. De `POSTGRES_*`-namn som stod här
// ensamma är den OFFICIELLA postgres-imagens
// konvention, och bitnami honorerar dem inte för
// _FILE-varianterna. Följden var att containern
// startade utan att se något lösenord alls:
// ERROR ==> The POSTGRESQL_PASSWORD environment
// variable is empty or not set.
// och StatefulSetet gick i CrashLoopBackOff vid varje
// ny slot. Mätt i njord-yvra-lab2 2026-08-23.
//
// De gamla namnen är KVAR med flit: byter någon
// PG_IMAGE till den officiella postgres-imagen ska
// den fortsätta fungera. En okänd variabel ignoreras
// av båda imagerna, så att sätta båda kostar inget.
{ "name": "POSTGRES_USER", "value": creds.username },
{ "name": "POSTGRESQL_USERNAME", "value": creds.username },
{ "name": "POSTGRES_PASSWORD_FILE", "value": "/opt/bitnami/postgresql/secrets/password" },
{ "name": "POSTGRESQL_PASSWORD_FILE", "value": "/opt/bitnami/postgresql/secrets/password" },
{ "name": "POSTGRES_POSTGRES_PASSWORD_FILE", "value": "/opt/bitnami/postgresql/secrets/postgres-password" },
{ "name": "POSTGRESQL_POSTGRES_PASSWORD_FILE", "value": "/opt/bitnami/postgresql/secrets/postgres-password" },
{ "name": "POSTGRES_DATABASE", "value": creds.database },
{ "name": "POSTGRESQL_DATABASE", "value": creds.database },
{ "name": "POSTGRESQL_ENABLE_LDAP", "value": "no" },
{ "name": "POSTGRESQL_ENABLE_TLS", "value": "no" },
{ "name": "POSTGRESQL_LOG_HOSTNAME", "value": "false" },
{ "name": "POSTGRESQL_LOG_CONNECTIONS", "value": "false" },
{ "name": "POSTGRESQL_LOG_DISCONNECTIONS", "value": "false" },
{ "name": "POSTGRESQL_PGAUDIT_LOG_CATALOG", "value": "off" },
{ "name": "POSTGRESQL_CLIENT_MIN_MESSAGES", "value": "error" },
{ "name": "POSTGRESQL_SHARED_PRELOAD_LIBRARIES", "value": "pgaudit" },
],
"ports": [{ "name": "tcp-postgresql", "containerPort": 5432, "protocol": "TCP" }],
"livenessProbe": {
"exec": { "command": ["/bin/sh", "-c",
format!("exec pg_isready -U \"{}\" -d \"dbname={}\" -h 127.0.0.1 -p 5432", creds.username, creds.database)] },
"failureThreshold": 6, "initialDelaySeconds": 30, "periodSeconds": 10,
"successThreshold": 1, "timeoutSeconds": 5,
},
"readinessProbe": {
"exec": { "command": ["/bin/sh", "-c", "-e",
format!("exec pg_isready -U \"{}\" -d \"dbname={}\" -h 127.0.0.1 -p 5432\n[ -f /opt/bitnami/postgresql/tmp/.initialized ] || [ -f /bitnami/postgresql/.initialized ]\n", creds.username, creds.database)] },
"failureThreshold": 6, "initialDelaySeconds": 5, "periodSeconds": 10,
"successThreshold": 1, "timeoutSeconds": 5,
},
"resources": {
"limits": { "cpu": "500m", "memory": "512Mi" },
"requests": { "cpu": "10m", "memory": "160Mi" },
},
"volumeMounts": [
{ "name": "empty-dir", "mountPath": "/tmp", "subPath": "tmp-dir" },
{ "name": "empty-dir", "mountPath": "/opt/bitnami/postgresql/conf", "subPath": "app-conf-dir" },
{ "name": "empty-dir", "mountPath": "/opt/bitnami/postgresql/tmp", "subPath": "app-tmp-dir" },
{ "name": "postgresql-password", "mountPath": "/opt/bitnami/postgresql/secrets/" },
{ "name": "dshm", "mountPath": "/dev/shm" },
{ "name": "data", "mountPath": "/bitnami/postgresql" },
],
}],
"volumes": [
{ "name": "empty-dir", "emptyDir": {} },
{ "name": "postgresql-password", "secret": { "secretName": name, "defaultMode": 420 } },
{ "name": "dshm", "emptyDir": { "medium": "Memory" } },
],
},
},
"volumeClaimTemplates": [{
"apiVersion": "v1",
"kind": "PersistentVolumeClaim",
"metadata": { "name": "data" },
"spec": {
"accessModes": ["ReadWriteOnce"],
"resources": { "requests": { "storage": storage } },
"storageClassName": "local-path",
"volumeMode": "Filesystem",
},
}],
},
})
}
/// The postgres ServiceAccount the StatefulSet's pods run as (bitnami creates
/// one; `automountServiceAccountToken: false` on the pod means it grants nothing,
/// but the pod references it by name so it must exist). Stamped with `manager`.
#[cfg(feature = "kubernetes")]
pub async fn postgres_service_account(
client: &Client,
ns: &str,
release: &str,
manager: &str,
) -> Result<()> {
let name = format!("{release}-postgresql");
let sa: Api<ServiceAccount> = Api::namespaced(client.clone(), ns);
let obj: ServiceAccount = serde_json::from_value(json!({
"apiVersion": "v1",
"kind": "ServiceAccount",
"metadata": { "name": name, "namespace": ns, "labels": pg_labels(release, manager) },
"automountServiceAccountToken": false,
}))?;
sa.patch(
&name,
&PatchParams::apply(manager).force(),
&Patch::Apply(&obj),
)
.await
.with_context(|| format!("apply ServiceAccount/{name}"))?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
const M: &str = "njord-deployer";
fn creds() -> PgCreds {
PgCreds {
username: "njord".into(),
password: "Njord-Lab-Pg-2026".into(),
postgres_password: "superpw".into(),
database: "njord".into(),
}
}
/// The rendered StatefulSet must reproduce the load-bearing fields of the
/// captured live bitnami render (image, serviceName, selector, probes, PVC).
#[test]
fn statefulset_matches_captured_bitnami_render() {
let sts = pg_statefulset("test1", "test1", &creds(), "8Gi", M);
assert_eq!(sts["spec"]["serviceName"], json!("test1-postgresql-hl"));
assert_eq!(
sts["spec"]["template"]["spec"]["containers"][0]["image"],
json!("docker.io/bitnamilegacy/postgresql:17.6.0-debian-12-r4")
);
// Immutable selector must equal what the live STS carries.
assert_eq!(sts["spec"]["selector"]["matchLabels"], pg_selector("test1"));
// PVC size is parameterised.
assert_eq!(
sts["spec"]["volumeClaimTemplates"][0]["spec"]["resources"]["requests"]["storage"],
json!("8Gi")
);
// **Bitnamis entrypoint måste se ett lösenord.**
//
// PG_IMAGE är bitnamis, och bitnami läser `POSTGRESQL_*`. Stod bara de
// officiella `POSTGRES_*`-namnen där såg containern inget lösenord och
// dog i CrashLoopBackOff med
// ERROR ==> The POSTGRESQL_PASSWORD environment variable is empty
// vid VARJE ny slot — provisioneringen såg lyckad ut, StatefulSetet
// fanns, och ändå startade databasen aldrig.
//
// Röd om någon plockar bort `POSTGRESQL_*` igen, eller pekar
// _FILE-variablerna på en annan sökväg än den monterade secreten.
let env = &sts["spec"]["template"]["spec"]["containers"][0]["env"];
let har = |namn: &str| {
env.as_array()
.expect("env är en lista")
.iter()
.any(|e| e["name"] == namn)
};
for namn in [
"POSTGRESQL_USERNAME",
"POSTGRESQL_PASSWORD_FILE",
"POSTGRESQL_POSTGRES_PASSWORD_FILE",
"POSTGRESQL_DATABASE",
] {
assert!(har(namn), "bitnami-imagen läser {namn} — den måste sättas");
}
// Och _FILE-variablerna måste peka på den MONTERADE secreten, inte
// någon annan sökväg: en fil som inte finns är lika tom som ingen
// variabel, fast tystare.
let varde = |namn: &str| {
env.as_array()
.unwrap()
.iter()
.find(|e| e["name"] == namn)
.and_then(|e| e["value"].as_str())
.unwrap_or("")
.to_string()
};
assert_eq!(varde("POSTGRESQL_PASSWORD_FILE"), "/opt/bitnami/postgresql/secrets/password");
assert_eq!(
varde("POSTGRESQL_POSTGRES_PASSWORD_FILE"),
"/opt/bitnami/postgresql/secrets/postgres-password"
);
// The app user is threaded into POSTGRES_USER and the probes.
let env = sts["spec"]["template"]["spec"]["containers"][0]["env"]
.as_array()
.unwrap();
assert!(
env.iter()
.any(|e| e["name"] == "POSTGRES_USER" && e["value"] == "njord")
);
let live =
sts["spec"]["template"]["spec"]["containers"][0]["livenessProbe"]["exec"]["command"][2]
.as_str()
.unwrap();
assert!(
live.contains("pg_isready -U \"njord\" -d \"dbname=njord\""),
"{live}"
);
}
#[test]
fn clusterip_service_is_not_headless_and_headless_is() {
let cip = pg_service("test1", "test1", false, M);
assert_eq!(cip["spec"].get("clusterIP"), None);
assert_eq!(cip["spec"]["sessionAffinity"], json!("None"));
let hl = pg_service("test1", "test1", true, M);
assert_eq!(hl["spec"]["clusterIP"], json!("None"));
assert_eq!(hl["spec"]["publishNotReadyAddresses"], json!(true));
assert_eq!(hl["metadata"]["name"], json!("test1-postgresql-hl"));
}
/// The `manager` knob lands on both the field-attribution label and, through
/// [`pg_labels`], every provisioned object.
#[test]
fn manager_stamps_the_managed_by_label() {
let sts = pg_statefulset("test1", "test1", &creds(), "8Gi", "skidbladnir");
assert_eq!(
sts["metadata"]["labels"]["app.kubernetes.io/managed-by"],
json!("skidbladnir")
);
}
}