Files
config-public/docs/bootstrap.md
T
Lucas WintherandClaude Opus 5.5 792f7cde48 Add the host bootstrap: deploy user, key, docker access, stack root
bootstrap.yml prepares a host as an admin via -u; new-deploy-key.sh makes the
key pair and refuses to write inside the repo. CI deploys refuse the bootstrap
playbook, since the dispatch API ignores the dropdown.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-30 15:07:31 +02:00

156 lines
7.4 KiB
Markdown

# Preparing a host
Everything else in this repository assumes a host that already has a `deploy`
user, a key it trusts, access to Docker, and a writable stack root. This is how
that gets made. It runs once per host.
There is a chicken-and-egg problem in the middle of it: every other playbook
connects **as** `deploy`, so the playbook that creates `deploy` cannot. It
connects as an administrator you already have access with, and it is the only
thing in this repository that does.
## 1. Generate the deploy key
```bash
scripts/new-deploy-key.sh # ~/.ssh/deploy_key
scripts/new-deploy-key.sh ~/.ssh/other # somewhere else
```
`ed25519`, and no passphrase — nothing in CI or in a playbook run can type one,
and an encrypted key fails much later with an authentication error that says
nothing about a passphrase.
The script prints the paths and the public key. It does not print the private
key unless you pass `--print-secret`, for the same reason
[`load-ssh-key.sh`](../scripts/load-ssh-key.sh) does not: key material should
stay out of terminals, logs and the process list.
It refuses two things. It will not overwrite an existing key without `--force`,
because replacing a live deploy key locks you out of every host that still
trusts the old one. And it will not write anywhere inside this repository:
gitleaks scans the working tree **and** the full history, so a key committed
once stays leaked after you delete the file.
One key serves every host and every stack. Rotating it is therefore an
all-hosts operation — replace the line in each `authorized_keys`, then update
the one CI secret.
## 2. Prepare the host
```bash
cd ansible
ansible-playbook playbooks/bootstrap.yml -u <admin-user> -k -K -e deploy_sudo=true
```
`deploy_sudo=true` is what grants the deploy user passwordless sudo, and it is
off by default. Leave it off only on a host where no stack sets `owner` or
`group` in `stack_dirs` — those entries are the only thing the role escalates
for, and `metrics.yml` and `openbao.yml` both have them. Omit it and the run
still ends green, with `skipped=1` in the recap as the only sign; the
consequence turns up much later, in a different playbook, as
`Missing sudo password`.
`-u` is honoured because the deploy account is `remote_user` in `ansible.cfg`
and not `ansible_user` in the inventory. That distinction exists for this
playbook: an inventory variable outranks a command-line `-u`, so naming the
account there would make the flag a silent no-op and send the run in as the
user that does not exist yet — or, on a host already bootstrapped, as the one
that cannot escalate.
Running it on the host itself avoids SSH altogether:
```bash
cd ansible
ansible-playbook playbooks/bootstrap.yml -c local -K -e deploy_sudo=true
```
`-c local` executes as whoever invoked it and ignores `ansible_user` altogether.
`-k` asks for that account's SSH password, `-K` for its sudo password; drop
either if it already uses a key or has passwordless sudo. `SSH_KEY_PATH` is not
needed here. The public key is read from whichever machine runs Ansible, so an
on-host run needs `deploy_pubkey_file` to point at a copy that is there.
| Variable | Default | |
| --- | --- | --- |
| `deploy_user` | `deploy` | Must match `remote_user` in `ansible/ansible.cfg` |
| `deploy_pubkey_file` | `~/.ssh/deploy_key.pub` | Read from the control node |
| `deploy_sudo` | `false` | Passwordless sudo for the deploy user |
What it does, and why each part matters:
- **Creates `deploy` in the `docker` group.** The `compose_stack` role runs
Compose unescalated, which is what stops bind mounts being created
root-owned. Without group membership every Docker call fails on the socket.
- **Installs rsync.** The payload sync is `ansible.posix.synchronize`, which
shells out to rsync **at both ends**. Missing it on the target fails the first
deploy; missing it on the machine running Ansible fails the same task with the
same message, so read the PATH in the error to tell the two apart.
- **Installs the public key**, never exclusively — rewriting the whole
`authorized_keys` would drop a key that is currently somebody's only way in.
- **Creates `{{ stack_root }}` owned by `deploy`.** This is the one that bites
silently: the role creates `/srv/stacks/<stack>` unescalated and `/srv` is
root-owned, so skipping it produces a permission error on first deploy that
looks nothing like its cause.
- **Grants passwordless sudo only when you ask.** The role escalates when a
`stack_dirs` entry sets `owner` or `group`, which is how a bind mount gets the
uid its container runs as. `metrics.yml` and `openbao.yml` both need it, so
any host running either wants `-e deploy_sudo=true`. The sudoers file is
written through `visudo -cf`, because a malformed one can lock everyone out of
a host permanently. `NOPASSWD:ALL` is broad — narrowing it to the commands the
`file` module actually invokes is a reasonable hardening once a host settles.
It is not imported by `site.yml` and not offered in the CI deploy dropdown.
Both authenticate as `deploy` — which on a fresh host does not exist yet, and on
a bootstrapped one is the unprivileged account this playbook exists to grant
privilege to. Either way it cannot bootstrap itself. Making CI able to would
mean storing an administrator's credential where the runner can reach it, a
larger and permanent grant in place of one interactive command.
**`--check` does not work on a host that has never been bootstrapped.** It stops
at *Authorise the deploy key* with `Either user must exist or you must provide
full path to key file in check mode`: check mode did not really create the user,
so the module has no home directory to inspect. That is Ansible's limitation,
not a fault in the run. On an already-bootstrapped host check mode is clean.
### rsync on the machine running Ansible
The half this playbook cannot do for you. `synchronize` runs rsync locally and
pushes, so whatever drives the deploy needs the binary as well:
- **A CI runner.** GitHub's hosted runners ship it. The GitLab jobs install it
in the `.ansible` `before_script`. The Gitea deploy installs it through the
toolchain action, because the act_runner images do not carry it. A runner
without it fails with `Failed to find required executable "rsync"` and a PATH
full of `/opt/hostedtoolcache/...`, which is the giveaway that the message is
about the runner and not about your host.
- **Your workstation**, if you deploy from it: `apt install rsync`, or the
equivalent.
### The first deploy of a stack cannot be a check run
`--check` creates nothing, so on a host that has never run a given stack there
is no project directory for Compose to inspect. The role notices and skips the
compose step with a note rather than failing, and the payload diff above it is
still reported — but the stack itself is only really created by a real deploy.
## 3. Hand the key to CI
```bash
base64 -w0 < ~/.ssh/deploy_key
```
Store it as `SSH_PRIVATE_KEY_B64`. Where that lives differs per platform —
[secrets.md](secrets.md) has the table, the other backends, and what not to use
on Windows.
## 4. Confirm it before deploying anything
Run the **connectivity** workflow first (`ansible:ping` on GitLab).
`SUCCESS => {"ping": "pong"}` proves
the runner decoded the key, reached port 22, authenticated, and ran Python on
the far side — everything a deploy needs except the playbook.
If it fails, the message tells you which of three different problems you have;
[connectivity.md](connectivity.md) has the table.