# 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: ```bash cargo install --path . ``` ## what it makes of a project `ahab status` prints the services it picked, the containers behind them, the credentials it would use and what the store holds, reporting whatever it cannot work out rather than stopping at the first thing: ``` $ ahab status services: db, django django: django (built from the project) container: 0a8b19e27634 postgres: db (postgres:18-alpine) container: 189d4d395d62 user: myproject database: myproject_db store: /home/you/.local/share/ahab/git.aflabs.org/urnik/afurnik linked: 2 `ahab link check` lists what a sandbox can still read ``` ## seeing what it runs `-v` prints each docker command as it runs, `--dry-run` prints the ones it would run without running them, and `-q` prints only what was asked for, dropping the progress along the way (compose's own progress included). All work before or after the subcommand. ```bash ahab -v django migrate ahab --dry-run postgres import ./dump ahab -q postgres dump ./dump ``` Nothing is written under `--dry-run`, filesystem included: `link add` and `django make-command` say what they would do and leave the tree alone. ## exit codes - `0` — it ran and had nothing to report - `1` — it could not finish - `2` — the arguments were wrong - `4` — it ran fine and found something worth reporting, i.e. `link check --exit-code` with anything outside the store ## shell completion `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. ## 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 ``` ## 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`. Where more than one does — a worker beside the web service, say — 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. Two services that both build and set the settings module are ambiguous unless exactly one of them publishes ports. `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. Both have to be names: `ahab` hands them to those tools as a role and a database, and libpq reads a database name holding an `=` or a url as a whole connection string, which would send a dump to whatever server it names. A value that could be read as something other than a name is refused rather than passed on. ## django ```bash 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 ahab django make-command ``` There is no `ahab django test`: which runner a project uses is the project's choice, and `manage.py test` exits 0 having collected nothing when the tests are written for pytest, so a wrong guess reads as a pass. Name the runner instead: ```bash ahab django run pytest -x tests/ ``` ## postgres ```bash ahab postgres dump # pg_dump, custom format ahab postgres dump -F plain # pg_dump, plain sql ahab postgres dump -F tar # pg_dump, tar ahab postgres dump -F directory # pg_dump, a directory of files ahab postgres dump -F cluster # pg_dumpall, roles and all databases ahab postgres dump -F plain -z # any of them, gzipped ahab postgres import # drop, create, then restore ahab postgres psql # psql in the database container ``` A dump is written `0600` and under a name of its own until it is complete, then renamed over the target: a cluster dump carries every role's password hash, and the default `0644` would hand it to anyone else with an account on the machine. `psql` passes its arguments through and only asks docker for a terminal when it has one to hand over, so both of these work: ```bash ahab postgres psql # interactive ahab postgres psql -tAc 'select count(*) from auth_user' | wc -l ``` The format of a dump being imported is read from the file rather than its name. A custom format dump starts with `PGDMP` and a tar one with `toc.dat`, both of which go to `pg_restore`, as does a directory produced by `pg_dump -Fd`. Anything else is treated as sql and fed to `psql` with `ON_ERROR_STOP` and `--single-transaction`, so a bad file rolls back instead of half applying. A whole cluster dump from `pg_dumpall` is recognised by its header and handled differently again: it creates its own databases and carries role statements, so the database is dropped but not recreated, the dump goes to `psql` connected to `postgres`, and it runs without `ON_ERROR_STOP` because roles that already exist report errors that are expected. Any error it does not expect is reported and fails the import, since psql without `ON_ERROR_STOP` exits 0 having applied only part of the dump. Gzipped dumps are decompressed on the way in, whichever of the three they hold. A cluster that has never held the database yet is a valid target either way, so importing into a fresh one works. `import` stops the project before it starts, so an import that fails leaves it stopped and says so: `docker compose up` brings it back. ## 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 restore .env # move it back into the repository ahab link restore --all # everything this repo has linked ahab link list # what the store holds for this repository ahab link check # what a sandbox can still read ahab link check --porcelain # ` `, for scripts ahab link check --exit-code # exit 4 when anything is outside the store ``` `restore` is the inverse of `add`: the file moves out of the store and back to where it was, and the store keeps nothing. A second checkout linking the same path is left with a dangling symlink, since there is only ever one stored copy. `list` says what state each stored path is in, which `check` cannot see because it asks git about the repository rather than reading the store: ``` $ ahab link list store: /home/you/.local/share/ahab/git.aflabs.org/urnik/afurnik linked: .env shadowed: config.local.py missing: secrets/token ``` `missing` is a path whose symlink is gone, `shadowed` one that a real file took back; either way the stored copy is the one nobody reads. `restore --all` moves back the linked ones and says how many it left, since there is nothing to undo for a path whose symlink is gone. `-z` is the porcelain format for scripts that must survive any filename: entries end with a NUL, and the two paths of a symlink leading elsewhere are separated by one as well, the way `git status -z` reports a rename. `check` looks at tracked paths too, since git tracks symlinks and one can lead out of the repository without appearing in any untracked listing. `add` cannot externalize a tracked path, so all `check` can do is report it, under the code `T>`. A symlink already leading out of the repository is not something `add` will take: moving the link would move the pointer and leave the contents where they are, so the store would hold a way back out while `check`, seeing a link into the store, called the repository clean. The store lives under `${XDG_DATA_HOME:-$HOME/.local/share}/ahab////`, derived from the git `origin` remote, or `_local/` when there is no remote to name it after. Its directories are created `0700`: it exists to hold what should not be readable from the repository, and on a shared machine the default `0755` would leave that to whoever else has an account. A store path derived from a remote, or a checkout name, that needed characters replacing carries a short fingerprint of the original, so two of them cannot land on one directory. ## configuration Currently `ahab` respects the following environment variables. - `AHAB_LINK_ROOT`: where `ahab link` keeps its store - the same as `--store`, which takes precedence, and defaults to `${XDG_DATA_HOME:-$HOME/.local/share}/ahab`. `ahab status` reads it too, so it reports the store the link commands use