Generic host/repo/branch-aware CD on adnanh/webhook

Replaces the per-project deploy scripts and self-contained webhook
receivers with one manifest-driven implementation that lives outside the
application repositories and can be updated independently of them.

First targets: domaindingo test and prod on s5.
This commit is contained in:
fisher
2026-08-23 07:49:52 +00:00
commit 3a6fafb5c7
22 changed files with 1872 additions and 0 deletions
+174
View File
@@ -0,0 +1,174 @@
# my-cd-webhook
Fleet-wide continuous deployment built on [adnanh/webhook](https://github.com/adnanh/webhook),
replacing the per-project deploy scripts and hand-rolled webhook receivers that
used to live inside each application repository.
This repository is checked out on every deploying host (currently `s4` and `s5`)
and updated independently of the projects it deploys.
## Why this exists
Every project had grown its own deployment mechanism, and each one was a
slightly different 200-line bash script plus, in some cases, a bespoke Python
HTTP server. They shared no code, drifted apart, and could only be changed by
committing to the application repository and redeploying it — which is exactly
the thing that is hardest to do when deployment is broken.
Concretely, the patterns this replaces:
| Project | What was there | Problem |
|---|---|---|
| `webdev/paradicsomleves` | `scripts/webhook.py` (275 lines), `webhook-install.sh`, `webhook-uninstall.sh` | A whole HTTP server, HMAC implementation, systemd watchdog integration and unit generator, maintained inside an image-gallery app. Removed in `660a309`. |
| `webdev/domaindingo` | `scripts/deploy-{dev,test,prod}.sh` | Three near-identical scripts, run by hand. A `hostname != s2` guard that `exit 0`s on mismatch — so a wrong-host run *reports success* — and which is now simply wrong, because test and prod both moved to s5. `deploy-prod.sh` has no host guard at all. |
| `webdev/grindex` | nothing | The pipeline stops at "image pushed"; deployment is a human running `docker compose up -d` on s4. |
The webhook was never the problem. A *self-contained* webhook, reimplemented
per project, was.
## Design
**One manifest.** [`targets.json`](targets.json) maps `(repo, branch)` onto a
host, an environment, a compose stack and an image. It is the only file that
knows this, and adding an environment is an edit here — never a change to an
application repository.
**Host-aware by construction.** Every host runs the same daemon from the same
checkout and acts only on the targets whose `host` matches. A push for another
host's target is a logged no-op, never a silent success. Host matching accepts
both `s5` and `s5.fisher.hu`, because the fleet uses both.
**Deploys are immutable and provenance-checked.** The receiver deploys the
`<branch>-sha-<short7>` tag, verifies the image's
`org.opencontainers.image.revision` label equals the commit that triggered it,
then re-pins to the resolved digest before handing it to compose. Mutable tags
have already drifted once in this fleet and served a broken build for months.
**It never touches git.** The compose stacks live in `sysadmin/uas-ng` and are
refreshed by that repository's own updater timer. A deploy script that also runs
`git reset --hard` is doing two jobs badly.
**It never runs `docker compose down`.** `up -d` recreates exactly the services
whose image changed. The old test script used `down -v`, which destroys data
volumes.
**Secrets stay on the host.** `secrets.env` (mode 0600) holds one webhook secret
per repository. The rendered hooks file is generated on the host and is never
committed.
## Layout
```
targets.json the manifest — the single source of truth
bin/cd-target manifest queries (resolve / list / host-config)
bin/cd-deploy the one generic deploy script
bin/cd-render-hooks targets.json -> adnanh/webhook hooks.json for this host
bin/cd-status what is this host deploying, and is it healthy
etc/cd-webhook.service systemd user unit template
install/install.sh idempotent installer
install/uninstall.sh removal (--purge also drops secrets and logs)
secrets.env.example template for the host-local secrets file
```
## How a deploy runs
```
git push origin test
Gitea push webhook ──► http://10.255.255.1:20090/hooks/webdev-domaindingo
│ (WireGuard only; never exposed publicly)
adnanh/webhook verify HMAC-SHA256, require X-Gitea-Event: push,
require a ref we actually deploy
bin/cd-deploy --repo webdev/domaindingo --ref refs/heads/test --sha <40-hex>
├─ resolve (repo, branch, this host) in targets.json ──► no match? log, exit 0
├─ take the per-target lock
├─ poll the registry for <branch>-sha-<short7> (CI is still building)
├─ verify the revision label matches the pushed commit
├─ resolve tag -> digest, and deploy the digest
├─ docker compose up -d
├─ health check
└─ roll back to the previous digest if health fails
ntfy
```
## Installing on a host
Requires `adnanh/webhook` on `PATH` (or `WEBHOOK_BIN` set), plus `docker`,
`python3`, `curl` and `flock`.
```bash
git clone ssh://git@gitea.fisher.hu:2221/sysadmin/my-cd-webhook.git ~/S/my-cd-webhook
cd ~/S/my-cd-webhook
./install/install.sh # creates the secrets template on the first run
$EDITOR ~/.config/cd-webhook/secrets.env
./install/install.sh # renders hooks, installs and starts the service
```
The installer is idempotent — re-run it after editing `targets.json`, rotating a
secret, or pulling a new version of this repository.
It prints the exact webhook URL for each repository at the end. In Gitea, under
**Repository → Settings → Webhooks → Add Webhook → Gitea**:
| Field | Value |
|---|---|
| Target URL | `http://<host wg ip>:20090/hooks/<id>` |
| HTTP Method | `POST` |
| POST Content Type | `application/json` |
| Secret | the matching value from `secrets.env` |
| Trigger On | Push Events |
| Branch filter | `*` (branch selection lives in `targets.json`) |
## Adding a target
Add an object to `targets.json` and re-run `install/install.sh` on the owning
host. Nothing else. If the repository is new to that host, add its secret to
`secrets.env` first and configure the webhook in Gitea.
Fields: `name`, `enabled`, `repo`, `branch`, `env`, `host`, `stack_dir`,
`compose_file`, `compose_project`, `container`, `image_repo`,
`image_tag_template`, `image_env_var`, `pull_policy_env_var`, `health_url`,
`health_expect_key`, `health_expect_value`, `rollback_on_failure`.
`image_tag_template` understands `{branch}`, `{env}`, `{sha}`, `{short7}` and
`{short12}`.
## Current targets
| Target | Repo | Branch | Host | Route |
|---|---|---|---|---|
| `domaindingo-test` | `webdev/domaindingo` | `test` | s5 | `ddt.fisher.hu` |
| `domaindingo-prod` | `webdev/domaindingo` | `prod` | s5 | `dd.fisher.hu` |
## Testing without deploying
```bash
./bin/cd-status # health of this host's receiver
./bin/cd-target list --host s5.fisher.hu # what s5 would deploy
./bin/cd-render-hooks --host s5.fisher.hu --redact # the hooks file, secrets masked
./bin/cd-deploy --repo webdev/domaindingo --ref refs/heads/test \
--sha $(git rev-parse HEAD) --dry-run
```
`CD_TARGETS_FILE` points the tooling at a scratch manifest for testing. It
changes which manifest is read, never which host this machine may act as.
## Not yet done
- **Port `20090` is not yet in the Port registry.** Register it before this goes
live on a second host.
- **Gitea (s5) must be able to reach s4 on `10.255.255.12:20090`** over
WireGuard. Verify before adding grindex.
- **grindex is deliberately absent from the manifest.** Its stack lives at
`/home/fisher/S/traefik-systems/grindex/` on s4, which is not in git and whose
compose project names have not been verified on the host. Adding it from
guesswork would be exactly the sloppiness this repo exists to remove.
- **`webdev/domaindingo` still carries `scripts/deploy-*.sh`.** They should be
deleted there once this is live, the way paradicsomleves' were.