diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index c1cab0d..e0263e2 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -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 diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index ddd64cf..3a3eef9 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -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 diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index d198596..1fea04a 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -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: diff --git a/AGENTS.md b/AGENTS.md index 7417221..37818bf 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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/.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/.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.** diff --git a/README.md b/README.md index 4ccdc3a..4274b05 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/ansible/inventory/group_vars/all.yml b/ansible/inventory/group_vars/all.yml index 30c04a9..7ae3387 100644 --- a/ansible/inventory/group_vars/all.yml +++ b/ansible/inventory/group_vars/all.yml @@ -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 diff --git a/ansible/roles/compose_stack/defaults/main.yml b/ansible/roles/compose_stack/defaults/main.yml index ef9e845..2c9dfaf 100644 --- a/ansible/roles/compose_stack/defaults/main.yml +++ b/ansible/roles/compose_stack/defaults/main.yml @@ -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 }}" diff --git a/ansible/roles/compose_stack/tasks/up.yml b/ansible/roles/compose_stack/tasks/up.yml index f9e938b..6b2064c 100644 --- a/ansible/roles/compose_stack/tasks/up.yml +++ b/ansible/roles/compose_stack/tasks/up.yml @@ -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 diff --git a/docs/architecture.md b/docs/architecture.md index 3a3e7c9..0f0c638 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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 diff --git a/docs/ci-gitea.md b/docs/ci-gitea.md index d7f92f0..ff51897 100644 --- a/docs/ci-gitea.md +++ b/docs/ci-gitea.md @@ -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: diff --git a/docs/ci-github-actions.md b/docs/ci-github-actions.md index 7fe87b5..9a3c5dd 100644 --- a/docs/ci-github-actions.md +++ b/docs/ci-github-actions.md @@ -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: diff --git a/docs/ci-gitlab.md b/docs/ci-gitlab.md index 53bac43..3e9e61e 100644 --- a/docs/ci-gitlab.md +++ b/docs/ci-gitlab.md @@ -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.