pg_embedded_setup_unpriv/bootstrap/
mod.rs

1//! Bootstraps embedded `PostgreSQL` while adapting to the caller's privileges.
2//!
3//! Provides [`bootstrap_for_tests`] so suites can retrieve structured settings and
4//! prepared environment variables without reimplementing bootstrap orchestration.
5mod env;
6mod mode;
7mod prepare;
8
9use std::time::Duration;
10
11use color_eyre::eyre::Context;
12use postgresql_embedded::Settings;
13
14use crate::{
15    PgEnvCfg,
16    error::{BootstrapResult, Result as CrateResult},
17};
18
19pub use env::{TestBootstrapEnvironment, find_timezone_dir};
20pub use mode::{ExecutionMode, ExecutionPrivileges, detect_execution_privileges};
21
22use self::{
23    env::{shutdown_timeout_from_env, worker_binary_from_env},
24    mode::determine_execution_mode,
25    prepare::prepare_bootstrap,
26};
27
28const DEFAULT_SETUP_TIMEOUT: Duration = Duration::from_secs(180);
29const DEFAULT_START_TIMEOUT: Duration = Duration::from_secs(60);
30
31/// Structured settings returned from [`bootstrap_for_tests`].
32#[derive(Debug, Clone)]
33pub struct TestBootstrapSettings {
34    /// Privilege level detected for the current process.
35    pub privileges: ExecutionPrivileges,
36    /// Strategy for executing `PostgreSQL` lifecycle commands.
37    pub execution_mode: ExecutionMode,
38    /// `PostgreSQL` configuration prepared for the embedded instance.
39    pub settings: Settings,
40    /// Environment variables required to exercise the embedded instance.
41    pub environment: TestBootstrapEnvironment,
42    /// Optional path to the helper binary used for subprocess execution.
43    pub worker_binary: Option<camino::Utf8PathBuf>,
44    /// Maximum time to allow the worker to complete the setup phase.
45    pub setup_timeout: Duration,
46    /// Maximum time to allow the worker to complete the start phase.
47    pub start_timeout: Duration,
48    /// Grace period granted to `PostgreSQL` during drop before teardown proceeds regardless.
49    pub shutdown_timeout: Duration,
50    /// Optional override for the binary cache directory.
51    ///
52    /// When set, `TestCluster` uses this directory instead of the default
53    /// resolved from environment variables.
54    pub binary_cache_dir: Option<camino::Utf8PathBuf>,
55}
56
57/// Bootstraps an embedded `PostgreSQL` instance, branching between root and unprivileged flows.
58///
59/// The bootstrap honours the following environment variables when present:
60/// - `PG_RUNTIME_DIR`: Overrides the `PostgreSQL` installation directory.
61/// - `PG_DATA_DIR`: Overrides the data directory used for initialisation.
62/// - `PG_SUPERUSER`: Sets the superuser account name.
63/// - `PG_PASSWORD`: Supplies the superuser password.
64///
65/// When executed as `root` on Unix platforms the runtime drops privileges to the `nobody` user
66/// and prepares the filesystem on that user's behalf. Unprivileged executions reuse the current
67/// user identity. The function returns a [`crate::Error`] describing failures encountered during
68/// bootstrap.
69///
70/// This convenience wrapper discards the detailed [`TestBootstrapSettings`]. Call
71/// [`bootstrap_for_tests`] to obtain the structured response for assertions.
72///
73/// # Examples
74/// ```rust
75/// use pg_embedded_setup_unpriv::run;
76///
77/// fn main() -> Result<(), pg_embedded_setup_unpriv::Error> {
78///     run()?;
79///     Ok(())
80/// }
81/// ```
82///
83/// # Errors
84/// Returns an error when bootstrap preparation fails or when subprocess orchestration
85/// cannot be configured.
86pub fn run() -> CrateResult<()> {
87    orchestrate_bootstrap()?;
88    Ok(())
89}
90
91/// Bootstraps `PostgreSQL` for integration tests and surfaces the prepared settings.
92///
93/// # Examples
94/// ```no_run
95/// use pg_embedded_setup_unpriv::bootstrap_for_tests;
96/// use temp_env::with_vars;
97///
98/// # fn main() -> pg_embedded_setup_unpriv::BootstrapResult<()> {
99/// let bootstrap = bootstrap_for_tests()?;
100/// with_vars(bootstrap.environment.to_env(), || -> pg_embedded_setup_unpriv::BootstrapResult<()> {
101///     // Launch application logic that relies on `bootstrap.settings` here.
102///     Ok(())
103/// })?;
104/// # Ok(())
105/// # }
106/// ```
107///
108/// # Errors
109/// Returns an error when bootstrap preparation fails or when subprocess orchestration
110/// cannot be configured.
111pub fn bootstrap_for_tests() -> BootstrapResult<TestBootstrapSettings> {
112    orchestrate_bootstrap()
113}
114
115fn orchestrate_bootstrap() -> BootstrapResult<TestBootstrapSettings> {
116    if let Err(err) = color_eyre::install() {
117        tracing::debug!("color_eyre already installed: {err}");
118    }
119
120    let privileges = detect_execution_privileges();
121    let cfg = PgEnvCfg::load().context("failed to load configuration via OrthoConfig")?;
122    let settings = cfg.to_settings()?;
123    let worker_binary = worker_binary_from_env(privileges)?;
124    let execution_mode = determine_execution_mode(privileges, worker_binary.as_ref())?;
125    let shutdown_timeout = shutdown_timeout_from_env()?;
126    let prepared = prepare_bootstrap(privileges, settings, &cfg)?;
127
128    Ok(TestBootstrapSettings {
129        privileges,
130        execution_mode,
131        settings: prepared.settings,
132        environment: prepared.environment,
133        worker_binary,
134        setup_timeout: DEFAULT_SETUP_TIMEOUT,
135        start_timeout: DEFAULT_START_TIMEOUT,
136        shutdown_timeout,
137        binary_cache_dir: cfg.binary_cache_dir,
138    })
139}
140
141#[cfg(test)]
142mod tests {
143    use super::*;
144    use crate::test_support::scoped_env;
145    use camino::Utf8PathBuf;
146    use rstest::{fixture, rstest};
147    use std::ffi::OsString;
148    use tempfile::tempdir;
149
150    /// Converts string key-value pairs to `OsString` pairs for `scoped_env`.
151    fn env_vars<const N: usize>(
152        pairs: [(&str, Option<&str>); N],
153    ) -> Vec<(OsString, Option<OsString>)> {
154        pairs
155            .into_iter()
156            .map(|(k, v)| (OsString::from(k), v.map(OsString::from)))
157            .collect()
158    }
159
160    #[test]
161    fn orchestrate_bootstrap_respects_env_overrides() {
162        if detect_execution_privileges() == ExecutionPrivileges::Root {
163            tracing::warn!(
164                "skipping orchestrate test because root privileges require PG_EMBEDDED_WORKER"
165            );
166            return;
167        }
168
169        let runtime = tempdir().expect("runtime dir");
170        let data = tempdir().expect("data dir");
171        let runtime_path =
172            Utf8PathBuf::from_path_buf(runtime.path().to_path_buf()).expect("runtime dir utf8");
173        let data_path =
174            Utf8PathBuf::from_path_buf(data.path().to_path_buf()).expect("data dir utf8");
175
176        let _guard = scoped_env(env_vars([
177            ("PG_RUNTIME_DIR", Some(runtime_path.as_str())),
178            ("PG_DATA_DIR", Some(data_path.as_str())),
179            ("PG_SUPERUSER", Some("bootstrap_test")),
180            ("PG_PASSWORD", Some("bootstrap_test_pw")),
181            ("PG_EMBEDDED_WORKER", None),
182        ]));
183        let settings = orchestrate_bootstrap().expect("bootstrap to succeed");
184
185        assert_paths(&settings, &runtime_path, &data_path);
186        assert_identity(&settings, "bootstrap_test", "bootstrap_test_pw");
187        assert_environment(&settings, &runtime_path);
188    }
189
190    /// Holds temporary directories for `run()` tests.
191    struct RunTestPaths {
192        _runtime: tempfile::TempDir,
193        _data: tempfile::TempDir,
194        runtime_path: Utf8PathBuf,
195        data_path: Utf8PathBuf,
196    }
197
198    /// Fixture providing run test paths, returning `None` if running as root.
199    #[fixture]
200    fn run_test_paths() -> Option<RunTestPaths> {
201        if detect_execution_privileges() == ExecutionPrivileges::Root {
202            tracing::warn!("skipping run test because root privileges require PG_EMBEDDED_WORKER");
203            return None;
204        }
205
206        let runtime = tempdir().expect("runtime dir");
207        let data = tempdir().expect("data dir");
208        let runtime_path =
209            Utf8PathBuf::from_path_buf(runtime.path().to_path_buf()).expect("runtime dir utf8");
210        let data_path =
211            Utf8PathBuf::from_path_buf(data.path().to_path_buf()).expect("data dir utf8");
212
213        Some(RunTestPaths {
214            _runtime: runtime,
215            _data: data,
216            runtime_path,
217            data_path,
218        })
219    }
220
221    #[rstest]
222    fn run_succeeds_with_customised_paths(run_test_paths: Option<RunTestPaths>) {
223        let Some(paths) = run_test_paths else {
224            return;
225        };
226
227        let _guard = scoped_env(env_vars([
228            ("PG_RUNTIME_DIR", Some(paths.runtime_path.as_str())),
229            ("PG_DATA_DIR", Some(paths.data_path.as_str())),
230            ("PG_SUPERUSER", Some("bootstrap_run")),
231            ("PG_PASSWORD", Some("bootstrap_run_pw")),
232            ("PG_EMBEDDED_WORKER", None),
233        ]));
234
235        run().expect("run should bootstrap successfully");
236
237        assert!(
238            paths.runtime_path.join("cache").exists(),
239            "cache directory should be created"
240        );
241        assert!(
242            paths.runtime_path.join("run").exists(),
243            "runtime directory should be created"
244        );
245    }
246
247    /// Holds temporary directories and their UTF-8 paths for bootstrap tests.
248    struct BootstrapPaths {
249        _runtime: tempfile::TempDir,
250        _data: tempfile::TempDir,
251        _cache: tempfile::TempDir,
252        runtime_path: Utf8PathBuf,
253        data_path: Utf8PathBuf,
254        cache_path: Utf8PathBuf,
255    }
256
257    /// Fixture providing bootstrap test paths, returning `None` if running as root.
258    #[fixture]
259    fn bootstrap_paths() -> Option<BootstrapPaths> {
260        if detect_execution_privileges() == ExecutionPrivileges::Root {
261            tracing::warn!(
262                "skipping orchestrate test because root privileges require PG_EMBEDDED_WORKER"
263            );
264            return None;
265        }
266
267        let runtime = tempdir().expect("runtime dir");
268        let data = tempdir().expect("data dir");
269        let cache = tempdir().expect("cache dir");
270        let runtime_path =
271            Utf8PathBuf::from_path_buf(runtime.path().to_path_buf()).expect("runtime dir utf8");
272        let data_path =
273            Utf8PathBuf::from_path_buf(data.path().to_path_buf()).expect("data dir utf8");
274        let cache_path =
275            Utf8PathBuf::from_path_buf(cache.path().to_path_buf()).expect("cache dir utf8");
276
277        Some(BootstrapPaths {
278            _runtime: runtime,
279            _data: data,
280            _cache: cache,
281            runtime_path,
282            data_path,
283            cache_path,
284        })
285    }
286
287    /// Runs `orchestrate_bootstrap` with cache-related environment variables set.
288    ///
289    /// Uses the mutex-protected `scoped_env` to avoid racing with other tests.
290    fn orchestrate_with_cache_env(paths: &BootstrapPaths) -> TestBootstrapSettings {
291        let _guard = scoped_env(env_vars([
292            ("PG_RUNTIME_DIR", Some(paths.runtime_path.as_str())),
293            ("PG_DATA_DIR", Some(paths.data_path.as_str())),
294            ("PG_BINARY_CACHE_DIR", Some(paths.cache_path.as_str())),
295            ("PG_SUPERUSER", Some("cache_test")),
296            ("PG_PASSWORD", Some("cache_test_pw")),
297            ("PG_EMBEDDED_WORKER", None),
298        ]));
299        orchestrate_bootstrap().expect("bootstrap to succeed")
300    }
301
302    #[rstest]
303    fn orchestrate_bootstrap_propagates_binary_cache_dir(bootstrap_paths: Option<BootstrapPaths>) {
304        let Some(paths) = bootstrap_paths else {
305            return;
306        };
307
308        let settings = orchestrate_with_cache_env(&paths);
309
310        assert_eq!(
311            settings.binary_cache_dir,
312            Some(paths.cache_path.clone()),
313            "binary_cache_dir should propagate from PG_BINARY_CACHE_DIR"
314        );
315    }
316
317    fn assert_paths(
318        settings: &TestBootstrapSettings,
319        runtime_path: &Utf8PathBuf,
320        data_path: &Utf8PathBuf,
321    ) {
322        let observed_install =
323            Utf8PathBuf::from_path_buf(settings.settings.installation_dir.clone())
324                .expect("installation dir utf8");
325        let observed_data =
326            Utf8PathBuf::from_path_buf(settings.settings.data_dir.clone()).expect("data dir utf8");
327
328        assert_eq!(observed_install.as_path(), runtime_path.as_path());
329        assert_eq!(observed_data.as_path(), data_path.as_path());
330    }
331
332    fn assert_identity(
333        settings: &TestBootstrapSettings,
334        expected_user: &str,
335        expected_password: &str,
336    ) {
337        assert_eq!(settings.settings.username, expected_user);
338        assert_eq!(settings.settings.password, expected_password);
339        assert_eq!(settings.privileges, ExecutionPrivileges::Unprivileged);
340        assert_eq!(settings.execution_mode, ExecutionMode::InProcess);
341        assert!(settings.worker_binary.is_none());
342    }
343
344    fn assert_environment(settings: &TestBootstrapSettings, runtime_path: &Utf8PathBuf) {
345        let env_pairs = settings.environment.to_env();
346        let pgpass = runtime_path.join(".pgpass");
347        assert!(env_pairs.contains(&("PGPASSFILE".into(), Some(pgpass.as_str().into()))));
348        assert_eq!(settings.environment.home.as_path(), runtime_path.as_path());
349    }
350}