refactor: name the modules after what they hold

This commit is contained in:
2026-09-08 12:10:41 +00:00
parent a3c08f6eaa
commit bb0a2cca44
12 changed files with 1014 additions and 989 deletions

View File

@@ -0,0 +1,46 @@
use std::io::{self, Write};
use anyhow::{Context, Result};
use clap::CommandFactory;
use clap_complete::{Shell, generate};
use crate::cli::Ahab;
pub fn completions(shell: Shell) -> Result<()> {
let script = script(shell);
let mut stdout = io::stdout().lock();
match stdout.write_all(&script).and_then(|()| stdout.flush()) {
Err(e) if e.kind() == io::ErrorKind::BrokenPipe => Ok(()),
result => result.context("writing the completion script"),
}
}
fn script(shell: Shell) -> Vec<u8> {
let mut cmd = Ahab::command();
let name = cmd.get_name().to_string();
let mut script = Vec::new();
generate(shell, &mut cmd, name, &mut script);
script
}
#[cfg(test)]
mod tests {
use super::{Shell, script};
use clap::ValueEnum;
#[test]
fn every_shell_gets_a_script_covering_the_subcommands() {
for shell in Shell::value_variants() {
let out = String::from_utf8(script(*shell)).expect("script is utf8");
for subcommand in ["django", "postgres", "link", "completions"] {
assert!(
out.contains(subcommand),
"{shell:?} script never mentions {subcommand}"
);
}
}
}
}

91
src/commands/django.rs Normal file
View 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
View 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
View 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
View 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");
}
}

4
src/commands/mod.rs Normal file
View File

@@ -0,0 +1,4 @@
pub mod completions;
pub mod django;
pub mod link;
pub mod postgres;

296
src/commands/postgres.rs Normal file
View 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}");
}
}
}

View 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)
}

View 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));
}
}