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

nordisk/gunnar

xtask/src/idp.rs

//! **`cargo xtask idp`**: company SSO against a REAL identity provider (owner 2026-10-26, lane
//! T-OWNERFLOW). `crates/gunnar-server/tests/sso_keycloak.rs` holds the tests; this verb gives
//! them their Keycloak and takes it away again:
//!
//! 1. the run's IdP certificate: a CA and `idp.acme.test`'s certificate, minted by the test
//!    binary itself (`MINT`, rcgen is already a dev-dependency there; this crate stays
//!    dependency-free), in the toolchain container, into the run's directory;
//! 2. a private podman network (`<prefix>-idp-<pid>`; never `--network host`);
//! 3. Keycloak ([`KEYCLOAK_IMAGE`], pinned by digest) on it as [`IDP_HOST`]:[`IDP_PORT`] over
//!    https with that certificate, its bootstrap admin password random per run and handed to
//!    podman and the tests by FILE only (never printed, never on a command line);
//! 4. the `#[ignore]`d tests, `--ignored`, in the toolchain container joined to the network;
//!    the tests that are RED for a row another lane fixes ([`RED_UNTIL`]) are skipped BY NAME
//!    and said so, until the row merges (`--with-red` runs them too);
//! 5. every container and the network removed, on every way out ([`Teardown`]).
//!
//! `cargo xtask idp [--with-red] [--keep] [<test name filter>]`. The container prefix is
//! `GUNNAR_IDP_CONTAINER_PREFIX` (default `gunnar-idp`). Without podman the verb FAILS by name:
//! a gate that cannot run is not a gate that passed (RA2-7).

use std::path::{Path, PathBuf};
use std::process::Command;
use std::time::{Duration, Instant};

/// Keycloak 26.3.5, by digest (pulled 2026-10-26; quay.io/keycloak/keycloak:26.3.5).
pub const KEYCLOAK_IMAGE: &str = "quay.io/keycloak/keycloak@sha256:357829ec7c4693397533035092ad13b0644bcc95ded311f33a3738c4d9e9bdba";
/// The IdP's name on the run's network (its certificate's only name).
pub const IDP_HOST: &str = "idp.acme.test";
/// Keycloak's https port.
pub const IDP_PORT: u16 = 8443;
/// The test target.
pub const TEST: &str = "sso_keycloak";
/// The feature [`TEST`] is built with: the file is `#![cfg(feature = "sso-egress")]` (A32-18).
pub const FEATURES: &str = "sso-egress";
/// The test-binary step that mints the run's CA and certificate.
pub const MINT: &str = "mint_the_runs_idp_certificate";
/// **Tests that are RED on today's code for a row another lane fixes**: skipped by name until
/// that row merges, then deleted from here (and their `#[ignore]` reason goes back to the
/// container one).
pub const RED_UNTIL: &[(&str, &str)] = &[];
/// Keycloak's plain port on the run's network (edda's tests speak plain HTTP to it).
pub const IDP_HTTP_PORT: u16 = 8080;
/// **edda's real-IdP tests** (`crates/nornir-auth-oidc/tests/keycloak.rs`): the OIDC bearer
/// provider Holger dispatches its `Oidc` method to, run against the same Keycloak when edda is
/// checked out beside gunnar.
pub const EDDA_TEST: &str = "keycloak";
/// edda's tests that are RED for a row another lane fixes (as [`RED_UNTIL`]).
pub const EDDA_RED_UNTIL: &[(&str, &str)] = &[];
/// How long Keycloak may take to say it listens.
pub const READY_WITHIN: Duration = Duration::from_secs(180);

/// One run's names: everything it starts carries `<prefix>-…-<pid>`.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Run {
    pub prefix: String,
    pub pid: u32,
}

impl Run {
    pub fn new(prefix: Option<&str>, pid: u32) -> Run {
        let prefix = prefix
            .map(str::trim)
            .filter(|p| !p.is_empty() && p.chars().all(|c| c.is_ascii_alphanumeric() || c == '-'))
            .unwrap_or("gunnar-idp");
        Run {
            prefix: prefix.to_owned(),
            pid,
        }
    }
    pub fn network(&self) -> String {
        format!("{}-idp-{}", self.prefix, self.pid)
    }
    pub fn keycloak(&self) -> String {
        format!("{}-keycloak-{}", self.prefix, self.pid)
    }
    pub fn tester(&self, step: &str) -> String {
        format!("{}-{step}-{}", self.prefix, self.pid)
    }
}

/// The `cargo` arguments of the mint step.
pub fn mint_args() -> Vec<String> {
    [
        "test",
        "--locked",
        "-p",
        "gunnar-server",
        "--features",
        FEATURES,
        "--test",
        TEST,
        "--",
        "--ignored",
        "--exact",
        MINT,
    ]
    .iter()
    .map(|s| s.to_string())
    .collect()
}

/// The `cargo` arguments of the test step: every ignored test but the mint, the RED ones skipped
/// by name unless `with_red`, and an optional name filter.
pub fn test_args(filter: Option<&str>, with_red: bool) -> Vec<String> {
    let mut v: Vec<String> = [
        "test",
        "--locked",
        "-p",
        "gunnar-server",
        "--features",
        FEATURES,
        "--test",
        TEST,
        "--",
        "--ignored",
        "--skip",
        MINT,
    ]
    .iter()
    .map(|s| s.to_string())
    .collect();
    if !with_red {
        for (name, _) in RED_UNTIL {
            v.push("--skip".into());
            v.push((*name).into());
        }
    }
    if let Some(f) = filter {
        v.push(f.to_owned());
    }
    v
}

/// The `cargo` arguments of edda's step (in edda's own workspace), as [`test_args`].
pub fn edda_args(filter: Option<&str>, with_red: bool) -> Vec<String> {
    let mut v: Vec<String> = [
        "test",
        "--locked",
        "-p",
        "nornir-auth-oidc",
        "--features",
        "online",
        "--test",
        EDDA_TEST,
        "--",
        "--ignored",
    ]
    .iter()
    .map(|s| s.to_string())
    .collect();
    if !with_red {
        for (name, _) in EDDA_RED_UNTIL {
            v.push("--skip".into());
            v.push((*name).into());
        }
    }
    if let Some(f) = filter {
        v.push(f.to_owned());
    }
    v
}

/// The toolchain container's argv (`crate::fast::toolchain_argv`) with this run's name, the
/// run's directory at `/idp` and, for the tests, the run's network and the IdP's environment.
pub fn tester_argv(
    base: Vec<String>,
    run: &Run,
    step: &str,
    dir: &Path,
    subnet: Option<&str>,
) -> Vec<String> {
    let mut extra: Vec<String> = vec!["--name".into(), run.tester(step)];
    extra.push("-v".into());
    extra.push(format!(
        "{}:/idp{}",
        dir.display(),
        if subnet.is_some() { ":ro" } else { "" }
    ));
    match subnet {
        Some(net) => {
            extra.extend(["--network".into(), run.network()]);
            for (k, v) in [
                ("GUNNAR_IDP_URL", format!("https://{IDP_HOST}:{IDP_PORT}")),
                ("GUNNAR_IDP_CA", "/idp/ca.pem".into()),
                (
                    "GUNNAR_IDP_ADMIN_PASSWORD_FILE",
                    "/idp/admin-password".into(),
                ),
                ("GUNNAR_IDP_NETS", net.to_owned()),
                (
                    "GUNNAR_IDP_HTTP_URL",
                    format!("http://{IDP_HOST}:{IDP_HTTP_PORT}"),
                ),
            ] {
                extra.push("-e".into());
                extra.push(format!("{k}={v}"));
            }
        }
        None => {
            extra.push("-e".into());
            extra.push("GUNNAR_IDP_MINT_DIR=/idp".into());
        }
    }
    let mut v = base;
    let at = v
        .iter()
        .position(|a| a == "run")
        .map(|i| i + 1)
        .unwrap_or(v.len());
    v.splice(at..at, extra);
    v
}

/// Keycloak's `podman run`: on the run's network under [`IDP_HOST`], https with the minted
/// certificate, the admin's name and password from the run's env FILE.
pub fn keycloak_argv(run: &Run, dir: &Path) -> Vec<String> {
    let mut v: Vec<String> = ["podman", "run", "-d", "--name"]
        .iter()
        .map(|s| s.to_string())
        .collect();
    v.push(run.keycloak());
    v.extend([
        "--network".into(),
        run.network(),
        "--network-alias".into(),
        IDP_HOST.into(),
    ]);
    v.extend([
        "-v".into(),
        format!("{}:/idp:ro", dir.join("tls").display()),
    ]);
    v.extend([
        "--env-file".into(),
        dir.join("admin.env").display().to_string(),
    ]);
    v.push(KEYCLOAK_IMAGE.into());
    v.extend(
        [
            "start-dev",
            &format!("--https-port={IDP_PORT}"),
            "--https-certificate-file=/idp/kc.crt",
            "--https-certificate-key-file=/idp/kc.key",
            "--hostname-strict=false",
        ]
        .iter()
        .map(|s| s.to_string()),
    );
    v
}

/// What every way out removes: the test containers, Keycloak, the network (in that order).
pub fn teardown_argv(run: &Run) -> Vec<Vec<String>> {
    let rm = |name: String| {
        ["podman", "rm", "-f", "-t", "0"]
            .iter()
            .map(|s| s.to_string())
            .chain([name])
            .collect::<Vec<_>>()
    };
    vec![
        rm(run.tester("mint")),
        rm(run.tester("test")),
        rm(run.tester("edda")),
        rm(run.keycloak()),
        ["podman", "network", "rm", "-f"]
            .iter()
            .map(|s| s.to_string())
            .chain([run.network()])
            .collect(),
    ]
}

/// Did Keycloak's log say it listens? (`Listening on: https://…` and its start line.)
pub fn keycloak_ready(log: &str) -> bool {
    log.contains("Listening on:") && log.contains(" started in ")
}

/// The run's teardown, run on drop: whatever happens, nothing of the run is left.
pub struct Teardown {
    run: Run,
    dir: PathBuf,
    keep: bool,
}

impl Drop for Teardown {
    fn drop(&mut self) {
        if self.keep {
            println!(
                "idp: --keep: {} and {} are left running",
                self.run.keycloak(),
                self.run.network()
            );
            return;
        }
        for argv in teardown_argv(&self.run) {
            let _ = Command::new(&argv[0]).args(&argv[1..]).output();
        }
        let _ = std::fs::remove_dir_all(&self.dir);
    }
}

fn podman(args: &[&str]) -> Result<String, String> {
    let out = Command::new("podman")
        .args(args)
        .output()
        .map_err(|e| format!("podman: {e}"))?;
    if out.status.success() {
        Ok(String::from_utf8_lossy(&out.stdout).trim().to_owned())
    } else {
        Err(format!(
            "podman {}: {}",
            args.first().unwrap_or(&""),
            String::from_utf8_lossy(&out.stderr).trim()
        ))
    }
}

fn status(argv: &[String], what: &str) -> Result<(), String> {
    let s = Command::new(&argv[0])
        .args(&argv[1..])
        .status()
        .map_err(|e| format!("{what}: {e}"))?;
    if s.success() {
        Ok(())
    } else {
        Err(format!("[idp-{what}] exited {s}"))
    }
}

fn random_hex(n: usize) -> Result<String, String> {
    use std::io::Read as _;
    let mut b = vec![0u8; n];
    std::fs::File::open("/dev/urandom")
        .and_then(|mut f| f.read_exact(&mut b))
        .map_err(|e| format!("/dev/urandom: {e}"))?;
    Ok(b.iter().map(|x| format!("{x:02x}")).collect())
}

fn write_private(path: &Path, text: &str, mode: u32) -> Result<(), String> {
    use std::os::unix::fs::PermissionsExt as _;
    std::fs::write(path, text).map_err(|e| format!("{}: {e}", path.display()))?;
    std::fs::set_permissions(path, std::fs::Permissions::from_mode(mode))
        .map_err(|e| format!("{}: {e}", path.display()))
}

/// The verb. `args`: `--with-red`, `--keep`, and an optional test name filter.
pub fn run(root: &Path, args: &[String]) -> Result<(), String> {
    use std::os::unix::fs::PermissionsExt as _;
    let with_red = args.iter().any(|a| a == "--with-red");
    let keep = args.iter().any(|a| a == "--keep");
    let filter = args
        .iter()
        .find(|a| !a.starts_with("--"))
        .map(String::as_str);
    if Command::new("podman")
        .arg("--version")
        .output()
        .map(|o| !o.status.success())
        .unwrap_or(true)
    {
        return Err("[idp-no-podman] podman is not on this host: the real-IdP tests did NOT run (run `cargo xtask idp` on a host with rootless podman)".into());
    }
    let run = Run::new(
        std::env::var("GUNNAR_IDP_CONTAINER_PREFIX").ok().as_deref(),
        std::process::id(),
    );
    let target = crate::fast::toolchain_target(root);
    let dir =
        std::path::absolute(target.join(format!("idp-{}", run.pid))).map_err(|e| e.to_string())?;
    let tls = dir.join("tls");
    std::fs::create_dir_all(&tls).map_err(|e| format!("{}: {e}", tls.display()))?;
    // Keycloak runs as its own user: the certificate directory is readable to it, nothing else.
    for d in [&dir, &tls] {
        std::fs::set_permissions(d, std::fs::Permissions::from_mode(0o755))
            .map_err(|e| e.to_string())?;
    }
    let _teardown = Teardown {
        run: run.clone(),
        dir: dir.clone(),
        keep,
    };
    let env = |k: &str| std::env::var(k).ok().filter(|v| !v.is_empty());
    let t0 = Instant::now();

    // 1. the run's CA and Keycloak's certificate, minted by the test binary.
    println!(
        "idp: minting the run's CA and {IDP_HOST}'s certificate (the test binary builds first)"
    );
    let mint = tester_argv(
        crate::fast::toolchain_argv(root, &target, &env, &mint_args(), None),
        &run,
        "mint",
        &dir,
        None,
    );
    status(&mint, "mint")?;
    for f in ["ca.pem", "tls/kc.crt", "tls/kc.key"] {
        if !dir.join(f).is_file() {
            return Err(format!("[idp-mint] the mint step left no {f}"));
        }
    }
    for f in ["tls/kc.crt", "tls/kc.key"] {
        std::fs::set_permissions(dir.join(f), std::fs::Permissions::from_mode(0o644))
            .map_err(|e| e.to_string())?;
    }
    let password = random_hex(24)?;
    write_private(&dir.join("admin-password"), &password, 0o600)?;
    write_private(
        &dir.join("admin.env"),
        &format!("KC_BOOTSTRAP_ADMIN_USERNAME=admin\nKC_BOOTSTRAP_ADMIN_PASSWORD={password}\n"),
        0o600,
    )?;

    // 2. the private network.
    podman(&["network", "create", &run.network()])?;
    let subnet = podman(&[
        "network",
        "inspect",
        &run.network(),
        "--format",
        "{{range .Subnets}}{{.Subnet}} {{end}}",
    ])?;
    let subnet = subnet
        .split_whitespace()
        .find(|s| s.contains('.'))
        .ok_or_else(|| {
            format!(
                "[idp-network] {} has no IPv4 subnet: {subnet:?}",
                run.network()
            )
        })?
        .to_owned();

    // 3. Keycloak, until it says it listens.
    let kc = keycloak_argv(&run, &dir);
    let out = Command::new(&kc[0])
        .args(&kc[1..])
        .output()
        .map_err(|e| format!("[idp-keycloak] podman: {e}"))?;
    if !out.status.success() {
        return Err(format!(
            "[idp-keycloak] podman run: {}",
            String::from_utf8_lossy(&out.stderr).trim()
        ));
    }
    let start = Instant::now();
    loop {
        let log = Command::new("podman")
            .args(["logs", &run.keycloak()])
            .output()
            .map(|o| {
                format!(
                    "{}{}",
                    String::from_utf8_lossy(&o.stdout),
                    String::from_utf8_lossy(&o.stderr)
                )
            })
            .unwrap_or_default();
        if keycloak_ready(&log) {
            break;
        }
        let running = podman(&["inspect", "--format", "{{.State.Running}}", &run.keycloak()])
            .unwrap_or_default();
        if running != "true" || start.elapsed() > READY_WITHIN {
            let tail: Vec<&str> = log.lines().rev().take(15).collect();
            return Err(format!(
                "[idp-keycloak] Keycloak did not come up within {} s: {}",
                READY_WITHIN.as_secs(),
                tail.into_iter().rev().collect::<Vec<_>>().join(" | ")
            ));
        }
        std::thread::sleep(Duration::from_secs(2));
    }
    println!(
        "idp: {} serves https://{IDP_HOST}:{IDP_PORT} on {} ({subnet}) after {} s",
        run.keycloak(),
        run.network(),
        start.elapsed().as_secs()
    );
    if !with_red {
        for (name, row) in RED_UNTIL.iter().chain(EDDA_RED_UNTIL) {
            println!("idp: SKIPPED by name: {name} (red until {row} merges; `--with-red` runs it)");
        }
    }

    // 4. the tests.
    let tests = tester_argv(
        crate::fast::toolchain_argv(root, &target, &env, &test_args(filter, with_red), None),
        &run,
        "test",
        &dir,
        Some(&subnet),
    );
    let verdict = status(&tests, "tests");
    // 5. edda's OIDC bearer provider (Holger's), against the same Keycloak.
    let edda = root.parent().map(|p| p.join("edda")).filter(|e| {
        e.join("crates/nornir-auth-oidc/tests/keycloak.rs")
            .is_file()
    });
    let edda_verdict = match edda {
        Some(e) => {
            let edda_target = target.join("edda");
            std::fs::create_dir_all(&edda_target)
                .map_err(|err| format!("{}: {err}", edda_target.display()))?;
            let a = tester_argv(
                crate::fast::toolchain_argv(
                    &e,
                    &edda_target,
                    &env,
                    &edda_args(filter, with_red),
                    None,
                ),
                &run,
                "edda",
                &dir,
                Some(&subnet),
            );
            status(&a, "edda")
        }
        None => {
            println!("idp: NOT RUN by name: edda's OIDC bearer tests (no ../edda/crates/nornir-auth-oidc/tests/keycloak.rs beside gunnar)");
            Ok(())
        }
    };
    let verdict = verdict.and(edda_verdict);
    println!(
        "idp: {} after {} s",
        if verdict.is_ok() { "GREEN" } else { "RED" },
        t0.elapsed().as_secs()
    );
    verdict
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn keycloak_is_pinned_by_digest() {
        assert!(KEYCLOAK_IMAGE.starts_with("quay.io/keycloak/keycloak@sha256:"));
        assert_eq!(KEYCLOAK_IMAGE.rsplit(':').next().unwrap().len(), 64);
    }

    #[test]
    fn a_run_names_everything_with_its_prefix_and_pid() {
        let r = Run::new(Some("t-ownerflow"), 42);
        assert_eq!(
            (r.network(), r.keycloak(), r.tester("test")),
            (
                "t-ownerflow-idp-42".into(),
                "t-ownerflow-keycloak-42".into(),
                "t-ownerflow-test-42".into()
            )
        );
        assert_eq!(Run::new(None, 7).prefix, "gunnar-idp");
        assert_eq!(
            Run::new(Some("bad name;rm"), 7).prefix,
            "gunnar-idp",
            "a prefix is a name, nothing else"
        );
    }

    #[test]
    fn keycloak_runs_on_the_runs_network_never_the_hosts() {
        let r = Run::new(Some("t"), 1);
        let v = keycloak_argv(&r, Path::new("/x"));
        assert!(
            !v.windows(2)
                .any(|w| (w[0] == "--network" || w[0] == "--net") && w[1] == "host")
                && !v.iter().any(|a| a == "--network=host" || a == "--net=host"),
            "{v:?}"
        );
        let at = v.iter().position(|a| a == "--network").unwrap();
        assert_eq!(v[at + 1], "t-idp-1");
        assert!(v.contains(&"--network-alias".to_string()) && v.contains(&IDP_HOST.to_string()));
        assert!(v.contains(&KEYCLOAK_IMAGE.to_string()));
        assert!(
            !v.iter().any(|a| a.contains("PASSWORD")),
            "the password is never on the command line: {v:?}"
        );
        assert!(v.contains(&"/x/admin.env".to_string()));
    }

    #[test]
    fn the_tests_join_the_network_and_skip_the_red_ones_by_name() {
        let r = Run::new(Some("t"), 1);
        let base: Vec<String> = ["podman", "run", "--rm", "img", "cargo", "test"]
            .iter()
            .map(|s| s.to_string())
            .collect();
        let v = tester_argv(
            base.clone(),
            &r,
            "test",
            Path::new("/d"),
            Some("10.89.0.0/24"),
        );
        assert_eq!(&v[..2], &["podman".to_string(), "run".to_string()]);
        assert_eq!(&v[2..4], &["--name".to_string(), "t-test-1".to_string()]);
        assert!(v
            .windows(2)
            .any(|w| w[0] == "--network" && w[1] == "t-idp-1"));
        assert!(v.contains(&"GUNNAR_IDP_NETS=10.89.0.0/24".to_string()));
        assert!(v.contains(&"/d:/idp:ro".to_string()));
        let mint = tester_argv(base, &r, "mint", Path::new("/d"), None);
        assert!(
            !mint.contains(&"--network".to_string())
                && mint.contains(&"GUNNAR_IDP_MINT_DIR=/idp".to_string())
        );
        let a = test_args(None, false);
        for (name, _) in RED_UNTIL {
            assert!(
                a.windows(2).any(|w| w[0] == "--skip" && w[1] == *name),
                "{name} is not skipped: {a:?}"
            );
        }
        assert!(a.windows(2).any(|w| w[0] == "--skip" && w[1] == MINT));
        assert!(a.contains(&"--ignored".to_string()));
        let red = test_args(Some("saml"), true);
        assert!(
            !RED_UNTIL.iter().any(|(n, _)| red.contains(&n.to_string()))
                && red.last().unwrap() == "saml"
        );
        assert_eq!(mint_args().last().unwrap(), MINT);
        let e = edda_args(None, false);
        for (name, _) in EDDA_RED_UNTIL {
            assert!(
                e.windows(2).any(|w| w[0] == "--skip" && w[1] == *name),
                "{name} is not skipped: {e:?}"
            );
        }
        assert!(
            e.windows(2)
                .any(|w| w[0] == "--features" && w[1] == "online"),
            "the provider's /userinfo path is what is tested: {e:?}"
        );
    }

    /// The test file is `#![cfg(feature = "sso-egress")]` (A32-18): a step without the feature
    /// builds an empty binary, the mint runs nothing and leaves no `ca.pem` (OX-SSOUI, 2026-10-28).
    #[test]
    fn both_keycloak_steps_build_the_test_with_sso_egress() {
        for (step, a) in [
            ("mint", mint_args()),
            ("test", test_args(Some("saml"), false)),
        ] {
            let end = a.iter().position(|x| x == "--").unwrap_or(a.len());
            assert!(
                a[..end]
                    .windows(2)
                    .any(|w| w[0] == "--features" && w[1] == "sso-egress"),
                "the {step} step builds {TEST} without sso-egress: {a:?}"
            );
        }
    }

    #[test]
    fn the_teardown_removes_every_container_and_the_network() {
        let r = Run::new(Some("t"), 9);
        let all = teardown_argv(&r);
        let names: Vec<&String> = all.iter().map(|a| a.last().unwrap()).collect();
        assert_eq!(
            names,
            vec![
                "t-mint-9",
                "t-test-9",
                "t-edda-9",
                "t-keycloak-9",
                "t-idp-9"
            ]
        );
        assert!(all[..4]
            .iter()
            .all(|a| a[1] == "rm" && a.contains(&"-f".to_string())));
        assert_eq!(&all[4][1..3], &["network".to_string(), "rm".to_string()]);
    }

    #[test]
    fn keycloak_is_ready_only_once_it_listens() {
        assert!(!keycloak_ready(
            "Updating the configuration and installing your custom providers"
        ));
        assert!(keycloak_ready("Keycloak 26.3.5 on JVM (powered by Quarkus 3.20.2) started in 9.1s. Listening on: http://0.0.0.0:8080 and https://0.0.0.0:8443"));
    }
}