use std::path::{Path, PathBuf};
use std::process;
use clap::{Parser, Subcommand};
use tracing_subscriber::EnvFilter;
#[derive(Parser)]
#[command(
name = "podup",
version,
about = "docker-compose translator for Podman"
)]
struct Cli {
#[arg(short, long, env = "COMPOSE_FILE")]
file: Option<PathBuf>,
#[arg(short, long, env = "COMPOSE_PROJECT_NAME", default_value = "podup")]
project: String,
#[arg(long, env = "PODMAN_SOCKET")]
socket: Option<String>,
#[arg(long, value_delimiter = ',', global = true)]
profile: Vec<String>,
#[arg(long, global = true)]
project_directory: Option<PathBuf>,
#[command(subcommand)]
command: Commands,
}
#[derive(Subcommand)]
enum Commands {
Up {
#[arg(short, long)]
detach: bool,
#[arg(long)]
build: bool,
#[arg(short, long)]
watch: bool,
#[arg(long)]
remove_orphans: bool,
#[arg(long)]
no_recreate: bool,
#[arg(long)]
force_recreate: bool,
#[arg(trailing_var_arg = true)]
services: Vec<String>,
},
Down {
#[arg(short = 'v', long)]
volumes: bool,
},
Start {
#[arg(trailing_var_arg = true)]
services: Vec<String>,
},
Stop {
#[arg(trailing_var_arg = true)]
services: Vec<String>,
},
Build {
#[arg(trailing_var_arg = true)]
services: Vec<String>,
},
Rm {
#[arg(short, long)]
force: bool,
#[arg(trailing_var_arg = true)]
services: Vec<String>,
},
Kill {
#[arg(short, long, default_value = "SIGKILL")]
signal: String,
#[arg(trailing_var_arg = true)]
services: Vec<String>,
},
Pause {
#[arg(trailing_var_arg = true)]
services: Vec<String>,
},
Unpause {
#[arg(trailing_var_arg = true)]
services: Vec<String>,
},
Run {
service: String,
#[arg(long, default_value_t = true)]
rm: bool,
#[arg(short, long)]
detach: bool,
#[arg(short, long = "env")]
env_overrides: Vec<String>,
#[arg(long)]
name: Option<String>,
#[arg(trailing_var_arg = true, allow_hyphen_values = true)]
cmd: Vec<String>,
},
Cp {
src: String,
dst: String,
},
Ps,
Top {
#[arg(trailing_var_arg = true)]
services: Vec<String>,
},
Port {
service: String,
private_port: u16,
#[arg(long, default_value = "tcp")]
proto: String,
},
Images,
Logs {
service: Option<String>,
#[arg(short, long)]
follow: bool,
},
Exec {
service: String,
#[arg(trailing_var_arg = true, allow_hyphen_values = true)]
cmd: Vec<String>,
},
Pull,
Restart {
service: Option<String>,
},
Config,
Generate {
#[command(subcommand)]
kind: GenerateCommands,
},
Watch,
Update {
#[arg(long)]
check: bool,
#[arg(long)]
force: bool,
},
}
#[derive(Subcommand)]
enum GenerateCommands {
Quadlet {
#[arg(short, long)]
output: Option<PathBuf>,
},
}
fn is_mutating(command: &Commands) -> bool {
matches!(
command,
Commands::Up { .. }
| Commands::Down { .. }
| Commands::Start { .. }
| Commands::Stop { .. }
| Commands::Build { .. }
| Commands::Rm { .. }
| Commands::Kill { .. }
| Commands::Pause { .. }
| Commands::Unpause { .. }
| Commands::Run { .. }
| Commands::Restart { .. }
)
}
fn write_quadlet(
file: &podup::compose::types::ComposeFile,
project: &str,
output: Option<&Path>,
) -> podup::Result<()> {
let result = podup::quadlet::generate(file, project);
for warning in &result.warnings {
eprintln!("warning: {warning}");
}
match output {
Some(dir) => {
std::fs::create_dir_all(dir)?;
for unit in &result.units {
let path = dir.join(&unit.filename);
std::fs::write(&path, &unit.contents)?;
println!("wrote {}", path.display());
}
}
None => {
for unit in &result.units {
println!("# {}", unit.filename);
print!("{}", unit.contents);
println!();
}
}
}
Ok(())
}
const COMPOSE_FILE_CANDIDATES: [&str; 4] = [
"compose.yaml",
"compose.yml",
"docker-compose.yaml",
"docker-compose.yml",
];
fn resolve_compose_file(explicit: Option<PathBuf>) -> PathBuf {
if let Some(path) = explicit {
return path;
}
for candidate in COMPOSE_FILE_CANDIDATES {
if Path::new(candidate).is_file() {
return PathBuf::from(candidate);
}
}
PathBuf::from("docker-compose.yml")
}
fn resolve_base_dir(project_directory: Option<&Path>, file: &Path) -> PathBuf {
project_directory
.map(Path::to_path_buf)
.unwrap_or_else(|| file.parent().map(Path::to_path_buf).unwrap_or_default())
}
#[tokio::main]
async fn main() {
match run().await {
Ok(()) => {}
Err(podup::ComposeError::RunExited(code)) => process::exit(code as i32),
Err(e @ podup::ComposeError::Update(_)) => {
eprintln!("error: {e}");
process::exit(podup::update::exit_code(&e));
}
Err(e) => {
eprintln!("error: {e}");
process::exit(1);
}
}
}
async fn run() -> podup::Result<()> {
tracing_subscriber::fmt()
.with_env_filter(EnvFilter::from_default_env())
.init();
let cli = Cli::parse();
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}")))?;
}
let compose_path = resolve_compose_file(cli.file.clone());
let file = podup::parse_file(&compose_path)?;
if matches!(cli.command, Commands::Config) {
let yaml = serde_yaml::to_string(&file).map_err(podup::ComposeError::Parse)?;
println!("{yaml}");
return Ok(());
}
if let Commands::Generate {
kind: GenerateCommands::Quadlet { output },
} = &cli.command
{
return write_quadlet(&file, &cli.project, output.as_deref());
}
let client = podup::podman::connect(cli.socket.as_deref())?;
let base_dir = resolve_base_dir(cli.project_directory.as_deref(), &compose_path);
let engine = podup::Engine::with_base_dir(client, cli.project, base_dir);
let _lock = if is_mutating(&cli.command) {
Some(engine.lock_project()?)
} else {
None
};
match cli.command {
Commands::Up {
detach,
build,
watch,
remove_orphans,
no_recreate,
force_recreate,
services,
} => {
if remove_orphans {
engine.remove_orphans(&file).await?;
}
if build {
engine.build_all(&file, &services).await?;
}
engine
.up_with_options(
&file,
detach,
&cli.profile,
&services,
no_recreate,
force_recreate,
)
.await?;
if watch {
engine.watch(&file).await?;
} else if !detach {
engine.attach_logs(&file).await?;
let _ = engine.stop(&file, &[]).await;
}
}
Commands::Down { volumes } => engine.down_with_options(&file, volumes).await?,
Commands::Start { services } => engine.start(&file, &services).await?,
Commands::Stop { services } => engine.stop(&file, &services).await?,
Commands::Build { services } => engine.build_all(&file, &services).await?,
Commands::Rm { force, services } => engine.rm(&file, &services, force).await?,
Commands::Kill { signal, services } => engine.kill(&file, &services, &signal).await?,
Commands::Pause { services } => engine.pause(&file, &services).await?,
Commands::Unpause { services } => engine.unpause(&file, &services).await?,
Commands::Run {
service,
rm,
detach,
env_overrides,
name,
cmd,
} => {
engine
.run(
&file,
&service,
podup::RunOptions {
cmd,
rm,
detach,
env_overrides,
name_override: name,
},
)
.await?
}
Commands::Cp { src, dst } => engine.cp(&file, &src, &dst).await?,
Commands::Ps => engine.ps(&file).await?,
Commands::Top { services } => engine.top(&file, &services).await?,
Commands::Port {
service,
private_port,
proto,
} => engine.port(&file, &service, private_port, &proto).await?,
Commands::Images => engine.images(&file).await?,
Commands::Logs { service, follow } => {
engine.logs(&file, service.as_deref(), follow).await?
}
Commands::Exec { service, cmd } => engine.exec(&file, &service, cmd).await?,
Commands::Pull => engine.pull(&file).await?,
Commands::Restart { service } => engine.restart(&file, service.as_deref()).await?,
Commands::Config => unreachable!("handled above"),
Commands::Generate { .. } => unreachable!("handled above"),
Commands::Watch => engine.watch(&file).await?,
Commands::Update { .. } => unreachable!("handled before compose parsing"),
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::{resolve_base_dir, resolve_compose_file};
use std::path::{Path, PathBuf};
#[test]
fn explicit_compose_file_wins() {
let p = resolve_compose_file(Some(PathBuf::from("custom.yml")));
assert_eq!(p, PathBuf::from("custom.yml"));
}
#[test]
fn missing_compose_file_falls_back_to_default_name() {
let dir = std::env::temp_dir().join(format!("podup-cf-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let prev = std::env::current_dir().unwrap();
std::env::set_current_dir(&dir).unwrap();
let p = resolve_compose_file(None);
std::env::set_current_dir(prev).unwrap();
let _ = std::fs::remove_dir_all(&dir);
assert_eq!(p, PathBuf::from("docker-compose.yml"));
}
#[test]
fn project_directory_override_wins() {
let base = resolve_base_dir(
Some(Path::new("/srv/app")),
Path::new("/etc/compose/docker-compose.yml"),
);
assert_eq!(base, PathBuf::from("/srv/app"));
}
#[test]
fn defaults_to_compose_file_parent() {
let base = resolve_base_dir(None, Path::new("/etc/compose/docker-compose.yml"));
assert_eq!(base, PathBuf::from("/etc/compose"));
}
#[test]
fn bare_filename_resolves_to_current_dir() {
let base = resolve_base_dir(None, Path::new("docker-compose.yml"));
assert_eq!(base, PathBuf::from(""));
}
}