podup 1.1.0

Translate and run docker-compose files on rootless Podman
Documentation
//! `podup` — docker-compose to Podman translator CLI.

// The binary carries no `unsafe`; deny it so any future addition is caught.
#![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_mutating, parse_cli};

fn main() {
	// Replace the default panic output (a raw Rust backtrace) with a `podup:`
	// internal-error notice that tells the user what to report and where, plus
	// the reminder to redact secrets first.
	std::panic::set_hook(Box::new(|info| {
		eprintln!("podup: internal error: {info}");
		eprintln!("{}", internal_error_notice());
	}));

	// Drive the runtime on a worker thread with a large stack. Clap's
	// command-building (debug builds especially) is stack-heavy and overflows
	// Windows' 1 MiB main-thread stack as the subcommand surface grows; an 8 MiB
	// matches Linux's default and leaves ample headroom.
	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");
}

/// Build the Tokio runtime and drive [`run`], mapping its result onto the
/// process exit status. Runs on the large-stack worker thread spawned by `main`.
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(_)) => {
			eprintln!("podup: error: {e}");
			process::exit(podup::update::exit_code(&e));
		}
		Err(e) => {
			eprintln!("podup: error: {e}");
			process::exit(1);
		}
	}
}

async fn run() -> podup::Result<()> {
	init_tracing();
	let cli = parse_cli();

	// `completions` derives entirely from the static CLI definition; it neither
	// parses a compose file nor contacts Podman. Print to stdout for piping.
	#[cfg(feature = "completions")]
	if let Commands::Completions { shell } = cli.command {
		let mut cmd = Cli::command();
		let name = cmd.get_name().to_string();
		clap_complete::generate(shell, &mut cmd, name, &mut std::io::stdout());
		return Ok(());
	}

	// `update` operates on the binary itself, not a compose project, so it runs
	// before any compose file is parsed or Podman is contacted. The network and
	// filesystem work is blocking; keep it off the async path entirely.
	#[cfg(feature = "update")]
	if let Commands::Update { check, force } = cli.command {
		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}")))?;
	}

	// `ls` discovers projects across the host by container label; it needs a
	// Podman connection but no compose file, so handle it before parsing one.
	if let Commands::Ls { all, quiet, format } = &cli.command {
		let client = podup::podman::connect(cli.socket.as_deref())?;
		return podup::list_projects(
			&client,
			podup::LsOptions {
				all: *all,
				quiet: *quiet,
				json: *format == OutputFormat::Json,
			},
		)
		.await;
	}

	let compose_files = resolve_compose_files(&cli.file);
	let file = podup::parse_files_with_env_files(&compose_files, &cli.env_file)?;

	if let Commands::Config {
		format,
		services,
		quiet,
		no_interpolate,
		resolve_image_digests,
	} = &cli.command
	{
		// `--no-interpolate` re-parses with substitution disabled; `file` (already
		// parsed with interpolation) is used otherwise.
		let parsed = if *no_interpolate {
			podup::parse_files_with_env_files_interp(&compose_files, &cli.env_file, false)?
		} else {
			file
		};
		// `--resolve-image-digests` pins each image to its registry digest, which
		// needs a Podman connection to inspect images.
		let resolved = if *resolve_image_digests {
			let client = podup::podman::connect(cli.socket.as_deref())?;
			podup::resolve_image_digests(&client, &parsed).await?
		} else {
			parsed
		};
		return startup::render_config(&resolved, format, *services, *quiet);
	}

	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);

	// Validate the resolved project name at the trust boundary, before it reaches
	// any code path that builds a filesystem path from it (staging, lock files,
	// quadlet generation). Explicit `-p`/`COMPOSE_PROJECT_NAME` values and the
	// compose `name:` field are otherwise taken verbatim; rejecting an unsafe
	// name here fails closed regardless of which command runs next.
	if !podup::is_safe_project_name(&project) {
		return Err(podup::ComposeError::Unsupported(format!(
			"project name {project:?} is not a safe path component: use only ASCII \
			 letters, digits, '-', '_', '.', not starting with '.', max 128 chars"
		)));
	}

	// `generate` produces declarative artifacts from the compose file alone; it
	// neither contacts Podman nor mutates project state.
	if let Commands::Generate {
		kind: GenerateCommands::Quadlet { output },
	} = &cli.command
	{
		return write_quadlet(&file, &project, output.as_deref());
	}

	let client = podup::podman::connect(cli.socket.as_deref())?;
	// The `-t/--timeout` shutdown-grace override applies to every command that
	// stops containers (up recreate, down, stop, restart).
	let stop_timeout = match &cli.command {
		Commands::Up { timeout, .. }
		| Commands::Down { timeout, .. }
		| Commands::Stop { timeout, .. }
		| Commands::Restart { timeout, .. } => *timeout,
		_ => None,
	};
	// `--scale SERVICE=N` (on `up`) and the `scale` subcommand both feed the
	// engine's replica overrides so `resolve_replicas` reports the target count.
	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(),
	};
	// `up` image-acquisition overrides: `--pull`, `--no-build`, `--quiet-pull`.
	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),
		_ => (None, false, false),
	};
	// `up -V/--renew-anon-volumes`: recreate anonymous volumes on container
	// recreation instead of leaving the old ones orphaned.
	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_renew_anon_volumes(renew_anon_volumes);

	// Serialize mutating lifecycle commands against concurrent `podup` runs on
	// the same project. Read-only / follow commands (ps, logs, top, port,
	// images, exec, pull, cp, config, watch) take no lock so they don't block
	// or get blocked. The guard is held until `run` returns.
	let _lock = if is_mutating(&cli.command) {
		Some(engine.lock_project()?)
	} else {
		None
	};

	dispatch::dispatch(&engine, &file, cli.command, &cli.profile).await
}