Skip to main content

pg_ephemeral/
cli.rs

1use crate::config::Config;
2use crate::{InstanceMap, InstanceName};
3
4#[derive(Debug, thiserror::Error)]
5pub enum Error {
6    #[error(transparent)]
7    Command(#[from] cmd_proc::CommandError),
8    #[error(transparent)]
9    Config(#[from] crate::config::Error),
10    #[error(transparent)]
11    Container(#[from] crate::container::Error),
12    #[error("Unknown instance: {0}")]
13    UnknownInstance(InstanceName),
14}
15
16#[derive(Clone, Debug, Default)]
17pub enum ConfigFileSource {
18    #[default]
19    Implicit,
20    Explicit(std::path::PathBuf),
21    None,
22}
23
24impl ConfigFileSource {
25    fn from_arguments(config_file: Option<std::path::PathBuf>, no_config_file: bool) -> Self {
26        match (config_file, no_config_file) {
27            (Some(path), false) => Self::Explicit(path),
28            (None, true) => Self::None,
29            (None, false) => Self::Implicit,
30            (Some(_), true) => unreachable!("clap conflicts_with prevents this"),
31        }
32    }
33}
34
35#[derive(Clone, Debug, clap::Parser)]
36#[command(after_help = "INSTANCE SELECTION:
37    All commands target the \"main\" instance by default.
38    Use --instance <NAME> to target a different instance.")]
39#[command(version = crate::VERSION_STR)]
40pub struct App {
41    /// Config file to use, defaults to attempt to load database.toml
42    ///
43    /// If absent on default location a single "main" database is assumed on
44    /// autodetected backend with latest postgres and no other configuration.
45    #[arg(long, conflicts_with = "no_config_file")]
46    config_file: Option<std::path::PathBuf>,
47    /// Do not load any config file, use default instance map
48    #[arg(long, conflicts_with = "config_file")]
49    no_config_file: bool,
50    /// Overwrite backend
51    ///
52    /// If not specified on the CLI and not in the config file will be autodetected:
53    /// first based on env variable OCIMAN_BACKEND, then on installed tools.
54    /// If the autodetection fails exits with an error.
55    #[arg(long)]
56    backend: Option<ociman::backend::Selection>,
57    /// Overwrite image
58    #[arg(long)]
59    image: Option<crate::image::Image>,
60    /// Enable SSL with the specified hostname
61    #[arg(long)]
62    ssl_hostname: Option<pg_client::HostName>,
63    #[clap(subcommand)]
64    command: Option<Command>,
65}
66
67impl App {
68    pub async fn run(&self) -> Result<(), Error> {
69        let overwrites = crate::config::InstanceDefinition {
70            backend: self.backend,
71            image: self.image.clone(),
72            seeds: indexmap::IndexMap::new(),
73            ssl_config: self
74                .ssl_hostname
75                .clone()
76                .map(|hostname| crate::config::SslConfigDefinition { hostname }),
77            wait_available_timeout: None,
78        };
79
80        let config_file_source =
81            ConfigFileSource::from_arguments(self.config_file.clone(), self.no_config_file);
82
83        let instance_map = match config_file_source {
84            ConfigFileSource::Explicit(config_file) => {
85                Config::load_toml_file(&config_file, &overwrites)?
86            }
87            ConfigFileSource::None => {
88                log::debug!("--no-config-file specified, using default instance map");
89                crate::Config::default().instance_map(&overwrites)?
90            }
91            ConfigFileSource::Implicit => {
92                log::debug!("No config file specified, trying to load from default location");
93
94                match Config::load_toml_file("database.toml", &overwrites) {
95                    Ok(value) => value,
96                    Err(crate::config::Error::IO(crate::config::IoError(
97                        std::io::ErrorKind::NotFound,
98                    ))) => {
99                        log::debug!(
100                            "Config file does not exist in default location, using default instance map"
101                        );
102                        crate::Config::default().instance_map(&overwrites)?
103                    }
104                    Err(error) => return Err(error.into()),
105                }
106            }
107        };
108
109        self.command
110            .clone()
111            .unwrap_or_default()
112            .run(&instance_map)
113            .await?;
114
115        Ok(())
116    }
117}
118
119#[derive(Clone, Debug, clap::Parser)]
120pub enum CacheCommand {
121    /// Print cache status for seeds
122    Status {
123        /// Output as JSON with full details
124        #[arg(long)]
125        json: bool,
126    },
127    /// Remove cached images for the instance
128    Reset {
129        /// Force removal even if images are in use by stopped containers
130        #[arg(long)]
131        force: bool,
132    },
133    /// Populate cache by running seeds and committing at each cacheable point
134    Populate,
135}
136
137#[derive(Clone, Debug, clap::Parser)]
138pub enum Command {
139    /// Cache related commands
140    Cache {
141        /// Target instance name
142        #[arg(long = "instance", default_value_t)]
143        instance_name: InstanceName,
144        #[clap(subcommand)]
145        command: CacheCommand,
146    },
147    /// Run interactive psql session on the container
148    #[command(name = "container-psql")]
149    ContainerPsql {
150        /// Target instance name
151        #[arg(long = "instance", default_value_t)]
152        instance_name: InstanceName,
153    },
154    /// List defined instances
155    List,
156    /// Run schema dump from the container
157    #[command(name = "container-schema-dump")]
158    ContainerSchemaDump {
159        /// Target instance name
160        #[arg(long = "instance", default_value_t)]
161        instance_name: InstanceName,
162    },
163    /// Run interactive shell on the container
164    #[command(name = "container-shell")]
165    ContainerShell {
166        /// Target instance name
167        #[arg(long = "instance", default_value_t)]
168        instance_name: InstanceName,
169    },
170    /// Run integration server
171    ///
172    /// Intent to be used for automation with other languages wrapping pg-ephemeral.
173    ///
174    /// After successful boot connects to the inherited pipe file descriptors,
175    /// writes a single JSON line with connection details to --result-fd,
176    /// then waits for EOF on --control-fd before shutting down.
177    #[command(name = "integration-server")]
178    IntegrationServer {
179        /// Target instance name
180        #[arg(long = "instance", default_value_t)]
181        instance_name: InstanceName,
182        /// File descriptor for writing the result JSON
183        #[arg(long)]
184        result_fd: std::os::fd::RawFd,
185        /// File descriptor for reading the control signal (EOF = shutdown)
186        #[arg(long)]
187        control_fd: std::os::fd::RawFd,
188    },
189    /// Run interactive psql on the host
190    Psql {
191        /// Target instance name
192        #[arg(long = "instance", default_value_t)]
193        instance_name: InstanceName,
194    },
195    /// Run shell command with environment variables for PostgreSQL connection
196    ///
197    /// Sets all PostgreSQL-related environment variables:
198    /// - libpq-style PG* environment variables (PGHOST, PGPORT, PGUSER, PGDATABASE, PGPASSWORD, PGSSLMODE, etc.)
199    /// - DATABASE_URL in PostgreSQL URL format
200    RunEnv {
201        /// Target instance name
202        #[arg(long = "instance", default_value_t)]
203        instance_name: InstanceName,
204        /// The command to run
205        command: String,
206        /// Arguments to pass to the command
207        arguments: Vec<String>,
208    },
209    /// Platform related commands
210    #[command(name = "platform")]
211    Platform {
212        #[clap(subcommand)]
213        command: PlatformCommand,
214    },
215}
216
217#[derive(Clone, Debug, clap::Parser)]
218pub enum PlatformCommand {
219    /// Check if the current platform is supported
220    ///
221    /// Exits with status 0 if platform is supported.
222    /// Exits with status 1 if platform is not supported.
223    Support,
224    /// Trigger a panic to test backtrace quality
225    ///
226    /// Used by integration tests to verify that backtraces
227    /// contain file paths and line numbers in release builds.
228    TestBacktrace,
229}
230
231impl PlatformCommand {
232    fn run(&self) {
233        match self {
234            Self::Support => match ociman::platform::support() {
235                Ok(()) => {
236                    std::process::exit(0);
237                }
238                Err(error) => {
239                    log::info!("pg-ephemeral is not supported on this platform: {error}");
240                    std::process::exit(1);
241                }
242            },
243            Self::TestBacktrace => {
244                trigger_test_panic();
245            }
246        }
247    }
248}
249
250#[inline(never)]
251fn trigger_test_panic() {
252    inner_function_for_backtrace_test();
253}
254
255#[inline(never)]
256fn inner_function_for_backtrace_test() {
257    panic!("intentional panic for backtrace testing");
258}
259
260impl Default for Command {
261    fn default() -> Self {
262        Self::Psql {
263            instance_name: InstanceName::default(),
264        }
265    }
266}
267
268impl Command {
269    pub async fn run(&self, instance_map: &InstanceMap) -> Result<(), Error> {
270        match self {
271            Self::Cache {
272                instance_name,
273                command,
274            } => match command {
275                CacheCommand::Status { json } => {
276                    let definition = Self::get_instance(instance_map, instance_name)?
277                        .definition(instance_name)
278                        .await
279                        .unwrap();
280                    definition.print_cache_status(instance_name, *json).await?
281                }
282                CacheCommand::Reset { force } => {
283                    let definition = Self::get_instance(instance_map, instance_name)?
284                        .definition(instance_name)
285                        .await
286                        .unwrap();
287                    let name: ociman::reference::Name =
288                        format!("pg-ephemeral/{instance_name}").parse().unwrap();
289                    let references = definition.backend.image_references_by_name(&name).await;
290                    for reference in &references {
291                        if *force {
292                            definition.backend.remove_image_force(reference).await;
293                        } else {
294                            definition.backend.remove_image(reference).await;
295                        }
296                        println!("Removed: {reference}");
297                    }
298                }
299                CacheCommand::Populate => {
300                    let definition = Self::get_instance(instance_map, instance_name)?
301                        .definition(instance_name)
302                        .await
303                        .unwrap();
304                    definition.populate_cache(instance_name).await?;
305                    definition.print_cache_status(instance_name, false).await?;
306                }
307            },
308            Self::ContainerPsql { instance_name } => {
309                let definition = Self::get_instance(instance_map, instance_name)?
310                    .definition(instance_name)
311                    .await
312                    .unwrap();
313                definition.with_container(container_psql).await?
314            }
315            Self::ContainerSchemaDump { instance_name } => {
316                let definition = Self::get_instance(instance_map, instance_name)?
317                    .definition(instance_name)
318                    .await
319                    .unwrap();
320                definition.with_container(container_schema_dump).await?
321            }
322            Self::ContainerShell { instance_name } => {
323                let definition = Self::get_instance(instance_map, instance_name)?
324                    .definition(instance_name)
325                    .await
326                    .unwrap();
327                definition.with_container(container_shell).await?
328            }
329            Self::IntegrationServer {
330                instance_name,
331                result_fd,
332                control_fd,
333            } => {
334                let definition = Self::get_instance(instance_map, instance_name)?
335                    .definition(instance_name)
336                    .await
337                    .unwrap();
338                definition
339                    .run_integration_server(*result_fd, *control_fd)
340                    .await?
341            }
342            Self::List => {
343                for instance_name in instance_map.keys() {
344                    println!("{instance_name}")
345                }
346            }
347            Self::Psql { instance_name } => {
348                let definition = Self::get_instance(instance_map, instance_name)?
349                    .definition(instance_name)
350                    .await
351                    .unwrap();
352                definition.with_container(host_psql).await??
353            }
354            Self::RunEnv {
355                instance_name,
356                command,
357                arguments,
358            } => {
359                let definition = Self::get_instance(instance_map, instance_name)?
360                    .definition(instance_name)
361                    .await
362                    .unwrap();
363                definition
364                    .with_container(async |container| {
365                        host_command(container, command, arguments).await
366                    })
367                    .await??
368            }
369            Self::Platform { command } => command.run(),
370        }
371
372        Ok(())
373    }
374
375    fn get_instance<'a>(
376        instance_map: &'a InstanceMap,
377        instance_name: &InstanceName,
378    ) -> Result<&'a crate::config::Instance, Error> {
379        instance_map
380            .get(instance_name)
381            .ok_or_else(|| Error::UnknownInstance(instance_name.clone()))
382    }
383}
384
385async fn host_psql(container: &crate::container::Container) -> Result<(), cmd_proc::CommandError> {
386    cmd_proc::Command::new("psql")
387        .envs(container.pg_env())
388        .status()
389        .await
390}
391
392async fn host_command(
393    container: &crate::container::Container,
394    command: &str,
395    arguments: &Vec<String>,
396) -> Result<(), cmd_proc::CommandError> {
397    cmd_proc::Command::new(command)
398        .arguments(arguments)
399        .envs(container.pg_env())
400        .env(&crate::ENV_DATABASE_URL, container.database_url())
401        .status()
402        .await
403}
404
405async fn container_psql(container: &crate::container::Container) {
406    container.exec_psql().await
407}
408
409async fn container_schema_dump(container: &crate::container::Container) {
410    let pg_schema_dump = pg_client::PgSchemaDump::new();
411    println!("{}", container.exec_schema_dump(&pg_schema_dump).await);
412}
413
414async fn container_shell(container: &crate::container::Container) {
415    container.exec_container_shell().await
416}