Add a start flag, and let --check survive a first deploy

-e start=false (mode sync in CI) lands the payload and starts nothing, for
migrations. A check run on a never-deployed stack now skips Compose with a note
instead of failing on the missing directory.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Lucas WintherandClaude Opus 5.5 committed 2026-09-30 15:04:56 +02:00
1 parent c9a4d56851
commit 78b227f32d
12 files changed
+151 -14

No files matched your search

+9 -2
View File
@@ -38,12 +38,15 @@ on:
- webapp
- webapp-staging
mode:
description: check reports what would change; deploy does it
description: >-
check reports what would change; sync copies the files but starts
nothing; deploy does both
required: true
default: check
type: choice
options:
- check
- sync
- deploy
pull:
description: >-
@@ -112,7 +115,7 @@ jobs:
''|*[!a-z0-9_-]*) echo "refusing STACK='$STACK'"; exit 1 ;;
esac
case "$MODE" in
check|deploy) ;;
check|sync|deploy) ;;
*) echo "refusing MODE='$MODE'"; exit 1 ;;
esac
# An unchecked value can smuggle extra `-e` overrides onto the command
@@ -161,6 +164,10 @@ jobs:
# no error message and a green-looking cancel.
if [ "$MODE" = "check" ]; then
set -- --check --diff
elif [ "$MODE" = "sync" ]; then
# A real run that stops before Compose: the payload lands, the
# directories and networks are created, no container is touched.
set -- -e start=false
else
set --
fi
+9 -2
View File
@@ -30,12 +30,15 @@ on:
- webapp
- webapp-staging
mode:
description: check reports what would change; deploy does it
description: >-
check reports what would change; sync copies the files but starts
nothing; deploy does both
required: true
default: check
type: choice
options:
- check
- sync
- deploy
pull:
description: >-
@@ -96,7 +99,7 @@ jobs:
''|*[!a-z0-9_-]*) echo "refusing STACK='$STACK'"; exit 1 ;;
esac
case "$MODE" in
check|deploy) ;;
check|sync|deploy) ;;
*) echo "refusing MODE='$MODE'"; exit 1 ;;
esac
# An unchecked value can smuggle extra `-e` overrides onto the command
@@ -145,6 +148,10 @@ jobs:
# with no error message and a green-looking cancel.
if [ "$MODE" = "check" ]; then
set -- --check --diff
elif [ "$MODE" = "sync" ]; then
# A real run that stops before Compose: the payload lands, the
# directories and networks are created, no container is touched.
set -- -e start=false
else
set --
fi
+7 -2
View File
@@ -104,8 +104,12 @@ ansible:ping:
# as STACK, so it gets the same treatment: an unchecked value can smuggle in
# extra `-e` overrides (stack_dest, ansible_host) and redirect a deploy.
- case "$PULL" in ''|always|missing|never|policy) ;; *) echo "refusing PULL='$PULL'"; exit 1;; esac
# START=false syncs the payload and starts nothing -- the `sync` mode of the
# GitHub and Gitea workflows. Validated for the same reason as PULL.
- case "$START" in ''|true|false) ;; *) echo "refusing START='$START'"; exit 1;; esac
- test -f "ansible/playbooks/${STACK}.yml" || { echo "no playbook for '$STACK'"; exit 1; }
- cd ansible && ansible-playbook "playbooks/${STACK}.yml" ${ANSIBLE_EXTRA_ARGS} ${PULL:+-e pull=$PULL}
- cd ansible
- ansible-playbook "playbooks/${STACK}.yml" ${ANSIBLE_EXTRA_ARGS} ${PULL:+-e pull=$PULL} ${START:+-e start=$START}
# Run a pipeline with STACK set (Build > Pipelines > Run pipeline, add a
# variable) and these two appear.
@@ -152,7 +156,8 @@ deploy:triggered:
#
# To update rather than deploy, run the pipeline with PULL=always and press the
# same deploy button. That also enables the work gated behind an update run --
# webapp's backup and migration.
# webapp's backup and migration. To copy the files and start nothing, run it
# with START=false.
#
# Order mirrors ansible/playbooks/site.yml. Add a pair when a playbook lands.
.check:
+29 -5
View File
@@ -53,11 +53,12 @@ These are the conventions. Follow them; do not invent per-stack variations.
stack runs on several hosts it is still one playbook with a host group, not
one per host. `metrics.yml` is the example.
2. **Self-contained.** Every playbook must run standalone as
`ansible-playbook playbooks/<stack>.yml` with no required extra vars — `pull`
is the one optional flag. It declares its own `hosts`, its own vars, and
everything the stack needs (directories, networks, compose files). Never rely
on another playbook having run first. Where that means two playbooks create
the same directory, that is correct and the repetition is deliberate.
`ansible-playbook playbooks/<stack>.yml` with no required extra vars. `pull`
and `start` are the two optional flags, and both are repository-wide. It
declares its own `hosts`, its own vars, and everything the stack needs
(directories, networks, compose files). Never rely on another playbook having
run first. Where that means two playbooks create the same directory, that is
correct and the repetition is deliberate.
3. **Tag every play** with the stack name, so `site.yml --tags metrics` works.
4. **Reuse goes in `roles/compose_stack`, not in copy-pasted tasks.** If two
playbooks need the same logic, extend the role's variable contract rather than
@@ -96,6 +97,7 @@ stack needs something new; do not work around it with loose tasks.
| `stack_pull` | `{{ pull \| default('policy') }}` | Per-invocation override of the pull flag |
| `stack_build` | `policy` | `always` for stacks built from a Dockerfile |
| `stack_state` | `present` | `present`, `absent`, or `restarted` |
| `stack_start` | `{{ start \| default(true) }}` | `false` syncs and starts nothing |
Worked examples for each: `static-site.yml` (the minimum), `reverse-proxy.yml`
(`stack_dirs`, reload), `metrics.yml` (`stack_src`, per-host maps, ownership),
@@ -195,6 +197,28 @@ actually move. Gate those tasks on the same flag rather than creating a separate
Two consequences worth stating plainly: **a default run must never destroy or
migrate anything**, and **an update run must be safe to repeat.**
### Deploying without starting
`start` is the second repo-wide extra-var, and the mirror image of `pull`:
```bash
ansible-playbook playbooks/webapp.yml -e start=false
```
The payload is synced, `stack_dirs` and `stack_networks` are created, and the
play stops before Compose — no container is created, started or recreated. It is
for the case where something has to be put *into* a bind mount between the files
landing and the stack opening them: a data directory copied across from a
hand-deployed instance, a database restored from a dump. When you bring a
hand-deployed service under Ansible this way, write the steps you used into the
playbook's header comment, so the next migration has a recipe.
Prefer it to commenting a playbook out or running the role with
`tasks_from: sync` by hand, and prefer the two entry points to it when the step
in the middle is something Ansible should own — `start=false` is for work a
person does, once. Nothing needs to read the flag: the role gates the Compose
task on it in `tasks/up.yml`, so it applies to two-phase playbooks too.
## Safety
- **Never run a playbook against a real host unless the user asks in that turn.**
+13 -1
View File
@@ -60,7 +60,7 @@ ansible/
├── requirements.yml # Galaxy collections
├── inventory/
│ ├── hosts.yml
│ └── group_vars/all.yml # connection settings, stack_root, the pull default
│ └── group_vars/all.yml # connection settings, stack_root, pull/start defaults
├── roles/compose_stack/ # the one shared "sync payload, bring stack up" role
└── playbooks/
├── site.yml # every stack, in dependency order
@@ -149,6 +149,18 @@ you mean to update.
Verify with `--check --diff` first. These are production servers.
### Deploy without starting
```bash
ansible-playbook playbooks/webapp.yml -e start=false
```
Syncs the payload and creates the directories and networks, then stops before
Compose. It is for the gap between the files landing and the stack opening its
bind mounts — copying data across from a hand-deployed instance, or restoring a
database dump, before anything starts on top of it. Run again without the flag
to start the stack.
## The examples
Six stacks, each chosen to demonstrate one thing. Read them in this order.
+5
View File
@@ -22,3 +22,8 @@ pull: policy
# Repository root, so playbooks and the role can find payloads under server/
# regardless of the directory ansible-playbook was invoked from.
repo_root: "{{ (inventory_dir ~ '/../..') | realpath }}"
# Whether a deploy starts the stack once its files are on the host. Overridden
# per run with `-e start=false` to land a payload and stop short of Compose —
# see "Deploy without starting" in the README.
start: true
@@ -51,3 +51,10 @@ stack_pull: "{{ pull | default('policy') }}"
# Compose build policy. `policy` builds only when the image is missing; stacks
# that build from a Dockerfile in their payload want `always` on an update run.
stack_build: policy
# Whether to start the stack at all. False syncs the payload, creates the
# directories and the networks, and stops before Compose — the shape a migration
# needs, where data has to be moved into a bind mount after the files land and
# before anything opens them. Defaults to the repo-wide `start` flag, so
# `-e start=false` works on every playbook without editing any of them.
stack_start: "{{ start | default(true) | bool }}"
+50
View File
@@ -1,4 +1,49 @@
---
# docker_compose_v2 validates project_src with os.path.isdir, which is false both
# for a path that is missing and for one that is a file, and reports either as
# `"..." is not a directory`. Stat it first so the two are told apart here rather
# than left to that one message.
- name: Look for the stack directory on the host
ansible.builtin.stat:
path: "{{ stack_dest }}"
register: compose_stack_dir
changed_when: false
- name: Verify the stack directory is a directory
ansible.builtin.assert:
that:
- compose_stack_dir.stat.isdir | default(false)
fail_msg: >-
{{ stack_dest }} exists on {{ inventory_hostname }} but is not a directory
quiet: true
when: compose_stack_dir.stat.exists
# A check run creates nothing, so on a host that has never deployed this stack
# there is no project directory for the module to inspect and it fails on its
# absence — a first `--check` would report the payload diff correctly and then
# die for a reason that says nothing about the stack. Skip it instead; the sync
# tasks above have already reported everything check mode can know.
- name: Note that the stack cannot be checked before its first deploy
ansible.builtin.debug:
msg: >-
{{ stack_dest }} does not exist yet, so the compose step was skipped.
Deploy for real to create it.
when:
- stack_start | bool
- ansible_check_mode
- not compose_stack_dir.stat.exists
# The counterpart to that message, for `-e start=false`. Without it the play ends
# with the compose task skipped and nothing saying so, which reads exactly like a
# run that quietly failed to do its job.
- name: Note that the stack was synced but deliberately not started
ansible.builtin.debug:
msg: >-
start=false, so the payload is on {{ inventory_hostname }} and
{{ stack_name }} was left as it was. Run again without the flag to start
it.
when: not stack_start | bool
- name: Bring the stack up
community.docker.docker_compose_v2:
project_src: "{{ stack_dest }}"
@@ -12,3 +57,8 @@
# project directory. The module sets the process working directory but not PWD,
# so ${PWD} would silently become the SSH login directory. Set it explicitly.
environment: "{{ {'PWD': stack_dest} | combine(stack_env) }}"
# stack_start is gated here rather than around the include in main.yml, because
# a two-phase playbook calls tasks_from: up directly and main.yml never sees it.
when:
- stack_start | bool
- compose_stack_dir.stat.exists or not ansible_check_mode
+16
View File
@@ -76,6 +76,22 @@ Two properties follow, and both are load-bearing:
- **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.
## Starting is a flag too
`start` is the same idea pointed at a different question. `pull` asks *which
images*; `start` asks *whether to run Compose at all*. `-e start=false` lands the
payload, creates the directories and the networks, and stops.
The alternative — a one-off playbook that syncs and does not start, or a
commented-out `roles:` block during a migration — is a copy of the real playbook
that exists only while somebody is mid-task, which is exactly when a copy is most
likely to be deployed by accident. A flag leaves one playbook, and leaves it
honest.
The role gates the Compose task itself rather than the `up.yml` include, because
a two-phase playbook calls `tasks_from: up` directly and would otherwise slip
past the flag.
## The `SSH_KEY_PATH` seam
Ansible knows one thing about credentials: a path to a private key. One script
+1 -1
View File
@@ -117,7 +117,7 @@ Actions → **deploy** → Run workflow. Three fields:
| Field | Values | Meaning |
| --- | --- | --- |
| `stack` | the dropdown | Which playbook to run. `site` is all of them. |
| `mode` | `check`, `deploy` | `check` runs `--check --diff` and touches nothing |
| `mode` | `check`, `sync`, `deploy` | `check` runs `--check --diff` and touches nothing; `sync` copies the files for real but starts no container (`-e start=false`) |
| `pull` | `policy`, `always`, `missing`, `never` | `always` fetches newer images **and** enables per-stack update work |
Work up from harmless to real the first time:
+1 -1
View File
@@ -67,7 +67,7 @@ Actions → **deploy** → Run workflow. Three fields:
| Field | Values | Meaning |
| --- | --- | --- |
| `stack` | the dropdown | Which playbook to run. `site` is all of them. |
| `mode` | `check`, `deploy` | `check` runs `--check --diff` and touches nothing |
| `mode` | `check`, `sync`, `deploy` | `check` runs `--check --diff` and touches nothing; `sync` copies the files for real but starts no container (`-e start=false`) |
| `pull` | `policy`, `always`, `missing`, `never` | `always` fetches newer images **and** enables per-stack update work |
Work up from harmless to real the first time:
+4
View File
@@ -54,6 +54,10 @@ Pipelines → Run pipeline, add a variable) and press the same deploy button. Th
also enables the work gated behind an update run — `webapp`'s backup and
migration.
To copy the files and start nothing — the `sync` mode of the other two
platforms — run it with `START=false` instead. `true`, `false` and empty are the
only values accepted.
`ansible:ping` is the right first thing to run after setting the variables up. It
should print `SUCCESS => {"ping": "pong"}` for every host, and changes nothing.