Files
ahab/README.md

236 lines
8.5 KiB
Markdown

# 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:
```bash
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:
```bash
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>
```
## Postgres
```bash
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.
Arguments reach `psql` untouched, and Docker is only asked for a terminal when
there is 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
```
### 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 runs without `ON_ERROR_STOP`, so the expected complaints
about roles that already exist do not stop it. Any other error fails the
import. 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.
## Link
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: the host resolves the link, the sandbox does not.
```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 # `<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:
```
$ 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
- `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.
Adding refuses two kinds of path:
- **a tracked path**, which git would restore anyway. Where one is a symlink
leading out of the repository, `check` reports it under the code `T>`
- **a symlink already leading out of the repository**, since moving it would
move the pointer and leave the contents where they are
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. Its directories are created `0700`. A remote or checkout name
that needed characters replacing carries a short fingerprint of the original,
so two of them cannot share a 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 problem:
```
$ 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:
```bash
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; ahab matches on what they are:
- **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: a value holding an `=`, a URL or a leading dash is refused, libpq
reading such a name as a whole connection string.
## 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:
```bash
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:
```bash
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.
## 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