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

nordisk/Skidbladnir

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