Skip to main content

rightsize_modules/
keycloak.rs

1//! A single-node Keycloak container in `start-dev` mode (in-memory H2, no external
2//! database — fine for tests, never for production).
3//!
4//! ### Admin bootstrap env — 26.x renamed these, verified against the pinned tag
5//!
6//! Keycloak 26.x replaced the old `KEYCLOAK_ADMIN`/`KEYCLOAK_ADMIN_PASSWORD` pair with
7//! `KC_BOOTSTRAP_ADMIN_USERNAME`/`KC_BOOTSTRAP_ADMIN_PASSWORD`. Verified directly
8//! against `quay.io/keycloak/keycloak:26.4` — booting with only the new names produces
9//! `KC-SERVICES0077: Created temporary admin user with username admin` in the log; the
10//! legacy names are not read by this image. This module sets only the new names.
11//!
12//! ### Health lives on the MANAGEMENT port (9000), not 8080 — verified, and the path is `/health`
13//!
14//! Captured verbatim from a real boot: `Listening on: http://0.0.0.0:8080. Management
15//! interface listening on http://0.0.0.0:9000.` With `KC_HEALTH_ENABLED=true` set, `GET
16//! /health` on port **9000** returns `200 OK` (body: literal `OK`); the same path on
17//! 8080 404s, and the commonly assumed `/health/ready` sub-path 404s on this tag too
18//! (this image's SmallRye Health root aggregate is served bare at `/health`, not
19//! `/health/ready`) — pinned to `/health` on 9000 accordingly, not `/health/ready` on
20//! 8080.
21//!
22//! ### Memory — Quarkus JVM, needed the ladder
23//!
24//! Booted under msb's default ~450M microVM RAM, the JVM is `Killed` (OOM) partway
25//! through startup (captured: `'java' ... -XX:MaxRAMPercentage=70 ... Killed`) — same
26//! Paketo/Quarkus-on-microVM story as [`crate::spring_cloud_config::SpringCloudConfigContainer`].
27//! Retried with `-m 1024M`: boots clean, `/health` reports `200` well within the
28//! startup timeout. `with_memory_limit(1024)` is this module's default.
29//!
30//! No control characters were found in the image's baked env (checked via
31//! `docker image inspect`), so no env override is needed here.
32
33use std::time::Duration;
34
35use rightsize::{Container, ContainerGuard, Result, Wait};
36
37const HTTP_PORT: u16 = 8080;
38const MANAGEMENT_PORT: u16 = 9000;
39
40/// A single-node Keycloak container in `start-dev` mode.
41pub struct KeycloakContainer {
42    container: Container,
43    admin_username: String,
44    admin_password: String,
45}
46
47impl KeycloakContainer {
48    /// Builds a container from the pinned default image
49    /// (`quay.io/keycloak/keycloak:26.4`).
50    pub fn new() -> Self {
51        Self::with_image("quay.io/keycloak/keycloak:26.4")
52    }
53
54    /// Builds a container from a caller-chosen image.
55    pub fn with_image(image: &str) -> Self {
56        let admin_username = "admin".to_string();
57        let admin_password = "admin".to_string();
58        let container = Container::new(image)
59            .with_exposed_ports(&[HTTP_PORT, MANAGEMENT_PORT])
60            .with_command(&["start-dev"])
61            .with_env("KC_BOOTSTRAP_ADMIN_USERNAME", &admin_username)
62            .with_env("KC_BOOTSTRAP_ADMIN_PASSWORD", &admin_password)
63            .with_env("KC_HEALTH_ENABLED", "true")
64            // Quarkus JVM — see the module doc for the measured OOM-at-default /
65            // clean-at-1024 story.
66            .with_memory_limit(1024)
67            // Health is on the MANAGEMENT port, not HTTP — see the module doc for
68            // the captured boot line.
69            .waiting_for(
70                Wait::for_http("/health")
71                    .for_port(MANAGEMENT_PORT)
72                    .with_startup_timeout(Duration::from_secs(120)),
73            );
74        Self {
75            container,
76            admin_username,
77            admin_password,
78        }
79    }
80
81    /// Overrides `KC_BOOTSTRAP_ADMIN_USERNAME` (default `admin`).
82    pub fn with_admin_username(mut self, username: &str) -> Self {
83        self.admin_username = username.to_string();
84        self.container = self
85            .container
86            .with_env("KC_BOOTSTRAP_ADMIN_USERNAME", username);
87        self
88    }
89
90    /// Overrides `KC_BOOTSTRAP_ADMIN_PASSWORD` (default `admin`).
91    pub fn with_admin_password(mut self, password: &str) -> Self {
92        self.admin_password = password.to_string();
93        self.container = self
94            .container
95            .with_env("KC_BOOTSTRAP_ADMIN_PASSWORD", password);
96        self
97    }
98
99    /// Boots the container.
100    pub async fn start(self) -> Result<KeycloakGuard> {
101        let guard = self.container.start().await?;
102        Ok(KeycloakGuard {
103            guard,
104            admin_username: self.admin_username,
105            admin_password: self.admin_password,
106        })
107    }
108}
109
110impl Default for KeycloakContainer {
111    fn default() -> Self {
112        Self::new()
113    }
114}
115
116/// The running guard for a [`KeycloakContainer`].
117pub struct KeycloakGuard {
118    guard: ContainerGuard,
119    admin_username: String,
120    admin_password: String,
121}
122
123impl KeycloakGuard {
124    /// The configured bootstrap admin username (default `admin`).
125    pub fn admin_username(&self) -> &str {
126        &self.admin_username
127    }
128
129    /// The configured bootstrap admin password (default `admin`).
130    pub fn admin_password(&self) -> &str {
131        &self.admin_password
132    }
133
134    /// The auth server's base URI (the HTTP port — realm/OIDC endpoints live under
135    /// this).
136    pub fn auth_server_url(&self) -> String {
137        format!(
138            "http://{}:{}",
139            self.guard.host(),
140            self.guard.get_mapped_port(HTTP_PORT).unwrap()
141        )
142    }
143
144    /// The management interface's base URI (health/metrics — port 9000, not
145    /// [`Self::auth_server_url`]'s port).
146    pub fn management_url(&self) -> String {
147        format!(
148            "http://{}:{}",
149            self.guard.host(),
150            self.guard.get_mapped_port(MANAGEMENT_PORT).unwrap()
151        )
152    }
153
154    /// Stops and removes the container, releasing its host ports.
155    pub async fn stop(self) -> Result<()> {
156        self.guard.stop().await
157    }
158}
159
160impl std::ops::Deref for KeycloakGuard {
161    type Target = ContainerGuard;
162    fn deref(&self) -> &ContainerGuard {
163        &self.guard
164    }
165}
166
167#[cfg(test)]
168mod tests {
169    use super::*;
170
171    #[test]
172    fn defaults_are_the_admin_pair() {
173        let c = KeycloakContainer::new();
174        assert_eq!(c.admin_username, "admin");
175        assert_eq!(c.admin_password, "admin");
176    }
177
178    #[test]
179    fn builders_override_the_defaults() {
180        let c = KeycloakContainer::new()
181            .with_admin_username("root")
182            .with_admin_password("s3cret");
183        assert_eq!(c.admin_username, "root");
184        assert_eq!(c.admin_password, "s3cret");
185    }
186}