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:
@@ -0,0 +1,12 @@
|
|||||||
|
# Agent instructions
|
||||||
|
|
||||||
|
This project's knowledge base lives in `ai-context/` and `docs/`. **Do not read
|
||||||
|
it preemptively.** When a task may touch project infrastructure, services, or
|
||||||
|
repository operations, consult the routing index `docs/active-context.md` and
|
||||||
|
open an entry only if its trigger matches your task.
|
||||||
|
|
||||||
|
Files under `ai-context/` are read-only — the install script overwrites them on
|
||||||
|
update, so local edits are lost.
|
||||||
|
|
||||||
|
Record project-specific discoveries (gotchas, quirks, non-obvious tool
|
||||||
|
behaviour) as short entries in `docs/hints.md`; prune stale ones.
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
# Rendered artefacts and anything host-local. The repository holds the manifest
|
||||||
|
# and the scripts; secrets and generated hooks belong only on the host.
|
||||||
|
hooks.json
|
||||||
|
secrets.env
|
||||||
|
*.log
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
# Agent instructions
|
||||||
|
|
||||||
|
This project's knowledge base lives in `ai-context/` and `docs/`. **Do not read
|
||||||
|
it preemptively.** When a task may touch project infrastructure, services, or
|
||||||
|
repository operations, consult the routing index `docs/active-context.md` and
|
||||||
|
open an entry only if its trigger matches your task.
|
||||||
|
|
||||||
|
Files under `ai-context/` are read-only — the install script overwrites them on
|
||||||
|
update, so local edits are lost.
|
||||||
|
|
||||||
|
Record project-specific discoveries (gotchas, quirks, non-obvious tool
|
||||||
|
behaviour) as short entries in `docs/hints.md`; prune stale ones.
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
# Agent instructions
|
||||||
|
|
||||||
|
This project's knowledge base lives in `ai-context/` and `docs/`. **Do not read
|
||||||
|
it preemptively.** When a task may touch project infrastructure, services, or
|
||||||
|
repository operations, consult the routing index `docs/active-context.md` and
|
||||||
|
open an entry only if its trigger matches your task.
|
||||||
|
|
||||||
|
Files under `ai-context/` are read-only — the install script overwrites them on
|
||||||
|
update, so local edits are lost.
|
||||||
|
|
||||||
|
Record project-specific discoveries (gotchas, quirks, non-obvious tool
|
||||||
|
behaviour) as short entries in `docs/hints.md`; prune stale ones.
|
||||||
@@ -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.
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
# Gitea workflow
|
||||||
|
|
||||||
|
## Branch flow
|
||||||
|
|
||||||
|
`main` → `test` → `dev` → feature branches.
|
||||||
|
|
||||||
|
`test` is the default branch, protected, merge-only.
|
||||||
|
|
||||||
|
For a brand-new empty repo: create `test` from `main`, then `dev` from `test`.
|
||||||
|
|
||||||
|
## Posting with `tea`
|
||||||
|
|
||||||
|
When posting to Gitea with `tea` (issues, comments, PRs):
|
||||||
|
|
||||||
|
- Run it in the **foreground**.
|
||||||
|
- **Use the right body argument** — there is no `--body` flag and no
|
||||||
|
read-from-file option:
|
||||||
|
- `tea issues create` / `tea pulls create`: body is `--description` / `-d`
|
||||||
|
- `tea comment <index> [<body>]`: body is a **positional** argument (no flag)
|
||||||
|
- **Never put `\n` escape sequences in the body.** `tea` stores the string
|
||||||
|
verbatim — it does not interpret escapes, so any `\n` you pass appears
|
||||||
|
*literally* in the posted text. The body must already contain real newlines:
|
||||||
|
write it to a file (the Write tool, or `printf` — never `echo "...\n..."`),
|
||||||
|
then pass it by command substitution, e.g. `-d "$(cat body.md)"` or
|
||||||
|
`tea comment 42 "$(cat body.md)"`. Inline heredocs and `\n`-laden argument
|
||||||
|
strings are what cause the literal `\n`.
|
||||||
|
- **Confirm it actually posted** — check the exit code *and* re-read the created
|
||||||
|
item; verify the body renders with real line breaks, not literal `\n`. `tea`
|
||||||
|
can fail silently.
|
||||||
|
- **Write subcommands hang on inherited stdin.** Even with the body passed as an
|
||||||
|
argument, `tea` (`comment`, `issues edit`, `issues close`, `pulls create`, …)
|
||||||
|
reads stdin; in a non-TTY shell the inherited stdin never sends EOF, so the
|
||||||
|
command blocks until it is killed. **Fix: redirect stdin — append `</dev/null`**
|
||||||
|
to every `tea` *write* subcommand (e.g. `tea comment 42 "$(cat body.md)"
|
||||||
|
</dev/null`). Plain reads are unaffected.
|
||||||
|
- `--repo` is optional inside the repo — it is not the fix for a silent failure.
|
||||||
|
|
||||||
|
## Reading and editing with `tea` (prefer `tea` over the API)
|
||||||
|
|
||||||
|
`tea` covers every issue/PR operation this workflow needs — there is **no
|
||||||
|
capability gap** versus the raw Gitea API, so reach for `tea` first and drop to
|
||||||
|
`curl` on `/api/v1/...` only if a genuinely new gap appears. Recent findings that
|
||||||
|
correct earlier assumptions:
|
||||||
|
|
||||||
|
- **Show a full issue/PR body:** `tea issue <n>` (note: `tea issues view <n>`
|
||||||
|
does *not* print the full body).
|
||||||
|
- **Show comments:** `tea issue <n> --comments`. Comments are hidden by default,
|
||||||
|
but this flag renders them — you do **not** need the API to read comments.
|
||||||
|
- **Edit an existing issue body:** `tea issues edit <n> -d "$(cat body.md)"
|
||||||
|
</dev/null`. `-d`/`--description` does a **full-body replace**. This subcommand
|
||||||
|
exists — earlier notes claiming `tea` could not edit bodies (and that the API
|
||||||
|
was required) were wrong. `tea issues edit` also sets title/labels/milestone/
|
||||||
|
assignees.
|
||||||
|
- The `curl` fallback has its own friction (the shell's proxy can mangle piped
|
||||||
|
`curl` output — write to a file then parse; comment POSTs need a
|
||||||
|
`{"body": "..."}` wrapper) and offers no capability upside, so it is a last
|
||||||
|
resort, not the default.
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
# Gitea → GitHub Release Distribution
|
||||||
|
|
||||||
|
Pattern for projects where **development lives privately in Gitea** and **public
|
||||||
|
releases ship to a separate GitHub repo**. GitHub only ever sees curated,
|
||||||
|
redacted release snapshots — a clean tagged history, **no Gitea history carried
|
||||||
|
over**. Repo/owner/package names below are placeholders; substitute per project.
|
||||||
|
|
||||||
|
## Placeholders
|
||||||
|
|
||||||
|
| Placeholder | Meaning |
|
||||||
|
| -------------- | ---------------------------------------------------- |
|
||||||
|
| `$DEV_REMOTE` | Gitea dev repo (`https://gitea.example/o/p.git`) |
|
||||||
|
| `$REL_REPO` | Local release git folder (publish staging repo) |
|
||||||
|
| `$REL_REMOTE` | Optional GitHub remote on `$REL_REPO`, added later |
|
||||||
|
| `$RELEASE_REF` | Gitea commit/branch/tag to cut from (e.g. `main`) |
|
||||||
|
| `$VERSION` | Version string from package metadata |
|
||||||
|
|
||||||
|
The release destination is a **local git folder** (`$REL_REPO`). Cutting a
|
||||||
|
release needs no remote, username, or password. Wiring `$REL_REMOTE` (GitHub)
|
||||||
|
and pushing is an optional later step, done once the public repo exists.
|
||||||
|
|
||||||
|
## Principles
|
||||||
|
|
||||||
|
1. **Two repos, two histories.** Never wired as upstream/downstream. Gitea is
|
||||||
|
the source of truth; GitHub sees only release snapshots.
|
||||||
|
2. **Allow-list, never deny-list.** Build the release tree from an explicit list
|
||||||
|
of paths to *include*. A blanket worktree copy (minus excludes) is how
|
||||||
|
secrets leak — forbidden. Anything not listed does not ship.
|
||||||
|
3. **Runtime-only minimal payload.** Ship what's needed to install and run:
|
||||||
|
source package (incl. bundled `locale/`, templates, assets), packaging
|
||||||
|
manifest (`pyproject.toml`/equiv), `README.md`, `LICENSE`, and *sanitized*
|
||||||
|
`*.example.*` config/service files. No tests, no CI by default.
|
||||||
|
4. **Redact real deployment data.** Real configs (hosts, channels, accounts,
|
||||||
|
credentials, customer data) must never reach a public repo. Treat any
|
||||||
|
non-example config as private until proven otherwise.
|
||||||
|
5. **Clean history.** One tagged commit (`v$VERSION`) per release; release diffs
|
||||||
|
visible on GitHub, Gitea dev history not.
|
||||||
|
6. **Deliberate and manual.** Cut from a known-good `$RELEASE_REF` by running a
|
||||||
|
checked-in script by hand. No push-on-commit.
|
||||||
|
|
||||||
|
Do not ship: internal docs, planning/spec material, agent/tooling instructions
|
||||||
|
(`AGENTS.md`, `CLAUDE.md`, copilot/agent dirs), ai-context notes, Gitea/runner
|
||||||
|
ops docs. (A Gitea self-hosted-runner workflow is meaningless on GitHub Actions —
|
||||||
|
if you want CI public, rewrite it GitHub-native, don't copy.)
|
||||||
|
|
||||||
|
## SSH auth (when the push is wired up)
|
||||||
|
|
||||||
|
Use a per-project SSH host alias backed by a dedicated deploy key in
|
||||||
|
`~/.ssh/config`:
|
||||||
|
|
||||||
|
```sshconfig
|
||||||
|
Host github-<project>-public
|
||||||
|
HostName github.com
|
||||||
|
User git
|
||||||
|
IdentityFile ~/.ssh/id_ed25519_<project>-public
|
||||||
|
IdentitiesOnly yes
|
||||||
|
```
|
||||||
|
|
||||||
|
Remote becomes `github-<project>-public:OWNER/REPO.git` — no URL/user/password at
|
||||||
|
release time, key scoped to one repo, `IdentitiesOnly yes` stops SSH offering
|
||||||
|
unrelated keys. `IdentityFile` may point at the `.pub` when the private key is in
|
||||||
|
`ssh-agent` (agent signs); otherwise point at the private key. The release script
|
||||||
|
should check the alias exists before pushing, failing early with guidance.
|
||||||
|
|
||||||
|
Once the alias and deploy key are in place for the project, remove this note's
|
||||||
|
entry from `docs/active-context.md` — the setup is one-time and shouldn't keep
|
||||||
|
occupying agent context.
|
||||||
|
|
||||||
|
## Release procedure
|
||||||
|
|
||||||
|
Keep `$REL_REPO` as a separate local folder from the dev clone. Each release:
|
||||||
|
|
||||||
|
1. Pick + verify `$RELEASE_REF` in Gitea (tests/CI green).
|
||||||
|
2. Read `$VERSION` from package metadata.
|
||||||
|
3. Rebuild `$REL_REPO`'s tracked content from the allow-list at `$RELEASE_REF`
|
||||||
|
via `git archive` (committed files only).
|
||||||
|
4. Commit as a single `Release $VERSION`, tag `v$VERSION` — all local.
|
||||||
|
5. Optionally push to GitHub once `$REL_REMOTE` exists, and optionally create a
|
||||||
|
GitHub Release. PyPI publishing, if any, is a separate explicit step.
|
||||||
|
|
||||||
|
### Allow-list export (sketch)
|
||||||
|
|
||||||
|
Keep the allow-list in the script, reviewed like code:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
DEV_REF="${1:-main}"
|
||||||
|
STAGE="$(mktemp -d)"
|
||||||
|
|
||||||
|
PATHS=( # Anything not listed does NOT ship.
|
||||||
|
src pyproject.toml README.md LICENSE
|
||||||
|
examples/app.service examples/config.example.yaml examples/content.example.yaml
|
||||||
|
)
|
||||||
|
|
||||||
|
git archive "$DEV_REF" -- "${PATHS[@]}" | tar -x -C "$STAGE" # committed only
|
||||||
|
|
||||||
|
# Guard: refuse if a non-example file slipped into examples/
|
||||||
|
if find "$STAGE/examples" -type f ! -name '*.example.*' ! -name '*.service' | grep -q .; then
|
||||||
|
echo "Refusing: non-example file in examples/ — possible private data" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Sync into $REL_REPO, preserving its .git, then commit/tag (push later):
|
||||||
|
# rsync -a --delete --exclude='.git' "$STAGE"/ "$REL_REPO"/
|
||||||
|
# git -C "$REL_REPO" add -A
|
||||||
|
# git -C "$REL_REPO" commit -m "Release $VERSION"
|
||||||
|
# git -C "$REL_REPO" tag "v$VERSION"
|
||||||
|
# git -C "$REL_REPO" push -u --follow-tags origin main # once a remote exists
|
||||||
|
```
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
# Rootless Docker Gitea Runner Notes
|
||||||
|
|
||||||
|
Reusable setup for running Gitea Actions jobs on a **rootless** Docker host when
|
||||||
|
jobs need Docker access (Playwright, Buildx). The runner runs as a container;
|
||||||
|
job containers reach the host's rootless socket. Substitute the rootless socket
|
||||||
|
path `/run/user/<UID>/docker.sock` for your runner user (examples use `3100`).
|
||||||
|
|
||||||
|
## Runner compose
|
||||||
|
|
||||||
|
Mount the rootless socket into the runner container:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
services:
|
||||||
|
gitea-runner-1:
|
||||||
|
image: gitea/act_runner:latest
|
||||||
|
container_name: s4-runner-1
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
GITEA_INSTANCE_URL: https://gitea.example.com
|
||||||
|
GITEA_RUNNER_REGISTRATION_TOKEN: ${GITEA_RUNNER_REGISTRATION_TOKEN}
|
||||||
|
GITEA_RUNNER_NAME: s4-runner-1
|
||||||
|
CONFIG_FILE: /data/config.yaml
|
||||||
|
volumes:
|
||||||
|
- runner1_data:/data
|
||||||
|
- ./config.yaml:/data/config.yaml:ro
|
||||||
|
- /run/user/3100/docker.sock:/var/run/docker.sock
|
||||||
|
volumes:
|
||||||
|
runner1_data:
|
||||||
|
```
|
||||||
|
|
||||||
|
## Runner config
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
container:
|
||||||
|
docker_host: "-" # critical
|
||||||
|
options: "-v /run/user/3100/docker.sock:/var/run/docker.sock"
|
||||||
|
valid_volumes:
|
||||||
|
- /run/user/3100/docker.sock # source path only
|
||||||
|
```
|
||||||
|
|
||||||
|
`docker_host: "-"` stops `act_runner` from adding its own socket mount on top of
|
||||||
|
the one in `options`, which otherwise fails job creation with
|
||||||
|
`Duplicate mount point: /var/run/docker.sock`. Keep `valid_volumes` as the host
|
||||||
|
source path only (not the `src:dst` bind form).
|
||||||
|
|
||||||
|
## Workflow patterns
|
||||||
|
|
||||||
|
- **Buildx**: use `docker/setup-buildx-action@v3` with `driver: docker`. The
|
||||||
|
default driver starts a separate BuildKit container that misbehaves on this
|
||||||
|
runner shape; the `docker` driver uses the mounted daemon directly.
|
||||||
|
- **No setup-node cache**: drop `cache: pnpm` from `actions/setup-node@v4`; the
|
||||||
|
job network often can't reach the Gitea Actions cache service (minutes of
|
||||||
|
restore/save timeouts). Use `pnpm install --frozen-lockfile` instead. Revisit
|
||||||
|
only once the cache-service network path is confirmed.
|
||||||
|
- **Playwright**: split browser E2E from validation — build + upload artifact in
|
||||||
|
a normal job, then run Playwright in the official image
|
||||||
|
(`mcr.microsoft.com/playwright:v1.58.2-noble`) against the downloaded artifact.
|
||||||
|
|
||||||
|
## Host checks
|
||||||
|
|
||||||
|
```sh
|
||||||
|
docker context show; echo "$DOCKER_HOST"; ls -l /run/user/3100/docker.sock; docker info
|
||||||
|
# DOCKER_HOST should be unix:///run/user/3100/docker.sock
|
||||||
|
docker run --rm -v /run/user/3100/docker.sock:/var/run/docker.sock docker:27-cli docker version
|
||||||
|
```
|
||||||
|
|
||||||
|
If that container can't use the socket, fix rootless Docker before touching the
|
||||||
|
runner.
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
- **`Duplicate mount point: /var/run/docker.sock`** — set `docker_host: "-"`,
|
||||||
|
keep the bind only in `options`, `valid_volumes` = source path only, recreate.
|
||||||
|
- **`WARNING: IPv4 forwarding is disabled`** may persist even after host
|
||||||
|
`net.ipv4.ip_forward=1`. `systemctl --user restart docker` and re-check
|
||||||
|
`docker info`. If jobs create containers and reach their networks, treat it as
|
||||||
|
stale output, not a failure.
|
||||||
|
- **Buildx/build fails** — use `driver: docker`; confirm `docker version` works
|
||||||
|
through the mounted socket.
|
||||||
|
|
||||||
|
## Useful commands
|
||||||
|
|
||||||
|
```sh
|
||||||
|
docker ps --format '{{.ID}} {{.Image}} {{.Status}} {{.Names}}'
|
||||||
|
docker logs --tail 120 s4-runner-1
|
||||||
|
docker compose up -d --force-recreate gitea-runner-1
|
||||||
|
tea actions runs --repo owner/repo
|
||||||
|
tea actions runs logs <run-id> --job <job-id> --repo owner/repo
|
||||||
|
```
|
||||||
|
|
||||||
|
## Known-good (Grindex / current runner)
|
||||||
|
|
||||||
|
Host `s4.fisher.hu`; container `s4-runner-1`; image `gitea/act_runner:latest`;
|
||||||
|
socket `/run/user/3100/docker.sock`; workflows in `.gitea/workflows`; Buildx
|
||||||
|
`driver: docker`; setup-node cache disabled.
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
# Cloudflare R2 CI Setup
|
||||||
|
|
||||||
|
Upload a build artifact (e.g. a status JSON) to an R2 bucket from CI, using
|
||||||
|
`awscli` against R2's S3-compatible API.
|
||||||
|
|
||||||
|
## Cloudflare side
|
||||||
|
|
||||||
|
Create a bucket and a write-capable API token/key pair; note the account ID.
|
||||||
|
|
||||||
|
## CI config
|
||||||
|
|
||||||
|
Variables: `R2_BUCKET_NAME`, `R2_ACCOUNT_ID`.
|
||||||
|
Secrets: `R2_ACCESS_KEY_ID`, `R2_SECRET_ACCESS_KEY`.
|
||||||
|
|
||||||
|
The keys must be passed as `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` —
|
||||||
|
`awscli` reads those names. Also required: `AWS_DEFAULT_REGION=auto` and
|
||||||
|
endpoint `https://${R2_ACCOUNT_ID}.r2.cloudflarestorage.com`.
|
||||||
|
|
||||||
|
## Job
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
publish_status:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v6
|
||||||
|
- uses: actions/setup-python@v6
|
||||||
|
with: { python-version: "3.12" }
|
||||||
|
- run: pip install awscli
|
||||||
|
- name: Publish to R2
|
||||||
|
env:
|
||||||
|
R2_BUCKET_NAME: ${{ vars.R2_BUCKET_NAME }}
|
||||||
|
R2_ACCOUNT_ID: ${{ vars.R2_ACCOUNT_ID }}
|
||||||
|
AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
|
||||||
|
AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
|
||||||
|
run: |
|
||||||
|
set -eu
|
||||||
|
# If publish is optional, skip on missing config; use exit 1 if mandatory.
|
||||||
|
[ -n "${R2_BUCKET_NAME:-}" ] && [ -n "${R2_ACCOUNT_ID:-}" ] \
|
||||||
|
&& [ -n "${AWS_ACCESS_KEY_ID:-}" ] && [ -n "${AWS_SECRET_ACCESS_KEY:-}" ] \
|
||||||
|
|| { echo "Skipping: R2 config not set."; exit 0; }
|
||||||
|
f="$(mktemp -d)/project-name.json"
|
||||||
|
cat > "$f" <<EOF
|
||||||
|
{"generated_at":"$(date -u +"%Y-%m-%d %H:%M UTC")","build":{"state":"passing"}}
|
||||||
|
EOF
|
||||||
|
AWS_DEFAULT_REGION=auto aws s3api put-object \
|
||||||
|
--bucket "$R2_BUCKET_NAME" --key "project-name.json" --body "$f" \
|
||||||
|
--content-type application/json \
|
||||||
|
--endpoint-url "https://${R2_ACCOUNT_ID}.r2.cloudflarestorage.com"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Keep object keys explicit and stable (`project-name.json`,
|
||||||
|
`status/project-name.json`). For dashboards, don't put commit SHAs in the
|
||||||
|
primary key without also keeping a stable "latest" pointer.
|
||||||
|
- Common failures: wrong account ID (bad endpoint), wrong bucket (auth ok but
|
||||||
|
write fails), missing AWS env vars (no auth), missing `AWS_DEFAULT_REGION=auto`
|
||||||
|
(inconsistent behaviour), using the standard AWS S3 endpoint instead of R2.
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
# ntfy CI Notification Setup
|
||||||
|
|
||||||
|
Send an `ntfy` message on workflow success and another on failure, for push
|
||||||
|
events only. Needs `curl` in the runner and the ntfy server reachable from CI.
|
||||||
|
|
||||||
|
- Base URL via workflow env: `NTFY_BASE_URL: https://ntfy.fisher.hu`
|
||||||
|
- Topic = repo name with `/` → `-`: `topic="${GITHUB_REPOSITORY//\//-}"`
|
||||||
|
(e.g. `webdev/domaindingo` → `webdev-domaindingo`)
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
env:
|
||||||
|
NTFY_BASE_URL: https://ntfy.fisher.hu
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
notify_ok:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: [build, publish_project_status] # replace with your pipeline jobs
|
||||||
|
if: ${{ always() && github.event_name == 'push' && needs.build.result == 'success' && needs.publish_project_status.result == 'success' }}
|
||||||
|
steps:
|
||||||
|
- name: Notify ntfy (success)
|
||||||
|
env: { COMMIT_MESSAGE: "${{ github.event.head_commit.message }}" }
|
||||||
|
run: |
|
||||||
|
topic="${GITHUB_REPOSITORY//\//-}"
|
||||||
|
curl -fsS -d "✅ Repo: ${GITHUB_REPOSITORY} | Branch: ${GITHUB_REF_NAME} | Message: ${COMMIT_MESSAGE}" \
|
||||||
|
"${NTFY_BASE_URL}/${topic}"
|
||||||
|
|
||||||
|
notify_failure:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: [build, publish_project_status]
|
||||||
|
if: ${{ always() && github.event_name == 'push' && (needs.build.result != 'success' || needs.publish_project_status.result != 'success') }}
|
||||||
|
steps:
|
||||||
|
- name: Notify ntfy (failure)
|
||||||
|
env: { COMMIT_MESSAGE: "${{ github.event.head_commit.message }}" }
|
||||||
|
run: |
|
||||||
|
topic="${GITHUB_REPOSITORY//\//-}"
|
||||||
|
curl -fsS -d "❌ Repo: ${GITHUB_REPOSITORY} | Branch: ${GITHUB_REF_NAME} | Message: ${COMMIT_MESSAGE}" \
|
||||||
|
"${NTFY_BASE_URL}/${topic}"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- `needs:` must list the jobs that define "done"; the `if:` expressions decide
|
||||||
|
success vs. failure. Keep `always()` so the failure job still runs when an
|
||||||
|
upstream job fails.
|
||||||
|
- Limit to `push`: `github.event.head_commit.message` is absent on other events.
|
||||||
|
- `curl -fsS` fails the step on HTTP errors while still printing diagnostics.
|
||||||
|
- Auth: if the server needs a token, store it as a secret and add
|
||||||
|
`-H "Authorization: Bearer ${NTFY_TOKEN}"` to the curl call.
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
# Working Style Checklist
|
||||||
|
|
||||||
|
Decision ladder before writing code or tooling (from [Ponytail](https://github.com/DietrichGebert/ponytail)):
|
||||||
|
|
||||||
|
1. **Does this need to exist?** → No: skip it (YAGNI)
|
||||||
|
2. **Already in this codebase?** → Reuse, don't rewrite
|
||||||
|
3. **Stdlib does it?** → Use it
|
||||||
|
4. **Native platform feature?** → Use it
|
||||||
|
5. **Installed dependency?** → Use it
|
||||||
|
6. **One line?** → One line
|
||||||
|
7. **Only then:** the minimum that works
|
||||||
|
|
||||||
|
Lazy about the solution, never about reading the problem first.
|
||||||
|
|
||||||
|
## Math & Arithmetic
|
||||||
|
|
||||||
|
For numeric calculations in shell scripts, pick the simplest rung:
|
||||||
|
- Shell arithmetic `$(( ))` — fixed-width integers, one line
|
||||||
|
- `bc` — arbitrary precision when needed
|
||||||
|
- Python `python3 -c "print(...)"` — complex logic or exact precision
|
||||||
|
- Always validate edge cases (overflow, division by zero)
|
||||||
|
|
||||||
|
## Plain English
|
||||||
|
|
||||||
|
Write in plain English for system administrators:
|
||||||
|
- Prefer short, direct sentences.
|
||||||
|
- Use concrete verbs: check, read, write, test, stop, ask.
|
||||||
|
- Avoid management and policy terms such as leverage, align, facilitate, ensure, optimize, stakeholder, outcome, and workflow.
|
||||||
|
- Say who should act and under which condition.
|
||||||
|
- Keep necessary technical terms, but explain unfamiliar ones.
|
||||||
|
- Remove repetition and text that does not change the action.
|
||||||
|
- Preserve the exact meaning and all safety requirements.
|
||||||
Executable
+319
@@ -0,0 +1,319 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
#
|
||||||
|
# Generic, host/repo/branch-aware deployment.
|
||||||
|
#
|
||||||
|
# One script for every project and every environment in the fleet. It is driven
|
||||||
|
# entirely by targets.json, so onboarding a new environment never means writing
|
||||||
|
# another copy of this logic inside a project repository.
|
||||||
|
#
|
||||||
|
# Invoked by the webhook daemon as:
|
||||||
|
# cd-deploy --repo OWNER/NAME --ref refs/heads/BRANCH --sha FULLSHA [--event push]
|
||||||
|
#
|
||||||
|
# Deliberate properties, each one a lesson from the scripts this replaces:
|
||||||
|
#
|
||||||
|
# * It never touches git. The compose stacks live in uas-ng and are refreshed
|
||||||
|
# by that repo's own updater timer. A deploy script that runs `git reset
|
||||||
|
# --hard` owns two jobs badly instead of one job well.
|
||||||
|
# * It deploys the immutable <branch>-sha-<short> tag, then re-pins to the
|
||||||
|
# resolved digest. Mutable tags like :latest and :test have already drifted
|
||||||
|
# once in this fleet and served a broken build.
|
||||||
|
# * It verifies the image's org.opencontainers.image.revision label equals the
|
||||||
|
# commit that triggered the deploy, before changing anything.
|
||||||
|
# * It never runs `docker compose down`, and most emphatically never
|
||||||
|
# `down -v` -- `up -d` recreates exactly the services whose image changed.
|
||||||
|
# * A target belonging to another host is a clean no-op, logged as such, and
|
||||||
|
# never reported as a success.
|
||||||
|
|
||||||
|
set -Eeuo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
readonly SCRIPT_DIR
|
||||||
|
readonly CD_TARGET="${SCRIPT_DIR}/cd-target"
|
||||||
|
|
||||||
|
readonly STATE_DIR="${XDG_STATE_HOME:-${HOME}/.local/state}/cd-webhook"
|
||||||
|
readonly NO_MATCH=3
|
||||||
|
|
||||||
|
REPO=""
|
||||||
|
REF=""
|
||||||
|
SHA=""
|
||||||
|
EVENT="push"
|
||||||
|
DRY_RUN=0
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- logging ---
|
||||||
|
|
||||||
|
log() { printf '%s %s\n' "$(date -u '+%Y-%m-%dT%H:%M:%SZ')" "$*"; }
|
||||||
|
warn() { log "WARN $*" >&2; }
|
||||||
|
die() { log "ERROR $*" >&2; exit 1; }
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
sed -n '3,30p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'
|
||||||
|
exit "${1:-0}"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ------------------------------------------------------------ arg parsing ---
|
||||||
|
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
--repo) REPO="${2:?--repo needs a value}"; shift 2 ;;
|
||||||
|
--ref) REF="${2:?--ref needs a value}"; shift 2 ;;
|
||||||
|
--sha) SHA="${2:?--sha needs a value}"; shift 2 ;;
|
||||||
|
--event) EVENT="${2:?--event needs a value}"; shift 2 ;;
|
||||||
|
--dry-run) DRY_RUN=1; shift ;;
|
||||||
|
-h|--help) usage 0 ;;
|
||||||
|
*) die "unknown argument: $1 (try --help)" ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
[[ -n "$REPO" ]] || die "--repo is required"
|
||||||
|
[[ -n "$REF" ]] || die "--ref is required"
|
||||||
|
[[ -n "$SHA" ]] || die "--sha is required"
|
||||||
|
|
||||||
|
if [[ "$EVENT" != "push" ]]; then
|
||||||
|
log "ignoring event '${EVENT}' for ${REPO} (only 'push' deploys)"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$REF" != refs/heads/* ]]; then
|
||||||
|
log "ignoring non-branch ref '${REF}' for ${REPO} (tags and deletes never deploy)"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
BRANCH="${REF#refs/heads/}"
|
||||||
|
|
||||||
|
if [[ ! "$SHA" =~ ^[0-9a-f]{40}$ ]]; then
|
||||||
|
die "--sha must be a full 40-character hex commit, got: ${SHA}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# The all-zero SHA is how git spells "this ref was deleted".
|
||||||
|
if [[ "$SHA" == "0000000000000000000000000000000000000000" ]]; then
|
||||||
|
log "ignoring branch deletion of ${REPO}@${BRANCH}"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ------------------------------------------------------ target resolution ---
|
||||||
|
|
||||||
|
set +e
|
||||||
|
resolved="$("$CD_TARGET" resolve --repo "$REPO" --branch "$BRANCH")"
|
||||||
|
resolve_rc=$?
|
||||||
|
set -e
|
||||||
|
|
||||||
|
if [[ $resolve_rc -eq $NO_MATCH ]]; then
|
||||||
|
log "no target on $(hostname) for ${REPO}@${BRANCH} -- nothing to do here"
|
||||||
|
exit 0
|
||||||
|
elif [[ $resolve_rc -ne 0 ]]; then
|
||||||
|
die "target lookup failed for ${REPO}@${BRANCH} (exit ${resolve_rc})"
|
||||||
|
fi
|
||||||
|
|
||||||
|
eval "$resolved"
|
||||||
|
|
||||||
|
mkdir -p "$STATE_DIR"
|
||||||
|
readonly LOG_FILE="${STATE_DIR}/${CD_NAME}.log"
|
||||||
|
exec > >(stdbuf -oL tee -a "$LOG_FILE") 2>&1
|
||||||
|
|
||||||
|
log "=============================================================="
|
||||||
|
log "target ${CD_NAME} (${CD_ENV} on ${CD_RESOLVED_HOST})"
|
||||||
|
log "repo ${REPO}@${BRANCH}"
|
||||||
|
log "commit ${SHA}"
|
||||||
|
log "stack ${CD_STACK_DIR}/${CD_COMPOSE_FILE} [project ${CD_COMPOSE_PROJECT}]"
|
||||||
|
[[ $DRY_RUN -eq 1 ]] && log "mode DRY RUN -- nothing will be changed"
|
||||||
|
|
||||||
|
[[ -d "$CD_STACK_DIR" ]] || die "stack directory missing: ${CD_STACK_DIR}"
|
||||||
|
[[ -f "${CD_STACK_DIR}/${CD_COMPOSE_FILE}" ]] \
|
||||||
|
|| die "compose file missing: ${CD_STACK_DIR}/${CD_COMPOSE_FILE}"
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------- notify ---
|
||||||
|
|
||||||
|
notify() {
|
||||||
|
local status="$1" message="$2" icon topic
|
||||||
|
case "$status" in
|
||||||
|
ok) icon="✅" ;;
|
||||||
|
fail) icon="❌" ;;
|
||||||
|
*) icon="ℹ️" ;;
|
||||||
|
esac
|
||||||
|
topic="${REPO//\//-}"
|
||||||
|
curl -fsS --max-time 10 \
|
||||||
|
-H "Title: ${CD_NAME} deploy" \
|
||||||
|
-d "${icon} ${CD_NAME} (${CD_ENV}): ${message}" \
|
||||||
|
"${CD_NTFY_BASE_URL}/${topic}" >/dev/null 2>&1 \
|
||||||
|
|| warn "ntfy notification failed (deploy outcome itself is unaffected)"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------- lock ---
|
||||||
|
|
||||||
|
# Serialise per target, not per host: a test deploy must not block a prod one.
|
||||||
|
readonly LOCK_FILE="${STATE_DIR}/${CD_NAME}.lock"
|
||||||
|
exec 9>"$LOCK_FILE"
|
||||||
|
log "waiting for deploy lock (${CD_LOCK_WAIT_SECONDS}s max)"
|
||||||
|
if ! flock -w "$CD_LOCK_WAIT_SECONDS" 9; then
|
||||||
|
notify fail "timed out waiting for the deploy lock after ${CD_LOCK_WAIT_SECONDS}s"
|
||||||
|
die "timed out waiting for deploy lock after ${CD_LOCK_WAIT_SECONDS}s"
|
||||||
|
fi
|
||||||
|
log "lock acquired"
|
||||||
|
|
||||||
|
# -------------------------------------------------------------- image tag ---
|
||||||
|
|
||||||
|
tag="$CD_IMAGE_TAG_TEMPLATE"
|
||||||
|
tag="${tag//\{branch\}/$BRANCH}"
|
||||||
|
tag="${tag//\{env\}/$CD_ENV}"
|
||||||
|
tag="${tag//\{sha\}/$SHA}"
|
||||||
|
tag="${tag//\{short7\}/${SHA:0:7}}"
|
||||||
|
tag="${tag//\{short12\}/${SHA:0:12}}"
|
||||||
|
readonly CANDIDATE="${CD_IMAGE_REPO}:${tag}"
|
||||||
|
|
||||||
|
log "candidate ${CANDIDATE}"
|
||||||
|
|
||||||
|
# ------------------------------------------------------- wait for the image --
|
||||||
|
|
||||||
|
# The push webhook fires the moment the commit lands, which is well before CI
|
||||||
|
# has finished building. Poll rather than fail: the running container keeps
|
||||||
|
# serving throughout.
|
||||||
|
wait_for_image() {
|
||||||
|
local elapsed=0
|
||||||
|
while true; do
|
||||||
|
if docker pull "$CANDIDATE" >/dev/null 2>&1; then
|
||||||
|
log "image available after ${elapsed}s"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
if (( elapsed >= CD_IMAGE_WAIT_SECONDS )); then
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
log "image not published yet, retrying in ${CD_IMAGE_POLL_INTERVAL}s (${elapsed}s elapsed)"
|
||||||
|
sleep "$CD_IMAGE_POLL_INTERVAL"
|
||||||
|
elapsed=$(( elapsed + CD_IMAGE_POLL_INTERVAL ))
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
log "waiting for CI to publish the image (up to ${CD_IMAGE_WAIT_SECONDS}s)"
|
||||||
|
if [[ $DRY_RUN -eq 1 ]]; then
|
||||||
|
log "dry run: skipping image wait"
|
||||||
|
elif ! wait_for_image; then
|
||||||
|
notify fail "image ${CANDIDATE} never appeared (waited ${CD_IMAGE_WAIT_SECONDS}s) -- did CI fail?"
|
||||||
|
die "image ${CANDIDATE} did not appear within ${CD_IMAGE_WAIT_SECONDS}s"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --------------------------------------------------- verify and pin digest ---
|
||||||
|
|
||||||
|
image_digest_ref() {
|
||||||
|
docker image inspect --format '{{range .RepoDigests}}{{println .}}{{end}}' "$1" 2>/dev/null \
|
||||||
|
| grep "^${CD_IMAGE_REPO}@" | head -n1
|
||||||
|
}
|
||||||
|
|
||||||
|
if [[ $DRY_RUN -eq 1 ]]; then
|
||||||
|
DEPLOY_IMAGE="$CANDIDATE"
|
||||||
|
log "dry run: would deploy ${DEPLOY_IMAGE}"
|
||||||
|
else
|
||||||
|
revision="$(docker image inspect \
|
||||||
|
--format '{{index .Config.Labels "org.opencontainers.image.revision"}}' \
|
||||||
|
"$CANDIDATE" 2>/dev/null || true)"
|
||||||
|
|
||||||
|
if [[ -z "$revision" || "$revision" == "<no value>" ]]; then
|
||||||
|
warn "image carries no org.opencontainers.image.revision label; cannot prove provenance"
|
||||||
|
elif [[ "$revision" != "$SHA" ]]; then
|
||||||
|
notify fail "image ${CANDIDATE} is built from ${revision:0:12}, not ${SHA:0:12} -- refusing to deploy"
|
||||||
|
die "provenance mismatch: ${CANDIDATE} declares revision ${revision}, expected ${SHA}"
|
||||||
|
else
|
||||||
|
log "provenance revision label matches ${SHA:0:12}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
DEPLOY_IMAGE="$(image_digest_ref "$CANDIDATE")"
|
||||||
|
if [[ -z "$DEPLOY_IMAGE" ]]; then
|
||||||
|
warn "could not resolve a digest for ${CANDIDATE}; deploying by tag instead"
|
||||||
|
DEPLOY_IMAGE="$CANDIDATE"
|
||||||
|
else
|
||||||
|
log "pinned ${DEPLOY_IMAGE}"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
readonly DEPLOY_IMAGE
|
||||||
|
|
||||||
|
# --------------------------------------------- remember what is running now ---
|
||||||
|
|
||||||
|
PREVIOUS_IMAGE="$(
|
||||||
|
container_image="$(docker inspect --format '{{.Image}}' "$CD_CONTAINER" 2>/dev/null || true)"
|
||||||
|
[[ -n "$container_image" ]] && image_digest_ref "$container_image" || true
|
||||||
|
)"
|
||||||
|
readonly PREVIOUS_IMAGE
|
||||||
|
|
||||||
|
if [[ -n "$PREVIOUS_IMAGE" ]]; then
|
||||||
|
log "current ${PREVIOUS_IMAGE}"
|
||||||
|
else
|
||||||
|
log "current (nothing running -- first deploy, or container absent)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------- deploy ---
|
||||||
|
|
||||||
|
compose_up() {
|
||||||
|
local image="$1"
|
||||||
|
# `up -d` recreates only what actually changed. `down` is deliberately not
|
||||||
|
# used here: it causes avoidable downtime, and `down -v` would destroy the
|
||||||
|
# data volumes these stacks depend on.
|
||||||
|
( cd "$CD_STACK_DIR" \
|
||||||
|
&& env "${CD_IMAGE_ENV_VAR}=${image}" \
|
||||||
|
"${CD_PULL_POLICY_ENV_VAR}=missing" \
|
||||||
|
timeout "$CD_COMPOSE_TIMEOUT_SECONDS" \
|
||||||
|
docker compose -f "$CD_COMPOSE_FILE" -p "$CD_COMPOSE_PROJECT" up -d --no-build )
|
||||||
|
}
|
||||||
|
|
||||||
|
check_health() {
|
||||||
|
local attempt=1 body
|
||||||
|
while (( attempt <= CD_HEALTH_RETRIES )); do
|
||||||
|
body="$(curl -fsS --max-time 5 "$CD_HEALTH_URL" 2>/dev/null || true)"
|
||||||
|
if [[ -n "$body" ]]; then
|
||||||
|
if python3 -c '
|
||||||
|
import json, sys
|
||||||
|
key, want = sys.argv[1], sys.argv[2]
|
||||||
|
try:
|
||||||
|
data = json.loads(sys.stdin.read())
|
||||||
|
except Exception:
|
||||||
|
sys.exit(1)
|
||||||
|
sys.exit(0 if str(data.get(key, "")).lower() == want.lower() else 1)
|
||||||
|
' "$CD_HEALTH_EXPECT_KEY" "$CD_HEALTH_EXPECT_VALUE" <<<"$body"; then
|
||||||
|
log "health ok after ${attempt} attempt(s): ${body}"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
log "health not ready (attempt ${attempt}/${CD_HEALTH_RETRIES}), retrying in ${CD_HEALTH_INTERVAL}s"
|
||||||
|
sleep "$CD_HEALTH_INTERVAL"
|
||||||
|
(( attempt++ ))
|
||||||
|
done
|
||||||
|
warn "health check never passed: last response was '${body:-<no response>}'"
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
if [[ $DRY_RUN -eq 1 ]]; then
|
||||||
|
log "dry run: would run docker compose up -d with ${CD_IMAGE_ENV_VAR}=${DEPLOY_IMAGE}"
|
||||||
|
log "dry run: would health-check ${CD_HEALTH_URL}"
|
||||||
|
log "dry run complete -- no changes made"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
log "deploying ${DEPLOY_IMAGE}"
|
||||||
|
if ! compose_up "$DEPLOY_IMAGE"; then
|
||||||
|
notify fail "docker compose up failed for ${SHA:0:12} -- stack left as-is"
|
||||||
|
die "docker compose up failed"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if check_health; then
|
||||||
|
log "=== deploy succeeded: ${CD_NAME} now runs ${SHA:0:12} ==="
|
||||||
|
notify ok "deployed ${SHA:0:12} (${DEPLOY_IMAGE##*@})"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- rollback ---
|
||||||
|
|
||||||
|
if [[ "$CD_ROLLBACK_ON_FAILURE" != "True" && "$CD_ROLLBACK_ON_FAILURE" != "true" ]]; then
|
||||||
|
notify fail "health check failed for ${SHA:0:12}; rollback disabled, stack left on the new image"
|
||||||
|
die "health check failed and rollback is disabled for this target"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -z "$PREVIOUS_IMAGE" ]]; then
|
||||||
|
notify fail "health check failed for ${SHA:0:12} and there is no previous image to roll back to"
|
||||||
|
die "health check failed; no previous image recorded, leaving the stack as it is"
|
||||||
|
fi
|
||||||
|
|
||||||
|
warn "health check failed -- rolling back to ${PREVIOUS_IMAGE}"
|
||||||
|
if compose_up "$PREVIOUS_IMAGE" && check_health; then
|
||||||
|
notify fail "deploy of ${SHA:0:12} failed health check; rolled back to the previous image successfully"
|
||||||
|
die "deploy failed health check; rolled back to ${PREVIOUS_IMAGE}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
notify fail "deploy of ${SHA:0:12} failed AND rollback failed -- ${CD_NAME} needs manual attention now"
|
||||||
|
die "deploy failed and rollback also failed; manual intervention required"
|
||||||
Executable
+237
@@ -0,0 +1,237 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Render an adnanh/webhook hooks file for this host from targets.json.
|
||||||
|
|
||||||
|
One hook is emitted per repository that this host actually deploys, not one per
|
||||||
|
environment. That keeps the Gitea side trivial -- a repository gets exactly one
|
||||||
|
webhook, configured once -- while the branch/environment mapping stays in
|
||||||
|
targets.json where it can change without anyone touching Gitea.
|
||||||
|
|
||||||
|
Secrets never live in this repository. They are read from a host-local file
|
||||||
|
(default ~/.config/cd-webhook/secrets.env, mode 0600) as:
|
||||||
|
|
||||||
|
CD_WEBHOOK_SECRET_WEBDEV_DOMAINDINGO=<the secret set in Gitea>
|
||||||
|
|
||||||
|
A repository with no secret is a hard error: a hook without HMAC validation
|
||||||
|
would accept a deploy request from anyone who can reach the port.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
cd-render-hooks [--host HOST] [--output PATH] [--redact]
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import importlib.machinery
|
||||||
|
import importlib.util
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import stat
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
BIN_DIR = Path(__file__).resolve().parent
|
||||||
|
REPO_ROOT = BIN_DIR.parent
|
||||||
|
|
||||||
|
|
||||||
|
def _load_cd_target():
|
||||||
|
"""Import bin/cd-target, whose hyphenated name blocks a plain import.
|
||||||
|
|
||||||
|
Sharing the module rather than re-implementing the lookup keeps one
|
||||||
|
definition of how a (repo, branch, host) triple is matched.
|
||||||
|
"""
|
||||||
|
spec = importlib.util.spec_from_loader(
|
||||||
|
"cd_target",
|
||||||
|
importlib.machinery.SourceFileLoader("cd_target", str(BIN_DIR / "cd-target")),
|
||||||
|
)
|
||||||
|
module = importlib.util.module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(module)
|
||||||
|
return module
|
||||||
|
|
||||||
|
|
||||||
|
_cd_target = _load_cd_target()
|
||||||
|
load = _cd_target.load
|
||||||
|
pick_host = _cd_target.pick_host
|
||||||
|
host_matches = _cd_target.host_matches
|
||||||
|
|
||||||
|
DEFAULT_CONFIG_DIR = Path(
|
||||||
|
os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config")
|
||||||
|
) / "cd-webhook"
|
||||||
|
DEFAULT_SECRETS = DEFAULT_CONFIG_DIR / "secrets.env"
|
||||||
|
DEFAULT_OUTPUT = DEFAULT_CONFIG_DIR / "hooks.json"
|
||||||
|
|
||||||
|
REDACTED = "<redacted>"
|
||||||
|
|
||||||
|
|
||||||
|
def secret_var(repo: str) -> str:
|
||||||
|
"""webdev/domaindingo -> CD_WEBHOOK_SECRET_WEBDEV_DOMAINDINGO"""
|
||||||
|
return "CD_WEBHOOK_SECRET_" + re.sub(r"[^A-Za-z0-9]+", "_", repo).upper()
|
||||||
|
|
||||||
|
|
||||||
|
def hook_id(repo: str) -> str:
|
||||||
|
"""webdev/domaindingo -> webdev-domaindingo (becomes the URL path)."""
|
||||||
|
return re.sub(r"[^A-Za-z0-9]+", "-", repo).lower().strip("-")
|
||||||
|
|
||||||
|
|
||||||
|
def load_secrets(path: Path) -> dict[str, str]:
|
||||||
|
if not path.exists():
|
||||||
|
sys.exit(
|
||||||
|
f"cd-render-hooks: no secrets file at {path}\n"
|
||||||
|
f" Create it (mode 0600) with one line per repository, e.g.\n"
|
||||||
|
f" CD_WEBHOOK_SECRET_WEBDEV_DOMAINDINGO=<secret from the Gitea webhook>"
|
||||||
|
)
|
||||||
|
|
||||||
|
mode = path.stat().st_mode
|
||||||
|
if mode & (stat.S_IRWXG | stat.S_IRWXO):
|
||||||
|
sys.exit(
|
||||||
|
f"cd-render-hooks: {path} is group/world accessible.\n"
|
||||||
|
f" Run: chmod 600 {path}"
|
||||||
|
)
|
||||||
|
|
||||||
|
secrets: dict[str, str] = {}
|
||||||
|
for raw in path.read_text().splitlines():
|
||||||
|
line = raw.strip()
|
||||||
|
if not line or line.startswith("#") or "=" not in line:
|
||||||
|
continue
|
||||||
|
key, _, value = line.partition("=")
|
||||||
|
secrets[key.strip()] = value.strip().strip('"').strip("'")
|
||||||
|
return secrets
|
||||||
|
|
||||||
|
|
||||||
|
def build_hook(repo: str, branches: list[str], secret: str) -> dict:
|
||||||
|
"""One hook: HMAC-validated, push-only, restricted to known branches."""
|
||||||
|
branch_rules = [
|
||||||
|
{
|
||||||
|
"match": {
|
||||||
|
"type": "value",
|
||||||
|
"value": f"refs/heads/{branch}",
|
||||||
|
"parameter": {"source": "payload", "name": "ref"},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for branch in branches
|
||||||
|
]
|
||||||
|
|
||||||
|
return {
|
||||||
|
"id": hook_id(repo),
|
||||||
|
"execute-command": str(REPO_ROOT / "bin" / "cd-deploy"),
|
||||||
|
"command-working-directory": str(REPO_ROOT),
|
||||||
|
"http-methods": ["POST"],
|
||||||
|
"incoming-payload-content-type": "application/json",
|
||||||
|
|
||||||
|
# The deploy runs detached; Gitea gets an immediate answer and never
|
||||||
|
# times out waiting for an image build plus a health check.
|
||||||
|
"response-message": f"deploy request accepted for {repo}",
|
||||||
|
"include-command-output-in-response": False,
|
||||||
|
|
||||||
|
# A push to a branch we do not deploy is a normal event, not a failure.
|
||||||
|
# Returning 200 keeps Gitea's delivery history readable; the daemon log
|
||||||
|
# records which rule did not match.
|
||||||
|
"trigger-rule-mismatch-http-response-code": 200,
|
||||||
|
|
||||||
|
"pass-arguments-to-command": [
|
||||||
|
{"source": "string", "name": "--repo"},
|
||||||
|
{"source": "payload", "name": "repository.full_name"},
|
||||||
|
{"source": "string", "name": "--ref"},
|
||||||
|
{"source": "payload", "name": "ref"},
|
||||||
|
{"source": "string", "name": "--sha"},
|
||||||
|
{"source": "payload", "name": "after"},
|
||||||
|
{"source": "string", "name": "--event"},
|
||||||
|
{"source": "header", "name": "X-Gitea-Event"},
|
||||||
|
],
|
||||||
|
|
||||||
|
"trigger-rule": {
|
||||||
|
"and": [
|
||||||
|
{
|
||||||
|
"match": {
|
||||||
|
"type": "payload-hmac-sha256",
|
||||||
|
"secret": secret,
|
||||||
|
"parameter": {
|
||||||
|
"source": "header",
|
||||||
|
"name": "X-Gitea-Signature",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"match": {
|
||||||
|
"type": "value",
|
||||||
|
"value": "push",
|
||||||
|
"parameter": {
|
||||||
|
"source": "header",
|
||||||
|
"name": "X-Gitea-Event",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{"or": branch_rules} if len(branch_rules) > 1 else branch_rules[0],
|
||||||
|
]
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
||||||
|
parser.add_argument("--host")
|
||||||
|
parser.add_argument("--secrets", type=Path, default=DEFAULT_SECRETS)
|
||||||
|
parser.add_argument("--output", type=Path, default=DEFAULT_OUTPUT)
|
||||||
|
parser.add_argument(
|
||||||
|
"--redact",
|
||||||
|
action="store_true",
|
||||||
|
help="print to stdout with secrets masked, and write nothing",
|
||||||
|
)
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
manifest = load()
|
||||||
|
host = pick_host(manifest, args.host)
|
||||||
|
names = {host.lower(), host.split(".", 1)[0].lower()}
|
||||||
|
|
||||||
|
# repo -> branches, preserving manifest order for a stable diff.
|
||||||
|
by_repo: dict[str, list[str]] = {}
|
||||||
|
for target in manifest.get("targets", []):
|
||||||
|
if not target.get("enabled", True):
|
||||||
|
continue
|
||||||
|
if not host_matches(target["host"], names):
|
||||||
|
continue
|
||||||
|
by_repo.setdefault(target["repo"], []).append(target["branch"])
|
||||||
|
|
||||||
|
if not by_repo:
|
||||||
|
sys.exit(
|
||||||
|
f"cd-render-hooks: no enabled targets for host {host!r}.\n"
|
||||||
|
f" Either this host deploys nothing, or its name does not match "
|
||||||
|
f"any 'host' field in targets.json."
|
||||||
|
)
|
||||||
|
|
||||||
|
secrets = {} if args.redact else load_secrets(args.secrets)
|
||||||
|
|
||||||
|
hooks = []
|
||||||
|
for repo, branches in by_repo.items():
|
||||||
|
if args.redact:
|
||||||
|
secret = REDACTED
|
||||||
|
else:
|
||||||
|
var = secret_var(repo)
|
||||||
|
secret = secrets.get(var, "")
|
||||||
|
if not secret:
|
||||||
|
sys.exit(
|
||||||
|
f"cd-render-hooks: {args.secrets} has no value for {var}\n"
|
||||||
|
f" Every deployed repository needs its Gitea webhook secret here."
|
||||||
|
)
|
||||||
|
hooks.append(build_hook(repo, branches, secret))
|
||||||
|
|
||||||
|
rendered = json.dumps(hooks, indent=2) + "\n"
|
||||||
|
|
||||||
|
if args.redact:
|
||||||
|
print(rendered, end="")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
args.output.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
# Create with restrictive permissions before any secret reaches the disk.
|
||||||
|
fd = os.open(args.output, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
||||||
|
with os.fdopen(fd, "w") as handle:
|
||||||
|
handle.write(rendered)
|
||||||
|
|
||||||
|
print(f"wrote {args.output} ({len(hooks)} hook(s) for {host})")
|
||||||
|
for repo, branches in by_repo.items():
|
||||||
|
print(f" /hooks/{hook_id(repo)} <- {repo} [{', '.join(branches)}]")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
Executable
+71
@@ -0,0 +1,71 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
#
|
||||||
|
# What is this host deploying, and is the receiver actually healthy?
|
||||||
|
#
|
||||||
|
# Written because "the timer is active" was never proof of anything in this
|
||||||
|
# fleet -- the same lesson the uas-ng updater documents. This shows the daemon,
|
||||||
|
# the socket, and the outcome of the last deploy for each target.
|
||||||
|
|
||||||
|
set -Eeuo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
readonly REPO_ROOT="$(cd -- "${SCRIPT_DIR}/.." && pwd)"
|
||||||
|
readonly STATE_DIR="${XDG_STATE_HOME:-${HOME}/.local/state}/cd-webhook"
|
||||||
|
readonly CONFIG_DIR="${XDG_CONFIG_HOME:-${HOME}/.config}/cd-webhook"
|
||||||
|
readonly UNIT_NAME="cd-webhook.service"
|
||||||
|
|
||||||
|
bold() { printf '\n\033[1m%s\033[0m\n' "$*"; }
|
||||||
|
|
||||||
|
bold "Host"
|
||||||
|
if eval "$("${REPO_ROOT}/bin/cd-target" host-config 2>/dev/null)"; then
|
||||||
|
printf ' %s, receiver on %s:%s\n' "$CD_HOST" "$CD_BIND" "$CD_PORT"
|
||||||
|
else
|
||||||
|
printf ' %s is not configured in targets.json\n' "$(hostname)"
|
||||||
|
printf ' answers to: %s\n' "$("${REPO_ROOT}/bin/cd-target" hostnames)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
bold "Receiver"
|
||||||
|
state="$(systemctl --user is-active "$UNIT_NAME" 2>/dev/null || true)"
|
||||||
|
printf ' %-12s %s\n' "state" "${state:-not installed}"
|
||||||
|
if [[ "$state" == "active" ]]; then
|
||||||
|
printf ' %-12s %s\n' "since" \
|
||||||
|
"$(systemctl --user show "$UNIT_NAME" -p ActiveEnterTimestamp --value)"
|
||||||
|
if command -v ss >/dev/null 2>&1 && [[ -n "${CD_PORT:-}" ]]; then
|
||||||
|
if ss -ltn 2>/dev/null | grep -q ":${CD_PORT}\b"; then
|
||||||
|
printf ' %-12s listening on port %s\n' "socket" "$CD_PORT"
|
||||||
|
else
|
||||||
|
printf ' %-12s \033[31mNOT listening on port %s\033[0m\n' "socket" "$CD_PORT"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -f "${CONFIG_DIR}/hooks.json" ]]; then
|
||||||
|
bold "Hook endpoints"
|
||||||
|
python3 - "${CONFIG_DIR}/hooks.json" <<'PY'
|
||||||
|
import json, sys
|
||||||
|
with open(sys.argv[1]) as fh:
|
||||||
|
for hook in json.load(fh):
|
||||||
|
print(f" /hooks/{hook['id']}")
|
||||||
|
PY
|
||||||
|
fi
|
||||||
|
|
||||||
|
bold "Targets on this host"
|
||||||
|
mapfile -t targets < <("${REPO_ROOT}/bin/cd-target" list 2>/dev/null || true)
|
||||||
|
if [[ ${#targets[@]} -eq 0 ]]; then
|
||||||
|
printf ' none\n'
|
||||||
|
else
|
||||||
|
for target in "${targets[@]}"; do
|
||||||
|
log="${STATE_DIR}/${target}.log"
|
||||||
|
if [[ -f "$log" ]]; then
|
||||||
|
last="$(grep -E '=== deploy succeeded|ERROR|WARN health' "$log" | tail -n1 || true)"
|
||||||
|
printf ' %-22s %s\n' "$target" "${last:-no completed deploy recorded}"
|
||||||
|
else
|
||||||
|
printf ' %-22s %s\n' "$target" "never deployed from this host"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
bold "Recent receiver log"
|
||||||
|
journalctl --user -u "$UNIT_NAME" -n 15 --no-pager 2>/dev/null \
|
||||||
|
|| printf ' (no journal entries)\n'
|
||||||
|
echo
|
||||||
Executable
+183
@@ -0,0 +1,183 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Query targets.json.
|
||||||
|
|
||||||
|
The manifest is the only place that knows how a (repo, branch) pair maps onto a
|
||||||
|
host, a compose stack and an image. Everything else -- the deploy script, the
|
||||||
|
hook generator, the installer -- asks this helper rather than parsing JSON
|
||||||
|
itself, so there is exactly one implementation of the matching rules.
|
||||||
|
|
||||||
|
Subcommands:
|
||||||
|
resolve --repo OWNER/NAME --branch BRANCH [--host HOST]
|
||||||
|
Print shell-quoted KEY=VALUE lines for the matching target.
|
||||||
|
Exit 3 when nothing matches (a normal, non-error outcome: it just
|
||||||
|
means this push is not ours to act on).
|
||||||
|
|
||||||
|
list [--host HOST]
|
||||||
|
Print the name of every enabled target owned by HOST, one per line.
|
||||||
|
|
||||||
|
repos [--host HOST]
|
||||||
|
Print the distinct repositories HOST deploys, one per line.
|
||||||
|
|
||||||
|
host-config [--host HOST]
|
||||||
|
Print shell-quoted BIND/PORT for HOST.
|
||||||
|
|
||||||
|
hostnames
|
||||||
|
Print the names this machine answers to (diagnostics).
|
||||||
|
|
||||||
|
HOST defaults to the local machine in every subcommand.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import shlex
|
||||||
|
import socket
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
|
||||||
|
# CD_TARGETS_FILE lets a test run against a scratch manifest. It changes which
|
||||||
|
# manifest is read, never which host this machine is allowed to act as, so it
|
||||||
|
# cannot be used to make one host deploy another's targets.
|
||||||
|
MANIFEST = Path(os.environ.get("CD_TARGETS_FILE", REPO_ROOT / "targets.json"))
|
||||||
|
|
||||||
|
NO_MATCH = 3
|
||||||
|
|
||||||
|
|
||||||
|
def local_hostnames() -> set[str]:
|
||||||
|
"""Every name this machine plausibly answers to.
|
||||||
|
|
||||||
|
Hosts in this fleet are referred to by FQDN in the manifest but report a
|
||||||
|
short name from `hostname`, so both forms -- and the leading label of any
|
||||||
|
FQDN -- have to count as a match. Getting this wrong is how the old
|
||||||
|
per-project scripts ended up with a dead `hostname != s2` guard.
|
||||||
|
"""
|
||||||
|
names = {socket.gethostname()}
|
||||||
|
try:
|
||||||
|
names.add(socket.getfqdn())
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
for name in list(names):
|
||||||
|
if "." in name:
|
||||||
|
names.add(name.split(".", 1)[0])
|
||||||
|
return {n.lower() for n in names if n}
|
||||||
|
|
||||||
|
|
||||||
|
def host_matches(target_host: str, names: set[str]) -> bool:
|
||||||
|
target_host = target_host.lower()
|
||||||
|
return target_host in names or target_host.split(".", 1)[0] in names
|
||||||
|
|
||||||
|
|
||||||
|
def load() -> dict:
|
||||||
|
try:
|
||||||
|
return json.loads(MANIFEST.read_text())
|
||||||
|
except FileNotFoundError:
|
||||||
|
sys.exit(f"cd-target: manifest not found: {MANIFEST}")
|
||||||
|
except json.JSONDecodeError as exc:
|
||||||
|
sys.exit(f"cd-target: manifest is not valid JSON: {exc}")
|
||||||
|
|
||||||
|
|
||||||
|
def pick_host(manifest: dict, explicit: str | None) -> str:
|
||||||
|
"""Return the manifest's canonical name for the host we are acting as."""
|
||||||
|
if explicit:
|
||||||
|
return explicit
|
||||||
|
names = local_hostnames()
|
||||||
|
for host in manifest.get("hosts", {}):
|
||||||
|
if host_matches(host, names):
|
||||||
|
return host
|
||||||
|
# Not a configured host: return the short local name so callers report
|
||||||
|
# something truthful rather than silently adopting another host's identity.
|
||||||
|
return socket.gethostname()
|
||||||
|
|
||||||
|
|
||||||
|
def emit(pairs: dict[str, object]) -> None:
|
||||||
|
for key, value in pairs.items():
|
||||||
|
print(f"{key}={shlex.quote(str(value))}")
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_resolve(args, manifest: dict) -> int:
|
||||||
|
host = pick_host(manifest, args.host)
|
||||||
|
names = local_hostnames() if not args.host else {args.host.lower()}
|
||||||
|
defaults = manifest.get("defaults", {})
|
||||||
|
|
||||||
|
for target in manifest.get("targets", []):
|
||||||
|
if not target.get("enabled", True):
|
||||||
|
continue
|
||||||
|
if target["repo"] != args.repo or target["branch"] != args.branch:
|
||||||
|
continue
|
||||||
|
if not host_matches(target["host"], names):
|
||||||
|
continue
|
||||||
|
|
||||||
|
merged = {**defaults, **target}
|
||||||
|
merged["resolved_host"] = host
|
||||||
|
emit({f"CD_{k.upper()}": v for k, v in merged.items() if not k.startswith("_")})
|
||||||
|
return 0
|
||||||
|
|
||||||
|
return NO_MATCH
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_list(args, manifest: dict) -> int:
|
||||||
|
names = local_hostnames() if not args.host else {args.host.lower()}
|
||||||
|
for target in manifest.get("targets", []):
|
||||||
|
if target.get("enabled", True) and host_matches(target["host"], names):
|
||||||
|
print(target["name"])
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_repos(args, manifest: dict) -> int:
|
||||||
|
names = local_hostnames() if not args.host else {args.host.lower()}
|
||||||
|
seen: list[str] = []
|
||||||
|
for target in manifest.get("targets", []):
|
||||||
|
if not target.get("enabled", True):
|
||||||
|
continue
|
||||||
|
if host_matches(target["host"], names) and target["repo"] not in seen:
|
||||||
|
seen.append(target["repo"])
|
||||||
|
print("\n".join(seen))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_host_config(args, manifest: dict) -> int:
|
||||||
|
host = pick_host(manifest, args.host)
|
||||||
|
config = manifest.get("hosts", {}).get(host)
|
||||||
|
if config is None:
|
||||||
|
sys.exit(
|
||||||
|
f"cd-target: host {host!r} has no entry in targets.json 'hosts'. "
|
||||||
|
"Add one before installing here."
|
||||||
|
)
|
||||||
|
emit({"CD_HOST": host, "CD_BIND": config["bind"], "CD_PORT": config["port"]})
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_hostnames(_args, _manifest: dict) -> int:
|
||||||
|
print(" ".join(sorted(local_hostnames())))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser(description="Query the CD target manifest")
|
||||||
|
sub = parser.add_subparsers(dest="command", required=True)
|
||||||
|
|
||||||
|
p = sub.add_parser("resolve")
|
||||||
|
p.add_argument("--repo", required=True)
|
||||||
|
p.add_argument("--branch", required=True)
|
||||||
|
p.add_argument("--host")
|
||||||
|
p.set_defaults(func=cmd_resolve)
|
||||||
|
|
||||||
|
for name, func in (("list", cmd_list), ("repos", cmd_repos),
|
||||||
|
("host-config", cmd_host_config)):
|
||||||
|
p = sub.add_parser(name)
|
||||||
|
p.add_argument("--host")
|
||||||
|
p.set_defaults(func=func)
|
||||||
|
|
||||||
|
p = sub.add_parser("hostnames")
|
||||||
|
p.set_defaults(func=cmd_hostnames)
|
||||||
|
|
||||||
|
args = parser.parse_args()
|
||||||
|
return args.func(args, load())
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
# Operations
|
||||||
|
|
||||||
|
Runbook for the CD webhook receiver. See [../README.md](../README.md) for the
|
||||||
|
design.
|
||||||
|
|
||||||
|
## First question: is it actually working?
|
||||||
|
|
||||||
|
```bash
|
||||||
|
~/S/my-cd-webhook/bin/cd-status
|
||||||
|
```
|
||||||
|
|
||||||
|
An active service is not proof of anything on its own — the same lesson the
|
||||||
|
`uas-ng` updater documents. `cd-status` shows the daemon state, whether the port
|
||||||
|
is genuinely listening, the hook endpoints, and the outcome of the last deploy
|
||||||
|
per target.
|
||||||
|
|
||||||
|
## Where things are
|
||||||
|
|
||||||
|
| What | Path |
|
||||||
|
|---|---|
|
||||||
|
| This repository | `~/S/my-cd-webhook` |
|
||||||
|
| Secrets (0600) | `~/.config/cd-webhook/secrets.env` |
|
||||||
|
| Rendered hooks (0600, generated) | `~/.config/cd-webhook/hooks.json` |
|
||||||
|
| Deploy logs and locks | `~/.local/state/cd-webhook/<target>.log` |
|
||||||
|
| systemd unit | `~/.config/systemd/user/cd-webhook.service` |
|
||||||
|
|
||||||
|
```bash
|
||||||
|
journalctl --user -u cd-webhook.service -f # receiver
|
||||||
|
tail -f ~/.local/state/cd-webhook/domaindingo-prod.log # a deploy
|
||||||
|
```
|
||||||
|
|
||||||
|
## Deploying by hand
|
||||||
|
|
||||||
|
The deploy script is the same one the webhook calls, so a manual deploy and an
|
||||||
|
automatic one are identical:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ~/S/my-cd-webhook
|
||||||
|
./bin/cd-deploy --repo webdev/domaindingo \
|
||||||
|
--ref refs/heads/prod \
|
||||||
|
--sha <full 40-char commit sha>
|
||||||
|
```
|
||||||
|
|
||||||
|
Add `--dry-run` to see what it would do without touching anything.
|
||||||
|
|
||||||
|
The commit must be one CI has built, because the script deploys
|
||||||
|
`<branch>-sha-<short7>` and verifies the image's revision label matches.
|
||||||
|
|
||||||
|
## Rolling back
|
||||||
|
|
||||||
|
A failed health check rolls back automatically when the target has
|
||||||
|
`rollback_on_failure` set. To roll back deliberately, deploy the previous
|
||||||
|
commit:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./bin/cd-deploy --repo webdev/domaindingo --ref refs/heads/prod --sha <previous sha>
|
||||||
|
```
|
||||||
|
|
||||||
|
This works because every build is published under an immutable per-commit tag.
|
||||||
|
Deploying "the previous version" never depends on a mutable tag still meaning
|
||||||
|
what it meant yesterday — which in this fleet it once did not.
|
||||||
|
|
||||||
|
## Rotating a webhook secret
|
||||||
|
|
||||||
|
1. Generate: `openssl rand -hex 32`
|
||||||
|
2. Update the value in Gitea (**Repository → Settings → Webhooks → edit → Secret**).
|
||||||
|
3. Update the matching line in `~/.config/cd-webhook/secrets.env`.
|
||||||
|
4. Re-render and restart: `./install/install.sh`
|
||||||
|
|
||||||
|
Order matters only in that there is a window between steps 2 and 3 where pushes
|
||||||
|
are rejected. Nothing deploys with a bad signature, so the failure mode is a
|
||||||
|
missed deploy, not a wrong one.
|
||||||
|
|
||||||
|
## Adding a host
|
||||||
|
|
||||||
|
1. Add the host to `"hosts"` in `targets.json` with its WireGuard address and
|
||||||
|
port `20090`.
|
||||||
|
2. Add its targets.
|
||||||
|
3. Commit and push this repository.
|
||||||
|
4. On the host: clone, `./install/install.sh`, fill in `secrets.env`, re-run.
|
||||||
|
5. Confirm Gitea (on s5) can reach the host's WireGuard address on that port.
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
**Gitea shows the delivery as succeeded but nothing deployed.**
|
||||||
|
Expected when the push was to a branch this host does not deploy — the hook
|
||||||
|
returns 200 by design so the delivery history stays readable. Check the receiver
|
||||||
|
log for which rule did not match, and `cd-target list` for what this host owns.
|
||||||
|
|
||||||
|
**Deliveries fail with a signature error.**
|
||||||
|
`secrets.env` and the Gitea webhook disagree. Re-run `install/install.sh` after
|
||||||
|
fixing, and confirm the Gitea webhook's content type is `application/json`.
|
||||||
|
|
||||||
|
**"image ... did not appear within 900s".**
|
||||||
|
CI never published the image. Check the repository's Actions run — this is
|
||||||
|
almost always a failed build, not a deployment fault. The old container keeps
|
||||||
|
serving throughout, so the site is unaffected.
|
||||||
|
|
||||||
|
**"provenance mismatch: ... declares revision X, expected Y".**
|
||||||
|
The tag exists but was built from a different commit. This is the tag-drift
|
||||||
|
failure mode, caught before anything changed. Do not work around it by deploying
|
||||||
|
the tag directly; find out why the tag moved.
|
||||||
|
|
||||||
|
**The receiver will not start.**
|
||||||
|
```bash
|
||||||
|
systemctl --user status cd-webhook.service
|
||||||
|
journalctl --user -u cd-webhook.service -n 50 --no-pager
|
||||||
|
```
|
||||||
|
Usual causes: the `webhook` binary is missing, the bind address does not exist
|
||||||
|
on this host (check `ip addr show wg0`), or the port is already taken.
|
||||||
|
|
||||||
|
**Deploys stopped after a reboot and nobody was logged in.**
|
||||||
|
`loginctl enable-linger` should have been set by the installer. Verify with
|
||||||
|
`loginctl show-user "$(whoami)" -p Linger`.
|
||||||
|
|
||||||
|
## Deliberate non-features
|
||||||
|
|
||||||
|
- **No `docker compose down`.** `up -d` recreates only what changed. `down -v`
|
||||||
|
in particular would destroy the data volumes these stacks depend on.
|
||||||
|
- **No arbitrary SHA input over the webhook.** The commit always comes from the
|
||||||
|
signed push payload.
|
||||||
|
- **No deploys from tags, branch deletions, or non-push events.**
|
||||||
|
- **No git operations.** Compose stacks are `uas-ng`'s responsibility and are
|
||||||
|
refreshed by its own updater timer.
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
# Active context — knowledge routing index
|
||||||
|
|
||||||
|
Index of this project's knowledge-base entries. Read an entry **only when your
|
||||||
|
current task matches its trigger** — never preemptively. When an instruction has
|
||||||
|
been fully implemented and is no longer needed, delete its line. (An ai-context
|
||||||
|
update may re-add it; if it's still done, just delete it again.)
|
||||||
|
|
||||||
|
## Entries
|
||||||
|
|
||||||
|
- Working style — code/tool checklist, math/arithmetic options → `ai-context/style/working-style.md`
|
||||||
|
- Repository operations — Gitea project, `tea` usage, branch/PR flow, remote dev
|
||||||
|
bind addresses, package-manager notes → `docs/project-knowledge-base.md`
|
||||||
|
- Gitea workflow — branch flow (`main → test → dev → feature`) and posting comments/PRs with `tea` → `ai-context/infrastructure/git-instructions.md`
|
||||||
|
- Gitea CI on a rootless-Docker runner — Playwright/Buildx, socket mount errors → `ai-context/infrastructure/rootless-docker-gitea-runner.md`
|
||||||
|
- Publishing public releases to a separate GitHub repo → `ai-context/infrastructure/gitea-to-github-release.md`
|
||||||
|
- Uploading build artifacts to Cloudflare R2 from CI → `ai-context/services/cloudflare-r2.md`
|
||||||
|
- CI success/failure notifications via ntfy → `ai-context/services/ntfy.md`
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Continuous deployment webhook receiver (adnanh/webhook)
|
||||||
|
Documentation=https://github.com/adnanh/webhook
|
||||||
|
After=network-online.target
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
|
||||||
|
# Placeholders are substituted by install/install.sh from targets.json.
|
||||||
|
# -hotreload picks up a re-rendered hooks file without a restart, so adding an
|
||||||
|
# environment is `cd-render-hooks` and nothing else.
|
||||||
|
# -nopanic keeps the daemon alive if the hooks file is momentarily unreadable
|
||||||
|
# mid-write rather than exiting and taking every other target down with it.
|
||||||
|
ExecStart=@WEBHOOK_BIN@ \
|
||||||
|
-hooks @HOOKS_FILE@ \
|
||||||
|
-ip @BIND@ \
|
||||||
|
-port @PORT@ \
|
||||||
|
-hotreload \
|
||||||
|
-nopanic
|
||||||
|
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=5s
|
||||||
|
|
||||||
|
# The deploy itself is a child process that talks to the user's Docker socket,
|
||||||
|
# so this stays deliberately unsandboxed apart from the cheap wins below.
|
||||||
|
NoNewPrivileges=yes
|
||||||
|
PrivateTmp=yes
|
||||||
|
ProtectControlGroups=yes
|
||||||
|
ProtectKernelTunables=yes
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=default.target
|
||||||
Executable
+135
@@ -0,0 +1,135 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
#
|
||||||
|
# Install the CD webhook receiver on this host.
|
||||||
|
#
|
||||||
|
# Idempotent: re-run it after editing targets.json, after rotating a secret, or
|
||||||
|
# after pulling a new version of this repository. It re-renders the hooks file
|
||||||
|
# and restarts the daemon; it never touches a project repository or uas-ng.
|
||||||
|
#
|
||||||
|
# Prerequisites:
|
||||||
|
# * adnanh/webhook on PATH (or WEBHOOK_BIN pointing at it)
|
||||||
|
# * a host-local secrets file, see secrets.env.example
|
||||||
|
# * this host present in targets.json under "hosts"
|
||||||
|
|
||||||
|
set -Eeuo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
readonly REPO_ROOT="$(cd -- "${SCRIPT_DIR}/.." && pwd)"
|
||||||
|
|
||||||
|
readonly CONFIG_DIR="${XDG_CONFIG_HOME:-${HOME}/.config}/cd-webhook"
|
||||||
|
readonly HOOKS_FILE="${CONFIG_DIR}/hooks.json"
|
||||||
|
readonly SECRETS_FILE="${CONFIG_DIR}/secrets.env"
|
||||||
|
readonly SYSTEMD_DIR="${HOME}/.config/systemd/user"
|
||||||
|
readonly UNIT_NAME="cd-webhook.service"
|
||||||
|
|
||||||
|
info() { printf '\n\033[1m==> %s\033[0m\n' "$*"; }
|
||||||
|
ok() { printf ' ok %s\n' "$*"; }
|
||||||
|
die() { printf '\n\033[31mERROR\033[0m %s\n' "$*" >&2; exit 1; }
|
||||||
|
|
||||||
|
# ------------------------------------------------------------ prerequisites --
|
||||||
|
|
||||||
|
info "Checking prerequisites"
|
||||||
|
|
||||||
|
for tool in python3 docker curl flock stdbuf; do
|
||||||
|
command -v "$tool" >/dev/null 2>&1 || die "required tool not found: ${tool}"
|
||||||
|
done
|
||||||
|
ok "python3, docker, curl, flock, stdbuf"
|
||||||
|
|
||||||
|
WEBHOOK_BIN="${WEBHOOK_BIN:-$(command -v webhook || true)}"
|
||||||
|
[[ -n "$WEBHOOK_BIN" && -x "$WEBHOOK_BIN" ]] || die \
|
||||||
|
"adnanh/webhook not found. Install it, or set WEBHOOK_BIN=/path/to/webhook.
|
||||||
|
Debian/Ubuntu: sudo apt install webhook
|
||||||
|
Or a release binary from https://github.com/adnanh/webhook/releases"
|
||||||
|
ok "webhook binary: ${WEBHOOK_BIN}"
|
||||||
|
|
||||||
|
docker info >/dev/null 2>&1 || die \
|
||||||
|
"cannot talk to Docker as $(whoami). The deploy runs as this user, so this
|
||||||
|
account needs working access to the Docker socket it deploys through."
|
||||||
|
ok "docker reachable as $(whoami)"
|
||||||
|
|
||||||
|
# ------------------------------------------------------------- host config ---
|
||||||
|
|
||||||
|
info "Resolving this host in targets.json"
|
||||||
|
|
||||||
|
eval "$("${REPO_ROOT}/bin/cd-target" host-config)"
|
||||||
|
ok "host ${CD_HOST}, binding ${CD_BIND}:${CD_PORT}"
|
||||||
|
|
||||||
|
mapfile -t TARGETS < <("${REPO_ROOT}/bin/cd-target" list)
|
||||||
|
[[ ${#TARGETS[@]} -gt 0 ]] || die "no enabled targets for ${CD_HOST} in targets.json"
|
||||||
|
for target in "${TARGETS[@]}"; do
|
||||||
|
ok "target ${target}"
|
||||||
|
done
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------- secrets ---
|
||||||
|
|
||||||
|
info "Checking the secrets file"
|
||||||
|
|
||||||
|
mkdir -p "$CONFIG_DIR"
|
||||||
|
chmod 700 "$CONFIG_DIR"
|
||||||
|
|
||||||
|
if [[ ! -f "$SECRETS_FILE" ]]; then
|
||||||
|
install -m 0600 "${REPO_ROOT}/secrets.env.example" "$SECRETS_FILE"
|
||||||
|
die "created a template at ${SECRETS_FILE}
|
||||||
|
Fill in one secret per repository, then re-run this installer.
|
||||||
|
The same value must be set as the Secret on the Gitea webhook."
|
||||||
|
fi
|
||||||
|
chmod 600 "$SECRETS_FILE"
|
||||||
|
ok "${SECRETS_FILE}"
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------- hooks ---
|
||||||
|
|
||||||
|
info "Rendering hooks for ${CD_HOST}"
|
||||||
|
"${REPO_ROOT}/bin/cd-render-hooks" --output "$HOOKS_FILE" --secrets "$SECRETS_FILE"
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ systemd --
|
||||||
|
|
||||||
|
info "Installing the user service"
|
||||||
|
|
||||||
|
mkdir -p "$SYSTEMD_DIR"
|
||||||
|
sed -e "s|@WEBHOOK_BIN@|${WEBHOOK_BIN}|g" \
|
||||||
|
-e "s|@HOOKS_FILE@|${HOOKS_FILE}|g" \
|
||||||
|
-e "s|@BIND@|${CD_BIND}|g" \
|
||||||
|
-e "s|@PORT@|${CD_PORT}|g" \
|
||||||
|
"${REPO_ROOT}/etc/cd-webhook.service" > "${SYSTEMD_DIR}/${UNIT_NAME}"
|
||||||
|
ok "${SYSTEMD_DIR}/${UNIT_NAME}"
|
||||||
|
|
||||||
|
systemctl --user daemon-reload
|
||||||
|
systemctl --user enable "$UNIT_NAME" >/dev/null
|
||||||
|
systemctl --user restart "$UNIT_NAME"
|
||||||
|
ok "enabled and restarted"
|
||||||
|
|
||||||
|
# Survive logout, same as the other user services in this fleet.
|
||||||
|
loginctl enable-linger "$(whoami)" >/dev/null 2>&1 || true
|
||||||
|
|
||||||
|
sleep 1
|
||||||
|
systemctl --user is-active --quiet "$UNIT_NAME" \
|
||||||
|
|| die "the service did not stay running:
|
||||||
|
systemctl --user status ${UNIT_NAME}
|
||||||
|
journalctl --user -u ${UNIT_NAME} -n 50 --no-pager"
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ summary --
|
||||||
|
|
||||||
|
info "Done. Configure these webhooks in Gitea"
|
||||||
|
|
||||||
|
echo
|
||||||
|
printf ' %-28s %s\n' "Target URL" "http://${CD_BIND}:${CD_PORT}/hooks/<id> (below)"
|
||||||
|
printf ' %-28s %s\n' "HTTP Method" "POST"
|
||||||
|
printf ' %-28s %s\n' "Content Type" "application/json"
|
||||||
|
printf ' %-28s %s\n' "Trigger On" "Push Events"
|
||||||
|
printf ' %-28s %s\n' "Secret" "the matching value from ${SECRETS_FILE}"
|
||||||
|
echo
|
||||||
|
|
||||||
|
"${REPO_ROOT}/bin/cd-render-hooks" --redact >/dev/null 2>&1 || true
|
||||||
|
python3 - "$HOOKS_FILE" "$CD_BIND" "$CD_PORT" <<'PY'
|
||||||
|
import json, sys
|
||||||
|
hooks_file, bind, port = sys.argv[1], sys.argv[2], sys.argv[3]
|
||||||
|
with open(hooks_file) as fh:
|
||||||
|
hooks = json.load(fh)
|
||||||
|
for hook in hooks:
|
||||||
|
print(f" http://{bind}:{port}/hooks/{hook['id']}")
|
||||||
|
PY
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo " Logs: journalctl --user -u ${UNIT_NAME} -f"
|
||||||
|
echo " Status: ${REPO_ROOT}/bin/cd-status"
|
||||||
|
echo
|
||||||
Executable
+36
@@ -0,0 +1,36 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
#
|
||||||
|
# Remove the CD webhook receiver from this host.
|
||||||
|
#
|
||||||
|
# Stops and deletes the user service. Deliberately leaves the secrets file and
|
||||||
|
# the deploy logs in place -- removing a receiver is not a reason to destroy
|
||||||
|
# credentials or the record of what was deployed. Pass --purge to remove them.
|
||||||
|
|
||||||
|
set -Eeuo pipefail
|
||||||
|
|
||||||
|
readonly CONFIG_DIR="${XDG_CONFIG_HOME:-${HOME}/.config}/cd-webhook"
|
||||||
|
readonly STATE_DIR="${XDG_STATE_HOME:-${HOME}/.local/state}/cd-webhook"
|
||||||
|
readonly SYSTEMD_DIR="${HOME}/.config/systemd/user"
|
||||||
|
readonly UNIT_NAME="cd-webhook.service"
|
||||||
|
|
||||||
|
PURGE=0
|
||||||
|
[[ "${1:-}" == "--purge" ]] && PURGE=1
|
||||||
|
|
||||||
|
echo "Stopping ${UNIT_NAME} ..."
|
||||||
|
systemctl --user disable --now "$UNIT_NAME" 2>/dev/null || true
|
||||||
|
rm -f "${SYSTEMD_DIR}/${UNIT_NAME}"
|
||||||
|
systemctl --user daemon-reload
|
||||||
|
|
||||||
|
# The rendered hooks file holds the webhook secrets, so it goes regardless; it
|
||||||
|
# is regenerated from targets.json plus secrets.env on the next install.
|
||||||
|
rm -f "${CONFIG_DIR}/hooks.json"
|
||||||
|
|
||||||
|
if [[ $PURGE -eq 1 ]]; then
|
||||||
|
echo "Purging secrets and deploy logs ..."
|
||||||
|
rm -rf "$CONFIG_DIR" "$STATE_DIR"
|
||||||
|
else
|
||||||
|
echo "Kept ${CONFIG_DIR}/secrets.env and deploy logs in ${STATE_DIR}"
|
||||||
|
echo " (re-run with --purge to remove them too)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Done."
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
# Host-local webhook secrets. NEVER commit the real file.
|
||||||
|
#
|
||||||
|
# Copy to ~/.config/cd-webhook/secrets.env, chmod 600, and set one value per
|
||||||
|
# repository this host deploys. Each value must match the "Secret" field of the
|
||||||
|
# corresponding webhook in Gitea (Repository -> Settings -> Webhooks).
|
||||||
|
#
|
||||||
|
# Variable name: CD_WEBHOOK_SECRET_ + the repo path, uppercased, non-alphanumerics
|
||||||
|
# collapsed to underscores. webdev/domaindingo -> CD_WEBHOOK_SECRET_WEBDEV_DOMAINDINGO
|
||||||
|
#
|
||||||
|
# Generate one with: openssl rand -hex 32
|
||||||
|
|
||||||
|
CD_WEBHOOK_SECRET_WEBDEV_DOMAINDINGO=
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
|
||||||
|
"_comment": [
|
||||||
|
"Single source of truth for the fleet's continuous-deployment targets.",
|
||||||
|
"A target is uniquely identified by (repo, branch). The 'host' field decides",
|
||||||
|
"which machine acts on it -- every host runs the same daemon from the same",
|
||||||
|
"checkout of this repo and simply ignores targets that are not its own.",
|
||||||
|
"",
|
||||||
|
"Adding an environment is an edit here plus a re-run of install/install.sh on",
|
||||||
|
"the owning host. It is never a change to the project repository."
|
||||||
|
],
|
||||||
|
|
||||||
|
"defaults": {
|
||||||
|
"registry": "gitea.fisher.hu",
|
||||||
|
"ntfy_base_url": "https://ntfy.fisher.hu",
|
||||||
|
|
||||||
|
"image_wait_seconds": 900,
|
||||||
|
"image_poll_interval": 15,
|
||||||
|
|
||||||
|
"health_retries": 30,
|
||||||
|
"health_interval": 5,
|
||||||
|
|
||||||
|
"lock_wait_seconds": 600,
|
||||||
|
"compose_timeout_seconds": 300
|
||||||
|
},
|
||||||
|
|
||||||
|
"hosts": {
|
||||||
|
"s5.fisher.hu": {
|
||||||
|
"bind": "10.255.255.1",
|
||||||
|
"port": 20090
|
||||||
|
},
|
||||||
|
"s4.fisher.hu": {
|
||||||
|
"bind": "10.255.255.12",
|
||||||
|
"port": 20090
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"name": "domaindingo-test",
|
||||||
|
"enabled": true,
|
||||||
|
"repo": "webdev/domaindingo",
|
||||||
|
"branch": "test",
|
||||||
|
"env": "test",
|
||||||
|
"host": "s5.fisher.hu",
|
||||||
|
|
||||||
|
"stack_dir": "/home/fisher/S/uas-ng/docker/domaindingo/s5.fisher.hu",
|
||||||
|
"compose_file": "docker-compose.yml",
|
||||||
|
"compose_project": "domaindingo-test",
|
||||||
|
"container": "domaindingo-test",
|
||||||
|
|
||||||
|
"image_repo": "gitea.fisher.hu/webdev/domaindingo",
|
||||||
|
"image_tag_template": "{branch}-sha-{short7}",
|
||||||
|
"image_env_var": "DOMAINDINGO_TEST_IMAGE",
|
||||||
|
"pull_policy_env_var": "DOMAINDINGO_TEST_PULL_POLICY",
|
||||||
|
|
||||||
|
"health_url": "http://127.0.0.1:9101/health",
|
||||||
|
"health_expect_key": "db",
|
||||||
|
"health_expect_value": "ok",
|
||||||
|
|
||||||
|
"rollback_on_failure": true
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
"name": "domaindingo-prod",
|
||||||
|
"enabled": true,
|
||||||
|
"repo": "webdev/domaindingo",
|
||||||
|
"branch": "prod",
|
||||||
|
"env": "prod",
|
||||||
|
"host": "s5.fisher.hu",
|
||||||
|
|
||||||
|
"stack_dir": "/home/fisher/S/uas-ng/docker/domaindingo/s5.fisher.hu",
|
||||||
|
"compose_file": "docker-compose-prod.yml",
|
||||||
|
"compose_project": "domaindingo-prod",
|
||||||
|
"container": "domaindingo-prod",
|
||||||
|
|
||||||
|
"image_repo": "gitea.fisher.hu/webdev/domaindingo",
|
||||||
|
"image_tag_template": "{branch}-sha-{short7}",
|
||||||
|
"image_env_var": "DOMAINDINGO_PROD_IMAGE",
|
||||||
|
"pull_policy_env_var": "DOMAINDINGO_PROD_PULL_POLICY",
|
||||||
|
|
||||||
|
"health_url": "http://127.0.0.1:9103/health",
|
||||||
|
"health_expect_key": "db",
|
||||||
|
"health_expect_value": "ok",
|
||||||
|
|
||||||
|
"rollback_on_failure": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user