From 7d71dd054cc4021c549a0eda6da9c68c012658f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Jane=C5=BEi=C4=8D?= Date: Mon, 7 Sep 2026 12:59:56 +0000 Subject: [PATCH 1/5] docs: bring the readme up to date --- README.md | 75 ++++++++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 72 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index a9762e5..0f71077 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,8 @@ # ahab +A wrapper around `docker compose` for our dockerized django projects, so the +same commands work in every repository. + ## installing You will need rust installed. Clone repo and run: @@ -7,20 +10,86 @@ You will need rust installed. Clone repo and run: cargo install --path . ``` -To print underlying commands build with `debug` flag. +To print the underlying docker commands as they run, build with debug +assertions: ```bash cargo install --path . --debug ``` ## shell completion -Completion files are generated **during build process** in `target/*/build/*/out/*`. Depending on your shell of choice move generated file to correct place in you file system. +`ahab completions ` writes a completion script to stdout, for bash, +elvish, fish, powershell or zsh: + +```bash +ahab completions zsh > ~/.local/share/zsh/completions/_ahab +eval "$(ahab completions zsh)" # or one line in .zshrc, never goes stale +``` + +Completions are also generated during the build. They land in +`target/*/build/*/out/` by default, and `SHELL_COMPLETIONS_DIR_` +installs a single shell's file straight into place: + +```bash +SHELL_COMPLETIONS_DIR_ZSH=~/.local/share/zsh/completions \ +SHELL_COMPLETIONS_DIR_FISH=~/.config/fish/completions \ + cargo install --path . +``` + +`SHELL_COMPLETIONS_DIR` writes every shell into one directory instead. + +## compose + +Wrappers around the matching `docker compose` call, plus `exec` and `bash` +which default to the django service. + +```bash +ahab compose up # also build, down, ps, start, stop +ahab compose rebuild # stop, build, up +ahab compose restart # stop, up, so containers are recreated +ahab compose bash # shell in the django service +ahab compose exec +``` + +## django + +```bash +ahab django manage # manage.py in a fresh container +ahab django makemigrations +ahab django migrate +ahab django shell +ahab django test +ahab django make-command +``` + +## postgres + +```bash +ahab postgres dump # pg_dump, custom format +ahab postgres import # drop, create, pg_restore +``` + +## link + +`ahab link` moves untracked paths out of the repository into an out-of-repo +store and symlinks them back, so a sandbox that mounts the repository sees a +dangling symlink instead of the contents, while the host resolves it as before. + +```bash +ahab link add .env secrets/ # move out, leave symlinks behind +ahab link check # what a sandbox can still read +ahab link check --porcelain # ` `, for scripts +``` + +The store lives under +`${XDG_DATA_HOME:-$HOME/.local/share}/ahab////`, derived +from the git `origin` remote. ## configuration Currently `ahab` respects the following environment variables. - `COMPOSE_FILE`: control which docker-compose file is used - defaults to `docker/local/docker-compose.yaml` -- `AHAB_DJANGO_CONTAINER`: control which compose service is used for sending django commands - defaults to `django` +- `AHAB_DJANGO_CONTAINER`: control which compose service is used for sending django commands - defaults to `django` - `AHAB_POSTGRES_CONTAINER`: control which compose service is used for sending postgres commands - defaults to `db` - `AHAB_LINK_ROOT`: root of the out-of-repo store `ahab link` moves paths into - defaults to `${XDG_DATA_HOME:-$HOME/.local/share}/ahab` From 836bbaac08a991803d6130f202bb2310700b677d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Jane=C5=BEi=C4=8D?= Date: Mon, 7 Sep 2026 12:01:26 +0000 Subject: [PATCH 2/5] feat: let docker compose find its own file --- README.md | 12 +++++++++++- src/command_builder.rs | 10 +--------- src/scripts/docker_compose.rs | 7 ------- 3 files changed, 12 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index 0f71077..59708f9 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,17 @@ SHELL_COMPLETIONS_DIR_FISH=~/.config/fish/completions \ `SHELL_COMPLETIONS_DIR` writes every shell into one directory instead. +## the compose file + +`ahab` does not pass `-f`. docker compose finds the file itself, so set +docker's own `COMPOSE_FILE` when it is not in the working directory, including +its `base.yaml:override.yaml` form. A project's `.env` is a good place for it, +since docker reads that too: + +``` +COMPOSE_FILE=docker/docker-compose.yaml +``` + ## compose Wrappers around the matching `docker compose` call, plus `exec` and `bash` @@ -89,7 +100,6 @@ from the git `origin` remote. Currently `ahab` respects the following environment variables. -- `COMPOSE_FILE`: control which docker-compose file is used - defaults to `docker/local/docker-compose.yaml` - `AHAB_DJANGO_CONTAINER`: control which compose service is used for sending django commands - defaults to `django` - `AHAB_POSTGRES_CONTAINER`: control which compose service is used for sending postgres commands - defaults to `db` - `AHAB_LINK_ROOT`: root of the out-of-repo store `ahab link` moves paths into - defaults to `${XDG_DATA_HOME:-$HOME/.local/share}/ahab` diff --git a/src/command_builder.rs b/src/command_builder.rs index 4381fa3..e57697d 100644 --- a/src/command_builder.rs +++ b/src/command_builder.rs @@ -1,6 +1,5 @@ use anyhow::{Context, Result}; use std::{ - env, fmt::Display, process::{Child, Command, Stdio}, }; @@ -27,11 +26,6 @@ impl From<&[String]> for Args { } } -fn get_compose_file() -> Result { - let cf = env::var("COMPOSE_FILE")?; - Ok(cf) -} - #[derive(Default)] pub struct CommandBuilder { args: Vec, @@ -49,9 +43,7 @@ impl CommandBuilder { } pub fn docker_compose() -> Self { - let cf = - get_compose_file().unwrap_or_else(|_| "docker/local/docker-compose.yaml".to_string()); - Self::default().args("docker compose -f").args(&cf) + Self::default().args("docker compose") } pub fn args(mut self, args: T) -> Self diff --git a/src/scripts/docker_compose.rs b/src/scripts/docker_compose.rs index c5d403f..ddff849 100644 --- a/src/scripts/docker_compose.rs +++ b/src/scripts/docker_compose.rs @@ -24,13 +24,6 @@ pub fn ps() -> Result<()> { CommandBuilder::docker_compose().args("ps").exec() } -/// Start containers via `docker compose start`. Optionally pass containers to be started. -/// ``` -/// # use ahab::scripts::docker_compose::start; -/// start(None); -/// ``` -/// is roughly the same as -/// `docker compose --env-file ./.env -f docker/local/docker-compose.yaml up start` pub fn start(containers: Option<&str>) -> Result<()> { let args = format!("start {}", containers.unwrap_or("")); CommandBuilder::docker_compose().args(&args).exec() From 9d63233d9a427fe29233770c6f12a571a58dd45d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Jane=C5=BEi=C4=8D?= Date: Mon, 7 Sep 2026 12:04:35 +0000 Subject: [PATCH 3/5] feat: detect the django and postgres services ahab guessed that the django service is called django and the postgres one db, falling back on AHAB_DJANGO_CONTAINER and AHAB_POSTGRES_CONTAINER. The standard project layout happens to agree, but nothing enforces it, and a stack naming them web and database could not use ahab without setting both variables. One `docker compose config --format json` call, roughly 120ms, resolves the stack even while it is down, and the services are identified from what they are rather than what they are called: - postgres is the service whose image is a postgres flavour, matching postg, timescale, pgvector or citus. Nothing else counts. - django is the service that both builds an image and has DJANGO_SETTINGS_MODULE in its environment. A celery worker sharing the same build and env_file matches too, so published ports break the tie: the service answering requests wins. Neither guess is allowed to be wrong quietly. No match, or two candidates that cannot be told apart, is an error naming the services it looked at. There is no variable to fall back on: both container variables are gone, along with the guessed defaults they backed up, so an ambiguous stack is fixed in the compose file rather than worked around per developer. Every service lookup goes through these rules, so `compose exec` and the whole django group agree on which container they mean. POSTGRES_USER and POSTGRES_DB come off the detected service, so dropdb, createdb, pg_restore and pg_dump stop assuming the role and database are both literally `db`, falling back to that only when the service declares neither. Note that env_file entries are merged into a service's environment, so POSTGRES_* is not safe for identifying the database service: one such line in a project's .env would make the django service match as well. That is why identification uses the image and only credentials use the environment. Co-Authored-By: Claude Opus 5 (1M context) --- Cargo.lock | 61 ++++++++ Cargo.toml | 1 + README.md | 20 ++- src/compose.rs | 261 ++++++++++++++++++++++++++++++++++ src/lib.rs | 1 + src/main.rs | 11 +- src/scripts/django.rs | 4 +- src/scripts/docker_compose.rs | 5 +- src/scripts/postgres.rs | 89 +++++++----- 9 files changed, 404 insertions(+), 49 deletions(-) create mode 100644 src/compose.rs diff --git a/Cargo.lock b/Cargo.lock index 7755517..d4b853d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -10,6 +10,7 @@ dependencies = [ "clap", "clap_complete", "dotenvy", + "serde_json", ] [[package]] @@ -141,6 +142,18 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + [[package]] name = "once_cell_polyfill" version = "1.70.2" @@ -165,6 +178,48 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + [[package]] name = "strsim" version = "0.11.1" @@ -208,3 +263,9 @@ checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" dependencies = [ "windows-link", ] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/Cargo.toml b/Cargo.toml index 71d867f..332025f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,6 +17,7 @@ clap = { version = "4.6.6", features = ["derive"] } clap_complete = "4.6.9" anyhow = "1.0.104" dotenvy = "0.15.7" +serde_json = "1.0.145" [build-dependencies] clap = { version = "4.6.6", features = ["derive"] } diff --git a/README.md b/README.md index 59708f9..78ca230 100644 --- a/README.md +++ b/README.md @@ -62,6 +62,24 @@ ahab compose bash # shell in the django service ahab compose exec ``` +## service detection + +`ahab` finds the services it needs in `docker compose config`, so they can be +named anything: + +- **postgres**: the service whose image is a postgres flavour, matching + `postg`, `timescale`, `pgvector` or `citus` +- **django**: the service that both builds an image and sets + `DJANGO_SETTINGS_MODULE`. A worker sharing the same build and `env_file` + matches too, so the one publishing ports wins + +If nothing matches, or two candidates cannot be told apart, `ahab` says so and +lists the services it looked at rather than guessing. + +`POSTGRES_USER` and `POSTGRES_DB` are read off the detected postgres service, so +`dropdb`, `createdb`, `pg_restore` and `pg_dump` use the role and database the +project declares, falling back to `db` when it declares neither. + ## django ```bash @@ -100,6 +118,4 @@ from the git `origin` remote. Currently `ahab` respects the following environment variables. -- `AHAB_DJANGO_CONTAINER`: control which compose service is used for sending django commands - defaults to `django` -- `AHAB_POSTGRES_CONTAINER`: control which compose service is used for sending postgres commands - defaults to `db` - `AHAB_LINK_ROOT`: root of the out-of-repo store `ahab link` moves paths into - defaults to `${XDG_DATA_HOME:-$HOME/.local/share}/ahab` diff --git a/src/compose.rs b/src/compose.rs new file mode 100644 index 0000000..d470913 --- /dev/null +++ b/src/compose.rs @@ -0,0 +1,261 @@ +use anyhow::{Context, Result, anyhow, bail}; +use serde_json::Value; + +use crate::command_builder::CommandBuilder; + +const POSTGRES_IMAGES: [&str; 4] = ["postg", "timescale", "pgvector", "citus"]; +const DJANGO_SETTINGS_MODULE: &str = "DJANGO_SETTINGS_MODULE"; + +pub struct Compose { + services: Value, +} + +impl Compose { + pub fn resolve() -> Result { + let out = CommandBuilder::docker_compose() + .args("config --format json") + .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 + .get("services") + .cloned() + .context("compose config has no services")?; + + Ok(Self { services }) + } + + pub fn django(&self) -> Result { + let candidates: Vec<&str> = self + .names() + .into_iter() + .filter(|name| { + let service = &self.services[name]; + service.get("build").is_some() && env_var(service, DJANGO_SETTINGS_MODULE).is_some() + }) + .collect(); + + match candidates.as_slice() { + [] => Err(anyhow!( + "no service both builds an image and sets {DJANGO_SETTINGS_MODULE}, looked at {}", + self.names().join(", ") + )), + [only] => Ok(only.to_string()), + several => { + let serving: Vec<&&str> = several + .iter() + .filter(|name| self.publishes_ports(name)) + .collect(); + + match serving.as_slice() { + [only] => Ok(only.to_string()), + _ => Err(anyhow!( + "cannot tell which service runs django, {} all build an image, set \ + {DJANGO_SETTINGS_MODULE} and publish ports", + several.join(", ") + )), + } + } + } + } + + pub fn postgres(&self) -> Result { + let candidates: Vec<&str> = self + .names() + .into_iter() + .filter(|name| { + self.services[name] + .get("image") + .and_then(Value::as_str) + .is_some_and(is_postgres_image) + }) + .collect(); + + match candidates.as_slice() { + [] => Err(anyhow!( + "no service uses a postgres image, looked at {}", + self.names().join(", ") + )), + [only] => Ok(only.to_string()), + several => Err(anyhow!( + "cannot tell which service is the database, {} all use a postgres image", + several.join(", ") + )), + } + } + + pub fn postgres_credentials(&self, service: &str) -> (String, String) { + let service = &self.services[service]; + + let user = env_var(service, "POSTGRES_USER").unwrap_or_else(|| "db".to_string()); + let database = env_var(service, "POSTGRES_DB").unwrap_or_else(|| "db".to_string()); + + (user, database) + } + + fn names(&self) -> Vec<&str> { + let mut names: Vec<&str> = self + .services + .as_object() + .map(|services| services.keys().map(String::as_str).collect()) + .unwrap_or_default(); + + names.sort_unstable(); + names + } + + fn publishes_ports(&self, service: &str) -> bool { + self.services[service] + .get("ports") + .and_then(Value::as_array) + .is_some_and(|ports| !ports.is_empty()) + } +} + +fn env_var(service: &Value, key: &str) -> Option { + service + .get("environment")? + .get(key)? + .as_str() + .map(str::to_string) +} + +fn is_postgres_image(image: &str) -> bool { + let image = image.to_lowercase(); + POSTGRES_IMAGES.iter().any(|kind| image.contains(kind)) +} + +#[cfg(test)] +mod tests { + use super::{Compose, is_postgres_image}; + use serde_json::json; + + fn compose(services: serde_json::Value) -> Compose { + Compose { services } + } + + #[test] + fn recognises_postgres_flavours() { + for image in [ + "postgres:18", + "postgis/postgis:16-3.4", + "timescale/timescaledb:latest-pg16", + "pgvector/pgvector:pg16", + "citusdata/citus:12", + "PostgreSQL:16", + ] { + assert!(is_postgres_image(image), "{image} should count as postgres"); + } + + for image in ["redis:7-alpine", "mysql:8", "nginx", "local-django"] { + assert!(!is_postgres_image(image), "{image} should not"); + } + } + + #[test] + fn picks_the_service_that_builds_and_has_the_settings_module() { + let compose = compose(json!({ + "db": {"image": "postgres:18", "environment": {"POSTGRES_DB": "n"}}, + "django": { + "build": {"context": "."}, + "environment": {"DJANGO_SETTINGS_MODULE": "config.settings.local"}, + "ports": [{"target": 8000}], + }, + })); + + assert_eq!(compose.django().unwrap(), "django"); + assert_eq!(compose.postgres().unwrap(), "db"); + } + + #[test] + fn breaks_a_worker_tie_on_published_ports() { + let compose = compose(json!({ + "django": { + "build": {"context": "."}, + "environment": {"DJANGO_SETTINGS_MODULE": "config.settings.local"}, + "ports": [{"target": 8000}], + }, + "worker": { + "build": {"context": "."}, + "environment": {"DJANGO_SETTINGS_MODULE": "config.settings.local"}, + }, + })); + + assert_eq!(compose.django().unwrap(), "django"); + } + + #[test] + fn refuses_when_two_candidates_both_serve() { + let serving = json!({ + "build": {"context": "."}, + "environment": {"DJANGO_SETTINGS_MODULE": "config.settings.local"}, + "ports": [{"target": 8000}], + }); + let compose = compose(json!({"api": serving, "web": serving})); + + let error = compose.django().unwrap_err().to_string(); + assert!(error.contains("api, web"), "{error}"); + } + + #[test] + fn refuses_when_nothing_matches() { + let compose = compose(json!({ + "db": {"image": "postgres:18"}, + "cache": {"image": "redis:7"}, + })); + + assert!( + compose + .django() + .unwrap_err() + .to_string() + .contains("cache, db") + ); + assert_eq!(compose.postgres().unwrap(), "db"); + } + + #[test] + fn refuses_two_postgres_services() { + let compose = compose(json!({ + "db": {"image": "postgres:18"}, + "replica": {"image": "postgres:18"}, + })); + + let error = compose.postgres().unwrap_err().to_string(); + assert!(error.contains("db, replica"), "{error}"); + } + + #[test] + fn reads_credentials_off_the_detected_service() { + let compose = compose(json!({ + "db": { + "image": "postgres:18", + "environment": {"POSTGRES_USER": "myproject", "POSTGRES_DB": "myproject_db"}, + }, + })); + + let (user, database) = compose.postgres_credentials("db"); + assert_eq!( + (user.as_str(), database.as_str()), + ("myproject", "myproject_db") + ); + } + + #[test] + fn falls_back_to_db_when_the_service_says_nothing() { + let compose = compose(json!({"db": {"image": "postgres:18"}})); + + let (user, database) = compose.postgres_credentials("db"); + assert_eq!((user.as_str(), database.as_str()), ("db", "db")); + } +} diff --git a/src/lib.rs b/src/lib.rs index 99c221c..041d969 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -5,6 +5,7 @@ use std::{ pub mod cli; pub mod command_builder; +pub mod compose; pub mod scripts; // NOTE: stolen from https://docs.rs/debug_print/latest/debug_print/ diff --git a/src/main.rs b/src/main.rs index c218255..f9a55a6 100644 --- a/src/main.rs +++ b/src/main.rs @@ -35,13 +35,10 @@ fn main() -> Result<()> { cli::Django::Shell => scripts::django::shell(), cli::Django::Test => scripts::django::test(), }, - cli::Commands::Postgres { command } => { - let db_container = std::env::var("AHAB_POSTGRES_CONTAINER").unwrap_or("db".to_string()); - match command { - cli::Postgres::Import { path } => scripts::postgres::import(&db_container, &path), - cli::Postgres::Dump { path } => scripts::postgres::dump(&db_container, &path), - } - } + cli::Commands::Postgres { command } => match command { + cli::Postgres::Import { path } => scripts::postgres::import(&path), + cli::Postgres::Dump { path } => scripts::postgres::dump(&path), + }, cli::Commands::Link { command } => match command { cli::Link::Add { paths, force } => scripts::link::add(&paths, force), cli::Link::Check { diff --git a/src/scripts/django.rs b/src/scripts/django.rs index e64821e..fe4f50f 100644 --- a/src/scripts/django.rs +++ b/src/scripts/django.rs @@ -1,4 +1,3 @@ -use std::env; use std::fs::create_dir; use std::io::Write; use std::path::{Path, PathBuf}; @@ -6,6 +5,7 @@ use std::path::{Path, PathBuf}; use anyhow::{Result, anyhow}; use crate::command_builder::CommandBuilder; +use crate::compose::Compose; use crate::{create_file, safe_create_file}; const DEBUG_TEMPLATE: &str = r#"from django.core.management.base import BaseCommand @@ -55,7 +55,7 @@ pub fn make_command(app: &PathBuf, name: &str) -> Result<()> { } pub fn manage(rest: &[String]) -> Result<()> { - let container = env::var("AHAB_DJANGO_CONTAINER").unwrap_or("django".to_string()); + let container = Compose::resolve()?.django()?; let joined = rest.join(" "); let command = format!("run --rm {container} python manage.py {joined}"); CommandBuilder::docker_compose().args(&command).exec() diff --git a/src/scripts/docker_compose.rs b/src/scripts/docker_compose.rs index ddff849..00e114d 100644 --- a/src/scripts/docker_compose.rs +++ b/src/scripts/docker_compose.rs @@ -1,6 +1,7 @@ use anyhow::Result; use crate::command_builder::CommandBuilder; +use crate::compose::Compose; // simple commands pub fn build() -> Result<()> { @@ -12,10 +13,10 @@ pub fn down() -> Result<()> { } pub fn exec(rest: &[String]) -> Result<()> { - let container = std::env::var("AHAB_DJANGO_CONTAINER").unwrap_or("django".to_string()); + let service = Compose::resolve()?.django()?; CommandBuilder::docker_compose() .args("exec") - .args(&container) + .args(&service) .args(rest) .exec() } diff --git a/src/scripts/postgres.rs b/src/scripts/postgres.rs index 36575d5..b817525 100644 --- a/src/scripts/postgres.rs +++ b/src/scripts/postgres.rs @@ -8,57 +8,71 @@ use std::{ }; use super::docker_compose; -use crate::{command_builder::CommandBuilder, debug_eprintln}; +use crate::{command_builder::CommandBuilder, compose::Compose, debug_eprintln}; -fn get_containers(container: &str) -> Result<[String; 2]> { - // get db container - let db_container = CommandBuilder::docker_compose() - .args("ps -q") - .args(container) - .exec_get_stdout()? - .trim() - .to_string(); - - let no_result = db_container.is_empty(); - if no_result { - return Err(anyhow!("no container")); - } - - // get all containers and filter out db container - let app_containers = CommandBuilder::docker_compose() - .args("ps -q") - .exec_get_stdout()? - .split_whitespace() - .filter(|x| x != &db_container) - .collect::>() - .join(" "); - - Ok([db_container, app_containers]) +struct Database { + service: String, + container: String, + user: String, + name: String, } -pub fn import(container: &str, file: &Path) -> Result<()> { - let [db_container, _] = get_containers(container)?; +impl Database { + fn resolve() -> Result { + let compose = Compose::resolve()?; + let service = compose.postgres()?; + let (user, name) = compose.postgres_credentials(&service); + + let container = CommandBuilder::docker_compose() + .args("ps -q") + .args(&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, + }) + } +} + +pub fn import(file: &Path) -> Result<()> { + let db = Database::resolve()?; let dump_file = file.to_string_lossy(); eprintln!("stopping all containers"); docker_compose::stop()?; eprintln!("starting db container"); - docker_compose::start(Some(container))?; + docker_compose::start(Some(&db.service))?; eprintln!("restoring database"); let commands = [ - format!("cp -L {dump_file} {db_container}:/tmp/dbdump"), - format!("exec {db_container} dropdb -U db db"), - format!("exec {db_container} createdb -U db -E utf8 -T template0 db"), - format!("exec {db_container} pg_restore -U db --dbname=db /tmp/dbdump"), + format!("cp -L {dump_file} {}:/tmp/dbdump", db.container), + format!("exec {} dropdb -U {} {}", db.container, db.user, db.name), + format!( + "exec {} createdb -U {} -E utf8 -T template0 {}", + db.container, db.user, db.name + ), + format!( + "exec {} pg_restore -U {} --dbname={} /tmp/dbdump", + db.container, db.user, db.name + ), ]; for command in commands { debug_eprintln!("waiting until pg_isready"); while !CommandBuilder::docker() .args(&format!( - "exec {db_container} pg_isready -h {container} -p 5432 -U db", + "exec {} pg_isready -U {} -d {}", + db.container, db.user, db.name )) .build()? .stdout(Stdio::null()) @@ -78,15 +92,18 @@ pub fn import(container: &str, file: &Path) -> Result<()> { Ok(()) } -pub fn dump(container: &str, file: &PathBuf) -> Result<()> { - let [db_container, _] = get_containers(container)?; +pub fn dump(file: &PathBuf) -> Result<()> { + let db = Database::resolve()?; eprintln!("dumping to local file {}", file.to_string_lossy()); let file = File::create(file)?; let stdout = Stdio::from(file); - let command = format!("exec {db_container} pg_dump -U db --format=c db"); + let command = format!( + "exec {} pg_dump -U {} --format=c {}", + db.container, db.user, db.name + ); CommandBuilder::docker() .args(&command) .exec_redirect_stdout(stdout)?; From d11b0c68cd3be4b6c88674b5bb70c4fcda06f351 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Jane=C5=BEi=C4=8D?= Date: Mon, 7 Sep 2026 12:04:57 +0000 Subject: [PATCH 4/5] feat: drop dotenvy --- Cargo.lock | 7 ------- Cargo.toml | 1 - src/main.rs | 3 --- 3 files changed, 11 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index d4b853d..a18df62 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -9,7 +9,6 @@ dependencies = [ "anyhow", "clap", "clap_complete", - "dotenvy", "serde_json", ] @@ -124,12 +123,6 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" -[[package]] -name = "dotenvy" -version = "0.15.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" - [[package]] name = "heck" version = "0.5.0" diff --git a/Cargo.toml b/Cargo.toml index 332025f..c8073ce 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,7 +16,6 @@ build = "build.rs" clap = { version = "4.6.6", features = ["derive"] } clap_complete = "4.6.9" anyhow = "1.0.104" -dotenvy = "0.15.7" serde_json = "1.0.145" [build-dependencies] diff --git a/src/main.rs b/src/main.rs index f9a55a6..c42019e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -4,9 +4,6 @@ use anyhow::Result; use clap::Parser; fn main() -> Result<()> { - // always load dotenv on start - dotenvy::dotenv().ok(); - let args = cli::Ahab::parse(); match args.command { From 6e44527a9ac2678bcd0b8bf9f55288cb286eb0a7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Jane=C5=BEi=C4=8D?= Date: Fri, 4 Sep 2026 14:35:57 +0000 Subject: [PATCH 5/5] feat: deprecate compose subcommand --- README.md | 17 +++------------ src/cli/ahab.rs | 8 +------ src/cli/django.rs | 24 ++++++++++++--------- src/cli/docker_compose.rs | 40 ----------------------------------- src/cli/mod.rs | 2 -- src/cli/postgres.rs | 10 ++------- src/main.rs | 19 ++--------------- src/scripts/completions.rs | 2 +- src/scripts/django.rs | 10 +++++++++ src/scripts/docker_compose.rs | 20 +++++++++--------- 10 files changed, 43 insertions(+), 109 deletions(-) delete mode 100644 src/cli/docker_compose.rs diff --git a/README.md b/README.md index 78ca230..e86031f 100644 --- a/README.md +++ b/README.md @@ -49,19 +49,6 @@ since docker reads that too: COMPOSE_FILE=docker/docker-compose.yaml ``` -## compose - -Wrappers around the matching `docker compose` call, plus `exec` and `bash` -which default to the django service. - -```bash -ahab compose up # also build, down, ps, start, stop -ahab compose rebuild # stop, build, up -ahab compose restart # stop, up, so containers are recreated -ahab compose bash # shell in the django service -ahab compose exec -``` - ## service detection `ahab` finds the services it needs in `docker compose config`, so they can be @@ -83,7 +70,9 @@ project declares, falling back to `db` when it declares neither. ## django ```bash -ahab django manage # manage.py in a fresh container +ahab django run # in a fresh container, through the entrypoint +ahab django bash # shell in a fresh container +ahab django manage # manage.py ahab django makemigrations ahab django migrate ahab django shell diff --git a/src/cli/ahab.rs b/src/cli/ahab.rs index f0f0784..9596252 100644 --- a/src/cli/ahab.rs +++ b/src/cli/ahab.rs @@ -1,4 +1,4 @@ -use super::{Django, DockerCompose, Link, Postgres}; +use super::{Django, Link, Postgres}; use clap::{Parser, Subcommand}; use clap_complete::Shell; @@ -12,12 +12,6 @@ pub struct Ahab { #[derive(Debug, Subcommand)] pub enum Commands { - /// Docker compose related subcommands - Compose { - #[command(subcommand)] - command: DockerCompose, - }, - /// Django related subcommands Django { #[command(subcommand)] diff --git a/src/cli/django.rs b/src/cli/django.rs index c793a90..347eb45 100644 --- a/src/cli/django.rs +++ b/src/cli/django.rs @@ -1,31 +1,35 @@ use std::path::PathBuf; -use clap::Parser; +use clap::Subcommand; // TODO: (matej) dsu template command -#[derive(Parser, Debug)] +#[derive(Subcommand, Debug)] pub enum Django { + /// Start a bash session in a fresh django container + Bash, + /// Prepare empty management command 'command' in app 'app'. - MakeCommand { - #[arg(value_enum)] - app: PathBuf, - #[arg(value_enum)] - name: String, - }, + MakeCommand { app: PathBuf, name: String }, /// Run Django's manage.py makemigrations. Makemigrations, /// Pass arguments to Django's manage.py. Manage { - #[arg(value_enum)] + #[arg(trailing_var_arg = true, allow_hyphen_values = true)] rest: Vec, }, /// Run Django's manage.py migrate. Migrate { - #[arg(value_enum)] + #[arg(trailing_var_arg = true, allow_hyphen_values = true)] + rest: Vec, + }, + + /// Run a command in a fresh django container, through its entrypoint + Run { + #[arg(trailing_var_arg = true, allow_hyphen_values = true)] rest: Vec, }, diff --git a/src/cli/docker_compose.rs b/src/cli/docker_compose.rs deleted file mode 100644 index 3de53c3..0000000 --- a/src/cli/docker_compose.rs +++ /dev/null @@ -1,40 +0,0 @@ -use clap::Subcommand; - -// TODO: (matej) add Exec, Bash - -/// Wraper for docker compose; autodiscover compose file and source .env file. -#[derive(Subcommand, Debug)] -pub enum DockerCompose { - /// Start bash session inside container - Bash, - - /// Build containers. - Build, - - /// Down containers. - Down, - - /// Exec command inside container. - Exec { - #[arg(value_enum)] - rest: Vec, - }, - - /// Print services - Ps, - - /// Stop, build and start containers. - Rebuild, - - /// Stop and start containers. - Restart, - - /// Start containers. - Start, - - /// Stop containers. - Stop, - - /// Up containers. - Up, -} diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 016985a..a4fd174 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -1,11 +1,9 @@ mod ahab; mod django; -mod docker_compose; mod link; mod postgres; pub use ahab::{Ahab, Commands}; pub use django::Django; -pub use docker_compose::DockerCompose; pub use link::Link; pub use postgres::Postgres; diff --git a/src/cli/postgres.rs b/src/cli/postgres.rs index 3a0b71f..7924e80 100644 --- a/src/cli/postgres.rs +++ b/src/cli/postgres.rs @@ -5,14 +5,8 @@ use clap::Subcommand; #[derive(Subcommand, Debug)] pub enum Postgres { /// Import dump via pg_restore - Import { - #[arg(value_enum)] - path: PathBuf, - }, + Import { path: PathBuf }, /// Dump via pg_dump with format=c - Dump { - #[arg(value_enum)] - path: PathBuf, - }, + Dump { path: PathBuf }, } diff --git a/src/main.rs b/src/main.rs index c42019e..b6a10ed 100644 --- a/src/main.rs +++ b/src/main.rs @@ -7,24 +7,9 @@ fn main() -> Result<()> { let args = cli::Ahab::parse(); match args.command { - cli::Commands::Compose { command } => { - eprintln!( - "DEPRECATION NOTICE: this is deprecated in favor of docker compose COMPOSE_FILE env" - ); - match command { - cli::DockerCompose::Bash => scripts::docker_compose::bash(), - cli::DockerCompose::Build => scripts::docker_compose::build(), - cli::DockerCompose::Down => scripts::docker_compose::down(), - cli::DockerCompose::Exec { rest } => scripts::docker_compose::exec(&rest), - cli::DockerCompose::Ps => scripts::docker_compose::ps(), - cli::DockerCompose::Rebuild => scripts::docker_compose::rebuild(), - cli::DockerCompose::Restart => scripts::docker_compose::restart(), - cli::DockerCompose::Start => scripts::docker_compose::start(None), - cli::DockerCompose::Stop => scripts::docker_compose::stop(), - cli::DockerCompose::Up => scripts::docker_compose::up(), - } - } cli::Commands::Django { command } => match command { + cli::Django::Bash => scripts::django::bash(), + cli::Django::Run { rest } => scripts::django::run(&rest), cli::Django::MakeCommand { app, name } => scripts::django::make_command(&app, &name), cli::Django::Makemigrations => scripts::django::makemigrations(), cli::Django::Manage { rest } => scripts::django::manage(&rest), diff --git a/src/scripts/completions.rs b/src/scripts/completions.rs index 949e0ef..b7ffb0d 100644 --- a/src/scripts/completions.rs +++ b/src/scripts/completions.rs @@ -35,7 +35,7 @@ mod tests { for shell in Shell::value_variants() { let out = String::from_utf8(script(*shell)).expect("script is utf8"); - for subcommand in ["compose", "django", "postgres", "link", "completions"] { + for subcommand in ["django", "postgres", "link", "completions"] { assert!( out.contains(subcommand), "{shell:?} script never mentions {subcommand}" diff --git a/src/scripts/django.rs b/src/scripts/django.rs index fe4f50f..47f339f 100644 --- a/src/scripts/django.rs +++ b/src/scripts/django.rs @@ -6,6 +6,7 @@ use anyhow::{Result, anyhow}; use crate::command_builder::CommandBuilder; 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 @@ -54,6 +55,15 @@ pub fn make_command(app: &PathBuf, name: &str) -> Result<()> { 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 container = Compose::resolve()?.django()?; let joined = rest.join(" "); diff --git a/src/scripts/docker_compose.rs b/src/scripts/docker_compose.rs index 00e114d..d2212a6 100644 --- a/src/scripts/docker_compose.rs +++ b/src/scripts/docker_compose.rs @@ -1,9 +1,7 @@ use anyhow::Result; use crate::command_builder::CommandBuilder; -use crate::compose::Compose; -// simple commands pub fn build() -> Result<()> { CommandBuilder::docker_compose().args("build").exec() } @@ -12,11 +10,18 @@ pub fn down() -> Result<()> { CommandBuilder::docker_compose().args("down").exec() } -pub fn exec(rest: &[String]) -> Result<()> { - let service = Compose::resolve()?.django()?; +pub fn run(service: &str, rest: &[String]) -> Result<()> { + CommandBuilder::docker_compose() + .args("run --rm") + .args(service) + .args(rest) + .exec() +} + +pub fn exec(service: &str, rest: &[String]) -> Result<()> { CommandBuilder::docker_compose() .args("exec") - .args(&service) + .args(service) .args(rest) .exec() } @@ -38,11 +43,6 @@ pub fn up() -> Result<()> { CommandBuilder::docker_compose().args("up -d").exec() } -// shortcuts -pub fn bash() -> Result<()> { - exec(&["bash".to_string()]) -} - pub fn rebuild() -> Result<()> { stop()?; build()?;