merge: qol improvements

This commit is contained in:
2026-09-08 17:35:32 +02:00
20 changed files with 427 additions and 115 deletions

View File

@@ -1,4 +1,4 @@
use super::{Django, Link, Postgres}; use super::{Django, Link, Postgres, Store};
use clap::builder::styling::{AnsiColor, Effects, Styles}; use clap::builder::styling::{AnsiColor, Effects, Styles};
use clap::{Parser, Subcommand}; use clap::{Parser, Subcommand};
@@ -16,6 +16,10 @@ pub struct Ahab {
#[arg(short, long, global = true)] #[arg(short, long, global = true)]
pub verbose: bool, 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 /// Print the docker commands that would run, without running them
#[arg(long, global = true)] #[arg(long, global = true)]
pub dry_run: bool, pub dry_run: bool,
@@ -40,6 +44,12 @@ pub enum Commands {
command: Link, command: Link,
}, },
/// Show what ahab makes of this project
Status {
#[command(flatten)]
store: Store,
},
/// Print a shell completion script on stdout /// Print a shell completion script on stdout
Completions { Completions {
/// Shell to generate the script for /// Shell to generate the script for

View File

@@ -35,7 +35,4 @@ pub enum Django {
/// Run Django's manage.py shell /// Run Django's manage.py shell
Shell, Shell,
/// Run Django's manage.py test
Test,
} }

View File

@@ -30,6 +30,12 @@ pub enum Link {
store: Store, store: Store,
}, },
/// List what this repository keeps in the store
List {
#[command(flatten)]
store: Store,
},
/// List untracked paths a sandbox would still see /// List untracked paths a sandbox would still see
Check { Check {
/// Limit the listing to these paths /// Limit the listing to these paths
@@ -39,7 +45,7 @@ pub enum Link {
#[arg(long)] #[arg(long)]
porcelain: bool, 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)] #[arg(long)]
exit_code: bool, exit_code: bool,

View File

@@ -7,5 +7,5 @@ mod postgres;
pub use ahab::{Ahab, Commands}; pub use ahab::{Ahab, Commands};
pub use django::Django; pub use django::Django;
pub use link::Link; pub use link::{Link, Store};
pub use postgres::{Format, Postgres}; pub use postgres::{Format, Postgres};

View File

@@ -7,6 +7,13 @@ pub enum Postgres {
/// Import a dump, in any format ahab can produce /// Import a dump, in any format ahab can produce
Import { path: PathBuf }, 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<String>,
},
/// Dump via pg_dump, or pg_dumpall for a whole cluster /// Dump via pg_dump, or pg_dumpall for a whole cluster
Dump { Dump {
path: PathBuf, path: PathBuf,

View File

@@ -1,7 +1,13 @@
use super::{Argv, Cmd}; use super::{Argv, Cmd};
fn compose() -> Argv { // compose prints its own progress, which --quiet has to ask it to stop
Argv::new("docker").arg("compose") 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 // 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 { impl Cmd for Run {
fn argv(&self) -> Argv { fn argv(&self) -> Argv {
compose() compose(false)
.arg("run") .arg("run")
.arg("--rm") .arg("--rm")
.arg(&self.service) .arg(&self.service)
@@ -35,7 +41,7 @@ pub struct Config;
impl Cmd for Config { impl Cmd for Config {
fn argv(&self) -> Argv { 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 { impl Cmd for Ps {
fn argv(&self) -> Argv { 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 { impl Cmd for Up {
fn argv(&self) -> Argv { fn argv(&self) -> Argv {
compose().arg("up").arg("--detach") compose(self.quiet).arg("up").arg("--detach")
} }
} }
pub struct Start { pub struct Start<'a> {
service: String, pub service: &'a str,
pub quiet: bool,
} }
impl Start { impl Cmd for Start<'_> {
pub fn service(service: &str) -> Self {
Self {
service: service.to_string(),
}
}
}
impl Cmd for Start {
fn argv(&self) -> Argv { 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 { impl Cmd for Stop {
fn argv(&self) -> Argv { fn argv(&self) -> Argv {
compose().arg("stop") compose(self.quiet).arg("stop")
} }
} }

View File

@@ -4,6 +4,7 @@ use super::{Argv, Cmd};
pub struct Exec { pub struct Exec {
container: String, container: String,
interactive: bool, interactive: bool,
tty: bool,
inner: Argv, inner: Argv,
} }
@@ -12,10 +13,17 @@ impl Exec {
Self { Self {
container: container.to_string(), container: container.to_string(),
interactive: false, interactive: false,
tty: false,
inner, 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 // keep stdin open, for a command that is fed a dump
pub fn interactive(mut self) -> Self { pub fn interactive(mut self) -> Self {
self.interactive = true; self.interactive = true;
@@ -31,6 +39,10 @@ impl Cmd for Exec {
argv = argv.arg("--interactive"); argv = argv.arg("--interactive");
} }
if self.tty {
argv = argv.arg("--tty");
}
argv.arg(&self.container).args(self.inner.words()) argv.arg(&self.container).args(self.inner.words())
} }
} }

View File

@@ -21,8 +21,10 @@ pub struct DropDb<'a> {
impl Cmd for DropDb<'_> { impl Cmd for DropDb<'_> {
fn argv(&self) -> Argv { fn argv(&self) -> Argv {
// a cluster that never held this database is still a restore target
Argv::new("dropdb") Argv::new("dropdb")
.flag("--username", self.username) .flag("--username", self.username)
.arg("--if-exists")
.arg(self.dbname) .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> { pub struct Psql<'a> {
username: &'a str, username: &'a str,
dbname: &'a str, dbname: &'a str,
quiet: bool,
atomic: bool, atomic: bool,
rest: &'a [String],
} }
impl<'a> Psql<'a> { impl<'a> Psql<'a> {
@@ -92,33 +96,51 @@ impl<'a> Psql<'a> {
Self { Self {
username, username,
dbname, dbname,
quiet: false,
atomic: 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: // 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 // it connects to each database itself, and trips over roles already there
pub fn atomic(mut self) -> Self { pub fn atomic(mut self) -> Self {
self.atomic = true; self.atomic = true;
self 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<'_> { impl Cmd for Psql<'_> {
fn argv(&self) -> Argv { fn argv(&self) -> Argv {
let argv = Argv::new("psql") let mut argv = Argv::new("psql");
.arg("--quiet")
.flag("--output", "/dev/null") if self.quiet {
argv = argv.arg("--quiet").flag("--output", "/dev/null");
}
argv = argv
.flag("--username", self.username) .flag("--username", self.username)
.flag("--dbname", self.dbname); .flag("--dbname", self.dbname);
if self.atomic { if self.atomic {
return argv argv = argv
.flag("--variable", "ON_ERROR_STOP=1") .flag("--variable", "ON_ERROR_STOP=1")
.arg("--single-transaction"); .arg("--single-transaction");
} }
argv argv.args(self.rest)
} }
} }
@@ -174,9 +196,22 @@ impl Cmd for PgDumpAll<'_> {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::{PgDump, Psql}; use super::{DropDb, PgDump, Psql};
use crate::cmd::Cmd; 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] #[test]
fn a_directory_dump_names_the_file_it_writes() { fn a_directory_dump_names_the_file_it_writes() {
assert_eq!( assert_eq!(
@@ -193,13 +228,14 @@ mod tests {
fn only_a_single_database_restore_stops_at_the_first_error() { fn only_a_single_database_restore_stops_at_the_first_error() {
assert!( assert!(
Psql::new("u", "db") Psql::new("u", "db")
.quiet()
.atomic() .atomic()
.argv() .argv()
.quoted() .quoted()
.ends_with("--variable 'ON_ERROR_STOP=1' --single-transaction") .ends_with("--variable 'ON_ERROR_STOP=1' --single-transaction")
); );
assert_eq!( assert_eq!(
Psql::new("u", "postgres").argv().quoted(), Psql::new("u", "postgres").quiet().argv().quoted(),
"psql --quiet --output /dev/null --username u --dbname postgres" "psql --quiet --output /dev/null --username u --dbname postgres"
); );
} }

View File

@@ -24,7 +24,7 @@ pub fn make_command(ctx: &Ctx, app: &PathBuf, name: &str) -> Result<()> {
return Err(anyhow!("directory {app_name} does not exist")); 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"); 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)?; create_dir(ctx, &management_dir)?;
touch(ctx, &management_dir.join("__init__.py"))?; 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"); 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)?; create_dir(ctx, &commands_dir)?;
touch(ctx, &commands_dir.join("__init__.py"))?; touch(ctx, &commands_dir.join("__init__.py"))?;
note!("created module {app_name}.management.commands") note!(ctx, "created module {app_name}.management.commands")
}; };
write_new( write_new(
@@ -50,7 +50,7 @@ pub fn make_command(ctx: &Ctx, app: &PathBuf, name: &str) -> Result<()> {
DEBUG_TEMPLATE.as_bytes(), DEBUG_TEMPLATE.as_bytes(),
)?; )?;
note!("created command {app_name}.management.commands.{name}"); note!(ctx, "created command {app_name}.management.commands.{name}");
Ok(()) Ok(())
} }
@@ -82,10 +82,6 @@ pub fn shell(ctx: &Ctx) -> Result<()> {
manage(ctx, &["shell".to_string()]) manage(ctx, &["shell".to_string()])
} }
pub fn test(ctx: &Ctx) -> Result<()> {
manage(ctx, &["test".to_string()])
}
fn service(ctx: &Ctx) -> Result<String> { fn service(ctx: &Ctx) -> Result<String> {
Project::resolve(ctx)?.django() Project::resolve(ctx)?.django()
} }

View File

@@ -12,7 +12,7 @@ use anyhow::{Result, anyhow, bail};
use self::store::{Repo, ignored, resolve, symlink_metadata_opt, tracked}; use self::store::{Repo, ignored, resolve, symlink_metadata_opt, tracked};
use crate::ctx::Ctx; use crate::ctx::Ctx;
use crate::fsops::{move_path, place_link, prune_empty, remove_file, rename, suffixed}; 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"; 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; let mut failed = 0;
for path in paths { for path in paths {
if let Err(e) = link_one(ctx, &repo, path, force, &report) { if let Err(e) = link_one(ctx, &repo, path, force, &report) {
note!("error: {e:#}"); note!(ctx, "error: {e:#}");
failed += 1; 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 repo = Repo::discover(ctx, store)?;
let report = Report::new(&repo); let report = Report::new(&repo);
let paths = match (all, paths) { let stored = match (all, paths) {
(true, []) => linked_paths(&repo, &repo.store)?, (true, []) => stored_paths(&repo, &repo.store)?,
(true, _) => bail!("--all restores everything, so it takes no paths"), (true, _) => bail!("--all restores everything, so it takes no paths"),
(false, []) => bail!("name a path to restore, or pass --all"), (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() { // only a linked path can be moved back; the store can hold orphans too
note!("nothing in the store for this repository"); let linked: Vec<PathBuf> = 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(()); 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); return restore_one(ctx, &repo, path, &report);
} }
let mut failed = 0; let mut failed = 0;
for path in &paths { for path in &linked {
if let Err(e) = restore_one(ctx, &repo, path, &report) { if let Err(e) = restore_one(ctx, &repo, path, &report) {
note!("error: {e:#}"); note!(ctx, "error: {e:#}");
failed += 1; failed += 1;
} }
} }
if failed > 0 { if failed > 0 {
return Err(anyhow!("{failed} of {} paths failed", paths.len())); return Err(anyhow!("{failed} of {} paths failed", linked.len()));
} }
Ok(()) Ok(())
} }
@@ -109,9 +134,20 @@ fn restore_one(ctx: &Ctx, repo: &Repo, path: &Path, report: &Report) -> Result<(
Ok(()) 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 // the store mirrors the repository layout, so walking it finds every path this
// repository has linked without asking git anything // repository has put there without asking git anything
fn linked_paths(repo: &Repo, dir: &Path) -> Result<Vec<PathBuf>> { fn stored_paths(repo: &Repo, dir: &Path) -> Result<Vec<(PathBuf, Stored)>> {
let mut found = Vec::new(); let mut found = Vec::new();
let entries = match read_dir(dir) { let entries = match read_dir(dir) {
@@ -127,20 +163,64 @@ fn linked_paths(repo: &Repo, dir: &Path) -> Result<Vec<PathBuf>> {
.expect("walked out of the store"); .expect("walked out of the store");
let src = repo.root.join(rel); let src = repo.root.join(rel);
let linked = symlink_metadata_opt(&src)?.is_some_and(|meta| meta.is_symlink()) let state = match symlink_metadata_opt(&src)? {
&& read_link(&src).is_ok_and(|dest| dest == stored); 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 { // a linked directory is one entry; otherwise the paths inside it are
found.push(src); if state == Stored::Linked || !stored.is_dir() {
} else if stored.is_dir() { found.push((src, state));
found.extend(linked_paths(repo, &stored)?); } else {
found.extend(stored_paths(repo, &stored)?);
} }
} }
found.sort(); found.sort_by(|(a, _), (b, _)| a.cmp(b));
Ok(found) 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 { struct Report {
store: PathBuf, store: PathBuf,
named: Cell<bool>, named: Cell<bool>,
@@ -157,14 +237,13 @@ impl Report {
fn line(&self, verb: &str, path: &Path) { fn line(&self, verb: &str, path: &Path) {
// worth naming once per run // worth naming once per run
if !self.named.replace(true) { 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<()> { fn link_one(ctx: &Ctx, repo: &Repo, path: &Path, force: bool, report: &Report) -> Result<()> {
let src = resolve(path)?; let src = resolve(path)?;
let rel = repo.relative(&src)?; let rel = repo.relative(&src)?;

View File

@@ -1,21 +1,21 @@
use fs_err::{read_dir, read_link}; use fs_err::{read_dir, read_link};
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::process::ExitCode;
use anyhow::{Result, anyhow}; use anyhow::{Result, anyhow};
use super::store::{Repo, resolve, symlink_metadata_opt}; use super::store::{Repo, resolve, symlink_metadata_opt};
use crate::cmd::{Cmd, LsFiles}; use crate::cmd::{Cmd, LsFiles};
use crate::ctx::Ctx; 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( pub fn check(
ctx: &Ctx, ctx: &Ctx,
paths: &[PathBuf], paths: &[PathBuf],
porcelain: bool, porcelain: bool,
null: bool, null: bool,
exit_code: bool,
store: Option<&Path>, store: Option<&Path>,
) -> Result<ExitCode> { ) -> Result<bool> {
let repo = Repo::discover(ctx, store)?; let repo = Repo::discover(ctx, store)?;
let pathspecs = relative_pathspecs(&repo, paths)?; let pathspecs = relative_pathspecs(&repo, paths)?;
@@ -42,30 +42,32 @@ pub fn check(
print_listing(&repo, &exposed); print_listing(&repo, &exposed);
} }
// git's --exit-code convention: nothing to report is 0, anything is 1 Ok(!exposed.is_empty())
if exit_code && !exposed.is_empty() {
return Ok(ExitCode::FAILURE);
}
Ok(ExitCode::SUCCESS)
} }
fn print_porcelain(exposed: &[Exposed], null: bool) { fn print_porcelain(exposed: &[Exposed], null: bool) {
let end = if null { '\0' } else { '\n' }; 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 { for item in exposed {
match &item.dest { match &item.dest {
Some(dest) => print!("{} {} -> {}{end}", item.code(), item.name, dest.display()), Some(dest) => text!(
None => print!("{} {}{end}", item.code(), item.name), "{} {}{between}{}{end}",
item.code(),
item.name,
dest.display()
),
None => text!("{} {}{end}", item.code(), item.name),
} }
} }
} }
fn print_listing(repo: &Repo, exposed: &[Exposed]) { fn print_listing(repo: &Repo, exposed: &[Exposed]) {
println!("store: {}", repo.store.display()); line!("store: {}", repo.store.display());
if exposed.is_empty() { 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; return;
} }
@@ -93,11 +95,11 @@ fn print_listing(repo: &Repo, exposed: &[Exposed]) {
continue; continue;
} }
println!("\n{heading}\n{hint}"); line!("\n{heading}\n{hint}");
for item in items { for item in items {
match &item.dest { match &item.dest {
Some(dest) => println!("\t{} -> {}", item.name, dest.display()), Some(dest) => line!("\t{} -> {}", item.name, dest.display()),
None => println!("\t{}", item.name), None => line!("\t{}", item.name),
} }
} }
} }

View File

@@ -86,7 +86,10 @@ fn repo_components(ctx: &Ctx, root: &Path) -> Result<PathBuf> {
if let Some(components) = components_from_remote(&url) { if let Some(components) = components_from_remote(&url) {
return Ok(components); 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 let name = root

View File

@@ -2,3 +2,4 @@ pub mod completions;
pub mod django; pub mod django;
pub mod link; pub mod link;
pub mod postgres; pub mod postgres;
pub mod status;

View File

@@ -2,7 +2,7 @@ mod server;
mod shape; mod shape;
use fs_err::File; use fs_err::File;
use std::io::{self, Write}; use std::io::{self, IsTerminal, Write};
use std::path::Path; use std::path::Path;
use std::process::Stdio; 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 self::shape::{Dump, HEADER_LEN, Kind};
use crate::cli::Format; use crate::cli::Format;
use crate::cmd::{ use crate::cmd::{
Cmd, Cp, CreateDb, DropDb, Gunzip, Gzip, Head, PgDump, PgDumpAll, PgRestore, Rm, Start, Stop, Cmd, Cp, CreateDb, DropDb, Gunzip, Gzip, Head, PgDump, PgDumpAll, PgRestore, Psql, Rm, Start,
Up, Stop, Up,
}; };
use crate::ctx::Ctx; use crate::ctx::Ctx;
use crate::fsops::{remove_file, rename, suffixed}; use crate::fsops::{remove_file, rename, suffixed};
@@ -45,11 +45,12 @@ fn restore_cluster(ctx: &Ctx, db: &Database, restore: &dyn Cmd, file: &Path) ->
continue; continue;
} }
note!("{line}"); note!(ctx, "{line}");
} }
if existing > 0 { if existing > 0 {
note!( note!(
ctx,
"left {existing} existing role{} alone", "left {existing} existing role{} alone",
if existing == 1 { "" } else { "s" } if existing == 1 { "" } else { "s" }
); );
@@ -66,11 +67,15 @@ pub fn import(ctx: &Ctx, file: &Path) -> Result<()> {
let dump = Dump::of(file)?; let dump = Dump::of(file)?;
let db = Database::resolve(ctx)?; let db = Database::resolve(ctx)?;
note!("stopping all containers"); note!(ctx, "stopping all containers");
Stop.run(ctx)?; Stop { quiet: ctx.quiet }.run(ctx)?;
note!("starting db container"); note!(ctx, "starting db container");
Start::service(&db.service).run(ctx)?; Start {
service: &db.service,
quiet: ctx.quiet,
}
.run(ctx)?;
let remote = remote_dump(); let remote = remote_dump();
@@ -121,7 +126,7 @@ pub fn import(ctx: &Ctx, file: &Path) -> Result<()> {
let restore = db.restore_with(kind); let restore = db.restore_with(kind);
// the name of what actually runs, so the message cannot drift from it // the name of what actually runs, so the message cannot drift from it
let tool = restore.argv().program().to_string(); let tool = restore.argv().program().to_string();
note!("restoring database with {tool}"); note!(ctx, "restoring database with {tool}");
when_ready( when_ready(
ctx, ctx,
@@ -149,7 +154,7 @@ pub fn import(ctx: &Ctx, file: &Path) -> Result<()> {
wait_until_ready(ctx, &db)?; wait_until_ready(ctx, &db)?;
if ctx.dry_run { if ctx.dry_run {
note!("would restore with {tool}"); note!(ctx, "would restore with {tool}");
} else { } else {
// a directory dump was copied in whole, so pg_restore reads it from the // 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 // 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); let _ = Rm::recursive(&remote).in_container(&db.container).run(ctx);
} }
note!("restarting containers"); note!(ctx, "restarting containers");
Stop.run(ctx)?; Stop { quiet: ctx.quiet }.run(ctx)?;
Up.run(ctx)?; Up { quiet: ctx.quiet }.run(ctx)?;
Ok(()) 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<()> { pub fn dump(ctx: &Ctx, file: &Path, format: Format, gzip: bool) -> Result<()> {
let db = Database::resolve(ctx)?; 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); 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 // written beside the target and renamed once the dump succeeds, so a failure
// cannot destroy the dump that is already there // 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(); let remote = remote_dump();
PgDump::new(&db.user, &db.name, "d") PgDump::new(&db.user, &db.name, "d")

View File

@@ -42,8 +42,8 @@ impl Database {
pub(super) fn restore_with(&self, kind: Kind) -> Box<dyn Cmd + '_> { pub(super) fn restore_with(&self, kind: Kind) -> Box<dyn Cmd + '_> {
match kind { match kind {
Kind::Archive => Box::new(PgRestore::new(&self.user, &self.name)), Kind::Archive => Box::new(PgRestore::new(&self.user, &self.name)),
Kind::Sql => Box::new(Psql::new(&self.user, &self.name).atomic()), Kind::Sql => Box::new(Psql::new(&self.user, &self.name).quiet().atomic()),
Kind::Cluster => Box::new(Psql::new(&self.user, "postgres")), Kind::Cluster => Box::new(Psql::new(&self.user, "postgres").quiet()),
} }
} }
} }

86
src/commands/status.rs Normal file
View File

@@ -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<String>, 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()
}

View File

@@ -2,4 +2,5 @@
pub struct Ctx { pub struct Ctx {
pub verbose: bool, pub verbose: bool,
pub dry_run: bool, pub dry_run: bool,
pub quiet: bool,
} }

View File

@@ -14,18 +14,22 @@ use clap::Parser;
use crate::ctx::Ctx; use crate::ctx::Ctx;
use crate::output::note; 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 { fn main() -> ExitCode {
let args = cli::Ahab::parse(); let args = cli::Ahab::parse();
let ctx = Ctx { let ctx = Ctx {
verbose: args.verbose, verbose: args.verbose,
dry_run: args.dry_run, dry_run: args.dry_run,
quiet: args.quiet,
}; };
// said once here rather than by each command, so every line that follows // said once here rather than by each command, so every line that follows
// reads as the plan it is // reads as the plan it is
if ctx.dry_run { if ctx.dry_run {
note!("dry run, nothing will be changed"); note!(ctx, "dry run, nothing will be changed");
} }
match run(&ctx, args.command) { match run(&ctx, args.command) {
@@ -52,7 +56,6 @@ fn run(ctx: &Ctx, command: cli::Commands) -> Result<ExitCode> {
cli::Django::Manage { rest } => commands::django::manage(ctx, &rest), cli::Django::Manage { rest } => commands::django::manage(ctx, &rest),
cli::Django::Migrate { rest } => commands::django::migrate(ctx, &rest), cli::Django::Migrate { rest } => commands::django::migrate(ctx, &rest),
cli::Django::Shell => commands::django::shell(ctx), cli::Django::Shell => commands::django::shell(ctx),
cli::Django::Test => commands::django::test(ctx),
}?; }?;
Ok(done) Ok(done)
@@ -60,6 +63,7 @@ fn run(ctx: &Ctx, command: cli::Commands) -> Result<ExitCode> {
cli::Commands::Postgres { command } => { cli::Commands::Postgres { command } => {
match command { match command {
cli::Postgres::Import { path } => commands::postgres::import(ctx, &path), cli::Postgres::Import { path } => commands::postgres::import(ctx, &path),
cli::Postgres::Psql { rest } => commands::postgres::psql(ctx, &rest),
cli::Postgres::Dump { path, format, gzip } => { cli::Postgres::Dump { path, format, gzip } => {
commands::postgres::dump(ctx, &path, format, gzip) commands::postgres::dump(ctx, &path, format, gzip)
} }
@@ -73,6 +77,9 @@ fn run(ctx: &Ctx, command: cli::Commands) -> Result<ExitCode> {
force, force,
store, store,
} => commands::link::add(ctx, &paths, force, store.root.as_deref()).map(|()| done), } => 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 } => { cli::Link::Restore { paths, all, store } => {
commands::link::restore(ctx, &paths, all, store.root.as_deref()).map(|()| done) commands::link::restore(ctx, &paths, all, store.root.as_deref()).map(|()| done)
} }
@@ -83,15 +90,21 @@ fn run(ctx: &Ctx, command: cli::Commands) -> Result<ExitCode> {
null, null,
exit_code, exit_code,
store, store,
} => commands::link::check( } => {
ctx, let exposed =
&paths, commands::link::check(ctx, &paths, porcelain, null, store.root.as_deref())?;
porcelain,
null, match exit_code && exposed {
exit_code, true => Ok(ExitCode::from(FINDINGS)),
store.root.as_deref(), false => Ok(done),
), }
}
}, },
cli::Commands::Status { store } => {
commands::status::status(ctx, store.root.as_deref())?;
Ok(done)
}
cli::Commands::Completions { shell } => { cli::Commands::Completions { shell } => {
commands::completions::completions(shell)?; commands::completions::completions(shell)?;

View File

@@ -1,11 +1,43 @@
// progress and warnings go to stderr, so what a caller pipes is only ever the use std::fmt::Arguments;
// data a command was asked for use std::io::{self, Write};
// progress, on stderr and only when it was asked for
macro_rules! note { 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 { macro_rules! warning {
($($arg:tt)*) => { eprintln!("warning: {}", format_args!($($arg)*)) }; ($($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};

View File

@@ -91,7 +91,7 @@ impl Project {
(user, database) (user, database)
} }
fn names(&self) -> Vec<&str> { pub fn names(&self) -> Vec<&str> {
let mut names: Vec<&str> = self let mut names: Vec<&str> = self
.services .services
.as_object() .as_object()
@@ -102,6 +102,16 @@ impl Project {
names 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<String> {
env_var(&self.services[service], key)
}
fn publishes_ports(&self, service: &str) -> bool { fn publishes_ports(&self, service: &str) -> bool {
self.services[service] self.services[service]
.get("ports") .get("ports")