diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a165358f..729f705b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -99,7 +99,8 @@ they are maintained here. See [its README](monitoring/README.md). `cloud-init-publisher` scenarios boot the real user-data templates, so they need network access to apt, PyPI and Galaxy. Keep each scenario well under its leg's timeout; when one grows, split it along its side effects (as `sponsord-install`, - `sponsord-onramp-nginx`, `sponsord-onramp-source`, `iroh-relay-gate`, + `sponsord-wallet`, `sponsord-onramp-nginx`, `sponsord-onramp-turnstile`, `sponsord-onramp-source`, + `iroh-relay-gate`, `iroh-relay-access`, `source-build-recovery` and the `validation-*` scenarios are), or move a play into a short sibling that already shares its converge. `source-build`, its two siblings and `sponsord-onramp-source` download rustup and a Rust toolchain from diff --git a/ansible/Makefile b/ansible/Makefile index f0fc76d5..3822d8f5 100644 --- a/ansible/Makefile +++ b/ansible/Makefile @@ -202,18 +202,20 @@ decommission: # /metrics probe timeout), grafana-cloud and grafana-cloud-token (opt-in Grafana # Cloud observability, host-provisioned vs inventory token), os-matrix (default's # plays on Debian 13, Ubuntu 24.04 and 26.04), lifecycle (network profile, -# backup + decommission), sponsord and sponsord-install (the sponsord role on a host -# with no node, through playbooks/sponsord.yml, on Debian 12 and Ubuntu 24.04; the -# install-method, wallet and secret.env provenance side effects run in -# sponsord-install), sponsord-onramp and sponsord-onramp-nginx (the onramp beside its -# daemon, behind the real distro nginx; the proxy, Caddy retirement, ACME, gate-page and -# inventory Turnstile side effects run in sponsord-onramp-nginx), sponsord-onramp-source +# backup + decommission), sponsord, sponsord-install and sponsord-wallet (the sponsord +# role on a host with no node, through playbooks/sponsord.yml, on Debian 12 and Ubuntu +# 24.04; the install-method side effects run in sponsord-install, the wallet and +# secret.env provenance ones in sponsord-wallet), sponsord-onramp, sponsord-onramp-nginx +# and sponsord-onramp-turnstile (the onramp beside its daemon, behind the real distro +# nginx; the proxy, Caddy retirement and ACME side effects run in sponsord-onramp-nginx, +# the gate-page and inventory Turnstile ones in sponsord-onramp-turnstile), +# sponsord-onramp-source # (that converge, then both sponsord roles built from source), sponsord-onramp-lifecycle # (that converge, then sponsord's backup and decommission), iroh-relay, -# iroh-relay-certificate, iroh-relay-gate, iroh-relay-lifecycle and +# iroh-relay-certificate, iroh-relay-gate, iroh-relay-access, iroh-relay-lifecycle and # iroh-relay-install (the relay through playbooks/iroh_relay.yml against a stub on # Debian 12 and Ubuntu 24.04: restarts, the fatal gate, the port guard; then the -# certificate check, the gate's other refusals with the bind and access modes, its +# certificate check, the gate's other refusals, the bind and access modes, its # decommission, and its release install from a local mirror), validation-iroh-relay # (the relay's bad knobs), iroh-dns-server, iroh-dns-server-certificate, # iroh-dns-server-gate, iroh-dns-server-install and iroh-dns-server-lifecycle (the @@ -264,10 +266,10 @@ decommission: # rename cannot leave it silently stale. SCENARIOS ?= all JOBS ?= -SLOW_FIRST ?= sponsord-onramp-source sponsord-install cloud-init source-build-recovery \ - iroh-relay-gate cloud-init-publisher sponsord source-build sponsord-onramp \ - sponsord-onramp-nginx decdn-gateway-lifecycle iroh-relay source-build-rollback \ - iroh-relay-certificate \ +SLOW_FIRST ?= sponsord-onramp-source sponsord-install sponsord-wallet cloud-init \ + source-build-recovery iroh-relay-gate iroh-relay-access cloud-init-publisher sponsord \ + source-build sponsord-onramp sponsord-onramp-nginx sponsord-onramp-turnstile \ + decdn-gateway-lifecycle iroh-relay source-build-rollback iroh-relay-certificate \ validation iroh-relay-install os-matrix grafana-cloud MOLECULE_LOCK_PREFIX ?= /tmp/decdn-devops-molecule molecule_env = SCENARIOS='$(SCENARIOS)' JOBS='$(JOBS)' SLOW_FIRST='$(SLOW_FIRST)' \ diff --git a/ansible/README.md b/ansible/README.md index ea98d03c..efd31288 100644 --- a/ansible/README.md +++ b/ansible/README.md @@ -480,21 +480,24 @@ itself is Debian 12), `lifecycle` (a `decdn_network` profile with an override, b backup scopes decrypted and checked, a rejected and a real decommission), `sponsord` (`playbooks/sponsord.yml` on a host with no node: generated token, credential rotation restart, the fatal `/healthz` gate, the top-up hold guard, Alloy scraping sponsord and not the node; -`grafana-cloud` covers sponsord co-located with a node), `sponsord-install` (the same -converge, then release mode against a signed loopback mirror, treasury wallet -generation and the `secret.env` provenance guards), `sponsord-onramp` (the onramp beside its daemon, behind the real distro +`grafana-cloud` covers sponsord co-located with a node), `sponsord-install` and +`sponsord-wallet` (the same converge, then release mode against a signed loopback +mirror; and treasury wallet generation and the `secret.env` provenance guards), +`sponsord-onramp` (the onramp beside its daemon, behind the real distro nginx with a self-signed certificate: the https chain, the client address nginx passes on, a Turnstile rotation restart, the fatal gates), `sponsord-onramp-nginx` (the same converge, then `none` mode, a broken upstream behind nginx, a refused foreign nginx site, the retirement of the role's earlier Caddy, ACME against an ACME server that -does not answer, a custom gate page and the Turnstile secret from inventory), +does not answer), `sponsord-onramp-turnstile` (the same converge, then a custom gate +page and the Turnstile secret from inventory), `sponsord-onramp-source` (the same converge, then both sponsord roles built from a fixture repo with `install_method: source`), `sponsord-onramp-lifecycle` (the same converge, then sponsord's backup decrypted and checked, decommission refused for a held top-up and a bad confirmation, then a real one), `iroh-relay` (the relay against a stub: restarts, -the fatal gate, the port guard), `iroh-relay-certificate` and `iroh-relay-gate` (the -same converge, then the certificate check; and the other gate refusals, QAD off, the -bind addresses and the access modes), `iroh-relay-install` and `iroh-relay-lifecycle` +the fatal gate, the port guard), `iroh-relay-certificate`, `iroh-relay-gate` and +`iroh-relay-access` (the same converge, then the certificate check; the other gate +refusals and QAD off; and the bind addresses and the access modes), +`iroh-relay-install` and `iroh-relay-lifecycle` (the same converge, then the release install and decommission), `iroh-dns-server` (the DNS server against a stub: restarts, the fatal gate, the port guard by address), `iroh-dns-server-certificate`, `iroh-dns-server-gate`, `iroh-dns-server-install` and diff --git a/ansible/molecule/iroh-relay-access/molecule.yml b/ansible/molecule/iroh-relay-access/molecule.yml new file mode 100644 index 00000000..6a6078c4 --- /dev/null +++ b/ansible/molecule/iroh-relay-access/molecule.yml @@ -0,0 +1,94 @@ +--- +# The relay's knobs, split from ../iroh-relay-gate (itself split from ../iroh-relay) +# so they run side by side: the same host shapes, inventory and converge (that +# scenario's prepare, converge and verify, by path), then side_effect.yml here: +# 0.0.0.0 and a specific IPv4 bind are rendered, bound and probed; the access modes, +# and every way the inventory read of node IDs fails. Two platforms: two nodes +# sharing an endpoint ID needs two hosts. +# +# The inventory below must stay identical to ../iroh-relay/molecule.yml's +# (`make test-scripts` checks); the stub is the one it names. +driver: + name: docker +platforms: + - name: relay-access-molecule + # Same digest-pinned images as ../sponsord (repo convention). + image: geerlingguy/docker-debian12-ansible@sha256:a131e3b7a9a38dda40e98b49b39d6200ffab0ad853c714a513f5eea41bab9a25 + pre_build_image: true + command: /usr/lib/systemd/systemd + privileged: true + cgroupns_mode: host + volumes: + - /sys/fs/cgroup:/sys/fs/cgroup:rw + groups: + - iroh_relay_hosts + # systemd 255: StateDirectory= and the ambient capability on a newer systemd. + - name: relay-access-molecule-u2404 + image: geerlingguy/docker-ubuntu2404-ansible@sha256:3394e335df58c961c00b78ae601ef30cdd7463b112f21b0c5f78674a70526302 + pre_build_image: true + command: /usr/lib/systemd/systemd + privileged: true + cgroupns_mode: host + volumes: + - /sys/fs/cgroup:/sys/fs/cgroup:rw + groups: + - iroh_relay_hosts +provisioner: + name: ansible + options: + skip-tags: baseline + env: + ANSIBLE_ROLES_PATH: "${MOLECULE_PROJECT_DIRECTORY}/roles" + ANSIBLE_COLLECTIONS_PATH: "${MOLECULE_PROJECT_DIRECTORY}/collections" + # Same pipelining note as ../default/molecule.yml. + ANSIBLE_PIPELINING: "true" + playbooks: + prepare: ../iroh-relay/prepare.yml + converge: ../iroh-relay/converge.yml + verify: ../iroh-relay/verify.yml + inventory: + group_vars: + iroh_relay_hosts: + iroh_relay_install_method: manual + iroh_relay_manual_bin_src: "${MOLECULE_PROJECT_DIRECTORY}/molecule/iroh-relay/files/iroh-relay-stub" + iroh_relay_hostname: relay.molecule.test + iroh_relay_acme_contact: ops@molecule.test + # A real ed25519 public key in iroh's hex form. + # The default allowlist mode: one ID listed here, plus the ID the stand-in + # `decdn` (prepare.yml) prints on each platform, every platform read as if + # it were one of the inventory's deCDN nodes (by every relay, across hosts). + iroh_relay_allowlist: + - 81d82536cb4c96ddbeab1f6bf4495d126c1942fb90b9ffb433d91d23314801e7 + iroh_relay_allowlist_node_hosts: "{{ groups['iroh_relay_hosts'] }}" + # Every optional key rendered, so the stub checks each one's shape. + iroh_relay_accept_conn_limit: 50 + iroh_relay_accept_conn_burst: 100 + iroh_relay_client_rx_bytes_per_second: 4194304 + iroh_relay_client_rx_max_burst_bytes: 8388608 + iroh_relay_log_level: info,iroh_relay=debug + iroh_relay_certificate_check: fail + iroh_relay_certificate_retries: 5 + iroh_relay_certificate_delay: 1 + iroh_relay_readiness_retries: 10 + iroh_relay_readiness_delay: 1 + # 1s, not the default 5: the side effects run the role dozens of times. The + # crash-after-bind case (../iroh-relay-gate) sets its own 5s. + iroh_relay_readiness_settle: 1 + decdn_grafana_cloud_enabled: true + grafana_alloy_install_method: manual + grafana_alloy_manual_bin_src: "${MOLECULE_PROJECT_DIRECTORY}/molecule/grafana-cloud/files/alloy-stub" + grafana_alloy_region: US + grafana_alloy_loki_url: https://logs-prod-xx.molecule.invalid/loki/api/v1/push +verifier: + name: ansible +scenario: + test_sequence: + # Leading destroy: see ../default/molecule.yml. No idempotence step: the + # converge is ../iroh-relay's, whose idempotence runs there. + - destroy + - create + - prepare + - converge + - side_effect + - verify + - destroy diff --git a/ansible/molecule/iroh-relay-access/side_effect.yml b/ansible/molecule/iroh-relay-access/side_effect.yml new file mode 100644 index 00000000..4285e547 --- /dev/null +++ b/ansible/molecule/iroh-relay-access/side_effect.yml @@ -0,0 +1,102 @@ +--- +# What a green converge + idempotence cannot prove, on ../iroh-relay's converge (split +# from ../iroh-relay-gate so the two run side by side): +# 5. 0.0.0.0 and a specific IPv4 address: rendered, bound, and probed there (the +# certificate check is "fail" in this inventory). +# 6. The allowlist: every relay reads every platform's node ID; a node with no +# identity, a failing `whoami`, an unreachable node, a host outside the +# inventory and two nodes sharing an ID each fail tasks_from node-ids; the +# read works in check mode; with the inventory read off only the listed IDs +# render; the "everyone" and "denylist" modes render as such. (A role run that +# skipped the read is refused: ../validation-iroh-relay.) Two nodes sharing an +# ID needs two hosts, hence this scenario's two platforms. +# The include files are ../iroh-relay's. The host is left as converged, with the +# molecule CA trusted, for verify (../iroh-relay's). +- name: Side effect — bind addresses, access modes + hosts: all + become: true + tasks: + # As playbooks/iroh_relay.yml does before the role: facts the converge run set + # do not carry over to this run. + - name: Read the inventory nodes' endpoint IDs, as the playbook does + ansible.builtin.include_role: + name: iroh_relay + tasks_from: node-ids + + # --- 5. non-wildcard bind addresses ------------------------------------------------ + # The certificate check is "fail" in this inventory, so a probe aimed at the + # wrong address fails these converges. + - name: Converge on each IPv4 bind address + ansible.builtin.include_tasks: ../iroh-relay/includes/bind-address.yml + loop: + - 0.0.0.0 + - "{{ ansible_facts['default_ipv4']['address'] }}" + loop_control: + loop_var: _ir_bind + + # --- 6. the access modes ----------------------------------------------------------- + - name: Read the node IDs while one stand-in node misbehaves + ansible.builtin.include_tasks: ../iroh-relay/includes/node-ids-fail.yml + loop: + - {marker: molecule-no-identity, failed_at: Require an endpoint ID from every deCDN node} + - {marker: molecule-whoami-fails, failed_at: Require an endpoint ID from every deCDN node} + - {marker: molecule-shared-id, failed_at: Refuse deCDN nodes that share an endpoint ID} + loop_control: + loop_var: _ir_case + + # A node that cannot be reached is reported by name, not as the relay host. + - name: Add a deCDN node nobody can reach + ansible.builtin.add_host: + name: molecule-unreachable-node + ansible_connection: ssh + ansible_host: 127.0.0.1 + ansible_port: 1 + ansible_ssh_common_args: -o ConnectTimeout=3 + + - name: Read the node IDs of an unreachable node and of a host outside the inventory + ansible.builtin.include_tasks: ../iroh-relay/includes/node-ids-fail.yml + loop: + - hosts: [molecule-unreachable-node] + failed_at: Require an endpoint ID from every deCDN node + - hosts: [molecule-not-an-inventory-host] + failed_at: Validate iroh_relay_allowlist_node_hosts + loop_control: + loop_var: _ir_case + + - name: Read the inventory nodes' IDs in check mode + ansible.builtin.include_role: + name: iroh_relay + tasks_from: node-ids + apply: + check_mode: true + + - name: Require the check-mode read to have read every node + ansible.builtin.assert: + that: + - _iroh_relay_inventory_node_ids_read + - _iroh_relay_inventory_node_ids | sort == groups['iroh_relay_hosts'] | map('hash', 'sha256') | sort + fail_msg: "Check mode read {{ _iroh_relay_inventory_node_ids }}." + + - name: Converge in each other access mode + ansible.builtin.include_tasks: ../iroh-relay/includes/access-mode.yml + loop: + - {access: everyone, allowlist: [], denylist: [], want: '"everyone"'} + # The inventory read off: only the listed ID, none of the nodes'. + - access: allowlist + allowlist: [81d82536cb4c96ddbeab1f6bf4495d126c1942fb90b9ffb433d91d23314801e7] + denylist: [] + from_inventory: false + want: '{ allowlist = ["81d82536cb4c96ddbeab1f6bf4495d126c1942fb90b9ffb433d91d23314801e7"] }' + - access: denylist + allowlist: [] + denylist: [08ebd644cd0bd76975a53e7ff96b1eacd14dd4f61672f3f54c7c8bb19fbf2a08] + want: '{ denylist = ["08ebd644cd0bd76975a53e7ff96b1eacd14dd4f61672f3f54c7c8bb19fbf2a08"] }' + loop_control: + loop_var: _ir_mode + + - name: Converge back on the inventory's settings + ansible.builtin.include_role: + name: iroh_relay + + - name: Check the relay is back on a current record + ansible.builtin.include_tasks: ../iroh-relay/includes/record-is-current.yml diff --git a/ansible/molecule/iroh-relay-gate/molecule.yml b/ansible/molecule/iroh-relay-gate/molecule.yml index 8ab6b7e7..bed2bb8d 100644 --- a/ansible/molecule/iroh-relay-gate/molecule.yml +++ b/ansible/molecule/iroh-relay-gate/molecule.yml @@ -1,13 +1,12 @@ --- -# The relay's other gate refusals and its knobs, split from ../iroh-relay so the two -# run side by side: the same host shapes, inventory and converge (that scenario's -# prepare, converge and verify, by path), then side_effect.yml here: a relay that -# skips its QUIC socket or crashes after binding FAILS the gate; the port guard -# passes the unit's own listeners while it restarts or has no MainPID; a missing -# record restarts a running relay; QAD off renders and binds no udp/7842, and a -# squatter there is refused only with QAD on; 0.0.0.0 and a specific IPv4 bind are -# rendered, bound and probed; the access modes, and every way the inventory read of -# node IDs fails. Two platforms: two nodes sharing an endpoint ID needs two hosts. +# The relay's other gate refusals, split from ../iroh-relay so the two run side by +# side: the same host shapes, inventory and converge (that scenario's prepare, +# converge and verify, by path), then side_effect.yml here: a relay that skips its +# QUIC socket or crashes after binding FAILS the gate; the port guard passes the +# unit's own listeners while it restarts or has no MainPID; a missing record +# restarts a running relay; QAD off renders and binds no udp/7842, and a squatter +# there is refused only with QAD on. Bind addresses and the access modes are +# ../iroh-relay-access, on the same two platforms. # # The inventory below must stay identical to ../iroh-relay/molecule.yml's # (`make test-scripts` checks); the stub is the one it names. diff --git a/ansible/molecule/iroh-relay-gate/side_effect.yml b/ansible/molecule/iroh-relay-gate/side_effect.yml index 78091bc7..107fde40 100644 --- a/ansible/molecule/iroh-relay-gate/side_effect.yml +++ b/ansible/molecule/iroh-relay-gate/side_effect.yml @@ -8,18 +8,10 @@ # 3. A running relay whose record is gone is restarted and recorded again. # 4. QAD off: rendered off, no udp/7842 listener, and another process on udp/7842 # is no conflict, until QAD is on again (the port guard's UDP half). -# 5. 0.0.0.0 and a specific IPv4 address: rendered, bound, and probed there (the -# certificate check is "fail" in this inventory). -# 6. The allowlist: every relay reads every platform's node ID; a node with no -# identity, a failing `whoami`, an unreachable node, a host outside the -# inventory and two nodes sharing an ID each fail tasks_from node-ids; the -# read works in check mode; with the inventory read off only the listed IDs -# render; the "everyone" and "denylist" modes render as such. (A role run that -# skipped the read is refused: ../validation-iroh-relay.) Two nodes sharing an -# ID needs two hosts, hence this scenario's two platforms. -# The include files are ../iroh-relay's. The host is left as converged, with the -# molecule CA trusted, for verify (../iroh-relay's). -- name: Side effect — gate variants, QAD off, bind addresses, access modes +# Bind addresses and the access modes are ../iroh-relay-access. The include files +# are ../iroh-relay's. The host is left as converged, with the molecule CA trusted, +# for verify (../iroh-relay's). +- name: Side effect — gate variants, QAD off hosts: all become: true tasks: @@ -324,77 +316,6 @@ state: stopped failed_when: false - # --- 5. non-wildcard bind addresses ------------------------------------------------ - # The certificate check is "fail" in this inventory, so a probe aimed at the - # wrong address fails these converges. - - name: Converge on each IPv4 bind address - ansible.builtin.include_tasks: ../iroh-relay/includes/bind-address.yml - loop: - - 0.0.0.0 - - "{{ ansible_facts['default_ipv4']['address'] }}" - loop_control: - loop_var: _ir_bind - - # --- 6. the access modes ----------------------------------------------------------- - - name: Read the node IDs while one stand-in node misbehaves - ansible.builtin.include_tasks: ../iroh-relay/includes/node-ids-fail.yml - loop: - - {marker: molecule-no-identity, failed_at: Require an endpoint ID from every deCDN node} - - {marker: molecule-whoami-fails, failed_at: Require an endpoint ID from every deCDN node} - - {marker: molecule-shared-id, failed_at: Refuse deCDN nodes that share an endpoint ID} - loop_control: - loop_var: _ir_case - - # A node that cannot be reached is reported by name, not as the relay host. - - name: Add a deCDN node nobody can reach - ansible.builtin.add_host: - name: molecule-unreachable-node - ansible_connection: ssh - ansible_host: 127.0.0.1 - ansible_port: 1 - ansible_ssh_common_args: -o ConnectTimeout=3 - - - name: Read the node IDs of an unreachable node and of a host outside the inventory - ansible.builtin.include_tasks: ../iroh-relay/includes/node-ids-fail.yml - loop: - - hosts: [molecule-unreachable-node] - failed_at: Require an endpoint ID from every deCDN node - - hosts: [molecule-not-an-inventory-host] - failed_at: Validate iroh_relay_allowlist_node_hosts - loop_control: - loop_var: _ir_case - - - name: Read the inventory nodes' IDs in check mode - ansible.builtin.include_role: - name: iroh_relay - tasks_from: node-ids - apply: - check_mode: true - - - name: Require the check-mode read to have read every node - ansible.builtin.assert: - that: - - _iroh_relay_inventory_node_ids_read - - _iroh_relay_inventory_node_ids | sort == groups['iroh_relay_hosts'] | map('hash', 'sha256') | sort - fail_msg: "Check mode read {{ _iroh_relay_inventory_node_ids }}." - - - name: Converge in each other access mode - ansible.builtin.include_tasks: ../iroh-relay/includes/access-mode.yml - loop: - - {access: everyone, allowlist: [], denylist: [], want: '"everyone"'} - # The inventory read off: only the listed ID, none of the nodes'. - - access: allowlist - allowlist: [81d82536cb4c96ddbeab1f6bf4495d126c1942fb90b9ffb433d91d23314801e7] - denylist: [] - from_inventory: false - want: '{ allowlist = ["81d82536cb4c96ddbeab1f6bf4495d126c1942fb90b9ffb433d91d23314801e7"] }' - - access: denylist - allowlist: [] - denylist: [08ebd644cd0bd76975a53e7ff96b1eacd14dd4f61672f3f54c7c8bb19fbf2a08] - want: '{ denylist = ["08ebd644cd0bd76975a53e7ff96b1eacd14dd4f61672f3f54c7c8bb19fbf2a08"] }' - loop_control: - loop_var: _ir_mode - - name: Converge back on the inventory's settings ansible.builtin.include_role: name: iroh_relay diff --git a/ansible/molecule/iroh-relay/molecule.yml b/ansible/molecule/iroh-relay/molecule.yml index 09845c45..e0c94fc9 100644 --- a/ansible/molecule/iroh-relay/molecule.yml +++ b/ansible/molecule/iroh-relay/molecule.yml @@ -20,8 +20,9 @@ # # The other side effects run on the same converge, so they run side by side (their # inventories must stay identical to this one: `make test-scripts` checks): the -# certificate check in ../iroh-relay-certificate; the gate's other refusals, QAD off, -# the bind addresses and the access modes in ../iroh-relay-gate; the release install +# certificate check in ../iroh-relay-certificate; the gate's other refusals and QAD +# off in ../iroh-relay-gate; the bind addresses and the access modes in +# ../iroh-relay-access; the release install # (download, sha256 pin, stamp, version backstop) in ../iroh-relay-install against a # local mirror; and decommission in ../iroh-relay-lifecycle. The broken-input matrix lives in # ../validation-iroh-relay. `baseline` is skipped (real-host-only — see diff --git a/ansible/molecule/iroh-relay/side_effect.yml b/ansible/molecule/iroh-relay/side_effect.yml index b7af29fb..6463b288 100644 --- a/ansible/molecule/iroh-relay/side_effect.yml +++ b/ansible/molecule/iroh-relay/side_effect.yml @@ -12,8 +12,8 @@ # as playbooks/iroh_relay.yml does: facts the converge run set do not carry over to # this run. # The certificate check runs in ../iroh-relay-certificate, the gate's other -# refusals, QAD off, the bind addresses and the access modes in ../iroh-relay-gate, -# and decommission in ../iroh-relay-lifecycle. The host is left as converged, with +# refusals and QAD off in ../iroh-relay-gate, the bind addresses and the access modes +# in ../iroh-relay-access, and decommission in ../iroh-relay-lifecycle. The host is left as converged, with # the molecule CA trusted, for verify. - name: Side effect — restarts, the fatal gate, the port guard, no config hosts: all diff --git a/ansible/molecule/sponsord-install/molecule.yml b/ansible/molecule/sponsord-install/molecule.yml index dff86e14..cac29c8e 100644 --- a/ansible/molecule/sponsord-install/molecule.yml +++ b/ansible/molecule/sponsord-install/molecule.yml @@ -4,11 +4,11 @@ # prepare ../sponsord's staging plus a signed release mirror on loopback; # side_effect release mode against that mirror (verified install, no re-download, # bad checksum / signature / version each failing at its own task, -# manual clearing the stamp), then treasury wallet generation, -# then secret.env's provenance guards. +# manual clearing the stamp). +# Treasury wallet generation and secret.env's provenance guards are ../sponsord-wallet. # No idempotence step (../sponsord runs it on the identical converge) and no verify: -# ../sponsord/verify.yml checks state its own side effect records, and each play -# here asserts its own outcome and leaves the host as converged. +# ../sponsord/verify.yml checks state its own side effect records, and the play here +# asserts its own outcome and leaves the host as converged. # # The inventory below must stay identical to ../sponsord/molecule.yml's # (`make test-scripts` checks). diff --git a/ansible/molecule/sponsord-install/side_effect.yml b/ansible/molecule/sponsord-install/side_effect.yml index 6ad7f8f9..e6479f8f 100644 --- a/ansible/molecule/sponsord-install/side_effect.yml +++ b/ansible/molecule/sponsord-install/side_effect.yml @@ -1,22 +1,12 @@ --- # Split from ../sponsord/side_effect.yml so the two run side by side, on the same -# converge (../sponsord/converge.yml, by path): -# 1. release mode against prepare.yml's signed loopback mirror: a verified -# install writes the version stamp; a re-run needs no download; a bad -# checksum, a bad signature and a binary that is not the pinned version all -# fail at their own task and leave no stamp; a release line installs its newest -# finished release; manual mode clears the stamp. -# 2. sponsord_generate_treasury_wallet: with no keystore, the role mints one on -# the host through the decdn CLI (the default scenario's key-gen stub), records -# the address and stops at its pool-id step; a re-run keeps that wallet; with a -# pool id the daemon starts on it. -# 3. secret.env's two homes and the provenance guards between them: an empty -# sponsord_rpc_url must not adopt the role's own file; a handed-over file is -# recorded `host` and locked to 0600; a host edit restarts the daemon; an -# inventory URL must not overwrite the host's file until confirmed. -# Each leaves the host healthy on the manual install, the prepared treasury files -# and the inventory-authored secret.env. -# The later plays reuse the first's facts (gather_facts: false). +# converge (../sponsord/converge.yml, by path): release mode against prepare.yml's +# signed loopback mirror. A verified install writes the version stamp; a re-run +# needs no download; a bad checksum, a bad signature and a binary that is not the +# pinned version all fail at their own task and leave no stamp; a release line +# installs its newest finished release; manual mode clears the stamp. It leaves the +# host healthy on the manual install. Treasury wallet generation and secret.env's +# provenance guards, which need no mirror, are ../sponsord-wallet. - name: Side effect — release mode against the signed loopback mirror hosts: all become: true @@ -267,321 +257,3 @@ ansible.builtin.assert: that: - not _sp_stamp_manual.stat.exists - -- name: Side effect — treasury wallet generation - hosts: all - become: true - gather_facts: false - vars: - # The default scenario's node stub implements `key-gen` (dummy keystore.json, a - # zero eth address on stdout). - _sp_cli_stub: "{{ playbook_dir }}/../default/files/decdn-node-stub" - tasks: - - name: Set the prepared treasury files aside - ansible.builtin.command: - argv: [mv, "/etc/sponsord/{{ item }}", "/root/molecule-{{ item }}"] - creates: "/root/molecule-{{ item }}" - loop: [treasury-keystore.json, treasury-password] - - - name: Converge with no wallet and no pool id - block: - - name: Run the role in wallet-creation mode - ansible.builtin.include_role: - name: sponsord - vars: - sponsord_generate_treasury_wallet: true - sponsord_decdn_cli_bin_src: "{{ _sp_cli_stub }}" - sponsord_pool_id: "" - - - name: Fail — the wallet-creation run did not stop - ansible.builtin.fail: - msg: The role finished with an empty sponsord_pool_id. - rescue: - - name: Require the stop to come from the pool-id step - ansible.builtin.assert: - that: - - ansible_failed_task.name == 'Stop until the treasury has opened its pool' - - "'0x0000000000000000000000000000000000000000' in ansible_failed_result.msg" - fail_msg: "Expected the pool-id stop, got \"{{ ansible_failed_task.name }}\"." - - - name: Stat the generated wallet, its address and the CLI - ansible.builtin.stat: - path: "{{ item }}" - follow: false - checksum_algorithm: sha256 - register: _sp_wallet - loop: - - /etc/sponsord/treasury-keystore.json - - /etc/sponsord/treasury-password - - /etc/sponsord/treasury-address - - /usr/local/lib/sponsord/decdn - - - name: Read the generated password and address - ansible.builtin.slurp: - src: "{{ item }}" - register: _sp_wallet_content - loop: - - /etc/sponsord/treasury-password - - /etc/sponsord/treasury-address - - - name: Find key-gen scratch directories left behind - ansible.builtin.find: - paths: /etc/sponsord - patterns: ".keygen.*" - file_type: directory - hidden: true - register: _sp_keygen_left - - - name: Require the wallet files, modes and contents - ansible.builtin.assert: - that: - - _sp_wallet.results[0].stat.isreg and _sp_wallet.results[0].stat.mode == '0600' - - _sp_wallet.results[1].stat.isreg and _sp_wallet.results[1].stat.mode == '0600' - - _sp_wallet.results[2].stat.isreg and _sp_wallet.results[2].stat.mode == '0644' - - _sp_wallet.results[3].stat.isreg and _sp_wallet.results[3].stat.mode == '0755' - - _sp_wallet_content.results[0].content | b64decode is match('^[0-9a-f]{64}\Z') - - (_sp_wallet_content.results[1].content | b64decode) == '0x0000000000000000000000000000000000000000\n' - - _sp_keygen_left.matched == 0 - fail_msg: >- - The generated wallet is not as expected: - {{ _sp_wallet.results | map(attribute='stat') | map(attribute='mode', default='absent') | list }}, - {{ _sp_keygen_left.matched }} scratch dir(s) left behind. - - - name: Converge in wallet-creation mode again - block: - - name: Run the role with the wallet already present - ansible.builtin.include_role: - name: sponsord - vars: - sponsord_generate_treasury_wallet: true - sponsord_decdn_cli_bin_src: "{{ _sp_cli_stub }}" - sponsord_pool_id: "" - - - name: Fail — the second wallet-creation run did not stop - ansible.builtin.fail: - msg: The role finished with an empty sponsord_pool_id. - rescue: - - name: Require the second stop to come from the pool-id step - ansible.builtin.assert: - that: - - ansible_failed_task.name == 'Stop until the treasury has opened its pool' - fail_msg: "Expected the pool-id stop, got \"{{ ansible_failed_task.name }}\"." - - - name: Re-stat the keystore after the second run - ansible.builtin.stat: - path: /etc/sponsord/treasury-keystore.json - checksum_algorithm: sha256 - register: _sp_wallet_again - - - name: Require the existing wallet to be kept - ansible.builtin.assert: - that: - - _sp_wallet_again.stat.checksum == _sp_wallet.results[0].stat.checksum - fail_msg: A second wallet-creation run replaced an existing keystore. - - # The scenario's pool id: the stub daemon accepts any JSON keystore. - - name: Converge with the pool id set - ansible.builtin.include_role: - name: sponsord - vars: - sponsord_generate_treasury_wallet: true - sponsord_decdn_cli_bin_src: "{{ _sp_cli_stub }}" - - - name: Read the unit state on the generated wallet - ansible.builtin.systemd_service: - name: sponsord - register: _sp_wallet_unit - - - name: Require sponsord running on the generated wallet - ansible.builtin.assert: - that: - - _sp_wallet_unit.status.ActiveState == 'active' - fail_msg: "sponsord is {{ _sp_wallet_unit.status.ActiveState }} on the generated wallet." - - - name: Remove the generated wallet, its address and the CLI - ansible.builtin.file: - path: "{{ item }}" - state: absent - loop: - - /etc/sponsord/treasury-keystore.json - - /etc/sponsord/treasury-password - - /etc/sponsord/treasury-address - - /usr/local/lib/sponsord/decdn - - - name: Put the prepared treasury files back - ansible.builtin.command: - argv: [mv, "/root/molecule-{{ item }}", "/etc/sponsord/{{ item }}"] - creates: "/etc/sponsord/{{ item }}" - loop: [treasury-keystore.json, treasury-password] - - - name: Converge back onto the prepared wallet - ansible.builtin.include_role: - name: sponsord - -- name: Side effect — secret.env provenance - hosts: all - become: true - gather_facts: false - vars: - _sp_env: /etc/sponsord/secret.env - _sp_rec: /etc/sponsord/.secret.env.sha256 - tasks: - # 1. secret.yml "went missing": the file is byte-for-byte the role's own. - - name: Hash secret.env before the adoption attempt - ansible.builtin.stat: - path: "{{ _sp_env }}" - checksum_algorithm: sha256 - register: _sp_env_before - - - name: Run with no inventory RPC URL against the role-authored file - block: - - name: Run the role with sponsord_rpc_url empty - ansible.builtin.include_role: - name: sponsord - vars: - sponsord_rpc_url: "" - - - name: Fail — the role adopted its own file as host-provisioned - ansible.builtin.fail: - msg: An empty sponsord_rpc_url silently adopted the role-authored secret.env. - rescue: - - name: Require the adoption guard to be what stopped it - ansible.builtin.assert: - that: - - ansible_failed_task.name == 'Refuse to adopt a role-authored secret.env as host-provisioned' - fail_msg: "Expected the adoption guard, got \"{{ ansible_failed_task.name }}\"." - - - name: Hash secret.env after the refused adoption - ansible.builtin.stat: - path: "{{ _sp_env }}" - checksum_algorithm: sha256 - register: _sp_env_after - - - name: Require the refused run to leave secret.env untouched - ansible.builtin.assert: - that: - - _sp_env_after.stat.checksum == _sp_env_before.stat.checksum - - # 2. A deliberate handover: drop the record, loosen the mode. The role must take - # the file as the operator's, lock it down, and owe no restart (same bytes). - - name: Read the unit state before the handover - ansible.builtin.systemd_service: - name: sponsord - register: _sp_unit_a - - - name: Hand secret.env to the host (discard the record, loosen the mode) - ansible.builtin.shell: - cmd: rm -f {{ _sp_rec }} && chmod 0644 {{ _sp_env }} - changed_when: true - - - name: Converge with the host-provisioned secret.env - ansible.builtin.include_role: - name: sponsord - vars: - sponsord_rpc_url: "" - - - name: Read secret.env, its record and the unit after the handover - ansible.builtin.stat: - path: "{{ _sp_env }}" - checksum_algorithm: sha256 - register: _sp_env_host - - - name: Read the provenance record after the handover - ansible.builtin.slurp: - src: "{{ _sp_rec }}" - register: _sp_rec_host - - - name: Read the unit state after the handover - ansible.builtin.systemd_service: - name: sponsord - register: _sp_unit_b - - - name: Require a locked, host-recorded file and no restart - ansible.builtin.assert: - that: - - _sp_env_host.stat.mode == '0600' - - (_sp_rec_host.content | b64decode).split() == ['host', _sp_env_host.stat.checksum] - - _sp_unit_b.status.MainPID == _sp_unit_a.status.MainPID - fail_msg: >- - Expected secret.env 0600 (got {{ _sp_env_host.stat.mode }}), record - "host {{ _sp_env_host.stat.checksum }}" (got "{{ _sp_rec_host.content | b64decode | trim }}") - and no restart (PID {{ _sp_unit_a.status.MainPID }} -> {{ _sp_unit_b.status.MainPID }}). - - # 3. The operator edits the host file: the next converge must restart onto it. - - name: Edit secret.env on the host - ansible.builtin.lineinfile: - path: "{{ _sp_env }}" - line: "# molecule host edit" - mode: "0600" - - - name: Converge after the host edit - ansible.builtin.include_role: - name: sponsord - vars: - sponsord_rpc_url: "" - - - name: Read the unit state after the host edit - ansible.builtin.systemd_service: - name: sponsord - register: _sp_unit_c - - - name: Require the host edit to have restarted sponsord - ansible.builtin.assert: - that: - - _sp_unit_c.status.ActiveState == 'active' - - _sp_unit_c.status.MainPID != _sp_unit_b.status.MainPID - - # 4. An inventory URL against the host's (edited) file: refused, file kept. - - name: Run with the inventory URL against the host's file - block: - - name: Run the role with the inventory URL (group_vars) - ansible.builtin.include_role: - name: sponsord - - - name: Fail — the role overwrote the host's file unconfirmed - ansible.builtin.fail: - msg: An inventory sponsord_rpc_url overwrote a host-provisioned secret.env without confirmation. - rescue: - - name: Require the overwrite guard to be what stopped it - ansible.builtin.assert: - that: - - ansible_failed_task.name == 'Require confirmation to overwrite a secret.env this role did not write' - fail_msg: "Expected the overwrite guard, got \"{{ ansible_failed_task.name }}\"." - - - name: Require the host edit to survive the refused overwrite - ansible.builtin.command: - cmd: grep -qx '# molecule host edit' {{ _sp_env }} - changed_when: false - - # 5. Confirmed: the role takes the file back and restarts onto it. - - name: Converge with the overwrite confirmed - ansible.builtin.include_role: - name: sponsord - vars: - sponsord_secret_env_overwrite_host_file: true - - - name: Read the provenance record after the takeover - ansible.builtin.slurp: - src: "{{ _sp_rec }}" - register: _sp_rec_inv - - - name: Read the unit state after the takeover - ansible.builtin.systemd_service: - name: sponsord - register: _sp_unit_d - - - name: Require an inventory record, the edit gone and a restart - ansible.builtin.assert: - that: - - (_sp_rec_inv.content | b64decode).split()[0] == 'inventory' - - _sp_unit_d.status.MainPID != _sp_unit_c.status.MainPID - fail_msg: >- - Expected record source "inventory" (got - "{{ _sp_rec_inv.content | b64decode | trim }}") and a restart. - - - name: Require the host edit to be gone - ansible.builtin.command: - cmd: grep -qx '# molecule host edit' {{ _sp_env }} - register: _sp_edit_gone - changed_when: false - failed_when: _sp_edit_gone.rc != 1 diff --git a/ansible/molecule/sponsord-onramp-nginx/molecule.yml b/ansible/molecule/sponsord-onramp-nginx/molecule.yml index aa734dab..6d562dab 100644 --- a/ansible/molecule/sponsord-onramp-nginx/molecule.yml +++ b/ansible/molecule/sponsord-onramp-nginx/molecule.yml @@ -7,8 +7,8 @@ # role did not enable (refused, left untouched), the role's earlier Caddy retired # (a marked Caddyfile and a stand-in caddy unit, gone after a converge), ACME # against a server that does not answer (the issuance fails the run, nginx keeps -# serving http); then a custom gate page, and the Turnstile secret -# from inventory (both from ../sponsord-onramp, to keep the two jobs short). +# serving http). The custom gate page and the Turnstile secret from inventory run +# in ../sponsord-onramp-turnstile. # No idempotence step: ../sponsord-onramp runs it on the identical converge. # # The inventory below must stay identical to ../sponsord-onramp/molecule.yml's diff --git a/ansible/molecule/sponsord-onramp-nginx/side_effect.yml b/ansible/molecule/sponsord-onramp-nginx/side_effect.yml index 1be1b9ca..b78027d7 100644 --- a/ansible/molecule/sponsord-onramp-nginx/side_effect.yml +++ b/ansible/molecule/sponsord-onramp-nginx/side_effect.yml @@ -14,12 +14,8 @@ # the renewal timer keeps running. # 6. ACME with the certificate already issued for that server and domain: the # converge serves it over https without running lego, and leaves no force. -# And two that need no proxy change, here so both scenarios stay short: -# 7. A custom gate page is installed, read by the onramp, and removed again. -# 8. The Turnstile secret from inventory: it replaces a host-written file only when -# confirmed, lands verbatim at 0600 with an `inventory` record and restarts the -# onramp; an unchanged re-run is a no-op; an emptied value never adopts the -# role's own file; with the record discarded the host's file is accepted. +# The custom gate page and the Turnstile secret, which need no proxy change, run in +# ../sponsord-onramp-turnstile. # Order matters: 1 leaves the host in proxy "none" mode and 2's first converge puts # nginx back. Every later section converges back onto the self-signed certificate, # so the host is left healthy on the role's own site for verify. Plays after the @@ -414,181 +410,3 @@ - _or_timer.status.ActiveState == 'inactive' - _or_timer.status.UnitFileState == 'disabled' fail_msg: "{{ _or_timer.status.ActiveState }}/{{ _or_timer.status.UnitFileState }}" - -- name: Side effect — a custom gate page - hosts: all - become: true - gather_facts: false - vars: - _or_page: >- - {{ lookup('ansible.builtin.env', 'MOLECULE_PROJECT_DIRECTORY') }}/molecule/sponsord-onramp/files/gate.html - tasks: - - name: Converge with a custom gate page - ansible.builtin.include_role: - name: sponsord_onramp - vars: - sponsord_onramp_gate_template_src: "{{ _or_page }}" - - - name: Stat the installed page and read the env file - ansible.builtin.stat: - path: /etc/sponsord/onramp-gate.html - register: _or_page_stat - - - name: Read the env file with the page set - ansible.builtin.slurp: - src: /etc/sponsord/sponsord-onramp.env - register: _or_page_env - - # The stub reads the page at start, as the onramp's dynamic user: the role's - # /healthz gate passing already proves it was readable. - - name: Require the page installed root-owned, world-readable and configured - ansible.builtin.assert: - that: - - _or_page_stat.stat.isreg - - _or_page_stat.stat.mode == '0644' - - _or_page_stat.stat.pw_name == 'root' - - "'ONRAMP_GATE_TEMPLATE=/etc/sponsord/onramp-gate.html' in (_or_page_env.content | b64decode)" - fail_msg: The custom gate page is not installed, or not configured. - - - name: Converge without the page again - ansible.builtin.include_role: - name: sponsord_onramp - - - name: Stat the page after it was dropped - ansible.builtin.stat: - path: /etc/sponsord/onramp-gate.html - register: _or_page_gone - - - name: Read the env file without the page - ansible.builtin.slurp: - src: /etc/sponsord/sponsord-onramp.env - register: _or_page_env_gone - - - name: Require the page and its setting gone - ansible.builtin.assert: - that: - - not _or_page_gone.stat.exists - - "'ONRAMP_GATE_TEMPLATE' not in (_or_page_env_gone.content | b64decode)" - fail_msg: The dropped gate page is still installed or configured. - -- name: Side effect — the Turnstile secret from inventory - hosts: all - become: true - gather_facts: false - vars: - _or_ts: /etc/sponsord/turnstile-secret - _or_ts_record: /etc/sponsord/.turnstile-secret.sha256 - _or_ts_value: 1x0000000000000000000000000000000DD - tasks: - - name: Converge with an inventory secret over the host's file, unconfirmed - block: - - name: Run the role with sponsord_onramp_turnstile_secret set - ansible.builtin.include_role: - name: sponsord_onramp - vars: - sponsord_onramp_turnstile_secret: "{{ _or_ts_value }}" - - - name: Fail — the role overwrote a host-provisioned Turnstile secret - ansible.builtin.fail: - msg: The role replaced a Turnstile secret it did not write, unconfirmed. - rescue: - - name: Require the failure to come from the overwrite guard - ansible.builtin.assert: - that: - - >- - ansible_failed_task.name - == 'Require confirmation to overwrite a Turnstile secret this role did not write' - fail_msg: "Expected the overwrite guard, got \"{{ ansible_failed_task.name }}\"." - - - name: Read the onramp before the takeover - ansible.builtin.systemd_service: - name: sponsord-onramp - register: _or_ts_before - - - name: Converge with the takeover confirmed - ansible.builtin.include_role: - name: sponsord_onramp - vars: - sponsord_onramp_turnstile_secret: "{{ _or_ts_value }}" - sponsord_onramp_turnstile_secret_overwrite_host_file: true - - - name: Read the secret, its record and the onramp after the takeover - ansible.builtin.slurp: - src: "{{ item }}" - register: _or_ts_files - loop: ["{{ _or_ts }}", "{{ _or_ts_record }}"] - - - name: Stat the secret after the takeover - ansible.builtin.stat: - path: "{{ _or_ts }}" - register: _or_ts_stat - - - name: Read the onramp after the takeover - ansible.builtin.systemd_service: - name: sponsord-onramp - register: _or_ts_after - - - name: Require the inventory secret written, recorded and picked up - ansible.builtin.assert: - that: - - (_or_ts_files.results[0].content | b64decode) == _or_ts_value - - (_or_ts_files.results[1].content | b64decode) is match('^inventory [0-9a-f]{64}\s*$') - - _or_ts_stat.stat.mode == '0600' - - _or_ts_after.status.ActiveState == 'active' - - _or_ts_after.status.MainPID != _or_ts_before.status.MainPID - fail_msg: >- - The inventory secret was not written verbatim at 0600 with an `inventory` - record, or the onramp did not restart onto it. - - - name: Re-run with the same inventory secret, unconfirmed - ansible.builtin.include_role: - name: sponsord_onramp - vars: - sponsord_onramp_turnstile_secret: "{{ _or_ts_value }}" - - - name: Read the onramp after the re-run - ansible.builtin.systemd_service: - name: sponsord-onramp - register: _or_ts_rerun - - - name: Require the re-run to leave the role's own file and the onramp alone - ansible.builtin.assert: - that: - - _or_ts_rerun.status.MainPID == _or_ts_after.status.MainPID - fail_msg: Re-running with an unchanged inventory secret restarted the onramp. - - - name: Converge with the inventory secret gone - block: - - name: Run the role with sponsord_onramp_turnstile_secret empty - ansible.builtin.include_role: - name: sponsord_onramp - - - name: Fail — the role adopted its own file as host-provisioned - ansible.builtin.fail: - msg: The role adopted a file it wrote from inventory as the host's. - rescue: - - name: Require the failure to come from the adoption guard - ansible.builtin.assert: - that: - - ansible_failed_task.name == 'Refuse to adopt a role-written Turnstile secret as host-provisioned' - fail_msg: "Expected the adoption guard, got \"{{ ansible_failed_task.name }}\"." - - - name: Hand the secret to the host (discard the record) - ansible.builtin.file: - path: "{{ _or_ts_record }}" - state: absent - - - name: Converge with the host-provisioned secret - ansible.builtin.include_role: - name: sponsord_onramp - - - name: Read the record after the handover - ansible.builtin.slurp: - src: "{{ _or_ts_record }}" - register: _or_ts_handover - - - name: Require a `host` record after the handover - ansible.builtin.assert: - that: - - (_or_ts_handover.content | b64decode) is match('^host [0-9a-f]{64}\s*$') - fail_msg: The handed-over secret was not recorded as the host's. diff --git a/ansible/molecule/sponsord-onramp-turnstile/molecule.yml b/ansible/molecule/sponsord-onramp-turnstile/molecule.yml new file mode 100644 index 00000000..8a604946 --- /dev/null +++ b/ansible/molecule/sponsord-onramp-turnstile/molecule.yml @@ -0,0 +1,101 @@ +--- +# The onramp's human gate, split from ../sponsord-onramp-nginx (itself split from +# ../sponsord-onramp) so the three run side by side: the same host shape, inventory +# and converge (../sponsord-onramp's prepare, converge and verify, by path), then +# side_effect.yml here: a custom gate page, and the Turnstile secret from inventory. +# No idempotence step: ../sponsord-onramp runs it on the identical converge. +# +# The inventory below must stay identical to ../sponsord-onramp/molecule.yml's +# (`make test-scripts` checks); the stubs are that scenario's files. +driver: + name: docker +platforms: + - name: onramp-turnstile-molecule + # Same digest-pinned images as ../sponsord (repo convention). + image: geerlingguy/docker-debian12-ansible@sha256:a131e3b7a9a38dda40e98b49b39d6200ffab0ad853c714a513f5eea41bab9a25 + pre_build_image: true + command: /usr/lib/systemd/systemd + privileged: true + cgroupns_mode: host + volumes: + - /sys/fs/cgroup:/sys/fs/cgroup:rw + groups: + - sponsord_hosts + - sponsord_onramp_hosts + # systemd 255 writes LoadCredential= files 0440. + - name: onramp-turnstile-molecule-u2404 + image: geerlingguy/docker-ubuntu2404-ansible@sha256:3394e335df58c961c00b78ae601ef30cdd7463b112f21b0c5f78674a70526302 + pre_build_image: true + command: /usr/lib/systemd/systemd + privileged: true + cgroupns_mode: host + volumes: + - /sys/fs/cgroup:/sys/fs/cgroup:rw + groups: + - sponsord_hosts + - sponsord_onramp_hosts +provisioner: + name: ansible + options: + skip-tags: baseline + env: + ANSIBLE_ROLES_PATH: "${MOLECULE_PROJECT_DIRECTORY}/roles" + ANSIBLE_COLLECTIONS_PATH: "${MOLECULE_PROJECT_DIRECTORY}/collections" + # Same pipelining note as ../default/molecule.yml. + ANSIBLE_PIPELINING: "true" + playbooks: + prepare: ../sponsord-onramp/prepare.yml + converge: ../sponsord-onramp/converge.yml + verify: ../sponsord-onramp/verify.yml + inventory: + group_vars: + sponsord_hosts: + sponsord_install_method: manual + sponsord_manual_bin_src: "${MOLECULE_PROJECT_DIRECTORY}/molecule/sponsord/files/sponsord-stub" + # Not the binary's version: proves non-release installs are not held to the pin + # (only release mode compares it). + sponsord_version: "9.9.9-not-the-binary" + sponsord_network: arbitrum-sepolia + sponsord_pool_id: "0x00000000000000000000000000000000000000000000000000000000000000aa" + sponsord_rpc_url: "https://rpc.example.invalid/v2/molecule" + sponsord_readiness_retries: 10 + sponsord_readiness_delay: 1 + decdn_grafana_cloud_enabled: true + grafana_alloy_install_method: manual + grafana_alloy_manual_bin_src: "${MOLECULE_PROJECT_DIRECTORY}/molecule/grafana-cloud/files/alloy-stub" + grafana_alloy_region: US + grafana_alloy_loki_url: https://logs-prod-xx.molecule.invalid/loki/api/v1/push + sponsord_onramp_hosts: + sponsord_onramp_install_method: manual + sponsord_onramp_manual_bin_src: >- + ${MOLECULE_PROJECT_DIRECTORY}/molecule/sponsord-onramp/files/sponsord-onramp-stub + # Not the binary's version: proves non-release installs are not held to the pin + # (only release mode compares it). + sponsord_onramp_version: "9.9.9-not-the-binary" + # sponsord_onramp_network follows sponsord_network (arbitrum-sepolia). + sponsord_onramp_rpc_url: https://sepolia-rollup.arbitrum.io/rpc + sponsord_onramp_domain: onramp.molecule.test + sponsord_onramp_tls: self-signed + # Read only under acme, which ../sponsord-onramp-nginx's side effects switch to. + sponsord_onramp_acme_email: ops@molecule.test + sponsord_onramp_turnstile_sitekey: 1x00000000000000000000AA + # Must equal EXPECTED_BRAND in ../sponsord-onramp/files/sponsord-onramp-stub: + # quotes and a dollar sign prove the env file's quoting ($$ is molecule's escape). + sponsord_onramp_brand_name: 'deCDN "molecule" $$HOME' + sponsord_onramp_decdn_release: v9.9.9 + sponsord_onramp_decdn_sums_sha256: "1111111111111111111111111111111111111111111111111111111111111111" + sponsord_onramp_fund_rate_per_min: 0 + sponsord_onramp_readiness_retries: 10 + sponsord_onramp_readiness_delay: 1 +verifier: + name: ansible +scenario: + test_sequence: + # Leading destroy: see ../default/molecule.yml. + - destroy + - create + - prepare + - converge + - side_effect + - verify + - destroy diff --git a/ansible/molecule/sponsord-onramp-turnstile/side_effect.yml b/ansible/molecule/sponsord-onramp-turnstile/side_effect.yml new file mode 100644 index 00000000..ada28f98 --- /dev/null +++ b/ansible/molecule/sponsord-onramp-turnstile/side_effect.yml @@ -0,0 +1,189 @@ +--- +# Split from ../sponsord-onramp-nginx (itself split from ../sponsord-onramp) so the +# three run side by side; same converged host (../sponsord-onramp's prepare, converge +# and verify, by path). Two things a green converge cannot prove, neither a proxy +# change: +# 1. A custom gate page is installed, read by the onramp, and removed again. +# 2. The Turnstile secret from inventory: it replaces a host-written file only when +# confirmed, lands verbatim at 0600 with an `inventory` record and restarts the +# onramp; an unchanged re-run is a no-op; an emptied value never adopts the +# role's own file; with the record discarded the host's file is accepted. +# Each converges back onto the self-signed certificate, so the host is left healthy +# on the role's own site for verify. The second play reuses the first's facts +# (gather_facts: false). +- name: Side effect — a custom gate page + hosts: all + become: true + vars: + _or_page: >- + {{ lookup('ansible.builtin.env', 'MOLECULE_PROJECT_DIRECTORY') }}/molecule/sponsord-onramp/files/gate.html + tasks: + - name: Converge with a custom gate page + ansible.builtin.include_role: + name: sponsord_onramp + vars: + sponsord_onramp_gate_template_src: "{{ _or_page }}" + + - name: Stat the installed page and read the env file + ansible.builtin.stat: + path: /etc/sponsord/onramp-gate.html + register: _or_page_stat + + - name: Read the env file with the page set + ansible.builtin.slurp: + src: /etc/sponsord/sponsord-onramp.env + register: _or_page_env + + # The stub reads the page at start, as the onramp's dynamic user: the role's + # /healthz gate passing already proves it was readable. + - name: Require the page installed root-owned, world-readable and configured + ansible.builtin.assert: + that: + - _or_page_stat.stat.isreg + - _or_page_stat.stat.mode == '0644' + - _or_page_stat.stat.pw_name == 'root' + - "'ONRAMP_GATE_TEMPLATE=/etc/sponsord/onramp-gate.html' in (_or_page_env.content | b64decode)" + fail_msg: The custom gate page is not installed, or not configured. + + - name: Converge without the page again + ansible.builtin.include_role: + name: sponsord_onramp + + - name: Stat the page after it was dropped + ansible.builtin.stat: + path: /etc/sponsord/onramp-gate.html + register: _or_page_gone + + - name: Read the env file without the page + ansible.builtin.slurp: + src: /etc/sponsord/sponsord-onramp.env + register: _or_page_env_gone + + - name: Require the page and its setting gone + ansible.builtin.assert: + that: + - not _or_page_gone.stat.exists + - "'ONRAMP_GATE_TEMPLATE' not in (_or_page_env_gone.content | b64decode)" + fail_msg: The dropped gate page is still installed or configured. + +- name: Side effect — the Turnstile secret from inventory + hosts: all + become: true + gather_facts: false + vars: + _or_ts: /etc/sponsord/turnstile-secret + _or_ts_record: /etc/sponsord/.turnstile-secret.sha256 + _or_ts_value: 1x0000000000000000000000000000000DD + tasks: + - name: Converge with an inventory secret over the host's file, unconfirmed + block: + - name: Run the role with sponsord_onramp_turnstile_secret set + ansible.builtin.include_role: + name: sponsord_onramp + vars: + sponsord_onramp_turnstile_secret: "{{ _or_ts_value }}" + + - name: Fail — the role overwrote a host-provisioned Turnstile secret + ansible.builtin.fail: + msg: The role replaced a Turnstile secret it did not write, unconfirmed. + rescue: + - name: Require the failure to come from the overwrite guard + ansible.builtin.assert: + that: + - >- + ansible_failed_task.name + == 'Require confirmation to overwrite a Turnstile secret this role did not write' + fail_msg: "Expected the overwrite guard, got \"{{ ansible_failed_task.name }}\"." + + - name: Read the onramp before the takeover + ansible.builtin.systemd_service: + name: sponsord-onramp + register: _or_ts_before + + - name: Converge with the takeover confirmed + ansible.builtin.include_role: + name: sponsord_onramp + vars: + sponsord_onramp_turnstile_secret: "{{ _or_ts_value }}" + sponsord_onramp_turnstile_secret_overwrite_host_file: true + + - name: Read the secret, its record and the onramp after the takeover + ansible.builtin.slurp: + src: "{{ item }}" + register: _or_ts_files + loop: ["{{ _or_ts }}", "{{ _or_ts_record }}"] + + - name: Stat the secret after the takeover + ansible.builtin.stat: + path: "{{ _or_ts }}" + register: _or_ts_stat + + - name: Read the onramp after the takeover + ansible.builtin.systemd_service: + name: sponsord-onramp + register: _or_ts_after + + - name: Require the inventory secret written, recorded and picked up + ansible.builtin.assert: + that: + - (_or_ts_files.results[0].content | b64decode) == _or_ts_value + - (_or_ts_files.results[1].content | b64decode) is match('^inventory [0-9a-f]{64}\s*$') + - _or_ts_stat.stat.mode == '0600' + - _or_ts_after.status.ActiveState == 'active' + - _or_ts_after.status.MainPID != _or_ts_before.status.MainPID + fail_msg: >- + The inventory secret was not written verbatim at 0600 with an `inventory` + record, or the onramp did not restart onto it. + + - name: Re-run with the same inventory secret, unconfirmed + ansible.builtin.include_role: + name: sponsord_onramp + vars: + sponsord_onramp_turnstile_secret: "{{ _or_ts_value }}" + + - name: Read the onramp after the re-run + ansible.builtin.systemd_service: + name: sponsord-onramp + register: _or_ts_rerun + + - name: Require the re-run to leave the role's own file and the onramp alone + ansible.builtin.assert: + that: + - _or_ts_rerun.status.MainPID == _or_ts_after.status.MainPID + fail_msg: Re-running with an unchanged inventory secret restarted the onramp. + + - name: Converge with the inventory secret gone + block: + - name: Run the role with sponsord_onramp_turnstile_secret empty + ansible.builtin.include_role: + name: sponsord_onramp + + - name: Fail — the role adopted its own file as host-provisioned + ansible.builtin.fail: + msg: The role adopted a file it wrote from inventory as the host's. + rescue: + - name: Require the failure to come from the adoption guard + ansible.builtin.assert: + that: + - ansible_failed_task.name == 'Refuse to adopt a role-written Turnstile secret as host-provisioned' + fail_msg: "Expected the adoption guard, got \"{{ ansible_failed_task.name }}\"." + + - name: Hand the secret to the host (discard the record) + ansible.builtin.file: + path: "{{ _or_ts_record }}" + state: absent + + - name: Converge with the host-provisioned secret + ansible.builtin.include_role: + name: sponsord_onramp + + - name: Read the record after the handover + ansible.builtin.slurp: + src: "{{ _or_ts_record }}" + register: _or_ts_handover + + - name: Require a `host` record after the handover + ansible.builtin.assert: + that: + - (_or_ts_handover.content | b64decode) is match('^host [0-9a-f]{64}\s*$') + fail_msg: The handed-over secret was not recorded as the host's. diff --git a/ansible/molecule/sponsord-onramp/molecule.yml b/ansible/molecule/sponsord-onramp/molecule.yml index 51e842ed..ffc06687 100644 --- a/ansible/molecule/sponsord-onramp/molecule.yml +++ b/ansible/molecule/sponsord-onramp/molecule.yml @@ -19,9 +19,10 @@ # https chain through nginx, the client address nginx passes on. # # The proxy side effects (proxy "none", ACME, a foreign nginx site, the earlier -# Caddy retired), a custom gate page and the Turnstile secret from inventory run in -# ../sponsord-onramp-nginx on the same converge, so the two run side by side; -# so do ../sponsord-onramp-source and ../sponsord-onramp-lifecycle. Their +# Caddy retired) run in ../sponsord-onramp-nginx, a custom gate page and the +# Turnstile secret from inventory in ../sponsord-onramp-turnstile, on the same +# converge, so they run side by side; so do ../sponsord-onramp-source and +# ../sponsord-onramp-lifecycle. Their # inventories must stay identical to this one (`make test-scripts` checks). # # `baseline` is skipped (real-host-only — see ../default/molecule.yml), so the diff --git a/ansible/molecule/sponsord-onramp/side_effect.yml b/ansible/molecule/sponsord-onramp/side_effect.yml index c4b5bf7f..d2e193a8 100644 --- a/ansible/molecule/sponsord-onramp/side_effect.yml +++ b/ansible/molecule/sponsord-onramp/side_effect.yml @@ -8,8 +8,8 @@ # healthy on its inputs), and a daemon restart brings the onramp back by # itself (PartOf=, Restart=on-failure, no start limit). # The proxy side effects (proxy "none", a foreign nginx site, the earlier Caddy -# retired, ACME), a custom gate page and the Turnstile secret from inventory run in -# ../sponsord-onramp-nginx, the +# retired, ACME) run in ../sponsord-onramp-nginx, a custom gate page and the +# Turnstile secret from inventory in ../sponsord-onramp-turnstile, the # source install in ../sponsord-onramp-source, and backup and decommission in # ../sponsord-onramp-lifecycle, beside this scenario. # The host is left healthy on the role's own nginx site, self-signed, for verify. diff --git a/ansible/molecule/sponsord-wallet/molecule.yml b/ansible/molecule/sponsord-wallet/molecule.yml new file mode 100644 index 00000000..4a18cb6e --- /dev/null +++ b/ansible/molecule/sponsord-wallet/molecule.yml @@ -0,0 +1,90 @@ +--- +# sponsord's treasury wallet and secret.env provenance, split from ../sponsord-install +# (itself split from ../sponsord) so the three run side by side: the same host shape, +# inventory, prepare and converge (../sponsord/prepare.yml and converge.yml, by path; +# no release mirror, which only ../sponsord-install's release mode needs), then +# side_effect wallet generation (mint, keep, start on a pool id), then +# secret.env's provenance guards. +# No idempotence step (../sponsord runs it on the identical converge) and no verify: +# ../sponsord/verify.yml checks state its own side effect records, and each play +# here asserts its own outcome and leaves the host as converged. +# +# The inventory below must stay identical to ../sponsord/molecule.yml's +# (`make test-scripts` checks). +driver: + name: docker +platforms: + - name: sponsord-wallet-molecule + # Same digest-pinned image as the other scenarios (repo convention). Re-resolve + # all of them together to bump. + image: geerlingguy/docker-debian12-ansible@sha256:a131e3b7a9a38dda40e98b49b39d6200ffab0ad853c714a513f5eea41bab9a25 + pre_build_image: true + command: /usr/lib/systemd/systemd + privileged: true + cgroupns_mode: host + volumes: + - /sys/fs/cgroup:/sys/fs/cgroup:rw + groups: + - sponsord_hosts + # systemd 255: LoadCredential= files arrive 0440 there (0400 on Debian 12's 252), + # which is what the unit's keystore copy into its RuntimeDirectory exists for. The + # stub enforces upstream's keystore mode check, so a regression fails here. + - name: sponsord-wallet-molecule-u2404 + # Same digest-pinned image as ../os-matrix (repo convention). + image: geerlingguy/docker-ubuntu2404-ansible@sha256:3394e335df58c961c00b78ae601ef30cdd7463b112f21b0c5f78674a70526302 + pre_build_image: true + command: /usr/lib/systemd/systemd + privileged: true + cgroupns_mode: host + volumes: + - /sys/fs/cgroup:/sys/fs/cgroup:rw + groups: + - sponsord_hosts +provisioner: + name: ansible + options: + skip-tags: baseline + env: + ANSIBLE_ROLES_PATH: "${MOLECULE_PROJECT_DIRECTORY}/roles" + ANSIBLE_COLLECTIONS_PATH: "${MOLECULE_PROJECT_DIRECTORY}/collections" + # Same pipelining note as ../default/molecule.yml. + ANSIBLE_PIPELINING: "true" + playbooks: + prepare: ../sponsord/prepare.yml + converge: ../sponsord/converge.yml + inventory: + group_vars: + # Inventory group_vars, not converge vars: the converge is an + # import_playbook, which takes no vars of its own. + sponsord_hosts: + sponsord_install_method: manual + sponsord_manual_bin_src: "${MOLECULE_PROJECT_DIRECTORY}/molecule/sponsord/files/sponsord-stub" + # Not the binary's version: proves non-release installs are not held to the pin + # (only release mode compares it). + sponsord_version: "9.9.9-not-the-binary" + sponsord_network: arbitrum-sepolia + sponsord_pool_id: "0x00000000000000000000000000000000000000000000000000000000000000aa" + # Must equal EXPECTED_RPC_URL in files/sponsord-stub. The doubled dollar + # signs are molecule's escape: it interpolates variables in this file. + sponsord_rpc_url: "https://rpc.example.invalid/?key=$$HOME$$(touch /tmp/sponsord-rpc-expanded)&q=o'brien \"x\"" + sponsord_max_ttl_secs: 3600 + sponsord_pool_low_water_micro_usdc: 0 + sponsord_log_level: "sponsord=debug,info" + sponsord_readiness_retries: 10 + sponsord_readiness_delay: 1 + decdn_grafana_cloud_enabled: true + grafana_alloy_install_method: manual + grafana_alloy_manual_bin_src: "${MOLECULE_PROJECT_DIRECTORY}/molecule/grafana-cloud/files/alloy-stub" + grafana_alloy_region: US + grafana_alloy_loki_url: https://logs-prod-xx.molecule.invalid/loki/api/v1/push +verifier: + name: ansible +scenario: + test_sequence: + # Leading destroy: see ../default/molecule.yml. + - destroy + - create + - prepare + - converge + - side_effect + - destroy diff --git a/ansible/molecule/sponsord-wallet/side_effect.yml b/ansible/molecule/sponsord-wallet/side_effect.yml new file mode 100644 index 00000000..36c1e32d --- /dev/null +++ b/ansible/molecule/sponsord-wallet/side_effect.yml @@ -0,0 +1,330 @@ +--- +# Split from ../sponsord-install (itself split from ../sponsord) so the three run side +# by side, on the same converge (../sponsord/converge.yml, by path): +# 1. sponsord_generate_treasury_wallet: with no keystore, the role mints one on +# the host through the decdn CLI (the default scenario's key-gen stub), records +# the address and stops at its pool-id step; a re-run keeps that wallet; with a +# pool id the daemon starts on it. +# 2. secret.env's two homes and the provenance guards between them: an empty +# sponsord_rpc_url must not adopt the role's own file; a handed-over file is +# recorded `host` and locked to 0600; a host edit restarts the daemon; an +# inventory URL must not overwrite the host's file until confirmed. +# Each leaves the host healthy on the manual install, the prepared treasury files +# and the inventory-authored secret.env. +# The second play reuses the first's facts (gather_facts: false). +- name: Side effect — treasury wallet generation + hosts: all + become: true + vars: + # The default scenario's node stub implements `key-gen` (dummy keystore.json, a + # zero eth address on stdout). + _sp_cli_stub: "{{ playbook_dir }}/../default/files/decdn-node-stub" + tasks: + - name: Set the prepared treasury files aside + ansible.builtin.command: + argv: [mv, "/etc/sponsord/{{ item }}", "/root/molecule-{{ item }}"] + creates: "/root/molecule-{{ item }}" + loop: [treasury-keystore.json, treasury-password] + + - name: Converge with no wallet and no pool id + block: + - name: Run the role in wallet-creation mode + ansible.builtin.include_role: + name: sponsord + vars: + sponsord_generate_treasury_wallet: true + sponsord_decdn_cli_bin_src: "{{ _sp_cli_stub }}" + sponsord_pool_id: "" + + - name: Fail — the wallet-creation run did not stop + ansible.builtin.fail: + msg: The role finished with an empty sponsord_pool_id. + rescue: + - name: Require the stop to come from the pool-id step + ansible.builtin.assert: + that: + - ansible_failed_task.name == 'Stop until the treasury has opened its pool' + - "'0x0000000000000000000000000000000000000000' in ansible_failed_result.msg" + fail_msg: "Expected the pool-id stop, got \"{{ ansible_failed_task.name }}\"." + + - name: Stat the generated wallet, its address and the CLI + ansible.builtin.stat: + path: "{{ item }}" + follow: false + checksum_algorithm: sha256 + register: _sp_wallet + loop: + - /etc/sponsord/treasury-keystore.json + - /etc/sponsord/treasury-password + - /etc/sponsord/treasury-address + - /usr/local/lib/sponsord/decdn + + - name: Read the generated password and address + ansible.builtin.slurp: + src: "{{ item }}" + register: _sp_wallet_content + loop: + - /etc/sponsord/treasury-password + - /etc/sponsord/treasury-address + + - name: Find key-gen scratch directories left behind + ansible.builtin.find: + paths: /etc/sponsord + patterns: ".keygen.*" + file_type: directory + hidden: true + register: _sp_keygen_left + + - name: Require the wallet files, modes and contents + ansible.builtin.assert: + that: + - _sp_wallet.results[0].stat.isreg and _sp_wallet.results[0].stat.mode == '0600' + - _sp_wallet.results[1].stat.isreg and _sp_wallet.results[1].stat.mode == '0600' + - _sp_wallet.results[2].stat.isreg and _sp_wallet.results[2].stat.mode == '0644' + - _sp_wallet.results[3].stat.isreg and _sp_wallet.results[3].stat.mode == '0755' + - _sp_wallet_content.results[0].content | b64decode is match('^[0-9a-f]{64}\Z') + - (_sp_wallet_content.results[1].content | b64decode) == '0x0000000000000000000000000000000000000000\n' + - _sp_keygen_left.matched == 0 + fail_msg: >- + The generated wallet is not as expected: + {{ _sp_wallet.results | map(attribute='stat') | map(attribute='mode', default='absent') | list }}, + {{ _sp_keygen_left.matched }} scratch dir(s) left behind. + + - name: Converge in wallet-creation mode again + block: + - name: Run the role with the wallet already present + ansible.builtin.include_role: + name: sponsord + vars: + sponsord_generate_treasury_wallet: true + sponsord_decdn_cli_bin_src: "{{ _sp_cli_stub }}" + sponsord_pool_id: "" + + - name: Fail — the second wallet-creation run did not stop + ansible.builtin.fail: + msg: The role finished with an empty sponsord_pool_id. + rescue: + - name: Require the second stop to come from the pool-id step + ansible.builtin.assert: + that: + - ansible_failed_task.name == 'Stop until the treasury has opened its pool' + fail_msg: "Expected the pool-id stop, got \"{{ ansible_failed_task.name }}\"." + + - name: Re-stat the keystore after the second run + ansible.builtin.stat: + path: /etc/sponsord/treasury-keystore.json + checksum_algorithm: sha256 + register: _sp_wallet_again + + - name: Require the existing wallet to be kept + ansible.builtin.assert: + that: + - _sp_wallet_again.stat.checksum == _sp_wallet.results[0].stat.checksum + fail_msg: A second wallet-creation run replaced an existing keystore. + + # The scenario's pool id: the stub daemon accepts any JSON keystore. + - name: Converge with the pool id set + ansible.builtin.include_role: + name: sponsord + vars: + sponsord_generate_treasury_wallet: true + sponsord_decdn_cli_bin_src: "{{ _sp_cli_stub }}" + + - name: Read the unit state on the generated wallet + ansible.builtin.systemd_service: + name: sponsord + register: _sp_wallet_unit + + - name: Require sponsord running on the generated wallet + ansible.builtin.assert: + that: + - _sp_wallet_unit.status.ActiveState == 'active' + fail_msg: "sponsord is {{ _sp_wallet_unit.status.ActiveState }} on the generated wallet." + + - name: Remove the generated wallet, its address and the CLI + ansible.builtin.file: + path: "{{ item }}" + state: absent + loop: + - /etc/sponsord/treasury-keystore.json + - /etc/sponsord/treasury-password + - /etc/sponsord/treasury-address + - /usr/local/lib/sponsord/decdn + + - name: Put the prepared treasury files back + ansible.builtin.command: + argv: [mv, "/root/molecule-{{ item }}", "/etc/sponsord/{{ item }}"] + creates: "/etc/sponsord/{{ item }}" + loop: [treasury-keystore.json, treasury-password] + + - name: Converge back onto the prepared wallet + ansible.builtin.include_role: + name: sponsord + +- name: Side effect — secret.env provenance + hosts: all + become: true + gather_facts: false + vars: + _sp_env: /etc/sponsord/secret.env + _sp_rec: /etc/sponsord/.secret.env.sha256 + tasks: + # 1. secret.yml "went missing": the file is byte-for-byte the role's own. + - name: Hash secret.env before the adoption attempt + ansible.builtin.stat: + path: "{{ _sp_env }}" + checksum_algorithm: sha256 + register: _sp_env_before + + - name: Run with no inventory RPC URL against the role-authored file + block: + - name: Run the role with sponsord_rpc_url empty + ansible.builtin.include_role: + name: sponsord + vars: + sponsord_rpc_url: "" + + - name: Fail — the role adopted its own file as host-provisioned + ansible.builtin.fail: + msg: An empty sponsord_rpc_url silently adopted the role-authored secret.env. + rescue: + - name: Require the adoption guard to be what stopped it + ansible.builtin.assert: + that: + - ansible_failed_task.name == 'Refuse to adopt a role-authored secret.env as host-provisioned' + fail_msg: "Expected the adoption guard, got \"{{ ansible_failed_task.name }}\"." + + - name: Hash secret.env after the refused adoption + ansible.builtin.stat: + path: "{{ _sp_env }}" + checksum_algorithm: sha256 + register: _sp_env_after + + - name: Require the refused run to leave secret.env untouched + ansible.builtin.assert: + that: + - _sp_env_after.stat.checksum == _sp_env_before.stat.checksum + + # 2. A deliberate handover: drop the record, loosen the mode. The role must take + # the file as the operator's, lock it down, and owe no restart (same bytes). + - name: Read the unit state before the handover + ansible.builtin.systemd_service: + name: sponsord + register: _sp_unit_a + + - name: Hand secret.env to the host (discard the record, loosen the mode) + ansible.builtin.shell: + cmd: rm -f {{ _sp_rec }} && chmod 0644 {{ _sp_env }} + changed_when: true + + - name: Converge with the host-provisioned secret.env + ansible.builtin.include_role: + name: sponsord + vars: + sponsord_rpc_url: "" + + - name: Read secret.env, its record and the unit after the handover + ansible.builtin.stat: + path: "{{ _sp_env }}" + checksum_algorithm: sha256 + register: _sp_env_host + + - name: Read the provenance record after the handover + ansible.builtin.slurp: + src: "{{ _sp_rec }}" + register: _sp_rec_host + + - name: Read the unit state after the handover + ansible.builtin.systemd_service: + name: sponsord + register: _sp_unit_b + + - name: Require a locked, host-recorded file and no restart + ansible.builtin.assert: + that: + - _sp_env_host.stat.mode == '0600' + - (_sp_rec_host.content | b64decode).split() == ['host', _sp_env_host.stat.checksum] + - _sp_unit_b.status.MainPID == _sp_unit_a.status.MainPID + fail_msg: >- + Expected secret.env 0600 (got {{ _sp_env_host.stat.mode }}), record + "host {{ _sp_env_host.stat.checksum }}" (got "{{ _sp_rec_host.content | b64decode | trim }}") + and no restart (PID {{ _sp_unit_a.status.MainPID }} -> {{ _sp_unit_b.status.MainPID }}). + + # 3. The operator edits the host file: the next converge must restart onto it. + - name: Edit secret.env on the host + ansible.builtin.lineinfile: + path: "{{ _sp_env }}" + line: "# molecule host edit" + mode: "0600" + + - name: Converge after the host edit + ansible.builtin.include_role: + name: sponsord + vars: + sponsord_rpc_url: "" + + - name: Read the unit state after the host edit + ansible.builtin.systemd_service: + name: sponsord + register: _sp_unit_c + + - name: Require the host edit to have restarted sponsord + ansible.builtin.assert: + that: + - _sp_unit_c.status.ActiveState == 'active' + - _sp_unit_c.status.MainPID != _sp_unit_b.status.MainPID + + # 4. An inventory URL against the host's (edited) file: refused, file kept. + - name: Run with the inventory URL against the host's file + block: + - name: Run the role with the inventory URL (group_vars) + ansible.builtin.include_role: + name: sponsord + + - name: Fail — the role overwrote the host's file unconfirmed + ansible.builtin.fail: + msg: An inventory sponsord_rpc_url overwrote a host-provisioned secret.env without confirmation. + rescue: + - name: Require the overwrite guard to be what stopped it + ansible.builtin.assert: + that: + - ansible_failed_task.name == 'Require confirmation to overwrite a secret.env this role did not write' + fail_msg: "Expected the overwrite guard, got \"{{ ansible_failed_task.name }}\"." + + - name: Require the host edit to survive the refused overwrite + ansible.builtin.command: + cmd: grep -qx '# molecule host edit' {{ _sp_env }} + changed_when: false + + # 5. Confirmed: the role takes the file back and restarts onto it. + - name: Converge with the overwrite confirmed + ansible.builtin.include_role: + name: sponsord + vars: + sponsord_secret_env_overwrite_host_file: true + + - name: Read the provenance record after the takeover + ansible.builtin.slurp: + src: "{{ _sp_rec }}" + register: _sp_rec_inv + + - name: Read the unit state after the takeover + ansible.builtin.systemd_service: + name: sponsord + register: _sp_unit_d + + - name: Require an inventory record, the edit gone and a restart + ansible.builtin.assert: + that: + - (_sp_rec_inv.content | b64decode).split()[0] == 'inventory' + - _sp_unit_d.status.MainPID != _sp_unit_c.status.MainPID + fail_msg: >- + Expected record source "inventory" (got + "{{ _sp_rec_inv.content | b64decode | trim }}") and a restart. + + - name: Require the host edit to be gone + ansible.builtin.command: + cmd: grep -qx '# molecule host edit' {{ _sp_env }} + register: _sp_edit_gone + changed_when: false + failed_when: _sp_edit_gone.rc != 1 diff --git a/ansible/molecule/sponsord/molecule.yml b/ansible/molecule/sponsord/molecule.yml index fff424c9..00792517 100644 --- a/ansible/molecule/sponsord/molecule.yml +++ b/ansible/molecule/sponsord/molecule.yml @@ -15,9 +15,10 @@ # top-up refuses the restart until it clears. # verify files, modes, unit hardening, credentials, listener, Alloy config. # -# Release mode, treasury wallet generation and secret.env's provenance guards run in -# ../sponsord-install on the same converge, so the two run side by side. Its inventory below must stay identical to -# this one (`make test-scripts` checks). +# Release mode runs in ../sponsord-install, treasury wallet generation and secret.env's +# provenance guards in ../sponsord-wallet, on the same converge, so the three run side +# by side. Their inventories must stay identical to this one (`make test-scripts` +# checks). # # `baseline` is skipped (real-host-only — see ../default/molecule.yml). The # broken-input matrix for this role lives in ../validation-sponsord. diff --git a/ansible/molecule/sponsord/side_effect.yml b/ansible/molecule/sponsord/side_effect.yml index 523c75b4..49d6d20a 100644 --- a/ansible/molecule/sponsord/side_effect.yml +++ b/ansible/molecule/sponsord/side_effect.yml @@ -12,8 +12,8 @@ # restarts nothing (the revert writes the recorded content back, which must # not slip a restart past the guard); the override restarts during the hold; # an unreadable /metrics refuses too; a plain converge restarts once it clears. -# Release mode, treasury wallet generation and secret.env's provenance guards run in -# ../sponsord-install, beside this scenario. The first play must run on a fresh +# Release mode runs in ../sponsord-install, treasury wallet generation and +# secret.env's provenance guards in ../sponsord-wallet, beside this scenario. The first play must run on a fresh # converge (it asserts no restart yet), and it records the API token verify.yml # compares against. # The host is left healthy, on the inventory-authored secret.env, for verify.yml. diff --git a/ansible/roles/iroh_relay/README.md b/ansible/roles/iroh_relay/README.md index dd4773a4..25946f8b 100644 --- a/ansible/roles/iroh_relay/README.md +++ b/ansible/roles/iroh_relay/README.md @@ -278,8 +278,8 @@ check of your own. - an untrusted certificate fails in `fail` mode, warns in `warn` mode, and fails in `warn` mode once a certificate was issued (`iroh-relay-certificate`); - `0.0.0.0` and a specific IPv4 address are rendered, bound and probed - (`iroh-relay-gate`); - - the allowlist (`iroh-relay-gate`): the ID read from a stand-in node (a fake + (`iroh-relay-access`); + - the allowlist (`iroh-relay-access`): the ID read from a stand-in node (a fake `decdn` on the relay host) is merged with the listed one; a node without an identity fails the read; the `everyone` and `denylist` modes render as such. - `molecule/iroh-relay-install` runs the `release` method against a local mirror diff --git a/ansible/roles/sponsord/README.md b/ansible/roles/sponsord/README.md index 1e2e9ff3..3c16ee7f 100644 --- a/ansible/roles/sponsord/README.md +++ b/ansible/roles/sponsord/README.md @@ -222,10 +222,11 @@ export it, so watch the address yourself. before the restart and keeps the restart record, the override restarts, and the next run after the hold clears restarts; - the Alloy toggles. -- `molecule/sponsord-install` runs beside it on the same converge. It checks: - - release mode against a locally signed mirror: the version stamp, a re-run - with the mirror down, and rejection of a bad checksum, a bad signature and - a binary that is not the pinned version; +- `molecule/sponsord-install` runs beside it on the same converge. It checks + release mode against a locally signed mirror: the version stamp, a re-run with + the mirror down, and rejection of a bad checksum, a bad signature and a binary + that is not the pinned version. +- `molecule/sponsord-wallet` runs beside both on the same converge. It checks: - treasury wallet generation through a `key-gen` stub: the files and modes, the stop on an empty pool id, an existing wallet kept, and the daemon starting on it; - each `secret.env` hand-off between inventory and host, with its guards. diff --git a/ansible/roles/sponsord_onramp/README.md b/ansible/roles/sponsord_onramp/README.md index 4bcc232a..0d005f63 100644 --- a/ansible/roles/sponsord_onramp/README.md +++ b/ansible/roles/sponsord_onramp/README.md @@ -252,7 +252,9 @@ else warns before the certificate expires. nginx's access logs are not shipped. - `molecule/sponsord-onramp-nginx` runs beside it on the same converge. It checks proxy `none`, a broken upstream behind nginx, the refusal of a foreign nginx site, the retirement of the role's earlier Caddy, and ACME against an ACME server that - does not answer; then a custom gate page, and the Turnstile secret from inventory. + does not answer. +- `molecule/sponsord-onramp-turnstile` runs beside both on the same converge. It + checks a custom gate page, and the Turnstile secret from inventory. - `molecule/validation-sponsord` holds the negative cases. - `tests/firewall-holes` (`make test-scripts`) pins the tcp/80 and tcp/443 holes. - `tests/alloy-config` (`make lint-alloy`) runs the onramp's log lines through the diff --git a/scripts/tests/test_molecule_affected.py b/scripts/tests/test_molecule_affected.py index 54314c1a..2a11931a 100644 --- a/scripts/tests/test_molecule_affected.py +++ b/scripts/tests/test_molecule_affected.py @@ -80,7 +80,7 @@ def test_a_role_change_selects_the_scenarios_named_after_it(self): class Dependencies(unittest.TestCase): def test_a_stub_selects_the_scenarios_that_read_it_by_path(self): got = names("ansible/molecule/default/files/decdn-node-stub") - self.assertLessEqual({"default", "os-matrix", "collection", "sponsord-install"}, got) + self.assertLessEqual({"default", "os-matrix", "collection", "sponsord-wallet"}, got) def test_reading_one_file_of_a_scenario_is_not_reading_it_all(self): # iroh-relay reads grafana-cloud's alloy-stub, not its converge. @@ -104,7 +104,7 @@ def test_a_stub_under_a_templated_root_is_a_dependency(self): def test_a_deleted_file_selects_every_reader_of_its_scenario(self): got = names("ansible/molecule/default/files/no-such-file") - self.assertLessEqual({"default", "os-matrix", "sponsord-install"}, got) + self.assertLessEqual({"default", "os-matrix", "sponsord-wallet"}, got) def test_a_deleted_file_alone_in_its_directory_keeps_its_readers(self): # Simulate deleting grafana-cloud's alloy-stub, the only file in files/: diff --git a/tests/scripts-test.sh b/tests/scripts-test.sh index 7e855dd1..078de207 100755 --- a/tests/scripts-test.sh +++ b/tests/scripts-test.sh @@ -207,10 +207,11 @@ want="$(cd "$repo/ansible/molecule" && for d in */molecule.yml; do echo "${d%/mo # Split scenarios share one converge, so their inventories must not drift apart. if command -v yq >/dev/null; then - for pair in sponsord:sponsord-install sponsord-onramp:sponsord-onramp-nginx \ + for pair in sponsord:sponsord-install sponsord:sponsord-wallet sponsord-onramp:sponsord-onramp-nginx \ + sponsord-onramp:sponsord-onramp-turnstile \ sponsord-onramp:sponsord-onramp-source sponsord-onramp:sponsord-onramp-lifecycle \ iroh-relay:iroh-relay-lifecycle iroh-relay:iroh-relay-install \ - iroh-relay:iroh-relay-certificate iroh-relay:iroh-relay-gate \ + iroh-relay:iroh-relay-certificate iroh-relay:iroh-relay-gate iroh-relay:iroh-relay-access \ iroh-dns-server:iroh-dns-server-certificate iroh-dns-server:iroh-dns-server-install \ iroh-dns-server:iroh-dns-server-lifecycle iroh-dns-server:iroh-dns-server-gate \ decdn-gateway:decdn-gateway-lifecycle \