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}