rightsize_modules/clickhouse.rs
1//! A single-node ClickHouse container, queried over its HTTP interface (port 8123).
2//! The native protocol port (9000) is exposed too, but this module's helpers are
3//! HTTP-first: the HTTP interface needs no client dependency (`ureq` as a dev-dep, no
4//! runtime crate), matching the house convention for HTTP-first modules.
5//!
6//! Defaults to a `test`/`test` user/password pair and a `test` database so
7//! [`ClickHouseGuard::http_url`] plus basic auth is usable with zero configuration;
8//! call [`ClickHouseContainer::with_username`]/[`ClickHouseContainer::with_password`]/
9//! [`ClickHouseContainer::with_database`] before `start()` to override any of them.
10//!
11//! ### Env var names — verified against a real boot
12//!
13//! `CLICKHOUSE_USER` / `CLICKHOUSE_PASSWORD` / `CLICKHOUSE_DB` are the image's
14//! documented names and were confirmed directly: booting with those three set
15//! produces `/entrypoint.sh: create new user 'test' instead 'default'` and
16//! `/entrypoint.sh: create database 'test'` in the logs, and `curl -u test:test`
17//! against the HTTP interface authenticates and runs queries successfully.
18//!
19//! ### Readiness — verified against a real 25.8 (LTS) boot
20//!
21//! `GET /ping` on the HTTP port returns the literal body `Ok.\n` as soon as the HTTP
22//! server is accepting connections (protocol-aware — no restart/double-boot race like
23//! the Postgres/MySQL/MariaDB entrypoints have), so `for_http("/ping")` at the default
24//! 200 status code is the correct and simplest readiness signal. Verified directly:
25//! `curl http://127.0.0.1:<port>/ping` returned `Ok.` immediately once the container's
26//! logs showed the config merge complete.
27//!
28//! No control characters were found in the image's baked env (checked via
29//! `docker image inspect`). No `with_memory_limit` override was needed at
30//! default settings (observed ~12s IT round-trip on msb, no memory-ladder escalation
31//! needed) — a single-node ClickHouse server, unlike Pinot's four-JVM QuickStart
32//! cluster, is not a JVM process at all.
33
34use std::time::Duration;
35
36use rightsize::{Container, ContainerGuard, Result, Wait};
37
38const HTTP_PORT: u16 = 8123;
39const NATIVE_PORT: u16 = 9000;
40
41/// A single-node ClickHouse container.
42pub struct ClickHouseContainer {
43 container: Container,
44 username: String,
45 password: String,
46 database: String,
47}
48
49impl ClickHouseContainer {
50 /// Builds a container from the pinned default image
51 /// (`clickhouse/clickhouse-server:25.8`).
52 pub fn new() -> Self {
53 Self::with_image("clickhouse/clickhouse-server:25.8")
54 }
55
56 /// Builds a container from a caller-chosen image.
57 pub fn with_image(image: &str) -> Self {
58 let username = "test".to_string();
59 let password = "test".to_string();
60 let database = "test".to_string();
61 let container = Container::new(image)
62 .with_exposed_ports(&[HTTP_PORT, NATIVE_PORT])
63 .with_env("CLICKHOUSE_USER", &username)
64 .with_env("CLICKHOUSE_PASSWORD", &password)
65 .with_env("CLICKHOUSE_DB", &database)
66 // Protocol-aware HTTP probe: /ping answers "Ok.\n" once the HTTP
67 // interface is really up — no double-boot restart race the way the
68 // Postgres/MySQL/MariaDB entrypoints have. 180s: the entrypoint's
69 // user/database provisioning runs a second server pass before the
70 // HTTP interface opens; a loaded Windows CI runner was observed
71 // still in early config processing at 120s.
72 .waiting_for(
73 Wait::for_http("/ping")
74 .for_port(HTTP_PORT)
75 .with_startup_timeout(Duration::from_secs(180)),
76 );
77 Self {
78 container,
79 username,
80 password,
81 database,
82 }
83 }
84
85 /// Overrides `CLICKHOUSE_USER` (default `test`).
86 pub fn with_username(mut self, username: &str) -> Self {
87 self.username = username.to_string();
88 self.container = self.container.with_env("CLICKHOUSE_USER", username);
89 self
90 }
91
92 /// Overrides `CLICKHOUSE_PASSWORD` (default `test`).
93 pub fn with_password(mut self, password: &str) -> Self {
94 self.password = password.to_string();
95 self.container = self.container.with_env("CLICKHOUSE_PASSWORD", password);
96 self
97 }
98
99 /// Overrides `CLICKHOUSE_DB` (default `test`).
100 pub fn with_database(mut self, database: &str) -> Self {
101 self.database = database.to_string();
102 self.container = self.container.with_env("CLICKHOUSE_DB", database);
103 self
104 }
105
106 /// Boots the container.
107 pub async fn start(self) -> Result<ClickHouseGuard> {
108 crate::register_default_backends();
109 let guard = self.container.start().await?;
110 Ok(ClickHouseGuard {
111 guard,
112 username: self.username,
113 password: self.password,
114 database: self.database,
115 })
116 }
117}
118
119impl Default for ClickHouseContainer {
120 fn default() -> Self {
121 Self::new()
122 }
123}
124
125/// The running guard for a [`ClickHouseContainer`].
126pub struct ClickHouseGuard {
127 guard: ContainerGuard,
128 username: String,
129 password: String,
130 database: String,
131}
132
133impl ClickHouseGuard {
134 /// The configured database user (default `test`).
135 pub fn username(&self) -> &str {
136 &self.username
137 }
138
139 /// The configured database password (default `test`).
140 pub fn password(&self) -> &str {
141 &self.password
142 }
143
144 /// The configured database name (default `test`).
145 pub fn database_name(&self) -> &str {
146 &self.database
147 }
148
149 /// The HTTP interface's base URI (query via `POST` with a SQL body, basic-auth'd).
150 pub fn http_url(&self) -> String {
151 format!(
152 "http://{}:{}",
153 self.guard.host(),
154 self.guard.get_mapped_port(HTTP_PORT).unwrap()
155 )
156 }
157
158 /// Stops and removes the container, releasing its host ports.
159 pub async fn stop(self) -> Result<()> {
160 self.guard.stop().await
161 }
162}
163
164impl std::ops::Deref for ClickHouseGuard {
165 type Target = ContainerGuard;
166 fn deref(&self) -> &ContainerGuard {
167 &self.guard
168 }
169}
170
171#[cfg(test)]
172mod tests {
173 use super::*;
174
175 #[test]
176 fn defaults_are_the_test_trio() {
177 let c = ClickHouseContainer::new();
178 assert_eq!(c.username, "test");
179 assert_eq!(c.password, "test");
180 assert_eq!(c.database, "test");
181 }
182
183 #[test]
184 fn builders_override_the_defaults() {
185 let c = ClickHouseContainer::new()
186 .with_username("alice")
187 .with_password("s3cret")
188 .with_database("app");
189 assert_eq!(c.username, "alice");
190 assert_eq!(c.password, "s3cret");
191 assert_eq!(c.database, "app");
192 }
193}