Skip to main content

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}