diff --git a/src/cli/ahab.rs b/src/cli/ahab.rs index e1f5feb..3cb10f4 100644 --- a/src/cli/ahab.rs +++ b/src/cli/ahab.rs @@ -1,4 +1,4 @@ -use super::{Django, Link, Postgres}; +use super::{Django, Link, Postgres, Store}; use clap::builder::styling::{AnsiColor, Effects, Styles}; use clap::{Parser, Subcommand}; @@ -16,6 +16,10 @@ pub struct Ahab { #[arg(short, long, global = true)] pub verbose: bool, + /// Print only what was asked for, not the progress along the way + #[arg(short, long, global = true, conflicts_with = "verbose")] + pub quiet: bool, + /// Print the docker commands that would run, without running them #[arg(long, global = true)] pub dry_run: bool, @@ -40,6 +44,12 @@ pub enum Commands { command: Link, }, + /// Show what ahab makes of this project + Status { + #[command(flatten)] + store: Store, + }, + /// Print a shell completion script on stdout Completions { /// Shell to generate the script for diff --git a/src/cli/django.rs b/src/cli/django.rs index d9faa96..0a4c8ad 100644 --- a/src/cli/django.rs +++ b/src/cli/django.rs @@ -35,7 +35,4 @@ pub enum Django { /// Run Django's manage.py shell Shell, - - /// Run Django's manage.py test - Test, } diff --git a/src/cli/link.rs b/src/cli/link.rs index b54be85..aa05edf 100644 --- a/src/cli/link.rs +++ b/src/cli/link.rs @@ -30,6 +30,12 @@ pub enum Link { store: Store, }, + /// List what this repository keeps in the store + List { + #[command(flatten)] + store: Store, + }, + /// List untracked paths a sandbox would still see Check { /// Limit the listing to these paths @@ -39,7 +45,7 @@ pub enum Link { #[arg(long)] porcelain: bool, - /// Exit with 1 when anything is outside the store, for scripts + /// Exit with 4 when anything is outside the store, for scripts #[arg(long)] exit_code: bool, diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 72ef580..663207e 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -7,5 +7,5 @@ mod postgres; pub use ahab::{Ahab, Commands}; pub use django::Django; -pub use link::Link; +pub use link::{Link, Store}; pub use postgres::{Format, Postgres}; diff --git a/src/cli/postgres.rs b/src/cli/postgres.rs index e0a90b3..2f50505 100644 --- a/src/cli/postgres.rs +++ b/src/cli/postgres.rs @@ -7,6 +7,13 @@ pub enum Postgres { /// Import a dump, in any format ahab can produce Import { path: PathBuf }, + /// Open psql in the database container + Psql { + /// Arguments for psql itself + #[arg(trailing_var_arg = true, allow_hyphen_values = true)] + rest: Vec, + }, + /// Dump via pg_dump, or pg_dumpall for a whole cluster Dump { path: PathBuf, diff --git a/src/cmd/compose.rs b/src/cmd/compose.rs index 77f55c5..d06313f 100644 --- a/src/cmd/compose.rs +++ b/src/cmd/compose.rs @@ -1,7 +1,13 @@ use super::{Argv, Cmd}; -fn compose() -> Argv { - Argv::new("docker").arg("compose") +// compose prints its own progress, which --quiet has to ask it to stop +fn compose(quiet: bool) -> Argv { + let argv = Argv::new("docker").arg("compose"); + + match quiet { + true => argv.flag("--progress", "quiet"), + false => argv, + } } // docker compose run --rm, which runs the image's entrypoint and so fixes up the @@ -22,7 +28,7 @@ impl Run { impl Cmd for Run { fn argv(&self) -> Argv { - compose() + compose(false) .arg("run") .arg("--rm") .arg(&self.service) @@ -35,7 +41,7 @@ pub struct Config; impl Cmd for Config { fn argv(&self) -> Argv { - compose().arg("config").flag("--format", "json") + compose(false).arg("config").flag("--format", "json") } } @@ -54,39 +60,37 @@ impl Ps { impl Cmd for Ps { fn argv(&self) -> Argv { - compose().arg("ps").arg("--quiet").arg(&self.service) + compose(false).arg("ps").arg("--quiet").arg(&self.service) } } -pub struct Up; +pub struct Up { + pub quiet: bool, +} impl Cmd for Up { fn argv(&self) -> Argv { - compose().arg("up").arg("--detach") + compose(self.quiet).arg("up").arg("--detach") } } -pub struct Start { - service: String, +pub struct Start<'a> { + pub service: &'a str, + pub quiet: bool, } -impl Start { - pub fn service(service: &str) -> Self { - Self { - service: service.to_string(), - } - } -} - -impl Cmd for Start { +impl Cmd for Start<'_> { fn argv(&self) -> Argv { - compose().arg("start").arg(&self.service) + compose(self.quiet).arg("start").arg(self.service) } } -pub struct Stop; + +pub struct Stop { + pub quiet: bool, +} impl Cmd for Stop { fn argv(&self) -> Argv { - compose().arg("stop") + compose(self.quiet).arg("stop") } } diff --git a/src/cmd/docker.rs b/src/cmd/docker.rs index d1fbc10..b57d9b1 100644 --- a/src/cmd/docker.rs +++ b/src/cmd/docker.rs @@ -4,6 +4,7 @@ use super::{Argv, Cmd}; pub struct Exec { container: String, interactive: bool, + tty: bool, inner: Argv, } @@ -12,10 +13,17 @@ impl Exec { Self { container: container.to_string(), interactive: false, + tty: false, inner, } } + // a terminal, for a command that is a shell rather than a pipe stage + pub fn tty(mut self, tty: bool) -> Self { + self.tty = tty; + self + } + // keep stdin open, for a command that is fed a dump pub fn interactive(mut self) -> Self { self.interactive = true; @@ -31,6 +39,10 @@ impl Cmd for Exec { argv = argv.arg("--interactive"); } + if self.tty { + argv = argv.arg("--tty"); + } + argv.arg(&self.container).args(self.inner.words()) } } diff --git a/src/cmd/postgres.rs b/src/cmd/postgres.rs index a39269f..74b9aea 100644 --- a/src/cmd/postgres.rs +++ b/src/cmd/postgres.rs @@ -21,8 +21,10 @@ pub struct DropDb<'a> { impl Cmd for DropDb<'_> { fn argv(&self) -> Argv { + // a cluster that never held this database is still a restore target Argv::new("dropdb") .flag("--username", self.username) + .arg("--if-exists") .arg(self.dbname) } } @@ -80,11 +82,13 @@ impl Cmd for PgRestore<'_> { } } -// reads a plain sql dump, of one database or of a whole cluster +// reads a plain sql dump, or runs as the interactive shell it is pub struct Psql<'a> { username: &'a str, dbname: &'a str, + quiet: bool, atomic: bool, + rest: &'a [String], } impl<'a> Psql<'a> { @@ -92,33 +96,51 @@ impl<'a> Psql<'a> { Self { username, dbname, + quiet: false, atomic: false, + rest: &[], } } + // say nothing and print no result rows, for a restore whose output is noise + pub fn quiet(mut self) -> Self { + self.quiet = true; + self + } + // stop at the first error and undo the rest, which a cluster dump cannot do: // it connects to each database itself, and trips over roles already there pub fn atomic(mut self) -> Self { self.atomic = true; self } + + // whatever the caller typed, for psql's own flags + pub fn args(mut self, rest: &'a [String]) -> Self { + self.rest = rest; + self + } } impl Cmd for Psql<'_> { fn argv(&self) -> Argv { - let argv = Argv::new("psql") - .arg("--quiet") - .flag("--output", "/dev/null") + let mut argv = Argv::new("psql"); + + if self.quiet { + argv = argv.arg("--quiet").flag("--output", "/dev/null"); + } + + argv = argv .flag("--username", self.username) .flag("--dbname", self.dbname); if self.atomic { - return argv + argv = argv .flag("--variable", "ON_ERROR_STOP=1") .arg("--single-transaction"); } - argv + argv.args(self.rest) } } @@ -174,9 +196,22 @@ impl Cmd for PgDumpAll<'_> { #[cfg(test)] mod tests { - use super::{PgDump, Psql}; + use super::{DropDb, PgDump, Psql}; use crate::cmd::Cmd; + #[test] + fn a_missing_database_is_not_a_failed_import() { + assert_eq!( + DropDb { + username: "u", + dbname: "db", + } + .argv() + .quoted(), + "dropdb --username u --if-exists db" + ); + } + #[test] fn a_directory_dump_names_the_file_it_writes() { assert_eq!( @@ -193,13 +228,14 @@ mod tests { fn only_a_single_database_restore_stops_at_the_first_error() { assert!( Psql::new("u", "db") + .quiet() .atomic() .argv() .quoted() .ends_with("--variable 'ON_ERROR_STOP=1' --single-transaction") ); assert_eq!( - Psql::new("u", "postgres").argv().quoted(), + Psql::new("u", "postgres").quiet().argv().quoted(), "psql --quiet --output /dev/null --username u --dbname postgres" ); } diff --git a/src/commands/django.rs b/src/commands/django.rs index 704e200..0c241e2 100644 --- a/src/commands/django.rs +++ b/src/commands/django.rs @@ -24,7 +24,7 @@ pub fn make_command(ctx: &Ctx, app: &PathBuf, name: &str) -> Result<()> { return Err(anyhow!("directory {app_name} does not exist")); } - note!("found app {app_name}"); + note!(ctx, "found app {app_name}"); let management_dir = app_dir.join("management"); @@ -32,7 +32,7 @@ pub fn make_command(ctx: &Ctx, app: &PathBuf, name: &str) -> Result<()> { create_dir(ctx, &management_dir)?; touch(ctx, &management_dir.join("__init__.py"))?; - note!("created module {app_name}.management") + note!(ctx, "created module {app_name}.management") }; let commands_dir = management_dir.join("commands"); @@ -41,7 +41,7 @@ pub fn make_command(ctx: &Ctx, app: &PathBuf, name: &str) -> Result<()> { create_dir(ctx, &commands_dir)?; touch(ctx, &commands_dir.join("__init__.py"))?; - note!("created module {app_name}.management.commands") + note!(ctx, "created module {app_name}.management.commands") }; write_new( @@ -50,7 +50,7 @@ pub fn make_command(ctx: &Ctx, app: &PathBuf, name: &str) -> Result<()> { DEBUG_TEMPLATE.as_bytes(), )?; - note!("created command {app_name}.management.commands.{name}"); + note!(ctx, "created command {app_name}.management.commands.{name}"); Ok(()) } @@ -82,10 +82,6 @@ pub fn shell(ctx: &Ctx) -> Result<()> { manage(ctx, &["shell".to_string()]) } -pub fn test(ctx: &Ctx) -> Result<()> { - manage(ctx, &["test".to_string()]) -} - fn service(ctx: &Ctx) -> Result { Project::resolve(ctx)?.django() } diff --git a/src/commands/link.rs b/src/commands/link.rs index d2a91df..7b1b7b6 100644 --- a/src/commands/link.rs +++ b/src/commands/link.rs @@ -12,7 +12,7 @@ use anyhow::{Result, anyhow, bail}; use self::store::{Repo, ignored, resolve, symlink_metadata_opt, tracked}; use crate::ctx::Ctx; use crate::fsops::{move_path, place_link, prune_empty, remove_file, rename, suffixed}; -use crate::output::{note, warning}; +use crate::output::{line, note, warning}; const BACKUP_SUFFIX: &str = ".ahab-bak"; @@ -28,7 +28,7 @@ pub fn add(ctx: &Ctx, paths: &[PathBuf], force: bool, store: Option<&Path>) -> R let mut failed = 0; for path in paths { if let Err(e) = link_one(ctx, &repo, path, force, &report) { - note!("error: {e:#}"); + note!(ctx, "error: {e:#}"); failed += 1; } } @@ -44,32 +44,57 @@ pub fn restore(ctx: &Ctx, paths: &[PathBuf], all: bool, store: Option<&Path>) -> let repo = Repo::discover(ctx, store)?; let report = Report::new(&repo); - let paths = match (all, paths) { - (true, []) => linked_paths(&repo, &repo.store)?, + let stored = match (all, paths) { + (true, []) => stored_paths(&repo, &repo.store)?, (true, _) => bail!("--all restores everything, so it takes no paths"), (false, []) => bail!("name a path to restore, or pass --all"), - (false, paths) => paths.to_vec(), + (false, paths) => paths.iter().cloned().map(|p| (p, Stored::Linked)).collect(), }; - if paths.is_empty() { - note!("nothing in the store for this repository"); + // only a linked path can be moved back; the store can hold orphans too + let linked: Vec = stored + .iter() + .filter(|(_, state)| *state == Stored::Linked) + .map(|(path, _)| path.clone()) + .collect(); + let skipped = stored.len() - linked.len(); + + if linked.is_empty() { + match skipped { + 0 => note!(ctx, "nothing in the store for this repository"), + n => note!( + ctx, + "nothing to restore: {n} path{} in the store {} not linked, see `ahab link list`", + if n == 1 { "" } else { "s" }, + if n == 1 { "is" } else { "are" } + ), + } + return Ok(()); } - if let [path] = paths.as_slice() { + if skipped > 0 { + note!( + ctx, + "leaving {skipped} path{} that the store holds but nothing links to", + if skipped == 1 { "" } else { "s" } + ); + } + + if let [path] = linked.as_slice() { return restore_one(ctx, &repo, path, &report); } let mut failed = 0; - for path in &paths { + for path in &linked { if let Err(e) = restore_one(ctx, &repo, path, &report) { - note!("error: {e:#}"); + note!(ctx, "error: {e:#}"); failed += 1; } } if failed > 0 { - return Err(anyhow!("{failed} of {} paths failed", paths.len())); + return Err(anyhow!("{failed} of {} paths failed", linked.len())); } Ok(()) } @@ -109,9 +134,20 @@ fn restore_one(ctx: &Ctx, repo: &Repo, path: &Path, report: &Report) -> Result<( Ok(()) } +// what the repository looks like from the store's side +#[derive(PartialEq)] +enum Stored { + // the symlink is there and points at the store, which is the whole point + Linked, + // nothing is at the path the store holds it for + Missing, + // something else took the path, so the stored copy is the one nobody reads + Taken, +} + // the store mirrors the repository layout, so walking it finds every path this -// repository has linked without asking git anything -fn linked_paths(repo: &Repo, dir: &Path) -> Result> { +// repository has put there without asking git anything +fn stored_paths(repo: &Repo, dir: &Path) -> Result> { let mut found = Vec::new(); let entries = match read_dir(dir) { @@ -127,20 +163,64 @@ fn linked_paths(repo: &Repo, dir: &Path) -> Result> { .expect("walked out of the store"); let src = repo.root.join(rel); - let linked = symlink_metadata_opt(&src)?.is_some_and(|meta| meta.is_symlink()) - && read_link(&src).is_ok_and(|dest| dest == stored); + let state = match symlink_metadata_opt(&src)? { + None => Stored::Missing, + Some(meta) if !meta.is_symlink() => Stored::Taken, + Some(_) if read_link(&src).is_ok_and(|dest| dest == stored) => Stored::Linked, + Some(_) => Stored::Taken, + }; - if linked { - found.push(src); - } else if stored.is_dir() { - found.extend(linked_paths(repo, &stored)?); + // a linked directory is one entry; otherwise the paths inside it are + if state == Stored::Linked || !stored.is_dir() { + found.push((src, state)); + } else { + found.extend(stored_paths(repo, &stored)?); } } - found.sort(); + found.sort_by(|(a, _), (b, _)| a.cmp(b)); Ok(found) } +// what the store holds, without the git questions check asks +pub fn stored_summary(ctx: &Ctx, store: Option<&Path>) -> Result<(PathBuf, usize, usize)> { + let repo = Repo::discover(ctx, store)?; + let stored = stored_paths(&repo, &repo.store)?; + + let linked = stored + .iter() + .filter(|(_, state)| *state == Stored::Linked) + .count(); + + Ok((repo.store, linked, stored.len() - linked)) +} + +// list what the store holds for this repository, the inverse of check +pub fn list(ctx: &Ctx, store: Option<&Path>) -> Result<()> { + let repo = Repo::discover(ctx, store)?; + let stored = stored_paths(&repo, &repo.store)?; + + line!("store: {}", repo.store.display()); + + if stored.is_empty() { + line!("nothing in the store for this repository"); + return Ok(()); + } + + for (path, state) in stored { + let rel = path.strip_prefix(&repo.root).unwrap_or(&path); + let verb = match state { + Stored::Linked => "linked", + Stored::Missing => "missing", + Stored::Taken => "shadowed", + }; + + line!("\t{:<11}{}", format!("{verb}:"), rel.display()); + } + + Ok(()) +} + struct Report { store: PathBuf, named: Cell, @@ -157,14 +237,13 @@ impl Report { fn line(&self, verb: &str, path: &Path) { // worth naming once per run if !self.named.replace(true) { - println!("store: {}", self.store.display()); + line!("store: {}", self.store.display()); } - println!("\t{:<11}{}", format!("{verb}:"), path.display()); + line!("\t{:<11}{}", format!("{verb}:"), path.display()); } } -// list untracked paths not in the store, i.e. what a sandbox can still read fn link_one(ctx: &Ctx, repo: &Repo, path: &Path, force: bool, report: &Report) -> Result<()> { let src = resolve(path)?; let rel = repo.relative(&src)?; diff --git a/src/commands/link/check.rs b/src/commands/link/check.rs index 637431d..d3c3791 100644 --- a/src/commands/link/check.rs +++ b/src/commands/link/check.rs @@ -1,21 +1,21 @@ use fs_err::{read_dir, read_link}; use std::path::{Path, PathBuf}; -use std::process::ExitCode; use anyhow::{Result, anyhow}; use super::store::{Repo, resolve, symlink_metadata_opt}; use crate::cmd::{Cmd, LsFiles}; use crate::ctx::Ctx; +use crate::output::{line, text}; +// whether anything is outside the store, which main turns into an exit code pub fn check( ctx: &Ctx, paths: &[PathBuf], porcelain: bool, null: bool, - exit_code: bool, store: Option<&Path>, -) -> Result { +) -> Result { let repo = Repo::discover(ctx, store)?; let pathspecs = relative_pathspecs(&repo, paths)?; @@ -42,30 +42,32 @@ pub fn check( print_listing(&repo, &exposed); } - // git's --exit-code convention: nothing to report is 0, anything is 1 - if exit_code && !exposed.is_empty() { - return Ok(ExitCode::FAILURE); - } - - Ok(ExitCode::SUCCESS) + Ok(!exposed.is_empty()) } fn print_porcelain(exposed: &[Exposed], null: bool) { let end = if null { '\0' } else { '\n' }; + // a filename can hold an arrow but not a NUL, as `git status -z` also assumes + let between = if null { "\0" } else { " -> " }; for item in exposed { match &item.dest { - Some(dest) => print!("{} {} -> {}{end}", item.code(), item.name, dest.display()), - None => print!("{} {}{end}", item.code(), item.name), + Some(dest) => text!( + "{} {}{between}{}{end}", + item.code(), + item.name, + dest.display() + ), + None => text!("{} {}{end}", item.code(), item.name), } } } fn print_listing(repo: &Repo, exposed: &[Exposed]) { - println!("store: {}", repo.store.display()); + line!("store: {}", repo.store.display()); if exposed.is_empty() { - println!("nothing outside the store, a sandbox would see tracked files only"); + line!("nothing outside the store, a sandbox would see tracked files only"); return; } @@ -93,11 +95,11 @@ fn print_listing(repo: &Repo, exposed: &[Exposed]) { continue; } - println!("\n{heading}\n{hint}"); + line!("\n{heading}\n{hint}"); for item in items { match &item.dest { - Some(dest) => println!("\t{} -> {}", item.name, dest.display()), - None => println!("\t{}", item.name), + Some(dest) => line!("\t{} -> {}", item.name, dest.display()), + None => line!("\t{}", item.name), } } } diff --git a/src/commands/link/store.rs b/src/commands/link/store.rs index 5683c22..ef7c89b 100644 --- a/src/commands/link/store.rs +++ b/src/commands/link/store.rs @@ -86,7 +86,10 @@ fn repo_components(ctx: &Ctx, root: &Path) -> Result { if let Some(components) = components_from_remote(&url) { return Ok(components); } - note!("could not parse git remote `{url}`, falling back to the checkout name"); + note!( + ctx, + "could not parse git remote `{url}`, falling back to the checkout name" + ); } let name = root diff --git a/src/commands/mod.rs b/src/commands/mod.rs index e33f8ca..80b0fbd 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -2,3 +2,4 @@ pub mod completions; pub mod django; pub mod link; pub mod postgres; +pub mod status; diff --git a/src/commands/postgres.rs b/src/commands/postgres.rs index 3ef52cf..17c11a5 100644 --- a/src/commands/postgres.rs +++ b/src/commands/postgres.rs @@ -2,7 +2,7 @@ mod server; mod shape; use fs_err::File; -use std::io::{self, Write}; +use std::io::{self, IsTerminal, Write}; use std::path::Path; use std::process::Stdio; @@ -12,8 +12,8 @@ use self::server::{Database, wait_until_ready, when_ready}; use self::shape::{Dump, HEADER_LEN, Kind}; use crate::cli::Format; use crate::cmd::{ - Cmd, Cp, CreateDb, DropDb, Gunzip, Gzip, Head, PgDump, PgDumpAll, PgRestore, Rm, Start, Stop, - Up, + Cmd, Cp, CreateDb, DropDb, Gunzip, Gzip, Head, PgDump, PgDumpAll, PgRestore, Psql, Rm, Start, + Stop, Up, }; use crate::ctx::Ctx; use crate::fsops::{remove_file, rename, suffixed}; @@ -45,11 +45,12 @@ fn restore_cluster(ctx: &Ctx, db: &Database, restore: &dyn Cmd, file: &Path) -> continue; } - note!("{line}"); + note!(ctx, "{line}"); } if existing > 0 { note!( + ctx, "left {existing} existing role{} alone", if existing == 1 { "" } else { "s" } ); @@ -66,11 +67,15 @@ pub fn import(ctx: &Ctx, file: &Path) -> Result<()> { let dump = Dump::of(file)?; let db = Database::resolve(ctx)?; - note!("stopping all containers"); - Stop.run(ctx)?; + note!(ctx, "stopping all containers"); + Stop { quiet: ctx.quiet }.run(ctx)?; - note!("starting db container"); - Start::service(&db.service).run(ctx)?; + note!(ctx, "starting db container"); + Start { + service: &db.service, + quiet: ctx.quiet, + } + .run(ctx)?; let remote = remote_dump(); @@ -121,7 +126,7 @@ pub fn import(ctx: &Ctx, file: &Path) -> Result<()> { let restore = db.restore_with(kind); // the name of what actually runs, so the message cannot drift from it let tool = restore.argv().program().to_string(); - note!("restoring database with {tool}"); + note!(ctx, "restoring database with {tool}"); when_ready( ctx, @@ -149,7 +154,7 @@ pub fn import(ctx: &Ctx, file: &Path) -> Result<()> { wait_until_ready(ctx, &db)?; if ctx.dry_run { - note!("would restore with {tool}"); + note!(ctx, "would restore with {tool}"); } else { // a directory dump was copied in whole, so pg_restore reads it from the // container; every other shape is fed in on stdin, through gunzip when it @@ -190,13 +195,25 @@ pub fn import(ctx: &Ctx, file: &Path) -> Result<()> { let _ = Rm::recursive(&remote).in_container(&db.container).run(ctx); } - note!("restarting containers"); - Stop.run(ctx)?; - Up.run(ctx)?; + note!(ctx, "restarting containers"); + Stop { quiet: ctx.quiet }.run(ctx)?; + Up { quiet: ctx.quiet }.run(ctx)?; Ok(()) } +pub fn psql(ctx: &Ctx, rest: &[String]) -> Result<()> { + let db = Database::resolve(ctx)?; + + // a terminal only if this one has one, so `psql -c ... | cat` still works + Psql::new(&db.user, &db.name) + .args(rest) + .in_container(&db.container) + .interactive() + .tty(io::stdin().is_terminal()) + .replace(ctx) +} + pub fn dump(ctx: &Ctx, file: &Path, format: Format, gzip: bool) -> Result<()> { let db = Database::resolve(ctx)?; @@ -208,7 +225,7 @@ pub fn dump(ctx: &Ctx, file: &Path, format: Format, gzip: bool) -> Result<()> { return dump_directory(ctx, &db, file); } - note!("dumping to local file {}", file.to_string_lossy()); + note!(ctx, "dumping to local file {}", file.to_string_lossy()); // written beside the target and renamed once the dump succeeds, so a failure // cannot destroy the dump that is already there @@ -259,7 +276,7 @@ fn dump_directory(ctx: &Ctx, db: &Database, target: &Path) -> Result<()> { ); } - note!("dumping to local directory {}", target.display()); + note!(ctx, "dumping to local directory {}", target.display()); let remote = remote_dump(); PgDump::new(&db.user, &db.name, "d") diff --git a/src/commands/postgres/server.rs b/src/commands/postgres/server.rs index 3d1a47d..7e840cd 100644 --- a/src/commands/postgres/server.rs +++ b/src/commands/postgres/server.rs @@ -42,8 +42,8 @@ impl Database { pub(super) fn restore_with(&self, kind: Kind) -> Box { match kind { Kind::Archive => Box::new(PgRestore::new(&self.user, &self.name)), - Kind::Sql => Box::new(Psql::new(&self.user, &self.name).atomic()), - Kind::Cluster => Box::new(Psql::new(&self.user, "postgres")), + Kind::Sql => Box::new(Psql::new(&self.user, &self.name).quiet().atomic()), + Kind::Cluster => Box::new(Psql::new(&self.user, "postgres").quiet()), } } } diff --git a/src/commands/status.rs b/src/commands/status.rs new file mode 100644 index 0000000..0da1340 --- /dev/null +++ b/src/commands/status.rs @@ -0,0 +1,86 @@ +use std::path::Path; + +use anyhow::Result; + +use crate::cmd::{Cmd, Ps}; +use crate::commands::link; +use crate::ctx::Ctx; +use crate::output::line; +use crate::project::Project; + +// which services ahab picked, what it would talk to, what the store holds +pub fn status(ctx: &Ctx, store: Option<&Path>) -> Result<()> { + // a role it cannot pick out is worth reporting; a project it cannot read is not + let project = match Project::resolve(ctx) { + Ok(project) => project, + Err(e) => { + stored(ctx, store); + return Err(e); + } + }; + + line!("services: {}", project.names().join(", ")); + + role(ctx, "django", project.django(), &project); + role(ctx, "postgres", project.postgres(), &project); + stored(ctx, store); + + Ok(()) +} + +// what the store holds, and where to look for what it does not +fn stored(ctx: &Ctx, store: Option<&Path>) { + match link::stored_summary(ctx, store) { + Err(e) => line!("store: {e:#}"), + Ok((store, linked, other)) => { + line!("store: {}", store.display()); + line!("\tlinked: {linked}"); + + if other > 0 { + line!("\tnot linked: {other} (see `ahab link list`)"); + } + + line!("\t`ahab link check` lists what a sandbox can still read"); + } + } +} + +// one detected service: the name, the image behind it, and whether it is up +fn role(ctx: &Ctx, role: &str, detected: Result, project: &Project) { + let service = match detected { + Ok(service) => service, + Err(e) => { + line!("{role}: {e:#}"); + return; + } + }; + + let image = project + .image(&service) + .unwrap_or("built from the project") + .to_string(); + + line!("{role}: {service} ({image})"); + + match Ps::id_of(&service).capture(ctx) { + Ok(id) if id.trim().is_empty() => line!("\tcontainer: not running"), + Ok(id) => line!("\tcontainer: {}", short(id.trim())), + Err(e) => line!("\tcontainer: {e:#}"), + } + + if role == "postgres" { + let (user, database) = project.postgres_credentials(&service); + let source = |key: &str| match project.env(&service, key) { + Some(_) => "", + None => " (default, nothing in the environment)", + }; + + line!("\tuser: {user}{}", source("POSTGRES_USER")); + line!("\tdatabase: {database}{}", source("POSTGRES_DB")); + } +} + +// a container id is 64 characters and only the first few are ever typed +fn short(id: &str) -> String { + id.chars().take(12).collect() +} diff --git a/src/ctx.rs b/src/ctx.rs index 38e7303..1099bc6 100644 --- a/src/ctx.rs +++ b/src/ctx.rs @@ -2,4 +2,5 @@ pub struct Ctx { pub verbose: bool, pub dry_run: bool, + pub quiet: bool, } diff --git a/src/main.rs b/src/main.rs index b40d113..1ab7e53 100644 --- a/src/main.rs +++ b/src/main.rs @@ -14,18 +14,22 @@ use clap::Parser; use crate::ctx::Ctx; use crate::output::note; +// 0 ran with nothing to report, 1 could not finish, 2 bad arguments, 4 found something +const FINDINGS: u8 = 4; + fn main() -> ExitCode { let args = cli::Ahab::parse(); let ctx = Ctx { verbose: args.verbose, dry_run: args.dry_run, + quiet: args.quiet, }; // said once here rather than by each command, so every line that follows // reads as the plan it is if ctx.dry_run { - note!("dry run, nothing will be changed"); + note!(ctx, "dry run, nothing will be changed"); } match run(&ctx, args.command) { @@ -52,7 +56,6 @@ fn run(ctx: &Ctx, command: cli::Commands) -> Result { cli::Django::Manage { rest } => commands::django::manage(ctx, &rest), cli::Django::Migrate { rest } => commands::django::migrate(ctx, &rest), cli::Django::Shell => commands::django::shell(ctx), - cli::Django::Test => commands::django::test(ctx), }?; Ok(done) @@ -60,6 +63,7 @@ fn run(ctx: &Ctx, command: cli::Commands) -> Result { cli::Commands::Postgres { command } => { match command { cli::Postgres::Import { path } => commands::postgres::import(ctx, &path), + cli::Postgres::Psql { rest } => commands::postgres::psql(ctx, &rest), cli::Postgres::Dump { path, format, gzip } => { commands::postgres::dump(ctx, &path, format, gzip) } @@ -73,6 +77,9 @@ fn run(ctx: &Ctx, command: cli::Commands) -> Result { force, store, } => commands::link::add(ctx, &paths, force, store.root.as_deref()).map(|()| done), + cli::Link::List { store } => { + commands::link::list(ctx, store.root.as_deref()).map(|()| done) + } cli::Link::Restore { paths, all, store } => { commands::link::restore(ctx, &paths, all, store.root.as_deref()).map(|()| done) } @@ -83,15 +90,21 @@ fn run(ctx: &Ctx, command: cli::Commands) -> Result { null, exit_code, store, - } => commands::link::check( - ctx, - &paths, - porcelain, - null, - exit_code, - store.root.as_deref(), - ), + } => { + let exposed = + commands::link::check(ctx, &paths, porcelain, null, store.root.as_deref())?; + + match exit_code && exposed { + true => Ok(ExitCode::from(FINDINGS)), + false => Ok(done), + } + } }, + cli::Commands::Status { store } => { + commands::status::status(ctx, store.root.as_deref())?; + + Ok(done) + } cli::Commands::Completions { shell } => { commands::completions::completions(shell)?; diff --git a/src/output.rs b/src/output.rs index 7ef0543..10448f1 100644 --- a/src/output.rs +++ b/src/output.rs @@ -1,11 +1,43 @@ -// progress and warnings go to stderr, so what a caller pipes is only ever the -// data a command was asked for +use std::fmt::Arguments; +use std::io::{self, Write}; + +// progress, on stderr and only when it was asked for macro_rules! note { - ($($arg:tt)*) => { eprintln!($($arg)*) }; + ($ctx:expr, $($arg:tt)*) => { + if !$ctx.quiet { + eprintln!($($arg)*) + } + }; } +// not progress: it speaks about the result, so --quiet keeps it macro_rules! warning { ($($arg:tt)*) => { eprintln!("warning: {}", format_args!($($arg)*)) }; } -pub(crate) use {note, warning}; +// what a command was asked for, on stdout +macro_rules! line { + ($($arg:tt)*) => { $crate::output::write_line(format_args!($($arg)*)) }; +} + +// the same, for a caller that terminates its own entries +macro_rules! text { + ($($arg:tt)*) => { $crate::output::write_text(format_args!($($arg)*)) }; +} + +pub(crate) fn write_line(args: Arguments) { + finish(writeln!(io::stdout(), "{args}")); +} + +pub(crate) fn write_text(args: Arguments) { + finish(write!(io::stdout(), "{args}")); +} + +// a reader leaving early ends the pipe; println! would panic instead +fn finish(written: io::Result<()>) { + if written.is_err() { + std::process::exit(0); + } +} + +pub(crate) use {line, note, text, warning}; diff --git a/src/project.rs b/src/project.rs index 296b3eb..239df00 100644 --- a/src/project.rs +++ b/src/project.rs @@ -91,7 +91,7 @@ impl Project { (user, database) } - fn names(&self) -> Vec<&str> { + pub fn names(&self) -> Vec<&str> { let mut names: Vec<&str> = self .services .as_object() @@ -102,6 +102,16 @@ impl Project { names } + // the image a service runs, which a service that builds its own does not have + pub fn image(&self, service: &str) -> Option<&str> { + self.services[service].get("image")?.as_str() + } + + // whether a value was set in the project or is ahab's own fallback + pub fn env(&self, service: &str, key: &str) -> Option { + env_var(&self.services[service], key) + } + fn publishes_ports(&self, service: &str) -> bool { self.services[service] .get("ports")