ahab

A wrapper around docker compose for our dockerized Django projects, so the same commands work in every repository.

It reads docker compose config to work out which service runs Django and which one is the database, so nothing here takes a service name or a -f. Everything it does, it does by running docker, git and the Postgres tools.

Installing

You will need Rust installed. Clone the repo and run:

cargo install --path .

Django

Each of these runs docker compose run --rm against the Django service, so it goes through the image's own entrypoint:

ahab django run <cmd>                # anything, in a fresh container
ahab django bash                     # a shell in a fresh container
ahab django manage <args>            # manage.py
ahab django makemigrations
ahab django migrate <args>
ahab django shell
ahab django make-command <app> <name>

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:

ahab django run pytest -x tests/

Postgres

ahab postgres dump <path>             # pg_dump, custom format
ahab postgres dump -F plain <path>    # pg_dump, plain SQL
ahab postgres dump -F tar <path>      # pg_dump, tar
ahab postgres dump -F directory <d>   # pg_dump, a directory of files
ahab postgres dump -F cluster <path>  # pg_dumpall, roles and all databases
ahab postgres dump -F plain -z <path> # any of them, gzipped
ahab postgres import <path>           # drop, create, then restore
ahab postgres psql <args>             # psql in the database container

Dumps are written 0600, under a name of their own until they are complete and then renamed over the target. A cluster dump carries every role's password hash, so the default 0644 would hand it to anyone else with an account.

Arguments reach psql untouched, and Docker is only asked for a terminal when there is one to hand over, so both of these work:

ahab postgres psql                          # interactive
ahab postgres psql -tAc 'select count(*) from auth_user' | wc -l

What an import makes of a dump

The shape is read from the file rather than from its name:

  • a PGDMP or toc.dat header — an archive, restored with pg_restore, as is a directory produced by pg_dump -Fd
  • a pg_dumpall header — a whole cluster, which creates its own databases and carries role statements, so the database is dropped but not recreated and the dump goes to psql connected to postgres
  • gzip magic — decompressed on the way in, whichever of the three is inside
  • anything else — SQL, fed to psql with ON_ERROR_STOP and --single-transaction, so a bad file rolls back instead of half applying

A cluster restore is the one that runs without ON_ERROR_STOP, because roles that already exist report errors which are expected. Any other error fails the import, since psql would otherwise exit 0 having applied part of the dump. Importing into a cluster that has never held the database works either way.

Containers are stopped before an import starts, so one that fails leaves the project stopped and says so.

A sandbox that mounts the repository can read anything untracked sitting in it — a .env, a dump, a key. Those paths can be moved out to a store of ahab's own and symlinked back, so the host resolves the link as before while the sandbox is left with a symlink into somewhere it was never given.

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         # `<code> <path>`, for scripts
ahab link check --exit-code         # exit 4 when anything is outside the store

Restoring is the inverse of adding: the file comes back to where it was and the store keeps nothing. A second checkout linking the same path is left with a dangling symlink, there being only ever one stored copy.

Every stored path is in one of three states, which list reads from the store rather than from git:

$ ahab link list
store: /home/you/.local/share/ahab/git.aflabs.org/urnik/afurnik
	linked:    .env
	shadowed:  config.local.py
	missing:   secrets/token
  • linked — the symlink is there and points at the store, which is the point
  • shadowed — a real file took the path back, so nothing reads the stored copy
  • missing — the symlink is gone, and nothing reads the stored copy either

Only a linked path can be moved back, so restore --all says how many it left alone.

Two things adding will not do:

  • a tracked path, which git would restore into the repository anyway. Where one is a symlink leading out, check reports it under the code T>, and saying so is all it can do
  • a symlink already leading out of the repository, because moving the link would move the pointer and leave the contents where they are. The store would then hold a way back out, while check saw a link into the store and called the repository clean

For scripts there is --porcelain, and -z for filenames that need it: 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.

The store lives under ${XDG_DATA_HOME:-$HOME/.local/share}/ahab/<host>/<owner>/<repo>/, derived from the Git origin remote, or _local/<checkout> when there is no remote to name it after. Two details of that:

  • its directories are created 0700, since it holds what should not be readable from the repository and 0755 would leave that to whoever else has an account on the machine
  • a remote or checkout name that needed characters replacing carries a short fingerprint of the original, so two of them cannot land on one directory

What ahab makes of your project

Running ahab status prints the services it picked, the containers behind them, the credentials it would use and what the store holds. It reports 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 — print each Docker command as it runs
  • --dry-run — print the commands it would run, without running them
  • -q — print only what was asked for, dropping the progress along the way, Compose's own included

All three work before or after the subcommand:

ahab -v django migrate
ahab --dry-run postgres import ./dump
ahab -q postgres dump ./dump

Nothing is written under --dry-run, the filesystem included: link add and django make-command say what they would do and leave the tree alone.

Service detection

Services can be named anything, since ahab looks at what they are rather than what they are called:

  • 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.

The role and database come from POSTGRES_USER and POSTGRES_DB on the detected service, falling back to db when neither is set. Both have to be names: libpq reads a database name holding an = or a URL as a whole connection string, which would send a dump to whatever server that names, so a value which could be read as anything but a name is refused.

The compose file

Compose finds its own file and ahab does not pass -f, so set Docker's own COMPOSE_FILE when the file 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

Shell completion

Completion scripts are written to stdout, for Bash, Elvish, Fish, PowerShell or Zsh:

ahab completions zsh > ~/.local/share/zsh/completions/_ahab
eval "$(ahab completions zsh)"      # or one line in .zshrc, never goes stale

They are generated during the build as well, landing in target/*/build/*/out/. To install a single shell's file straight into place:

SHELL_COMPLETIONS_DIR_ZSH=~/.local/share/zsh/completions \
SHELL_COMPLETIONS_DIR_FISH=~/.config/fish/completions \
  cargo install --path .

Set SHELL_COMPLETIONS_DIR to write every shell into one directory instead. Either way the path has to be absolute, since a build script runs from wherever cargo happens to be building.

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, which today means link check --exit-code with anything outside the store

Environment variables

  • AHAB_LINK_ROOT — where ahab link keeps its store, the same as --store, which takes precedence. Defaults to ${XDG_DATA_HOME:-$HOME/.local/share}/ahab. Read by ahab status too, so it reports the store the link commands actually use
Description
No description provided
Readme MIT 389 KiB
Languages
Rust 100%