diff --git a/.github/workflows/molecule.yml b/.github/workflows/molecule.yml index dfdf4b4..85a963f 100644 --- a/.github/workflows/molecule.yml +++ b/.github/workflows/molecule.yml @@ -47,7 +47,8 @@ jobs: # for 14 scenarios, CPU-bound (per-task overhead roughly doubled under contention), # with the slowest scenarios queued last. Now the wall clock is the slowest single # scenario plus setup: ~10m for 18 scenarios (first run, PR #96), set by - # cloud-init-publisher (~9m45s), then cloud-init (~8m) and sponsord-onramp (~7m30s); + # cloud-init-sponsord (~9m45s; now cloud-init-publisher, which also boots an + # origin node), then cloud-init (~8m) and sponsord-onramp (~7m30s); # most others take 2-5m. By October 2026 iroh-relay and source-build had reached # ~11m on their side effects, which are now split over sibling scenarios (#106), # so no scenario should go much past ~6.5m. Re-measure against the timeout when a diff --git a/.github/workflows/release-collection.yml b/.github/workflows/release-collection.yml index 4a31413..0014402 100644 --- a/.github/workflows/release-collection.yml +++ b/.github/workflows/release-collection.yml @@ -126,8 +126,26 @@ jobs: fi echo "latest=$latest" >> "$GITHUB_OUTPUT" echo "Latest: $latest (newest node collection release: ${newest:-n/a})" + # The runner's system Python is externally managed (PEP 668): pip into it fails. + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: '3.12' - name: Install ansible-core run: python3 -m pip install --upgrade ansible-core + # decdn.publisher installs only if Galaxy can resolve its decdn.node dependency. + # scripts/release.sh checks origin's node-collection tags, but a tag is not an + # upload (one pushed while PUBLISH_ENABLED was off, or whose publish failed), so + # ask Galaxy itself, before anything is published: re-run this job once decdn.node + # is up. + - name: Require decdn.publisher's decdn.node dependency on Galaxy + run: | + [[ "$TAG" == publisher-collection-v* ]] || exit 0 + tarball="dist/decdn-${TAG%-collection-v*}-${TAG##*-collection-v}.tar.gz" + need="$(tar -xzOf "$tarball" MANIFEST.json \ + | python3 -c 'import json, sys; print(json.load(sys.stdin)["collection_info"]["dependencies"]["decdn.node"])')" + echo "decdn.publisher requires decdn.node $need" + ansible-galaxy collection download "decdn.node:$need" --no-deps -p "$(mktemp -d)" \ + || { echo "::error::could not resolve decdn.node $need on Galaxy (see the error above): if it is not published, publish a node-collection release first; then re-run this job"; exit 1; } # Not idempotent: Galaxy refuses a version that already exists, so a re-run # after this step succeeded fails here. RELEASING.md covers recovery. - name: Publish the collection to Galaxy diff --git a/AGENTS.md b/AGENTS.md index 19d68f6..40375bb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -17,7 +17,8 @@ split along that line: ADR 002/011), and optionally `sponsord` + its onramp, iroh relays and an iroh DNS server: `publisher.yml` / `make deploy-publisher` (`origin.yml` + the component playbooks), `cloud-init/user-data-publisher.yaml`, Compose's `origin`/`onramp` - profiles, the `decdn.publisher` collection. Front door: `docs/publishers.md`. + profiles, the chart (an origin node only), the `decdn.publisher` collection. Front + door: `docs/publishers.md`. There are four deploy paths: **Ansible** (`ansible/`, VMs/bare metal, the primary path, also the `decdn.node` and @@ -104,7 +105,7 @@ monitoring/ # Grafana dashboards + Prometheus alert rules (maintaine sponsord/ # sponsord's (+ promtool unit tests), Ansible/Compose only iroh-relay/ # the relay's (+ promtool unit tests, exported-metrics.txt), Ansible only docs/ # cross-path operator docs: node-operators.md + publishers.md (the front doors), requirements.md, lifecycle.md -scripts/ # upstream-mirror generators, the release script (release.sh, git-cliff: ../cliff.toml) and its gate (+ its Artifact Hub changes generator), the molecule driver (molecule.sh) +scripts/ # upstream-mirror generators, the release script (release.sh, git-cliff: ../cliff.toml) and its gate (+ its Artifact Hub changes generator), the molecule driver (molecule.sh), ansible/Makefile's LIMIT preflight (limit-guard.sh) ``` **Generated mirrors of upstream — regenerate, never hand-edit:** @@ -465,7 +466,8 @@ the pinned `iroh-relay` (re-capture it when bumping `iroh_relay_version`). `node` and `origin` (both start the one `decdn-node`: a node operator's cache node, or a publisher's origin, whose `node.toml` carries `[cache.origin]`), `sponsord`, `onramp` (also starts `sponsord`) and `caddy`. An fs origin's content mount goes in - the operator's own `compose.override.yaml`, so the committed mounts stay exact. + the operator's own `compose.override.yaml` (passed as a second `-f` on every + command: Compose skips it when `-f` is given), so the committed mounts stay exact. - **sponsord / onramp:** the roles' `/etc/sponsord/` layout, except that the credential files belong to a host `sponsord` account (bind mounts keep owner and mode, and upstream rejects a group-readable keystore). They are mounted @@ -581,11 +583,12 @@ which depends on `decdn.node`). Each overlay lives in `ansible/galaxy/` — there is **no** `galaxy.yml` at the `ansible/` root (that would make ansible-lint treat the deploy project as a collection). -A new role goes in one `roles.txt`. Build/validate with `make build` / `make galaxy-check` -(both; `-node`/`-publisher` suffixes for one). **Publishing** is per artifact, each on +A new role goes in one `roles.txt`. Build/validate from `ansible/` with `make build` / +`make galaxy-check` (both; `-node`/`-publisher` suffixes for one; the root's +`galaxy-build`/`galaxy-check` run both). **Publishing** is per artifact, each on its own version: `release-collection.yml` on a `node-collection-vX.Y.Z` or -`publisher-collection-vX.Y.Z` tag (the repo's Latest follows `decdn.node`; the publisher -collection's first release waits for node's), `release-chart.yml` on a +`publisher-collection-vX.Y.Z` tag (the repo's Latest follows `decdn.node`; release.sh +refuses a publisher release until a node tag satisfies its `decdn.node` constraint), `release-chart.yml` on a `decdn-node-X.Y.Z` tag (that file name and tag prefix are the chart's cosign identity: never rename them), and only while the `PUBLISH_ENABLED` repository variable is `true` (RELEASING.md). The chart's publish also pushes its diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2d56724..e868a73 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -41,8 +41,8 @@ targets, so a local pass means a CI pass. Deploy targets live in | `make lint-helm` | chart: `helm lint --strict`, positive/negative render tests, kubeconform and `promtool check rules` on the alert rules (digest-pinned images), the shared schema-key check (needs `helm`, `yq`, `python3` ≥ 3.11, Docker). Set `DECDN_CLI=` to also run the real `decdn config validate` (CI can't). | | `make lint-alloy` | renders `roles/grafana_alloy`'s templates and validates them with the **real** digest-pinned Alloy binary. The molecule stub exits 0 for everything, so this is the only gate that proves the config loads. `ALLOY_BIN=` skips the download. | | `make lint-compose` | renders `compose/compose.yaml` with every profile on (with its example env, without env files, with an empty `.env`) and asserts `compose/tests/invariants.jq`, `inline-env.jq` and `fail-closed.jq` | -| `make lint-cloud-init` | `cloud-init schema` on `cloud-init/user-data-node.yaml` and `cloud-init/user-data-publisher.yaml`, then `cloud-init/tests/lint.py` on each: only the templates' top-level modules and no YAML anchors, no secrets (only the bootstrap's own files, once each, plain `content` only, no secret-looking keys or assignments), no hardening skip, `release` installs verified against the vendored keys with a host-generated node wallet (trust knobs only in their own group's `vars`, no moved keyring or secret path), only localhost, in `decdn_nodes` and/or `sponsord_hosts` (an origin only beside `decdn_nodes` and with a backend, the onramp only beside sponsord), a keyed admin account, `runcmd` exactly stage 1, shellcheck-clean scripts, and a collection lock that covers `ansible/requirements.yml` (needs `cloud-init`, `shellcheck`, `yq`). `CLOUD_INIT_FILE=` checks your own filled-in copy. | -| `make test-scripts` | `tests/scripts-test.sh`: the `ansible/Makefile` scoping guards (dry runs), the molecule driver's selection guards and locks (no containers), the split scenarios' shared inventories, the release gate, `scripts/release.sh` on a fixture repo and `pr-title.yml`'s check (the former skipped without `git-cliff` or `ssh-keygen` on PATH, failed in CI), the negative cases of `lint-compose` and `lint-cloud-init` (the latter skipped without `cloud-init` on PATH), and the cloud-init bootstrap's contracts: `baseline-plays.sh` on fixtures and on the real playbooks (every node.yml, origin.yml and sponsord.yml play runs `baseline` tagged `baseline`), the two templates' shared stage 1, login hint and `final_message`, `bootstrap.sh`'s groups against lint.py's, and lint.py's `FORBIDDEN_VARS` against the roles' keyrings and secret paths. `UPSTREAM=` adds the sync generators' exit codes. | +| `make lint-cloud-init` | `cloud-init schema` on `cloud-init/user-data-node.yaml` and `cloud-init/user-data-publisher.yaml`, then `cloud-init/tests/lint.py` on each: only the templates' top-level modules and no YAML anchors, no secrets (only the bootstrap's own files, once each, plain `content` only, no secret-looking keys or assignments), no hardening skip, `release` installs verified against the vendored keys with a host-generated node wallet (trust knobs only in their own group's `vars`, no moved keyring or secret path), only localhost, in `decdn_nodes` and/or `sponsord_hosts` (an origin only beside `decdn_nodes` and with a backend, every backend key only in `decdn_origin_nodes.vars`, the onramp only beside sponsord), a keyed admin account, `runcmd` exactly stage 1, shellcheck-clean scripts, and a collection lock that covers `ansible/requirements.yml` (needs `cloud-init`, `shellcheck`, `yq`). `CLOUD_INIT_FILE=` checks your own filled-in copy. | +| `make test-scripts` | `tests/scripts-test.sh`: the `ansible/Makefile` scoping guards (dry runs, and `limit-guard.sh` on a fixture inventory), the molecule driver's selection guards and locks (no containers), the split scenarios' shared inventories, the release gate, `scripts/release.sh` on a fixture repo and `pr-title.yml`'s check (the former skipped without `git-cliff` or `ssh-keygen` on PATH, failed in CI), the negative cases of `lint-compose` and `lint-cloud-init` (the latter skipped without `cloud-init` on PATH), and the cloud-init bootstrap's contracts: `baseline-plays.sh` on fixtures and on the real playbooks (every node.yml, origin.yml and sponsord.yml play runs `baseline` tagged `baseline`), the two templates' shared stage 1, login hint and `final_message`, `bootstrap.sh`'s groups against lint.py's, and lint.py's `FORBIDDEN_VARS` against the roles' keyrings and secret paths. `UPSTREAM=` adds the sync generators' exit codes. | | `make security` | KICS IaC scan of `ansible/`, the rendered chart and `compose/` (digest-pinned engine, fail on HIGH) | | `make galaxy-check` | build the `decdn.node` and `decdn.publisher` collections and run galaxy-importer's checks on each (`make -C ansible galaxy-check-node` / `galaxy-check-publisher` for one) | diff --git a/RELEASING.md b/RELEASING.md index f458f18..91781cb 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -11,8 +11,11 @@ The repo publishes three artifacts, each on its own version and released by its Release an artifact when it has changes; the others are not touched. Their versions are independent. The one dependency is Galaxy's: `decdn.publisher` requires `decdn.node` (its playbooks run `decdn.node`'s baseline, Alloy and origin nodes), so the publisher -collection's first release comes after the node collection's, and `scripts/release.sh` -refuses it before. What the artifacts must agree on is checked by CI, not by a shared +collection's first release comes after the node collection's. `scripts/release.sh` +refuses any publisher release whose `decdn.node: ">=X.Y.Z"` no `node-collection-v*` +tag on origin satisfies yet. A tag is not a Galaxy upload, so the publish job also +asks Galaxy, before uploading a `decdn.publisher`, for a `decdn.node` that satisfies +it, and fails while there is none: publish that node release, then re-run the job. What the artifacts must agree on is checked by CI, not by a shared version: the decdn version (the chart's `appVersion` and the role's `decdn_node_version`) on every PR (`make test-scripts`), and the config keys each one renders against the same upstream schema-key inventory whenever either changes diff --git a/ansible/Makefile b/ansible/Makefile index 32c8b27..a94dd72 100644 --- a/ansible/Makefile +++ b/ansible/Makefile @@ -50,11 +50,20 @@ INVENTORY ?= inventory/hosts.yml # with no inventory present. strict_inventory = ANSIBLE_INVENTORY_UNPARSED_FAILED=True +# $(call limit_guard,): refuse a run in which the playbook's plays select no +# host, or skip a host LIMIT names; ansible-playbook reports both as success (e.g. +# `deploy-node LIMIT=`: node.yml leaves decdn_origin_nodes to origin.yml). +# See scripts/limit-guard.sh. +define limit_guard +@$(strict_inventory) ../scripts/limit-guard.sh '$(INVENTORY)' $(1) '$(LIMIT)' $(ANSIBLE_ARGS) +endef + # Fail loud on the two ways a run can look scoped but silently be fleet-wide. Scoped to # the playbook targets (check/deploy[-node|-origin|-publisher|-sponsord|-relay|-dns]/backup/decommission) 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. +# unrelated `make lint` is never blocked. A run that selects no host, or skips one LIMIT +# names, is refused by limit_guard, above. NOTE: neither catches a --tags that is well-formed but +# selects nothing — a typo (decdn-node vs decdn_node) runs zero tasks and still exits 0. +# See ansible/README.md. ifneq ($(filter check deploy check-node deploy-node check-origin deploy-origin check-publisher deploy-publisher check-sponsord deploy-sponsord check-relay deploy-relay check-dns deploy-dns backup decommission,$(MAKECMDGOALS)),) # Echo back the goal the operator actually typed, so the hint never nudges a # `make check` dry-run toward the mutating `make deploy`. @@ -89,70 +98,86 @@ endif # Dry run of everything in $(INVENTORY) (site.yml: node.yml, then publisher.yml) — # shows diffs, mutates nothing. check: + $(call limit_guard,playbooks/site.yml) $(strict_inventory) ansible-playbook -i '$(INVENTORY)' playbooks/site.yml --check --diff $(limit_arg) $(ANSIBLE_ARGS) # Provision every host in $(INVENTORY) (default inventory/hosts.yml). deploy: + $(call limit_guard,playbooks/site.yml) $(strict_inventory) ansible-playbook -i '$(INVENTORY)' playbooks/site.yml $(limit_arg) $(ANSIBLE_ARGS) # Node operators: the cache nodes alone (playbooks/node.yml, decdn_nodes that are not # in decdn_origin_nodes). check-node: + $(call limit_guard,playbooks/node.yml) $(strict_inventory) ansible-playbook -i '$(INVENTORY)' playbooks/node.yml --check --diff $(limit_arg) $(ANSIBLE_ARGS) deploy-node: + $(call limit_guard,playbooks/node.yml) $(strict_inventory) ansible-playbook -i '$(INVENTORY)' playbooks/node.yml $(limit_arg) $(ANSIBLE_ARGS) # Publishers: everything a publisher runs (playbooks/publisher.yml: origins, sponsord # and its onramp, iroh relays, iroh DNS servers), leaving the cache nodes untouched. check-publisher: + $(call limit_guard,playbooks/publisher.yml) $(strict_inventory) ansible-playbook -i '$(INVENTORY)' playbooks/publisher.yml --check --diff $(limit_arg) $(ANSIBLE_ARGS) deploy-publisher: + $(call limit_guard,playbooks/publisher.yml) $(strict_inventory) ansible-playbook -i '$(INVENTORY)' playbooks/publisher.yml $(limit_arg) $(ANSIBLE_ARGS) # A publisher's origin nodes alone (playbooks/origin.yml, hosts in decdn_origin_nodes). check-origin: + $(call limit_guard,playbooks/origin.yml) $(strict_inventory) ansible-playbook -i '$(INVENTORY)' playbooks/origin.yml --check --diff $(limit_arg) $(ANSIBLE_ARGS) deploy-origin: + $(call limit_guard,playbooks/origin.yml) $(strict_inventory) ansible-playbook -i '$(INVENTORY)' playbooks/origin.yml $(limit_arg) $(ANSIBLE_ARGS) # sponsord alone (playbooks/sponsord.yml, hosts in sponsord_hosts). check/deploy and # check/deploy-publisher above already include it; these leave every decdn-node untouched. check-sponsord: + $(call limit_guard,playbooks/sponsord.yml) $(strict_inventory) ansible-playbook -i '$(INVENTORY)' playbooks/sponsord.yml --check --diff $(limit_arg) $(ANSIBLE_ARGS) deploy-sponsord: + $(call limit_guard,playbooks/sponsord.yml) $(strict_inventory) ansible-playbook -i '$(INVENTORY)' playbooks/sponsord.yml $(limit_arg) $(ANSIBLE_ARGS) # iroh relays alone (playbooks/iroh_relay.yml, hosts in iroh_relay_hosts). check/deploy # and check/deploy-publisher above already include them; these leave every other host # untouched. check-relay: + $(call limit_guard,playbooks/iroh_relay.yml) $(strict_inventory) ansible-playbook -i '$(INVENTORY)' playbooks/iroh_relay.yml --check --diff $(limit_arg) $(ANSIBLE_ARGS) deploy-relay: + $(call limit_guard,playbooks/iroh_relay.yml) $(strict_inventory) ansible-playbook -i '$(INVENTORY)' playbooks/iroh_relay.yml $(limit_arg) $(ANSIBLE_ARGS) # iroh DNS servers alone (playbooks/iroh_dns_server.yml, hosts in iroh_dns_server_hosts). # check/deploy and check/deploy-publisher above already include them; these leave every # other host untouched. check-dns: + $(call limit_guard,playbooks/iroh_dns_server.yml) $(strict_inventory) ansible-playbook -i '$(INVENTORY)' playbooks/iroh_dns_server.yml --check --diff $(limit_arg) $(ANSIBLE_ARGS) deploy-dns: + $(call limit_guard,playbooks/iroh_dns_server.yml) $(strict_inventory) ansible-playbook -i '$(INVENTORY)' playbooks/iroh_dns_server.yml $(limit_arg) $(ANSIBLE_ARGS) # Encrypted backup of each node (identity by default; -e decdn_backup_scope=full for # the whole data dir, which stops the node for the copy). One host at a time. # Needs decdn_backup_age_recipients. See docs/lifecycle.md. backup: + $(call limit_guard,playbooks/backup.yml) $(strict_inventory) ansible-playbook -i '$(INVENTORY)' playbooks/backup.yml $(limit_arg) $(ANSIBLE_ARGS) # Stop the node and remove its unit on LIMIT (required). Keeps the identity; prints # the on-chain exit steps. Asks for typed confirmation. See docs/lifecycle.md. decommission: + $(call limit_guard,playbooks/decommission.yml) $(strict_inventory) ansible-playbook -i '$(INVENTORY)' playbooks/decommission.yml $(limit_arg) $(ANSIBLE_ARGS) # --- Tests ------------------------------------------------------------------- diff --git a/ansible/README.md b/ansible/README.md index 2217926..7f03b6f 100644 --- a/ansible/README.md +++ b/ansible/README.md @@ -128,8 +128,11 @@ the overlay carries its own: create them next to its `hosts.yml`, starting from of your own when hosts differ by group (disk size, an egress cap on metered bandwidth), each with a `group_vars/.yml`. Origins go in `decdn_origin_nodes`, as a child of `decdn_nodes` (see `inventory/hosts-publisher.yml.example`). Settings every host needs regardless of -inventory, currently the public firewall holes (the node's udp/4433, and tcp/80 + -tcp/443 for Caddy on `sponsord_onramp_hosts`), live in +inventory, currently the public firewall holes (the node's udp/4433, tcp/80 + tcp/443 +for Caddy on `sponsord_onramp_hosts` with `sponsord_onramp_proxy: caddy`, tcp/80 + +tcp/443 on `iroh_relay_hosts` plus udp/7842 unless +`iroh_relay_enable_quic_addr_discovery` is false, tcp/443 + udp/53 + tcp/53 on +`iroh_dns_server_hosts`), live in `playbooks/group_vars/`, so an overlay can't drop them: `all.yml` builds the list from the host's groups and each group's file (`decdn_nodes.yml`, `decdn_origin_nodes.yml`, `sponsord_hosts.yml`, …) applies it. Override @@ -226,10 +229,16 @@ connects as your `$USER` with no flag. You do not want that bootstrap play — o — reaching 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`, `deploy`, `backup` and `decommission` refuse to run if `LIMIT`, -`ANSIBLE_ARGS` or `INVENTORY` reaches them from an exported shell variable, or if `LIMIT` -or `INVENTORY` expands empty: all ways a run looks scoped but silently targets something -else. `make decommission` additionally requires `LIMIT` on the command line. +`make check`, `deploy` (and their `-node`, `-origin`, `-publisher`, … variants), `backup` and +`decommission` refuse to run if `LIMIT`, `ANSIBLE_ARGS` or `INVENTORY` reaches them from an +exported shell variable, or if `LIMIT` or `INVENTORY` expands empty: all ways a run looks +scoped but silently targets something else. They also refuse a run in which the playbook +selects no host, and a `LIMIT` that includes any host the playbook does not run on, both of +which Ansible itself reports as success: `make deploy-node LIMIT=` or +`LIMIT='node-1,origin-1'` (an origin is deployed by `deploy-origin`), `make deploy-origin +LIMIT=`. For a group `LIMIT` that mixes kinds, subtract what the target does +not cover (`LIMIT='eu:!decdn_origin_nodes'`) or use `make deploy` +([`scripts/limit-guard.sh`](../scripts/limit-guard.sh)). `make decommission` additionally requires `LIMIT` on the command line. `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`); @@ -243,8 +252,8 @@ make deploy LIMIT=decdn-node-1 ANSIBLE_ARGS='--start-at-task="Install decdn-node > **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. +> and so does a `--limit` passed in `ANSIBLE_ARGS` (only `LIMIT` is checked). A malformed +> flag fails loudly; these do not. Confirm the recap lists the hosts you expected. Then confirm: diff --git a/ansible/galaxy/publisher/README.md b/ansible/galaxy/publisher/README.md index 4c64622..cdf47af 100644 --- a/ansible/galaxy/publisher/README.md +++ b/ansible/galaxy/publisher/README.md @@ -95,8 +95,8 @@ This repository's own playbooks enforce three placement rules that the roles do check, so a playbook of your own must keep them: `sponsord_onramp` runs on a `sponsord` host (it reads the daemon's API token and calls it on loopback); an iroh relay does not share a host with the onramp (both want tcp/80 and tcp/443); an iroh -DNS server shares a host with neither (all want tcp/443). Each role still refuses -ports another process holds. See each role's README for the full variable list and +DNS server shares a host with neither (all want tcp/443). The relay and DNS server +roles also refuse ports another process holds; the onramp's Caddy does not check. See each role's README for the full variable list and day-2 ops: - [`roles/sponsord`](https://github.com/decdn/devops/tree/main/ansible/roles/sponsord) @@ -116,8 +116,9 @@ those three. `iroh_dns_server` is public by design too: it terminates its own TL tcp/443 and answers DNS on udp/53 and tcp/53 at one address (its metrics and health listener stay on loopback), so a DNS server host opens those three. No secrets ship in the collection or are committed: the treasury keystore, the RPC URL and the -Turnstile secret are host-provisioned or rendered to `0600` files, and reach the -daemons as systemd credentials. +Turnstile secret are host-provisioned or rendered to `0600` files. The keystore, its +password, the API token and the Turnstile secret reach the daemons as systemd +credentials; the RPC URL, as an `EnvironmentFile`. ## License diff --git a/ansible/galaxy/publisher/galaxy.yml b/ansible/galaxy/publisher/galaxy.yml index 9272ac7..a718e23 100644 --- a/ansible/galaxy/publisher/galaxy.yml +++ b/ansible/galaxy/publisher/galaxy.yml @@ -39,7 +39,7 @@ tags: # decdn.node. The dependency is for the playbooks that run these roles: a publisher # host takes decdn.node.baseline first and decdn.node.grafana_alloy for # observability, and its origin nodes are decdn.node.decdn_node. scripts/release.sh -# refuses this collection's first release before decdn.node has one. +# refuses a release of this collection while no node-collection tag satisfies it. dependencies: decdn.node: ">=0.1.0" repository: https://github.com/decdn/devops diff --git a/ansible/inventory/group_vars/iroh_dns_server_hosts.yml b/ansible/inventory/group_vars/iroh_dns_server_hosts.yml index 39cb403..d36a176 100644 --- a/ansible/inventory/group_vars/iroh_dns_server_hosts.yml +++ b/ansible/inventory/group_vars/iroh_dns_server_hosts.yml @@ -1,6 +1,6 @@ --- # Shared config for iroh DNS server hosts (playbooks/iroh_dns_server.yml, also run -# by site.yml). Uncomment and fill in; per-host values (the hostname, at least) go +# by publisher.yml). Uncomment and fill in; per-host values (the hostname, at least) go # under the host in hosts.yml (as hosts-publisher.yml.example shows) or in # host_vars//main.yml. # Full variable list: roles/iroh_dns_server/defaults/main.yml and its README.md. diff --git a/ansible/inventory/group_vars/iroh_relay_hosts.yml b/ansible/inventory/group_vars/iroh_relay_hosts.yml index 7214679..3212200 100644 --- a/ansible/inventory/group_vars/iroh_relay_hosts.yml +++ b/ansible/inventory/group_vars/iroh_relay_hosts.yml @@ -1,6 +1,6 @@ --- # Shared config for iroh relay hosts (playbooks/iroh_relay.yml, also run by -# site.yml). Uncomment and fill in; per-host values (the hostname, at least) go +# publisher.yml). Uncomment and fill in; per-host values (the hostname, at least) go # under the host in hosts.yml (as hosts-publisher.yml.example shows) or in # host_vars//main.yml. # Full variable list: roles/iroh_relay/defaults/main.yml and its README.md. diff --git a/ansible/inventory/group_vars/sponsord_hosts.yml b/ansible/inventory/group_vars/sponsord_hosts.yml index 9493b56..cda975e 100644 --- a/ansible/inventory/group_vars/sponsord_hosts.yml +++ b/ansible/inventory/group_vars/sponsord_hosts.yml @@ -1,5 +1,5 @@ --- -# Shared config for sponsord hosts (playbooks/sponsord.yml, also run by site.yml). +# Shared config for sponsord hosts (playbooks/sponsord.yml, also run by publisher.yml). # Uncomment and fill in; per-host values can move to host_vars//main.yml. # Full variable list: roles/sponsord/defaults/main.yml and roles/sponsord/README.md. # diff --git a/ansible/inventory/group_vars/sponsord_onramp_hosts.yml b/ansible/inventory/group_vars/sponsord_onramp_hosts.yml index ba9efe3..1c891b3 100644 --- a/ansible/inventory/group_vars/sponsord_onramp_hosts.yml +++ b/ansible/inventory/group_vars/sponsord_onramp_hosts.yml @@ -1,6 +1,6 @@ --- # Shared config for sponsord-onramp hosts (the second play of playbooks/sponsord.yml, -# also run by site.yml). Each host must be in sponsord_hosts too. Uncomment and fill +# also run by publisher.yml). Each host must be in sponsord_hosts too. Uncomment and fill # in; per-host values can move to host_vars//main.yml. # Full variable list: roles/sponsord_onramp/defaults/main.yml and its README.md. # diff --git a/ansible/playbooks/iroh_dns_server.yml b/ansible/playbooks/iroh_dns_server.yml index 27c0022..9f5b810 100644 --- a/ansible/playbooks/iroh_dns_server.yml +++ b/ansible/playbooks/iroh_dns_server.yml @@ -4,7 +4,7 @@ # self-hosted in place of n0's dns.iroh.link. # iroh_dns_server_hosts: baseline -> grafana_alloy (opt-in, off by default) -> # iroh_dns_server -# Standalone entry point (`make deploy-dns`), also imported by site.yml. The server +# Standalone entry point (`make deploy-dns`), also imported by publisher.yml. The server # is public by design: it terminates its own TLS (Let's Encrypt) on tcp/443 and # answers DNS on udp+tcp/53, opened by playbooks/group_vars/all.yml; its metrics # and health listener stay on loopback. Point nodes at it with diff --git a/ansible/playbooks/iroh_relay.yml b/ansible/playbooks/iroh_relay.yml index 73c768b..8a164d6 100644 --- a/ansible/playbooks/iroh_relay.yml +++ b/ansible/playbooks/iroh_relay.yml @@ -5,7 +5,7 @@ # iroh_relay_hosts: read the deCDN nodes' IDs (the allowlist) -> baseline -> # grafana_alloy (opt-in, off by default) -> iroh_relay, one relay # at a time -# Standalone entry point (`make deploy-relay`), also imported by site.yml. The +# Standalone entry point (`make deploy-relay`), also imported by publisher.yml. The # relay is public by design: it terminates its own TLS (Let's Encrypt) on tcp/443 # and binds tcp/80 and udp/7842, opened by playbooks/group_vars/all.yml; its # metrics stay on loopback. Point nodes at it with decdn_relay_urls — see diff --git a/ansible/playbooks/sponsord.yml b/ansible/playbooks/sponsord.yml index a5d56ea..6b8e9e7 100644 --- a/ansible/playbooks/sponsord.yml +++ b/ansible/playbooks/sponsord.yml @@ -3,7 +3,7 @@ # and its public onramp. # sponsord_hosts: baseline -> grafana_alloy (opt-in, off by default) -> sponsord # sponsord_onramp_hosts: sponsord_onramp (+ Caddy in front, unless turned off) -# Standalone entry point (`make deploy-sponsord`), also imported by site.yml. The +# Standalone entry point (`make deploy-sponsord`), also imported by publisher.yml. The # daemon needs no decdn-node on the host and listens on loopback only, for the # onramp on the same host. The onramp listens on loopback too; Caddy is its public # side (tcp/80 + tcp/443, opened by playbooks/group_vars/all.yml). A host in both diff --git a/cloud-init/README.md b/cloud-init/README.md index fbf5190..bec862f 100644 --- a/cloud-init/README.md +++ b/cloud-init/README.md @@ -30,7 +30,7 @@ only in their inventory: A publisher who wants fewer of them on the VM deletes the groups it does not need from the publisher template: an origin alone keeps `decdn_nodes` and `decdn_origin_nodes`, a sponsor alone keeps `sponsord_hosts` and `sponsord_onramp_hosts` (and takes the -`baseline_*` settings into `sponsord_hosts`). Ansible applies one `baseline_sudo_users` +`baseline_*` settings, and any `ssh_allow_cidrs`, into `sponsord_hosts`' vars). Ansible applies one `baseline_sudo_users` list, not the union of two groups', so the lint wants it set once. The bootstrap waits for the secrets of every group the host is in. @@ -65,12 +65,14 @@ for the secrets of every group the host is in. [`collections.lock.yml`](collections.lock.yml). It then syntax-checks the playbook. It also checks that the inventory puts localhost - in `decdn_nodes` or `sponsord_hosts` (and in `sponsord_hosts` whenever it is in + in `decdn_nodes` or `sponsord_hosts` (and in `decdn_nodes` whenever it is in + `decdn_origin_nodes`, in `sponsord_hosts` whenever it is in `sponsord_onramp_hosts`). Last, it checks that `--tags baseline` still selects the - baseline role in every `decdn_nodes` and `sponsord_hosts` play that localhost is in - ([`baseline-plays.sh`](baseline-plays.sh)). Another group's play does not count, - so a play that lost the role or its tag stops the run before it reports a host - hardened that is not. + baseline role in every play that hardens localhost + ([`baseline-plays.sh`](baseline-plays.sh)): `node.yml`'s for a cache node, + `origin.yml`'s for an origin, `sponsord.yml`'s for a sponsor. Another group's play + does not count, so a play that lost the role or its tag stops the run before it + reports a host hardened that is not. 4. Each of the host's groups needs its secrets on the host: `decdn.env` for the node, and the files in the table above for a sponsor. While any is missing, stage 2 runs only the `baseline` role, lists the missing paths in @@ -318,9 +320,14 @@ Day 2 is in the role READMEs. Rotate a secret by replacing its file and running looks for the secrets at the roles' defaults; or of sponsord's API-token and treasury-wallet generation, since the token is always generated on the host and the wallet is the operator's; - - an inventory group other than `decdn_nodes`, `sponsord_hosts` and - `sponsord_onramp_hosts`, a group holding anything but `hosts` and `vars` (no - `children:`), a host other than localhost, or the onramp without `sponsord_hosts`; + - an inventory group other than `decdn_nodes`, `decdn_origin_nodes`, + `sponsord_hosts` and `sponsord_onramp_hosts`, a group holding anything but `hosts` + and `vars` (no `children:`), a host other than localhost, an origin without + `decdn_nodes`, or the onramp without `sponsord_hosts`; + - an origin without a complete backend in `decdn_origin_nodes.vars` + (`decdn_cache_origin_kind` http, fs or s3 with its fields, or + `decdn_cache_origins`), or a backend key set anywhere else, where it would + override the checked one or turn a cache node into an origin; - `baseline_sudo_users` set in more than one place. - **Everything is pinned.** - This repo: by commit SHA (checked after checkout), or by tag, which is weaker diff --git a/cloud-init/baseline-plays.awk b/cloud-init/baseline-plays.awk index 5be9736..52a2821 100644 --- a/cloud-init/baseline-plays.awk +++ b/cloud-init/baseline-plays.awk @@ -1,6 +1,7 @@ # Run by baseline-plays.sh (for bootstrap.sh) on the output of # ansible-playbook playbooks/site.yml --tags baseline --list-hosts --list-tasks -# with -v groups="" (decdn_nodes and/or sponsord_hosts). +# with -v groups="" (decdn_nodes, or decdn_origin_nodes +# for an origin, and/or sponsord_hosts). # # Phase 1 relies on `--tags baseline` hardening the host, which is true only if every # play that targets one of those groups, and lists localhost, still selects a baseline diff --git a/cloud-init/bootstrap.sh b/cloud-init/bootstrap.sh index 46951e3..2937115 100755 --- a/cloud-init/bootstrap.sh +++ b/cloud-init/bootstrap.sh @@ -149,8 +149,8 @@ for g in "${HOST_GROUPS[@]}"; do done [[ -n ${member[decdn_nodes]:-} || -n ${member[sponsord_hosts]:-} ]] \ || die "$INVENTORY puts localhost in neither decdn_nodes nor sponsord_hosts (site.yml would match nothing)" -# playbooks/origin.yml asserts the same, but under `--tags baseline` only after the -# host waited for its secrets. +# playbooks/origin.yml asserts the same (tagged always, so in phase 1 too), but only +# once a play runs; this says so before any does. [[ -z ${member[decdn_origin_nodes]:-} || -n ${member[decdn_nodes]:-} ]] \ || die "$INVENTORY puts localhost in decdn_origin_nodes but not in decdn_nodes (an origin is a deCDN node)" # playbooks/sponsord.yml asserts the same, in a play that `--tags baseline` skips. diff --git a/cloud-init/tests/lint.py b/cloud-init/tests/lint.py index 8340a8f..2d8689c 100755 --- a/cloud-init/tests/lint.py +++ b/cloud-init/tests/lint.py @@ -25,7 +25,8 @@ group's vars would override them. Nor may a user-data move the signing keys or the secret files bootstrap.sh's gate looks for; * the inventory holds only localhost, in decdn_nodes and/or sponsord_hosts (and - decdn_origin_nodes only beside decdn_nodes, with an origin backend in its vars; + decdn_origin_nodes only beside decdn_nodes, with an origin backend in its vars and + no backend key anywhere else; sponsord_onramp_hosts only beside sponsord_hosts), with `ansible_connection: local` set at least once and nothing else anywhere, and names a keyed admin account (baseline's lockout guard). A malformed shape is a violation, not a traceback; @@ -70,15 +71,18 @@ # all, but every host is in decdn_nodes or sponsord_hosts: an origin is a node, so its # group needs decdn_nodes too, and the onramp runs beside sponsord, so its group needs # sponsord_hosts too. The template lists an origin in both groups as siblings (no -# `children:`, which this lint refuses); both are one level deep, so Ansible merges -# their vars by name and decdn_origin_nodes' win, which is harmless while the two set -# different keys. +# `children:`, which this lint refuses); all four are one level deep, so Ansible +# merges their vars by group name: decdn_origin_nodes' win over decdn_nodes', and +# the sponsord_* groups' over both. PINNED_VARS and ORIGIN_VAR keep each checked key +# in its own group, so the merge cannot override it. GROUPS = ("decdn_nodes", "decdn_origin_nodes", "sponsord_hosts", "sponsord_onramp_hosts") BASE_GROUPS = {"decdn_nodes", "sponsord_hosts"} # Knobs this lint checks, by the group whose role reads them: allowed only in that # group's vars (checked there), because a host var or another group could override the -# checked value. All but the last decide the install's trust. +# checked value. The install's trust, the onramp's public RPC URL, and the origin +# backend (ORIGIN_VAR, below, pinned to decdn_origin_nodes), which a sponsord_* group +# would otherwise override: it sorts after decdn_origin_nodes. PINNED_VARS = { "decdn_node_install_method": "decdn_nodes", "decdn_node_generate_keystore": "decdn_nodes", @@ -128,6 +132,9 @@ "fs": (("decdn_cache_origin_path",), ("path",)), "s3": (("decdn_cache_origin_s3_bucket", "decdn_cache_origin_s3_region"), ("bucket", "region")), } +# Every origin backend knob (decdn_cache_origins, decdn_cache_origin_*), the optional +# ones too: set elsewhere, one would override or extend the checked backend. +ORIGIN_VAR = re.compile(r"decdn_cache_origin(s|_\w+)") # The format roles/sponsord_onramp/tasks/main.yml asserts for sponsord_onramp_rpc_url. # Run here too: by the time the role refuses a keyed URL, the user-data has already @@ -326,8 +333,9 @@ def check_inventory(inventory): if c != "local": violation(f"inventory: {g}.hosts.localhost.ansible_connection is {c!r}; every group must use local") for p, k in walk_keys(inventory): - if k in PINNED_VARS and p != f"{PINNED_VARS[k]}.vars.{k}": - violation(f"inventory: {p}: set {k} only in {PINNED_VARS[k]}.vars, where it is checked") + home = PINNED_VARS.get(k) or ("decdn_origin_nodes" if ORIGIN_VAR.fullmatch(k) else None) + if home and p != f"{home}.vars.{k}": + violation(f"inventory: {p}: set {k} only in {home}.vars, where it is checked") if k in FORBIDDEN_VARS: violation(f"inventory: {p}: {k} may not be overridden here") gv = {g: group.get("vars") if isinstance(group.get("vars"), dict) else {} for g, group in groups.items()} @@ -341,8 +349,9 @@ def check_inventory(inventory): if iv.get("decdn_verify_release_signature", True) is not True: violation("inventory: decdn_verify_release_signature must not be turned off") if "decdn_origin_nodes" in gv: - # playbooks/origin.yml refuses an origin without a backend, but only after the - # host waited for its secrets; say so before it boots. A placeholder is the + # playbooks/origin.yml refuses an origin without a backend too, but only once + # the host has booted (its pre_tasks are tagged always, so in phase 1, before + # baseline hardens it); say so before it boots. A placeholder is the # template's (the bootstrap refuses a CHANGE_ME left anywhere). ov = gv["decdn_origin_nodes"] kind, origins = ov.get("decdn_cache_origin_kind"), ov.get("decdn_cache_origins") diff --git a/cloud-init/user-data-publisher.yaml b/cloud-init/user-data-publisher.yaml index e7b958e..d2d70e3 100644 --- a/cloud-init/user-data-publisher.yaml +++ b/cloud-init/user-data-publisher.yaml @@ -7,7 +7,8 @@ # The full walkthrough is in cloud-init/README.md of github.com/decdn/devops # ("Publisher host"). To run fewer of them, delete the groups you do not want: an origin # alone keeps decdn_nodes and decdn_origin_nodes, a sponsor alone sponsord_hosts and -# sponsord_onramp_hosts (move baseline_sudo_users into sponsord_hosts' vars then). +# sponsord_onramp_hosts (move baseline_sudo_users, baseline_sudo_autodetect_runner and +# any ssh_allow_cidrs into sponsord_hosts' vars then). # # Edit every value marked MUST-EDIT first. The bootstrap refuses to run while any # CHANGE_ME is left. @@ -43,9 +44,8 @@ write_files: DEVOPS_REF=CHANGE_ME # The Ansible inventory for this host. Everything the playbook needs that is not a - # secret lives here. More knobs: ansible/roles/*/defaults/main.yml. To run a node on - # the same host, add the decdn_nodes group from user-data-node.yaml without its - # baseline_* settings: the admin account is set once, below. + # secret lives here. More knobs: ansible/roles/*/defaults/main.yml. The admin account + # is set once, in decdn_nodes' vars below. - path: /etc/decdn-bootstrap/inventory.yml owner: root:root permissions: "0600" @@ -101,6 +101,8 @@ write_files: sponsord_hosts: hosts: localhost: + ansible_connection: local + ansible_python_interpreter: /usr/bin/python3 vars: # sponsord, installed from the GPG-verified release tarball. The release is # the sponsord role's default at DEVOPS_REF (sponsord_version); set diff --git a/compose/README.md b/compose/README.md index cd30555..ceaaa5f 100644 --- a/compose/README.md +++ b/compose/README.md @@ -169,8 +169,10 @@ the root-only env files whenever it creates a container. An origin is the same node with an origin backend: the canonical source of a publisher's namespace. Set `COMPOSE_PROFILES=origin` and give `node.toml` the -backend. For an HTTP store, add `--origin ` to step 4's `config init`; for S3 -(and R2, B2, MinIO) or a local or NFS path, write the `[cache.origin]` table by hand. +backend: add `--origin` to step 4's `config init`, with an `https://…` URL for an HTTP +store, `file:///` for a local or NFS path, or `s3://` for S3 (and R2, B2, +MinIO). For S3 it writes `region = "us-east-1"` for you to confirm and leaves +`endpoint_url` (R2, B2, MinIO) commented. Or write the `[cache.origin]` table by hand. The [`decdn_node` role's README](../ansible/roles/decdn_node/README.md) lists the keys, and `decdn config validate` (step 6) checks them. Static S3 keys go in `/etc/decdn/decdn.env` (`AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`), never in @@ -178,7 +180,11 @@ keys, and `decdn config validate` (step 6) checks them. Static S3 keys go in A path backend must be visible inside the container. `compose.yaml` mounts only `/etc/decdn` and `/var/lib/decdn`, and `make lint-compose` holds it to exactly those, -so add the content root in a `compose.override.yaml` of your own beside it, read-only: +so add the content root in a `compose.override.yaml` of your own beside it, read-only. +Compose reads an override file by itself only when no `-f` is given, and every command +in this README passes `-f compose/compose.yaml`, so add `-f compose/compose.override.yaml` +after it on every command (`up`, `logs`, `stop`, `pull`, …): an `up` without it +recreates the node without the mount. The override: ```yaml services: diff --git a/docs/lifecycle.md b/docs/lifecycle.md index 328d0fa..1611048 100644 --- a/docs/lifecycle.md +++ b/docs/lifecycle.md @@ -170,8 +170,9 @@ firewall rule stays until baseline is re-run without it. An **origin node** (`decdn_origin_nodes`) decommissions the same way, and the same command covers it. Its operator also stays seated in `OriginAssignment` for its namespaces. The namespace's publisher unseats it with `decdn publish revoke` -(`OriginAssignment.removeOrigin`; governance may too, and once the operator -deregisters anyone may prune it as inactive) +(`OriginAssignment.removeOrigin`; governance may too, and once the operator leaves +`CapacityBond`'s active set, by deregistering, unbonding or ejection, anyone may prune +it as inactive) ([ADR 011 § Origin Assignment Authority](https://github.com/decdn/decdn/blob/main/adr/011-content-takedown.md#origin-assignment-authority)), ideally after another origin is seated, since a namespace with no origin left becomes unassigned. diff --git a/docs/publishers.md b/docs/publishers.md index a665105..87b9b6e 100644 --- a/docs/publishers.md +++ b/docs/publishers.md @@ -43,7 +43,9 @@ same hardening, port and day-2 tooling as a cache node. Three things differ: - **The backend.** Set `decdn_cache_origin_kind` (`http`, `fs` or `s3`) and its fields, or `decdn_cache_origins` for an ordered fallback list ([`decdn_node` role](../ansible/roles/decdn_node/README.md)). - S3 keys go in `/etc/decdn/decdn.env` on the host, never in inventory or user-data. + S3 keys go in `/etc/decdn/decdn.env` on the host (or in `decdn_extra_env` in a + git-ignored `secret.yml`, beside `decdn_rpc_url`), never in tracked inventory or + user-data. `playbooks/origin.yml` refuses a `decdn_origin_nodes` host without a backend, or one outside `decdn_nodes`. - **What it serves.** With a backend, the node fills misses from it rather than from @@ -78,6 +80,7 @@ details: [`iroh_relay`](../ansible/roles/iroh_relay/README.md), [`lifecycle.md`](lifecycle.md) covers backup, restore, migration and decommission for every service above. Decommissioning an origin stops the node but leaves its operator seated on-chain, where `OriginAssignment.getOrigins` still lists it. Unseat it with -`decdn publish revoke` (`OriginAssignment.removeOrigin`); once the operator deregisters, -anyone may also prune it as inactive +`decdn publish revoke` (`OriginAssignment.removeOrigin`); once the operator leaves +`CapacityBond`'s active set (it deregisters, unbonds or is ejected), anyone may also +prune it as inactive ([ADR 011 § Origin Assignment Authority](https://github.com/decdn/decdn/blob/main/adr/011-content-takedown.md#origin-assignment-authority)). diff --git a/scripts/limit-guard.sh b/scripts/limit-guard.sh new file mode 100755 index 0000000..7e43d7c --- /dev/null +++ b/scripts/limit-guard.sh @@ -0,0 +1,60 @@ +#!/usr/bin/env bash +# ansible/Makefile's preflight for every playbook target. Run from ansible/ (the +# Makefile does): +# +# scripts/limit-guard.sh [ANSIBLE_ARGS...] +# +# ansible-playbook reports success for a run that skips hosts: --limit refuses a host +# missing from the inventory, but a host the plays' own patterns exclude only prints +# "no hosts matched" and exits 0. So `make deploy-node LIMIT=` (node.yml +# leaves decdn_origin_nodes to origin.yml), or LIMIT='node-1,origin-1', would report a +# deploy of a host it never touched. This lists the hosts the playbook's plays select +# (--list-hosts, which connects to nothing) and refuses: +# - a run in which no play selects a host, with or without a LIMIT; +# - a LIMIT that resolves to any host no play selects, naming each. A group LIMIT +# that mixes kinds is refused too: subtract what the target does not cover +# (LIMIT='eu:!decdn_origin_nodes') or use `make deploy`. +# A --limit passed in ANSIBLE_ARGS is not checked, and --syntax-check lists no hosts, +# so it skips the guard. +set -euo pipefail + +die() { echo "limit-guard: $*" >&2; exit 1; } +(($# >= 3)) || die "usage: limit-guard.sh [ansible-playbook args...]" +inventory=$1 playbook=$2 limit=$3 +shift 3 +for a in "$@"; do [[ $a == --syntax-check ]] && exit 0; done + +limit_args=() +[[ -z $limit ]] || limit_args=(--limit "$limit") +# stdout only: warnings and errors go straight to the terminal. +listing="$(ansible-playbook -i "$inventory" "$playbook" --list-hosts "${limit_args[@]}" "$@" &2 + while read -r h; do echo " $h"; done <<<"$skipped" >&2 + case "${playbook##*/}" in + node.yml) echo "An origin (decdn_origin_nodes) is deployed by the -origin targets, or by deploy/deploy-publisher." >&2 ;; + origin.yml) echo "A cache node (decdn_nodes, not decdn_origin_nodes) is deployed by the -node targets, or by deploy." >&2 ;; + *) echo "Check those hosts' groups against the playbook's plays, or narrow LIMIT." >&2 ;; + esac + exit 1 +fi diff --git a/scripts/release.sh b/scripts/release.sh index 4c6dc2f..0c39b44 100755 --- a/scripts/release.sh +++ b/scripts/release.sh @@ -27,8 +27,9 @@ # The first release (no tag yet, the manifest at the 0.0.0 placeholder) names its level # and releases the changelog's hand-written `## [Unreleased]` section instead: without a # previous tag, git-cliff would render the whole history. decdn.publisher depends on -# decdn.node, so its first release is refused until origin has a node-collection tag -# that satisfies the `decdn.node: ">=X.Y.Z"` its galaxy.yml declares. +# decdn.node, so each of its releases is refused until origin has a node-collection tag +# that satisfies the `decdn.node: ">=X.Y.Z"` its galaxy.yml declares (the publish job +# then checks Galaxy itself). # # --execute runs scripts/check-release-version.sh on the result (and, for the chart, # scripts/chart-artifacthub-changes.py), then the artifact's make target (make @@ -150,20 +151,23 @@ current="$(sed -nE 's/^version:[[:space:]]*"?([^"#[:space:]]+)"?.*/\1/p' "$manif [[ -n "$current" ]] || die "$manifest has no top-level version: line" today="$(date -u +%F)" +# Every decdn.publisher release must be installable: the decdn.node version its +# galaxy.yml requires has to be released already (origin's node-collection tags). +if [[ "$artifact" == "publisher-collection" ]]; then + need="$(sed -nE 's/^[[:space:]]+decdn\.node:[[:space:]]*">=([0-9]+\.[0-9]+\.[0-9]+)".*/\1/p' "$manifest")" + [[ -n "$need" ]] || die "$manifest declares no decdn.node: \">=X.Y.Z\" dependency" + node_tags="$(git ls-remote --tags --refs origin 'refs/tags/node-collection-v*')" || die "git ls-remote origin failed" + node_prev="$(awk '{sub(/^refs\/tags\//, "", $2); print $2}' <<<"$node_tags" \ + | { grep -E '^node-collection-v[0-9]+\.[0-9]+\.[0-9]+$' || (($? == 1)); } | sed 's/^node-collection-v//' | sort -V | tail -n1)" + if [[ -z "$node_prev" ]] || ver_gt "$need" "$node_prev"; then + die "decdn.publisher needs decdn.node >=$need, and origin's highest node-collection tag is ${node_prev:-none}: release node-collection first" + fi +fi + # --- version and section ----------------------------------------------------------- if [[ -z "$prev" ]]; then # First release: the hand-written [Unreleased] section becomes the release's. [[ "$current" == "0.0.0" ]] || die "no ${prefix}X.Y.Z tag yet, but $manifest is $current, not the 0.0.0 placeholder" - if [[ "$artifact" == "publisher-collection" ]]; then - need="$(sed -nE 's/^[[:space:]]+decdn\.node:[[:space:]]*">=([0-9]+\.[0-9]+\.[0-9]+)".*/\1/p' "$manifest")" - [[ -n "$need" ]] || die "$manifest declares no decdn.node: \">=X.Y.Z\" dependency" - node_tags="$(git ls-remote --tags --refs origin 'refs/tags/node-collection-v*')" || die "git ls-remote origin failed" - node_prev="$(awk '{sub(/^refs\/tags\//, "", $2); print $2}' <<<"$node_tags" \ - | { grep -E '^node-collection-v[0-9]+\.[0-9]+\.[0-9]+$' || (($? == 1)); } | sed 's/^node-collection-v//' | sort -V | tail -n1)" - if [[ -z "$node_prev" ]] || ver_gt "$need" "$node_prev"; then - die "decdn.publisher needs decdn.node >=$need, and origin's highest node-collection tag is ${node_prev:-none}: release node-collection first" - fi - fi case "$level" in auto) die "the first release names its level: minor for 0.1.0" ;; patch) version="0.0.1" ;; diff --git a/tests/scripts-test.sh b/tests/scripts-test.sh index 07750f4..f2b681c 100755 --- a/tests/scripts-test.sh +++ b/tests/scripts-test.sh @@ -32,29 +32,36 @@ expect 2 "decommission refuses an empty LIMIT" mk decommission LIMI expect 2 "decommission refuses LIMIT from the environment" env LIMIT=h make -s -C "$repo/ansible" -n decommission expect 2 "deploy refuses INVENTORY from the environment" env INVENTORY=x make -s -C "$repo/ansible" -n deploy expect 2 "backup refuses ANSIBLE_ARGS from the environment" env ANSIBLE_ARGS=-v make -s -C "$repo/ansible" -n backup -mk decommission LIMIT=h | grep -q -- "playbooks/decommission.yml --limit 'h'" \ - || fail "decommission LIMIT=h does not scope the playbook" -pass "decommission LIMIT=h scopes playbooks/decommission.yml" -mk backup | grep -q -- "playbooks/backup.yml" || fail "backup does not run playbooks/backup.yml" -pass "backup runs playbooks/backup.yml fleet-wide by default" -expect 2 "deploy-relay refuses LIMIT from the environment" env LIMIT=h make -s -C "$repo/ansible" -n deploy-relay -mk deploy-relay LIMIT=h | grep -q -- "playbooks/iroh_relay.yml --limit 'h'" \ - || fail "deploy-relay LIMIT=h does not run playbooks/iroh_relay.yml scoped to h" -pass "deploy-relay LIMIT=h scopes playbooks/iroh_relay.yml" -expect 2 "deploy-dns refuses LIMIT from the environment" env LIMIT=h make -s -C "$repo/ansible" -n deploy-dns -mk deploy-dns LIMIT=h | grep -q -- "playbooks/iroh_dns_server.yml --limit 'h'" \ - || fail "deploy-dns LIMIT=h does not run playbooks/iroh_dns_server.yml scoped to h" -pass "deploy-dns LIMIT=h scopes playbooks/iroh_dns_server.yml" -# The persona entry points: each goal is guarded and runs its own playbook. -for t in check-node:node deploy-node:node check-origin:origin deploy-origin:origin \ - check-publisher:publisher deploy-publisher:publisher; do - expect 2 "${t%%:*} refuses LIMIT from the environment" env LIMIT=h make -s -C "$repo/ansible" -n "${t%%:*}" - mk "${t%%:*}" LIMIT=h | grep -q -- "playbooks/${t##*:}.yml.* --limit 'h'" \ - || fail "${t%%:*} LIMIT=h does not run playbooks/${t##*:}.yml scoped to h" +for t in deploy-relay deploy-dns check-node deploy-node check-origin deploy-origin check-publisher deploy-publisher; do + expect 2 "$t refuses LIMIT from the environment" env LIMIT=h make -s -C "$repo/ansible" -n "$t" done -mk check-publisher | grep -q -- "playbooks/publisher.yml --check --diff" || fail "check-publisher is not a --check run" -mk deploy | grep -q -- "playbooks/site.yml" || fail "deploy does not run playbooks/site.yml" -pass "check/deploy-node, -origin and -publisher scope their own playbooks; deploy runs site.yml" +# Every playbook target, read from the guard block's own list so a new one is covered: +# exactly one ansible-playbook run, of the target's playbook, scoped to LIMIT, with +# limit_guard checking that same playbook and LIMIT first. Captured, then matched: +# `make -n | grep -q` can SIGPIPE make before it prints the line that matters. +targets="$(sed -nE 's/^ifneq \(\$\(filter ([a-z -]+),\$\(MAKECMDGOALS\)\),\)$/\1/p' "$repo/ansible/Makefile")" +(($(wc -w <<<"$targets") >= 16)) || fail "could not read the playbook targets from ansible/Makefile: $targets" +declare -A want_pb=([check]=site [deploy]=site [backup]=backup [decommission]=decommission + [check-node]=node [deploy-node]=node [check-origin]=origin [deploy-origin]=origin + [check-publisher]=publisher [deploy-publisher]=publisher [check-sponsord]=sponsord [deploy-sponsord]=sponsord + [check-relay]=iroh_relay [deploy-relay]=iroh_relay [check-dns]=iroh_dns_server [deploy-dns]=iroh_dns_server) +for t in $targets; do + pb="playbooks/${want_pb[$t]:?ansible/Makefile guards $t, which this test does not map to a playbook}.yml" + check=""; [[ $t == check* ]] && check=" --check --diff" + for lim in h ""; do + [[ -z $lim && $t == decommission ]] && continue + largs=(); [[ -z $lim ]] || largs=(LIMIT="$lim") + out="$(mk "$t" "${largs[@]}")" || fail "make -n $t ${largs[*]} failed" + scope=""; [[ -z $lim ]] || scope=" --limit '$lim'" + want_run="ANSIBLE_INVENTORY_UNPARSED_FAILED=True ansible-playbook -i 'inventory/hosts.yml' $pb$check$scope" + want_guard="ANSIBLE_INVENTORY_UNPARSED_FAILED=True ../scripts/limit-guard.sh 'inventory/hosts.yml' $pb '$lim'" + # shellcheck disable=SC2001 # per line: the recipes' trailing spaces (empty LIMIT/ANSIBLE_ARGS) + out="$(sed 's/ *$//' <<<"$out")" + [[ "$out" == "$want_guard"$'\n'"$want_run" ]] \ + || fail "make $t ${largs[*]} does not run limit_guard on $pb, then $pb${lim:+ scoped to $lim}:"$'\n'"$out" + done +done +pass "every playbook target (${targets// /, }) runs limit_guard, then its own playbook, scoped to LIMIT" # --- molecule driver (scripts/molecule.sh): selection, guards, locks ------------------ # Against a throwaway ansible/ layout, so nothing here can start a container or touch @@ -568,6 +575,41 @@ SH ! grep -q "decdn-$other-" "$work/gh-args" || fail "release-collection.yml's $c Release attaches the $other collection" ! LATEST="" run_in "$co" "$c-collection-v$cversion" "$release" || fail "release-collection.yml's Release runs with no Latest decision" done + # A decdn.publisher publish first asks Galaxy (ansible-galaxy collection download) + # for the decdn.node its built MANIFEST.json requires; a node publish skips it. + galaxy_dep="$(wfstep release-collection.yml publish "Require decdn.publisher's decdn.node dependency on Galaxy")" + cat > "$work/bin/ansible-galaxy" <<'SH' +#!/usr/bin/env bash +printf '%s\n' "$@" > "$GALAXY_ARGS" +[[ -n ${GALAXY_HAS_NODE:-} ]] +SH + chmod +x "$work/bin/ansible-galaxy" + gd="$work/galaxy-dep" + mkdir -p "$gd/m" "$gd/dist" + printf '{"collection_info": {"dependencies": {"decdn.node": ">=0.1.0"}}}' > "$gd/m/MANIFEST.json" + tar -czf "$gd/dist/decdn-publisher-$cversion.tar.gz" -C "$gd/m" MANIFEST.json + export GALAXY_ARGS="$work/galaxy-args" + : > "$GALAXY_ARGS" + run_in "$gd" "node-collection-v$cversion" "$galaxy_dep" \ + || { cat "$work/out" >&2; fail "release-collection.yml's Galaxy dependency step fails a node release"; } + [[ ! -s $GALAXY_ARGS ]] || fail "release-collection.yml's Galaxy dependency step queries Galaxy for a node release" + GALAXY_HAS_NODE=1 run_in "$gd" "publisher-collection-v$cversion" "$galaxy_dep" \ + || { cat "$work/out" >&2; fail "release-collection.yml's Galaxy dependency step refuses a resolvable decdn.node"; } + for arg in 'decdn.node:>=0.1.0' --no-deps; do + grep -qx -- "$arg" "$GALAXY_ARGS" \ + || fail "release-collection.yml's Galaxy dependency step does not pass $arg: $(xargs <"$GALAXY_ARGS")" + done + ! run_in "$gd" "publisher-collection-v$cversion" "$galaxy_dep" \ + || fail "release-collection.yml publishes decdn.publisher while Galaxy has no decdn.node >=0.1.0" + grep -qF "could not resolve decdn.node >=0.1.0 on Galaxy" "$work/out" || { cat "$work/out" >&2; fail "the Galaxy dependency refusal has another message"; } + unset GALAXY_ARGS + # ...and it runs before anything is published. + # shellcheck disable=SC2016 # a yq expression + order="$(yq '.jobs.publish.steps | to_entries | map(select(.value.name == "Require decdn.publisher'"'"'s decdn.node dependency on Galaxy" or .value.name == "Publish the collection to Galaxy" or .value.name == "Create the GitHub Release") | .value.name) | join("|")' \ + "$repo/.github/workflows/release-collection.yml")" + [[ "$order" == "Require decdn.publisher's decdn.node dependency on Galaxy|Publish the collection to Galaxy|Create the GitHub Release" ]] \ + || fail "release-collection.yml's publish job does not check decdn.node before publishing: $order" + pass "release-collection.yml publishes decdn.publisher only when Galaxy resolves its decdn.node" # Latest follows decdn.node only: a node release when no higher node release exists, # never a publisher release; chart and publisher tags never count. decide="$(wfstep release-collection.yml publish "Decide whether this release becomes Latest")" @@ -892,6 +934,16 @@ if command -v git-cliff >/dev/null && command -v ssh-keygen >/dev/null; then expect 0 "release.sh cuts the publisher collection's first release after node's" rsrel publisher-collection patch --execute rsmade galaxy-check-publisher rspushed publisher-collection-v0.0.1 + # A later release is held to the constraint too: raised past the highest node tag, + # the collection would not install from Galaxy. + sed -i 's/">=0.0.1"/">=0.1.0"/' "$rs/ansible/galaxy/publisher/galaxy.yml" + rsgit commit -qam "build: raise the fixture's decdn.node constraint"; rspush + # Checked before the changelog: no releasable commit is needed to trip it (the only + # commit since the publisher's tag is a build commit, which makes no entry). + rsrefused "a later decdn.publisher release above its decdn.node constraint" \ + "needs decdn.node >=0.1.0, and origin's highest node-collection tag is 0.0.1" publisher-collection + sed -i 's/">=0.1.0"/">=0.0.1"/' "$rs/ansible/galaxy/publisher/galaxy.yml" + rsgit commit -qam "build: restore the fixture's decdn.node constraint"; rspush rscommit ansible/molecule/default/x.yml "feat(molecule): not shipped" rscommit ansible/roles/unshipped/x.yml "feat(unshipped): not in a roles.txt" rsrefused "a change outside what the node collection ships" "nothing to release" node-collection @@ -1149,6 +1201,12 @@ if command -v cloud-init >/dev/null; then ci_variant "an s3 origin without a region" 'an origin of kind s3 needs decdn_cache_origin_s3_bucket and decdn_cache_origin_s3_region' 's/^(\s*)decdn_cache_origin_kind: http$/\1decdn_cache_origin_kind: s3\n\1decdn_cache_origin_s3_bucket: b/' "$sponsorud" ci_variant "an empty origin in the list" 'decdn_cache_origins[0] needs a kind of http, fs or s3' '/^\s*decdn_cache_origin_url:/d; s/^(\s*)decdn_cache_origin_kind: http$/\1decdn_cache_origins: [{}]/' "$sponsorud" ci_variant "a list origin without its path" 'decdn_cache_origins[0] (fs) needs path' '/^\s*decdn_cache_origin_url:/d; s/^(\s*)decdn_cache_origin_kind: http$/\1decdn_cache_origins: [{kind: fs}]/' "$sponsorud" + # The sponsord_* groups and host vars outrank decdn_origin_nodes.vars, so the backend + # the lint checks there must be the only one. + ci_variant "an origin backend emptied in sponsord_hosts" 'set decdn_cache_origin_kind only in decdn_origin_nodes.vars' "s#$spnet#&\\n\\1decdn_cache_origin_kind: \"\"#" "$sponsorud" + ci_variant "an origin backend as a host var" 'set decdn_cache_origin_url only in decdn_origin_nodes.vars' "s#$loc#&\\n\\1decdn_cache_origin_url: https://other.example/#" "$sponsorud" + ci_variant "an optional backend key in sponsord_hosts" 'set decdn_cache_origin_s3_endpoint_url only in decdn_origin_nodes.vars' "s#$spnet#&\\n\\1decdn_cache_origin_s3_endpoint_url: https://s3.other.example#" "$sponsorud" + ci_variant "a backend on a cache node" 'set decdn_cache_origins only in decdn_origin_nodes.vars' "s#$net#&\\n\\1decdn_cache_origins: [{kind: fs, path: /srv}]#" ci_variant "an origin node built from source" 'decdn_node_install_method must be release' 's/^(\s*)decdn_node_install_method: release$/\1decdn_node_install_method: source/' "$sponsorud" ci_variant "S3 keys in the origin vars" 'decdn_origin_nodes.vars.decdn_extra_env looks secret-bearing' 's/^(\s*)decdn_cache_origin_kind: http$/&\n\1decdn_extra_env: {AWS_SECRET_ACCESS_KEY: x}/' "$sponsorud" ci_variant "a second connection" "sponsord_onramp_hosts.hosts.localhost.ansible_connection is 'ssh'" '/^\s*sponsord_onramp_hosts:$/,/^\s*vars:$/ s/^(\s*)localhost:$/&\n\1 ansible_connection: ssh/' "$sponsorud" @@ -1168,6 +1226,18 @@ if command -v cloud-init >/dev/null; then "$userdata" > "$work/ci-colocated.yaml" expect 0 "lint-cloud-init accepts a node co-located with sponsord" \ make -s -C "$repo" lint-cloud-init CLOUD_INIT_FILE="$work/ci-colocated.yaml" + # The publisher template cut down as its header says: a sponsor alone (the admin + # account moved into sponsord_hosts' vars) and an origin alone. + sed -E '/^ decdn_nodes:$/,/^ sponsord_hosts:$/{/^ sponsord_hosts:$/!d} + s/^(\s*)sponsord_network: arbitrum-sepolia$/&\n\1baseline_sudo_users: [{name: alice, keys: ["ssh-ed25519 AAAA alice"]}]\n\1baseline_sudo_autodetect_runner: false/' \ + "$sponsorud" > "$work/ci-sponsor-alone.yaml" + grep -q '^ decdn_nodes:$' "$work/ci-sponsor-alone.yaml" && fail "the sponsor-alone variant kept decdn_nodes" + expect 0 "lint-cloud-init accepts the publisher template as a sponsor alone" \ + make -s -C "$repo" lint-cloud-init CLOUD_INIT_FILE="$work/ci-sponsor-alone.yaml" + sed -E '/^ sponsord_hosts:$/,/^$/d; /^ # The onramp runs beside/,/^$/d' "$sponsorud" > "$work/ci-origin-alone.yaml" + grep -qE '^ sponsord_(onramp_)?hosts:$' "$work/ci-origin-alone.yaml" && fail "the origin-alone variant kept a sponsord group" + expect 0 "lint-cloud-init accepts the publisher template as an origin alone" \ + make -s -C "$repo" lint-cloud-init CLOUD_INIT_FILE="$work/ci-origin-alone.yaml" ci_variant "one sponsord version moved" 'set sponsord_version and sponsord_onramp_version together' 's/^(\s*)sponsord_onramp_install_method: release$/&\n\1sponsord_onramp_version: "0.0.9"/' "$sponsorud" ci_variant "an onramp pin without its digest" 'set all of sponsord_onramp_decdn_release' 's/^(\s*)sponsord_onramp_install_method: release$/&\n\1sponsord_onramp_cli_release: v0.0.9/' "$sponsorud" ci_variant "onramp nested under children" 'sponsord_hosts may hold only hosts and vars, not' '/^ sponsord_onramp_hosts:$/,/^$/{s/^ / /}; s/^ sponsord_onramp_hosts:$/ children:\n&/' "$sponsorud" @@ -1515,6 +1585,79 @@ if command -v ansible >/dev/null; then grep -q "skipping: no hosts matched" "$work/out" \ || { cat "$work/out" >&2; fail "node.yml's play matches an origin (decdn_origin_nodes) host"; } pass "node.yml's play leaves decdn_origin_nodes to origin.yml" + # Every pre_task is tagged always: lift() runs them untagged, but a --tags run + # (cloud-init's phase 1 is --tags baseline) would otherwise skip the refusals. + for pb in node.yml origin.yml; do + untagged="$(yq '[.[] | select(.pre_tasks) | .pre_tasks[] | select([.tags] | flatten | any_c(. == "always") | not) | .name] | .[]' \ + "$repo/ansible/playbooks/$pb")" || fail "yq could not read playbooks/$pb" + [[ -z $untagged ]] || fail "playbooks/$pb has pre_tasks not tagged always: $untagged" + done + pass "node.yml's and origin.yml's guards run under any --tags" + # site.yml runs each node once: node.yml's play on the cache nodes and origin.yml's + # (through publisher.yml) on the origins, disjoint, together every decdn_nodes and + # decdn_origin_nodes host. Read from --list-hosts, which needs no collections. + play_hosts() { # : "\t" per host, "\t" per play + (cd "$repo/ansible" && ANSIBLE_DEPRECATION_WARNINGS=0 \ + ansible-playbook -i tests/playbook-guards/inventory.yml "playbooks/$1" --list-hosts "$work/site-plays" || fail "ansible-playbook --list-hosts failed on site.yml" + play_hosts publisher.yml > "$work/publisher-plays" || fail "ansible-playbook --list-hosts failed on publisher.yml" + cache=$'Provision deCDN cache nodes\t' orig=$'Provision deCDN origin nodes\t' + [[ "$(grep -cx "$cache" "$work/site-plays")" == 1 && "$(grep -cx "$orig" "$work/site-plays")" == 1 ]] \ + || fail "site.yml does not run the cache-node play and the origin play exactly once each" + [[ "$(grep -cx "$orig" "$work/publisher-plays")" == 1 ]] || fail "publisher.yml does not run the origin play once" + ! grep -qx "$cache" "$work/publisher-plays" || fail "publisher.yml runs the cache-node play" + want="$(cd "$repo/ansible" && ansible -i tests/playbook-guards/inventory.yml 'decdn_nodes:decdn_origin_nodes' --list-hosts \ + | awk 'NR > 1 {print $1}' | sort)" + got="$(grep -E "^($cache|$orig)." "$work/site-plays" | cut -f2 | sort)" + [[ -n $want && "$got" == "$want" ]] \ + || fail "site.yml's node and origin plays do not cover each node exactly once: got [$(xargs <<<"$got")], want [$(xargs <<<"$want")]" + pass "site.yml runs every node exactly once: cache nodes in node.yml, origins in origin.yml" + # ansible/Makefile refuses a LIMIT that matches no host of the target's playbook, + # which ansible-playbook itself reports as success. ANSIBLE_ARGS keeps the run a listing. + # ansible/Makefile's limit_guard (scripts/limit-guard.sh) refuses a run that selects + # no host, or skips a host LIMIT names, both of which ansible-playbook reports as + # success. ANSIBLE_ARGS keeps the real run a listing. + mkl() { # [inventory] + local lim=(); [[ -z $2 ]] || lim=(LIMIT="$2") + make -s -C "$repo/ansible" "$1" "${lim[@]}" INVENTORY="${3:-tests/playbook-guards/inventory.yml}" \ + ANSIBLE_ARGS=--list-hosts + local desc=$1 msg=$2; shift 2 + expect 2 "$desc" mkl "$@" + grep -qF -- "$msg" "$work/out" || { cat "$work/out" >&2; fail "$desc: not refused with: $msg"; } + } + refused "make check-node refuses LIMIT=" \ + "no play in playbooks/node.yml selects a host of tests/playbook-guards/inventory.yml within LIMIT='origin-ok'" check-node origin-ok + refused "make deploy-origin refuses LIMIT=" \ + "no play in playbooks/origin.yml selects a host" deploy-origin node-only + refused "make check-node refuses a LIMIT that names an origin among cache nodes" \ + "LIMIT='node-only,origin-ok' includes hosts no play in playbooks/node.yml selects" check-node node-only,origin-ok + if ! grep -qx ' origin-ok' "$work/out" || ! grep -qF 'deployed by the -origin targets' "$work/out"; then + cat "$work/out" >&2; fail "check-node's partial-LIMIT refusal does not name origin-ok and the -origin targets" + fi + ! grep -qx ' node-only' "$work/out" || fail "check-node's partial-LIMIT refusal names a host it runs" + refused "make check-relay refuses a group LIMIT that mixes in other hosts" \ + "includes hosts no play in playbooks/iroh_relay.yml selects" check-relay 'relay-only:node-only' + grep -qF "Check those hosts' groups" "$work/out" || { cat "$work/out" >&2; fail "check-relay's refusal gives the node/origin hint"; } + refused "make backup refuses LIMIT=" "no play in playbooks/backup.yml selects a host" backup relay-only + # Without a LIMIT, an inventory the playbook selects nothing in. + printf 'all:\n children:\n decdn_nodes:\n hosts:\n n1: {}\n' > "$work/nodes-only.yml" + refused "make check-relay refuses an inventory with no relay" \ + "no play in playbooks/iroh_relay.yml selects a host of $work/nodes-only.yml: the run" check-relay "" "$work/nodes-only.yml" + expect 0 "make check-node takes an inventory of cache nodes, no LIMIT" mkl check-node "" "$work/nodes-only.yml" + # A LIMIT outside the inventory is ansible's own refusal, passed through. + refused "make check-node passes on ansible's refusal of an unknown LIMIT" \ + "ansible-playbook --list-hosts failed on playbooks/node.yml" check-node no-such-host + grep -q "no hosts to target" "$work/out" || { cat "$work/out" >&2; fail "limit_guard hid ansible's own error"; } + expect 0 "make check-node takes LIMIT=" mkl check-node node-only + expect 0 "make deploy-origin takes LIMIT=" mkl deploy-origin origin-ok + expect 0 "make check takes LIMIT=" mkl check node-only,origin-ok + expect 0 "make check-node with --syntax-check skips limit_guard" \ + make -s -C "$repo/ansible" check-node LIMIT=node-only INVENTORY=tests/playbook-guards/inventory.yml ANSIBLE_ARGS=--syntax-check # decommission.yml's host cap covers the whole run: a node-only host and a # sponsord host pass each role's own per-play cap but not this one. Only the # cap task is lifted (the hold check needs the roles).