refactor: give every command we run a type of its own
This commit is contained in:
7
Cargo.lock
generated
7
Cargo.lock
generated
@@ -10,6 +10,7 @@ dependencies = [
|
|||||||
"clap",
|
"clap",
|
||||||
"clap_complete",
|
"clap_complete",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
"shlex",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -213,6 +214,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,7 @@ clap = { version = "4.6.6", features = ["derive", "env"] }
|
|||||||
clap_complete = "4.6.9"
|
clap_complete = "4.6.9"
|
||||||
anyhow = "1.0.104"
|
anyhow = "1.0.104"
|
||||||
serde_json = "1.0.145"
|
serde_json = "1.0.145"
|
||||||
|
shlex = "2.0.1"
|
||||||
|
|
||||||
[build-dependencies]
|
[build-dependencies]
|
||||||
clap = { version = "4.6.6", features = ["derive", "env"] }
|
clap = { version = "4.6.6", features = ["derive", "env"] }
|
||||||
|
|||||||
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
80
src/cmd/mod.rs
Normal file
80
src/cmd/mod.rs
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
mod argv;
|
||||||
|
mod compose;
|
||||||
|
mod django;
|
||||||
|
mod docker;
|
||||||
|
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 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,123 +0,0 @@
|
|||||||
use anyhow::{Context, Result, bail};
|
|
||||||
use std::{
|
|
||||||
fmt::Display,
|
|
||||||
os::unix::process::CommandExt,
|
|
||||||
process::{Command, ExitStatus, Stdio},
|
|
||||||
};
|
|
||||||
|
|
||||||
use crate::ctx::Ctx;
|
|
||||||
|
|
||||||
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, ctx: &Ctx) -> Result<Command> {
|
|
||||||
if ctx.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, ctx: &Ctx) -> Result<String> {
|
|
||||||
let shown = self.to_string();
|
|
||||||
let out = self.build(ctx)?.output()?;
|
|
||||||
|
|
||||||
check(&shown, out.status)?;
|
|
||||||
Ok(String::from_utf8(out.stdout)?)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn exec(self, ctx: &Ctx) -> Result<()> {
|
|
||||||
if ctx.dry_run {
|
|
||||||
eprintln!("would run `{self}`");
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
|
|
||||||
let shown = self.to_string();
|
|
||||||
let status = self.build(ctx)?.spawn()?.wait()?;
|
|
||||||
|
|
||||||
check(&shown, status)
|
|
||||||
}
|
|
||||||
|
|
||||||
// replaces this process, so the command's exit code and signals become ours
|
|
||||||
pub fn exec_replace(self, ctx: &Ctx) -> Result<()> {
|
|
||||||
if ctx.dry_run {
|
|
||||||
eprintln!("would run `{self}`");
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
|
|
||||||
let shown = self.to_string();
|
|
||||||
let error = self.build(ctx)?.exec();
|
|
||||||
|
|
||||||
Err(error).with_context(|| format!("running `{shown}`"))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn exec_redirect_stdout(self, ctx: &Ctx, stdio: Stdio) -> Result<()> {
|
|
||||||
if ctx.dry_run {
|
|
||||||
eprintln!("would run `{self}`");
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
|
|
||||||
let shown = self.to_string();
|
|
||||||
let status = self.build(ctx)?.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"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
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;
|
use crate::ctx::Ctx;
|
||||||
|
|
||||||
const POSTGRES_IMAGES: [&str; 4] = ["postg", "timescale", "pgvector", "citus"];
|
const POSTGRES_IMAGES: [&str; 4] = ["postg", "timescale", "pgvector", "citus"];
|
||||||
@@ -13,21 +13,8 @@ pub struct Compose {
|
|||||||
|
|
||||||
impl Compose {
|
impl Compose {
|
||||||
pub fn resolve(ctx: &Ctx) -> 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(ctx)?
|
|
||||||
.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()
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
use std::process::ExitCode;
|
use std::process::ExitCode;
|
||||||
|
|
||||||
mod cli;
|
mod cli;
|
||||||
mod command_builder;
|
mod cmd;
|
||||||
mod compose;
|
mod compose;
|
||||||
mod ctx;
|
mod ctx;
|
||||||
mod scripts;
|
mod scripts;
|
||||||
|
|||||||
@@ -4,9 +4,9 @@ use std::path::{Path, PathBuf};
|
|||||||
|
|
||||||
use anyhow::{Result, anyhow};
|
use anyhow::{Result, anyhow};
|
||||||
|
|
||||||
|
use crate::cmd::{Bash, Cmd, Manage, Words};
|
||||||
use crate::compose::Compose;
|
use crate::compose::Compose;
|
||||||
use crate::ctx::Ctx;
|
use crate::ctx::Ctx;
|
||||||
use crate::scripts::docker_compose;
|
|
||||||
|
|
||||||
const DEBUG_TEMPLATE: &str = r#"from django.core.management.base import BaseCommand
|
const DEBUG_TEMPLATE: &str = r#"from django.core.management.base import BaseCommand
|
||||||
|
|
||||||
@@ -55,19 +55,15 @@ pub fn make_command(app: &PathBuf, name: &str) -> Result<()> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn bash(ctx: &Ctx) -> Result<()> {
|
pub fn bash(ctx: &Ctx) -> Result<()> {
|
||||||
run(ctx, &["bash".to_string()])
|
Bash.in_service(&service(ctx)?).replace(ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn run(ctx: &Ctx, rest: &[String]) -> Result<()> {
|
pub fn run(ctx: &Ctx, rest: &[String]) -> Result<()> {
|
||||||
let service = Compose::resolve(ctx)?.django()?;
|
Words::new(rest).in_service(&service(ctx)?).replace(ctx)
|
||||||
docker_compose::run(ctx, &service, rest)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn manage(ctx: &Ctx, rest: &[String]) -> Result<()> {
|
pub fn manage(ctx: &Ctx, rest: &[String]) -> Result<()> {
|
||||||
let mut args = vec!["python".to_string(), "manage.py".to_string()];
|
Manage::new(rest).in_service(&service(ctx)?).replace(ctx)
|
||||||
args.extend_from_slice(rest);
|
|
||||||
|
|
||||||
run(ctx, &args)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// shortcuts
|
// shortcuts
|
||||||
@@ -76,10 +72,10 @@ pub fn makemigrations(ctx: &Ctx) -> Result<()> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn migrate(ctx: &Ctx, rest: &[String]) -> Result<()> {
|
pub fn migrate(ctx: &Ctx, rest: &[String]) -> Result<()> {
|
||||||
let mut full_rest = vec!["migrate".to_string()];
|
let mut args = vec!["migrate".to_string()];
|
||||||
full_rest.extend_from_slice(rest);
|
args.extend_from_slice(rest);
|
||||||
|
|
||||||
manage(ctx, &full_rest)
|
manage(ctx, &args)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn shell(ctx: &Ctx) -> Result<()> {
|
pub fn shell(ctx: &Ctx) -> Result<()> {
|
||||||
@@ -90,6 +86,10 @@ pub fn test(ctx: &Ctx) -> Result<()> {
|
|||||||
manage(ctx, &["test".to_string()])
|
manage(ctx, &["test".to_string()])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn service(ctx: &Ctx) -> Result<String> {
|
||||||
|
Compose::resolve(ctx)?.django()
|
||||||
|
}
|
||||||
|
|
||||||
fn safe_create_file(path: PathBuf) -> Result<File, std::io::Error> {
|
fn safe_create_file(path: PathBuf) -> Result<File, std::io::Error> {
|
||||||
OpenOptions::new().write(true).create_new(true).open(path)
|
OpenOptions::new().write(true).create_new(true).open(path)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,29 +0,0 @@
|
|||||||
use anyhow::Result;
|
|
||||||
|
|
||||||
use crate::command_builder::CommandBuilder;
|
|
||||||
use crate::ctx::Ctx;
|
|
||||||
|
|
||||||
pub fn run(ctx: &Ctx, service: &str, rest: &[String]) -> Result<()> {
|
|
||||||
CommandBuilder::docker_compose()
|
|
||||||
.args("run --rm")
|
|
||||||
.arg(service)
|
|
||||||
.args(rest)
|
|
||||||
.exec_replace(ctx)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn start(ctx: &Ctx, service: Option<&str>) -> Result<()> {
|
|
||||||
let mut command = CommandBuilder::docker_compose().args("start");
|
|
||||||
if let Some(service) = service {
|
|
||||||
command = command.arg(service);
|
|
||||||
}
|
|
||||||
|
|
||||||
command.exec(ctx)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn stop(ctx: &Ctx) -> Result<()> {
|
|
||||||
CommandBuilder::docker_compose().args("stop").exec(ctx)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn up(ctx: &Ctx) -> Result<()> {
|
|
||||||
CommandBuilder::docker_compose().args("up -d").exec(ctx)
|
|
||||||
}
|
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -8,10 +8,13 @@ use std::{
|
|||||||
time::{Duration, Instant},
|
time::{Duration, Instant},
|
||||||
};
|
};
|
||||||
|
|
||||||
use super::docker_compose;
|
|
||||||
use crate::cli::Format;
|
use crate::cli::Format;
|
||||||
|
use crate::cmd::{
|
||||||
|
Cmd, Cp, CreateDb, DropDb, Gunzip, Gzip, Head, PgDump, PgDumpAll, PgIsReady, PgRestore, Ps,
|
||||||
|
Psql, Rm, Start, Stop, Up,
|
||||||
|
};
|
||||||
|
use crate::compose::Compose;
|
||||||
use crate::ctx::Ctx;
|
use crate::ctx::Ctx;
|
||||||
use crate::{command_builder::CommandBuilder, compose::Compose};
|
|
||||||
|
|
||||||
const CUSTOM_MAGIC: &[u8] = b"PGDMP";
|
const CUSTOM_MAGIC: &[u8] = b"PGDMP";
|
||||||
const TAR_MAGIC: &[u8] = b"toc.dat";
|
const TAR_MAGIC: &[u8] = b"toc.dat";
|
||||||
@@ -42,12 +45,7 @@ impl Database {
|
|||||||
let service = compose.postgres()?;
|
let service = compose.postgres()?;
|
||||||
let (user, name) = compose.postgres_credentials(&service);
|
let (user, name) = compose.postgres_credentials(&service);
|
||||||
|
|
||||||
let container = CommandBuilder::docker_compose()
|
let container = Ps::id_of(&service).capture(ctx)?.trim().to_string();
|
||||||
.args("ps -q")
|
|
||||||
.arg(&service)
|
|
||||||
.exec_get_stdout(ctx)?
|
|
||||||
.trim()
|
|
||||||
.to_string();
|
|
||||||
|
|
||||||
if container.is_empty() {
|
if container.is_empty() {
|
||||||
return Err(anyhow!("service {service} has no running container"));
|
return Err(anyhow!("service {service} has no running container"));
|
||||||
@@ -61,16 +59,12 @@ impl Database {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn restore_with(&self, kind: Kind) -> String {
|
// what reads this shape of dump back in
|
||||||
|
fn restore_with(&self, kind: Kind) -> Box<dyn Cmd + '_> {
|
||||||
match kind {
|
match kind {
|
||||||
Kind::Archive => format!("pg_restore -U {} --dbname={}", self.user, self.name),
|
Kind::Archive => Box::new(PgRestore::new(&self.user, &self.name)),
|
||||||
Kind::Sql => format!(
|
Kind::Sql => Box::new(Psql::new(&self.user, &self.name).atomic()),
|
||||||
"psql -q -o /dev/null -U {} -d {} -v ON_ERROR_STOP=1 --single-transaction",
|
Kind::Cluster => Box::new(Psql::new(&self.user, "postgres")),
|
||||||
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),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -132,9 +126,11 @@ fn is_existing_role_error(line: &str) -> bool {
|
|||||||
line.starts_with("ERROR:") && line.contains("role \"") && line.ends_with("already exists")
|
line.starts_with("ERROR:") && line.contains("role \"") && line.ends_with("already exists")
|
||||||
}
|
}
|
||||||
|
|
||||||
fn restore_cluster(ctx: &Ctx, db: &Database, script: &str, file: &Path) -> Result<()> {
|
fn restore_cluster(ctx: &Ctx, db: &Database, restore: &dyn Cmd, file: &Path) -> Result<()> {
|
||||||
let out = piped(ctx, db, script, file)?
|
let out = restore
|
||||||
.output()
|
.in_container(&db.container)
|
||||||
|
.interactive()
|
||||||
|
.stdin_from_captured(ctx, file)
|
||||||
.context("running psql")?;
|
.context("running psql")?;
|
||||||
|
|
||||||
io::stdout().write_all(&out.stdout).ok();
|
io::stdout().write_all(&out.stdout).ok();
|
||||||
@@ -182,26 +178,6 @@ fn read_header(path: &Path) -> Result<Vec<u8>> {
|
|||||||
Ok(header)
|
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(ctx: &Ctx, 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(ctx)?;
|
|
||||||
|
|
||||||
command.stdin(Stdio::from(file));
|
|
||||||
Ok(command)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn wait_until_ready(ctx: &Ctx, db: &Database) -> Result<()> {
|
fn wait_until_ready(ctx: &Ctx, db: &Database) -> Result<()> {
|
||||||
if ctx.dry_run {
|
if ctx.dry_run {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
@@ -210,18 +186,12 @@ fn wait_until_ready(ctx: &Ctx, db: &Database) -> Result<()> {
|
|||||||
let deadline = Instant::now() + READY_TIMEOUT;
|
let deadline = Instant::now() + READY_TIMEOUT;
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
let ready = CommandBuilder::docker()
|
let ready = PgIsReady {
|
||||||
.args("exec")
|
username: &db.user,
|
||||||
.arg(&db.container)
|
dbname: &db.name,
|
||||||
.args("pg_isready -U")
|
}
|
||||||
.arg(&db.user)
|
.in_container(&db.container)
|
||||||
.args("-d")
|
.quietly_succeeds(ctx)?;
|
||||||
.arg(&db.name)
|
|
||||||
.build(ctx)?
|
|
||||||
.stdout(Stdio::null())
|
|
||||||
.spawn()?
|
|
||||||
.wait()?
|
|
||||||
.success();
|
|
||||||
|
|
||||||
if ready {
|
if ready {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
@@ -239,13 +209,9 @@ fn wait_until_ready(ctx: &Ctx, db: &Database) -> Result<()> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn when_ready(ctx: &Ctx, db: &Database, command: CommandBuilder) -> Result<()> {
|
fn when_ready(ctx: &Ctx, db: &Database, command: &dyn Cmd) -> Result<()> {
|
||||||
wait_until_ready(ctx, db)?;
|
wait_until_ready(ctx, db)?;
|
||||||
command.exec(ctx)
|
command.run(ctx)
|
||||||
}
|
|
||||||
|
|
||||||
fn in_container(db: &Database) -> CommandBuilder {
|
|
||||||
CommandBuilder::docker().args("exec").arg(&db.container)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn import(ctx: &Ctx, file: &Path) -> Result<()> {
|
pub fn import(ctx: &Ctx, file: &Path) -> Result<()> {
|
||||||
@@ -253,10 +219,10 @@ pub fn import(ctx: &Ctx, file: &Path) -> Result<()> {
|
|||||||
let db = Database::resolve(ctx)?;
|
let db = Database::resolve(ctx)?;
|
||||||
|
|
||||||
eprintln!("stopping all containers");
|
eprintln!("stopping all containers");
|
||||||
docker_compose::stop(ctx)?;
|
Stop.run(ctx)?;
|
||||||
|
|
||||||
eprintln!("starting db container");
|
eprintln!("starting db container");
|
||||||
docker_compose::start(ctx, Some(&db.service))?;
|
Start::service(&db.service).run(ctx)?;
|
||||||
|
|
||||||
let remote = remote_dump();
|
let remote = remote_dump();
|
||||||
|
|
||||||
@@ -265,24 +231,22 @@ pub fn import(ctx: &Ctx, file: &Path) -> Result<()> {
|
|||||||
when_ready(
|
when_ready(
|
||||||
ctx,
|
ctx,
|
||||||
&db,
|
&db,
|
||||||
CommandBuilder::docker()
|
&Cp::into_container(&file.to_string_lossy(), &db.container, &remote),
|
||||||
.args("cp -L")
|
|
||||||
.arg(file.to_string_lossy())
|
|
||||||
.arg(format!("{}:{remote}", db.container)),
|
|
||||||
)?;
|
)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
let (kind, restore) = match &dump {
|
let kind = match &dump {
|
||||||
Dump::Directory => (Kind::Archive, None),
|
Dump::Directory => Kind::Archive,
|
||||||
Dump::Header(header) => {
|
Dump::Header(header) => Kind::of(header),
|
||||||
let kind = Kind::of(header);
|
|
||||||
|
|
||||||
(kind, Some(db.restore_with(kind)))
|
|
||||||
}
|
|
||||||
Dump::Gzip => {
|
Dump::Gzip => {
|
||||||
wait_until_ready(ctx, &db)?;
|
wait_until_ready(ctx, &db)?;
|
||||||
let out = piped(ctx, &db, &format!("gunzip -c | head -c {HEADER_LEN}"), file)?
|
let out = Gunzip
|
||||||
.output()
|
.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")?;
|
.context("reading the compressed dump's header")?;
|
||||||
|
|
||||||
// head exits 0 whatever gunzip did, so an empty header is the only
|
// head exits 0 whatever gunzip did, so an empty header is the only
|
||||||
@@ -302,12 +266,7 @@ pub fn import(ctx: &Ctx, file: &Path) -> Result<()> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
let kind = Kind::of(&out.stdout);
|
Kind::of(&out.stdout)
|
||||||
|
|
||||||
(
|
|
||||||
kind,
|
|
||||||
Some(pipefail(&format!("gunzip -c | {}", db.restore_with(kind)))),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -317,10 +276,11 @@ pub fn import(ctx: &Ctx, file: &Path) -> Result<()> {
|
|||||||
when_ready(
|
when_ready(
|
||||||
ctx,
|
ctx,
|
||||||
&db,
|
&db,
|
||||||
in_container(&db)
|
&DropDb {
|
||||||
.args("dropdb -U")
|
username: &db.user,
|
||||||
.arg(&db.user)
|
dbname: &db.name,
|
||||||
.arg(&db.name),
|
}
|
||||||
|
.in_container(&db.container),
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
// a cluster dump creates the database itself, and would trip over one that
|
// a cluster dump creates the database itself, and would trip over one that
|
||||||
@@ -329,11 +289,11 @@ pub fn import(ctx: &Ctx, file: &Path) -> Result<()> {
|
|||||||
when_ready(
|
when_ready(
|
||||||
ctx,
|
ctx,
|
||||||
&db,
|
&db,
|
||||||
in_container(&db)
|
&CreateDb {
|
||||||
.args("createdb -U")
|
username: &db.user,
|
||||||
.arg(&db.user)
|
dbname: &db.name,
|
||||||
.args("-E utf8 -T template0")
|
}
|
||||||
.arg(&db.name),
|
.in_container(&db.container),
|
||||||
)?;
|
)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -341,23 +301,35 @@ pub fn import(ctx: &Ctx, file: &Path) -> Result<()> {
|
|||||||
if ctx.dry_run {
|
if ctx.dry_run {
|
||||||
eprintln!("would restore with {tool}");
|
eprintln!("would restore with {tool}");
|
||||||
} else {
|
} else {
|
||||||
match (kind, restore.as_deref()) {
|
// a directory dump was copied in whole, so pg_restore reads it from the
|
||||||
// psql's output is read rather than streamed here, to keep the expected
|
// container; every other shape is fed in on stdin, through gunzip when it
|
||||||
// role errors out of the way
|
// arrives compressed
|
||||||
(Kind::Cluster, Some(script)) => restore_cluster(ctx, &db, script, file)?,
|
if matches!(dump, Dump::Directory) {
|
||||||
(_, restore) => {
|
let status = PgRestore::new(&db.user, &db.name)
|
||||||
let status = match restore {
|
.from(&remote)
|
||||||
Some(script) => piped(ctx, &db, script, file)?.spawn()?.wait()?,
|
.in_container(&db.container)
|
||||||
None => in_container(&db)
|
.status(ctx)?;
|
||||||
.args("pg_restore -U")
|
|
||||||
.arg(&db.user)
|
if !status.success() {
|
||||||
.arg(format!("--dbname={}", db.name))
|
bail!("{tool} failed, the database is left empty");
|
||||||
.arg(&remote)
|
}
|
||||||
.build(ctx)?
|
} else {
|
||||||
.spawn()?
|
let restore = db.restore_with(kind);
|
||||||
.wait()?,
|
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() {
|
if !status.success() {
|
||||||
bail!("{tool} failed, the database is left empty");
|
bail!("{tool} failed, the database is left empty");
|
||||||
}
|
}
|
||||||
@@ -366,12 +338,12 @@ pub fn import(ctx: &Ctx, file: &Path) -> Result<()> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if matches!(dump, Dump::Directory) {
|
if matches!(dump, Dump::Directory) {
|
||||||
let _ = in_container(&db).args("rm -rf").arg(&remote).exec(ctx);
|
let _ = Rm::recursive(&remote).in_container(&db.container).run(ctx);
|
||||||
}
|
}
|
||||||
|
|
||||||
eprintln!("restarting containers");
|
eprintln!("restarting containers");
|
||||||
docker_compose::stop(ctx)?;
|
Stop.run(ctx)?;
|
||||||
docker_compose::up(ctx)?;
|
Up.run(ctx)?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -401,25 +373,15 @@ pub fn dump(ctx: &Ctx, file: &PathBuf, format: Format, gzip: bool) -> Result<()>
|
|||||||
)
|
)
|
||||||
};
|
};
|
||||||
|
|
||||||
let dumped = if gzip {
|
let dumping = dump_command(&db, format);
|
||||||
// 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(ctx, 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(ctx, stdout)
|
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 {
|
if let Err(e) = dumped {
|
||||||
@@ -437,11 +399,11 @@ pub fn dump(ctx: &Ctx, file: &PathBuf, format: Format, gzip: bool) -> Result<()>
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
// what to run inside the container, without the docker exec in front of it
|
// pg_dump for one database, pg_dumpall for a cluster, which has no format letter
|
||||||
fn dump_command(db: &Database, format: Format) -> String {
|
fn dump_command(db: &Database, format: Format) -> Box<dyn Cmd + '_> {
|
||||||
match format.flag() {
|
match format.flag() {
|
||||||
Some(flag) => format!("pg_dump -U {} --format={flag} {}", db.user, db.name),
|
Some(flag) => Box::new(PgDump::new(&db.user, &db.name, flag)),
|
||||||
None => format!("pg_dumpall -U {}", db.user),
|
None => Box::new(PgDumpAll { username: &db.user }),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -458,21 +420,14 @@ fn dump_directory(ctx: &Ctx, db: &Database, target: &Path) -> Result<()> {
|
|||||||
eprintln!("dumping to local directory {}", target.display());
|
eprintln!("dumping to local directory {}", target.display());
|
||||||
let remote = remote_dump();
|
let remote = remote_dump();
|
||||||
|
|
||||||
in_container(db)
|
PgDump::new(&db.user, &db.name, "d")
|
||||||
.args("pg_dump -U")
|
.to(&remote)
|
||||||
.arg(&db.user)
|
.in_container(&db.container)
|
||||||
.args("--format=d -f")
|
.run(ctx)?;
|
||||||
.arg(&remote)
|
|
||||||
.arg(&db.name)
|
|
||||||
.exec(ctx)?;
|
|
||||||
|
|
||||||
let copied = CommandBuilder::docker()
|
let copied = Cp::out_of_container(&db.container, &remote, &target.to_string_lossy()).run(ctx);
|
||||||
.args("cp")
|
|
||||||
.arg(format!("{}:{remote}", db.container))
|
|
||||||
.arg(target.to_string_lossy())
|
|
||||||
.exec(ctx);
|
|
||||||
|
|
||||||
let _ = in_container(db).args("rm -rf").arg(&remote).exec(ctx);
|
let _ = Rm::recursive(&remote).in_container(&db.container).run(ctx);
|
||||||
|
|
||||||
copied
|
copied
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user