merge: code reorg
This commit is contained in:
23
Cargo.lock
generated
23
Cargo.lock
generated
@@ -9,7 +9,9 @@ dependencies = [
|
|||||||
"anyhow",
|
"anyhow",
|
||||||
"clap",
|
"clap",
|
||||||
"clap_complete",
|
"clap_complete",
|
||||||
|
"fs-err",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
"shlex",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -68,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"
|
||||||
@@ -123,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"
|
||||||
@@ -213,6 +230,12 @@ dependencies = [
|
|||||||
"zmij",
|
"zmij",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "shlex"
|
||||||
|
version = "2.0.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "strsim"
|
name = "strsim"
|
||||||
version = "0.11.1"
|
version = "0.11.1"
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ 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"
|
||||||
|
|
||||||
[build-dependencies]
|
[build-dependencies]
|
||||||
clap = { version = "4.6.6", features = ["derive", "env"] }
|
clap = { version = "4.6.6", features = ["derive", "env"] }
|
||||||
|
|||||||
@@ -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
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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, Store};
|
pub use link::Link;
|
||||||
pub use postgres::{Format, Postgres};
|
pub use postgres::{Format, Postgres};
|
||||||
|
|||||||
206
src/cmd/argv.rs
Normal file
206
src/cmd/argv.rs
Normal file
@@ -0,0 +1,206 @@
|
|||||||
|
use std::{
|
||||||
|
fmt::Display,
|
||||||
|
fs::File,
|
||||||
|
os::unix::process::CommandExt,
|
||||||
|
path::Path,
|
||||||
|
process::{Command, ExitStatus, Output, Stdio},
|
||||||
|
};
|
||||||
|
|
||||||
|
use anyhow::{Context, Result, anyhow};
|
||||||
|
|
||||||
|
use crate::ctx::Ctx;
|
||||||
|
|
||||||
|
// a program and its arguments: the only thing in ahab that knows what argv looks like
|
||||||
|
#[derive(Default, Clone)]
|
||||||
|
pub struct Argv(Vec<String>);
|
||||||
|
|
||||||
|
impl Display for Argv {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
f.write_str(&self.quoted())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Argv {
|
||||||
|
pub fn new(program: &str) -> Self {
|
||||||
|
Self(vec![program.to_string()])
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn arg(mut self, arg: impl AsRef<str>) -> Self {
|
||||||
|
self.0.push(arg.as_ref().to_string());
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn args<I, S>(mut self, args: I) -> Self
|
||||||
|
where
|
||||||
|
I: IntoIterator<Item = S>,
|
||||||
|
S: AsRef<str>,
|
||||||
|
{
|
||||||
|
self.0
|
||||||
|
.extend(args.into_iter().map(|arg| arg.as_ref().to_string()));
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
// a long option and the value it takes
|
||||||
|
pub fn flag(self, name: &str, value: impl AsRef<str>) -> Self {
|
||||||
|
self.arg(name).arg(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn program(&self) -> &str {
|
||||||
|
self.0.first().map(String::as_str).unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn words(&self) -> &[String] {
|
||||||
|
&self.0
|
||||||
|
}
|
||||||
|
|
||||||
|
// one word list for `sh -c`, quoted so a value with a space survives the shell
|
||||||
|
pub fn quoted(&self) -> String {
|
||||||
|
// a nul byte is the only thing shlex refuses, and no argv can hold one
|
||||||
|
shlex::try_join(self.0.iter().map(String::as_str)).unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn run(&self, ctx: &Ctx) -> Result<()> {
|
||||||
|
if self.skipped(ctx) {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let status = self.command(ctx)?.spawn()?.wait()?;
|
||||||
|
|
||||||
|
self.check(status)
|
||||||
|
}
|
||||||
|
|
||||||
|
// reading changes nothing, so a dry run answers the question for real
|
||||||
|
pub fn capture(&self, ctx: &Ctx) -> Result<String> {
|
||||||
|
let out = self.command(ctx)?.output()?;
|
||||||
|
|
||||||
|
if !out.status.success() {
|
||||||
|
// output() holds stderr back, so the command's own complaint has to be
|
||||||
|
// passed on here or it is lost
|
||||||
|
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||||
|
|
||||||
|
return Err(match stderr.trim() {
|
||||||
|
"" => self.failure(out.status),
|
||||||
|
reason => anyhow!("`{self}` failed: {reason}"),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(String::from_utf8(out.stdout)?)
|
||||||
|
}
|
||||||
|
|
||||||
|
// replaces this process, so the command's exit code and signals become ours
|
||||||
|
pub fn replace(&self, ctx: &Ctx) -> Result<()> {
|
||||||
|
if self.skipped(ctx) {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let error = self.command(ctx)?.exec();
|
||||||
|
|
||||||
|
Err(error).with_context(|| format!("running `{self}`"))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn stream_to(&self, ctx: &Ctx, out: Stdio) -> Result<()> {
|
||||||
|
if self.skipped(ctx) {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let status = self.command(ctx)?.stdout(out).spawn()?.wait()?;
|
||||||
|
|
||||||
|
self.check(status)
|
||||||
|
}
|
||||||
|
|
||||||
|
// the status rather than an error, for callers with something better to say
|
||||||
|
pub fn status(&self, ctx: &Ctx) -> Result<ExitStatus> {
|
||||||
|
Ok(self.command(ctx)?.spawn()?.wait()?)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn stdin_from(&self, ctx: &Ctx, input: &Path) -> Result<ExitStatus> {
|
||||||
|
let stdin = self.opened(input)?;
|
||||||
|
|
||||||
|
Ok(self.command(ctx)?.stdin(stdin).spawn()?.wait()?)
|
||||||
|
}
|
||||||
|
|
||||||
|
// both streams held back, for callers that read the command's complaints
|
||||||
|
pub fn stdin_from_captured(&self, ctx: &Ctx, input: &Path) -> Result<Output> {
|
||||||
|
let stdin = self.opened(input)?;
|
||||||
|
|
||||||
|
Ok(self.command(ctx)?.stdin(stdin).output()?)
|
||||||
|
}
|
||||||
|
|
||||||
|
// whether it succeeded, without failure being an error
|
||||||
|
pub fn quietly_succeeds(&self, ctx: &Ctx) -> Result<bool> {
|
||||||
|
Ok(self
|
||||||
|
.command(ctx)?
|
||||||
|
.stdout(Stdio::null())
|
||||||
|
.spawn()?
|
||||||
|
.wait()?
|
||||||
|
.success())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn command(&self, ctx: &Ctx) -> Result<Command> {
|
||||||
|
if ctx.verbose {
|
||||||
|
eprintln!("running `{self}`");
|
||||||
|
}
|
||||||
|
|
||||||
|
let (program, rest) = self.0.split_first().context("empty command")?;
|
||||||
|
let mut command = Command::new(program);
|
||||||
|
command.args(rest);
|
||||||
|
|
||||||
|
Ok(command)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn skipped(&self, ctx: &Ctx) -> bool {
|
||||||
|
if ctx.dry_run {
|
||||||
|
eprintln!("would run `{self}`");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
fn opened(&self, path: &Path) -> Result<Stdio> {
|
||||||
|
let file = File::open(path).with_context(|| format!("opening {}", path.display()))?;
|
||||||
|
|
||||||
|
Ok(Stdio::from(file))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn check(&self, status: ExitStatus) -> Result<()> {
|
||||||
|
if status.success() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
Err(self.failure(status))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn failure(&self, status: ExitStatus) -> anyhow::Error {
|
||||||
|
match status.code() {
|
||||||
|
Some(code) => anyhow!("`{self}` exited with {code}"),
|
||||||
|
None => anyhow!("`{self}` was killed by a signal"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::Argv;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn plain_words_are_left_as_they_are() {
|
||||||
|
let argv = Argv::new("pg_dump")
|
||||||
|
.flag("--username", "myproject")
|
||||||
|
.arg("--data-only")
|
||||||
|
.arg("myproject_db");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
argv.quoted(),
|
||||||
|
"pg_dump --username myproject --data-only myproject_db"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn anything_a_shell_would_read_as_more_than_one_word_is_quoted() {
|
||||||
|
assert_eq!(Argv::new("psql").arg("my db").quoted(), "psql 'my db'");
|
||||||
|
assert_eq!(Argv::new("sh").arg("a | b").quoted(), "sh 'a | b'");
|
||||||
|
assert_eq!(Argv::new("echo").arg("").quoted(), "echo ''");
|
||||||
|
assert_eq!(Argv::new("echo").arg("it's").quoted(), r#"echo "it's""#);
|
||||||
|
}
|
||||||
|
}
|
||||||
92
src/cmd/compose.rs
Normal file
92
src/cmd/compose.rs
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
use super::{Argv, Cmd};
|
||||||
|
|
||||||
|
fn compose() -> Argv {
|
||||||
|
Argv::new("docker").arg("compose")
|
||||||
|
}
|
||||||
|
|
||||||
|
// docker compose run --rm, which runs the image's entrypoint and so fixes up the
|
||||||
|
// container user before handing over
|
||||||
|
pub struct Run {
|
||||||
|
service: String,
|
||||||
|
inner: Argv,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Run {
|
||||||
|
pub fn wrapping(service: &str, inner: Argv) -> Self {
|
||||||
|
Self {
|
||||||
|
service: service.to_string(),
|
||||||
|
inner,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Cmd for Run {
|
||||||
|
fn argv(&self) -> Argv {
|
||||||
|
compose()
|
||||||
|
.arg("run")
|
||||||
|
.arg("--rm")
|
||||||
|
.arg(&self.service)
|
||||||
|
.args(self.inner.words())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// the merged project file, with every extends and env_file resolved
|
||||||
|
pub struct Config;
|
||||||
|
|
||||||
|
impl Cmd for Config {
|
||||||
|
fn argv(&self) -> Argv {
|
||||||
|
compose().arg("config").flag("--format", "json")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// the container id a service is running as, empty when it is not running
|
||||||
|
pub struct Ps {
|
||||||
|
service: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Ps {
|
||||||
|
pub fn id_of(service: &str) -> Self {
|
||||||
|
Self {
|
||||||
|
service: service.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Cmd for Ps {
|
||||||
|
fn argv(&self) -> Argv {
|
||||||
|
compose().arg("ps").arg("--quiet").arg(&self.service)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct Up;
|
||||||
|
|
||||||
|
impl Cmd for Up {
|
||||||
|
fn argv(&self) -> Argv {
|
||||||
|
compose().arg("up").arg("--detach")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct Start {
|
||||||
|
service: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Start {
|
||||||
|
pub fn service(service: &str) -> Self {
|
||||||
|
Self {
|
||||||
|
service: service.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Cmd for Start {
|
||||||
|
fn argv(&self) -> Argv {
|
||||||
|
compose().arg("start").arg(&self.service)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pub struct Stop;
|
||||||
|
|
||||||
|
impl Cmd for Stop {
|
||||||
|
fn argv(&self) -> Argv {
|
||||||
|
compose().arg("stop")
|
||||||
|
}
|
||||||
|
}
|
||||||
44
src/cmd/django.rs
Normal file
44
src/cmd/django.rs
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
use super::{Argv, Cmd};
|
||||||
|
|
||||||
|
// an interactive shell in the django container
|
||||||
|
pub struct Bash;
|
||||||
|
|
||||||
|
impl Cmd for Bash {
|
||||||
|
fn argv(&self) -> Argv {
|
||||||
|
Argv::new("bash")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// django's manage.py, with the subcommand and its arguments
|
||||||
|
pub struct Manage<'a> {
|
||||||
|
args: &'a [String],
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> Manage<'a> {
|
||||||
|
pub fn new(args: &'a [String]) -> Self {
|
||||||
|
Self { args }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Cmd for Manage<'_> {
|
||||||
|
fn argv(&self) -> Argv {
|
||||||
|
Argv::new("python").arg("manage.py").args(self.args)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// whatever the caller typed, passed through as it stands
|
||||||
|
pub struct Words<'a> {
|
||||||
|
words: &'a [String],
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> Words<'a> {
|
||||||
|
pub fn new(words: &'a [String]) -> Self {
|
||||||
|
Self { words }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Cmd for Words<'_> {
|
||||||
|
fn argv(&self) -> Argv {
|
||||||
|
Argv::default().args(self.words)
|
||||||
|
}
|
||||||
|
}
|
||||||
69
src/cmd/docker.rs
Normal file
69
src/cmd/docker.rs
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
use super::{Argv, Cmd};
|
||||||
|
|
||||||
|
// docker exec, around a command that runs inside the container
|
||||||
|
pub struct Exec {
|
||||||
|
container: String,
|
||||||
|
interactive: bool,
|
||||||
|
inner: Argv,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Exec {
|
||||||
|
pub fn wrapping(container: &str, inner: Argv) -> Self {
|
||||||
|
Self {
|
||||||
|
container: container.to_string(),
|
||||||
|
interactive: false,
|
||||||
|
inner,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// keep stdin open, for a command that is fed a dump
|
||||||
|
pub fn interactive(mut self) -> Self {
|
||||||
|
self.interactive = true;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Cmd for Exec {
|
||||||
|
fn argv(&self) -> Argv {
|
||||||
|
let mut argv = Argv::new("docker").arg("exec");
|
||||||
|
|
||||||
|
if self.interactive {
|
||||||
|
argv = argv.arg("--interactive");
|
||||||
|
}
|
||||||
|
|
||||||
|
argv.arg(&self.container).args(self.inner.words())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// docker cp, in either direction
|
||||||
|
pub struct Cp {
|
||||||
|
from: String,
|
||||||
|
to: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Cp {
|
||||||
|
pub fn into_container(local: &str, container: &str, remote: &str) -> Self {
|
||||||
|
Self {
|
||||||
|
from: local.to_string(),
|
||||||
|
to: format!("{container}:{remote}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn out_of_container(container: &str, remote: &str, local: &str) -> Self {
|
||||||
|
Self {
|
||||||
|
from: format!("{container}:{remote}"),
|
||||||
|
to: local.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Cmd for Cp {
|
||||||
|
fn argv(&self) -> Argv {
|
||||||
|
// -L has no long form; it copies what a symlink points at
|
||||||
|
Argv::new("docker")
|
||||||
|
.arg("cp")
|
||||||
|
.arg("-L")
|
||||||
|
.arg(&self.from)
|
||||||
|
.arg(&self.to)
|
||||||
|
}
|
||||||
|
}
|
||||||
156
src/cmd/git.rs
Normal file
156
src/cmd/git.rs
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
use super::{Argv, Cmd};
|
||||||
|
|
||||||
|
// -C has no long form; it runs git as though from that directory
|
||||||
|
fn git(root: &Path) -> Argv {
|
||||||
|
Argv::new("git").arg("-C").arg(root.to_string_lossy())
|
||||||
|
}
|
||||||
|
|
||||||
|
// the root of the repository the working directory is in
|
||||||
|
pub struct RevParse;
|
||||||
|
|
||||||
|
impl Cmd for RevParse {
|
||||||
|
fn argv(&self) -> Argv {
|
||||||
|
Argv::new("git").arg("rev-parse").arg("--show-toplevel")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// the url a remote points at, if the repository has one
|
||||||
|
pub struct ConfigGet<'a> {
|
||||||
|
pub key: &'a str,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Cmd for ConfigGet<'_> {
|
||||||
|
fn argv(&self) -> Argv {
|
||||||
|
Argv::new("git").arg("config").flag("--get", self.key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct LsFiles<'a> {
|
||||||
|
root: &'a Path,
|
||||||
|
tracked: bool,
|
||||||
|
ignored: bool,
|
||||||
|
pathspecs: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> LsFiles<'a> {
|
||||||
|
// paths git would not restore: untracked ones, and whole directories rather
|
||||||
|
// than every file inside them
|
||||||
|
pub fn untracked(root: &'a Path) -> Self {
|
||||||
|
Self {
|
||||||
|
root,
|
||||||
|
tracked: false,
|
||||||
|
ignored: false,
|
||||||
|
pathspecs: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn tracked(root: &'a Path) -> Self {
|
||||||
|
Self {
|
||||||
|
root,
|
||||||
|
tracked: true,
|
||||||
|
ignored: false,
|
||||||
|
pathspecs: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// the ignored paths instead of the merely untracked ones
|
||||||
|
pub fn ignored(mut self) -> Self {
|
||||||
|
self.ignored = true;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn limited_to<P: AsRef<Path>>(mut self, pathspecs: &[P]) -> Self {
|
||||||
|
self.pathspecs = pathspecs
|
||||||
|
.iter()
|
||||||
|
.map(|path| path.as_ref().to_string_lossy().to_string())
|
||||||
|
.collect();
|
||||||
|
self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Cmd for LsFiles<'_> {
|
||||||
|
fn argv(&self) -> Argv {
|
||||||
|
let mut argv = git(self.root).arg("ls-files");
|
||||||
|
|
||||||
|
if self.tracked {
|
||||||
|
argv = argv.arg("--cached");
|
||||||
|
} else {
|
||||||
|
// -z has no long form; it separates the paths with NUL, which is the
|
||||||
|
// only separator a filename cannot contain
|
||||||
|
argv = argv
|
||||||
|
.arg("-z")
|
||||||
|
.arg("--others")
|
||||||
|
.arg("--exclude-standard")
|
||||||
|
.arg("--directory")
|
||||||
|
.arg("--no-empty-directory");
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.ignored {
|
||||||
|
argv = argv.arg("--ignored");
|
||||||
|
}
|
||||||
|
|
||||||
|
argv.arg("--").args(&self.pathspecs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// whether a path is ignored, said through the exit code alone
|
||||||
|
pub struct CheckIgnore<'a> {
|
||||||
|
root: &'a Path,
|
||||||
|
path: &'a Path,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> CheckIgnore<'a> {
|
||||||
|
pub fn new(root: &'a Path, path: &'a Path) -> Self {
|
||||||
|
Self { root, path }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Cmd for CheckIgnore<'_> {
|
||||||
|
fn argv(&self) -> Argv {
|
||||||
|
git(self.root)
|
||||||
|
.arg("check-ignore")
|
||||||
|
.arg("--quiet")
|
||||||
|
.arg("--")
|
||||||
|
.arg(self.path.to_string_lossy())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use super::{CheckIgnore, LsFiles};
|
||||||
|
use crate::cmd::Cmd;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_untracked_listing_asks_for_whole_directories() {
|
||||||
|
let argv = LsFiles::untracked(Path::new("/repo"))
|
||||||
|
.ignored()
|
||||||
|
.limited_to(&[PathBuf::from("a b")])
|
||||||
|
.argv();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
argv.quoted(),
|
||||||
|
"git -C /repo ls-files -z --others --exclude-standard --directory \
|
||||||
|
--no-empty-directory --ignored -- 'a b'"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_tracked_listing_asks_git_only_about_the_paths_given() {
|
||||||
|
let argv = LsFiles::tracked(Path::new("/repo"))
|
||||||
|
.limited_to(&[PathBuf::from(".env")])
|
||||||
|
.argv();
|
||||||
|
|
||||||
|
assert_eq!(argv.quoted(), "git -C /repo ls-files --cached -- .env");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn check_ignore_says_nothing_and_reports_through_its_status() {
|
||||||
|
let argv = CheckIgnore::new(Path::new("/repo"), Path::new(".env")).argv();
|
||||||
|
|
||||||
|
assert_eq!(argv.quoted(), "git -C /repo check-ignore --quiet -- .env");
|
||||||
|
}
|
||||||
|
}
|
||||||
82
src/cmd/mod.rs
Normal file
82
src/cmd/mod.rs
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
mod argv;
|
||||||
|
mod compose;
|
||||||
|
mod django;
|
||||||
|
mod docker;
|
||||||
|
mod git;
|
||||||
|
mod postgres;
|
||||||
|
mod shell;
|
||||||
|
|
||||||
|
use std::{
|
||||||
|
path::Path,
|
||||||
|
process::{ExitStatus, Output, Stdio},
|
||||||
|
};
|
||||||
|
|
||||||
|
use anyhow::Result;
|
||||||
|
|
||||||
|
pub use argv::Argv;
|
||||||
|
pub use compose::{Config, Ps, Run, Start, Stop, Up};
|
||||||
|
pub use django::{Bash, Manage, Words};
|
||||||
|
pub use docker::{Cp, Exec};
|
||||||
|
pub use git::{CheckIgnore, ConfigGet, LsFiles, RevParse};
|
||||||
|
pub use postgres::{CreateDb, DropDb, PgDump, PgDumpAll, PgIsReady, PgRestore, Psql};
|
||||||
|
pub use shell::{Gunzip, Gzip, Head, Pipeline, Rm};
|
||||||
|
|
||||||
|
use crate::ctx::Ctx;
|
||||||
|
|
||||||
|
// every command ahab runs, from `docker compose config` to `pg_dump`, is a type
|
||||||
|
// that renders itself here; adapters wrap one command in another and terminals
|
||||||
|
// run it, so a call site reads as one chain
|
||||||
|
pub trait Cmd {
|
||||||
|
fn argv(&self) -> Argv;
|
||||||
|
|
||||||
|
// this command as one word list, for a shell to read
|
||||||
|
fn shell(&self) -> String {
|
||||||
|
self.argv().quoted()
|
||||||
|
}
|
||||||
|
|
||||||
|
// adapters
|
||||||
|
fn pipe(&self, next: &dyn Cmd) -> Pipeline {
|
||||||
|
Pipeline::starting(self.shell()).pipe(next)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn in_container(&self, container: &str) -> Exec {
|
||||||
|
Exec::wrapping(container, self.argv())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn in_service(&self, service: &str) -> Run {
|
||||||
|
Run::wrapping(service, self.argv())
|
||||||
|
}
|
||||||
|
// terminals
|
||||||
|
fn run(&self, ctx: &Ctx) -> Result<()> {
|
||||||
|
self.argv().run(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn capture(&self, ctx: &Ctx) -> Result<String> {
|
||||||
|
self.argv().capture(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn replace(&self, ctx: &Ctx) -> Result<()> {
|
||||||
|
self.argv().replace(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn stream_to(&self, ctx: &Ctx, out: Stdio) -> Result<()> {
|
||||||
|
self.argv().stream_to(ctx, out)
|
||||||
|
}
|
||||||
|
|
||||||
|
// the status rather than an error, for callers with something better to say
|
||||||
|
fn status(&self, ctx: &Ctx) -> Result<ExitStatus> {
|
||||||
|
self.argv().status(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn stdin_from(&self, ctx: &Ctx, input: &Path) -> Result<ExitStatus> {
|
||||||
|
self.argv().stdin_from(ctx, input)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn stdin_from_captured(&self, ctx: &Ctx, input: &Path) -> Result<Output> {
|
||||||
|
self.argv().stdin_from_captured(ctx, input)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn quietly_succeeds(&self, ctx: &Ctx) -> Result<bool> {
|
||||||
|
self.argv().quietly_succeeds(ctx)
|
||||||
|
}
|
||||||
|
}
|
||||||
206
src/cmd/postgres.rs
Normal file
206
src/cmd/postgres.rs
Normal file
@@ -0,0 +1,206 @@
|
|||||||
|
use super::{Argv, Cmd};
|
||||||
|
|
||||||
|
// whether the server is accepting connections yet
|
||||||
|
pub struct PgIsReady<'a> {
|
||||||
|
pub username: &'a str,
|
||||||
|
pub dbname: &'a str,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Cmd for PgIsReady<'_> {
|
||||||
|
fn argv(&self) -> Argv {
|
||||||
|
Argv::new("pg_isready")
|
||||||
|
.flag("--username", self.username)
|
||||||
|
.flag("--dbname", self.dbname)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct DropDb<'a> {
|
||||||
|
pub username: &'a str,
|
||||||
|
pub dbname: &'a str,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Cmd for DropDb<'_> {
|
||||||
|
fn argv(&self) -> Argv {
|
||||||
|
Argv::new("dropdb")
|
||||||
|
.flag("--username", self.username)
|
||||||
|
.arg(self.dbname)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct CreateDb<'a> {
|
||||||
|
pub username: &'a str,
|
||||||
|
pub dbname: &'a str,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Cmd for CreateDb<'_> {
|
||||||
|
fn argv(&self) -> Argv {
|
||||||
|
// template0 rather than template1, so nothing the local cluster picked up
|
||||||
|
// ends up in a restored database
|
||||||
|
Argv::new("createdb")
|
||||||
|
.flag("--username", self.username)
|
||||||
|
.flag("--encoding", "utf8")
|
||||||
|
.flag("--template", "template0")
|
||||||
|
.arg(self.dbname)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// reads a custom, tar or directory format dump
|
||||||
|
pub struct PgRestore<'a> {
|
||||||
|
username: &'a str,
|
||||||
|
dbname: &'a str,
|
||||||
|
from: Option<&'a str>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> PgRestore<'a> {
|
||||||
|
pub fn new(username: &'a str, dbname: &'a str) -> Self {
|
||||||
|
Self {
|
||||||
|
username,
|
||||||
|
dbname,
|
||||||
|
from: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// a path in the container, for a dump that could not be streamed in
|
||||||
|
pub fn from(mut self, path: &'a str) -> Self {
|
||||||
|
self.from = Some(path);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Cmd for PgRestore<'_> {
|
||||||
|
fn argv(&self) -> Argv {
|
||||||
|
let argv = Argv::new("pg_restore")
|
||||||
|
.flag("--username", self.username)
|
||||||
|
.flag("--dbname", self.dbname);
|
||||||
|
|
||||||
|
match self.from {
|
||||||
|
Some(path) => argv.arg(path),
|
||||||
|
None => argv,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// reads a plain sql dump, of one database or of a whole cluster
|
||||||
|
pub struct Psql<'a> {
|
||||||
|
username: &'a str,
|
||||||
|
dbname: &'a str,
|
||||||
|
atomic: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> Psql<'a> {
|
||||||
|
pub fn new(username: &'a str, dbname: &'a str) -> Self {
|
||||||
|
Self {
|
||||||
|
username,
|
||||||
|
dbname,
|
||||||
|
atomic: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Cmd for Psql<'_> {
|
||||||
|
fn argv(&self) -> Argv {
|
||||||
|
let argv = Argv::new("psql")
|
||||||
|
.arg("--quiet")
|
||||||
|
.flag("--output", "/dev/null")
|
||||||
|
.flag("--username", self.username)
|
||||||
|
.flag("--dbname", self.dbname);
|
||||||
|
|
||||||
|
if self.atomic {
|
||||||
|
return argv
|
||||||
|
.flag("--variable", "ON_ERROR_STOP=1")
|
||||||
|
.arg("--single-transaction");
|
||||||
|
}
|
||||||
|
|
||||||
|
argv
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct PgDump<'a> {
|
||||||
|
username: &'a str,
|
||||||
|
dbname: &'a str,
|
||||||
|
format: &'a str,
|
||||||
|
to: Option<&'a str>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> PgDump<'a> {
|
||||||
|
pub fn new(username: &'a str, dbname: &'a str, format: &'a str) -> Self {
|
||||||
|
Self {
|
||||||
|
username,
|
||||||
|
dbname,
|
||||||
|
format,
|
||||||
|
to: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// a path in the container, for the directory format, which pg_dump writes
|
||||||
|
// itself rather than to stdout
|
||||||
|
pub fn to(mut self, path: &'a str) -> Self {
|
||||||
|
self.to = Some(path);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Cmd for PgDump<'_> {
|
||||||
|
fn argv(&self) -> Argv {
|
||||||
|
let argv = Argv::new("pg_dump")
|
||||||
|
.flag("--username", self.username)
|
||||||
|
.flag("--format", self.format);
|
||||||
|
|
||||||
|
match self.to {
|
||||||
|
Some(path) => argv.flag("--file", path),
|
||||||
|
None => argv,
|
||||||
|
}
|
||||||
|
.arg(self.dbname)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// the whole cluster, roles and all
|
||||||
|
pub struct PgDumpAll<'a> {
|
||||||
|
pub username: &'a str,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Cmd for PgDumpAll<'_> {
|
||||||
|
fn argv(&self) -> Argv {
|
||||||
|
Argv::new("pg_dumpall").flag("--username", self.username)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::{PgDump, Psql};
|
||||||
|
use crate::cmd::Cmd;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_directory_dump_names_the_file_it_writes() {
|
||||||
|
assert_eq!(
|
||||||
|
PgDump::new("u", "db", "d").to("/tmp/dump").argv().quoted(),
|
||||||
|
"pg_dump --username u --format d --file /tmp/dump db"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
PgDump::new("u", "db", "c").argv().quoted(),
|
||||||
|
"pg_dump --username u --format c db"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn only_a_single_database_restore_stops_at_the_first_error() {
|
||||||
|
assert!(
|
||||||
|
Psql::new("u", "db")
|
||||||
|
.atomic()
|
||||||
|
.argv()
|
||||||
|
.quoted()
|
||||||
|
.ends_with("--variable 'ON_ERROR_STOP=1' --single-transaction")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
Psql::new("u", "postgres").argv().quoted(),
|
||||||
|
"psql --quiet --output /dev/null --username u --dbname postgres"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
150
src/cmd/shell.rs
Normal file
150
src/cmd/shell.rs
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
use super::{Argv, Cmd};
|
||||||
|
|
||||||
|
// sh -c, the only way to reach a shell feature inside a container
|
||||||
|
pub struct Sh(String);
|
||||||
|
|
||||||
|
impl Sh {
|
||||||
|
pub fn new(script: impl Into<String>) -> Self {
|
||||||
|
Self(script.into())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Cmd for Sh {
|
||||||
|
fn argv(&self) -> Argv {
|
||||||
|
// -c has no long form
|
||||||
|
Argv::new("sh").arg("-c").arg(&self.0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// commands joined by pipes, which only a shell can run
|
||||||
|
pub struct Pipeline {
|
||||||
|
stages: Vec<String>,
|
||||||
|
pipefail: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Pipeline {
|
||||||
|
pub fn starting(first: String) -> Self {
|
||||||
|
Self {
|
||||||
|
stages: vec![first],
|
||||||
|
pipefail: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn pipe(mut self, next: &dyn Cmd) -> Self {
|
||||||
|
self.stages.push(next.shell());
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
// for a pipeline whose last stage closes the pipe on purpose, where the
|
||||||
|
// SIGPIPE that kills an earlier stage is the expected end and not a failure
|
||||||
|
pub fn allow_early_close(mut self) -> Self {
|
||||||
|
self.pipefail = false;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
fn script(&self) -> String {
|
||||||
|
let piped = self.stages.join(" | ");
|
||||||
|
|
||||||
|
// a pipeline reports only its last stage's status, so a first stage that
|
||||||
|
// dies mid-stream reads as success without this
|
||||||
|
if self.pipefail {
|
||||||
|
format!("set -o pipefail; {piped}")
|
||||||
|
} else {
|
||||||
|
piped
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Cmd for Pipeline {
|
||||||
|
fn argv(&self) -> Argv {
|
||||||
|
Sh::new(self.script()).argv()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct Gzip;
|
||||||
|
|
||||||
|
impl Cmd for Gzip {
|
||||||
|
fn argv(&self) -> Argv {
|
||||||
|
Argv::new("gzip")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// busybox, which alpine based images ship, has no long options for these three
|
||||||
|
pub struct Gunzip;
|
||||||
|
|
||||||
|
impl Cmd for Gunzip {
|
||||||
|
fn argv(&self) -> Argv {
|
||||||
|
Argv::new("gunzip").arg("-c")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct Head {
|
||||||
|
bytes: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Head {
|
||||||
|
pub fn bytes(bytes: usize) -> Self {
|
||||||
|
Self { bytes }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Cmd for Head {
|
||||||
|
fn argv(&self) -> Argv {
|
||||||
|
Argv::new("head").arg("-c").arg(self.bytes.to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct Rm {
|
||||||
|
path: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Rm {
|
||||||
|
pub fn recursive(path: &str) -> Self {
|
||||||
|
Self {
|
||||||
|
path: path.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Cmd for Rm {
|
||||||
|
fn argv(&self) -> Argv {
|
||||||
|
Argv::new("rm").arg("-rf").arg(&self.path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::{Gunzip, Gzip, Head};
|
||||||
|
use crate::cmd::Cmd;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_pipeline_reports_a_stage_that_dies_mid_stream() {
|
||||||
|
let script = Gunzip.pipe(&Gzip).shell();
|
||||||
|
|
||||||
|
assert_eq!(script, "sh -c 'set -o pipefail; gunzip -c | gzip'");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_pipeline_that_closes_the_pipe_on_purpose_keeps_the_default() {
|
||||||
|
let script = Gunzip.pipe(&Head::bytes(512)).allow_early_close().shell();
|
||||||
|
|
||||||
|
assert_eq!(script, "sh -c 'gunzip -c | head -c 512'");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_pipeline_reaches_a_container_as_one_argument() {
|
||||||
|
let argv = Gunzip.pipe(&Gzip).in_container("abc123").argv();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
argv.words(),
|
||||||
|
[
|
||||||
|
"docker",
|
||||||
|
"exec",
|
||||||
|
"abc123",
|
||||||
|
"sh",
|
||||||
|
"-c",
|
||||||
|
"set -o pipefail; gunzip -c | gzip"
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,146 +0,0 @@
|
|||||||
use anyhow::{Context, Result, bail};
|
|
||||||
use std::{
|
|
||||||
fmt::Display,
|
|
||||||
os::unix::process::CommandExt,
|
|
||||||
process::{Child, Command, ExitStatus, Stdio},
|
|
||||||
sync::OnceLock,
|
|
||||||
};
|
|
||||||
|
|
||||||
pub struct Args(Vec<String>);
|
|
||||||
|
|
||||||
impl From<&str> for Args {
|
|
||||||
fn from(value: &str) -> Self {
|
|
||||||
Self(Vec::from_iter(value.split_whitespace().map(String::from)))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<&[String]> for Args {
|
|
||||||
fn from(value: &[String]) -> Self {
|
|
||||||
Self(value.to_vec())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Default)]
|
|
||||||
pub struct CommandBuilder {
|
|
||||||
args: Vec<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Display for CommandBuilder {
|
|
||||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
||||||
f.write_str(&self.args.join(" "))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl CommandBuilder {
|
|
||||||
pub fn docker() -> Self {
|
|
||||||
Self::default().args("docker")
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn docker_compose() -> Self {
|
|
||||||
Self::default().args("docker compose")
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn arg(mut self, arg: impl AsRef<str>) -> Self {
|
|
||||||
self.args.push(arg.as_ref().to_string());
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn args<T>(mut self, args: T) -> Self
|
|
||||||
where
|
|
||||||
Args: From<T>,
|
|
||||||
{
|
|
||||||
self.args.extend(Args::from(args).0);
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn build(self) -> Result<Command> {
|
|
||||||
if options().verbose {
|
|
||||||
eprintln!("running `{self}`");
|
|
||||||
}
|
|
||||||
let (first, rest) = self.args.split_first().context("empty args")?;
|
|
||||||
let mut command = Command::new(first);
|
|
||||||
command.args(rest);
|
|
||||||
|
|
||||||
Ok(command)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn exec_get_stdout(self) -> Result<String> {
|
|
||||||
let shown = self.to_string();
|
|
||||||
let out = self.build()?.output()?;
|
|
||||||
|
|
||||||
check(&shown, out.status)?;
|
|
||||||
Ok(String::from_utf8(out.stdout)?)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn exec(self) -> Result<()> {
|
|
||||||
if options().dry_run {
|
|
||||||
eprintln!("would run `{self}`");
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
|
|
||||||
let shown = self.to_string();
|
|
||||||
let status = self.build()?.spawn()?.wait()?;
|
|
||||||
|
|
||||||
check(&shown, status)
|
|
||||||
}
|
|
||||||
|
|
||||||
// replaces this process, so the command's exit code and signals become ours
|
|
||||||
pub fn exec_replace(self) -> Result<()> {
|
|
||||||
if options().dry_run {
|
|
||||||
eprintln!("would run `{self}`");
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
|
|
||||||
let shown = self.to_string();
|
|
||||||
let error = self.build()?.exec();
|
|
||||||
|
|
||||||
Err(error).with_context(|| format!("running `{shown}`"))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn spawn(self) -> Result<Child> {
|
|
||||||
Ok(self.build()?.spawn()?)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn exec_redirect_stdout(self, stdio: Stdio) -> Result<()> {
|
|
||||||
if options().dry_run {
|
|
||||||
eprintln!("would run `{self}`");
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
|
|
||||||
let shown = self.to_string();
|
|
||||||
let status = self.build()?.stdout(stdio).spawn()?.wait()?;
|
|
||||||
|
|
||||||
check(&shown, status)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn check(command: &str, status: ExitStatus) -> Result<()> {
|
|
||||||
if status.success() {
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
|
|
||||||
match status.code() {
|
|
||||||
Some(code) => bail!("`{command}` exited with {code}"),
|
|
||||||
None => bail!("`{command}` was killed by a signal"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Default, Clone, Copy)]
|
|
||||||
pub struct Options {
|
|
||||||
pub verbose: bool,
|
|
||||||
pub dry_run: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
static OPTIONS: OnceLock<Options> = OnceLock::new();
|
|
||||||
|
|
||||||
pub fn set_options(options: Options) {
|
|
||||||
let _ = OPTIONS.set(options);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn options() -> Options {
|
|
||||||
OPTIONS.get().copied().unwrap_or_default()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn is_dry_run() -> bool {
|
|
||||||
options().dry_run
|
|
||||||
}
|
|
||||||
91
src/commands/django.rs
Normal file
91
src/commands/django.rs
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use anyhow::{Result, anyhow};
|
||||||
|
|
||||||
|
use crate::cmd::{Bash, Cmd, Manage, Words};
|
||||||
|
use crate::ctx::Ctx;
|
||||||
|
use crate::fsops::{create_dir, touch, write_new};
|
||||||
|
use crate::output::note;
|
||||||
|
use crate::project::Project;
|
||||||
|
|
||||||
|
const DEBUG_TEMPLATE: &str = r#"from django.core.management.base import BaseCommand
|
||||||
|
|
||||||
|
class Command(BaseCommand):
|
||||||
|
def handle(self, *args, **options):
|
||||||
|
pass
|
||||||
|
|
||||||
|
"#;
|
||||||
|
|
||||||
|
pub fn make_command(ctx: &Ctx, app: &PathBuf, name: &str) -> Result<()> {
|
||||||
|
let app_name = app.to_string_lossy();
|
||||||
|
let app_dir = Path::new(&app);
|
||||||
|
|
||||||
|
if !app_dir.is_dir() {
|
||||||
|
return Err(anyhow!("directory {app_name} does not exist"));
|
||||||
|
}
|
||||||
|
|
||||||
|
note!("found app {app_name}");
|
||||||
|
|
||||||
|
let management_dir = app_dir.join("management");
|
||||||
|
|
||||||
|
if !management_dir.exists() {
|
||||||
|
create_dir(ctx, &management_dir)?;
|
||||||
|
touch(ctx, &management_dir.join("__init__.py"))?;
|
||||||
|
|
||||||
|
note!("created module {app_name}.management")
|
||||||
|
};
|
||||||
|
|
||||||
|
let commands_dir = management_dir.join("commands");
|
||||||
|
|
||||||
|
if !commands_dir.exists() {
|
||||||
|
create_dir(ctx, &commands_dir)?;
|
||||||
|
touch(ctx, &commands_dir.join("__init__.py"))?;
|
||||||
|
|
||||||
|
note!("created module {app_name}.management.commands")
|
||||||
|
};
|
||||||
|
|
||||||
|
write_new(
|
||||||
|
ctx,
|
||||||
|
&commands_dir.join(format!("{name}.py")),
|
||||||
|
DEBUG_TEMPLATE.as_bytes(),
|
||||||
|
)?;
|
||||||
|
|
||||||
|
note!("created command {app_name}.management.commands.{name}");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn bash(ctx: &Ctx) -> Result<()> {
|
||||||
|
Bash.in_service(&service(ctx)?).replace(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn run(ctx: &Ctx, rest: &[String]) -> Result<()> {
|
||||||
|
Words::new(rest).in_service(&service(ctx)?).replace(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn manage(ctx: &Ctx, rest: &[String]) -> Result<()> {
|
||||||
|
Manage::new(rest).in_service(&service(ctx)?).replace(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
// shortcuts
|
||||||
|
pub fn makemigrations(ctx: &Ctx) -> Result<()> {
|
||||||
|
manage(ctx, &["makemigrations".to_string()])
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn migrate(ctx: &Ctx, rest: &[String]) -> Result<()> {
|
||||||
|
let mut args = vec!["migrate".to_string()];
|
||||||
|
args.extend_from_slice(rest);
|
||||||
|
|
||||||
|
manage(ctx, &args)
|
||||||
|
}
|
||||||
|
|
||||||
|
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<String> {
|
||||||
|
Project::resolve(ctx)?.django()
|
||||||
|
}
|
||||||
284
src/commands/link.rs
Normal file
284
src/commands/link.rs
Normal file
@@ -0,0 +1,284 @@
|
|||||||
|
mod check;
|
||||||
|
mod store;
|
||||||
|
|
||||||
|
pub use check::check;
|
||||||
|
|
||||||
|
use fs_err::{read_dir, read_link};
|
||||||
|
use std::cell::Cell;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
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};
|
||||||
|
|
||||||
|
const BACKUP_SUFFIX: &str = ".ahab-bak";
|
||||||
|
|
||||||
|
// move untracked paths out of the repo and symlink them back
|
||||||
|
pub fn add(ctx: &Ctx, paths: &[PathBuf], force: bool, store: Option<&Path>) -> Result<()> {
|
||||||
|
let repo = Repo::discover(ctx, store)?;
|
||||||
|
let report = Report::new(&repo);
|
||||||
|
|
||||||
|
if let [path] = paths {
|
||||||
|
return link_one(ctx, &repo, path, force, &report);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut failed = 0;
|
||||||
|
for path in paths {
|
||||||
|
if let Err(e) = link_one(ctx, &repo, path, force, &report) {
|
||||||
|
note!("error: {e:#}");
|
||||||
|
failed += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if failed > 0 {
|
||||||
|
return Err(anyhow!("{failed} of {} paths failed", paths.len()));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
// move paths in the store back into the repo, the inverse of add
|
||||||
|
pub fn restore(ctx: &Ctx, paths: &[PathBuf], all: bool, store: Option<&Path>) -> Result<()> {
|
||||||
|
let repo = Repo::discover(ctx, store)?;
|
||||||
|
let report = Report::new(&repo);
|
||||||
|
|
||||||
|
let paths = match (all, paths) {
|
||||||
|
(true, []) => linked_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(),
|
||||||
|
};
|
||||||
|
|
||||||
|
if paths.is_empty() {
|
||||||
|
note!("nothing in the store for this repository");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
if let [path] = paths.as_slice() {
|
||||||
|
return restore_one(ctx, &repo, path, &report);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut failed = 0;
|
||||||
|
for path in &paths {
|
||||||
|
if let Err(e) = restore_one(ctx, &repo, path, &report) {
|
||||||
|
note!("error: {e:#}");
|
||||||
|
failed += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if failed > 0 {
|
||||||
|
return Err(anyhow!("{failed} of {} paths failed", paths.len()));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn restore_one(ctx: &Ctx, repo: &Repo, path: &Path, report: &Report) -> Result<()> {
|
||||||
|
let src = resolve(path)?;
|
||||||
|
let rel = repo.relative(&src)?;
|
||||||
|
let stored = repo.store.join(&rel);
|
||||||
|
|
||||||
|
let Some(meta) = symlink_metadata_opt(&src)? else {
|
||||||
|
bail!("{} does not exist", rel.display());
|
||||||
|
};
|
||||||
|
if !meta.is_symlink() {
|
||||||
|
bail!(
|
||||||
|
"{} is not a symlink, so it is not in the store",
|
||||||
|
rel.display()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let dest = read_link(&src)?;
|
||||||
|
if dest != stored {
|
||||||
|
bail!(
|
||||||
|
"{} points at {}, which is not where the store keeps it",
|
||||||
|
rel.display(),
|
||||||
|
dest.display()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if symlink_metadata_opt(&stored)?.is_none() {
|
||||||
|
bail!("{} is missing from the store", rel.display());
|
||||||
|
}
|
||||||
|
|
||||||
|
remove_file(ctx, &src)?;
|
||||||
|
move_path(ctx, &stored, &src)?;
|
||||||
|
prune_empty(ctx, stored.parent(), &repo.base);
|
||||||
|
|
||||||
|
report.line("restored", &rel);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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<Vec<PathBuf>> {
|
||||||
|
let mut found = Vec::new();
|
||||||
|
|
||||||
|
let entries = match read_dir(dir) {
|
||||||
|
Ok(entries) => entries,
|
||||||
|
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(found),
|
||||||
|
Err(e) => return Err(e.into()),
|
||||||
|
};
|
||||||
|
|
||||||
|
for entry in entries {
|
||||||
|
let stored = entry?.path();
|
||||||
|
let rel = stored
|
||||||
|
.strip_prefix(&repo.store)
|
||||||
|
.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);
|
||||||
|
|
||||||
|
if linked {
|
||||||
|
found.push(src);
|
||||||
|
} else if stored.is_dir() {
|
||||||
|
found.extend(linked_paths(repo, &stored)?);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
found.sort();
|
||||||
|
Ok(found)
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Report {
|
||||||
|
store: PathBuf,
|
||||||
|
named: Cell<bool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Report {
|
||||||
|
fn new(repo: &Repo) -> Self {
|
||||||
|
Self {
|
||||||
|
store: repo.store.clone(),
|
||||||
|
named: Cell::new(false),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn line(&self, verb: &str, path: &Path) {
|
||||||
|
// worth naming once per run
|
||||||
|
if !self.named.replace(true) {
|
||||||
|
println!("store: {}", self.store.display());
|
||||||
|
}
|
||||||
|
|
||||||
|
println!("\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)?;
|
||||||
|
let target = repo.store.join(&rel);
|
||||||
|
|
||||||
|
// a target inside the repo would be readable from the sandbox anyway
|
||||||
|
if target.starts_with(&repo.root) {
|
||||||
|
return Err(anyhow!(
|
||||||
|
"target {} is inside the repository; point AHAB_LINK_ROOT elsewhere",
|
||||||
|
target.display()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if tracked(ctx, repo, &rel)? {
|
||||||
|
return Err(anyhow!(
|
||||||
|
"{} is tracked by git; only untracked or ignored paths can be externalized",
|
||||||
|
rel.display()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if !ignored(ctx, repo, &rel) {
|
||||||
|
warning!("{} is not gitignored", rel.display());
|
||||||
|
}
|
||||||
|
|
||||||
|
let src_meta = symlink_metadata_opt(&src)?;
|
||||||
|
let target_taken = symlink_metadata_opt(&target)?.is_some();
|
||||||
|
|
||||||
|
match src_meta {
|
||||||
|
Some(meta) if meta.is_symlink() => {
|
||||||
|
let dest = read_link(&src)?;
|
||||||
|
|
||||||
|
if dest == target {
|
||||||
|
if !target_taken {
|
||||||
|
return Err(anyhow!(
|
||||||
|
"{} already points at {}, but nothing is there",
|
||||||
|
rel.display(),
|
||||||
|
target.display()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
report.line("unchanged", &rel);
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
// nothing in the store to adopt, so the symlink itself moves out
|
||||||
|
if !target_taken {
|
||||||
|
if !src.exists() {
|
||||||
|
warning!(
|
||||||
|
"{} is a broken symlink to {}",
|
||||||
|
rel.display(),
|
||||||
|
dest.display()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
move_path(ctx, &src, &target)?;
|
||||||
|
place_link(ctx, &src, &target)?;
|
||||||
|
report.line("moved", &rel);
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
if !force {
|
||||||
|
return Err(needs_force(&target));
|
||||||
|
}
|
||||||
|
|
||||||
|
place_link(ctx, &src, &target)?;
|
||||||
|
report.line("repointed", &rel);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
Some(_) if target_taken => {
|
||||||
|
if !force {
|
||||||
|
return Err(needs_force(&target));
|
||||||
|
}
|
||||||
|
|
||||||
|
let backup = suffixed(&src, BACKUP_SUFFIX);
|
||||||
|
if symlink_metadata_opt(&backup)?.is_some() {
|
||||||
|
return Err(anyhow!(
|
||||||
|
"{} already exists; remove it before re-linking",
|
||||||
|
backup.display()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
rename(ctx, &src, &backup)?;
|
||||||
|
report.line("saved", &suffixed(&rel, BACKUP_SUFFIX));
|
||||||
|
|
||||||
|
place_link(ctx, &src, &target)?;
|
||||||
|
report.line("linked", &rel);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
Some(_) => {
|
||||||
|
move_path(ctx, &src, &target)?;
|
||||||
|
place_link(ctx, &src, &target)?;
|
||||||
|
report.line("moved", &rel);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
None if target_taken => {
|
||||||
|
if !force {
|
||||||
|
return Err(needs_force(&target));
|
||||||
|
}
|
||||||
|
place_link(ctx, &src, &target)?;
|
||||||
|
report.line("linked", &rel);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
None => Err(anyhow!(
|
||||||
|
"{} does not exist and the store has no {}",
|
||||||
|
rel.display(),
|
||||||
|
target.display()
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn needs_force(target: &Path) -> anyhow::Error {
|
||||||
|
anyhow!(
|
||||||
|
"{} already exists; pass --force to link to it",
|
||||||
|
target.display()
|
||||||
|
)
|
||||||
|
}
|
||||||
243
src/commands/link/check.rs
Normal file
243
src/commands/link/check.rs
Normal file
@@ -0,0 +1,243 @@
|
|||||||
|
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;
|
||||||
|
|
||||||
|
pub fn check(
|
||||||
|
ctx: &Ctx,
|
||||||
|
paths: &[PathBuf],
|
||||||
|
porcelain: bool,
|
||||||
|
null: bool,
|
||||||
|
exit_code: bool,
|
||||||
|
store: Option<&Path>,
|
||||||
|
) -> Result<ExitCode> {
|
||||||
|
let repo = Repo::discover(ctx, store)?;
|
||||||
|
let pathspecs = relative_pathspecs(&repo, paths)?;
|
||||||
|
|
||||||
|
let mut exposed = Vec::new();
|
||||||
|
// git lists untracked and ignored separately
|
||||||
|
for (mark, ignored) in [(UNTRACKED, false), (IGNORED, true)] {
|
||||||
|
for entry in list_others(ctx, &repo, ignored, &pathspecs)? {
|
||||||
|
let rel = PathBuf::from(entry.trim_end_matches('/'));
|
||||||
|
|
||||||
|
// --directory collapses a wholly untracked dir into `dir/`
|
||||||
|
if entry.ends_with('/') {
|
||||||
|
exposed.extend(walk(&repo, &rel, mark)?.1);
|
||||||
|
} else {
|
||||||
|
exposed.extend(classify(&repo, &rel, mark)?);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
exposed.sort_by(|a, b| a.name.cmp(&b.name));
|
||||||
|
|
||||||
|
if porcelain || null {
|
||||||
|
print_porcelain(&exposed, null);
|
||||||
|
} else {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn print_porcelain(exposed: &[Exposed], null: bool) {
|
||||||
|
let end = if null { '\0' } else { '\n' };
|
||||||
|
|
||||||
|
for item in exposed {
|
||||||
|
match &item.dest {
|
||||||
|
Some(dest) => print!("{} {} -> {}{end}", item.code(), item.name, dest.display()),
|
||||||
|
None => print!("{} {}{end}", item.code(), item.name),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn print_listing(repo: &Repo, exposed: &[Exposed]) {
|
||||||
|
println!("store: {}", repo.store.display());
|
||||||
|
|
||||||
|
if exposed.is_empty() {
|
||||||
|
println!("nothing outside the store, a sandbox would see tracked files only");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let sections = [
|
||||||
|
(
|
||||||
|
"Untracked paths a sandbox can read:",
|
||||||
|
" (use \"ahab link add <path>...\" to move them into the store)",
|
||||||
|
Section::Content(UNTRACKED),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"Ignored paths a sandbox can read:",
|
||||||
|
" (use \"ahab link add <path>...\" to move them into the store)",
|
||||||
|
Section::Content(IGNORED),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"Symlinks leading outside the store:",
|
||||||
|
" (their contents are not in the repository either way)",
|
||||||
|
Section::Elsewhere,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
for (heading, hint, section) in sections {
|
||||||
|
let mut items = exposed.iter().filter(|item| section.holds(item)).peekable();
|
||||||
|
if items.peek().is_none() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
println!("\n{heading}\n{hint}");
|
||||||
|
for item in items {
|
||||||
|
match &item.dest {
|
||||||
|
Some(dest) => println!("\t{} -> {}", item.name, dest.display()),
|
||||||
|
None => println!("\t{}", item.name),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum Section {
|
||||||
|
Content(char),
|
||||||
|
Elsewhere,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Section {
|
||||||
|
fn holds(&self, item: &Exposed) -> bool {
|
||||||
|
match self {
|
||||||
|
Self::Content(mark) => item.dest.is_none() && item.mark == *mark,
|
||||||
|
Self::Elsewhere => item.dest.is_some(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// status codes as `git status --porcelain` spells them
|
||||||
|
const UNTRACKED: char = '?';
|
||||||
|
const IGNORED: char = '!';
|
||||||
|
const ELSEWHERE: char = '>';
|
||||||
|
|
||||||
|
struct Exposed {
|
||||||
|
mark: char,
|
||||||
|
name: String,
|
||||||
|
dest: Option<PathBuf>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Exposed {
|
||||||
|
fn content(mark: char, name: String) -> Self {
|
||||||
|
Self {
|
||||||
|
mark,
|
||||||
|
name,
|
||||||
|
dest: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn code(&self) -> String {
|
||||||
|
let second = if self.dest.is_some() {
|
||||||
|
ELSEWHERE
|
||||||
|
} else {
|
||||||
|
self.mark
|
||||||
|
};
|
||||||
|
format!("{}{second}", self.mark)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn classify(repo: &Repo, rel: &Path, mark: char) -> Result<Option<Exposed>> {
|
||||||
|
let src = repo.root.join(rel);
|
||||||
|
let name = rel.display().to_string();
|
||||||
|
|
||||||
|
let Some(meta) = symlink_metadata_opt(&src)? else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
if !meta.is_symlink() {
|
||||||
|
return Ok(Some(Exposed::content(mark, name)));
|
||||||
|
}
|
||||||
|
|
||||||
|
let dest = read_link(&src)?;
|
||||||
|
if dest == repo.store.join(rel) {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Some(Exposed {
|
||||||
|
mark,
|
||||||
|
name,
|
||||||
|
dest: Some(dest),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn walk(repo: &Repo, rel: &Path, mark: char) -> Result<(usize, Vec<Exposed>)> {
|
||||||
|
let dir = repo.root.join(rel);
|
||||||
|
let mut handled = 0;
|
||||||
|
let mut exposed = Vec::new();
|
||||||
|
|
||||||
|
for entry in read_dir(&dir)? {
|
||||||
|
let entry = entry?;
|
||||||
|
let child = rel.join(entry.file_name());
|
||||||
|
|
||||||
|
if entry.file_type()?.is_dir() {
|
||||||
|
let (below, inside) = walk(repo, &child, mark)?;
|
||||||
|
handled += below;
|
||||||
|
exposed.extend(inside);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
match classify(repo, &child, mark)? {
|
||||||
|
Some(item) => exposed.push(item),
|
||||||
|
None => handled += 1,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// nothing below is in the store, so collapse to one line
|
||||||
|
if handled == 0 && !exposed.is_empty() {
|
||||||
|
let name = format!("{}/", rel.display());
|
||||||
|
return Ok((0, vec![Exposed::content(mark, name)]));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok((handled, exposed))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn relative_pathspecs(repo: &Repo, paths: &[PathBuf]) -> Result<Vec<PathBuf>> {
|
||||||
|
let mut specs = Vec::with_capacity(paths.len());
|
||||||
|
|
||||||
|
for path in paths {
|
||||||
|
let abs = resolve(path)?;
|
||||||
|
let rel = abs.strip_prefix(&repo.root).map_err(|_| {
|
||||||
|
anyhow!(
|
||||||
|
"{} is outside the repository {}",
|
||||||
|
abs.display(),
|
||||||
|
repo.root.display()
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
if rel.as_os_str().is_empty() {
|
||||||
|
return Ok(Vec::new());
|
||||||
|
}
|
||||||
|
specs.push(rel.to_path_buf());
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(specs)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn list_others(
|
||||||
|
ctx: &Ctx,
|
||||||
|
repo: &Repo,
|
||||||
|
ignored: bool,
|
||||||
|
pathspecs: &[PathBuf],
|
||||||
|
) -> Result<Vec<String>> {
|
||||||
|
let mut listing = LsFiles::untracked(&repo.root).limited_to(pathspecs);
|
||||||
|
if ignored {
|
||||||
|
listing = listing.ignored();
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(listing
|
||||||
|
.capture(ctx)?
|
||||||
|
.split('\0')
|
||||||
|
.filter(|p| !p.is_empty())
|
||||||
|
.map(String::from)
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
254
src/commands/link/store.rs
Normal file
254
src/commands/link/store.rs
Normal file
@@ -0,0 +1,254 @@
|
|||||||
|
use fs_err as fs;
|
||||||
|
use std::env;
|
||||||
|
use std::ffi::OsString;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use anyhow::{Context, Result, anyhow};
|
||||||
|
|
||||||
|
use crate::cmd::{CheckIgnore, Cmd, ConfigGet, LsFiles, RevParse};
|
||||||
|
use crate::ctx::Ctx;
|
||||||
|
use crate::output::note;
|
||||||
|
|
||||||
|
// a checkout with no remote to name it after
|
||||||
|
const LOCAL_NAMESPACE: &str = "_local";
|
||||||
|
|
||||||
|
pub(super) struct Repo {
|
||||||
|
pub(super) root: PathBuf,
|
||||||
|
pub(super) store: PathBuf,
|
||||||
|
// the configured store root, above the per-repository directories
|
||||||
|
pub(super) base: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Repo {
|
||||||
|
pub(super) fn discover(ctx: &Ctx, store: Option<&Path>) -> Result<Self> {
|
||||||
|
let root = git_root(ctx)?;
|
||||||
|
let base = match store {
|
||||||
|
Some(store) => store.to_path_buf(),
|
||||||
|
None => store_root()?,
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
store: base.join(repo_components(ctx, &root)?),
|
||||||
|
root,
|
||||||
|
base,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
pub(super) fn relative(&self, src: &Path) -> Result<PathBuf> {
|
||||||
|
let rel = src.strip_prefix(&self.root).map_err(|_| {
|
||||||
|
anyhow!(
|
||||||
|
"{} is outside the repository {}",
|
||||||
|
src.display(),
|
||||||
|
self.root.display()
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
// empty means the whole repo
|
||||||
|
if rel.as_os_str().is_empty() {
|
||||||
|
return Err(anyhow!(
|
||||||
|
"refusing to externalize the repository root itself"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if rel.starts_with(".git") {
|
||||||
|
return Err(anyhow!("refusing to externalize anything under .git"));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(rel.to_path_buf())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn resolve(path: &Path) -> Result<PathBuf> {
|
||||||
|
let abs = std::path::absolute(path)
|
||||||
|
.with_context(|| format!("resolving absolute path of {}", path.display()))?;
|
||||||
|
|
||||||
|
// the parent must exist so symlinked components resolve like git's toplevel
|
||||||
|
let Some(name) = abs.file_name().map(OsString::from) else {
|
||||||
|
return Ok(fs::canonicalize(&abs)?);
|
||||||
|
};
|
||||||
|
let parent = fs::canonicalize(abs.parent().unwrap_or(Path::new("/")))?;
|
||||||
|
Ok(parent.join(name))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn git_root(ctx: &Ctx) -> Result<PathBuf> {
|
||||||
|
let root = RevParse
|
||||||
|
.capture(ctx)
|
||||||
|
.map_err(|_| anyhow!("not inside a git repository"))?;
|
||||||
|
|
||||||
|
let root = root.trim().to_string();
|
||||||
|
if root.is_empty() {
|
||||||
|
return Err(anyhow!("git reported an empty repository root"));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(fs::canonicalize(&root)?)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn repo_components(ctx: &Ctx, root: &Path) -> Result<PathBuf> {
|
||||||
|
if let Some(url) = git_origin_url(ctx) {
|
||||||
|
if let Some(components) = components_from_remote(&url) {
|
||||||
|
return Ok(components);
|
||||||
|
}
|
||||||
|
note!("could not parse git remote `{url}`, falling back to the checkout name");
|
||||||
|
}
|
||||||
|
|
||||||
|
let name = root
|
||||||
|
.file_name()
|
||||||
|
.ok_or_else(|| anyhow!("cannot derive a store path for {}", root.display()))?;
|
||||||
|
Ok(Path::new(LOCAL_NAMESPACE).join(sanitize(&name.to_string_lossy())))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn git_origin_url(ctx: &Ctx) -> Option<String> {
|
||||||
|
let url = ConfigGet {
|
||||||
|
key: "remote.origin.url",
|
||||||
|
}
|
||||||
|
.capture(ctx)
|
||||||
|
.ok()?;
|
||||||
|
|
||||||
|
let url = url.trim().to_string();
|
||||||
|
(!url.is_empty()).then_some(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn components_from_remote(url: &str) -> Option<PathBuf> {
|
||||||
|
let url = url.trim();
|
||||||
|
let url = url.strip_suffix(".git").unwrap_or(url);
|
||||||
|
|
||||||
|
// `scheme://[user@]host[:port]/path`, or scp-like `[user@]host:path`
|
||||||
|
let (authority, path) = match url.split_once("://") {
|
||||||
|
Some((_, after)) => after.split_once('/')?,
|
||||||
|
None => url.split_once(':')?,
|
||||||
|
};
|
||||||
|
|
||||||
|
let host = authority.rsplit_once('@').map_or(authority, |(_, h)| h);
|
||||||
|
let host = host.split_once(':').map_or(host, |(h, _)| h);
|
||||||
|
// a local remote has no host to key on
|
||||||
|
if host.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut components = PathBuf::from(sanitize(&host.to_lowercase()));
|
||||||
|
let mut depth = 0;
|
||||||
|
for part in path.split('/').filter(|p| !p.is_empty()) {
|
||||||
|
components.push(sanitize(part));
|
||||||
|
depth += 1;
|
||||||
|
}
|
||||||
|
(depth > 0).then_some(components)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sanitize(s: &str) -> String {
|
||||||
|
let out: String = s
|
||||||
|
.chars()
|
||||||
|
.map(|c| {
|
||||||
|
if c.is_ascii_alphanumeric() || matches!(c, '-' | '.' | '_') {
|
||||||
|
c
|
||||||
|
} else {
|
||||||
|
'_'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// `.` and `..` are legal characters but not legal components
|
||||||
|
if out.chars().all(|c| c == '.') {
|
||||||
|
return "_".repeat(out.len());
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
fn store_root() -> Result<PathBuf> {
|
||||||
|
if let Some(xdg) = non_empty_var("XDG_DATA_HOME") {
|
||||||
|
return Ok(PathBuf::from(xdg).join("ahab"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let home = non_empty_var("HOME")
|
||||||
|
.filter(|v| !v.is_empty())
|
||||||
|
.ok_or_else(|| anyhow!("neither XDG_DATA_HOME nor HOME is set"))?;
|
||||||
|
|
||||||
|
Ok(PathBuf::from(home).join(".local/share/ahab"))
|
||||||
|
}
|
||||||
|
fn non_empty_var(name: &str) -> Option<OsString> {
|
||||||
|
env::var_os(name).filter(|v| !v.is_empty())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn tracked(ctx: &Ctx, repo: &Repo, rel: &Path) -> Result<bool> {
|
||||||
|
let listed = LsFiles::tracked(&repo.root)
|
||||||
|
.limited_to(&[rel])
|
||||||
|
.capture(ctx)?;
|
||||||
|
|
||||||
|
Ok(!listed.is_empty())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn ignored(ctx: &Ctx, repo: &Repo, rel: &Path) -> bool {
|
||||||
|
CheckIgnore::new(&repo.root, rel)
|
||||||
|
.quietly_succeeds(ctx)
|
||||||
|
.unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn symlink_metadata_opt(path: &Path) -> Result<Option<std::fs::Metadata>> {
|
||||||
|
// symlink_metadata does not follow the link, so a symlink shows as one
|
||||||
|
match fs::symlink_metadata(path) {
|
||||||
|
Ok(meta) => Ok(Some(meta)),
|
||||||
|
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
|
||||||
|
Err(e) => Err(e.into()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::{components_from_remote, sanitize};
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_every_spelling_of_a_remote() {
|
||||||
|
let cases = [
|
||||||
|
(
|
||||||
|
"git@git.aflabs.org:urnik/afurnik.git",
|
||||||
|
"git.aflabs.org/urnik/afurnik",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"https://git.aflabs.org/urnik/afurnik.git",
|
||||||
|
"git.aflabs.org/urnik/afurnik",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"https://git.aflabs.org/urnik/afurnik",
|
||||||
|
"git.aflabs.org/urnik/afurnik",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"https://git.aflabs.org/urnik/afurnik/",
|
||||||
|
"git.aflabs.org/urnik/afurnik",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"ssh://git@git.aflabs.org:22/urnik/afurnik.git",
|
||||||
|
"git.aflabs.org/urnik/afurnik",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"git@GIT.Aflabs.org:urnik/AFurnik.git",
|
||||||
|
"git.aflabs.org/urnik/AFurnik",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"git@git.aflabs.org:urnik/internal/afurnik.git",
|
||||||
|
"git.aflabs.org/urnik/internal/afurnik",
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
for (url, want) in cases {
|
||||||
|
assert_eq!(
|
||||||
|
components_from_remote(url),
|
||||||
|
Some(PathBuf::from(want)),
|
||||||
|
"url: {url}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_remotes_without_a_host() {
|
||||||
|
assert_eq!(components_from_remote("not-a-url"), None);
|
||||||
|
assert_eq!(components_from_remote("/srv/git/afurnik.git"), None);
|
||||||
|
assert_eq!(components_from_remote("file:///srv/git/afurnik.git"), None);
|
||||||
|
assert_eq!(components_from_remote("https://git.aflabs.org/"), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sanitize_never_yields_a_traversal() {
|
||||||
|
assert_eq!(sanitize(".."), "__");
|
||||||
|
assert_eq!(sanitize("."), "_");
|
||||||
|
assert_eq!(sanitize("a/b"), "a_b");
|
||||||
|
assert_eq!(sanitize(".env"), ".env");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
pub mod completions;
|
pub mod completions;
|
||||||
pub mod django;
|
pub mod django;
|
||||||
pub mod docker_compose;
|
|
||||||
pub mod link;
|
pub mod link;
|
||||||
pub mod postgres;
|
pub mod postgres;
|
||||||
296
src/commands/postgres.rs
Normal file
296
src/commands/postgres.rs
Normal file
@@ -0,0 +1,296 @@
|
|||||||
|
mod server;
|
||||||
|
mod shape;
|
||||||
|
|
||||||
|
use fs_err::File;
|
||||||
|
use std::io::{self, Write};
|
||||||
|
use std::path::Path;
|
||||||
|
use std::process::Stdio;
|
||||||
|
|
||||||
|
use anyhow::{Context, Result, bail};
|
||||||
|
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
use crate::ctx::Ctx;
|
||||||
|
use crate::fsops::{remove_file, rename, suffixed};
|
||||||
|
use crate::output::note;
|
||||||
|
|
||||||
|
// unique per run: docker cp will not copy a directory over an existing path, and
|
||||||
|
// quietly leaves whatever was there for pg_restore to read instead
|
||||||
|
fn remote_dump() -> String {
|
||||||
|
format!("/tmp/ahab-dump-{}", std::process::id())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_existing_role_error(line: &str) -> bool {
|
||||||
|
line.starts_with("ERROR:") && line.contains("role \"") && line.ends_with("already exists")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn restore_cluster(ctx: &Ctx, db: &Database, restore: &dyn Cmd, file: &Path) -> Result<()> {
|
||||||
|
let out = restore
|
||||||
|
.in_container(&db.container)
|
||||||
|
.interactive()
|
||||||
|
.stdin_from_captured(ctx, file)
|
||||||
|
.context("running psql")?;
|
||||||
|
|
||||||
|
io::stdout().write_all(&out.stdout).ok();
|
||||||
|
|
||||||
|
let mut existing = 0;
|
||||||
|
for line in String::from_utf8_lossy(&out.stderr).lines() {
|
||||||
|
if is_existing_role_error(line) {
|
||||||
|
existing += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
note!("{line}");
|
||||||
|
}
|
||||||
|
|
||||||
|
if existing > 0 {
|
||||||
|
note!(
|
||||||
|
"left {existing} existing role{} alone",
|
||||||
|
if existing == 1 { "" } else { "s" }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if !out.status.success() {
|
||||||
|
bail!("psql failed, the database is left empty");
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
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!("starting db container");
|
||||||
|
Start::service(&db.service).run(ctx)?;
|
||||||
|
|
||||||
|
let remote = remote_dump();
|
||||||
|
|
||||||
|
// a directory cannot be streamed, so it is the one shape that gets copied in
|
||||||
|
if matches!(dump, Dump::Directory) {
|
||||||
|
when_ready(
|
||||||
|
ctx,
|
||||||
|
&db,
|
||||||
|
&Cp::into_container(&file.to_string_lossy(), &db.container, &remote),
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let kind = match &dump {
|
||||||
|
Dump::Directory => Kind::Archive,
|
||||||
|
Dump::Header(header) => Kind::of(header),
|
||||||
|
Dump::Gzip => {
|
||||||
|
wait_until_ready(ctx, &db)?;
|
||||||
|
let out = Gunzip
|
||||||
|
.pipe(&Head::bytes(HEADER_LEN))
|
||||||
|
// head closes the pipe once it has its bytes, which kills gunzip
|
||||||
|
.allow_early_close()
|
||||||
|
.in_container(&db.container)
|
||||||
|
.interactive()
|
||||||
|
.stdin_from_captured(ctx, file)
|
||||||
|
.context("reading the compressed dump's header")?;
|
||||||
|
|
||||||
|
// head exits 0 whatever gunzip did, so an empty header is the only
|
||||||
|
// sign that decompression failed
|
||||||
|
if !out.status.success() || out.stdout.is_empty() {
|
||||||
|
let reason = String::from_utf8_lossy(&out.stderr);
|
||||||
|
let reason = reason.trim();
|
||||||
|
|
||||||
|
bail!(
|
||||||
|
"could not decompress {}: {}",
|
||||||
|
file.display(),
|
||||||
|
if reason.is_empty() {
|
||||||
|
"no data came out"
|
||||||
|
} else {
|
||||||
|
reason
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Kind::of(&out.stdout)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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}");
|
||||||
|
|
||||||
|
when_ready(
|
||||||
|
ctx,
|
||||||
|
&db,
|
||||||
|
&DropDb {
|
||||||
|
username: &db.user,
|
||||||
|
dbname: &db.name,
|
||||||
|
}
|
||||||
|
.in_container(&db.container),
|
||||||
|
)?;
|
||||||
|
|
||||||
|
// a cluster dump creates the database itself, and would trip over one that
|
||||||
|
// is already there
|
||||||
|
if kind != Kind::Cluster {
|
||||||
|
when_ready(
|
||||||
|
ctx,
|
||||||
|
&db,
|
||||||
|
&CreateDb {
|
||||||
|
username: &db.user,
|
||||||
|
dbname: &db.name,
|
||||||
|
}
|
||||||
|
.in_container(&db.container),
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
wait_until_ready(ctx, &db)?;
|
||||||
|
if ctx.dry_run {
|
||||||
|
note!("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
|
||||||
|
// arrives compressed
|
||||||
|
if matches!(dump, Dump::Directory) {
|
||||||
|
let status = PgRestore::new(&db.user, &db.name)
|
||||||
|
.from(&remote)
|
||||||
|
.in_container(&db.container)
|
||||||
|
.status(ctx)?;
|
||||||
|
|
||||||
|
if !status.success() {
|
||||||
|
bail!("{tool} failed, the database is left empty");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let restore: Box<dyn Cmd> = match dump {
|
||||||
|
Dump::Gzip => Box::new(Gunzip.pipe(&*restore)),
|
||||||
|
_ => restore,
|
||||||
|
};
|
||||||
|
|
||||||
|
if kind == Kind::Cluster {
|
||||||
|
// psql's output is read rather than streamed here, to keep the
|
||||||
|
// expected role errors out of the way
|
||||||
|
restore_cluster(ctx, &db, &*restore, file)?;
|
||||||
|
} else {
|
||||||
|
let status = restore
|
||||||
|
.in_container(&db.container)
|
||||||
|
.interactive()
|
||||||
|
.stdin_from(ctx, file)?;
|
||||||
|
|
||||||
|
if !status.success() {
|
||||||
|
bail!("{tool} failed, the database is left empty");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if matches!(dump, Dump::Directory) {
|
||||||
|
let _ = Rm::recursive(&remote).in_container(&db.container).run(ctx);
|
||||||
|
}
|
||||||
|
|
||||||
|
note!("restarting containers");
|
||||||
|
Stop.run(ctx)?;
|
||||||
|
Up.run(ctx)?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn dump(ctx: &Ctx, file: &Path, format: Format, gzip: bool) -> Result<()> {
|
||||||
|
let db = Database::resolve(ctx)?;
|
||||||
|
|
||||||
|
if format == Format::Directory {
|
||||||
|
if gzip {
|
||||||
|
bail!("a directory dump is a directory of already compressed files, not a stream");
|
||||||
|
}
|
||||||
|
|
||||||
|
return dump_directory(ctx, &db, file);
|
||||||
|
}
|
||||||
|
|
||||||
|
note!("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
|
||||||
|
let partial = suffixed(file, ".partial");
|
||||||
|
// a dry run produces no dump, so it must not lay a hand on the target either
|
||||||
|
let stdout = if ctx.dry_run {
|
||||||
|
Stdio::null()
|
||||||
|
} else {
|
||||||
|
Stdio::from(std::fs::File::from(File::create(&partial)?))
|
||||||
|
};
|
||||||
|
|
||||||
|
let dumping = dump_command(&db, format);
|
||||||
|
|
||||||
|
let dumped = if gzip {
|
||||||
|
dumping
|
||||||
|
.pipe(&Gzip)
|
||||||
|
.in_container(&db.container)
|
||||||
|
.stream_to(ctx, stdout)
|
||||||
|
} else {
|
||||||
|
dumping.in_container(&db.container).stream_to(ctx, stdout)
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Err(e) = dumped {
|
||||||
|
let _ = remove_file(ctx, &partial);
|
||||||
|
return Err(e);
|
||||||
|
}
|
||||||
|
|
||||||
|
rename(ctx, &partial, file)?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
// pg_dump for one database, pg_dumpall for a cluster, which has no format letter
|
||||||
|
fn dump_command(db: &Database, format: Format) -> Box<dyn Cmd + '_> {
|
||||||
|
match format.flag() {
|
||||||
|
Some(flag) => Box::new(PgDump::new(&db.user, &db.name, flag)),
|
||||||
|
None => Box::new(PgDumpAll { username: &db.user }),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// pg_dump writes a directory format dump itself rather than to stdout, so it lands
|
||||||
|
// in the container and comes back with docker cp
|
||||||
|
fn dump_directory(ctx: &Ctx, db: &Database, target: &Path) -> Result<()> {
|
||||||
|
if target.exists() {
|
||||||
|
bail!(
|
||||||
|
"{} already exists; a directory dump will not be written over it",
|
||||||
|
target.display()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
note!("dumping to local directory {}", target.display());
|
||||||
|
let remote = remote_dump();
|
||||||
|
|
||||||
|
PgDump::new(&db.user, &db.name, "d")
|
||||||
|
.to(&remote)
|
||||||
|
.in_container(&db.container)
|
||||||
|
.run(ctx)?;
|
||||||
|
|
||||||
|
let copied = Cp::out_of_container(&db.container, &remote, &target.to_string_lossy()).run(ctx);
|
||||||
|
|
||||||
|
let _ = Rm::recursive(&remote).in_container(&db.container).run(ctx);
|
||||||
|
|
||||||
|
copied
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::is_existing_role_error;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn only_the_existing_role_complaint_is_expected() {
|
||||||
|
assert!(is_existing_role_error(
|
||||||
|
r#"ERROR: role "postgres" already exists"#
|
||||||
|
));
|
||||||
|
|
||||||
|
for line in [
|
||||||
|
r#"ERROR: relation "things" already exists"#,
|
||||||
|
r#"ERROR: database "db" already exists"#,
|
||||||
|
"ERROR: syntax error at or near \"slect\"",
|
||||||
|
"NOTICE: role \"postgres\" already exists",
|
||||||
|
] {
|
||||||
|
assert!(!is_existing_role_error(line), "{line}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
85
src/commands/postgres/server.rs
Normal file
85
src/commands/postgres/server.rs
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
use std::thread;
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
use anyhow::{Result, anyhow, bail};
|
||||||
|
|
||||||
|
use super::shape::Kind;
|
||||||
|
use crate::cmd::{Cmd, PgIsReady, PgRestore, Ps, Psql};
|
||||||
|
use crate::ctx::Ctx;
|
||||||
|
use crate::project::Project;
|
||||||
|
|
||||||
|
const READY_TIMEOUT: Duration = Duration::from_secs(60);
|
||||||
|
const POLL_INTERVAL: Duration = Duration::from_secs(1);
|
||||||
|
|
||||||
|
pub(super) struct Database {
|
||||||
|
pub(super) service: String,
|
||||||
|
pub(super) container: String,
|
||||||
|
pub(super) user: String,
|
||||||
|
pub(super) name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Database {
|
||||||
|
pub(super) fn resolve(ctx: &Ctx) -> Result<Self> {
|
||||||
|
let compose = Project::resolve(ctx)?;
|
||||||
|
let service = compose.postgres()?;
|
||||||
|
let (user, name) = compose.postgres_credentials(&service);
|
||||||
|
|
||||||
|
let container = Ps::id_of(&service).capture(ctx)?.trim().to_string();
|
||||||
|
|
||||||
|
if container.is_empty() {
|
||||||
|
return Err(anyhow!("service {service} has no running container"));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
service,
|
||||||
|
container,
|
||||||
|
user,
|
||||||
|
name,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// what reads this shape of dump back in
|
||||||
|
pub(super) fn restore_with(&self, kind: Kind) -> Box<dyn Cmd + '_> {
|
||||||
|
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")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn wait_until_ready(ctx: &Ctx, db: &Database) -> Result<()> {
|
||||||
|
if ctx.dry_run {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let deadline = Instant::now() + READY_TIMEOUT;
|
||||||
|
|
||||||
|
loop {
|
||||||
|
let ready = PgIsReady {
|
||||||
|
username: &db.user,
|
||||||
|
dbname: &db.name,
|
||||||
|
}
|
||||||
|
.in_container(&db.container)
|
||||||
|
.quietly_succeeds(ctx)?;
|
||||||
|
|
||||||
|
if ready {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
if Instant::now() >= deadline {
|
||||||
|
bail!(
|
||||||
|
"{} did not accept connections within {} seconds",
|
||||||
|
db.service,
|
||||||
|
READY_TIMEOUT.as_secs()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
thread::sleep(POLL_INTERVAL);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn when_ready(ctx: &Ctx, db: &Database, command: &dyn Cmd) -> Result<()> {
|
||||||
|
wait_until_ready(ctx, db)?;
|
||||||
|
command.run(ctx)
|
||||||
|
}
|
||||||
104
src/commands/postgres/shape.rs
Normal file
104
src/commands/postgres/shape.rs
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
use fs_err::File;
|
||||||
|
use std::io::Read;
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
use anyhow::Result;
|
||||||
|
|
||||||
|
const CUSTOM_MAGIC: &[u8] = b"PGDMP";
|
||||||
|
const TAR_MAGIC: &[u8] = b"toc.dat";
|
||||||
|
const GZIP_MAGIC: &[u8] = b"\x1f\x8b";
|
||||||
|
const CLUSTER_MARKER: &str = "PostgreSQL database cluster dump";
|
||||||
|
// wide enough for the cluster marker, which sits a few bytes into the file
|
||||||
|
pub(super) const HEADER_LEN: usize = 512;
|
||||||
|
|
||||||
|
pub(super) enum Dump {
|
||||||
|
Directory,
|
||||||
|
Gzip,
|
||||||
|
Header(Vec<u8>),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Dump {
|
||||||
|
pub(super) fn of(path: &Path) -> Result<Self> {
|
||||||
|
if path.is_dir() {
|
||||||
|
return Ok(Self::Directory);
|
||||||
|
}
|
||||||
|
|
||||||
|
let header = read_header(path)?;
|
||||||
|
if header.starts_with(GZIP_MAGIC) {
|
||||||
|
return Ok(Self::Gzip);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Self::Header(header))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// how the dump has to be fed back in: pg_restore for an archive, psql into the
|
||||||
|
// database for a single database dump, psql into postgres for a whole cluster
|
||||||
|
#[derive(Clone, Copy, PartialEq)]
|
||||||
|
pub(super) enum Kind {
|
||||||
|
Archive,
|
||||||
|
Sql,
|
||||||
|
Cluster,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Kind {
|
||||||
|
pub(super) fn of(header: &[u8]) -> Self {
|
||||||
|
if header.starts_with(CUSTOM_MAGIC) || header.starts_with(TAR_MAGIC) {
|
||||||
|
return Self::Archive;
|
||||||
|
}
|
||||||
|
|
||||||
|
if String::from_utf8_lossy(header).contains(CLUSTER_MARKER) {
|
||||||
|
return Self::Cluster;
|
||||||
|
}
|
||||||
|
|
||||||
|
Self::Sql
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// pg_dumpall recreates roles the cluster already has, so this is the expected
|
||||||
|
|
||||||
|
fn read_header(path: &Path) -> Result<Vec<u8>> {
|
||||||
|
let mut file = File::open(path)?;
|
||||||
|
let mut header = vec![0; HEADER_LEN];
|
||||||
|
|
||||||
|
let read = file.read(&mut header)?;
|
||||||
|
header.truncate(read);
|
||||||
|
|
||||||
|
Ok(header)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::{GZIP_MAGIC, Kind};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_archive_is_recognised_by_its_magic() {
|
||||||
|
assert!(matches!(Kind::of(b"PGDMP\x01\x0f"), Kind::Archive));
|
||||||
|
assert!(matches!(Kind::of(b"toc.dat\x00\x00"), Kind::Archive));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_cluster_dump_is_recognised_by_its_header() {
|
||||||
|
let header = b"--\n-- PostgreSQL database cluster dump\n--\n\n\\restrict abc\n";
|
||||||
|
assert!(matches!(Kind::of(header), Kind::Cluster));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn anything_else_is_a_single_database_dump() {
|
||||||
|
for header in [
|
||||||
|
&b"--\n-- PostgreSQL database dump\n"[..],
|
||||||
|
&b"BEGIN;"[..],
|
||||||
|
&b""[..],
|
||||||
|
&b"PGD"[..],
|
||||||
|
&b"toc.da"[..],
|
||||||
|
] {
|
||||||
|
assert!(matches!(Kind::of(header), Kind::Sql), "{header:?}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn gzip_is_recognised_by_its_magic() {
|
||||||
|
assert!(b"\x1f\x8b\x08\x00rest".starts_with(GZIP_MAGIC));
|
||||||
|
assert!(!b"PGDMP".starts_with(GZIP_MAGIC));
|
||||||
|
}
|
||||||
|
}
|
||||||
5
src/ctx.rs
Normal file
5
src/ctx.rs
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
// how ahab was invoked, handed to everything that runs commands or writes files
|
||||||
|
pub struct Ctx {
|
||||||
|
pub verbose: bool,
|
||||||
|
pub dry_run: bool,
|
||||||
|
}
|
||||||
165
src/fsops.rs
Normal file
165
src/fsops.rs
Normal 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(())
|
||||||
|
}
|
||||||
29
src/lib.rs
29
src/lib.rs
@@ -1,29 +0,0 @@
|
|||||||
use std::{
|
|
||||||
fs::{File, OpenOptions},
|
|
||||||
path::PathBuf,
|
|
||||||
};
|
|
||||||
|
|
||||||
pub mod cli;
|
|
||||||
pub mod command_builder;
|
|
||||||
pub mod compose;
|
|
||||||
pub mod scripts;
|
|
||||||
|
|
||||||
// NOTE: stolen from https://docs.rs/debug_print/latest/debug_print/
|
|
||||||
#[macro_export]
|
|
||||||
macro_rules! debug_eprintln {
|
|
||||||
($($arg:tt)*) => (#[cfg(debug_assertions)] eprintln!($($arg)*));
|
|
||||||
}
|
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
60
src/main.rs
60
src/main.rs
@@ -1,23 +1,34 @@
|
|||||||
use std::process::ExitCode;
|
use std::process::ExitCode;
|
||||||
|
|
||||||
use ahab::{cli, command_builder, scripts};
|
mod cli;
|
||||||
|
mod cmd;
|
||||||
|
mod commands;
|
||||||
|
mod ctx;
|
||||||
|
mod fsops;
|
||||||
|
mod output;
|
||||||
|
mod project;
|
||||||
|
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use clap::Parser;
|
use clap::Parser;
|
||||||
|
|
||||||
|
use crate::ctx::Ctx;
|
||||||
|
use crate::output::note;
|
||||||
|
|
||||||
fn main() -> ExitCode {
|
fn main() -> ExitCode {
|
||||||
let args = cli::Ahab::parse();
|
let args = cli::Ahab::parse();
|
||||||
|
|
||||||
command_builder::set_options(command_builder::Options {
|
let ctx = Ctx {
|
||||||
verbose: args.verbose,
|
verbose: args.verbose,
|
||||||
dry_run: args.dry_run,
|
dry_run: args.dry_run,
|
||||||
});
|
};
|
||||||
|
|
||||||
if args.dry_run {
|
// said once here rather than by each command, so every line that follows
|
||||||
cli::reject_unsupported_dry_run(&args.command);
|
// reads as the plan it is
|
||||||
|
if ctx.dry_run {
|
||||||
|
note!("dry run, nothing will be changed");
|
||||||
}
|
}
|
||||||
|
|
||||||
match run(args.command) {
|
match run(&ctx, args.command) {
|
||||||
Ok(code) => code,
|
Ok(code) => code,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
eprintln!("Error: {e:#}");
|
eprintln!("Error: {e:#}");
|
||||||
@@ -26,31 +37,31 @@ fn main() -> ExitCode {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn run(command: cli::Commands) -> Result<ExitCode> {
|
fn run(ctx: &Ctx, command: cli::Commands) -> Result<ExitCode> {
|
||||||
let done = ExitCode::SUCCESS;
|
let done = ExitCode::SUCCESS;
|
||||||
|
|
||||||
match command {
|
match command {
|
||||||
cli::Commands::Django { command } => {
|
cli::Commands::Django { command } => {
|
||||||
match command {
|
match command {
|
||||||
cli::Django::Bash => scripts::django::bash(),
|
cli::Django::Bash => commands::django::bash(ctx),
|
||||||
cli::Django::Run { rest } => scripts::django::run(&rest),
|
cli::Django::Run { rest } => commands::django::run(ctx, &rest),
|
||||||
cli::Django::MakeCommand { app, name } => {
|
cli::Django::MakeCommand { app, name } => {
|
||||||
scripts::django::make_command(&app, &name)
|
commands::django::make_command(ctx, &app, &name)
|
||||||
}
|
}
|
||||||
cli::Django::Makemigrations => scripts::django::makemigrations(),
|
cli::Django::Makemigrations => commands::django::makemigrations(ctx),
|
||||||
cli::Django::Manage { rest } => scripts::django::manage(&rest),
|
cli::Django::Manage { rest } => commands::django::manage(ctx, &rest),
|
||||||
cli::Django::Migrate { rest } => scripts::django::migrate(&rest),
|
cli::Django::Migrate { rest } => commands::django::migrate(ctx, &rest),
|
||||||
cli::Django::Shell => scripts::django::shell(),
|
cli::Django::Shell => commands::django::shell(ctx),
|
||||||
cli::Django::Test => scripts::django::test(),
|
cli::Django::Test => commands::django::test(ctx),
|
||||||
}?;
|
}?;
|
||||||
|
|
||||||
Ok(done)
|
Ok(done)
|
||||||
}
|
}
|
||||||
cli::Commands::Postgres { command } => {
|
cli::Commands::Postgres { command } => {
|
||||||
match command {
|
match command {
|
||||||
cli::Postgres::Import { path } => scripts::postgres::import(&path),
|
cli::Postgres::Import { path } => commands::postgres::import(ctx, &path),
|
||||||
cli::Postgres::Dump { path, format, gzip } => {
|
cli::Postgres::Dump { path, format, gzip } => {
|
||||||
scripts::postgres::dump(&path, format, gzip)
|
commands::postgres::dump(ctx, &path, format, gzip)
|
||||||
}
|
}
|
||||||
}?;
|
}?;
|
||||||
|
|
||||||
@@ -61,9 +72,9 @@ fn run(command: cli::Commands) -> Result<ExitCode> {
|
|||||||
paths,
|
paths,
|
||||||
force,
|
force,
|
||||||
store,
|
store,
|
||||||
} => scripts::link::add(&paths, force, store.root.as_deref()).map(|()| done),
|
} => commands::link::add(ctx, &paths, force, store.root.as_deref()).map(|()| done),
|
||||||
cli::Link::Restore { paths, all, store } => {
|
cli::Link::Restore { paths, all, store } => {
|
||||||
scripts::link::restore(&paths, all, store.root.as_deref()).map(|()| done)
|
commands::link::restore(ctx, &paths, all, store.root.as_deref()).map(|()| done)
|
||||||
}
|
}
|
||||||
// the one command with something to say through its exit code
|
// the one command with something to say through its exit code
|
||||||
cli::Link::Check {
|
cli::Link::Check {
|
||||||
@@ -72,10 +83,17 @@ fn run(command: cli::Commands) -> Result<ExitCode> {
|
|||||||
null,
|
null,
|
||||||
exit_code,
|
exit_code,
|
||||||
store,
|
store,
|
||||||
} => scripts::link::check(&paths, porcelain, null, exit_code, store.root.as_deref()),
|
} => commands::link::check(
|
||||||
|
ctx,
|
||||||
|
&paths,
|
||||||
|
porcelain,
|
||||||
|
null,
|
||||||
|
exit_code,
|
||||||
|
store.root.as_deref(),
|
||||||
|
),
|
||||||
},
|
},
|
||||||
cli::Commands::Completions { shell } => {
|
cli::Commands::Completions { shell } => {
|
||||||
scripts::completions::completions(shell)?;
|
commands::completions::completions(shell)?;
|
||||||
|
|
||||||
Ok(done)
|
Ok(done)
|
||||||
}
|
}
|
||||||
|
|||||||
11
src/output.rs
Normal file
11
src/output.rs
Normal 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};
|
||||||
@@ -1,32 +1,20 @@
|
|||||||
use anyhow::{Context, Result, anyhow, bail};
|
use anyhow::{Context, Result, anyhow};
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
|
|
||||||
use crate::command_builder::CommandBuilder;
|
use crate::cmd::{Cmd, Config};
|
||||||
|
use crate::ctx::Ctx;
|
||||||
|
|
||||||
const POSTGRES_IMAGES: [&str; 4] = ["postg", "timescale", "pgvector", "citus"];
|
const POSTGRES_IMAGES: [&str; 4] = ["postg", "timescale", "pgvector", "citus"];
|
||||||
const DJANGO_SETTINGS_MODULE: &str = "DJANGO_SETTINGS_MODULE";
|
const DJANGO_SETTINGS_MODULE: &str = "DJANGO_SETTINGS_MODULE";
|
||||||
|
|
||||||
pub struct Compose {
|
pub struct Project {
|
||||||
services: Value,
|
services: Value,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Compose {
|
impl Project {
|
||||||
pub fn resolve() -> Result<Self> {
|
pub fn resolve(ctx: &Ctx) -> Result<Self> {
|
||||||
let out = CommandBuilder::docker_compose()
|
let json = Config.capture(ctx)?;
|
||||||
.args("config --format json")
|
let config: Value = serde_json::from_str(&json).context("parsing docker compose config")?;
|
||||||
.build()?
|
|
||||||
.output()
|
|
||||||
.context("running docker compose config")?;
|
|
||||||
|
|
||||||
if !out.status.success() {
|
|
||||||
bail!(
|
|
||||||
"docker compose config failed: {}",
|
|
||||||
String::from_utf8_lossy(&out.stderr).trim()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
let config: Value =
|
|
||||||
serde_json::from_slice(&out.stdout).context("parsing docker compose config")?;
|
|
||||||
let services = config
|
let services = config
|
||||||
.get("services")
|
.get("services")
|
||||||
.cloned()
|
.cloned()
|
||||||
@@ -137,11 +125,11 @@ fn is_postgres_image(image: &str) -> bool {
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{Compose, is_postgres_image};
|
use super::{Project, is_postgres_image};
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
|
|
||||||
fn compose(services: serde_json::Value) -> Compose {
|
fn compose(services: serde_json::Value) -> Project {
|
||||||
Compose { services }
|
Project { services }
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -1,91 +0,0 @@
|
|||||||
use std::fs::create_dir;
|
|
||||||
use std::io::Write;
|
|
||||||
use std::path::{Path, PathBuf};
|
|
||||||
|
|
||||||
use anyhow::{Result, anyhow};
|
|
||||||
|
|
||||||
use crate::compose::Compose;
|
|
||||||
use crate::scripts::docker_compose;
|
|
||||||
use crate::{create_file, safe_create_file};
|
|
||||||
|
|
||||||
const DEBUG_TEMPLATE: &str = r#"from django.core.management.base import BaseCommand
|
|
||||||
|
|
||||||
class Command(BaseCommand):
|
|
||||||
def handle(self, *args, **options):
|
|
||||||
pass
|
|
||||||
|
|
||||||
"#;
|
|
||||||
|
|
||||||
pub fn make_command(app: &PathBuf, name: &str) -> Result<()> {
|
|
||||||
let app_name = app.to_string_lossy();
|
|
||||||
let app_dir = Path::new(&app);
|
|
||||||
|
|
||||||
let not_app_exists = !app_dir.is_dir();
|
|
||||||
if not_app_exists {
|
|
||||||
return Err(anyhow!("directory {app_name} does not exist"));
|
|
||||||
}
|
|
||||||
|
|
||||||
eprintln!("found app {app_name}");
|
|
||||||
|
|
||||||
let management_dir = app_dir.join("management");
|
|
||||||
|
|
||||||
let not_management_exists = !management_dir.exists();
|
|
||||||
if not_management_exists {
|
|
||||||
create_dir(&management_dir)?;
|
|
||||||
create_file(management_dir.join("__init__.py"))?;
|
|
||||||
|
|
||||||
eprintln!("created module {app_name}.management")
|
|
||||||
};
|
|
||||||
|
|
||||||
let commands_dir = management_dir.join("commands");
|
|
||||||
|
|
||||||
let not_commands_exists = !commands_dir.exists();
|
|
||||||
if not_commands_exists {
|
|
||||||
create_dir(&commands_dir)?;
|
|
||||||
create_file(commands_dir.join("__init__.py"))?;
|
|
||||||
|
|
||||||
eprintln!("created module {app_name}.management.commands")
|
|
||||||
};
|
|
||||||
|
|
||||||
let mut file = safe_create_file(commands_dir.join(format!("{name}.py")))?;
|
|
||||||
file.write_all(DEBUG_TEMPLATE.as_bytes())?;
|
|
||||||
|
|
||||||
eprintln!("created command {app_name}.management.commands.{name}");
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn bash() -> Result<()> {
|
|
||||||
run(&["bash".to_string()])
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn run(rest: &[String]) -> Result<()> {
|
|
||||||
let service = Compose::resolve()?.django()?;
|
|
||||||
docker_compose::run(&service, rest)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn manage(rest: &[String]) -> Result<()> {
|
|
||||||
let mut args = vec!["python".to_string(), "manage.py".to_string()];
|
|
||||||
args.extend_from_slice(rest);
|
|
||||||
|
|
||||||
run(&args)
|
|
||||||
}
|
|
||||||
|
|
||||||
// shortcuts
|
|
||||||
pub fn makemigrations() -> Result<()> {
|
|
||||||
manage(&["makemigrations".to_string()])
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn migrate(rest: &[String]) -> Result<()> {
|
|
||||||
let mut full_rest = vec!["migrate".to_string()];
|
|
||||||
full_rest.extend_from_slice(rest);
|
|
||||||
|
|
||||||
manage(&full_rest)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn shell() -> Result<()> {
|
|
||||||
manage(&["shell".to_string()])
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn test() -> Result<()> {
|
|
||||||
manage(&["test".to_string()])
|
|
||||||
}
|
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
use anyhow::Result;
|
|
||||||
|
|
||||||
use crate::command_builder::CommandBuilder;
|
|
||||||
|
|
||||||
pub fn build() -> Result<()> {
|
|
||||||
CommandBuilder::docker_compose().args("build").exec()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn down() -> Result<()> {
|
|
||||||
CommandBuilder::docker_compose().args("down").exec()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn run(service: &str, rest: &[String]) -> Result<()> {
|
|
||||||
CommandBuilder::docker_compose()
|
|
||||||
.args("run --rm")
|
|
||||||
.arg(service)
|
|
||||||
.args(rest)
|
|
||||||
.exec_replace()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn exec(service: &str, rest: &[String]) -> Result<()> {
|
|
||||||
CommandBuilder::docker_compose()
|
|
||||||
.args("exec")
|
|
||||||
.arg(service)
|
|
||||||
.args(rest)
|
|
||||||
.exec()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn ps() -> Result<()> {
|
|
||||||
CommandBuilder::docker_compose().args("ps").exec()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn start(service: Option<&str>) -> Result<()> {
|
|
||||||
let mut command = CommandBuilder::docker_compose().args("start");
|
|
||||||
if let Some(service) = service {
|
|
||||||
command = command.arg(service);
|
|
||||||
}
|
|
||||||
|
|
||||||
command.exec()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn stop() -> Result<()> {
|
|
||||||
CommandBuilder::docker_compose().args("stop").exec()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn up() -> Result<()> {
|
|
||||||
CommandBuilder::docker_compose().args("up -d").exec()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn rebuild() -> Result<()> {
|
|
||||||
stop()?;
|
|
||||||
build()?;
|
|
||||||
up()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn restart() -> Result<()> {
|
|
||||||
stop()?;
|
|
||||||
up()
|
|
||||||
}
|
|
||||||
@@ -1,895 +0,0 @@
|
|||||||
use std::cell::Cell;
|
|
||||||
use std::env;
|
|
||||||
use std::ffi::OsString;
|
|
||||||
use std::fs;
|
|
||||||
use std::os::unix::fs::symlink;
|
|
||||||
use std::path::{Path, PathBuf};
|
|
||||||
use std::process::Command;
|
|
||||||
use std::process::ExitCode;
|
|
||||||
|
|
||||||
use anyhow::{Context, Result, anyhow, bail};
|
|
||||||
|
|
||||||
const BACKUP_SUFFIX: &str = ".ahab-bak";
|
|
||||||
const LOCAL_NAMESPACE: &str = "_local";
|
|
||||||
|
|
||||||
// move untracked paths out of the repo and symlink them back
|
|
||||||
pub fn add(paths: &[PathBuf], force: bool, store: Option<&Path>) -> Result<()> {
|
|
||||||
let repo = Repo::discover(store)?;
|
|
||||||
let report = Report::new(&repo);
|
|
||||||
|
|
||||||
if let [path] = paths {
|
|
||||||
return link_one(&repo, path, force, &report);
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut failed = 0;
|
|
||||||
for path in paths {
|
|
||||||
if let Err(e) = link_one(&repo, path, force, &report) {
|
|
||||||
eprintln!("error: {e:#}");
|
|
||||||
failed += 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if failed > 0 {
|
|
||||||
return Err(anyhow!("{failed} of {} paths failed", paths.len()));
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
// move paths in the store back into the repo, the inverse of add
|
|
||||||
pub fn restore(paths: &[PathBuf], all: bool, store: Option<&Path>) -> Result<()> {
|
|
||||||
let repo = Repo::discover(store)?;
|
|
||||||
let report = Report::new(&repo);
|
|
||||||
|
|
||||||
let paths = match (all, paths) {
|
|
||||||
(true, []) => linked_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(),
|
|
||||||
};
|
|
||||||
|
|
||||||
if paths.is_empty() {
|
|
||||||
eprintln!("nothing in the store for this repository");
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
|
|
||||||
if let [path] = paths.as_slice() {
|
|
||||||
return restore_one(&repo, path, &report);
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut failed = 0;
|
|
||||||
for path in &paths {
|
|
||||||
if let Err(e) = restore_one(&repo, path, &report) {
|
|
||||||
eprintln!("error: {e:#}");
|
|
||||||
failed += 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if failed > 0 {
|
|
||||||
return Err(anyhow!("{failed} of {} paths failed", paths.len()));
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn restore_one(repo: &Repo, path: &Path, report: &Report) -> Result<()> {
|
|
||||||
let src = resolve(path)?;
|
|
||||||
let rel = repo.relative(&src)?;
|
|
||||||
let stored = repo.store.join(&rel);
|
|
||||||
|
|
||||||
let Some(meta) = symlink_metadata_opt(&src)? else {
|
|
||||||
bail!("{} does not exist", rel.display());
|
|
||||||
};
|
|
||||||
if !meta.is_symlink() {
|
|
||||||
bail!(
|
|
||||||
"{} is not a symlink, so it is not in the store",
|
|
||||||
rel.display()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
let dest = fs::read_link(&src).with_context(|| format!("reading {}", src.display()))?;
|
|
||||||
if dest != stored {
|
|
||||||
bail!(
|
|
||||||
"{} points at {}, which is not where the store keeps it",
|
|
||||||
rel.display(),
|
|
||||||
dest.display()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if symlink_metadata_opt(&stored)?.is_none() {
|
|
||||||
bail!("{} is missing from the store", rel.display());
|
|
||||||
}
|
|
||||||
|
|
||||||
fs::remove_file(&src).with_context(|| format!("removing {}", src.display()))?;
|
|
||||||
move_path(&stored, &src)?;
|
|
||||||
prune_empty(stored.parent(), &repo.base);
|
|
||||||
|
|
||||||
report.line("restored", &rel);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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<Vec<PathBuf>> {
|
|
||||||
let mut found = Vec::new();
|
|
||||||
|
|
||||||
let entries = match fs::read_dir(dir) {
|
|
||||||
Ok(entries) => entries,
|
|
||||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(found),
|
|
||||||
Err(e) => return Err(e).with_context(|| format!("reading {}", dir.display())),
|
|
||||||
};
|
|
||||||
|
|
||||||
for entry in entries {
|
|
||||||
let stored = entry?.path();
|
|
||||||
let rel = stored
|
|
||||||
.strip_prefix(&repo.store)
|
|
||||||
.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())
|
|
||||||
&& fs::read_link(&src).is_ok_and(|dest| dest == stored);
|
|
||||||
|
|
||||||
if linked {
|
|
||||||
found.push(src);
|
|
||||||
} else if stored.is_dir() {
|
|
||||||
found.extend(linked_paths(repo, &stored)?);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
found.sort();
|
|
||||||
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 {
|
|
||||||
store: PathBuf,
|
|
||||||
named: Cell<bool>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Report {
|
|
||||||
fn new(repo: &Repo) -> Self {
|
|
||||||
Self {
|
|
||||||
store: repo.store.clone(),
|
|
||||||
named: Cell::new(false),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn line(&self, verb: &str, path: &Path) {
|
|
||||||
// worth naming once per run
|
|
||||||
if !self.named.replace(true) {
|
|
||||||
println!("store: {}", self.store.display());
|
|
||||||
}
|
|
||||||
|
|
||||||
println!("\t{:<11}{}", format!("{verb}:"), path.display());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
pub fn check(
|
|
||||||
paths: &[PathBuf],
|
|
||||||
porcelain: bool,
|
|
||||||
null: bool,
|
|
||||||
exit_code: bool,
|
|
||||||
store: Option<&Path>,
|
|
||||||
) -> Result<ExitCode> {
|
|
||||||
let repo = Repo::discover(store)?;
|
|
||||||
let pathspecs = relative_pathspecs(&repo, paths)?;
|
|
||||||
|
|
||||||
let mut exposed = Vec::new();
|
|
||||||
// git lists untracked and ignored separately
|
|
||||||
for (mark, ignored) in [(UNTRACKED, false), (IGNORED, true)] {
|
|
||||||
for entry in list_others(&repo, ignored, &pathspecs)? {
|
|
||||||
let rel = PathBuf::from(entry.trim_end_matches('/'));
|
|
||||||
|
|
||||||
// --directory collapses a wholly untracked dir into `dir/`
|
|
||||||
if entry.ends_with('/') {
|
|
||||||
exposed.extend(walk(&repo, &rel, mark)?.1);
|
|
||||||
} else {
|
|
||||||
exposed.extend(classify(&repo, &rel, mark)?);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
exposed.sort_by(|a, b| a.name.cmp(&b.name));
|
|
||||||
|
|
||||||
if porcelain || null {
|
|
||||||
print_porcelain(&exposed, null);
|
|
||||||
} else {
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn print_porcelain(exposed: &[Exposed], null: bool) {
|
|
||||||
let end = if null { '\0' } else { '\n' };
|
|
||||||
|
|
||||||
for item in exposed {
|
|
||||||
match &item.dest {
|
|
||||||
Some(dest) => print!("{} {} -> {}{end}", item.code(), item.name, dest.display()),
|
|
||||||
None => print!("{} {}{end}", item.code(), item.name),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn print_listing(repo: &Repo, exposed: &[Exposed]) {
|
|
||||||
println!("store: {}", repo.store.display());
|
|
||||||
|
|
||||||
if exposed.is_empty() {
|
|
||||||
println!("nothing outside the store, a sandbox would see tracked files only");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let sections = [
|
|
||||||
(
|
|
||||||
"Untracked paths a sandbox can read:",
|
|
||||||
" (use \"ahab link add <path>...\" to move them into the store)",
|
|
||||||
Section::Content(UNTRACKED),
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"Ignored paths a sandbox can read:",
|
|
||||||
" (use \"ahab link add <path>...\" to move them into the store)",
|
|
||||||
Section::Content(IGNORED),
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"Symlinks leading outside the store:",
|
|
||||||
" (their contents are not in the repository either way)",
|
|
||||||
Section::Elsewhere,
|
|
||||||
),
|
|
||||||
];
|
|
||||||
|
|
||||||
for (heading, hint, section) in sections {
|
|
||||||
let mut items = exposed.iter().filter(|item| section.holds(item)).peekable();
|
|
||||||
if items.peek().is_none() {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
println!("\n{heading}\n{hint}");
|
|
||||||
for item in items {
|
|
||||||
match &item.dest {
|
|
||||||
Some(dest) => println!("\t{} -> {}", item.name, dest.display()),
|
|
||||||
None => println!("\t{}", item.name),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
enum Section {
|
|
||||||
Content(char),
|
|
||||||
Elsewhere,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Section {
|
|
||||||
fn holds(&self, item: &Exposed) -> bool {
|
|
||||||
match self {
|
|
||||||
Self::Content(mark) => item.dest.is_none() && item.mark == *mark,
|
|
||||||
Self::Elsewhere => item.dest.is_some(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// status codes as `git status --porcelain` spells them
|
|
||||||
const UNTRACKED: char = '?';
|
|
||||||
const IGNORED: char = '!';
|
|
||||||
const ELSEWHERE: char = '>';
|
|
||||||
|
|
||||||
struct Exposed {
|
|
||||||
mark: char,
|
|
||||||
name: String,
|
|
||||||
dest: Option<PathBuf>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Exposed {
|
|
||||||
fn content(mark: char, name: String) -> Self {
|
|
||||||
Self {
|
|
||||||
mark,
|
|
||||||
name,
|
|
||||||
dest: None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn code(&self) -> String {
|
|
||||||
let second = if self.dest.is_some() {
|
|
||||||
ELSEWHERE
|
|
||||||
} else {
|
|
||||||
self.mark
|
|
||||||
};
|
|
||||||
format!("{}{second}", self.mark)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn classify(repo: &Repo, rel: &Path, mark: char) -> Result<Option<Exposed>> {
|
|
||||||
let src = repo.root.join(rel);
|
|
||||||
let name = rel.display().to_string();
|
|
||||||
|
|
||||||
let Some(meta) = symlink_metadata_opt(&src)? else {
|
|
||||||
return Ok(None);
|
|
||||||
};
|
|
||||||
if !meta.is_symlink() {
|
|
||||||
return Ok(Some(Exposed::content(mark, name)));
|
|
||||||
}
|
|
||||||
|
|
||||||
let dest = fs::read_link(&src).with_context(|| format!("reading symlink {}", src.display()))?;
|
|
||||||
if dest == repo.store.join(rel) {
|
|
||||||
return Ok(None);
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(Some(Exposed {
|
|
||||||
mark,
|
|
||||||
name,
|
|
||||||
dest: Some(dest),
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn walk(repo: &Repo, rel: &Path, mark: char) -> Result<(usize, Vec<Exposed>)> {
|
|
||||||
let dir = repo.root.join(rel);
|
|
||||||
let mut handled = 0;
|
|
||||||
let mut exposed = Vec::new();
|
|
||||||
|
|
||||||
for entry in fs::read_dir(&dir).with_context(|| format!("reading {}", dir.display()))? {
|
|
||||||
let entry = entry?;
|
|
||||||
let child = rel.join(entry.file_name());
|
|
||||||
|
|
||||||
if entry.file_type()?.is_dir() {
|
|
||||||
let (below, inside) = walk(repo, &child, mark)?;
|
|
||||||
handled += below;
|
|
||||||
exposed.extend(inside);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
match classify(repo, &child, mark)? {
|
|
||||||
Some(item) => exposed.push(item),
|
|
||||||
None => handled += 1,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// nothing below is in the store, so collapse to one line
|
|
||||||
if handled == 0 && !exposed.is_empty() {
|
|
||||||
let name = format!("{}/", rel.display());
|
|
||||||
return Ok((0, vec![Exposed::content(mark, name)]));
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok((handled, exposed))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn relative_pathspecs(repo: &Repo, paths: &[PathBuf]) -> Result<Vec<PathBuf>> {
|
|
||||||
let mut specs = Vec::with_capacity(paths.len());
|
|
||||||
|
|
||||||
for path in paths {
|
|
||||||
let abs = resolve(path)?;
|
|
||||||
let rel = abs.strip_prefix(&repo.root).map_err(|_| {
|
|
||||||
anyhow!(
|
|
||||||
"{} is outside the repository {}",
|
|
||||||
abs.display(),
|
|
||||||
repo.root.display()
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
|
|
||||||
if rel.as_os_str().is_empty() {
|
|
||||||
return Ok(Vec::new());
|
|
||||||
}
|
|
||||||
specs.push(rel.to_path_buf());
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(specs)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn list_others(repo: &Repo, ignored: bool, pathspecs: &[PathBuf]) -> Result<Vec<String>> {
|
|
||||||
let mut cmd = Command::new("git");
|
|
||||||
cmd.arg("-C").arg(&repo.root).args([
|
|
||||||
"ls-files",
|
|
||||||
"-z",
|
|
||||||
"--others",
|
|
||||||
"--exclude-standard",
|
|
||||||
"--directory",
|
|
||||||
"--no-empty-directory",
|
|
||||||
]);
|
|
||||||
if ignored {
|
|
||||||
cmd.arg("--ignored");
|
|
||||||
}
|
|
||||||
cmd.arg("--").args(pathspecs);
|
|
||||||
|
|
||||||
let out = cmd.output().context("running git ls-files")?;
|
|
||||||
if !out.status.success() {
|
|
||||||
return Err(anyhow!(
|
|
||||||
"git ls-files failed: {}",
|
|
||||||
String::from_utf8_lossy(&out.stderr).trim()
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(String::from_utf8(out.stdout)?
|
|
||||||
.split('\0')
|
|
||||||
.filter(|p| !p.is_empty())
|
|
||||||
.map(String::from)
|
|
||||||
.collect())
|
|
||||||
}
|
|
||||||
|
|
||||||
struct Repo {
|
|
||||||
root: PathBuf,
|
|
||||||
store: PathBuf,
|
|
||||||
// the configured store root, above the per-repository directories
|
|
||||||
base: PathBuf,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Repo {
|
|
||||||
fn discover(store: Option<&Path>) -> Result<Self> {
|
|
||||||
let root = git_root()?;
|
|
||||||
let base = match store {
|
|
||||||
Some(store) => store.to_path_buf(),
|
|
||||||
None => store_root()?,
|
|
||||||
};
|
|
||||||
|
|
||||||
Ok(Self {
|
|
||||||
store: base.join(repo_components(&root)?),
|
|
||||||
root,
|
|
||||||
base,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
fn relative(&self, src: &Path) -> Result<PathBuf> {
|
|
||||||
let rel = src.strip_prefix(&self.root).map_err(|_| {
|
|
||||||
anyhow!(
|
|
||||||
"{} is outside the repository {}",
|
|
||||||
src.display(),
|
|
||||||
self.root.display()
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
|
|
||||||
// empty means the whole repo
|
|
||||||
if rel.as_os_str().is_empty() {
|
|
||||||
return Err(anyhow!(
|
|
||||||
"refusing to externalize the repository root itself"
|
|
||||||
));
|
|
||||||
}
|
|
||||||
if rel.starts_with(".git") {
|
|
||||||
return Err(anyhow!("refusing to externalize anything under .git"));
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(rel.to_path_buf())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn link_one(repo: &Repo, path: &Path, force: bool, report: &Report) -> Result<()> {
|
|
||||||
let src = resolve(path)?;
|
|
||||||
let rel = repo.relative(&src)?;
|
|
||||||
let target = repo.store.join(&rel);
|
|
||||||
|
|
||||||
// a target inside the repo would be readable from the sandbox anyway
|
|
||||||
if target.starts_with(&repo.root) {
|
|
||||||
return Err(anyhow!(
|
|
||||||
"target {} is inside the repository; point AHAB_LINK_ROOT elsewhere",
|
|
||||||
target.display()
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
if tracked(repo, &rel)? {
|
|
||||||
return Err(anyhow!(
|
|
||||||
"{} is tracked by git; only untracked or ignored paths can be externalized",
|
|
||||||
rel.display()
|
|
||||||
));
|
|
||||||
}
|
|
||||||
if !ignored(repo, &rel) {
|
|
||||||
warn(format!("{} is not gitignored", rel.display()));
|
|
||||||
}
|
|
||||||
|
|
||||||
let src_meta = symlink_metadata_opt(&src)?;
|
|
||||||
let target_taken = symlink_metadata_opt(&target)?.is_some();
|
|
||||||
|
|
||||||
match src_meta {
|
|
||||||
Some(meta) if meta.is_symlink() => {
|
|
||||||
let dest = fs::read_link(&src)
|
|
||||||
.with_context(|| format!("reading symlink {}", src.display()))?;
|
|
||||||
|
|
||||||
if dest == target {
|
|
||||||
if !target_taken {
|
|
||||||
return Err(anyhow!(
|
|
||||||
"{} already points at {}, but nothing is there",
|
|
||||||
rel.display(),
|
|
||||||
target.display()
|
|
||||||
));
|
|
||||||
}
|
|
||||||
report.line("unchanged", &rel);
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
|
|
||||||
// nothing in the store to adopt, so the symlink itself moves out
|
|
||||||
if !target_taken {
|
|
||||||
if !src.exists() {
|
|
||||||
warn(format!(
|
|
||||||
"{} is a broken symlink to {}",
|
|
||||||
rel.display(),
|
|
||||||
dest.display()
|
|
||||||
));
|
|
||||||
}
|
|
||||||
move_path(&src, &target)?;
|
|
||||||
place_link(&src, &target)?;
|
|
||||||
report.line("moved", &rel);
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
|
|
||||||
if !force {
|
|
||||||
return Err(needs_force(&target));
|
|
||||||
}
|
|
||||||
|
|
||||||
place_link(&src, &target)?;
|
|
||||||
report.line("repointed", &rel);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
Some(_) if target_taken => {
|
|
||||||
if !force {
|
|
||||||
return Err(needs_force(&target));
|
|
||||||
}
|
|
||||||
|
|
||||||
let backup = suffixed(&src, BACKUP_SUFFIX);
|
|
||||||
if symlink_metadata_opt(&backup)?.is_some() {
|
|
||||||
return Err(anyhow!(
|
|
||||||
"{} already exists; remove it before re-linking",
|
|
||||||
backup.display()
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
fs::rename(&src, &backup)
|
|
||||||
.with_context(|| format!("renaming {} to {}", src.display(), backup.display()))?;
|
|
||||||
report.line("saved", &suffixed(&rel, BACKUP_SUFFIX));
|
|
||||||
|
|
||||||
place_link(&src, &target)?;
|
|
||||||
report.line("linked", &rel);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
Some(_) => {
|
|
||||||
move_path(&src, &target)?;
|
|
||||||
place_link(&src, &target)?;
|
|
||||||
report.line("moved", &rel);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
None if target_taken => {
|
|
||||||
if !force {
|
|
||||||
return Err(needs_force(&target));
|
|
||||||
}
|
|
||||||
place_link(&src, &target)?;
|
|
||||||
report.line("linked", &rel);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
None => Err(anyhow!(
|
|
||||||
"{} does not exist and the store has no {}",
|
|
||||||
rel.display(),
|
|
||||||
target.display()
|
|
||||||
)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn needs_force(target: &Path) -> anyhow::Error {
|
|
||||||
anyhow!(
|
|
||||||
"{} already exists; pass --force to link to it",
|
|
||||||
target.display()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn resolve(path: &Path) -> Result<PathBuf> {
|
|
||||||
let abs = std::path::absolute(path)
|
|
||||||
.with_context(|| format!("resolving absolute path of {}", path.display()))?;
|
|
||||||
|
|
||||||
// the parent must exist so symlinked components resolve like git's toplevel
|
|
||||||
let Some(name) = abs.file_name().map(OsString::from) else {
|
|
||||||
return fs::canonicalize(&abs).with_context(|| format!("resolving {}", abs.display()));
|
|
||||||
};
|
|
||||||
let parent = abs.parent().unwrap_or(Path::new("/"));
|
|
||||||
|
|
||||||
let parent = fs::canonicalize(parent)
|
|
||||||
.with_context(|| format!("resolving directory {}", parent.display()))?;
|
|
||||||
Ok(parent.join(name))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn git_root() -> Result<PathBuf> {
|
|
||||||
let out = Command::new("git")
|
|
||||||
.args(["rev-parse", "--show-toplevel"])
|
|
||||||
.output()
|
|
||||||
.context("running git")?;
|
|
||||||
|
|
||||||
if !out.status.success() {
|
|
||||||
return Err(anyhow!("not inside a git repository"));
|
|
||||||
}
|
|
||||||
|
|
||||||
let root = String::from_utf8(out.stdout)?.trim().to_string();
|
|
||||||
if root.is_empty() {
|
|
||||||
return Err(anyhow!("git reported an empty repository root"));
|
|
||||||
}
|
|
||||||
|
|
||||||
fs::canonicalize(&root).with_context(|| format!("resolving {root}"))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn repo_components(root: &Path) -> Result<PathBuf> {
|
|
||||||
if let Some(url) = git_origin_url() {
|
|
||||||
if let Some(components) = components_from_remote(&url) {
|
|
||||||
return Ok(components);
|
|
||||||
}
|
|
||||||
eprintln!("could not parse git remote `{url}`, falling back to the checkout name");
|
|
||||||
}
|
|
||||||
|
|
||||||
let name = root
|
|
||||||
.file_name()
|
|
||||||
.ok_or_else(|| anyhow!("cannot derive a store path for {}", root.display()))?;
|
|
||||||
Ok(Path::new(LOCAL_NAMESPACE).join(sanitize(&name.to_string_lossy())))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn git_origin_url() -> Option<String> {
|
|
||||||
let out = Command::new("git")
|
|
||||||
.args(["config", "--get", "remote.origin.url"])
|
|
||||||
.output()
|
|
||||||
.ok()?;
|
|
||||||
if !out.status.success() {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
|
|
||||||
let url = String::from_utf8(out.stdout).ok()?.trim().to_string();
|
|
||||||
(!url.is_empty()).then_some(url)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn components_from_remote(url: &str) -> Option<PathBuf> {
|
|
||||||
let url = url.trim();
|
|
||||||
let url = url.strip_suffix(".git").unwrap_or(url);
|
|
||||||
|
|
||||||
// `scheme://[user@]host[:port]/path`, or scp-like `[user@]host:path`
|
|
||||||
let (authority, path) = match url.split_once("://") {
|
|
||||||
Some((_, after)) => after.split_once('/')?,
|
|
||||||
None => url.split_once(':')?,
|
|
||||||
};
|
|
||||||
|
|
||||||
let host = authority.rsplit_once('@').map_or(authority, |(_, h)| h);
|
|
||||||
let host = host.split_once(':').map_or(host, |(h, _)| h);
|
|
||||||
// a local remote has no host to key on
|
|
||||||
if host.is_empty() {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut components = PathBuf::from(sanitize(&host.to_lowercase()));
|
|
||||||
let mut depth = 0;
|
|
||||||
for part in path.split('/').filter(|p| !p.is_empty()) {
|
|
||||||
components.push(sanitize(part));
|
|
||||||
depth += 1;
|
|
||||||
}
|
|
||||||
(depth > 0).then_some(components)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn sanitize(s: &str) -> String {
|
|
||||||
let out: String = s
|
|
||||||
.chars()
|
|
||||||
.map(|c| {
|
|
||||||
if c.is_ascii_alphanumeric() || matches!(c, '-' | '.' | '_') {
|
|
||||||
c
|
|
||||||
} else {
|
|
||||||
'_'
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
// `.` and `..` are legal characters but not legal components
|
|
||||||
if out.chars().all(|c| c == '.') {
|
|
||||||
return "_".repeat(out.len());
|
|
||||||
}
|
|
||||||
out
|
|
||||||
}
|
|
||||||
|
|
||||||
fn store_root() -> Result<PathBuf> {
|
|
||||||
if let Some(xdg) = non_empty_var("XDG_DATA_HOME") {
|
|
||||||
return Ok(PathBuf::from(xdg).join("ahab"));
|
|
||||||
}
|
|
||||||
|
|
||||||
let home = non_empty_var("HOME")
|
|
||||||
.filter(|v| !v.is_empty())
|
|
||||||
.ok_or_else(|| anyhow!("neither XDG_DATA_HOME nor HOME is set"))?;
|
|
||||||
|
|
||||||
Ok(PathBuf::from(home).join(".local/share/ahab"))
|
|
||||||
}
|
|
||||||
fn non_empty_var(name: &str) -> Option<OsString> {
|
|
||||||
env::var_os(name).filter(|v| !v.is_empty())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn tracked(repo: &Repo, rel: &Path) -> Result<bool> {
|
|
||||||
let out = Command::new("git")
|
|
||||||
.arg("-C")
|
|
||||||
.arg(&repo.root)
|
|
||||||
.args(["ls-files", "--cached", "--"])
|
|
||||||
.arg(rel)
|
|
||||||
.output()
|
|
||||||
.context("running git ls-files")?;
|
|
||||||
|
|
||||||
if !out.status.success() {
|
|
||||||
return Err(anyhow!(
|
|
||||||
"git ls-files failed: {}",
|
|
||||||
String::from_utf8_lossy(&out.stderr).trim()
|
|
||||||
));
|
|
||||||
}
|
|
||||||
Ok(!out.stdout.is_empty())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn ignored(repo: &Repo, rel: &Path) -> bool {
|
|
||||||
Command::new("git")
|
|
||||||
.arg("-C")
|
|
||||||
.arg(&repo.root)
|
|
||||||
.args(["check-ignore", "-q", "--"])
|
|
||||||
.arg(rel)
|
|
||||||
.status()
|
|
||||||
.is_ok_and(|s| s.success())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn symlink_metadata_opt(path: &Path) -> Result<Option<fs::Metadata>> {
|
|
||||||
// symlink_metadata does not follow the link, so a symlink shows as one
|
|
||||||
match fs::symlink_metadata(path) {
|
|
||||||
Ok(meta) => Ok(Some(meta)),
|
|
||||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
|
|
||||||
Err(e) => Err(e).with_context(|| format!("inspecting {}", path.display())),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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)]
|
|
||||||
mod tests {
|
|
||||||
use super::{components_from_remote, sanitize};
|
|
||||||
use std::path::PathBuf;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn parses_every_spelling_of_a_remote() {
|
|
||||||
let cases = [
|
|
||||||
(
|
|
||||||
"git@git.aflabs.org:urnik/afurnik.git",
|
|
||||||
"git.aflabs.org/urnik/afurnik",
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"https://git.aflabs.org/urnik/afurnik.git",
|
|
||||||
"git.aflabs.org/urnik/afurnik",
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"https://git.aflabs.org/urnik/afurnik",
|
|
||||||
"git.aflabs.org/urnik/afurnik",
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"https://git.aflabs.org/urnik/afurnik/",
|
|
||||||
"git.aflabs.org/urnik/afurnik",
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"ssh://git@git.aflabs.org:22/urnik/afurnik.git",
|
|
||||||
"git.aflabs.org/urnik/afurnik",
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"git@GIT.Aflabs.org:urnik/AFurnik.git",
|
|
||||||
"git.aflabs.org/urnik/AFurnik",
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"git@git.aflabs.org:urnik/internal/afurnik.git",
|
|
||||||
"git.aflabs.org/urnik/internal/afurnik",
|
|
||||||
),
|
|
||||||
];
|
|
||||||
|
|
||||||
for (url, want) in cases {
|
|
||||||
assert_eq!(
|
|
||||||
components_from_remote(url),
|
|
||||||
Some(PathBuf::from(want)),
|
|
||||||
"url: {url}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn rejects_remotes_without_a_host() {
|
|
||||||
assert_eq!(components_from_remote("not-a-url"), None);
|
|
||||||
assert_eq!(components_from_remote("/srv/git/afurnik.git"), None);
|
|
||||||
assert_eq!(components_from_remote("file:///srv/git/afurnik.git"), None);
|
|
||||||
assert_eq!(components_from_remote("https://git.aflabs.org/"), None);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn sanitize_never_yields_a_traversal() {
|
|
||||||
assert_eq!(sanitize(".."), "__");
|
|
||||||
assert_eq!(sanitize("."), "_");
|
|
||||||
assert_eq!(sanitize("a/b"), "a_b");
|
|
||||||
assert_eq!(sanitize(".env"), ".env");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,525 +0,0 @@
|
|||||||
use anyhow::{Context, Result, anyhow, bail};
|
|
||||||
use std::{
|
|
||||||
fs::{self, File},
|
|
||||||
io::{self, Read, Write},
|
|
||||||
path::{Path, PathBuf},
|
|
||||||
process::Stdio,
|
|
||||||
thread,
|
|
||||||
time::{Duration, Instant},
|
|
||||||
};
|
|
||||||
|
|
||||||
use super::docker_compose;
|
|
||||||
use crate::cli::Format;
|
|
||||||
use crate::command_builder;
|
|
||||||
use crate::{command_builder::CommandBuilder, compose::Compose};
|
|
||||||
|
|
||||||
const CUSTOM_MAGIC: &[u8] = b"PGDMP";
|
|
||||||
const TAR_MAGIC: &[u8] = b"toc.dat";
|
|
||||||
const GZIP_MAGIC: &[u8] = b"\x1f\x8b";
|
|
||||||
const CLUSTER_MARKER: &str = "PostgreSQL database cluster dump";
|
|
||||||
const READY_TIMEOUT: Duration = Duration::from_secs(60);
|
|
||||||
const POLL_INTERVAL: Duration = Duration::from_secs(1);
|
|
||||||
|
|
||||||
// wide enough for the cluster marker, which sits a few bytes into the file
|
|
||||||
const HEADER_LEN: usize = 512;
|
|
||||||
|
|
||||||
// unique per run: docker cp will not copy a directory over an existing path, and
|
|
||||||
// quietly leaves whatever was there for pg_restore to read instead
|
|
||||||
fn remote_dump() -> String {
|
|
||||||
format!("/tmp/ahab-dump-{}", std::process::id())
|
|
||||||
}
|
|
||||||
|
|
||||||
struct Database {
|
|
||||||
service: String,
|
|
||||||
container: String,
|
|
||||||
user: String,
|
|
||||||
name: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Database {
|
|
||||||
fn resolve() -> Result<Self> {
|
|
||||||
let compose = Compose::resolve()?;
|
|
||||||
let service = compose.postgres()?;
|
|
||||||
let (user, name) = compose.postgres_credentials(&service);
|
|
||||||
|
|
||||||
let container = CommandBuilder::docker_compose()
|
|
||||||
.args("ps -q")
|
|
||||||
.arg(&service)
|
|
||||||
.exec_get_stdout()?
|
|
||||||
.trim()
|
|
||||||
.to_string();
|
|
||||||
|
|
||||||
if container.is_empty() {
|
|
||||||
return Err(anyhow!("service {service} has no running container"));
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(Self {
|
|
||||||
service,
|
|
||||||
container,
|
|
||||||
user,
|
|
||||||
name,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn restore_with(&self, kind: Kind) -> String {
|
|
||||||
match kind {
|
|
||||||
Kind::Archive => format!("pg_restore -U {} --dbname={}", self.user, self.name),
|
|
||||||
Kind::Sql => format!(
|
|
||||||
"psql -q -o /dev/null -U {} -d {} -v ON_ERROR_STOP=1 --single-transaction",
|
|
||||||
self.user, self.name
|
|
||||||
),
|
|
||||||
// a cluster dump connects to each database itself, and stopping at the
|
|
||||||
// first error would stop at a role that is already there
|
|
||||||
Kind::Cluster => format!("psql -q -o /dev/null -U {} -d postgres", self.user),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
enum Dump {
|
|
||||||
Directory,
|
|
||||||
Gzip,
|
|
||||||
Header(Vec<u8>),
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Dump {
|
|
||||||
fn of(path: &Path) -> Result<Self> {
|
|
||||||
if path.is_dir() {
|
|
||||||
return Ok(Self::Directory);
|
|
||||||
}
|
|
||||||
|
|
||||||
let header = read_header(path)?;
|
|
||||||
if header.starts_with(GZIP_MAGIC) {
|
|
||||||
return Ok(Self::Gzip);
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(Self::Header(header))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// how the dump has to be fed back in: pg_restore for an archive, psql into the
|
|
||||||
// database for a single database dump, psql into postgres for a whole cluster
|
|
||||||
#[derive(Clone, Copy, PartialEq)]
|
|
||||||
enum Kind {
|
|
||||||
Archive,
|
|
||||||
Sql,
|
|
||||||
Cluster,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Kind {
|
|
||||||
fn of(header: &[u8]) -> Self {
|
|
||||||
if header.starts_with(CUSTOM_MAGIC) || header.starts_with(TAR_MAGIC) {
|
|
||||||
return Self::Archive;
|
|
||||||
}
|
|
||||||
|
|
||||||
if String::from_utf8_lossy(header).contains(CLUSTER_MARKER) {
|
|
||||||
return Self::Cluster;
|
|
||||||
}
|
|
||||||
|
|
||||||
Self::Sql
|
|
||||||
}
|
|
||||||
|
|
||||||
fn tool(&self) -> &'static str {
|
|
||||||
match self {
|
|
||||||
Self::Archive => "pg_restore",
|
|
||||||
Self::Sql | Self::Cluster => "psql",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// pg_dumpall recreates roles the cluster already has, so this is the expected
|
|
||||||
// shape of a working restore rather than a problem
|
|
||||||
fn is_existing_role_error(line: &str) -> bool {
|
|
||||||
line.starts_with("ERROR:") && line.contains("role \"") && line.ends_with("already exists")
|
|
||||||
}
|
|
||||||
|
|
||||||
fn restore_cluster(db: &Database, script: &str, file: &Path) -> Result<()> {
|
|
||||||
let out = piped(db, script, file)?.output().context("running psql")?;
|
|
||||||
|
|
||||||
io::stdout().write_all(&out.stdout).ok();
|
|
||||||
|
|
||||||
let mut existing = 0;
|
|
||||||
for line in String::from_utf8_lossy(&out.stderr).lines() {
|
|
||||||
if is_existing_role_error(line) {
|
|
||||||
existing += 1;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
eprintln!("{line}");
|
|
||||||
}
|
|
||||||
|
|
||||||
if existing > 0 {
|
|
||||||
eprintln!(
|
|
||||||
"left {existing} existing role{} alone",
|
|
||||||
if existing == 1 { "" } else { "s" }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if !out.status.success() {
|
|
||||||
bail!("psql failed, the database is left empty");
|
|
||||||
}
|
|
||||||
|
|
||||||
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>> {
|
|
||||||
let mut file = File::open(path).with_context(|| format!("opening {}", path.display()))?;
|
|
||||||
let mut header = vec![0; HEADER_LEN];
|
|
||||||
|
|
||||||
let read = file
|
|
||||||
.read(&mut header)
|
|
||||||
.with_context(|| format!("reading {}", path.display()))?;
|
|
||||||
header.truncate(read);
|
|
||||||
|
|
||||||
Ok(header)
|
|
||||||
}
|
|
||||||
|
|
||||||
// a pipeline reports the last stage's status by default, so a first stage that
|
|
||||||
// dies mid-stream looks like success; not for pipelines that close the pipe
|
|
||||||
// early on purpose, where SIGPIPE would then read as failure
|
|
||||||
fn pipefail(script: &str) -> String {
|
|
||||||
format!("set -o pipefail; {script}")
|
|
||||||
}
|
|
||||||
|
|
||||||
fn piped(db: &Database, script: &str, input: &Path) -> Result<std::process::Command> {
|
|
||||||
let file = File::open(input).with_context(|| format!("opening {}", input.display()))?;
|
|
||||||
let mut command = CommandBuilder::docker()
|
|
||||||
.args("exec -i")
|
|
||||||
.arg(&db.container)
|
|
||||||
.args("sh -c")
|
|
||||||
.arg(script)
|
|
||||||
.build()?;
|
|
||||||
|
|
||||||
command.stdin(Stdio::from(file));
|
|
||||||
Ok(command)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn wait_until_ready(db: &Database) -> Result<()> {
|
|
||||||
if command_builder::is_dry_run() {
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
|
|
||||||
let deadline = Instant::now() + READY_TIMEOUT;
|
|
||||||
|
|
||||||
loop {
|
|
||||||
let ready = CommandBuilder::docker()
|
|
||||||
.args("exec")
|
|
||||||
.arg(&db.container)
|
|
||||||
.args("pg_isready -U")
|
|
||||||
.arg(&db.user)
|
|
||||||
.args("-d")
|
|
||||||
.arg(&db.name)
|
|
||||||
.build()?
|
|
||||||
.stdout(Stdio::null())
|
|
||||||
.spawn()?
|
|
||||||
.wait()?
|
|
||||||
.success();
|
|
||||||
|
|
||||||
if ready {
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
|
|
||||||
if Instant::now() >= deadline {
|
|
||||||
bail!(
|
|
||||||
"{} did not accept connections within {} seconds",
|
|
||||||
db.service,
|
|
||||||
READY_TIMEOUT.as_secs()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
thread::sleep(POLL_INTERVAL);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn when_ready(db: &Database, command: CommandBuilder) -> Result<()> {
|
|
||||||
wait_until_ready(db)?;
|
|
||||||
command.exec()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn in_container(db: &Database) -> CommandBuilder {
|
|
||||||
CommandBuilder::docker().args("exec").arg(&db.container)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn import(file: &Path) -> Result<()> {
|
|
||||||
let dump = Dump::of(file)?;
|
|
||||||
let db = Database::resolve()?;
|
|
||||||
|
|
||||||
eprintln!("stopping all containers");
|
|
||||||
docker_compose::stop()?;
|
|
||||||
|
|
||||||
eprintln!("starting db container");
|
|
||||||
docker_compose::start(Some(&db.service))?;
|
|
||||||
|
|
||||||
let remote = remote_dump();
|
|
||||||
|
|
||||||
// a directory cannot be streamed, so it is the one shape that gets copied in
|
|
||||||
if matches!(dump, Dump::Directory) {
|
|
||||||
when_ready(
|
|
||||||
&db,
|
|
||||||
CommandBuilder::docker()
|
|
||||||
.args("cp -L")
|
|
||||||
.arg(file.to_string_lossy())
|
|
||||||
.arg(format!("{}:{remote}", db.container)),
|
|
||||||
)?;
|
|
||||||
}
|
|
||||||
|
|
||||||
let (kind, restore) = match &dump {
|
|
||||||
Dump::Directory => (Kind::Archive, None),
|
|
||||||
Dump::Header(header) => {
|
|
||||||
let kind = Kind::of(header);
|
|
||||||
|
|
||||||
(kind, Some(db.restore_with(kind)))
|
|
||||||
}
|
|
||||||
Dump::Gzip => {
|
|
||||||
wait_until_ready(&db)?;
|
|
||||||
let out = piped(&db, &format!("gunzip -c | head -c {HEADER_LEN}"), file)?
|
|
||||||
.output()
|
|
||||||
.context("reading the compressed dump's header")?;
|
|
||||||
|
|
||||||
// head exits 0 whatever gunzip did, so an empty header is the only
|
|
||||||
// sign that decompression failed
|
|
||||||
if !out.status.success() || out.stdout.is_empty() {
|
|
||||||
let reason = String::from_utf8_lossy(&out.stderr);
|
|
||||||
let reason = reason.trim();
|
|
||||||
|
|
||||||
bail!(
|
|
||||||
"could not decompress {}: {}",
|
|
||||||
file.display(),
|
|
||||||
if reason.is_empty() {
|
|
||||||
"no data came out"
|
|
||||||
} else {
|
|
||||||
reason
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
let kind = Kind::of(&out.stdout);
|
|
||||||
|
|
||||||
(
|
|
||||||
kind,
|
|
||||||
Some(pipefail(&format!("gunzip -c | {}", db.restore_with(kind)))),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let tool = kind.tool();
|
|
||||||
eprintln!("restoring database with {tool}");
|
|
||||||
|
|
||||||
when_ready(
|
|
||||||
&db,
|
|
||||||
in_container(&db)
|
|
||||||
.args("dropdb -U")
|
|
||||||
.arg(&db.user)
|
|
||||||
.arg(&db.name),
|
|
||||||
)?;
|
|
||||||
|
|
||||||
// a cluster dump creates the database itself, and would trip over one that
|
|
||||||
// is already there
|
|
||||||
if kind != Kind::Cluster {
|
|
||||||
when_ready(
|
|
||||||
&db,
|
|
||||||
in_container(&db)
|
|
||||||
.args("createdb -U")
|
|
||||||
.arg(&db.user)
|
|
||||||
.args("-E utf8 -T template0")
|
|
||||||
.arg(&db.name),
|
|
||||||
)?;
|
|
||||||
}
|
|
||||||
|
|
||||||
wait_until_ready(&db)?;
|
|
||||||
if command_builder::is_dry_run() {
|
|
||||||
eprintln!("would restore with {tool}");
|
|
||||||
} else {
|
|
||||||
match (kind, restore.as_deref()) {
|
|
||||||
// psql's output is read rather than streamed here, to keep the expected
|
|
||||||
// role errors out of the way
|
|
||||||
(Kind::Cluster, Some(script)) => restore_cluster(&db, script, file)?,
|
|
||||||
(_, restore) => {
|
|
||||||
let status = match restore {
|
|
||||||
Some(script) => piped(&db, script, file)?.spawn()?.wait()?,
|
|
||||||
None => in_container(&db)
|
|
||||||
.args("pg_restore -U")
|
|
||||||
.arg(&db.user)
|
|
||||||
.arg(format!("--dbname={}", db.name))
|
|
||||||
.arg(&remote)
|
|
||||||
.build()?
|
|
||||||
.spawn()?
|
|
||||||
.wait()?,
|
|
||||||
};
|
|
||||||
|
|
||||||
if !status.success() {
|
|
||||||
bail!("{tool} failed, the database is left empty");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if matches!(dump, Dump::Directory) {
|
|
||||||
let _ = in_container(&db).args("rm -rf").arg(&remote).exec();
|
|
||||||
}
|
|
||||||
|
|
||||||
eprintln!("restarting containers");
|
|
||||||
docker_compose::stop()?;
|
|
||||||
docker_compose::up()?;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn dump(file: &PathBuf, format: Format, gzip: bool) -> Result<()> {
|
|
||||||
let db = Database::resolve()?;
|
|
||||||
|
|
||||||
if format == Format::Directory {
|
|
||||||
if gzip {
|
|
||||||
bail!("a directory dump is a directory of already compressed files, not a stream");
|
|
||||||
}
|
|
||||||
|
|
||||||
return dump_directory(&db, file);
|
|
||||||
}
|
|
||||||
|
|
||||||
eprintln!("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
|
|
||||||
let partial = suffixed(file, ".partial");
|
|
||||||
// a dry run produces no dump, so it must not lay a hand on the target either
|
|
||||||
let stdout = if command_builder::is_dry_run() {
|
|
||||||
Stdio::null()
|
|
||||||
} else {
|
|
||||||
Stdio::from(
|
|
||||||
File::create(&partial).with_context(|| format!("creating {}", partial.display()))?,
|
|
||||||
)
|
|
||||||
};
|
|
||||||
|
|
||||||
let dumped = if gzip {
|
|
||||||
// the whole pipeline has to arrive as one shell argument
|
|
||||||
CommandBuilder::docker()
|
|
||||||
.args("exec")
|
|
||||||
.arg(&db.container)
|
|
||||||
.args("sh -c")
|
|
||||||
.arg(pipefail(&format!("{} | gzip", dump_command(&db, format))))
|
|
||||||
.exec_redirect_stdout(stdout)
|
|
||||||
} else {
|
|
||||||
let dumping = match format.flag() {
|
|
||||||
Some(flag) => in_container(&db)
|
|
||||||
.args("pg_dump -U")
|
|
||||||
.arg(&db.user)
|
|
||||||
.arg(format!("--format={flag}"))
|
|
||||||
.arg(&db.name),
|
|
||||||
None => in_container(&db).args("pg_dumpall -U").arg(&db.user),
|
|
||||||
};
|
|
||||||
|
|
||||||
dumping.exec_redirect_stdout(stdout)
|
|
||||||
};
|
|
||||||
|
|
||||||
if let Err(e) = dumped {
|
|
||||||
let _ = fs::remove_file(&partial);
|
|
||||||
return Err(e);
|
|
||||||
}
|
|
||||||
|
|
||||||
if command_builder::is_dry_run() {
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
|
|
||||||
fs::rename(&partial, file)
|
|
||||||
.with_context(|| format!("renaming {} to {}", partial.display(), file.display()))?;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
// what to run inside the container, without the docker exec in front of it
|
|
||||||
fn dump_command(db: &Database, format: Format) -> String {
|
|
||||||
match format.flag() {
|
|
||||||
Some(flag) => format!("pg_dump -U {} --format={flag} {}", db.user, db.name),
|
|
||||||
None => format!("pg_dumpall -U {}", db.user),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// pg_dump writes a directory format dump itself rather than to stdout, so it lands
|
|
||||||
// in the container and comes back with docker cp
|
|
||||||
fn dump_directory(db: &Database, target: &Path) -> Result<()> {
|
|
||||||
if target.exists() {
|
|
||||||
bail!(
|
|
||||||
"{} already exists; a directory dump will not be written over it",
|
|
||||||
target.display()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
eprintln!("dumping to local directory {}", target.display());
|
|
||||||
let remote = remote_dump();
|
|
||||||
|
|
||||||
in_container(db)
|
|
||||||
.args("pg_dump -U")
|
|
||||||
.arg(&db.user)
|
|
||||||
.args("--format=d -f")
|
|
||||||
.arg(&remote)
|
|
||||||
.arg(&db.name)
|
|
||||||
.exec()?;
|
|
||||||
|
|
||||||
let copied = CommandBuilder::docker()
|
|
||||||
.args("cp")
|
|
||||||
.arg(format!("{}:{remote}", db.container))
|
|
||||||
.arg(target.to_string_lossy())
|
|
||||||
.exec();
|
|
||||||
|
|
||||||
let _ = in_container(db).args("rm -rf").arg(&remote).exec();
|
|
||||||
|
|
||||||
copied
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::{GZIP_MAGIC, Kind, is_existing_role_error};
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn only_the_existing_role_complaint_is_expected() {
|
|
||||||
assert!(is_existing_role_error(
|
|
||||||
r#"ERROR: role "postgres" already exists"#
|
|
||||||
));
|
|
||||||
|
|
||||||
for line in [
|
|
||||||
r#"ERROR: relation "things" already exists"#,
|
|
||||||
r#"ERROR: database "db" already exists"#,
|
|
||||||
"ERROR: syntax error at or near \"slect\"",
|
|
||||||
"NOTICE: role \"postgres\" already exists",
|
|
||||||
] {
|
|
||||||
assert!(!is_existing_role_error(line), "{line}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn an_archive_is_recognised_by_its_magic() {
|
|
||||||
assert!(matches!(Kind::of(b"PGDMP\x01\x0f"), Kind::Archive));
|
|
||||||
assert!(matches!(Kind::of(b"toc.dat\x00\x00"), Kind::Archive));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn a_cluster_dump_is_recognised_by_its_header() {
|
|
||||||
let header = b"--\n-- PostgreSQL database cluster dump\n--\n\n\\restrict abc\n";
|
|
||||||
assert!(matches!(Kind::of(header), Kind::Cluster));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn anything_else_is_a_single_database_dump() {
|
|
||||||
for header in [
|
|
||||||
&b"--\n-- PostgreSQL database dump\n"[..],
|
|
||||||
&b"BEGIN;"[..],
|
|
||||||
&b""[..],
|
|
||||||
&b"PGD"[..],
|
|
||||||
&b"toc.da"[..],
|
|
||||||
] {
|
|
||||||
assert!(matches!(Kind::of(header), Kind::Sql), "{header:?}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn gzip_is_recognised_by_its_magic() {
|
|
||||||
assert!(b"\x1f\x8b\x08\x00rest".starts_with(GZIP_MAGIC));
|
|
||||||
assert!(!b"PGDMP".starts_with(GZIP_MAGIC));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user