diff --git a/README.md b/README.md index 51d31af..826fc5a 100644 --- a/README.md +++ b/README.md @@ -10,10 +10,14 @@ You will need rust installed. Clone repo and run: cargo install --path . ``` -To print the underlying docker commands as they run, build with debug -assertions: +## seeing what it runs + +`-v` prints each docker command as it runs, and `--dry-run` prints the ones it +would run without running them. Both work before or after the subcommand. + ```bash -cargo install --path . --debug +ahab -v django test +ahab --dry-run postgres import ./dump ``` ## shell completion diff --git a/src/cli/ahab.rs b/src/cli/ahab.rs index 9596252..095aa17 100644 --- a/src/cli/ahab.rs +++ b/src/cli/ahab.rs @@ -2,14 +2,21 @@ use super::{Django, Link, Postgres}; use clap::{Parser, Subcommand}; use clap_complete::Shell; -/// A program for interacting with various dockerized applications. +/// A program for interacting with various dockerized applications #[derive(Parser, Debug)] #[command(author, version, about, long_about=None)] pub struct Ahab { #[command(subcommand)] pub command: Commands, -} + /// Print each docker command as it runs + #[arg(short, long, global = true)] + pub verbose: bool, + + /// Print the docker commands that would run, without running them + #[arg(long, global = true)] + pub dry_run: bool, +} #[derive(Debug, Subcommand)] pub enum Commands { /// Django related subcommands diff --git a/src/cli/django.rs b/src/cli/django.rs index 347eb45..d9faa96 100644 --- a/src/cli/django.rs +++ b/src/cli/django.rs @@ -9,19 +9,19 @@ pub enum Django { /// Start a bash session in a fresh django container Bash, - /// Prepare empty management command 'command' in app 'app'. + /// Prepare empty management command 'command' in app 'app' MakeCommand { app: PathBuf, name: String }, - /// Run Django's manage.py makemigrations. + /// Run Django's manage.py makemigrations Makemigrations, - /// Pass arguments to Django's manage.py. + /// Pass arguments to Django's manage.py Manage { #[arg(trailing_var_arg = true, allow_hyphen_values = true)] rest: Vec, }, - /// Run Django's manage.py migrate. + /// Run Django's manage.py migrate Migrate { #[arg(trailing_var_arg = true, allow_hyphen_values = true)] rest: Vec, @@ -33,9 +33,9 @@ pub enum Django { rest: Vec, }, - /// Run Django's manage.py shell. + /// Run Django's manage.py shell Shell, - /// Run Django's manage.py test. + /// Run Django's manage.py test Test, } diff --git a/src/cli/link.rs b/src/cli/link.rs index d777a08..58d29de 100644 --- a/src/cli/link.rs +++ b/src/cli/link.rs @@ -24,6 +24,10 @@ pub enum Link { #[arg(long)] porcelain: bool, + /// Exit with 1 when anything is outside the store, for scripts + #[arg(long)] + exit_code: bool, + /// Terminate porcelain entries with NUL #[arg(short = 'z')] null: bool, diff --git a/src/command_builder.rs b/src/command_builder.rs index b7a9d78..3eaf0c9 100644 --- a/src/command_builder.rs +++ b/src/command_builder.rs @@ -1,11 +1,10 @@ -use anyhow::{Context, Result}; +use anyhow::{Context, Result, bail}; use std::{ fmt::Display, - process::{Child, Command, Stdio}, + process::{Child, Command, ExitStatus, Stdio}, + sync::OnceLock, }; -use crate::debug_eprintln; - pub struct Args(Vec); impl From<&str> for Args { @@ -14,12 +13,6 @@ impl From<&str> for Args { } } -impl From<&String> for Args { - fn from(value: &String) -> Self { - Self(Vec::from_iter(value.split_whitespace().map(String::from))) - } -} - impl From<&[String]> for Args { fn from(value: &[String]) -> Self { Self(value.to_vec()) @@ -46,8 +39,8 @@ impl CommandBuilder { Self::default().args("docker compose") } - pub fn arg(mut self, arg: impl Into) -> Self { - self.args.push(arg.into()); + pub fn arg(mut self, arg: impl AsRef) -> Self { + self.args.push(arg.as_ref().to_string()); self } @@ -60,8 +53,9 @@ impl CommandBuilder { } pub fn build(self) -> Result { - debug_eprintln!("running `{self}`"); - + if options().verbose { + eprintln!("running `{self}`"); + } let (first, rest) = self.args.split_first().context("empty args")?; let mut command = Command::new(first); command.args(rest); @@ -70,12 +64,23 @@ impl CommandBuilder { } pub fn exec_get_stdout(self) -> Result { - Ok(String::from_utf8(self.build()?.output()?.stdout)?) + let shown = self.to_string(); + let out = self.build()?.output()?; + + check(&shown, out.status)?; + Ok(String::from_utf8(out.stdout)?) } pub fn exec(self) -> Result<()> { - self.build()?.spawn()?.wait()?; - Ok(()) + if options().dry_run { + eprintln!("would run `{self}`"); + return Ok(()); + } + + let shown = self.to_string(); + let status = self.build()?.spawn()?.wait()?; + + check(&shown, status) } pub fn spawn(self) -> Result { @@ -83,7 +88,45 @@ impl CommandBuilder { } pub fn exec_redirect_stdout(self, stdio: Stdio) -> Result<()> { - self.build()?.stdout(stdio).spawn()?.wait()?; - Ok(()) + if options().dry_run { + eprintln!("would run `{self}`"); + return Ok(()); + } + + let shown = self.to_string(); + let status = self.build()?.stdout(stdio).spawn()?.wait()?; + + check(&shown, status) } } + +fn check(command: &str, status: ExitStatus) -> Result<()> { + if status.success() { + return Ok(()); + } + + match status.code() { + Some(code) => bail!("`{command}` exited with {code}"), + None => bail!("`{command}` was killed by a signal"), + } +} + +#[derive(Default, Clone, Copy)] +pub struct Options { + pub verbose: bool, + pub dry_run: bool, +} + +static OPTIONS: OnceLock = OnceLock::new(); + +pub fn set_options(options: Options) { + let _ = OPTIONS.set(options); +} + +fn options() -> Options { + OPTIONS.get().copied().unwrap_or_default() +} + +pub fn is_dry_run() -> bool { + options().dry_run +} diff --git a/src/main.rs b/src/main.rs index f70e2e4..3a020eb 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,4 +1,4 @@ -use ahab::{cli, scripts}; +use ahab::{cli, command_builder, scripts}; use anyhow::Result; use clap::Parser; @@ -6,6 +6,11 @@ use clap::Parser; fn main() -> Result<()> { let args = cli::Ahab::parse(); + command_builder::set_options(command_builder::Options { + verbose: args.verbose, + dry_run: args.dry_run, + }); + match args.command { cli::Commands::Django { command } => match command { cli::Django::Bash => scripts::django::bash(), @@ -29,7 +34,8 @@ fn main() -> Result<()> { paths, porcelain, null, - } => scripts::link::check(&paths, porcelain, null), + exit_code, + } => scripts::link::check(&paths, porcelain, null, exit_code), }, cli::Commands::Completions { shell } => scripts::completions::completions(shell), } diff --git a/src/scripts/django.rs b/src/scripts/django.rs index 47f339f..c302043 100644 --- a/src/scripts/django.rs +++ b/src/scripts/django.rs @@ -4,7 +4,6 @@ use std::path::{Path, PathBuf}; use anyhow::{Result, anyhow}; -use crate::command_builder::CommandBuilder; use crate::compose::Compose; use crate::scripts::docker_compose; use crate::{create_file, safe_create_file}; @@ -65,10 +64,10 @@ pub fn run(rest: &[String]) -> Result<()> { } pub fn manage(rest: &[String]) -> Result<()> { - let container = Compose::resolve()?.django()?; - let joined = rest.join(" "); - let command = format!("run --rm {container} python manage.py {joined}"); - CommandBuilder::docker_compose().args(&command).exec() + let mut args = vec!["python".to_string(), "manage.py".to_string()]; + args.extend_from_slice(rest); + + run(&args) } // shortcuts diff --git a/src/scripts/docker.rs b/src/scripts/docker.rs deleted file mode 100644 index 6ff49e8..0000000 --- a/src/scripts/docker.rs +++ /dev/null @@ -1,15 +0,0 @@ -use anyhow::Result; - -use crate::command_builder::CommandBuilder; - -pub fn stop_all() -> Result<()> { - let running_containers = CommandBuilder::docker().args("ps -q").exec_get_stdout()?; - - if running_containers.is_empty() { - return Ok(()); - } - - CommandBuilder::docker() - .args(&format!("stop {running_containers}")) - .exec() -} diff --git a/src/scripts/docker_compose.rs b/src/scripts/docker_compose.rs index d2212a6..763f53b 100644 --- a/src/scripts/docker_compose.rs +++ b/src/scripts/docker_compose.rs @@ -13,7 +13,7 @@ pub fn down() -> Result<()> { pub fn run(service: &str, rest: &[String]) -> Result<()> { CommandBuilder::docker_compose() .args("run --rm") - .args(service) + .arg(service) .args(rest) .exec() } @@ -21,7 +21,7 @@ pub fn run(service: &str, rest: &[String]) -> Result<()> { pub fn exec(service: &str, rest: &[String]) -> Result<()> { CommandBuilder::docker_compose() .args("exec") - .args(service) + .arg(service) .args(rest) .exec() } @@ -30,9 +30,13 @@ pub fn ps() -> Result<()> { CommandBuilder::docker_compose().args("ps").exec() } -pub fn start(containers: Option<&str>) -> Result<()> { - let args = format!("start {}", containers.unwrap_or("")); - CommandBuilder::docker_compose().args(&args).exec() +pub fn start(service: Option<&str>) -> Result<()> { + let mut command = CommandBuilder::docker_compose().args("start"); + if let Some(service) = service { + command = command.arg(service); + } + + command.exec() } pub fn stop() -> Result<()> { diff --git a/src/scripts/link.rs b/src/scripts/link.rs index 92f2d79..83e2619 100644 --- a/src/scripts/link.rs +++ b/src/scripts/link.rs @@ -2,8 +2,10 @@ use std::cell::Cell; use std::env; use std::ffi::OsString; use std::fs; +use std::io::{self, Write}; use std::os::unix::fs::symlink; use std::path::{Path, PathBuf}; +use std::process; use std::process::Command; use anyhow::{Context, Result, anyhow}; @@ -62,7 +64,7 @@ fn warn(msg: impl std::fmt::Display) { } // list untracked paths not in the store, i.e. what a sandbox can still read -pub fn check(paths: &[PathBuf], porcelain: bool, null: bool) -> Result<()> { +pub fn check(paths: &[PathBuf], porcelain: bool, null: bool, exit_code: bool) -> Result<()> { let repo = Repo::discover()?; let pathspecs = relative_pathspecs(&repo, paths)?; @@ -89,6 +91,12 @@ pub fn check(paths: &[PathBuf], porcelain: bool, null: bool) -> Result<()> { print_listing(&repo, &exposed); } + // git's --exit-code convention: nothing to report is 0, anything is 1 + if exit_code && !exposed.is_empty() { + io::stdout().flush().context("writing the listing")?; + process::exit(1); + } + Ok(()) } diff --git a/src/scripts/mod.rs b/src/scripts/mod.rs index 4fbf51b..451a4ad 100644 --- a/src/scripts/mod.rs +++ b/src/scripts/mod.rs @@ -1,6 +1,5 @@ pub mod completions; pub mod django; -pub mod docker; pub mod docker_compose; pub mod link; pub mod postgres; diff --git a/src/scripts/postgres.rs b/src/scripts/postgres.rs index b0fcbcd..4a0dda2 100644 --- a/src/scripts/postgres.rs +++ b/src/scripts/postgres.rs @@ -1,21 +1,25 @@ use anyhow::{Context, Result, anyhow, bail}; use std::{ - fs::File, + fs::{self, File}, io::{self, Read, Write}, path::{Path, PathBuf}, process::Stdio, thread, - time::Duration, + time::{Duration, Instant}, }; use super::docker_compose; use crate::cli::Format; -use crate::{command_builder::CommandBuilder, compose::Compose, debug_eprintln}; +use crate::command_builder; +use crate::{command_builder::CommandBuilder, compose::Compose}; const CUSTOM_MAGIC: &[u8] = b"PGDMP"; const TAR_MAGIC: &[u8] = b"toc.dat"; const GZIP_MAGIC: &[u8] = b"\x1f\x8b"; const CLUSTER_MARKER: &str = "PostgreSQL database cluster dump"; +const READY_TIMEOUT: Duration = Duration::from_secs(60); +const POLL_INTERVAL: Duration = Duration::from_secs(1); + // wide enough for the cluster marker, which sits a few bytes into the file const HEADER_LEN: usize = 512; @@ -40,7 +44,7 @@ impl Database { let container = CommandBuilder::docker_compose() .args("ps -q") - .args(&service) + .arg(&service) .exec_get_stdout()? .trim() .to_string(); @@ -157,6 +161,13 @@ fn restore_cluster(db: &Database, script: &str, file: &Path) -> Result<()> { Ok(()) } +fn suffixed(path: &Path, suffix: &str) -> PathBuf { + let mut out = path.as_os_str().to_owned(); + out.push(suffix); + + PathBuf::from(out) +} + fn read_header(path: &Path) -> Result> { let mut file = File::open(path).with_context(|| format!("opening {}", path.display()))?; let mut header = vec![0; HEADER_LEN]; @@ -173,7 +184,7 @@ fn piped(db: &Database, script: &str, input: &Path) -> Result Result Result<()> { - debug_eprintln!("waiting until pg_isready"); - while !CommandBuilder::docker() - .args(&format!( - "exec {} pg_isready -U {} -d {}", - db.container, db.user, db.name - )) - .build()? - .stdout(Stdio::null()) - .spawn()? - .wait()? - .success() - { - thread::sleep(Duration::from_secs(1)); + if command_builder::is_dry_run() { + return Ok(()); } - Ok(()) + let deadline = Instant::now() + READY_TIMEOUT; + + loop { + let ready = CommandBuilder::docker() + .args("exec") + .arg(&db.container) + .args("pg_isready -U") + .arg(&db.user) + .args("-d") + .arg(&db.name) + .build()? + .stdout(Stdio::null()) + .spawn()? + .wait()? + .success(); + + if ready { + return Ok(()); + } + + if Instant::now() >= deadline { + bail!( + "{} did not accept connections within {} seconds", + db.service, + READY_TIMEOUT.as_secs() + ); + } + + thread::sleep(POLL_INTERVAL); + } } -fn run_when_ready(db: &Database, command: &str) -> Result<()> { +fn when_ready(db: &Database, command: CommandBuilder) -> Result<()> { wait_until_ready(db)?; - CommandBuilder::docker().args(command).exec() + command.exec() +} + +fn in_container(db: &Database) -> CommandBuilder { + CommandBuilder::docker().args("exec").arg(&db.container) } pub fn import(file: &Path) -> Result<()> { @@ -220,9 +253,12 @@ pub fn import(file: &Path) -> Result<()> { // a directory cannot be streamed, so it is the one shape that gets copied in if matches!(dump, Dump::Directory) { - run_when_ready( + when_ready( &db, - &format!("cp -L {} {}:{remote}", file.display(), db.container), + CommandBuilder::docker() + .args("cp -L") + .arg(file.to_string_lossy()) + .arg(format!("{}:{remote}", db.container)), )?; } @@ -264,51 +300,58 @@ pub fn import(file: &Path) -> Result<()> { let tool = kind.tool(); eprintln!("restoring database with {tool}"); - run_when_ready( + + when_ready( &db, - &format!("exec {} dropdb -U {} {}", db.container, db.user, db.name), + in_container(&db) + .args("dropdb -U") + .arg(&db.user) + .arg(&db.name), )?; // a cluster dump creates the database itself, and would trip over one that // is already there if kind != Kind::Cluster { - run_when_ready( + when_ready( &db, - &format!( - "exec {} createdb -U {} -E utf8 -T template0 {}", - db.container, db.user, db.name - ), + in_container(&db) + .args("createdb -U") + .arg(&db.user) + .args("-E utf8 -T template0") + .arg(&db.name), )?; } wait_until_ready(&db)?; - match (kind, restore.as_deref()) { - // psql's output is read rather than streamed here, to keep the expected - // role errors out of the way - (Kind::Cluster, Some(script)) => restore_cluster(&db, script, file)?, - (_, restore) => { - let status = match restore { - Some(script) => piped(&db, script, file)?.spawn()?.wait()?, - None => CommandBuilder::docker() - .args(&format!( - "exec {} pg_restore -U {} --dbname={} {remote}", - db.container, db.user, db.name - )) - .build()? - .spawn()? - .wait()?, - }; + if command_builder::is_dry_run() { + eprintln!("would restore with {tool}"); + } else { + match (kind, restore.as_deref()) { + // psql's output is read rather than streamed here, to keep the expected + // role errors out of the way + (Kind::Cluster, Some(script)) => restore_cluster(&db, script, file)?, + (_, restore) => { + let status = match restore { + Some(script) => piped(&db, script, file)?.spawn()?.wait()?, + None => in_container(&db) + .args("pg_restore -U") + .arg(&db.user) + .arg(format!("--dbname={}", db.name)) + .arg(&remote) + .build()? + .spawn()? + .wait()?, + }; - if !status.success() { - bail!("{tool} failed, the database is left empty"); + if !status.success() { + bail!("{tool} failed, the database is left empty"); + } } } } if matches!(dump, Dump::Directory) { - let _ = CommandBuilder::docker() - .args(&format!("exec {} rm -rf {remote}", db.container)) - .exec(); + let _ = in_container(&db).args("rm -rf").arg(&remote).exec(); } eprintln!("restarting containers"); @@ -331,22 +374,42 @@ pub fn dump(file: &PathBuf, format: Format, gzip: bool) -> Result<()> { eprintln!("dumping to local file {}", file.to_string_lossy()); - let stdout = Stdio::from(File::create(file)?); - let dumping = dump_command(&db, format); + // written beside the target and renamed once the dump succeeds, so a failure + // cannot destroy the dump that is already there + let partial = suffixed(file, ".partial"); + let stdout = Stdio::from( + File::create(&partial).with_context(|| format!("creating {}", partial.display()))?, + ); - if gzip { + let dumped = if gzip { + // the whole pipeline has to arrive as one shell argument CommandBuilder::docker() .args("exec") - .args(&db.container) + .arg(&db.container) .args("sh -c") - .arg(format!("{dumping} | gzip")) - .exec_redirect_stdout(stdout)?; + .arg(format!("{} | gzip", dump_command(&db, format))) + .exec_redirect_stdout(stdout) } else { - CommandBuilder::docker() - .args(&format!("exec {} {dumping}", db.container)) - .exec_redirect_stdout(stdout)?; + let dumping = match format.flag() { + Some(flag) => in_container(&db) + .args("pg_dump -U") + .arg(&db.user) + .arg(format!("--format={flag}")) + .arg(&db.name), + None => in_container(&db).args("pg_dumpall -U").arg(&db.user), + }; + + dumping.exec_redirect_stdout(stdout) + }; + + if let Err(e) = dumped { + let _ = fs::remove_file(&partial); + return Err(e); } + fs::rename(&partial, file) + .with_context(|| format!("renaming {} to {}", partial.display(), file.display()))?; + Ok(()) } @@ -371,24 +434,21 @@ fn dump_directory(db: &Database, target: &Path) -> Result<()> { eprintln!("dumping to local directory {}", target.display()); let remote = remote_dump(); - CommandBuilder::docker() - .args(&format!( - "exec {} pg_dump -U {} --format=d -f {remote} {}", - db.container, db.user, db.name - )) + in_container(db) + .args("pg_dump -U") + .arg(&db.user) + .args("--format=d -f") + .arg(&remote) + .arg(&db.name) .exec()?; let copied = CommandBuilder::docker() - .args(&format!( - "cp {}:{remote} {}", - db.container, - target.display() - )) + .args("cp") + .arg(format!("{}:{remote}", db.container)) + .arg(target.to_string_lossy()) .exec(); - let _ = CommandBuilder::docker() - .args(&format!("exec {} rm -rf {remote}", db.container)) - .exec(); + let _ = in_container(db).args("rm -rf").arg(&remote).exec(); copied }