refactor: put every filesystem write behind one module

This commit is contained in:
2026-09-08 12:04:51 +00:00
parent bbbca755be
commit a3c08f6eaa
10 changed files with 261 additions and 236 deletions

16
Cargo.lock generated
View File

@@ -9,6 +9,7 @@ dependencies = [
"anyhow", "anyhow",
"clap", "clap",
"clap_complete", "clap_complete",
"fs-err",
"serde_json", "serde_json",
"shlex", "shlex",
] ]
@@ -69,6 +70,12 @@ version = "1.0.104"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
[[package]]
name = "autocfg"
version = "1.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
[[package]] [[package]]
name = "clap" name = "clap"
version = "4.6.6" version = "4.6.6"
@@ -124,6 +131,15 @@ version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
[[package]]
name = "fs-err"
version = "3.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b91aa448ca50d7e79433bdf3ee8d99215430d2ec02ade5aefab2a073a1822e8a"
dependencies = [
"autocfg",
]
[[package]] [[package]]
name = "heck" name = "heck"
version = "0.5.0" version = "0.5.0"

View File

@@ -17,6 +17,7 @@ clap = { version = "4.6.6", features = ["derive", "env"] }
clap_complete = "4.6.9" clap_complete = "4.6.9"
anyhow = "1.0.104" anyhow = "1.0.104"
serde_json = "1.0.145" serde_json = "1.0.145"
fs-err = "3.3.1"
shlex = "2.0.1" shlex = "2.0.1"
[build-dependencies] [build-dependencies]

View File

@@ -1,7 +1,7 @@
use super::{Django, Link, Postgres}; use super::{Django, Link, Postgres};
use clap::builder::styling::{AnsiColor, Effects, Styles}; use clap::builder::styling::{AnsiColor, Effects, Styles};
use clap::error::ErrorKind;
use clap::{CommandFactory, Parser, Subcommand}; use clap::{Parser, Subcommand};
use clap_complete::Shell; use clap_complete::Shell;
/// A program for interacting with various dockerized applications /// A program for interacting with various dockerized applications
@@ -56,63 +56,13 @@ fn help_styles() -> Styles {
.placeholder(AnsiColor::Cyan.on_default()) .placeholder(AnsiColor::Cyan.on_default())
} }
/// Exit with a usage error when `--dry-run` would be a lie
pub fn reject_unsupported_dry_run(command: &Commands) {
if let Some(name) = writes_locally(command) {
Ahab::command()
.error(
ErrorKind::ArgumentConflict,
format!("--dry-run is not supported by `{name}`, which writes to the working tree"),
)
.exit()
}
}
// TODO:(@janezicmatej) honour --dry-run in these commands instead of refusing it
fn writes_locally(command: &Commands) -> Option<&'static str> {
match command {
Commands::Link { command } => match command {
Link::Add { .. } => Some("link add"),
Link::Restore { .. } => Some("link restore"),
Link::Check { .. } => None,
},
Commands::Django { command } => match command {
Django::MakeCommand { .. } => Some("django make-command"),
_ => None,
},
Commands::Postgres { .. } | Commands::Completions { .. } => None,
}
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::Ahab; use super::Ahab;
use clap::{CommandFactory, Parser}; use clap::CommandFactory;
#[test] #[test]
fn the_command_is_well_formed() { fn the_command_is_well_formed() {
Ahab::command().debug_assert(); Ahab::command().debug_assert();
} }
#[test]
fn dry_run_is_refused_only_where_it_cannot_be_honoured() {
let command = |args: &[&str]| Ahab::try_parse_from(args).unwrap().command;
assert_eq!(
super::writes_locally(&command(&["ahab", "link", "add", ".env"])),
Some("link add")
);
assert_eq!(
super::writes_locally(&command(&["ahab", "django", "make-command", "app", "name"])),
Some("django make-command")
);
assert_eq!(
super::writes_locally(&command(&["ahab", "link", "check"])),
None
);
assert_eq!(
super::writes_locally(&command(&["ahab", "postgres", "dump", "out.sql"])),
None
);
}
} }

View File

@@ -1,9 +1,11 @@
// build.rs reaches these definitions with include!, so nothing here may refer to
// the rest of the crate: keep this tree to clap definitions only
mod ahab; mod ahab;
mod django; mod django;
mod link; mod link;
mod postgres; mod postgres;
pub use ahab::{Ahab, Commands, reject_unsupported_dry_run}; pub use ahab::{Ahab, Commands};
pub use django::Django; pub use django::Django;
pub use link::Link; pub use link::Link;
pub use postgres::{Format, Postgres}; pub use postgres::{Format, Postgres};

165
src/fsops.rs Normal file
View File

@@ -0,0 +1,165 @@
use std::io::Write;
use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
// std::fs with the path and the operation already in the error
use fs_err as fs;
use fs_err::os::unix::fs::symlink;
use crate::ctx::Ctx;
pub fn suffixed(path: &Path, suffix: &str) -> PathBuf {
let mut out = path.as_os_str().to_owned();
out.push(suffix);
PathBuf::from(out)
}
// every write to the working tree goes through this module, so a dry run is held
// back in one place rather than at each call site
pub fn move_path(ctx: &Ctx, src: &Path, target: &Path) -> Result<()> {
if ctx.dry_run {
return Ok(());
}
ensure_parent(ctx, target)?;
// rename cannot cross filesystems, and the store often is another one
match fs::rename(src, target) {
Ok(()) => Ok(()),
Err(rename_err) => match copy_recursive(src, target) {
Ok(()) => remove_recursive(ctx, src),
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 place_link(ctx: &Ctx, link_path: &Path, target: &Path) -> Result<()> {
if ctx.dry_run {
return Ok(());
}
// symlink under a temp name and rename over the path: the rename is atomic
let tmp = suffixed(link_path, ".ahab-tmp");
let _ = fs::remove_file(&tmp);
symlink(target, &tmp)?;
Ok(fs::rename(&tmp, link_path)?)
}
pub fn remove_file(ctx: &Ctx, path: &Path) -> Result<()> {
if ctx.dry_run {
return Ok(());
}
Ok(fs::remove_file(path)?)
}
// whatever is there, file, directory or symlink
fn remove_recursive(ctx: &Ctx, path: &Path) -> Result<()> {
if ctx.dry_run {
return Ok(());
}
if fs::symlink_metadata(path)?.is_dir() {
fs::remove_dir_all(path)?;
} else {
fs::remove_file(path)?;
}
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<()> {
let meta = fs::symlink_metadata(src)?;
if meta.is_dir() {
fs::create_dir_all(target)?;
for entry in fs::read_dir(src)? {
let entry = entry?;
copy_recursive(&entry.path(), &target.join(entry.file_name()))?;
}
return Ok(());
}
if meta.is_symlink() {
return Ok(symlink(fs::read_link(src)?, target)?);
}
fs::copy(src, target)?;
Ok(())
}

View File

@@ -4,12 +4,15 @@ mod cli;
mod cmd; mod cmd;
mod compose; mod compose;
mod ctx; mod ctx;
mod fsops;
mod output;
mod scripts; mod scripts;
use anyhow::Result; use anyhow::Result;
use clap::Parser; use clap::Parser;
use crate::ctx::Ctx; use crate::ctx::Ctx;
use crate::output::note;
fn main() -> ExitCode { fn main() -> ExitCode {
let args = cli::Ahab::parse(); let args = cli::Ahab::parse();
@@ -19,8 +22,10 @@ fn main() -> ExitCode {
dry_run: args.dry_run, dry_run: args.dry_run,
}; };
// said once here rather than by each command, so every line that follows
// reads as the plan it is
if ctx.dry_run { if ctx.dry_run {
cli::reject_unsupported_dry_run(&args.command); note!("dry run, nothing will be changed");
} }
match run(&ctx, args.command) { match run(&ctx, args.command) {
@@ -41,7 +46,7 @@ fn run(ctx: &Ctx, command: cli::Commands) -> Result<ExitCode> {
cli::Django::Bash => scripts::django::bash(ctx), cli::Django::Bash => scripts::django::bash(ctx),
cli::Django::Run { rest } => scripts::django::run(ctx, &rest), cli::Django::Run { rest } => scripts::django::run(ctx, &rest),
cli::Django::MakeCommand { app, name } => { cli::Django::MakeCommand { app, name } => {
scripts::django::make_command(&app, &name) scripts::django::make_command(ctx, &app, &name)
} }
cli::Django::Makemigrations => scripts::django::makemigrations(ctx), cli::Django::Makemigrations => scripts::django::makemigrations(ctx),
cli::Django::Manage { rest } => scripts::django::manage(ctx, &rest), cli::Django::Manage { rest } => scripts::django::manage(ctx, &rest),

11
src/output.rs Normal file
View File

@@ -0,0 +1,11 @@
// progress and warnings go to stderr, so what a caller pipes is only ever the
// data a command was asked for
macro_rules! note {
($($arg:tt)*) => { eprintln!($($arg)*) };
}
macro_rules! warning {
($($arg:tt)*) => { eprintln!("warning: {}", format_args!($($arg)*)) };
}
pub(crate) use {note, warning};

View File

@@ -1,5 +1,3 @@
use std::fs::{File, OpenOptions, create_dir};
use std::io::Write;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use anyhow::{Result, anyhow}; use anyhow::{Result, anyhow};
@@ -7,6 +5,8 @@ use anyhow::{Result, anyhow};
use crate::cmd::{Bash, Cmd, Manage, Words}; use crate::cmd::{Bash, Cmd, Manage, Words};
use crate::compose::Compose; use crate::compose::Compose;
use crate::ctx::Ctx; use crate::ctx::Ctx;
use crate::fsops::{create_dir, touch, write_new};
use crate::output::note;
const DEBUG_TEMPLATE: &str = r#"from django.core.management.base import BaseCommand const DEBUG_TEMPLATE: &str = r#"from django.core.management.base import BaseCommand
@@ -16,7 +16,7 @@ class Command(BaseCommand):
"#; "#;
pub fn make_command(app: &PathBuf, name: &str) -> Result<()> { pub fn make_command(ctx: &Ctx, app: &PathBuf, name: &str) -> Result<()> {
let app_name = app.to_string_lossy(); let app_name = app.to_string_lossy();
let app_dir = Path::new(&app); let app_dir = Path::new(&app);
@@ -25,32 +25,35 @@ pub fn make_command(app: &PathBuf, name: &str) -> Result<()> {
return Err(anyhow!("directory {app_name} does not exist")); return Err(anyhow!("directory {app_name} does not exist"));
} }
eprintln!("found app {app_name}"); note!("found app {app_name}");
let management_dir = app_dir.join("management"); let management_dir = app_dir.join("management");
let not_management_exists = !management_dir.exists(); let not_management_exists = !management_dir.exists();
if not_management_exists { if not_management_exists {
create_dir(&management_dir)?; create_dir(ctx, &management_dir)?;
create_file(management_dir.join("__init__.py"))?; touch(ctx, &management_dir.join("__init__.py"))?;
eprintln!("created module {app_name}.management") note!("created module {app_name}.management")
}; };
let commands_dir = management_dir.join("commands"); let commands_dir = management_dir.join("commands");
let not_commands_exists = !commands_dir.exists(); let not_commands_exists = !commands_dir.exists();
if not_commands_exists { if not_commands_exists {
create_dir(&commands_dir)?; create_dir(ctx, &commands_dir)?;
create_file(commands_dir.join("__init__.py"))?; touch(ctx, &commands_dir.join("__init__.py"))?;
eprintln!("created module {app_name}.management.commands") note!("created module {app_name}.management.commands")
}; };
let mut file = safe_create_file(commands_dir.join(format!("{name}.py")))?; write_new(
file.write_all(DEBUG_TEMPLATE.as_bytes())?; ctx,
&commands_dir.join(format!("{name}.py")),
DEBUG_TEMPLATE.as_bytes(),
)?;
eprintln!("created command {app_name}.management.commands.{name}"); note!("created command {app_name}.management.commands.{name}");
Ok(()) Ok(())
} }
@@ -89,17 +92,3 @@ pub fn test(ctx: &Ctx) -> Result<()> {
fn service(ctx: &Ctx) -> Result<String> { fn service(ctx: &Ctx) -> Result<String> {
Compose::resolve(ctx)?.django() Compose::resolve(ctx)?.django()
} }
fn safe_create_file(path: PathBuf) -> Result<File, std::io::Error> {
OpenOptions::new().write(true).create_new(true).open(path)
}
// truncate(false) keeps an existing file's contents, which is what callers creating
// an empty __init__.py want
fn create_file(path: PathBuf) -> Result<File, std::io::Error> {
OpenOptions::new()
.write(true)
.create(true)
.truncate(false)
.open(path)
}

View File

@@ -2,7 +2,6 @@ use std::cell::Cell;
use std::env; use std::env;
use std::ffi::OsString; use std::ffi::OsString;
use std::fs; use std::fs;
use std::os::unix::fs::symlink;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::process::ExitCode; use std::process::ExitCode;
@@ -11,6 +10,8 @@ use anyhow::{Context, Result, anyhow, bail};
use crate::cmd::{CheckIgnore, Cmd, ConfigGet, LsFiles, RevParse}; use crate::cmd::{CheckIgnore, Cmd, ConfigGet, LsFiles, RevParse};
use crate::ctx::Ctx; use crate::ctx::Ctx;
use crate::fsops::{move_path, place_link, prune_empty, remove_file, rename, suffixed};
use crate::output::{note, warning};
const BACKUP_SUFFIX: &str = ".ahab-bak"; const BACKUP_SUFFIX: &str = ".ahab-bak";
const LOCAL_NAMESPACE: &str = "_local"; const LOCAL_NAMESPACE: &str = "_local";
@@ -27,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) {
eprintln!("error: {e:#}"); note!("error: {e:#}");
failed += 1; failed += 1;
} }
} }
@@ -51,18 +52,18 @@ pub fn restore(ctx: &Ctx, paths: &[PathBuf], all: bool, store: Option<&Path>) ->
}; };
if paths.is_empty() { if paths.is_empty() {
eprintln!("nothing in the store for this repository"); note!("nothing in the store for this repository");
return Ok(()); return Ok(());
} }
if let [path] = paths.as_slice() { if let [path] = paths.as_slice() {
return restore_one(&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 &paths {
if let Err(e) = restore_one(&repo, path, &report) { if let Err(e) = restore_one(ctx, &repo, path, &report) {
eprintln!("error: {e:#}"); note!("error: {e:#}");
failed += 1; failed += 1;
} }
} }
@@ -73,7 +74,7 @@ pub fn restore(ctx: &Ctx, paths: &[PathBuf], all: bool, store: Option<&Path>) ->
Ok(()) Ok(())
} }
fn restore_one(repo: &Repo, path: &Path, report: &Report) -> Result<()> { fn restore_one(ctx: &Ctx, repo: &Repo, path: &Path, report: &Report) -> Result<()> {
let src = resolve(path)?; let src = resolve(path)?;
let rel = repo.relative(&src)?; let rel = repo.relative(&src)?;
let stored = repo.store.join(&rel); let stored = repo.store.join(&rel);
@@ -100,9 +101,9 @@ fn restore_one(repo: &Repo, path: &Path, report: &Report) -> Result<()> {
bail!("{} is missing from the store", rel.display()); bail!("{} is missing from the store", rel.display());
} }
fs::remove_file(&src).with_context(|| format!("removing {}", src.display()))?; remove_file(ctx, &src)?;
move_path(&stored, &src)?; move_path(ctx, &stored, &src)?;
prune_empty(stored.parent(), &repo.base); prune_empty(ctx, stored.parent(), &repo.base);
report.line("restored", &rel); report.line("restored", &rel);
Ok(()) Ok(())
@@ -140,19 +141,6 @@ fn linked_paths(repo: &Repo, dir: &Path) -> Result<Vec<PathBuf>> {
Ok(found) Ok(found)
} }
// a restored path can leave the store holding nothing but empty directories
fn prune_empty(dir: Option<&Path>, stop: &Path) {
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();
}
}
struct Report { struct Report {
store: PathBuf, store: PathBuf,
named: Cell<bool>, named: Cell<bool>,
@@ -176,10 +164,6 @@ impl Report {
} }
} }
fn warn(msg: impl std::fmt::Display) {
eprintln!("warning: {msg}");
}
// list untracked paths not in the store, i.e. what a sandbox can still read // list untracked paths not in the store, i.e. what a sandbox can still read
pub fn check( pub fn check(
ctx: &Ctx, ctx: &Ctx,
@@ -479,7 +463,7 @@ fn link_one(ctx: &Ctx, repo: &Repo, path: &Path, force: bool, report: &Report) -
)); ));
} }
if !ignored(ctx, repo, &rel) { if !ignored(ctx, repo, &rel) {
warn(format!("{} is not gitignored", rel.display())); warning!("{} is not gitignored", rel.display());
} }
let src_meta = symlink_metadata_opt(&src)?; let src_meta = symlink_metadata_opt(&src)?;
@@ -505,14 +489,14 @@ fn link_one(ctx: &Ctx, repo: &Repo, path: &Path, force: bool, report: &Report) -
// nothing in the store to adopt, so the symlink itself moves out // nothing in the store to adopt, so the symlink itself moves out
if !target_taken { if !target_taken {
if !src.exists() { if !src.exists() {
warn(format!( warning!(
"{} is a broken symlink to {}", "{} is a broken symlink to {}",
rel.display(), rel.display(),
dest.display() dest.display()
)); );
} }
move_path(&src, &target)?; move_path(ctx, &src, &target)?;
place_link(&src, &target)?; place_link(ctx, &src, &target)?;
report.line("moved", &rel); report.line("moved", &rel);
return Ok(()); return Ok(());
} }
@@ -521,7 +505,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(&src, &target)?; place_link(ctx, &src, &target)?;
report.line("repointed", &rel); report.line("repointed", &rel);
Ok(()) Ok(())
} }
@@ -539,18 +523,17 @@ fn link_one(ctx: &Ctx, repo: &Repo, path: &Path, force: bool, report: &Report) -
)); ));
} }
fs::rename(&src, &backup) rename(ctx, &src, &backup)?;
.with_context(|| format!("renaming {} to {}", src.display(), backup.display()))?;
report.line("saved", &suffixed(&rel, BACKUP_SUFFIX)); report.line("saved", &suffixed(&rel, BACKUP_SUFFIX));
place_link(&src, &target)?; place_link(ctx, &src, &target)?;
report.line("linked", &rel); report.line("linked", &rel);
Ok(()) Ok(())
} }
Some(_) => { Some(_) => {
move_path(&src, &target)?; move_path(ctx, &src, &target)?;
place_link(&src, &target)?; place_link(ctx, &src, &target)?;
report.line("moved", &rel); report.line("moved", &rel);
Ok(()) Ok(())
} }
@@ -559,7 +542,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(&src, &target)?; place_link(ctx, &src, &target)?;
report.line("linked", &rel); report.line("linked", &rel);
Ok(()) Ok(())
} }
@@ -612,7 +595,7 @@ 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);
} }
eprintln!("could not parse git remote `{url}`, falling back to the checkout name"); note!("could not parse git remote `{url}`, falling back to the checkout name");
} }
let name = root let name = root
@@ -715,93 +698,6 @@ fn symlink_metadata_opt(path: &Path) -> Result<Option<fs::Metadata>> {
} }
} }
fn move_path(src: &Path, target: &Path) -> Result<()> {
ensure_parent(target)?;
// rename cannot cross filesystems, and the store often is another one
match fs::rename(src, target) {
Ok(()) => Ok(()),
Err(rename_err) => match copy_recursive(src, target) {
Ok(()) => remove_recursive(src),
Err(copy_err) => Err(copy_err).with_context(|| {
format!(
"moving {} to {} (rename failed: {rename_err})",
src.display(),
target.display()
)
}),
},
}
}
fn copy_recursive(src: &Path, target: &Path) -> Result<()> {
let meta =
fs::symlink_metadata(src).with_context(|| format!("inspecting {}", src.display()))?;
if meta.is_dir() {
fs::create_dir_all(target)
.with_context(|| format!("creating directory {}", target.display()))?;
for entry in
fs::read_dir(src).with_context(|| format!("reading directory {}", src.display()))?
{
let entry = entry?;
copy_recursive(&entry.path(), &target.join(entry.file_name()))?;
}
return Ok(());
}
if meta.is_symlink() {
let dest = fs::read_link(src)?;
return symlink(&dest, target)
.with_context(|| format!("creating symlink {}", target.display()));
}
fs::copy(src, target)
.with_context(|| format!("copying {} to {}", src.display(), target.display()))?;
Ok(())
}
fn remove_recursive(path: &Path) -> Result<()> {
let meta =
fs::symlink_metadata(path).with_context(|| format!("inspecting {}", path.display()))?;
if meta.is_dir() {
fs::remove_dir_all(path)
} else {
fs::remove_file(path)
}
.with_context(|| format!("removing {}", path.display()))
}
fn ensure_parent(target: &Path) -> Result<()> {
if let Some(parent) = target.parent() {
if !parent.as_os_str().is_empty() {
fs::create_dir_all(parent)
.with_context(|| format!("creating directory {}", parent.display()))?;
}
}
Ok(())
}
fn place_link(link_path: &Path, target: &Path) -> Result<()> {
// symlink under a temp name and rename over the path: the rename is atomic
let tmp = suffixed(link_path, ".ahab-tmp");
let _ = fs::remove_file(&tmp);
symlink(target, &tmp)
.with_context(|| format!("creating symlink {} -> {}", tmp.display(), target.display()))?;
fs::rename(&tmp, link_path)
.with_context(|| format!("replacing {} with a symlink", link_path.display()))?;
Ok(())
}
fn suffixed(path: &Path, suffix: &str) -> PathBuf {
let mut out = path.as_os_str().to_owned();
out.push(suffix);
PathBuf::from(out)
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::{components_from_remote, sanitize}; use super::{components_from_remote, sanitize};

View File

@@ -1,8 +1,8 @@
use anyhow::{Context, Result, anyhow, bail}; use anyhow::{Context, Result, anyhow, bail};
use std::{ use std::{
fs::{self, File}, fs::File,
io::{self, Read, Write}, io::{self, Read, Write},
path::{Path, PathBuf}, path::Path,
process::Stdio, process::Stdio,
thread, thread,
time::{Duration, Instant}, time::{Duration, Instant},
@@ -15,6 +15,8 @@ use crate::cmd::{
}; };
use crate::compose::Compose; use crate::compose::Compose;
use crate::ctx::Ctx; use crate::ctx::Ctx;
use crate::fsops::{remove_file, rename, suffixed};
use crate::output::note;
const CUSTOM_MAGIC: &[u8] = b"PGDMP"; const CUSTOM_MAGIC: &[u8] = b"PGDMP";
const TAR_MAGIC: &[u8] = b"toc.dat"; const TAR_MAGIC: &[u8] = b"toc.dat";
@@ -142,11 +144,11 @@ fn restore_cluster(ctx: &Ctx, db: &Database, restore: &dyn Cmd, file: &Path) ->
continue; continue;
} }
eprintln!("{line}"); note!("{line}");
} }
if existing > 0 { if existing > 0 {
eprintln!( note!(
"left {existing} existing role{} alone", "left {existing} existing role{} alone",
if existing == 1 { "" } else { "s" } if existing == 1 { "" } else { "s" }
); );
@@ -159,13 +161,6 @@ fn restore_cluster(ctx: &Ctx, db: &Database, restore: &dyn Cmd, file: &Path) ->
Ok(()) Ok(())
} }
fn suffixed(path: &Path, suffix: &str) -> PathBuf {
let mut out = path.as_os_str().to_owned();
out.push(suffix);
PathBuf::from(out)
}
fn read_header(path: &Path) -> Result<Vec<u8>> { fn read_header(path: &Path) -> Result<Vec<u8>> {
let mut file = File::open(path).with_context(|| format!("opening {}", path.display()))?; let mut file = File::open(path).with_context(|| format!("opening {}", path.display()))?;
let mut header = vec![0; HEADER_LEN]; let mut header = vec![0; HEADER_LEN];
@@ -218,10 +213,10 @@ 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)?;
eprintln!("stopping all containers"); note!("stopping all containers");
Stop.run(ctx)?; Stop.run(ctx)?;
eprintln!("starting db container"); note!("starting db container");
Start::service(&db.service).run(ctx)?; Start::service(&db.service).run(ctx)?;
let remote = remote_dump(); let remote = remote_dump();
@@ -271,7 +266,7 @@ pub fn import(ctx: &Ctx, file: &Path) -> Result<()> {
}; };
let tool = kind.tool(); let tool = kind.tool();
eprintln!("restoring database with {tool}"); note!("restoring database with {tool}");
when_ready( when_ready(
ctx, ctx,
@@ -299,7 +294,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 {
eprintln!("would restore with {tool}"); note!("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
@@ -341,14 +336,14 @@ 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);
} }
eprintln!("restarting containers"); note!("restarting containers");
Stop.run(ctx)?; Stop.run(ctx)?;
Up.run(ctx)?; Up.run(ctx)?;
Ok(()) Ok(())
} }
pub fn dump(ctx: &Ctx, file: &PathBuf, 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)?;
if format == Format::Directory { if format == Format::Directory {
@@ -359,7 +354,7 @@ pub fn dump(ctx: &Ctx, file: &PathBuf, format: Format, gzip: bool) -> Result<()>
return dump_directory(ctx, &db, file); return dump_directory(ctx, &db, file);
} }
eprintln!("dumping to local file {}", file.to_string_lossy()); note!("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
@@ -385,16 +380,11 @@ pub fn dump(ctx: &Ctx, file: &PathBuf, format: Format, gzip: bool) -> Result<()>
}; };
if let Err(e) = dumped { if let Err(e) = dumped {
let _ = fs::remove_file(&partial); let _ = remove_file(ctx, &partial);
return Err(e); return Err(e);
} }
if ctx.dry_run { rename(ctx, &partial, file)?;
return Ok(());
}
fs::rename(&partial, file)
.with_context(|| format!("renaming {} to {}", partial.display(), file.display()))?;
Ok(()) Ok(())
} }
@@ -417,7 +407,7 @@ fn dump_directory(ctx: &Ctx, db: &Database, target: &Path) -> Result<()> {
); );
} }
eprintln!("dumping to local directory {}", target.display()); note!("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")