Add the documentation and the agent instructions
README for humans, AGENTS.md for agents and contributors, and the docs that outlive any one platform: architecture, secrets, connectivity, triggering a deploy from another repository. CLAUDE.md, GEMINI.md and .claude/skills/ are pointers rather than copies, so every agent and every human reads the same text. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5
parent
971034e630
commit
e1f152ec3e
@@ -0,0 +1,138 @@
|
||||
# Why it is shaped this way
|
||||
|
||||
Short notes on the decisions that are not obvious from the code, and what each
|
||||
one is buying. Read `AGENTS.md` for the rules; this is the reasoning behind them.
|
||||
|
||||
## Payloads are data, not templates
|
||||
|
||||
A stack's compose file is copied to the host **verbatim**. Nothing is templated,
|
||||
nothing is generated.
|
||||
|
||||
This is the decision most likely to look wrong at first, because templating is
|
||||
what configuration management is *for*. The argument against it here:
|
||||
|
||||
- **You can read the repository and know what is on the host.** With templating
|
||||
you know what the host will get after rendering, which is not the same thing
|
||||
at three in the morning.
|
||||
- **You can `docker compose up` it by hand.** When the deploy path is broken —
|
||||
and the deploy path is what breaks — the payload is still a working compose
|
||||
project you can run from an SSH session.
|
||||
- **Upstream projects ship compose files.** Nextcloud, Gitea, Discourse and
|
||||
most self-hosted software give you one. Keeping it verbatim means you can diff
|
||||
against theirs when you upgrade. Templating it means you re-derive your changes
|
||||
every time.
|
||||
- **Per-host differences belong in the playbook.** Which networks exist, which
|
||||
directories to create, which compose files to use: these are deployment
|
||||
concerns and the role contract expresses all of them. See `metrics.yml`, which
|
||||
covers two hosts from one payload without templating anything.
|
||||
|
||||
The cost is real: genuinely dynamic values have to come from the environment
|
||||
rather than from Ansible. In practice that means a `.env` on the host, which is
|
||||
where the secrets had to live anyway.
|
||||
|
||||
## One role, and a contract
|
||||
|
||||
There is one role. Every stack-specific need is a variable on it, and the list is
|
||||
documented in `AGENTS.md` as a contract.
|
||||
|
||||
The alternative — a role per stack, or a role per *kind* of stack — is how these
|
||||
repositories usually grow, and the failure mode is consistent: twelve roles that
|
||||
are each 90% the same, diverging slowly, so that a fix to the sync logic has to
|
||||
be applied twelve times and is applied to nine.
|
||||
|
||||
The rule that keeps it honest is in `AGENTS.md` rule 4: **if two playbooks need
|
||||
the same logic, extend the contract, do not add a second role.** When a stack
|
||||
needs something the contract cannot express, that is information — say so, rather
|
||||
than working around it with loose tasks.
|
||||
|
||||
`banner.yml` is the deliberate exception. It is not a Compose stack at all: one
|
||||
shared file dropped into another stack's directory. Forcing it through a role
|
||||
built around "sync a directory, then `docker compose up`" would mean weakening
|
||||
the role for one caller. Plain tasks are the right answer, and the fact that
|
||||
there is exactly one such playbook is the signal that the contract is holding.
|
||||
|
||||
## Two entry points, not two playbooks
|
||||
|
||||
`compose_stack` exposes `sync` and `up` separately, so a playbook that must do
|
||||
work in between — a backup, a schema migration — can interleave its own tasks.
|
||||
|
||||
The alternative is a second playbook, `webapp-update.yml`, and the reason to
|
||||
avoid it is that the two immediately drift. The update playbook gains a network
|
||||
the deploy playbook does not have; someone fixes a directory in one and not the
|
||||
other. One playbook that behaves differently under a flag cannot drift from
|
||||
itself.
|
||||
|
||||
## Deploy and update are one flag
|
||||
|
||||
`pull` is a single repository-wide extra-var. `policy` is the default and means
|
||||
"Compose decides", so nothing is fetched for an image already present. `always`
|
||||
fetches newer images **and** gates the heavier per-stack work.
|
||||
|
||||
Two properties follow, and both are load-bearing:
|
||||
|
||||
- **A default run must never destroy or migrate anything.** You should be able to
|
||||
run `site.yml` at any time, against everything, without thinking about it. That
|
||||
is what makes it useful for convergence after a manual change.
|
||||
- **An update run must be safe to repeat.** It is not transactional. Something
|
||||
will fail halfway through, and the fix is to run it again.
|
||||
|
||||
## The `SSH_KEY_PATH` seam
|
||||
|
||||
Ansible knows one thing about credentials: a path to a private key. One script
|
||||
decides where that key comes from.
|
||||
|
||||
The payoff is that the same playbooks run unchanged from a laptop, from GitHub
|
||||
Actions, from GitLab CI and from Gitea Actions, and that adding a secret backend
|
||||
is a change to one
|
||||
file that no playbook imports. The script prints a path and never prints key
|
||||
material, so it is safe to call in a CI log.
|
||||
|
||||
The constraint that makes it work is worth stating: **nothing else in the
|
||||
repository touches key material.** The moment a playbook learns how to read a
|
||||
vault, the seam is gone.
|
||||
|
||||
## Nothing deploys on push
|
||||
|
||||
Every deploy path is manual or externally triggered. Merging changes what *would*
|
||||
be deployed; a person still decides when.
|
||||
|
||||
This is a judgement call, not a universal truth. It is right for a handful of
|
||||
long-lived stateful services where a bad deploy means restoring a database, and
|
||||
where the person merging is often not the person who should be watching the
|
||||
deploy. It is wrong for a fleet of stateless services with good rollback, where
|
||||
continuous deployment is the whole point.
|
||||
|
||||
If you adopt this repository and your situation is the second one, the change is
|
||||
small — add a `push` trigger to the deploy workflow — but make it deliberately.
|
||||
|
||||
## Bind mounts and the directories nobody creates
|
||||
|
||||
A surprising amount of the role contract exists because of one Docker behaviour:
|
||||
**a bind mount whose host path does not exist is created by Docker, owned by
|
||||
root.** The container then cannot write to it, and the failure surfaces as an
|
||||
application error rather than a permissions one.
|
||||
|
||||
`stack_dirs` pre-creates them as the deploy user. Three details are the result of
|
||||
getting this wrong:
|
||||
|
||||
- **Attributes apply only when set.** A directory that already exists on the host
|
||||
keeps its permissions. Re-chmodding a live bind mount is how you break a
|
||||
Postgres data directory, which refuses to start unless it is `0700` or `0750`.
|
||||
- **Escalate only for `owner`/`group`.** Creating these as root defeats the
|
||||
purpose. The role escalates only when an entry actually asks for another uid.
|
||||
- **It belongs in `stack_dirs`, not `pre_tasks`.** `pre_tasks` run before the role
|
||||
creates the stack directory, and `ansible.builtin.file` stamps its attributes
|
||||
onto every parent it creates on the way down. An escalated chown of a child
|
||||
leaves the *stack* directory owned by that uid, and the payload sync — running
|
||||
as the deploy user — then cannot write into it.
|
||||
|
||||
## Bind-mounted config needs a reload
|
||||
|
||||
Compose recreates a container when the compose file changes. It does not know or
|
||||
care that a file bind-mounted *into* the container changed, so a new nginx vhost
|
||||
or Prometheus scrape config lands on the host and has no effect.
|
||||
|
||||
The role sets `compose_stack_synced` after the sync, and playbooks key a reload
|
||||
off it. The `when:` is what keeps the playbook idempotent — without it every run
|
||||
reports a change forever, and "changed=0 means nothing happened" stops being
|
||||
true, which is the only cheap signal these deploys have.
|
||||
@@ -0,0 +1,105 @@
|
||||
# When CI cannot reach a host
|
||||
|
||||
A deploy job that **hangs and then times out** is a different problem from one
|
||||
that fails, and the distinction tells you where to look.
|
||||
|
||||
| What you see | What it means | Where the problem is |
|
||||
| --- | --- | --- |
|
||||
| `Permission denied (publickey)` | You reached sshd and it rejected the key | The key, or `authorized_keys` |
|
||||
| `Connection refused` | You reached the host; nothing is listening on that port | sshd is down, or on another port |
|
||||
| `Connection timed out` | The packets vanished in transit | The network path |
|
||||
|
||||
Only the third is a network problem. No amount of re-pasting the deploy key will
|
||||
fix it, and it is worth being sure which one you have before spending an
|
||||
afternoon on the wrong thing.
|
||||
|
||||
## The probe
|
||||
|
||||
`connectivity.yml` has two jobs.
|
||||
|
||||
**`ping`** uses the real deploy key and asks Ansible to talk to every host in the
|
||||
inventory. Success looks like `SUCCESS => {"ping": "pong"}` for each one, which
|
||||
proves the runner decoded the key, reached port 22, authenticated, and ran Python
|
||||
on the far side. That is everything a deploy needs except the playbook. Run this
|
||||
first, always.
|
||||
|
||||
**`probe`** uses no credentials at all and answers a narrower question: *where do
|
||||
the packets stop?* Run it when `ping` times out. It reports:
|
||||
|
||||
| Section | Question |
|
||||
| --- | --- |
|
||||
| 0 | Does the runner have working DNS and outbound HTTPS at all? And what is its egress address? |
|
||||
| 1 | Does each target host answer with an SSH banner? |
|
||||
| 2 | Does SSH to a known-good third party work — is outbound 22 blocked wholesale? |
|
||||
| 3 | For each host and port: open, refused, or filtered? |
|
||||
|
||||
## Reading the result
|
||||
|
||||
| What you see | What it means |
|
||||
| --- | --- |
|
||||
| Section 0 has no DNS or no external HTTPS | The runner has no usable egress at all. Talk to whoever runs it. |
|
||||
| Section 2 gets `SSH-2.0-...` but section 1 does not | Outbound 22 works generally; your hosts are dropping this runner specifically. Take the egress address to your firewall or cloud security group. |
|
||||
| Section 2 also gets no reply | Outbound 22 is blocked wholesale on the runner's network. |
|
||||
| Every port but 443 `filtered` in section 3 | An egress allowlist. No alternative SSH port will help — see below. |
|
||||
|
||||
Two details in the probe are deliberate and worth keeping if you rewrite it.
|
||||
|
||||
**It asks for the SSH banner, not just a TCP connect.** A bare connect can be
|
||||
answered by a transparent proxy that then says nothing, which looks like success
|
||||
and is not. A real sshd greets first, so `SSH-2.0-...` is the only honest proof
|
||||
you reached the actual server.
|
||||
|
||||
**It distinguishes `refused` from `filtered`.** "Refused" means you reached the
|
||||
host and nothing was listening, so that port is *allowed out*. "Filtered" means
|
||||
the packets vanished. That difference is what tells you whether you are looking
|
||||
at a service problem or a network policy, and it is the one thing a plain
|
||||
`timeout` in a deploy log never tells you.
|
||||
|
||||
## Fixing it
|
||||
|
||||
### Move the runner, not the port
|
||||
|
||||
If you find an egress allowlist, resist the urge to move sshd to a different
|
||||
port. An allowlist permits a *set* of destination ports; it is not a block on 22,
|
||||
so there is nowhere to move to. Test this rather than assuming it — the probe's
|
||||
section 3 will tell you in a minute.
|
||||
|
||||
The reliable fix is a runner that sits somewhere with a working path to the
|
||||
hosts. A CI runner only makes **outbound HTTPS** connections to its coordinator,
|
||||
which is why this works where an inbound exception would need a policy change and
|
||||
someone else's timeline.
|
||||
|
||||
```bash
|
||||
# GitHub: Settings > Actions > Runners > New self-hosted runner
|
||||
# GitLab:
|
||||
sudo gitlab-runner register --url https://gitlab.example.com --token <token>
|
||||
# Gitea:
|
||||
act_runner register --instance https://gitea.example.com --token <token> \
|
||||
--labels deploy-runner:docker://docker.gitea.com/runner-images:ubuntu-latest
|
||||
```
|
||||
|
||||
Then point the workflows at it — `DEPLOY_RUNNER` on GitHub, the `tags:` block in
|
||||
`.gitlab-ci.yml` on GitLab, and the `runs-on:` label in `.gitea/workflows/` on
|
||||
Gitea, which has to be edited in the file because Gitea takes no expression
|
||||
there. All three are described in the CI setup docs.
|
||||
|
||||
Two prerequisites, easy to forget: the host firewall or cloud security group must
|
||||
allow SSH **from the runner's address**, and the deploy key must be authorised
|
||||
for the deploy user on each host.
|
||||
|
||||
### Or ask for an exception
|
||||
|
||||
Ask whoever runs the network to allow the runner's egress address outbound to
|
||||
your hosts on port 22. Less work on your side, but it depends on someone else's
|
||||
policy and timeline, and it has to be renewed whenever the runner moves.
|
||||
|
||||
### In the meantime
|
||||
|
||||
Deploy from a workstation that can reach the hosts. The playbooks are identical
|
||||
either way — that is the point of the `SSH_KEY_PATH` seam.
|
||||
|
||||
## A note on ICMP
|
||||
|
||||
Many cloud providers drop ICMP by default, so a failed `ping` to a host proves
|
||||
nothing on its own. The probe does not test it for that reason. Judge
|
||||
reachability by the SSH banner and the port states.
|
||||
+170
@@ -0,0 +1,170 @@
|
||||
# Secrets
|
||||
|
||||
Three separate things, handled three different ways. The split matters: the first
|
||||
is needed to *reach* a host, the second is needed by the software *on* it, and
|
||||
the third is a mistake.
|
||||
|
||||
## 1. The deploy SSH key
|
||||
|
||||
Ansible knows exactly one thing about credentials: **`SSH_KEY_PATH`**, a path to
|
||||
a `0600` private key. `inventory/group_vars/all.yml` feeds it to
|
||||
`ansible_ssh_private_key_file` and nothing else in the repository touches key
|
||||
material.
|
||||
|
||||
Everything about *where the key comes from* lives in `scripts/load-ssh-key.sh`,
|
||||
which prints a path and never prints the key:
|
||||
|
||||
```bash
|
||||
export SSH_KEY_PATH="$(scripts/load-ssh-key.sh)" # env: already on disk
|
||||
export SSH_KEY_PATH="$(scripts/load-ssh-key.sh ci)" # ci: from a CI secret
|
||||
export SSH_KEY_PATH="$(scripts/load-ssh-key.sh vault)" # vault: from Vault / OpenBao
|
||||
```
|
||||
|
||||
Add a backend there rather than teaching a playbook about secrets. That seam is
|
||||
the reason the same playbooks run unchanged from a laptop, from GitHub Actions,
|
||||
from GitLab CI and from Gitea Actions.
|
||||
|
||||
Whichever backend you use, the matching **public** key must be in the deploy
|
||||
user's `~/.ssh/authorized_keys` on every host, or the key is useless.
|
||||
|
||||
### Storing it in CI
|
||||
|
||||
Store the key **base64-encoded**, as a single line:
|
||||
|
||||
```bash
|
||||
base64 -w0 < ~/.ssh/deploy_key # Linux
|
||||
base64 < ~/.ssh/deploy_key | tr -d '\n' # macOS
|
||||
```
|
||||
|
||||
```powershell
|
||||
# Windows PowerShell
|
||||
[Convert]::ToBase64String([IO.File]::ReadAllBytes("$HOME\.ssh\deploy_key"))
|
||||
```
|
||||
|
||||
On Windows use exactly that. `certutil -encode` wraps its output in a header and
|
||||
line breaks, and `Get-Content` re-encodes the bytes — both produce something that
|
||||
looks like base64 and decodes to an unusable key. Do not re-wrap the output
|
||||
either; it is one long line by design.
|
||||
|
||||
Then set it as `SSH_PRIVATE_KEY_B64`:
|
||||
|
||||
| Platform | Where | Notes |
|
||||
| --- | --- | --- |
|
||||
| GitHub | Settings → Environments → `production` → Secrets | Scoped to the environment, so only jobs naming it can read it |
|
||||
| GitLab | Settings → CI/CD → Variables | Mark **Masked** and **Protected** |
|
||||
| Gitea | Settings → Actions → Secrets | No environments and no masking controls: any run in the repository can read it. See [ci-gitea.md](ci-gitea.md) |
|
||||
|
||||
Why base64 rather than the raw key? GitLab can only *mask* a value that is a
|
||||
single line with no whitespace, and an OpenSSH private key is neither. GitHub
|
||||
redacts secrets from logs regardless, but a single-line value survives being
|
||||
passed between steps without surprises. One encoding satisfies both.
|
||||
|
||||
A raw `SSH_PRIVATE_KEY` still works — as a GitLab File-type variable, as a
|
||||
GitHub multi-line secret, or as a path on disk. The loader accepts all three. It
|
||||
just cannot be masked on GitLab, so the key can appear in a job log if something
|
||||
echoes it.
|
||||
|
||||
### Storing it in a vault
|
||||
|
||||
`vault` reads a HashiCorp Vault or OpenBao KV v2 secret. The expected layout,
|
||||
overridable with `VAULT_KV_MOUNT` / `VAULT_SECRET_PATH` / `VAULT_SECRET_FIELD`
|
||||
(or the `BAO_*` spellings):
|
||||
|
||||
```
|
||||
kv/infra/ansible/deploy-key field: private_key
|
||||
```
|
||||
|
||||
A read-only policy is enough:
|
||||
|
||||
```hcl
|
||||
path "kv/data/infra/ansible/deploy-key" {
|
||||
capabilities = ["read"]
|
||||
}
|
||||
```
|
||||
|
||||
**Keep the vault off the deploy path.** If the vault runs on a host this
|
||||
repository deploys to, and it seals on reboot, then a deploy that fetches its key
|
||||
from the vault cannot run — including the deploy that would fix that host. Host
|
||||
it elsewhere, or auto-unseal it. A CI secret is the dependable route.
|
||||
|
||||
## 2. Per-stack `.env` files
|
||||
|
||||
Stack secrets live on the hosts, not here.
|
||||
|
||||
The role excludes `.env` from every sync (`stack_exclude`), so editing one in
|
||||
this repository has no effect, and a host's `.env` is never overwritten by a
|
||||
deploy. To change one, edit it on the host and redeploy.
|
||||
|
||||
Each example stack that needs credentials ships a `.env.example` listing the
|
||||
variable names with no values, and reads them in its compose file with Compose's
|
||||
required-variable syntax:
|
||||
|
||||
```yaml
|
||||
environment:
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set it in .env on the host}
|
||||
```
|
||||
|
||||
That is worth copying. Without the `:?`, a missing variable expands to the empty
|
||||
string and Postgres starts with **no password at all** — a failure that looks
|
||||
like success until someone finds it. With it, Compose refuses to start and names
|
||||
the variable.
|
||||
|
||||
`webapp.yml` goes one step further and asserts the file exists on the host before
|
||||
doing anything, so the error arrives before the payload is synced rather than
|
||||
halfway through starting the stack:
|
||||
|
||||
```yaml
|
||||
pre_tasks:
|
||||
- name: Look for the webapp .env on the host
|
||||
ansible.builtin.stat:
|
||||
path: "{{ webapp_dir }}/.env"
|
||||
register: webapp_env
|
||||
changed_when: false
|
||||
|
||||
- name: Verify the webapp .env is present on the host
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- webapp_env.stat.exists
|
||||
fail_msg: >-
|
||||
{{ webapp_dir }}/.env is missing on {{ inventory_hostname }}.
|
||||
quiet: true
|
||||
```
|
||||
|
||||
Do this for any stack whose compose file has no safe defaults.
|
||||
|
||||
## 3. Secrets committed to the repository
|
||||
|
||||
There is no third category. This section exists because it is the failure mode
|
||||
this layout is designed to prevent, and it is worth being blunt about why.
|
||||
|
||||
A credential written directly into a compose file:
|
||||
|
||||
- **cannot be excluded from the sync.** `stack_exclude` keeps `.env` off the
|
||||
wire; it can do nothing about a value inside `docker-compose.yml`.
|
||||
- **is in every clone, forever.** Git history is the point of git. Deleting the
|
||||
file in a later commit changes nothing about the credential.
|
||||
- **cannot be rotated without a deploy**, so it usually is not rotated at all.
|
||||
- **escapes its blast radius.** A personal access token carries its creator's
|
||||
access to everything they can reach, not just the project it was added to. A
|
||||
cloud application secret outlives the VM it was used on.
|
||||
|
||||
If you find one, the fix is to **rotate the credential**, not to delete the file.
|
||||
Removing it stops new clones carrying it and changes nothing else. Then move it
|
||||
to a `.env` on the host and read it with `${VAR:?}`.
|
||||
|
||||
### The guard
|
||||
|
||||
The `lint` workflow — [`.github/workflows/lint.yml`](../.github/workflows/lint.yml),
|
||||
[`.gitea/workflows/lint.yml`](../.gitea/workflows/lint.yml) and the `lint` job in
|
||||
`.gitlab-ci.yml` — runs [gitleaks](https://github.com/gitleaks/gitleaks) on every
|
||||
push, over the working tree **and** the full history: a secret that was committed
|
||||
and then removed is still leaked, and the scan should say so. `.gitleaks.toml`
|
||||
allowlists only `*.env.example` files and this document.
|
||||
|
||||
On GitHub it runs the upstream Docker image rather than the marketplace action on
|
||||
purpose: the action requires a licence key for organisation-owned repositories
|
||||
and silently does nothing without one, which is the worst possible behaviour for
|
||||
a secret scanner. The Gitea port runs the pinned release binary instead, because
|
||||
a job there is itself a container with no Docker socket.
|
||||
|
||||
Do not add an allowlist entry to make a finding go away. Rotate the credential.
|
||||
@@ -0,0 +1,195 @@
|
||||
# Triggering a deploy from another repository
|
||||
|
||||
The case: an application repository builds a new image, pushes it, and then asks
|
||||
*this* repository to redeploy the stack that runs it.
|
||||
|
||||
```
|
||||
application repo config repo
|
||||
────────────────────── ──────────────────────────
|
||||
build → push :latest ─── trigger ───► deploy
|
||||
stack=webapp └─ ansible-playbook
|
||||
pull=always playbooks/webapp.yml
|
||||
-e pull=always
|
||||
```
|
||||
|
||||
`pull=always` is not optional here. The image tag has not changed — only its
|
||||
contents have — and Compose is perfectly happy with the image it already has. A
|
||||
trigger that forgets it produces a deploy that does nothing and reports success.
|
||||
|
||||
## GitHub Actions
|
||||
|
||||
`deploy.yml` accepts a `repository_dispatch` event of type `deploy`.
|
||||
|
||||
### Caller
|
||||
|
||||
```yaml
|
||||
- name: Redeploy the stack
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.CONFIG_REPO_TOKEN }}
|
||||
run: |
|
||||
gh api repos/OWNER/config/dispatches \
|
||||
--field event_type=deploy \
|
||||
--field 'client_payload[stack]=webapp' \
|
||||
--field 'client_payload[pull]=always'
|
||||
```
|
||||
|
||||
`CONFIG_REPO_TOKEN` is a fine-grained personal access token, or a GitHub App
|
||||
installation token, with **Contents: read and write** on the config repository.
|
||||
The default `GITHUB_TOKEN` cannot dispatch to another repository.
|
||||
|
||||
### What happens downstream
|
||||
|
||||
`deploy.yml` validates the payload before doing anything:
|
||||
|
||||
1. rejects `stack` unless it matches `[a-z0-9_-]+`, and `pull` unless it is one
|
||||
of `policy`, `always`, `missing`, `never`,
|
||||
2. fails if `ansible/playbooks/<stack>.yml` does not exist,
|
||||
3. materialises the SSH key with `scripts/load-ssh-key.sh`,
|
||||
4. runs the playbook.
|
||||
|
||||
The values arrive from another repository and end up on a command line, so they
|
||||
are treated as hostile. See "Why untrusted input is validated" in
|
||||
[ci-github-actions.md](ci-github-actions.md).
|
||||
|
||||
### The caller cannot wait
|
||||
|
||||
A `repository_dispatch` returns as soon as GitHub accepts the event. The response
|
||||
contains no run id, so the caller cannot poll for a result either. If the build
|
||||
pipeline must go red when the deploy fails, you have two options:
|
||||
|
||||
- **Put the deploy in the same repository** as the build and use `workflow_call`,
|
||||
which does propagate failure.
|
||||
- **Poll.** After dispatching, list workflow runs for `deploy.yml` created after
|
||||
your dispatch timestamp and wait on the newest. This is fiddly and racy; only
|
||||
do it if you genuinely need the coupling.
|
||||
|
||||
Most of the time you do not. A deploy that fails is visible in the config
|
||||
repository's Actions tab and can notify on failure there.
|
||||
|
||||
## GitLab CI
|
||||
|
||||
`deploy:triggered` exists only in pipelines that were triggered from outside, so
|
||||
it can never fire on a push here.
|
||||
|
||||
### Multi-project trigger (preferred)
|
||||
|
||||
In the upstream project:
|
||||
|
||||
```yaml
|
||||
deploy-config:
|
||||
stage: deploy
|
||||
trigger:
|
||||
project: your-group/config
|
||||
branch: main # must be a protected branch — see below
|
||||
strategy: depend # upstream waits and mirrors the result
|
||||
variables:
|
||||
STACK: webapp
|
||||
PULL: always
|
||||
```
|
||||
|
||||
`strategy: depend` is what makes the build pipeline report the *deployment's*
|
||||
result. Drop it and the trigger job goes green the moment the request is
|
||||
accepted, whether or not the deploy worked.
|
||||
|
||||
This authenticates with `CI_JOB_TOKEN`, so **the config project must allowlist
|
||||
the upstream project** under Settings → CI/CD → Job token permissions. Recent
|
||||
GitLab versions deny cross-project job-token access by default, and the failure
|
||||
is a **404** rather than a permissions error — which is the most common cause of
|
||||
"the trigger does nothing".
|
||||
|
||||
### Trigger token
|
||||
|
||||
For callers that are not GitLab CI — a webhook, a cron box, a person with
|
||||
`curl`. Create the token under Settings → CI/CD → Pipeline trigger tokens.
|
||||
|
||||
```bash
|
||||
curl --request POST \
|
||||
--form token="$DEPLOY_TRIGGER_TOKEN" \
|
||||
--form ref=main \
|
||||
--form "variables[STACK]=webapp" \
|
||||
--form "variables[PULL]=always" \
|
||||
"https://gitlab.example.com/api/v4/projects/your-group%2Fconfig/trigger/pipeline"
|
||||
```
|
||||
|
||||
The project path must be URL-encoded (`%2F` for the slash), or use the numeric
|
||||
project ID. The response is the created pipeline as JSON; `.web_url` takes you
|
||||
straight to it.
|
||||
|
||||
### One-time setup
|
||||
|
||||
1. **Allowlist the caller** under Job token permissions, or the trigger 404s.
|
||||
2. **Protect the target branch**, and mark the deploy variables Protected.
|
||||
Protected variables only exist on protected refs; trigger a feature branch and
|
||||
the key comes back empty.
|
||||
3. **Give the upstream project Developer access**, or the job token is refused
|
||||
regardless of the allowlist.
|
||||
|
||||
## Gitea Actions
|
||||
|
||||
Gitea has no `repository_dispatch`. The way in is the **workflow dispatch API**,
|
||||
which fills in the same form a person would use under Actions → deploy → Run
|
||||
workflow. It needs Gitea 1.24 or newer.
|
||||
|
||||
### Caller
|
||||
|
||||
```bash
|
||||
curl -X POST \
|
||||
-H "Authorization: token $CONFIG_REPO_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"ref": "main",
|
||||
"inputs": {"stack": "webapp", "mode": "deploy", "pull": "always"}
|
||||
}' \
|
||||
https://gitea.example.com/api/v1/repos/OWNER/config/actions/workflows/deploy.yml/dispatches
|
||||
```
|
||||
|
||||
Three things are easy to get wrong here:
|
||||
|
||||
- **`ref` is required**, and it is the ref the playbooks are read from as well as
|
||||
the one the run is attributed to. Point it at your default branch.
|
||||
- **`mode` falls back to `check`.** Omit it and the trigger runs
|
||||
`--check --diff`, reports what it would have done, and goes green having
|
||||
deployed nothing. This is the Gitea equivalent of forgetting `pull=always`, and
|
||||
it fails just as quietly. (The workflow applies that fallback itself: an
|
||||
input's declared `default:` belongs to the dispatch form, not to the API.)
|
||||
- **The job's own `GITEA_TOKEN` cannot do this.** It is scoped to the repository
|
||||
the job runs in. `CONFIG_REPO_TOKEN` is an access token belonging to a user
|
||||
with write access to the config repository — Settings → Applications → Access
|
||||
tokens, with the `write:repository` scope — stored as a secret in the calling
|
||||
repository.
|
||||
|
||||
The workflow name in the path is the file name, `deploy.yml`.
|
||||
|
||||
### The caller can wait, if it wants to
|
||||
|
||||
Add `?return_run_details=true` and the response carries the run's id and URL, so
|
||||
a caller can poll `/api/v1/repos/OWNER/config/actions/runs/<id>` until it
|
||||
finishes and fail its own pipeline on the result. That is more than GitHub's
|
||||
`repository_dispatch` offers, and more than most callers need: a deploy that
|
||||
fails is visible in the config repository's Actions tab and can notify from
|
||||
there.
|
||||
|
||||
### What happens downstream
|
||||
|
||||
Exactly what happens on GitHub — the same validation is in
|
||||
`.gitea/workflows/deploy.yml`, and it matters more here. A `type: choice` input
|
||||
constrains the *form* and not the API, so a dispatch call may put any string in
|
||||
`stack`.
|
||||
|
||||
## Before you enable this
|
||||
|
||||
**A trigger is a deploy.** Anyone holding the token, and any repository on the
|
||||
allowlist, can deploy any stack in this repository. The validation guard stops
|
||||
them running *arbitrary commands*; it does not stop them deploying a stack they
|
||||
had no business touching. Scope the allowlist deliberately, and rotate the token
|
||||
when someone leaves.
|
||||
|
||||
**Confirm the playbook exists first.** The downstream job fails the pipeline by
|
||||
design when it does not, which is correct but makes for a confusing first
|
||||
experience if you enable the trigger before converting the stack.
|
||||
|
||||
## Checking it worked
|
||||
|
||||
The Ansible recap at the end of the job log is the real answer. `changed=0` means
|
||||
the images had not moved — which, for a trigger that fired after a successful
|
||||
build, almost always means `pull=always` was not passed.
|
||||
Reference in New Issue
Block a user