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(180)),
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 crate::register_default_backends();
102 let guard = self.container.start().await?;
103 Ok(KeycloakGuard {
104 guard,
105 admin_username: self.admin_username,
106 admin_password: self.admin_password,
107 })
108 }
109}
110
111impl Default for KeycloakContainer {
112 fn default() -> Self {
113 Self::new()
114 }
115}
116
117/// The running guard for a [`KeycloakContainer`].
118pub struct KeycloakGuard {
119 guard: ContainerGuard,
120 admin_username: String,
121 admin_password: String,
122}
123
124impl KeycloakGuard {
125 /// The configured bootstrap admin username (default `admin`).
126 pub fn admin_username(&self) -> &str {
127 &self.admin_username
128 }
129
130 /// The configured bootstrap admin password (default `admin`).
131 pub fn admin_password(&self) -> &str {
132 &self.admin_password
133 }
134
135 /// The auth server's base URI (the HTTP port — realm/OIDC endpoints live under
136 /// this).
137 pub fn auth_server_url(&self) -> String {
138 format!(
139 "http://{}:{}",
140 self.guard.host(),
141 self.guard.get_mapped_port(HTTP_PORT).unwrap()
142 )
143 }
144
145 /// The management interface's base URI (health/metrics — port 9000, not
146 /// [`Self::auth_server_url`]'s port).
147 pub fn management_url(&self) -> String {
148 format!(
149 "http://{}:{}",
150 self.guard.host(),
151 self.guard.get_mapped_port(MANAGEMENT_PORT).unwrap()
152 )
153 }
154
155 /// Stops and removes the container, releasing its host ports.
156 pub async fn stop(self) -> Result<()> {
157 self.guard.stop().await
158 }
159}
160
161impl std::ops::Deref for KeycloakGuard {
162 type Target = ContainerGuard;
163 fn deref(&self) -> &ContainerGuard {
164 &self.guard
165 }
166}
167
168#[cfg(test)]
169mod tests {
170 use super::*;
171
172 #[test]
173 fn defaults_are_the_admin_pair() {
174 let c = KeycloakContainer::new();
175 assert_eq!(c.admin_username, "admin");
176 assert_eq!(c.admin_password, "admin");
177 }
178
179 #[test]
180 fn builders_override_the_defaults() {
181 let c = KeycloakContainer::new()
182 .with_admin_username("root")
183 .with_admin_password("s3cret");
184 assert_eq!(c.admin_username, "root");
185 assert_eq!(c.admin_password, "s3cret");
186 }
187}