Skip to main content

pg_ephemeral/
cli.rs

1pub mod cache;
2pub mod container;
3pub mod host;
4pub mod instance;
5pub mod meta;
6pub mod platform;
7pub mod session;
8pub mod transparent;
9
10use crate::config::Config;
11use crate::container::TtyIfTerminal;
12use crate::seed::SeedName;
13use crate::{InstanceMap, InstanceName};
14
15#[derive(Debug, thiserror::Error)]
16pub enum Error {
17    #[error(transparent)]
18    Command(#[from] cmd_proc::CommandError),
19    #[error(transparent)]
20    Config(#[from] crate::config::Error),
21    #[error(transparent)]
22    Container(#[from] crate::container::Error),
23    #[error(transparent)]
24    AttachSession(#[from] crate::container::AttachSessionError),
25    #[error(transparent)]
26    EnvVariableValue(#[from] cmd_proc::EnvVariableValueError),
27    #[error(transparent)]
28    EnvVariableName(#[from] cmd_proc::EnvVariableNameError),
29    #[error("Unknown instance: {0}")]
30    UnknownInstance(InstanceName),
31    #[error("Instance {instance} has no seeds; cache credentials requires a cacheable seed")]
32    NoSeedsDefined { instance: InstanceName },
33    #[error("Instance {instance} has no seed named {seed}")]
34    UnknownSeed {
35        instance: InstanceName,
36        seed: SeedName,
37    },
38    #[error(
39        "Seed {seed} on instance {instance} is uncacheable; cache credentials requires a cacheable seed"
40    )]
41    SeedUncacheable {
42        instance: InstanceName,
43        seed: SeedName,
44    },
45    #[error(
46        "Seed {seed} on instance {instance} is not yet cached; run `pg-ephemeral cache populate` first"
47    )]
48    SeedNotCached {
49        instance: InstanceName,
50        seed: SeedName,
51    },
52    #[error("Failed to resolve container backend")]
53    BackendResolve(#[source] ociman::backend::resolve::Error),
54    #[error(transparent)]
55    Session(crate::session::ListError),
56    #[error(transparent)]
57    SessionFind(crate::session::FindError),
58    #[error(transparent)]
59    SessionStop(crate::session::StopError),
60    #[error(transparent)]
61    SessionMetadata(#[from] crate::session::MetadataError),
62    #[error("Unknown session: {name}")]
63    UnknownSession { name: crate::session::Name },
64    #[error("Failed to read current working directory")]
65    CurrentDir(#[source] std::io::Error),
66    #[error(transparent)]
67    TransparentWorkdir(#[from] crate::definition::TransparentWorkdirError),
68}
69
70#[derive(Clone, Debug, Default)]
71pub enum ConfigFileSource {
72    #[default]
73    Implicit,
74    Explicit(std::path::PathBuf),
75    None,
76}
77
78impl ConfigFileSource {
79    fn from_arguments(config_file: Option<std::path::PathBuf>, no_config_file: bool) -> Self {
80        match (config_file, no_config_file) {
81            (Some(path), false) => Self::Explicit(path),
82            (None, true) => Self::None,
83            (None, false) => Self::Implicit,
84            (Some(_), true) => unreachable!("clap conflicts_with prevents this"),
85        }
86    }
87}
88
89#[derive(Clone, Debug, clap::Parser)]
90#[command(after_help = "EXECUTION CONTEXT:
91    Bare commands (psql, run-env, schema-dump, shell) run in transparent mode:
92    the current working directory is bind-mounted into the container at the
93    same path, the command executes inside the container as the host user,
94    and PG* / DATABASE_URL point at the in-container unix socket.
95
96    Use `host <sub>` for an explicit host-side process (TCP to published port),
97    or `container <sub>` for an explicit in-container exec without the cwd
98    bind mount.
99
100INSTANCE SELECTION:
101    All commands target the \"main\" instance by default.
102    Use --instance <NAME> to target a different instance.")]
103#[command(version = crate::VERSION_STR)]
104pub struct App {
105    /// Config file to use, defaults to attempt to load database.toml
106    ///
107    /// If absent on default location a single "main" database is assumed on
108    /// autodetected backend with latest postgres and no other configuration.
109    #[arg(long, conflicts_with = "no_config_file")]
110    config_file: Option<std::path::PathBuf>,
111    /// Do not load any config file, use default instance map
112    #[arg(long, conflicts_with = "config_file")]
113    no_config_file: bool,
114    /// Overwrite backend
115    ///
116    /// If not specified on the CLI and not in the config file will be autodetected:
117    /// first based on env variable OCIMAN_BACKEND, then on installed tools.
118    /// If the autodetection fails exits with an error.
119    #[arg(long)]
120    backend: Option<ociman::backend::Selection>,
121    /// Overwrite image
122    #[arg(long)]
123    image: Option<crate::image::Image>,
124    /// Enable SSL with the specified hostname
125    #[arg(long)]
126    ssl_hostname: Option<pg_client::config::HostName>,
127    #[clap(subcommand)]
128    command: Option<Command>,
129}
130
131impl App {
132    pub async fn run(&self) -> Result<(), Error> {
133        let overwrites = crate::config::InstanceDefinition {
134            image: self.image.clone(),
135            parameters: pg_client::parameter::Map::new(),
136            seeds: indexmap::IndexMap::new(),
137            ssl_config: self
138                .ssl_hostname
139                .clone()
140                .map(|hostname| crate::config::SslConfigDefinition { hostname }),
141            wait_available_timeout: None,
142        };
143
144        let config_file_source =
145            ConfigFileSource::from_arguments(self.config_file.clone(), self.no_config_file);
146
147        let resolved = match config_file_source {
148            ConfigFileSource::Explicit(config_file) => {
149                Config::load_toml_file(&config_file, self.backend, &overwrites)?
150            }
151            ConfigFileSource::None => {
152                log::debug!("--no-config-file specified, using default instance map");
153                crate::Config::default().resolve(self.backend, &overwrites)?
154            }
155            ConfigFileSource::Implicit => {
156                log::debug!("No config file specified, trying to load from default location");
157
158                match Config::load_toml_file("database.toml", self.backend, &overwrites) {
159                    Ok(value) => value,
160                    Err(crate::config::Error::IO(crate::config::IoError(
161                        std::io::ErrorKind::NotFound,
162                    ))) => {
163                        log::debug!(
164                            "Config file does not exist in default location, using default instance map"
165                        );
166                        crate::Config::default().resolve(self.backend, &overwrites)?
167                    }
168                    Err(error) => return Err(error.into()),
169                }
170            }
171        };
172
173        self.command
174            .clone()
175            .unwrap_or_default()
176            .run(resolved.backend_selection, &resolved.instances)
177            .await?;
178
179        Ok(())
180    }
181}
182
183async fn resolve_backend(selection: ociman::backend::Selection) -> Result<ociman::Backend, Error> {
184    selection.resolve().await.map_err(Error::BackendResolve)
185}
186
187#[derive(Clone, Debug, clap::Parser)]
188pub enum Command {
189    /// Run a tool from the instance image against the host working directory
190    ///
191    /// Boots no PostgreSQL and sets no PG* / DATABASE_URL: the current working
192    /// directory is bind-mounted into the container at the same path, the tool
193    /// runs as the container `--entrypoint`, and its stdout/stderr stream to
194    /// the terminal. Intended for running the image's version-pinned tooling
195    /// (`pg_dump`, `pg_format`, …) on host files without a database.
196    ///
197    /// Use `--` to separate pg-ephemeral's options from the tool and its
198    /// arguments: `pg-ephemeral bin -- pg_dump --version`.
199    Bin {
200        /// Target instance name (selects the image)
201        #[arg(long = "instance", default_value_t)]
202        instance_name: InstanceName,
203        /// The tool to run, resolved against the image's $PATH
204        command: String,
205        /// Arguments passed to the tool
206        arguments: Vec<String>,
207    },
208    /// Cache related commands
209    Cache {
210        /// Target instance name
211        #[arg(long = "instance", default_value_t)]
212        instance_name: InstanceName,
213        #[clap(subcommand)]
214        command: cache::Command,
215    },
216    /// Operations executed inside the running container
217    ///
218    /// Each subcommand `podman exec`s the target inside the booted
219    /// PostgreSQL container: the executable resolves against the image's
220    /// $PATH, sees the container filesystem, and connects to PostgreSQL
221    /// via the in-container unix socket (`/var/run/postgresql`). Use these
222    /// when you need container-side semantics — version-matched `pg_dump`,
223    /// scripts that depend on container-installed extensions, etc.
224    Container {
225        /// Target instance name
226        #[arg(long = "instance", default_value_t)]
227        instance_name: InstanceName,
228        #[clap(subcommand)]
229        command: instance::Command,
230    },
231    /// Operations executed on the host against the running container
232    ///
233    /// Each subcommand runs as a host process with stdio inherited and
234    /// PG* / DATABASE_URL pointing at the container's published TCP port.
235    /// Use these when the tool must read or write host filesystem, or
236    /// must stream binary data through pipes without PTY corruption.
237    Host {
238        /// Target instance name
239        #[arg(long = "instance", default_value_t)]
240        instance_name: InstanceName,
241        #[clap(subcommand)]
242        command: instance::Command,
243    },
244    /// Run integration server
245    ///
246    /// Intent to be used for automation with other languages wrapping pg-ephemeral.
247    ///
248    /// After successful boot connects to the inherited pipe file descriptors,
249    /// writes a single JSON line with connection details to --result-fd,
250    /// then waits for EOF on --control-fd before shutting down.
251    #[command(name = "integration-server")]
252    IntegrationServer {
253        /// Target instance name
254        #[arg(long = "instance", default_value_t)]
255        instance_name: InstanceName,
256        /// File descriptor for writing the result JSON
257        #[arg(long)]
258        result_fd: std::os::fd::RawFd,
259        /// File descriptor for reading the control signal (EOF = shutdown)
260        #[arg(long)]
261        control_fd: std::os::fd::RawFd,
262    },
263    /// List defined instances
264    List,
265    /// Backend introspection (kind, version, rootless status)
266    Meta {
267        #[clap(subcommand)]
268        command: meta::Command,
269    },
270    /// Platform related commands
271    #[command(name = "platform")]
272    Platform {
273        #[clap(subcommand)]
274        command: platform::Command,
275    },
276    /// Run interactive psql
277    Psql {
278        /// Target instance name
279        #[arg(long = "instance", default_value_t)]
280        instance_name: InstanceName,
281    },
282    /// Run a command with PostgreSQL connection environment
283    #[command(name = "run-env")]
284    RunEnv {
285        /// Target instance name
286        #[arg(long = "instance", default_value_t)]
287        instance_name: InstanceName,
288        /// The command to run
289        command: String,
290        /// Arguments to pass to the command
291        arguments: Vec<String>,
292    },
293    /// Named-session management
294    Session {
295        #[clap(subcommand)]
296        command: session::Command,
297    },
298    /// Dump schema to stdout
299    #[command(name = "schema-dump")]
300    SchemaDump {
301        /// Target instance name
302        #[arg(long = "instance", default_value_t)]
303        instance_name: InstanceName,
304    },
305    /// Run interactive shell
306    Shell {
307        /// Target instance name
308        #[arg(long = "instance", default_value_t)]
309        instance_name: InstanceName,
310    },
311}
312
313impl Default for Command {
314    fn default() -> Self {
315        Self::Psql {
316            instance_name: InstanceName::default(),
317        }
318    }
319}
320
321impl Command {
322    pub async fn run(
323        &self,
324        backend_selection: ociman::backend::Selection,
325        instance_map: &InstanceMap,
326    ) -> Result<(), Error> {
327        match self {
328            Self::Bin {
329                instance_name,
330                command,
331                arguments,
332            } => {
333                let backend = resolve_backend(backend_selection).await?;
334                run_bin(backend, instance_map, instance_name, command, arguments).await?
335            }
336            Self::Cache {
337                instance_name,
338                command,
339            } => {
340                let backend = resolve_backend(backend_selection).await?;
341                command.run(&backend, instance_map, instance_name).await?
342            }
343            Self::Container {
344                instance_name,
345                command,
346            } => {
347                let backend = resolve_backend(backend_selection).await?;
348                let definition =
349                    get_instance(instance_map, instance_name)?.definition(backend, instance_name);
350                container::Command(command).run(&definition).await?
351            }
352            Self::Host {
353                instance_name,
354                command,
355            } => {
356                let backend = resolve_backend(backend_selection).await?;
357                let definition =
358                    get_instance(instance_map, instance_name)?.definition(backend, instance_name);
359                host::Command(command).run(&definition).await?
360            }
361            Self::IntegrationServer {
362                instance_name,
363                result_fd,
364                control_fd,
365            } => {
366                let backend = resolve_backend(backend_selection).await?;
367                let definition =
368                    get_instance(instance_map, instance_name)?.definition(backend, instance_name);
369                definition
370                    .run_integration_server(*result_fd, *control_fd)
371                    .await?
372            }
373            Self::List => {
374                for instance_name in instance_map.keys() {
375                    println!("{instance_name}")
376                }
377            }
378            Self::Meta { command } => {
379                let backend = resolve_backend(backend_selection).await?;
380                command.run(&backend).await?
381            }
382            Self::Platform { command } => command.run(),
383            Self::Psql { instance_name } => {
384                let backend = resolve_backend(backend_selection).await?;
385                run_transparent(
386                    backend,
387                    instance_map,
388                    instance_name,
389                    &instance::Command::Psql,
390                )
391                .await?
392            }
393            Self::RunEnv {
394                instance_name,
395                command,
396                arguments,
397            } => {
398                let backend = resolve_backend(backend_selection).await?;
399                run_transparent(
400                    backend,
401                    instance_map,
402                    instance_name,
403                    &instance::Command::RunEnv {
404                        command: command.clone(),
405                        arguments: arguments.clone(),
406                    },
407                )
408                .await?
409            }
410            Self::Session { command } => {
411                let backend = resolve_backend(backend_selection).await?;
412                command.run(&backend, instance_map).await?
413            }
414            Self::SchemaDump { instance_name } => {
415                let backend = resolve_backend(backend_selection).await?;
416                run_transparent(
417                    backend,
418                    instance_map,
419                    instance_name,
420                    &instance::Command::SchemaDump,
421                )
422                .await?
423            }
424            Self::Shell { instance_name } => {
425                let backend = resolve_backend(backend_selection).await?;
426                run_transparent(
427                    backend,
428                    instance_map,
429                    instance_name,
430                    &instance::Command::Shell,
431                )
432                .await?
433            }
434        }
435
436        Ok(())
437    }
438}
439
440async fn run_transparent(
441    backend: ociman::Backend,
442    instance_map: &InstanceMap,
443    instance_name: &InstanceName,
444    command: &instance::Command,
445) -> Result<(), Error> {
446    let cwd = std::env::current_dir().map_err(Error::CurrentDir)?;
447    let workdir = crate::definition::TransparentWorkdir::try_from(cwd)?;
448    let definition = get_instance(instance_map, instance_name)?
449        .definition(backend, instance_name)
450        .transparent_workdir(workdir.clone());
451    transparent::Command {
452        command,
453        workdir: &workdir,
454    }
455    .run(&definition)
456    .await
457}
458
459/// Run a tool from the instance image against the host cwd, without booting
460/// PostgreSQL.
461///
462/// Projects the instance definition to an `ociman::Definition` (carrying the
463/// cwd bind mount via `transparent_workdir`), overrides the entrypoint to the
464/// requested tool, and runs it with host stdio inherited. No PostgreSQL boot,
465/// no seeds, no PG* / DATABASE_URL.
466async fn run_bin(
467    backend: ociman::Backend,
468    instance_map: &InstanceMap,
469    instance_name: &InstanceName,
470    command: &str,
471    arguments: &[String],
472) -> Result<(), Error> {
473    let cwd = std::env::current_dir().map_err(Error::CurrentDir)?;
474    let workdir = crate::definition::TransparentWorkdir::try_from(cwd)?;
475    get_instance(instance_map, instance_name)?
476        .definition(backend, instance_name)
477        .transparent_workdir(workdir.clone())
478        .to_ociman_definition()
479        .remove()
480        .workdir(workdir.as_str())
481        .entrypoint(command)
482        .arguments(arguments.iter().cloned())
483        .environment_variables(host_pg_env()?)
484        // Forward host stdin and attach a PTY when running from a terminal, so
485        // interactive tools and stdin piping behave like a local install.
486        .interactive()
487        .tty_if_terminal()
488        .to_cmd_proc_command()
489        .status()
490        .await?;
491    Ok(())
492}
493
494/// Collect the host's `PG*` environment variables to forward into `bin`'s
495/// container, so image tooling honors the same libpq connection environment a
496/// host install would (`PGHOST`, `PGUSER`, `PGPASSWORD`, `PGSSLMODE`, …).
497///
498/// Only the `PG`-prefixed variables are forwarded; host-specific variables
499/// like `PATH` / `HOME` / `LD_LIBRARY_PATH` would break tool and library
500/// resolution inside the image, so they are deliberately left out.
501///
502/// Path-valued vars (`PGSSLROOTCERT`, `PGPASSFILE`, `PGSERVICEFILE`, …) are
503/// forwarded verbatim, so they only resolve when the referenced file is
504/// reachable in the container at that path — i.e. inside the bind-mounted
505/// working directory. A path elsewhere on the host will not be found.
506#[allow(
507    clippy::result_large_err,
508    reason = "cli::Error aggregates container/seed errors that intentionally carry diagnostic context; the 128-byte threshold targets async-server hot paths that don't apply here"
509)]
510fn host_pg_env() -> Result<Vec<(cmd_proc::EnvVariableName, cmd_proc::EnvVariableValue)>, Error> {
511    std::env::vars()
512        .filter(|(name, _)| name.starts_with("PG"))
513        .map(|(name, value)| {
514            Ok((
515                name.parse::<cmd_proc::EnvVariableName>()?,
516                value.parse::<cmd_proc::EnvVariableValue>()?,
517            ))
518        })
519        .collect()
520}
521
522#[allow(
523    clippy::result_large_err,
524    reason = "cli::Error aggregates container/seed errors that intentionally carry diagnostic context; the 128-byte threshold targets async-server hot paths that don't apply here"
525)]
526pub(super) fn get_instance<'a>(
527    instance_map: &'a InstanceMap,
528    instance_name: &InstanceName,
529) -> Result<&'a crate::config::Instance, Error> {
530    instance_map
531        .get(instance_name)
532        .ok_or_else(|| Error::UnknownInstance(instance_name.clone()))
533}