# 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 `-sha-` 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 -sha- (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://:20090/hooks/` | | 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.