#![deny(unsafe_code)]
use std::process;
#[cfg(feature = "completions")]
use clap::CommandFactory;
mod cli;
mod dispatch;
mod generate;
mod resolve;
mod startup;
use cli::*;
use generate::write_quadlet;
use resolve::*;
use startup::{init_tracing, internal_error_notice, is_label_only, is_mutating, parse_cli};
fn main() {
std::panic::set_hook(Box::new(|info| {
if startup::is_broken_pipe_panic(&info.to_string()) {
std::process::exit(0);
}
eprintln!("podup: internal error: {info}");
eprintln!("{}", internal_error_notice());
}));
std::thread::Builder::new()
.stack_size(8 * 1024 * 1024)
.name("podup".into())
.spawn(run_to_exit)
.expect("spawn podup worker thread")
.join()
.expect("podup worker thread panicked");
}
fn run_to_exit() {
let runtime = tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()
.expect("build Tokio runtime");
match runtime.block_on(run()) {
Ok(()) => {}
Err(podup::ComposeError::RunExited(code)) => process::exit(code as i32),
#[cfg(feature = "update")]
Err(e @ podup::ComposeError::Update(_)) => {
print_error(&e);
process::exit(podup::update::exit_code(&e));
}
Err(e) => {
print_error(&e);
let code = match &e {
podup::ComposeError::Podman(_) => command_failure_exit_code(&e.to_string()),
_ => 1,
};
process::exit(code);
}
}
}
fn resolve_socket(cli_socket: Option<&str>) -> Option<String> {
cli_socket
.map(str::to_string)
.or_else(|| std::env::var("DOCKER_HOST").ok().filter(|s| !s.is_empty()))
}
fn print_command_help(commands: &[String]) -> podup::Result<()> {
use clap::CommandFactory;
let mut cmd = Cli::command();
let target = commands
.iter()
.find(|t| !matches!(t.as_str(), "-h" | "--help" | "--"));
let rendered = match target.and_then(|name| cmd.find_subcommand_mut(name)) {
Some(sub) => sub.render_long_help(),
None => cmd.render_long_help(),
};
if podup::ui::stdout_colored() {
print!("\n{}\n", rendered.ansi());
} else {
print!("\n{rendered}\n");
}
Ok(())
}
fn command_failure_exit_code(msg: &str) -> i32 {
let m = msg.to_ascii_lowercase();
let not_found = m.contains("executable file not found")
|| m.contains("not found in $path")
|| (m.contains("oci runtime") && m.contains("no such file"));
let not_executable = m.contains("exec format error")
|| m.contains("not executable")
|| (m.contains("oci runtime") && m.contains("permission denied"));
if not_found {
127
} else if not_executable {
126
} else {
1
}
}
fn down_by_label_path(command: &Commands, project: Option<&str>, compose_present: bool) -> bool {
matches!(command, Commands::Down { .. }) && project.is_some() && !compose_present
}
fn print_error(e: &podup::ComposeError) {
use std::io::Write;
let style = podup::ui::error_style();
let mut err = anstream::stderr();
let _ = writeln!(
err,
"podup: {}error:{} {e}",
style.render(),
style.render_reset()
);
}
async fn run() -> podup::Result<()> {
let cli = parse_cli();
podup::ui::set_color_choice(cli.ansi.into());
podup::ui::set_progress(true);
let log_floor = if matches!(cli.command, Commands::Watch) {
"info"
} else {
"warn"
};
init_tracing(log_floor);
if let Commands::Help { commands } = &cli.command {
return print_command_help(commands);
}
#[cfg(feature = "completions")]
if let Commands::Completions { shell } = cli.command {
let mut cmd = Cli::command();
let name = cmd.get_name().to_string();
let mut buf = Vec::new();
clap_complete::generate(shell, &mut cmd, name, &mut buf);
match std::io::Write::write_all(&mut std::io::stdout(), &buf) {
Ok(()) => return Ok(()),
Err(e) if e.kind() == std::io::ErrorKind::BrokenPipe => return Ok(()),
Err(e) => return Err(e.into()),
}
}
#[cfg(feature = "update")]
if let Commands::Update { check, force } = cli.command {
if let Some(flag) = misused_update_global() {
use clap::CommandFactory;
Cli::command()
.error(
clap::error::ErrorKind::ArgumentConflict,
format!(
"`{flag}` has no effect on `update`, which replaces the podup \
binary itself rather than acting on a compose project"
),
)
.exit();
}
let opts = podup::update::UpdateOptions {
check_only: check,
force,
};
return tokio::task::spawn_blocking(move || podup::update::run(opts))
.await
.map_err(|e| podup::ComposeError::Update(format!("update task failed: {e}")))?;
}
validate_project_directory(cli.project_directory.as_deref())?;
if let Commands::Ls {
all,
quiet,
filter,
format,
} = &cli.command
{
for ef in &cli.env_file {
if !std::path::Path::new(ef).is_file() {
return Err(podup::ComposeError::Unsupported(format!(
"--env-file {ef} does not exist or is not a file"
)));
}
}
let client = podup::podman::connect(resolve_socket(cli.socket.as_deref()).as_deref())?;
return podup::list_projects_filtered(
&client,
podup::LsOptions {
all: *all,
quiet: *quiet,
json: *format == OutputFormat::Json,
},
filter,
)
.await;
}
if let Commands::Ps {
all,
quiet,
services_only,
filter,
status,
format,
services,
} = &cli.command
{
let compose_files = resolve_compose_files(&cli.file);
let base_dir = resolve_base_dir(cli.project_directory.as_deref(), &compose_files[0]);
let parsed = podup::parse_files_with_env_files(&compose_files, &cli.env_file).ok();
let compose_name = parsed.as_ref().and_then(|f| f.name.clone());
let file = parsed.unwrap_or_default();
let project = resolve_project_name(cli.project.clone(), compose_name.as_deref(), &base_dir);
startup::validate_project_name(&project)?;
let client = podup::podman::connect(resolve_socket(cli.socket.as_deref()).as_deref())?;
let engine = podup::Engine::with_base_dir(client, project, base_dir);
return engine
.ps_filtered(
&file,
podup::PsOptions {
all: *all,
quiet: *quiet,
json: *format == OutputFormat::Json,
},
podup::PsFilterOptions {
services_only: *services_only,
services: services.clone(),
status: status.clone(),
filters: filter.clone(),
},
)
.await;
}
let compose_files = resolve_compose_files(&cli.file);
if down_by_label_path(
&cli.command,
cli.project.as_deref(),
compose_files.iter().any(|p| p.is_file()),
) {
if let Commands::Down {
volumes,
rmi,
timeout,
..
} = &cli.command
{
let project = cli.project.clone().expect("checked by down_by_label_path");
startup::validate_project_name(&project)?;
if rmi.is_some() {
tracing::warn!(
"--rmi has no effect without a compose file; containers, networks and \
volumes are still removed by project label"
);
}
let base_dir = resolve_base_dir(cli.project_directory.as_deref(), &compose_files[0]);
let stop_timeout = podup::validate_stop_timeout(*timeout)?;
let client = podup::podman::connect(resolve_socket(cli.socket.as_deref()).as_deref())?;
let engine = podup::Engine::with_base_dir(client, project, base_dir)
.with_stop_timeout(stop_timeout);
let _lock = engine.lock_project()?;
return engine.down_by_label(*volumes).await;
}
}
let no_interpolate = matches!(
&cli.command,
Commands::Config {
no_interpolate: true,
..
}
);
let label_only = is_label_only(&cli.command);
let file = if label_only && !compose_files.iter().any(|p| p.is_file()) {
podup::compose::types::ComposeFile::default()
} else {
podup::parse_files_with_env_files_interp(&compose_files, &cli.env_file, !no_interpolate)?
};
if let Commands::Config {
format,
services,
volumes,
images,
profiles,
hash,
quiet,
resolve_image_digests,
..
} = &cli.command
{
let base_dir = resolve_base_dir(cli.project_directory.as_deref(), &compose_files[0]);
let project = resolve_project_name(cli.project.clone(), file.name.as_deref(), &base_dir);
startup::validate_project_name(&project)?;
let parsed = file;
let mut resolved = if *resolve_image_digests {
let client = podup::podman::connect(resolve_socket(cli.socket.as_deref()).as_deref())?;
podup::resolve_image_digests(&client, &parsed).await?
} else {
parsed
};
podup::retain_active_profiles(&mut resolved, &cli.profile);
return startup::render_config(
&resolved,
format,
&startup::ConfigOutput {
services: *services,
volumes: *volumes,
images: *images,
profiles: *profiles,
hash: hash.clone(),
quiet: *quiet,
},
&project,
&base_dir,
);
}
let base_dir = resolve_base_dir(cli.project_directory.as_deref(), &compose_files[0]);
let project = resolve_project_name(cli.project, file.name.as_deref(), &base_dir);
startup::validate_project_name(&project)?;
if let Commands::Generate {
kind: GenerateCommands::Quadlet { output },
} = &cli.command
{
let mut filtered = file.clone();
podup::retain_active_profiles(&mut filtered, &cli.profile);
return write_quadlet(&filtered, &project, output.as_deref());
}
let client = podup::podman::connect(resolve_socket(cli.socket.as_deref()).as_deref())?;
let stop_timeout = match &cli.command {
Commands::Up { timeout, .. }
| Commands::Down { timeout, .. }
| Commands::Stop { timeout, .. }
| Commands::Restart { timeout, .. } => *timeout,
_ => None,
};
let stop_timeout = podup::validate_stop_timeout(stop_timeout)?;
let scale_overrides: std::collections::HashMap<String, u32> = match &cli.command {
Commands::Up { scale, .. } => scale.iter().cloned().collect(),
Commands::Scale { pairs } => pairs.iter().cloned().collect(),
_ => std::collections::HashMap::new(),
};
let (pull_override, no_build, quiet_pull) = match &cli.command {
Commands::Up {
pull,
no_build,
quiet_pull,
..
} => (pull.clone(), *no_build, *quiet_pull),
Commands::Pull { quiet, policy, .. } => (policy.clone(), false, *quiet),
Commands::Create { pull, .. } => (pull.clone(), false, false),
_ => (None, false, false),
};
let renew_anon_volumes = matches!(
&cli.command,
Commands::Up {
renew_anon_volumes: true,
..
}
);
let engine = podup::Engine::with_base_dir(client, project, base_dir)
.with_stop_timeout(stop_timeout)
.with_scale_overrides(scale_overrides)
.with_up_overrides(pull_override, no_build, quiet_pull)
.with_run_overrides(startup::run_overrides_for(&cli.command))
.with_run_env_files(cli.env_file.clone())
.with_run_labels(startup::run_labels_for(&cli.command))
.with_renew_anon_volumes(renew_anon_volumes);
let _lock = if is_mutating(&cli.command) {
Some(engine.lock_project()?)
} else {
None
};
dispatch::dispatch(&engine, &file, cli.command, &cli.profile).await
}
#[cfg(test)]
mod down_by_label_tests {
use super::down_by_label_path;
use crate::cli::Commands;
fn down() -> Commands {
Commands::Down {
volumes: false,
remove_orphans: false,
rmi: None,
timeout: None,
}
}
#[test]
fn down_with_project_and_no_file_takes_label_path() {
assert!(down_by_label_path(&down(), Some("proj"), false));
}
#[test]
fn down_without_project_or_with_file_does_not() {
assert!(!down_by_label_path(&down(), None, false));
assert!(!down_by_label_path(&down(), Some("proj"), true));
}
#[test]
fn other_commands_never_take_the_down_label_path() {
assert!(!down_by_label_path(&Commands::Watch, Some("proj"), false));
}
}
#[cfg(test)]
mod exit_code_tests {
use super::command_failure_exit_code;
#[test]
fn not_found_maps_to_127() {
assert_eq!(
command_failure_exit_code(
"podman error: crun: executable file `foo` not found in $PATH: \
No such file or directory: OCI runtime command not found error"
),
127
);
assert_eq!(
command_failure_exit_code("OCI runtime error: ...: no such file or directory"),
127
);
}
#[test]
fn not_executable_maps_to_126() {
assert_eq!(
command_failure_exit_code("OCI runtime error: permission denied"),
126
);
assert_eq!(command_failure_exit_code("exec format error"), 126);
}
#[test]
fn unrelated_errors_map_to_1() {
assert_eq!(command_failure_exit_code("some other failure"), 1);
assert_eq!(command_failure_exit_code("container is restarting"), 1);
}
}
#[cfg(feature = "update")]
const UPDATE_IRRELEVANT_GLOBALS: &[(&str, &str)] = &[
("socket", "--socket"),
("profile", "--profile"),
("project_directory", "--project-directory"),
("env_file", "--env-file"),
];
#[cfg(feature = "update")]
fn misused_update_global() -> Option<&'static str> {
use clap::CommandFactory;
let matches = Cli::command().try_get_matches().ok()?;
first_misused_global(&matches)
}
#[cfg(feature = "update")]
fn first_misused_global(matches: &clap::ArgMatches) -> Option<&'static str> {
use clap::parser::ValueSource;
let update = matches.subcommand_matches("update");
UPDATE_IRRELEVANT_GLOBALS.iter().find_map(|(id, flag)| {
let from_cli = |m: &clap::ArgMatches| m.value_source(id) == Some(ValueSource::CommandLine);
(from_cli(matches) || update.is_some_and(from_cli)).then_some(*flag)
})
}
#[cfg(all(test, feature = "update"))]
mod tests {
use super::*;
use clap::CommandFactory;
fn matches_for(args: &[&str]) -> clap::ArgMatches {
Cli::command()
.try_get_matches_from(args)
.expect("args parse")
}
#[test]
fn update_flags_compose_globals_before_subcommand_are_rejected() {
let m = matches_for(&[
"podup",
"--socket",
"unix:///tmp/x.sock",
"update",
"--check",
]);
assert_eq!(first_misused_global(&m), Some("--socket"));
}
#[test]
fn update_flags_compose_globals_after_subcommand_are_rejected() {
let m = matches_for(&["podup", "update", "--project-directory", "/tmp"]);
assert_eq!(first_misused_global(&m), Some("--project-directory"));
}
#[test]
fn update_without_compose_globals_is_accepted() {
let m = matches_for(&["podup", "update", "--check", "--force"]);
assert_eq!(first_misused_global(&m), None);
}
}