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