Skip to main content

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