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 #[arg(long, conflicts_with = "no_config_file")]
110 config_file: Option<std::path::PathBuf>,
111 #[arg(long, conflicts_with = "config_file")]
113 no_config_file: bool,
114 #[arg(long)]
120 backend: Option<ociman::backend::Selection>,
121 #[arg(long)]
123 image: Option<crate::image::Image>,
124 #[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 Bin {
200 #[arg(long = "instance", default_value_t)]
202 instance_name: InstanceName,
203 command: String,
205 arguments: Vec<String>,
207 },
208 Cache {
210 #[arg(long = "instance", default_value_t)]
212 instance_name: InstanceName,
213 #[clap(subcommand)]
214 command: cache::Command,
215 },
216 Container {
225 #[arg(long = "instance", default_value_t)]
227 instance_name: InstanceName,
228 #[clap(subcommand)]
229 command: instance::Command,
230 },
231 Host {
238 #[arg(long = "instance", default_value_t)]
240 instance_name: InstanceName,
241 #[clap(subcommand)]
242 command: instance::Command,
243 },
244 #[command(name = "integration-server")]
252 IntegrationServer {
253 #[arg(long = "instance", default_value_t)]
255 instance_name: InstanceName,
256 #[arg(long)]
258 result_fd: std::os::fd::RawFd,
259 #[arg(long)]
261 control_fd: std::os::fd::RawFd,
262 },
263 List,
265 Meta {
267 #[clap(subcommand)]
268 command: meta::Command,
269 },
270 #[command(name = "platform")]
272 Platform {
273 #[clap(subcommand)]
274 command: platform::Command,
275 },
276 Psql {
278 #[arg(long = "instance", default_value_t)]
280 instance_name: InstanceName,
281 },
282 #[command(name = "run-env")]
284 RunEnv {
285 #[arg(long = "instance", default_value_t)]
287 instance_name: InstanceName,
288 command: String,
290 arguments: Vec<String>,
292 },
293 Session {
295 #[clap(subcommand)]
296 command: session::Command,
297 },
298 #[command(name = "schema-dump")]
300 SchemaDump {
301 #[arg(long = "instance", default_value_t)]
303 instance_name: InstanceName,
304 },
305 Shell {
307 #[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
459async 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 .interactive()
487 .tty_if_terminal()
488 .to_cmd_proc_command()
489 .status()
490 .await?;
491 Ok(())
492}
493
494#[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}