From fe1890356e984a121b79a2f77dbb2751c2ee09b7 Mon Sep 17 00:00:00 2001 From: Ant Somers Date: Thu, 3 Sep 2026 02:19:42 +0300 Subject: [PATCH 1/2] feat(ansible): scope check/deploy to a subset of hosts with LIMIT `make check` / `make deploy` hardcoded the playbook invocation, so the moment `decdn_nodes` holds more than one host every run was fleet-wide with no supported way to say "just this node". That is the wrong default for bringing up a new node (whose first converge creates the operator account and lets ssh_hardening disable root login) and for re-running one node after a config change or a failed play. The workaround was dropping to a raw ansible-playbook command, which silently loses any flags the targets grow later. Add two optional, empty-by-default variables to both targets: make deploy LIMIT=decdn-node-1 make deploy LIMIT='decdn-node-1,decdn-node-2' make deploy LIMIT=decdn-node-1 ANSIBLE_ARGS='--tags decdn_node -vv' Defaults are byte-identical to before, so existing callers are unaffected. The --limit value is single-quoted, unlike the sketch in the issue: unquoted, `LIMIT='*'` glob-expands against ansible/ before ansible ever sees it. Per AGENTS.md rule 4 ("fail loud"), guard the two ways a run can look scoped but silently be fleet-wide -- an exported LIMIT/ANSIBLE_ARGS reaching make from the operator's shell (`?=` does not override the environment), and a LIMIT that expands empty (`make deploy LIMIT=$NODE` with NODE unset, which drops --limit entirely and converges the whole fleet). Both now abort at parse time, scoped to check/deploy via MAKECMDGOALS so `make lint` is never blocked. An explicit command-line LIMIT still wins over the environment. Not fixed, documented instead: a well-formed argument that selects nothing exits 0. `--tags decdn-node` (hyphen, vs the real `decdn_node`) runs zero tasks and reports success, as does a --limit matching a host outside decdn_nodes. A malformed flag fails loudly; these do not. An empty-PLAY-RECAP gate is a behaviour change beyond this issue's scope -- see the "Watch the PLAY RECAP" callout in ansible/README.md. Closes #38 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01WaE3ZP2xQdjd9juzRYKLwi --- AGENTS.md | 1 + README.md | 1 + ansible/Makefile | 41 +++++++++++++++++++++++++++++++++++++++-- ansible/README.md | 39 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 80 insertions(+), 2 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 17e2f788..e37bd97d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -80,6 +80,7 @@ make molecule # containerised converge/verify of the decdn_node role (ne cd ansible make deps # vendor pinned Galaxy collections into ./collections make check / deploy # deCDN node (site.yml): dry-run / provision + # fleet-wide by default; LIMIT= scopes, ANSIBLE_ARGS='…' passes through make build / galaxy-check # stage + build the decdn.node collection, then validate it ``` diff --git a/README.md b/README.md index 47f52a62..db442de3 100644 --- a/README.md +++ b/README.md @@ -99,6 +99,7 @@ make security # KICS IaC scan of ansible/ (pinned engine image) cd ansible make deps # vendor pinned Galaxy collections into ./collections make check / deploy # deCDN node (site.yml): dry-run / provision + # fleet-wide by default; LIMIT= scopes, ANSIBLE_ARGS='…' passes through ``` **Gotcha — pre-commit is local-only.** Hygiene/shellcheck/yamllint/markdown run via diff --git a/ansible/Makefile b/ansible/Makefile index c1db30dd..52f84ed6 100644 --- a/ansible/Makefile +++ b/ansible/Makefile @@ -14,13 +14,50 @@ lint: ansible-lint # --- Primary: the public deCDN node ------------------------------------------ +# Optional scoping for the deploy targets. Both are empty by default, so check/deploy +# stay fleet-wide (every host in decdn_nodes) unless you say otherwise. +# make deploy LIMIT=decdn-node-1 # one host +# make deploy LIMIT='decdn-node-1,decdn-node-2' # a list +# make deploy ANSIBLE_ARGS='--tags decdn_node -vv' +# +# Quote both values at YOUR prompt, not just in the examples: +# - make expands the --limit value single-quoted, so ansible host patterns reach +# ansible intact — but your own shell runs first, and eats an unquoted '!node' +# (history expansion) or 'decdn-node-*' (globbing) before make ever starts. +# - ANSIBLE_ARGS is substituted unquoted on purpose: the recipe's shell splits it +# back into separate flags (so inner quoting works — +# ANSIBLE_ARGS='--start-at-task="Install the decdn binaries"' survives). Unquoted +# at your prompt, make takes the extra words as goals and flags of its own — a +# bare -vv is make's own -v. A literal $ must be written $$. +LIMIT ?= +ANSIBLE_ARGS ?= +limit_arg = $(if $(LIMIT),--limit '$(LIMIT)') + +# Fail loud on the two ways a run can look scoped but silently be fleet-wide. Scoped to +# check/deploy via MAKECMDGOALS so an unrelated `make lint` is never blocked. NOTE: this +# does not catch an argument that is well-formed but selects nothing — `--tags` with a +# typo (decdn-node vs decdn_node) runs zero tasks and still exits 0. See ansible/README.md. +ifneq ($(filter check deploy,$(MAKECMDGOALS)),) + ifeq ($(origin LIMIT),environment) + $(error LIMIT is set in your environment ($(LIMIT)) and would silently scope this run. Unset it or pass it on the command line: make deploy LIMIT=) + endif + ifeq ($(origin ANSIBLE_ARGS),environment) + $(error ANSIBLE_ARGS is set in your environment and would silently alter this run. Unset it or pass it on the command line) + endif + ifeq ($(origin LIMIT),command line) + ifeq ($(strip $(LIMIT)),) + $(error LIMIT was supplied but expanded empty (unset shell variable?) - refusing to run fleet-wide by accident. Omit LIMIT entirely to mean "all hosts") + endif + endif +endif + # Dry run against the real inventory — shows diffs, mutates nothing. check: - ansible-playbook playbooks/site.yml --check --diff + ansible-playbook playbooks/site.yml --check --diff $(limit_arg) $(ANSIBLE_ARGS) # Provision the deCDN node host(s) in inventory/hosts.yml. deploy: - ansible-playbook playbooks/site.yml + ansible-playbook playbooks/site.yml $(limit_arg) $(ANSIBLE_ARGS) # --- Tests ------------------------------------------------------------------- # Containerised converge + idempotence + verify of the decdn_node role against a diff --git a/ansible/README.md b/ansible/README.md index 8e8ab8aa..0d95176b 100644 --- a/ansible/README.md +++ b/ansible/README.md @@ -106,6 +106,45 @@ make check # dry run (--check --diff); asserts fire if required knobs a make deploy # provision + start the node ``` +Both targets are **fleet-wide by default** — every host in `decdn_nodes`. Scope a run with +`LIMIT`: + +```bash +make check LIMIT=decdn-node-1 # dry-run one host +make deploy LIMIT=decdn-node-1 # converge one host +make deploy LIMIT='decdn-node-1,decdn-node-2' # a list +make deploy LIMIT='!decdn-node-2' # any ansible host pattern +``` + +Quote the pattern at your own prompt — your shell runs before make does, and eats a bare +`!decdn-node-2` (history expansion) or `decdn-node-*` (globbing). + +Use `LIMIT` when **bringing up a new node**: that host's first converge creates the +operator account and then lets `ssh_hardening` disable root + password login — after which +you switch its `ansible_user` by hand (see [Setup](#setup) above; the converge does not +edit your inventory). You do not want that play re-converging nodes already serving +paid traffic. Same when re-running a single node after a config change, a failed play, or a +binary bump. + +`make check`/`make deploy` refuse to run if `LIMIT` or `ANSIBLE_ARGS` reaches them from an +exported shell variable, or if `LIMIT` expands empty — both are ways a run looks scoped but +is silently fleet-wide. + +`ANSIBLE_ARGS` passes anything else straight through. Quote the whole value at your prompt, +or make will read the extra words as its own goals and flags (a bare `-vv` is make's `-v`); +the recipe's shell then splits it back into separate flags, so inner quoting works. Write a +literal `$` as `$$`: + +```bash +make deploy LIMIT=decdn-node-1 ANSIBLE_ARGS='--tags decdn_node -vv' +make deploy LIMIT=decdn-node-1 ANSIBLE_ARGS='--start-at-task="Install the decdn binaries"' +``` + +> **Watch the PLAY RECAP.** A *well-formed* argument that selects nothing is not an error: +> `--tags decdn-node` (hyphen, vs the real `decdn_node`) runs zero tasks and still exits 0, +> as does a `--limit` matching a host outside `decdn_nodes`. A malformed flag fails loudly; +> these do not. Confirm the recap lists the hosts you expected. + Then confirm: ```bash From e9272d8251e824dbd088de909772c2f0f4824f85 Mon Sep 17 00:00:00 2001 From: Ant Somers Date: Thu, 3 Sep 2026 02:23:46 +0300 Subject: [PATCH 2/2] fix(ansible): make the LIMIT env guard hint match the invoked goal The parse-time guard fires for both check and deploy, but its hint hardcoded `make deploy LIMIT=` -- so an operator who hit it during a `make check` dry-run was pointed at the mutating target. Echo $(MAKECMDGOALS) instead. The other two guard messages name no target and were already goal-agnostic. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01WaE3ZP2xQdjd9juzRYKLwi --- ansible/Makefile | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/ansible/Makefile b/ansible/Makefile index 52f84ed6..b7dbcc7e 100644 --- a/ansible/Makefile +++ b/ansible/Makefile @@ -38,8 +38,11 @@ limit_arg = $(if $(LIMIT),--limit '$(LIMIT)') # does not catch an argument that is well-formed but selects nothing — `--tags` with a # typo (decdn-node vs decdn_node) runs zero tasks and still exits 0. See ansible/README.md. ifneq ($(filter check deploy,$(MAKECMDGOALS)),) + # Echo back the goal the operator actually typed, so the hint never nudges a + # `make check` dry-run toward the mutating `make deploy`. + scoped_goal := $(firstword $(filter check deploy,$(MAKECMDGOALS))) ifeq ($(origin LIMIT),environment) - $(error LIMIT is set in your environment ($(LIMIT)) and would silently scope this run. Unset it or pass it on the command line: make deploy LIMIT=) + $(error LIMIT is set in your environment ($(LIMIT)) and would silently scope this run. Unset it or pass it on the command line: make $(scoped_goal) LIMIT=) endif ifeq ($(origin ANSIBLE_ARGS),environment) $(error ANSIBLE_ARGS is set in your environment and would silently alter this run. Unset it or pass it on the command line)