refactor: put the dry-run and quiet checks on Ctx

This commit is contained in:
2026-09-09 12:32:55 +00:00
parent d9439253fd
commit b40dfe6124
10 changed files with 253 additions and 217 deletions

View File

@@ -1,4 +1,5 @@
use super::{Argv, Cmd}; use super::{Argv, Cmd};
use crate::ctx::Ctx;
// compose prints its own progress, which --quiet has to ask it to stop // compose prints its own progress, which --quiet has to ask it to stop
fn compose(quiet: bool) -> Argv { fn compose(quiet: bool) -> Argv {
@@ -10,6 +11,49 @@ fn compose(quiet: bool) -> Argv {
} }
} }
// the compose commands, each already knowing whether -q asked compose to stop
// narrating. the flag was a field every construction site set by hand, and
// three of them did not, so `ahab -q` still had compose talking over the output
pub struct Compose<'a> {
ctx: &'a Ctx,
}
impl<'a> Compose<'a> {
pub(crate) fn new(ctx: &'a Ctx) -> Self {
Self { ctx }
}
pub fn up(&self) -> Up {
Up {
quiet: self.ctx.quiet,
}
}
pub fn stop(&self) -> Stop {
Stop {
quiet: self.ctx.quiet,
}
}
pub fn start(&self, service: &'a str) -> Start<'a> {
Start {
service,
quiet: self.ctx.quiet,
}
}
// reading the project says nothing, so there is no progress to quieten
pub fn config(&self) -> Config {
Config
}
pub fn ps(&self, service: &str) -> Ps {
Ps {
service: service.to_string(),
}
}
}
// 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
// container user before handing over // container user before handing over
pub struct Run { pub struct Run {
@@ -19,20 +63,15 @@ pub struct Run {
} }
impl Run { impl Run {
pub fn wrapping(service: &str, inner: Argv) -> Self { // only reached through Cmd::in_service, which takes the context, so the
// progress flag cannot be left off by forgetting a builder call
pub(super) fn wrapping(ctx: &Ctx, service: &str, inner: Argv) -> Self {
Self { Self {
service: service.to_string(), service: service.to_string(),
inner, inner,
quiet: false, quiet: ctx.quiet,
} }
} }
// compose narrates the container it creates before handing over, which is
// progress along the way rather than what was asked for
pub fn quiet(mut self, quiet: bool) -> Self {
self.quiet = quiet;
self
}
} }
impl Cmd for Run { impl Cmd for Run {
@@ -59,14 +98,6 @@ pub struct Ps {
service: String, service: String,
} }
impl Ps {
pub fn id_of(service: &str) -> Self {
Self {
service: service.to_string(),
}
}
}
impl Cmd for Ps { impl Cmd for Ps {
fn argv(&self) -> Argv { fn argv(&self) -> Argv {
compose(false).arg("ps").arg("--quiet").arg(&self.service) compose(false).arg("ps").arg("--quiet").arg(&self.service)
@@ -74,7 +105,7 @@ impl Cmd for Ps {
} }
pub struct Up { pub struct Up {
pub quiet: bool, quiet: bool,
} }
impl Cmd for Up { impl Cmd for Up {
@@ -84,8 +115,8 @@ impl Cmd for Up {
} }
pub struct Start<'a> { pub struct Start<'a> {
pub service: &'a str, service: &'a str,
pub quiet: bool, quiet: bool,
} }
impl Cmd for Start<'_> { impl Cmd for Start<'_> {
@@ -95,7 +126,7 @@ impl Cmd for Start<'_> {
} }
pub struct Stop { pub struct Stop {
pub quiet: bool, quiet: bool,
} }
impl Cmd for Stop { impl Cmd for Stop {

View File

@@ -14,7 +14,7 @@ use std::{
use anyhow::Result; use anyhow::Result;
pub use argv::Argv; pub use argv::Argv;
pub use compose::{Config, Ps, Run, Start, Stop, Up}; pub use compose::{Compose, Run};
pub use django::{Bash, Manage, Words}; pub use django::{Bash, Manage, Words};
pub use docker::{Cp, Exec}; pub use docker::{Cp, Exec};
pub use git::{CheckIgnore, ConfigGet, LsFiles, RevParse}; pub use git::{CheckIgnore, ConfigGet, LsFiles, RevParse};
@@ -43,9 +43,12 @@ pub trait Cmd {
Exec::wrapping(container, self.argv()) Exec::wrapping(container, self.argv())
} }
fn in_service(&self, service: &str) -> Run { // the context comes in here because compose narrates what it is doing, and
Run::wrapping(service, self.argv()) // whether it should is the caller's -q rather than this command's business
fn in_service(&self, ctx: &Ctx, service: &str) -> Run {
Run::wrapping(ctx, service, self.argv())
} }
// terminals // terminals
fn run(&self, ctx: &Ctx) -> Result<()> { fn run(&self, ctx: &Ctx) -> Result<()> {
self.argv().run(ctx) self.argv().run(ctx)

View File

@@ -4,7 +4,7 @@ use anyhow::{Result, anyhow};
use crate::cmd::{Bash, Cmd, Manage, Words}; use crate::cmd::{Bash, Cmd, Manage, Words};
use crate::ctx::Ctx; use crate::ctx::Ctx;
use crate::fsops::{create_dir, touch, write_new};
use crate::output::note; use crate::output::note;
use crate::project::Project; use crate::project::Project;
@@ -40,8 +40,8 @@ pub fn make_command(ctx: &Ctx, app: &Path, name: &str) -> Result<()> {
let management_dir = app_dir.join("management"); let management_dir = app_dir.join("management");
if !management_dir.exists() { if !management_dir.exists() {
create_dir(ctx, &management_dir)?; ctx.fs().create_dir(&management_dir)?;
touch(ctx, &management_dir.join("__init__.py"))?; ctx.fs().touch(&management_dir.join("__init__.py"))?;
note!(ctx, "created module {app_name}.management") note!(ctx, "created module {app_name}.management")
}; };
@@ -49,14 +49,13 @@ pub fn make_command(ctx: &Ctx, app: &Path, name: &str) -> Result<()> {
let commands_dir = management_dir.join("commands"); let commands_dir = management_dir.join("commands");
if !commands_dir.exists() { if !commands_dir.exists() {
create_dir(ctx, &commands_dir)?; ctx.fs().create_dir(&commands_dir)?;
touch(ctx, &commands_dir.join("__init__.py"))?; ctx.fs().touch(&commands_dir.join("__init__.py"))?;
note!(ctx, "created module {app_name}.management.commands") note!(ctx, "created module {app_name}.management.commands")
}; };
write_new( ctx.fs().write_new(
ctx,
&commands_dir.join(format!("{name}.py")), &commands_dir.join(format!("{name}.py")),
DEBUG_TEMPLATE.as_bytes(), DEBUG_TEMPLATE.as_bytes(),
)?; )?;
@@ -72,22 +71,18 @@ fn is_module_name(name: &str) -> bool {
} }
pub fn bash(ctx: &Ctx) -> Result<()> { pub fn bash(ctx: &Ctx) -> Result<()> {
Bash.in_service(&service(ctx)?) Bash.in_service(ctx, &service(ctx)?).replace(ctx)
.quiet(ctx.quiet)
.replace(ctx)
} }
pub fn run(ctx: &Ctx, rest: &[String]) -> Result<()> { pub fn run(ctx: &Ctx, rest: &[String]) -> Result<()> {
Words::new(rest) Words::new(rest)
.in_service(&service(ctx)?) .in_service(ctx, &service(ctx)?)
.quiet(ctx.quiet)
.replace(ctx) .replace(ctx)
} }
pub fn manage(ctx: &Ctx, rest: &[String]) -> Result<()> { pub fn manage(ctx: &Ctx, rest: &[String]) -> Result<()> {
Manage::new(rest) Manage::new(rest)
.in_service(&service(ctx)?) .in_service(ctx, &service(ctx)?)
.quiet(ctx.quiet)
.replace(ctx) .replace(ctx)
} }

View File

@@ -11,9 +11,7 @@ use anyhow::{Context, Result, anyhow, bail};
use self::store::{Leads, Repo, ignored, leads, resolve, symlink_metadata_opt, tracked}; use self::store::{Leads, Repo, ignored, leads, resolve, symlink_metadata_opt, tracked};
use crate::ctx::Ctx; use crate::ctx::Ctx;
use crate::fsops::{ use crate::fsops::suffixed;
ensure_private_parent, move_path, place_link, prune_empty, remove_file, rename, suffixed,
};
use crate::output::{line, note, warning}; use crate::output::{line, note, warning};
const BACKUP_SUFFIX: &str = ".ahab-bak"; const BACKUP_SUFFIX: &str = ".ahab-bak";
@@ -137,10 +135,10 @@ fn restore_one(ctx: &Ctx, repo: &Repo, path: &Path, report: &Report) -> Result<(
bail!("{} is in the way; move it aside", aside.display()); bail!("{} is in the way; move it aside", aside.display());
} }
rename(ctx, &src, &aside)?; ctx.fs().rename(&src, &aside)?;
if let Err(e) = move_path(ctx, &stored, &src) { if let Err(e) = ctx.fs().move_path(&stored, &src) {
rename(ctx, &aside, &src).with_context(|| { ctx.fs().rename(&aside, &src).with_context(|| {
format!( format!(
"could not put the link at {} back after failing to restore it", "could not put the link at {} back after failing to restore it",
src.display() src.display()
@@ -150,8 +148,8 @@ fn restore_one(ctx: &Ctx, repo: &Repo, path: &Path, report: &Report) -> Result<(
return Err(e); return Err(e);
} }
remove_file(ctx, &aside)?; ctx.fs().remove_file(&aside)?;
prune_empty(ctx, stored.parent(), &repo.base); ctx.fs().prune_empty(stored.parent(), &repo.base);
report.line("restored", &rel); report.line("restored", &rel);
Ok(()) Ok(())
@@ -295,7 +293,7 @@ fn link_one(ctx: &Ctx, repo: &Repo, path: &Path, force: bool, report: &Report) -
stays_in_store(repo, &rel)?; stays_in_store(repo, &rel)?;
// before anything is moved in, so the tree it lands in is never briefly // before anything is moved in, so the tree it lands in is never briefly
// readable by anyone else // readable by anyone else
ensure_private_parent(ctx, &repo.base, &target)?; ctx.fs().ensure_private_parent(&repo.base, &target)?;
if tracked(ctx, repo, &rel)? { if tracked(ctx, repo, &rel)? {
return Err(anyhow!( return Err(anyhow!(
@@ -363,7 +361,7 @@ fn link_one(ctx: &Ctx, repo: &Repo, path: &Path, force: bool, report: &Report) -
return Err(needs_force(&target)); return Err(needs_force(&target));
} }
place_link(ctx, &src, &target)?; ctx.fs().place_link(&src, &target)?;
report.line("repointed", &rel); report.line("repointed", &rel);
Ok(()) Ok(())
} }
@@ -381,10 +379,10 @@ fn link_one(ctx: &Ctx, repo: &Repo, path: &Path, force: bool, report: &Report) -
)); ));
} }
rename(ctx, &src, &backup)?; ctx.fs().rename(&src, &backup)?;
report.line("saved", &suffixed(&rel, BACKUP_SUFFIX)); report.line("saved", &suffixed(&rel, BACKUP_SUFFIX));
place_link(ctx, &src, &target)?; ctx.fs().place_link(&src, &target)?;
report.line("linked", &rel); report.line("linked", &rel);
Ok(()) Ok(())
} }
@@ -399,7 +397,7 @@ fn link_one(ctx: &Ctx, repo: &Repo, path: &Path, force: bool, report: &Report) -
if !force { if !force {
return Err(needs_force(&target)); return Err(needs_force(&target));
} }
place_link(ctx, &src, &target)?; ctx.fs().place_link(&src, &target)?;
report.line("linked", &rel); report.line("linked", &rel);
Ok(()) Ok(())
} }
@@ -416,10 +414,10 @@ fn link_one(ctx: &Ctx, repo: &Repo, path: &Path, force: bool, report: &Report) -
// but no link placed, the store holds a path nothing points at and the working // but no link placed, the store holds a path nothing points at and the working
// tree has lost it altogether, which is the one outcome worse than failing // tree has lost it altogether, which is the one outcome worse than failing
fn move_and_link(ctx: &Ctx, src: &Path, target: &Path) -> Result<()> { fn move_and_link(ctx: &Ctx, src: &Path, target: &Path) -> Result<()> {
move_path(ctx, src, target)?; ctx.fs().move_path(src, target)?;
if let Err(e) = place_link(ctx, src, target) { if let Err(e) = ctx.fs().place_link(src, target) {
move_path(ctx, target, src).with_context(|| { ctx.fs().move_path(target, src).with_context(|| {
format!( format!(
"could not put {} back after failing to link it to {}", "could not put {} back after failing to link it to {}",
src.display(), src.display(),

View File

@@ -11,11 +11,10 @@ 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, Psql, Rm, Start, Cmd, Cp, CreateDb, DropDb, Gunzip, Gzip, Head, PgDump, PgDumpAll, PgRestore, Psql, Rm,
Stop, Up,
}; };
use crate::ctx::Ctx; use crate::ctx::Ctx;
use crate::fsops::{create_private_new, remove_file, rename, suffixed}; use crate::fsops::{create_private_new, suffixed};
use crate::output::{note, warning}; use crate::output::{note, warning};
// unique per run: docker cp will not copy a directory over an existing path, and // unique per run: docker cp will not copy a directory over an existing path, and
@@ -136,7 +135,7 @@ pub fn import(ctx: &Ctx, file: &Path) -> Result<()> {
} }
note!(ctx, "stopping all containers"); note!(ctx, "stopping all containers");
Stop { quiet: ctx.quiet }.run(ctx)?; ctx.compose().stop().run(ctx)?;
// everything from here runs with the project down, so an error leaves it // everything from here runs with the project down, so an error leaves it
// that way and the user has no reason to guess as much // that way and the user has no reason to guess as much
@@ -159,11 +158,7 @@ fn local_path_for_docker(file: &Path) -> Result<()> {
fn imported(ctx: &Ctx, db: &Database, dump: &Dump, file: &Path) -> Result<()> { fn imported(ctx: &Ctx, db: &Database, dump: &Dump, file: &Path) -> Result<()> {
note!(ctx, "starting db container"); note!(ctx, "starting db container");
Start { ctx.compose().start(&db.service).run(ctx)?;
service: &db.service,
quiet: ctx.quiet,
}
.run(ctx)?;
let remote = remote_dump(); let remote = remote_dump();
@@ -229,8 +224,8 @@ fn imported(ctx: &Ctx, db: &Database, dump: &Dump, file: &Path) -> Result<()> {
restored?; restored?;
note!(ctx, "restarting containers"); note!(ctx, "restarting containers");
Stop { quiet: ctx.quiet }.run(ctx)?; ctx.compose().stop().run(ctx)?;
Up { quiet: ctx.quiet }.run(ctx)?; ctx.compose().up().run(ctx)?;
Ok(()) Ok(())
} }
@@ -337,11 +332,11 @@ pub fn dump(ctx: &Ctx, file: &Path, format: Format, gzip: bool) -> Result<()> {
}; };
if let Err(e) = dumped { if let Err(e) = dumped {
let _ = remove_file(ctx, &partial); let _ = ctx.fs().remove_file(&partial);
return Err(e); return Err(e);
} }
rename(ctx, &partial, file)?; ctx.fs().rename(&partial, file)?;
Ok(()) Ok(())
} }

View File

@@ -4,7 +4,7 @@ use std::time::{Duration, Instant};
use anyhow::{Result, bail}; use anyhow::{Result, bail};
use super::shape::Kind; use super::shape::Kind;
use crate::cmd::{Cmd, PgIsReady, PgRestore, Ps, Psql}; use crate::cmd::{Cmd, PgIsReady, PgRestore, Psql};
use crate::ctx::Ctx; use crate::ctx::Ctx;
use crate::project::Project; use crate::project::Project;
@@ -26,7 +26,7 @@ impl Database {
let service = compose.postgres()?; let service = compose.postgres()?;
let (user, name) = compose.postgres_credentials(&service)?; let (user, name) = compose.postgres_credentials(&service)?;
let listed = Ps::id_of(&service).capture(ctx)?; let listed = ctx.compose().ps(&service).capture(ctx)?;
let mut ids = listed.lines().map(str::trim).filter(|id| !id.is_empty()); let mut ids = listed.lines().map(str::trim).filter(|id| !id.is_empty());
let container = match (ids.next(), ids.next()) { let container = match (ids.next(), ids.next()) {

View File

@@ -2,7 +2,7 @@ use std::path::Path;
use anyhow::Result; use anyhow::Result;
use crate::cmd::{Cmd, Ps}; use crate::cmd::Cmd;
use crate::commands::link; use crate::commands::link;
use crate::ctx::Ctx; use crate::ctx::Ctx;
use crate::output::line; use crate::output::line;
@@ -62,7 +62,7 @@ fn role(ctx: &Ctx, role: &str, detected: Result<String>, project: &Project) {
line!("{role}: {service} ({image})"); line!("{role}: {service} ({image})");
match Ps::id_of(&service).capture(ctx) { match ctx.compose().ps(&service).capture(ctx) {
Ok(id) if id.trim().is_empty() => line!("\tcontainer: not running"), Ok(id) if id.trim().is_empty() => line!("\tcontainer: not running"),
Ok(id) => line!("\tcontainer: {}", short(id.trim())), Ok(id) => line!("\tcontainer: {}", short(id.trim())),
Err(e) => line!("\tcontainer: {e:#}"), Err(e) => line!("\tcontainer: {e:#}"),

View File

@@ -1,6 +1,22 @@
use crate::cmd::Compose;
use crate::fsops::Fs;
// how ahab was invoked, handed to everything that runs commands or writes files // how ahab was invoked, handed to everything that runs commands or writes files
pub struct Ctx { pub struct Ctx {
pub verbose: bool, pub verbose: bool,
pub dry_run: bool, pub dry_run: bool,
pub quiet: bool, pub quiet: bool,
} }
impl Ctx {
// the two things these flags actually decide, asked for by name rather than
// read field by field wherever they are needed: a dry run writes nothing,
// and --quiet stops compose narrating
pub fn fs(&self) -> Fs<'_> {
Fs::new(self)
}
pub fn compose(&self) -> Compose<'_> {
Compose::new(self)
}
}

View File

@@ -16,47 +16,59 @@ pub fn suffixed(path: &Path, suffix: &str) -> PathBuf {
PathBuf::from(out) PathBuf::from(out)
} }
// every write to the working tree goes through this module, so a dry run is held // the working tree, as this invocation is allowed to touch it. every write goes
// back in one place rather than at each call site // through here, so what a dry run means is settled in one place rather than at
pub fn move_path(ctx: &Ctx, src: &Path, target: &Path) -> Result<()> { // the top of each operation, where the next one added could simply not ask
if ctx.dry_run { pub struct Fs<'a> {
return Ok(()); ctx: &'a Ctx,
}
impl<'a> Fs<'a> {
pub(crate) fn new(ctx: &'a Ctx) -> Self {
Self { ctx }
} }
ensure_parent(ctx, target)?; // a dry run writes nothing and reports that nothing went wrong, which for
// every operation here is its empty answer
fn unless_planned<T: Default>(&self, act: impl FnOnce() -> Result<T>) -> Result<T> {
match self.ctx.dry_run {
true => Ok(T::default()),
false => act(),
}
}
pub fn move_path(&self, src: &Path, target: &Path) -> Result<()> {
self.unless_planned(|| {
ensure_parent(target)?;
// rename cannot cross filesystems, and the store often is another one // rename cannot cross filesystems, and the store often is another one
match fs::rename(src, target) { match fs::rename(src, target) {
Ok(()) => Ok(()), Ok(()) => Ok(()),
Err(rename_err) => match copy_recursive(src, target) { Err(rename_err) => match copy_recursive(src, target) {
Ok(()) => remove_recursive(ctx, src), Ok(()) => remove_recursive(src),
Err(copy_err) => Err(copy_err).with_context(|| format!("after {rename_err}")), Err(copy_err) => Err(copy_err).with_context(|| format!("after {rename_err}")),
}, },
} }
} })
pub fn rename(ctx: &Ctx, src: &Path, target: &Path) -> Result<()> {
if ctx.dry_run {
return Ok(());
} }
Ok(fs::rename(src, target)?) pub fn rename(&self, src: &Path, target: &Path) -> Result<()> {
} self.unless_planned(|| Ok(fs::rename(src, target)?))
pub fn place_link(ctx: &Ctx, link_path: &Path, target: &Path) -> Result<()> {
if ctx.dry_run {
return Ok(());
} }
// the parent can be missing when the store holds a path the repository does pub fn place_link(&self, link_path: &Path, target: &Path) -> Result<()> {
// not have any more, and symlink would only report the bare ENOENT self.unless_planned(|| {
ensure_parent(ctx, link_path)?; // the parent can be missing when the store holds a path the
// repository does not have any more, and symlink would only report
// the bare ENOENT
ensure_parent(link_path)?;
// symlink under a temp name and rename over the path: the rename is atomic // symlink under a temp name and rename over the path: the rename is
// atomic
let tmp = suffixed(link_path, ".ahab-tmp"); let tmp = suffixed(link_path, ".ahab-tmp");
// only ahab's own leftover is cleared away: anything else here belongs to // only ahab's own leftover is cleared away: anything else here
// the project, and silently unlinking it would lose it // belongs to the project, and silently unlinking it would lose it
match fs::symlink_metadata(&tmp) { match fs::symlink_metadata(&tmp) {
Ok(meta) if meta.is_symlink() => fs::remove_file(&tmp)?, Ok(meta) if meta.is_symlink() => fs::remove_file(&tmp)?,
Ok(_) => { Ok(_) => {
@@ -72,29 +84,46 @@ pub fn place_link(ctx: &Ctx, link_path: &Path, target: &Path) -> Result<()> {
symlink(target, &tmp)?; symlink(target, &tmp)?;
Ok(fs::rename(&tmp, link_path)?) Ok(fs::rename(&tmp, link_path)?)
} })
// 0600 and only where nothing is yet. a dump is the whole database, and
// pg_dumpall's is every role's password hash, so it is not the owner's to share
// by default; create_new also refuses to follow a symlink planted at the path,
// which File::create would open and truncate
pub fn create_private_new(path: &Path) -> Result<std::fs::File> {
std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.mode(0o600)
.open(path)
.with_context(|| format!("creating {}", path.display()))
}
// the store holds what should not be reachable from the repository, so the
// directories it is kept in are the owner's alone. only the tree at or below
// `base` is created here; what is above it is the user's own business
pub fn ensure_private_parent(ctx: &Ctx, base: &Path, target: &Path) -> Result<()> {
if ctx.dry_run {
return Ok(());
} }
pub fn remove_file(&self, path: &Path) -> Result<()> {
self.unless_planned(|| Ok(fs::remove_file(path)?))
}
pub fn create_dir(&self, path: &Path) -> Result<()> {
self.unless_planned(|| Ok(fs::create_dir(path)?))
}
// created if it is missing, left as it is otherwise, which is what a caller
// making an empty __init__.py wants
pub fn touch(&self, path: &Path) -> Result<()> {
self.unless_planned(|| {
fs::OpenOptions::new()
.write(true)
.create(true)
.truncate(false)
.open(path)?;
Ok(())
})
}
// fails rather than writing over a file that is already there
pub fn write_new(&self, path: &Path, contents: &[u8]) -> Result<()> {
self.unless_planned(|| {
let mut file = fs::File::create_new(path)?;
file.write_all(contents)?;
Ok(())
})
}
// the store holds what should not be reachable from the repository, so the
// directories it is kept in are the owner's alone. only the tree at or
// below `base` is created here; what is above it is the user's own business
pub fn ensure_private_parent(&self, base: &Path, target: &Path) -> Result<()> {
self.unless_planned(|| {
let Some(parent) = target.parent().filter(|dir| dir.starts_with(base)) else { let Some(parent) = target.parent().filter(|dir| dir.starts_with(base)) else {
return Ok(()); return Ok(());
}; };
@@ -105,22 +134,55 @@ pub fn ensure_private_parent(ctx: &Ctx, base: &Path, target: &Path) -> Result<()
.mode(0o700) .mode(0o700)
.create(parent) .create(parent)
.with_context(|| format!("creating {}", parent.display())) .with_context(|| format!("creating {}", parent.display()))
} })
pub fn remove_file(ctx: &Ctx, path: &Path) -> Result<()> {
if ctx.dry_run {
return Ok(());
} }
Ok(fs::remove_file(path)?) // a restored path can leave the store holding nothing but empty directories
pub fn prune_empty(&self, dir: Option<&Path>, stop: &Path) {
let pruned: Result<()> = self.unless_planned(|| {
let mut dir = dir;
while let Some(path) = dir {
if path == stop || !path.starts_with(stop) || fs::remove_dir(path).is_err() {
break;
}
dir = path.parent();
}
Ok(())
});
// an empty directory left behind is untidy, not a failure
let _ = pruned;
}
}
// 0600 and only where nothing is yet. a dump is the whole database, and
// pg_dumpall's is every role's password hash, so it is not the owner's to share
// by default; create_new also refuses to follow a symlink planted at the path,
// which File::create would open and truncate.
//
// not on Fs: a dry run has no file to hand back, so its one caller asks for
// this only once it knows it is writing for real
pub fn create_private_new(path: &Path) -> Result<std::fs::File> {
std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.mode(0o600)
.open(path)
.with_context(|| format!("creating {}", path.display()))
}
fn ensure_parent(target: &Path) -> Result<()> {
match target.parent() {
Some(parent) if !parent.as_os_str().is_empty() => Ok(fs::create_dir_all(parent)?),
_ => Ok(()),
}
} }
// whatever is there, file, directory or symlink // whatever is there, file, directory or symlink
fn remove_recursive(ctx: &Ctx, path: &Path) -> Result<()> { fn remove_recursive(path: &Path) -> Result<()> {
if ctx.dry_run {
return Ok(());
}
if fs::symlink_metadata(path)?.is_dir() { if fs::symlink_metadata(path)?.is_dir() {
fs::remove_dir_all(path)?; fs::remove_dir_all(path)?;
} else { } else {
@@ -130,70 +192,6 @@ fn remove_recursive(ctx: &Ctx, path: &Path) -> Result<()> {
Ok(()) Ok(())
} }
fn ensure_parent(ctx: &Ctx, target: &Path) -> Result<()> {
if ctx.dry_run {
return Ok(());
}
match target.parent() {
Some(parent) if !parent.as_os_str().is_empty() => Ok(fs::create_dir_all(parent)?),
_ => Ok(()),
}
}
pub fn create_dir(ctx: &Ctx, path: &Path) -> Result<()> {
if ctx.dry_run {
return Ok(());
}
Ok(fs::create_dir(path)?)
}
// created if it is missing, left as it is otherwise, which is what a caller
// making an empty __init__.py wants
pub fn touch(ctx: &Ctx, path: &Path) -> Result<()> {
if ctx.dry_run {
return Ok(());
}
fs::OpenOptions::new()
.write(true)
.create(true)
.truncate(false)
.open(path)?;
Ok(())
}
// fails rather than writing over a file that is already there
pub fn write_new(ctx: &Ctx, path: &Path, contents: &[u8]) -> Result<()> {
if ctx.dry_run {
return Ok(());
}
let mut file = fs::File::create_new(path)?;
file.write_all(contents)?;
Ok(())
}
// a restored path can leave the store holding nothing but empty directories
pub fn prune_empty(ctx: &Ctx, dir: Option<&Path>, stop: &Path) {
if ctx.dry_run {
return;
}
let mut dir = dir;
while let Some(path) = dir {
if path == stop || !path.starts_with(stop) || fs::remove_dir(path).is_err() {
return;
}
dir = path.parent();
}
}
fn copy_recursive(src: &Path, target: &Path) -> Result<()> { fn copy_recursive(src: &Path, target: &Path) -> Result<()> {
let meta = fs::symlink_metadata(src)?; let meta = fs::symlink_metadata(src)?;

View File

@@ -1,7 +1,7 @@
use anyhow::{Context, Result, anyhow}; use anyhow::{Context, Result, anyhow};
use serde_json::Value; use serde_json::Value;
use crate::cmd::{Cmd, Config}; use crate::cmd::Cmd;
use crate::ctx::Ctx; use crate::ctx::Ctx;
const POSTGRES_IMAGES: [&str; 4] = ["postg", "timescale", "pgvector", "citus"]; const POSTGRES_IMAGES: [&str; 4] = ["postg", "timescale", "pgvector", "citus"];
@@ -13,7 +13,7 @@ pub struct Project {
impl Project { impl Project {
pub fn resolve(ctx: &Ctx) -> Result<Self> { pub fn resolve(ctx: &Ctx) -> Result<Self> {
let json = Config.capture(ctx)?; let json = ctx.compose().config().capture(ctx)?;
let config: Value = serde_json::from_str(&json).context("parsing docker compose config")?; let config: Value = serde_json::from_str(&json).context("parsing docker compose config")?;
let services = config let services = config
.get("services") .get("services")