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 rightsize::{Container, ContainerGuard, Result, Wait};
35
36const HTTP_PORT: u16 = 8123;
37const NATIVE_PORT: u16 = 9000;
38
39/// A single-node ClickHouse container.
40pub struct ClickHouseContainer {
41 container: Container,
42 username: String,
43 password: String,
44 database: String,
45}
46
47impl ClickHouseContainer {
48 /// Builds a container from the pinned default image
49 /// (`clickhouse/clickhouse-server:25.8`).
50 pub fn new() -> Self {
51 Self::with_image("clickhouse/clickhouse-server:25.8")
52 }
53
54 /// Builds a container from a caller-chosen image.
55 pub fn with_image(image: &str) -> Self {
56 let username = "test".to_string();
57 let password = "test".to_string();
58 let database = "test".to_string();
59 let container = Container::new(image)
60 .with_exposed_ports(&[HTTP_PORT, NATIVE_PORT])
61 .with_env("CLICKHOUSE_USER", &username)
62 .with_env("CLICKHOUSE_PASSWORD", &password)
63 .with_env("CLICKHOUSE_DB", &database)
64 // Protocol-aware HTTP probe: /ping answers "Ok.\n" once the HTTP
65 // interface is really up — no double-boot restart race the way the
66 // Postgres/MySQL/MariaDB entrypoints have.
67 .waiting_for(Wait::for_http("/ping").for_port(HTTP_PORT));
68 Self {
69 container,
70 username,
71 password,
72 database,
73 }
74 }
75
76 /// Overrides `CLICKHOUSE_USER` (default `test`).
77 pub fn with_username(mut self, username: &str) -> Self {
78 self.username = username.to_string();
79 self.container = self.container.with_env("CLICKHOUSE_USER", username);
80 self
81 }
82
83 /// Overrides `CLICKHOUSE_PASSWORD` (default `test`).
84 pub fn with_password(mut self, password: &str) -> Self {
85 self.password = password.to_string();
86 self.container = self.container.with_env("CLICKHOUSE_PASSWORD", password);
87 self
88 }
89
90 /// Overrides `CLICKHOUSE_DB` (default `test`).
91 pub fn with_database(mut self, database: &str) -> Self {
92 self.database = database.to_string();
93 self.container = self.container.with_env("CLICKHOUSE_DB", database);
94 self
95 }
96
97 /// Boots the container.
98 pub async fn start(self) -> Result<ClickHouseGuard> {
99 let guard = self.container.start().await?;
100 Ok(ClickHouseGuard {
101 guard,
102 username: self.username,
103 password: self.password,
104 database: self.database,
105 })
106 }
107}
108
109impl Default for ClickHouseContainer {
110 fn default() -> Self {
111 Self::new()
112 }
113}
114
115/// The running guard for a [`ClickHouseContainer`].
116pub struct ClickHouseGuard {
117 guard: ContainerGuard,
118 username: String,
119 password: String,
120 database: String,
121}
122
123impl ClickHouseGuard {
124 /// The configured database user (default `test`).
125 pub fn username(&self) -> &str {
126 &self.username
127 }
128
129 /// The configured database password (default `test`).
130 pub fn password(&self) -> &str {
131 &self.password
132 }
133
134 /// The configured database name (default `test`).
135 pub fn database_name(&self) -> &str {
136 &self.database
137 }
138
139 /// The HTTP interface's base URI (query via `POST` with a SQL body, basic-auth'd).
140 pub fn http_url(&self) -> String {
141 format!(
142 "http://{}:{}",
143 self.guard.host(),
144 self.guard.get_mapped_port(HTTP_PORT).unwrap()
145 )
146 }
147
148 /// Stops and removes the container, releasing its host ports.
149 pub async fn stop(self) -> Result<()> {
150 self.guard.stop().await
151 }
152}
153
154impl std::ops::Deref for ClickHouseGuard {
155 type Target = ContainerGuard;
156 fn deref(&self) -> &ContainerGuard {
157 &self.guard
158 }
159}
160
161#[cfg(test)]
162mod tests {
163 use super::*;
164
165 #[test]
166 fn defaults_are_the_test_trio() {
167 let c = ClickHouseContainer::new();
168 assert_eq!(c.username, "test");
169 assert_eq!(c.password, "test");
170 assert_eq!(c.database, "test");
171 }
172
173 #[test]
174 fn builders_override_the_defaults() {
175 let c = ClickHouseContainer::new()
176 .with_username("alice")
177 .with_password("s3cret")
178 .with_database("app");
179 assert_eq!(c.username, "alice");
180 assert_eq!(c.password, "s3cret");
181 assert_eq!(c.database, "app");
182 }
183}