diff --git a/src/command_builder.rs b/src/command_builder.rs index 245a6c2..3f63082 100644 --- a/src/command_builder.rs +++ b/src/command_builder.rs @@ -3,9 +3,10 @@ use std::{ fmt::Display, os::unix::process::CommandExt, process::{Command, ExitStatus, Stdio}, - sync::OnceLock, }; +use crate::ctx::Ctx; + pub struct Args(Vec); impl From<&str> for Args { @@ -53,8 +54,8 @@ impl CommandBuilder { self } - pub fn build(self) -> Result { - if options().verbose { + pub fn build(self, ctx: &Ctx) -> Result { + if ctx.verbose { eprintln!("running `{self}`"); } let (first, rest) = self.args.split_first().context("empty args")?; @@ -64,47 +65,47 @@ impl CommandBuilder { Ok(command) } - pub fn exec_get_stdout(self) -> Result { + pub fn exec_get_stdout(self, ctx: &Ctx) -> Result { let shown = self.to_string(); - let out = self.build()?.output()?; + let out = self.build(ctx)?.output()?; check(&shown, out.status)?; Ok(String::from_utf8(out.stdout)?) } - pub fn exec(self) -> Result<()> { - if options().dry_run { + pub fn exec(self, ctx: &Ctx) -> Result<()> { + if ctx.dry_run { eprintln!("would run `{self}`"); return Ok(()); } let shown = self.to_string(); - let status = self.build()?.spawn()?.wait()?; + let status = self.build(ctx)?.spawn()?.wait()?; check(&shown, status) } // replaces this process, so the command's exit code and signals become ours - pub fn exec_replace(self) -> Result<()> { - if options().dry_run { + pub fn exec_replace(self, ctx: &Ctx) -> Result<()> { + if ctx.dry_run { eprintln!("would run `{self}`"); return Ok(()); } let shown = self.to_string(); - let error = self.build()?.exec(); + let error = self.build(ctx)?.exec(); Err(error).with_context(|| format!("running `{shown}`")) } - pub fn exec_redirect_stdout(self, stdio: Stdio) -> Result<()> { - if options().dry_run { + pub fn exec_redirect_stdout(self, ctx: &Ctx, stdio: Stdio) -> Result<()> { + if ctx.dry_run { eprintln!("would run `{self}`"); return Ok(()); } let shown = self.to_string(); - let status = self.build()?.stdout(stdio).spawn()?.wait()?; + let status = self.build(ctx)?.stdout(stdio).spawn()?.wait()?; check(&shown, status) } @@ -120,23 +121,3 @@ fn check(command: &str, status: ExitStatus) -> Result<()> { 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/compose.rs b/src/compose.rs index d470913..c604f81 100644 --- a/src/compose.rs +++ b/src/compose.rs @@ -2,6 +2,7 @@ use anyhow::{Context, Result, anyhow, bail}; use serde_json::Value; use crate::command_builder::CommandBuilder; +use crate::ctx::Ctx; const POSTGRES_IMAGES: [&str; 4] = ["postg", "timescale", "pgvector", "citus"]; const DJANGO_SETTINGS_MODULE: &str = "DJANGO_SETTINGS_MODULE"; @@ -11,10 +12,10 @@ pub struct Compose { } impl Compose { - pub fn resolve() -> Result { + pub fn resolve(ctx: &Ctx) -> Result { let out = CommandBuilder::docker_compose() .args("config --format json") - .build()? + .build(ctx)? .output() .context("running docker compose config")?; diff --git a/src/ctx.rs b/src/ctx.rs new file mode 100644 index 0000000..38e7303 --- /dev/null +++ b/src/ctx.rs @@ -0,0 +1,5 @@ +// how ahab was invoked, handed to everything that runs commands or writes files +pub struct Ctx { + pub verbose: bool, + pub dry_run: bool, +} diff --git a/src/main.rs b/src/main.rs index 26344b0..7546671 100644 --- a/src/main.rs +++ b/src/main.rs @@ -3,24 +3,27 @@ use std::process::ExitCode; mod cli; mod command_builder; mod compose; +mod ctx; mod scripts; use anyhow::Result; use clap::Parser; +use crate::ctx::Ctx; + fn main() -> ExitCode { let args = cli::Ahab::parse(); - command_builder::set_options(command_builder::Options { + let ctx = Ctx { verbose: args.verbose, dry_run: args.dry_run, - }); + }; - if args.dry_run { + if ctx.dry_run { cli::reject_unsupported_dry_run(&args.command); } - match run(args.command) { + match run(&ctx, args.command) { Ok(code) => code, Err(e) => { eprintln!("Error: {e:#}"); @@ -29,31 +32,31 @@ fn main() -> ExitCode { } } -fn run(command: cli::Commands) -> Result { +fn run(ctx: &Ctx, command: cli::Commands) -> Result { let done = ExitCode::SUCCESS; match command { cli::Commands::Django { command } => { match command { - cli::Django::Bash => scripts::django::bash(), - cli::Django::Run { rest } => scripts::django::run(&rest), + cli::Django::Bash => scripts::django::bash(ctx), + cli::Django::Run { rest } => scripts::django::run(ctx, &rest), cli::Django::MakeCommand { app, name } => { scripts::django::make_command(&app, &name) } - cli::Django::Makemigrations => scripts::django::makemigrations(), - cli::Django::Manage { rest } => scripts::django::manage(&rest), - cli::Django::Migrate { rest } => scripts::django::migrate(&rest), - cli::Django::Shell => scripts::django::shell(), - cli::Django::Test => scripts::django::test(), + cli::Django::Makemigrations => scripts::django::makemigrations(ctx), + cli::Django::Manage { rest } => scripts::django::manage(ctx, &rest), + cli::Django::Migrate { rest } => scripts::django::migrate(ctx, &rest), + cli::Django::Shell => scripts::django::shell(ctx), + cli::Django::Test => scripts::django::test(ctx), }?; Ok(done) } cli::Commands::Postgres { command } => { match command { - cli::Postgres::Import { path } => scripts::postgres::import(&path), + cli::Postgres::Import { path } => scripts::postgres::import(ctx, &path), cli::Postgres::Dump { path, format, gzip } => { - scripts::postgres::dump(&path, format, gzip) + scripts::postgres::dump(ctx, &path, format, gzip) } }?; diff --git a/src/scripts/django.rs b/src/scripts/django.rs index dc01d71..915c9a5 100644 --- a/src/scripts/django.rs +++ b/src/scripts/django.rs @@ -5,6 +5,7 @@ use std::path::{Path, PathBuf}; use anyhow::{Result, anyhow}; use crate::compose::Compose; +use crate::ctx::Ctx; use crate::scripts::docker_compose; const DEBUG_TEMPLATE: &str = r#"from django.core.management.base import BaseCommand @@ -53,40 +54,40 @@ pub fn make_command(app: &PathBuf, name: &str) -> Result<()> { Ok(()) } -pub fn bash() -> Result<()> { - run(&["bash".to_string()]) +pub fn bash(ctx: &Ctx) -> Result<()> { + run(ctx, &["bash".to_string()]) } -pub fn run(rest: &[String]) -> Result<()> { - let service = Compose::resolve()?.django()?; - docker_compose::run(&service, rest) +pub fn run(ctx: &Ctx, rest: &[String]) -> Result<()> { + let service = Compose::resolve(ctx)?.django()?; + docker_compose::run(ctx, &service, rest) } -pub fn manage(rest: &[String]) -> Result<()> { +pub fn manage(ctx: &Ctx, rest: &[String]) -> Result<()> { let mut args = vec!["python".to_string(), "manage.py".to_string()]; args.extend_from_slice(rest); - run(&args) + run(ctx, &args) } // shortcuts -pub fn makemigrations() -> Result<()> { - manage(&["makemigrations".to_string()]) +pub fn makemigrations(ctx: &Ctx) -> Result<()> { + manage(ctx, &["makemigrations".to_string()]) } -pub fn migrate(rest: &[String]) -> Result<()> { +pub fn migrate(ctx: &Ctx, rest: &[String]) -> Result<()> { let mut full_rest = vec!["migrate".to_string()]; full_rest.extend_from_slice(rest); - manage(&full_rest) + manage(ctx, &full_rest) } -pub fn shell() -> Result<()> { - manage(&["shell".to_string()]) +pub fn shell(ctx: &Ctx) -> Result<()> { + manage(ctx, &["shell".to_string()]) } -pub fn test() -> Result<()> { - manage(&["test".to_string()]) +pub fn test(ctx: &Ctx) -> Result<()> { + manage(ctx, &["test".to_string()]) } fn safe_create_file(path: PathBuf) -> Result { diff --git a/src/scripts/docker_compose.rs b/src/scripts/docker_compose.rs index 73d84f2..b889798 100644 --- a/src/scripts/docker_compose.rs +++ b/src/scripts/docker_compose.rs @@ -1,28 +1,29 @@ use anyhow::Result; use crate::command_builder::CommandBuilder; +use crate::ctx::Ctx; -pub fn run(service: &str, rest: &[String]) -> Result<()> { +pub fn run(ctx: &Ctx, service: &str, rest: &[String]) -> Result<()> { CommandBuilder::docker_compose() .args("run --rm") .arg(service) .args(rest) - .exec_replace() + .exec_replace(ctx) } -pub fn start(service: Option<&str>) -> Result<()> { +pub fn start(ctx: &Ctx, service: Option<&str>) -> Result<()> { let mut command = CommandBuilder::docker_compose().args("start"); if let Some(service) = service { command = command.arg(service); } - command.exec() + command.exec(ctx) } -pub fn stop() -> Result<()> { - CommandBuilder::docker_compose().args("stop").exec() +pub fn stop(ctx: &Ctx) -> Result<()> { + CommandBuilder::docker_compose().args("stop").exec(ctx) } -pub fn up() -> Result<()> { - CommandBuilder::docker_compose().args("up -d").exec() +pub fn up(ctx: &Ctx) -> Result<()> { + CommandBuilder::docker_compose().args("up -d").exec(ctx) } diff --git a/src/scripts/postgres.rs b/src/scripts/postgres.rs index f5b4d76..e73e4e9 100644 --- a/src/scripts/postgres.rs +++ b/src/scripts/postgres.rs @@ -10,7 +10,7 @@ use std::{ use super::docker_compose; use crate::cli::Format; -use crate::command_builder; +use crate::ctx::Ctx; use crate::{command_builder::CommandBuilder, compose::Compose}; const CUSTOM_MAGIC: &[u8] = b"PGDMP"; @@ -37,15 +37,15 @@ struct Database { } impl Database { - fn resolve() -> Result { - let compose = Compose::resolve()?; + fn resolve(ctx: &Ctx) -> Result { + let compose = Compose::resolve(ctx)?; let service = compose.postgres()?; let (user, name) = compose.postgres_credentials(&service); let container = CommandBuilder::docker_compose() .args("ps -q") .arg(&service) - .exec_get_stdout()? + .exec_get_stdout(ctx)? .trim() .to_string(); @@ -132,8 +132,10 @@ fn is_existing_role_error(line: &str) -> bool { line.starts_with("ERROR:") && line.contains("role \"") && line.ends_with("already exists") } -fn restore_cluster(db: &Database, script: &str, file: &Path) -> Result<()> { - let out = piped(db, script, file)?.output().context("running psql")?; +fn restore_cluster(ctx: &Ctx, db: &Database, script: &str, file: &Path) -> Result<()> { + let out = piped(ctx, db, script, file)? + .output() + .context("running psql")?; io::stdout().write_all(&out.stdout).ok(); @@ -187,21 +189,21 @@ fn pipefail(script: &str) -> String { format!("set -o pipefail; {script}") } -fn piped(db: &Database, script: &str, input: &Path) -> Result { +fn piped(ctx: &Ctx, db: &Database, script: &str, input: &Path) -> Result { let file = File::open(input).with_context(|| format!("opening {}", input.display()))?; let mut command = CommandBuilder::docker() .args("exec -i") .arg(&db.container) .args("sh -c") .arg(script) - .build()?; + .build(ctx)?; command.stdin(Stdio::from(file)); Ok(command) } -fn wait_until_ready(db: &Database) -> Result<()> { - if command_builder::is_dry_run() { +fn wait_until_ready(ctx: &Ctx, db: &Database) -> Result<()> { + if ctx.dry_run { return Ok(()); } @@ -215,7 +217,7 @@ fn wait_until_ready(db: &Database) -> Result<()> { .arg(&db.user) .args("-d") .arg(&db.name) - .build()? + .build(ctx)? .stdout(Stdio::null()) .spawn()? .wait()? @@ -237,30 +239,31 @@ fn wait_until_ready(db: &Database) -> Result<()> { } } -fn when_ready(db: &Database, command: CommandBuilder) -> Result<()> { - wait_until_ready(db)?; - command.exec() +fn when_ready(ctx: &Ctx, db: &Database, command: CommandBuilder) -> Result<()> { + wait_until_ready(ctx, db)?; + command.exec(ctx) } fn in_container(db: &Database) -> CommandBuilder { CommandBuilder::docker().args("exec").arg(&db.container) } -pub fn import(file: &Path) -> Result<()> { +pub fn import(ctx: &Ctx, file: &Path) -> Result<()> { let dump = Dump::of(file)?; - let db = Database::resolve()?; + let db = Database::resolve(ctx)?; eprintln!("stopping all containers"); - docker_compose::stop()?; + docker_compose::stop(ctx)?; eprintln!("starting db container"); - docker_compose::start(Some(&db.service))?; + docker_compose::start(ctx, Some(&db.service))?; let remote = remote_dump(); // a directory cannot be streamed, so it is the one shape that gets copied in if matches!(dump, Dump::Directory) { when_ready( + ctx, &db, CommandBuilder::docker() .args("cp -L") @@ -277,8 +280,8 @@ pub fn import(file: &Path) -> Result<()> { (kind, Some(db.restore_with(kind))) } Dump::Gzip => { - wait_until_ready(&db)?; - let out = piped(&db, &format!("gunzip -c | head -c {HEADER_LEN}"), file)? + wait_until_ready(ctx, &db)?; + let out = piped(ctx, &db, &format!("gunzip -c | head -c {HEADER_LEN}"), file)? .output() .context("reading the compressed dump's header")?; @@ -312,6 +315,7 @@ pub fn import(file: &Path) -> Result<()> { eprintln!("restoring database with {tool}"); when_ready( + ctx, &db, in_container(&db) .args("dropdb -U") @@ -323,6 +327,7 @@ pub fn import(file: &Path) -> Result<()> { // is already there if kind != Kind::Cluster { when_ready( + ctx, &db, in_container(&db) .args("createdb -U") @@ -332,23 +337,23 @@ pub fn import(file: &Path) -> Result<()> { )?; } - wait_until_ready(&db)?; - if command_builder::is_dry_run() { + wait_until_ready(ctx, &db)?; + if ctx.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)?, + (Kind::Cluster, Some(script)) => restore_cluster(ctx, &db, script, file)?, (_, restore) => { let status = match restore { - Some(script) => piped(&db, script, file)?.spawn()?.wait()?, + Some(script) => piped(ctx, &db, script, file)?.spawn()?.wait()?, None => in_container(&db) .args("pg_restore -U") .arg(&db.user) .arg(format!("--dbname={}", db.name)) .arg(&remote) - .build()? + .build(ctx)? .spawn()? .wait()?, }; @@ -361,25 +366,25 @@ pub fn import(file: &Path) -> Result<()> { } if matches!(dump, Dump::Directory) { - let _ = in_container(&db).args("rm -rf").arg(&remote).exec(); + let _ = in_container(&db).args("rm -rf").arg(&remote).exec(ctx); } eprintln!("restarting containers"); - docker_compose::stop()?; - docker_compose::up()?; + docker_compose::stop(ctx)?; + docker_compose::up(ctx)?; Ok(()) } -pub fn dump(file: &PathBuf, format: Format, gzip: bool) -> Result<()> { - let db = Database::resolve()?; +pub fn dump(ctx: &Ctx, file: &PathBuf, format: Format, gzip: bool) -> Result<()> { + let db = Database::resolve(ctx)?; if format == Format::Directory { if gzip { bail!("a directory dump is a directory of already compressed files, not a stream"); } - return dump_directory(&db, file); + return dump_directory(ctx, &db, file); } eprintln!("dumping to local file {}", file.to_string_lossy()); @@ -388,7 +393,7 @@ pub fn dump(file: &PathBuf, format: Format, gzip: bool) -> Result<()> { // cannot destroy the dump that is already there let partial = suffixed(file, ".partial"); // a dry run produces no dump, so it must not lay a hand on the target either - let stdout = if command_builder::is_dry_run() { + let stdout = if ctx.dry_run { Stdio::null() } else { Stdio::from( @@ -403,7 +408,7 @@ pub fn dump(file: &PathBuf, format: Format, gzip: bool) -> Result<()> { .arg(&db.container) .args("sh -c") .arg(pipefail(&format!("{} | gzip", dump_command(&db, format)))) - .exec_redirect_stdout(stdout) + .exec_redirect_stdout(ctx, stdout) } else { let dumping = match format.flag() { Some(flag) => in_container(&db) @@ -414,7 +419,7 @@ pub fn dump(file: &PathBuf, format: Format, gzip: bool) -> Result<()> { None => in_container(&db).args("pg_dumpall -U").arg(&db.user), }; - dumping.exec_redirect_stdout(stdout) + dumping.exec_redirect_stdout(ctx, stdout) }; if let Err(e) = dumped { @@ -422,7 +427,7 @@ pub fn dump(file: &PathBuf, format: Format, gzip: bool) -> Result<()> { return Err(e); } - if command_builder::is_dry_run() { + if ctx.dry_run { return Ok(()); } @@ -442,7 +447,7 @@ fn dump_command(db: &Database, format: Format) -> String { // pg_dump writes a directory format dump itself rather than to stdout, so it lands // in the container and comes back with docker cp -fn dump_directory(db: &Database, target: &Path) -> Result<()> { +fn dump_directory(ctx: &Ctx, db: &Database, target: &Path) -> Result<()> { if target.exists() { bail!( "{} already exists; a directory dump will not be written over it", @@ -459,15 +464,15 @@ fn dump_directory(db: &Database, target: &Path) -> Result<()> { .args("--format=d -f") .arg(&remote) .arg(&db.name) - .exec()?; + .exec(ctx)?; let copied = CommandBuilder::docker() .args("cp") .arg(format!("{}:{remote}", db.container)) .arg(target.to_string_lossy()) - .exec(); + .exec(ctx); - let _ = in_container(db).args("rm -rf").arg(&remote).exec(); + let _ = in_container(db).args("rm -rf").arg(&remote).exec(ctx); copied }