diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 40acc32..62d6df6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -217,7 +217,7 @@ jobs: # promises (no secrets, release installs, host-generated node wallet, localhost in # decdn_nodes and/or sponsord_hosts, a keyed admin account, shellcheck-clean stage 1, # a collection lock covering ansible/requirements.yml), for both templates. The boots - # themselves are the molecule `cloud-init` and `cloud-init-sponsord` scenarios + # themselves are the molecule `cloud-init` and `cloud-init-publisher` scenarios # (molecule.yml). Runs only when cloud-init/, ansible/requirements.yml, the root # Makefile or ci.yml changed. cloud-init: diff --git a/.github/workflows/molecule.yml b/.github/workflows/molecule.yml index 94a6163..dfdf4b4 100644 --- a/.github/workflows/molecule.yml +++ b/.github/workflows/molecule.yml @@ -47,7 +47,7 @@ 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-sponsord (~9m45s), then cloud-init (~8m) and sponsord-onramp (~7m30s); + # cloud-init-publisher (~9m45s), 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/AGENTS.md b/AGENTS.md index 5204aa6..0b851e7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -75,7 +75,7 @@ ansible/ # the deployment project (DevSec-hardened, lean roles) playbooks/ # site.yml = node.yml (node operators: cache nodes) + publisher.yml (publishers: origin.yml, sponsord.yml (+ onramp), iroh_relay.yml, iroh_dns_server.yml); backup.yml, decommission.yml roles/ # baseline, decdn_node, grafana_alloy, sponsord, sponsord_onramp, iroh_relay, iroh_dns_server inventory/ galaxy/ molecule/ # see ansible/README.md -cloud-init/ # user-data{,-sponsord}.yaml + on-host bootstrap.sh; pinned ansible-core/collections (see its README.md) +cloud-init/ # user-data-{node,publisher}.yaml + on-host bootstrap.sh; pinned ansible-core/collections (see its README.md) compose/ # Docker Compose deploy path for a single host (see its README.md) compose.yaml # profiles: node, sponsord, onramp (+ sponsord), caddy Caddyfile # mirrors roles/sponsord_onramp/templates/Caddyfile.j2 @@ -269,8 +269,8 @@ the pinned `iroh-relay` (re-capture it when bumping `iroh_relay_version`). config. - **Molecule:** under docker, `/` must be `--make-rshared` (see `molecule/sponsord/prepare.yml`) or every credential directory is empty. - - **cloud-init:** `cloud-init/user-data-sponsord.yaml` (with the onramp), release - mode only; see the `cloud-init/` bullet below. + - **cloud-init:** `cloud-init/user-data-publisher.yaml` (with the onramp and an + origin node), release mode only; see the `cloud-init/` bullet below. - **`ansible/roles/sponsord_onramp`** — `sponsord-onramp`, sponsord's public side (the Turnstile gate, the installers, the `decdn-sponsored` CLI API), as a second play of @@ -397,9 +397,10 @@ the pinned `iroh-relay` (re-capture it when bumping `iroh_relay_version`). real binary's record round-trip (PUT, then `dig TXT`) is a manual check, listed in the role README. No `monitoring/` assets yet. -- **`cloud-init/`** — the Ansible path with no control machine. Two templates, - `user-data.yaml` (a node) and `user-data-sponsord.yaml` (sponsord + onramp), share - stage 1 and differ only in their inventory. They carry only public material (the lint +- **`cloud-init/`** — the Ansible path with no control machine. Two templates, one per + persona: `user-data-node.yaml` (node operators: a cache node) and + `user-data-publisher.yaml` (publishers: an origin node + sponsord + onramp, deletable + group by group), share stage 1 and differ only in their inventory. They carry only public material (the lint refuses secret-looking keys, credentials in URLs and unknown `bootstrap.env` keys) and a stage-1 `decdn-bootstrap`. That script clones this repo at a pinned ref (a full SHA is verified after checkout) and execs `cloud-init/bootstrap.sh`, which: @@ -408,11 +409,16 @@ the pinned `iroh-relay` (re-capture it when bumping `iroh_relay_version`). - installs the exact collections from `collections.lock.yml`; - runs `site.yml` (which imports `node.yml` and `publisher.yml`) against localhost. The inventory must put localhost in `decdn_nodes` and/or `sponsord_hosts` (and in - `sponsord_hosts` whenever it is in `sponsord_onramp_hosts`), or the plays match - nothing and the firewall holes never load. + `decdn_nodes` whenever it is in `decdn_origin_nodes`, in `sponsord_hosts` whenever + it is in `sponsord_onramp_hosts`), or the plays match nothing and the firewall + holes never load. The template lists an origin in `decdn_nodes` and + `decdn_origin_nodes` as siblings (the lint refuses `children:`), and the lint + requires an origin backend in `decdn_origin_nodes.vars`. Each group needs its secrets on the host first, at the role-default paths (the lint - forbids moving them): `/etc/decdn/decdn.env` for a node; `secret.env`, the treasury + forbids moving them): `/etc/decdn/decdn.env` for a node (an origin's too, with an S3 + backend's keys; `decdn_origin_nodes` has no `SECRETS` of its own, so no `case` arm); + `secret.env`, the treasury keystore and password, and (onramp) `turnstile-secret` under `/etc/sponsord/`. While any is missing it runs `--tags baseline` only, lists them in `/var/lib/decdn-bootstrap/awaiting` and records `awaiting-secret`. The operator @@ -425,12 +431,14 @@ the pinned `iroh-relay` (re-capture it when bumping `iroh_relay_version`). steps at the end of `bootstrap.sh`, and the README's table. A new group also needs its own `case` arm and a place in both `HOST_GROUPS` and lint.py's `GROUPS` (`make test-scripts` checks all three). The baseline-only run trusts every - `decdn_nodes`/`sponsord_hosts` play to run `baseline` tagged `baseline` (checked per - play at boot by `cloud-init/baseline-plays.sh`, and by `make test-scripts`). The lint + node.yml/origin.yml/sponsord.yml play to run `baseline` tagged `baseline` (checked + per play at boot by `cloud-init/baseline-plays.sh`, for `decdn_origin_nodes` in place + of `decdn_nodes` on an origin, whose play is origin.yml's; and by `make + test-scripts`). The lint allows only the templates' top-level modules and no YAML anchors, so what it reads is what cloud-init and Ansible read. When `ansible/requirements.yml` changes, re-sync the lock: `make lint-cloud-init` checks it covers the requirements. The molecule `cloud-init` - and `cloud-init-sponsord` scenarios boot the real templates through cloud-init + and `cloud-init-publisher` scenarios boot the real templates through cloud-init (skipping `baseline`) against a locally signed release mirror, built from the shared `molecule/cloud-init/pack.yml` and `includes/`. They are the only coverage of the node's and the onramp's release download and verify path. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3380770..2d56724 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.yaml` and `cloud-init/user-data-sponsord.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` (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 `decdn_nodes`/`sponsord_hosts` 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, 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 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) | @@ -88,7 +88,7 @@ they are maintained here. See [its README](monitoring/README.md). scenario (`make molecule SCENARIOS=`), the matrix read from `make molecule-list`. The `molecule` job aggregates them: it is the one check name to require, since the per-scenario names follow the scenario list. The `cloud-init` and - `cloud-init-sponsord` scenarios boot the real user-data templates, so they need + `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-caddy`, `sponsord-onramp-source`, `iroh-relay-gate`, diff --git a/Makefile b/Makefile index 5edb46d..9d5c160 100644 --- a/Makefile +++ b/Makefile @@ -126,7 +126,7 @@ lint-compose: ## render compose/ with its examples and check its security # ansible/requirements.yml. # CLOUD_INIT_FILE (one path or several) is overridable so operators can check their # filled-in copy and tests/scripts-test.sh can feed it broken variants. -CLOUD_INIT_FILE ?= cloud-init/user-data.yaml cloud-init/user-data-sponsord.yaml +CLOUD_INIT_FILE ?= cloud-init/user-data-node.yaml cloud-init/user-data-publisher.yaml lint-cloud-init: ## schema-check the cloud-init/ user-data templates and their invariants (needs cloud-init, shellcheck, yq) @command -v cloud-init >/dev/null || { echo "lint-cloud-init: needs cloud-init on PATH" >&2; exit 2; } @# An empty list would loop zero times and report success with nothing checked. diff --git a/README.md b/README.md index 59e30bd..8e5e6bb 100644 --- a/README.md +++ b/README.md @@ -106,7 +106,7 @@ This is the canonical statement; the per-path READMEs add only what is specific | Path | What it is | |------|------------| | [`ansible/`](ansible/README.md) | The Ansible project: `inventory/`, `playbooks/` (`site.yml`, `node.yml` for node operators, `publisher.yml` for publishers with `origin.yml`, `sponsord.yml`, `iroh_relay.yml` and `iroh_dns_server.yml`, `backup.yml`, `decommission.yml`), `roles/` (`baseline`, `decdn_node`, `grafana_alloy`, `sponsord`, `sponsord_onramp`, `iroh_relay`, `iroh_dns_server`), `galaxy/` (the `decdn.node` and `decdn.publisher` collections), `molecule/`. | -| [`cloud-init/`](cloud-init/README.md) | The cloud-init deploy path: `user-data.yaml` (a node), `user-data-sponsord.yaml` (a sponsor host), the on-host `bootstrap.sh`, and the pinned ansible-core and collections it installs. | +| [`cloud-init/`](cloud-init/README.md) | The cloud-init deploy path: `user-data-node.yaml` (node operators: a cache node), `user-data-publisher.yaml` (publishers: an origin node, sponsord and its onramp), the on-host `bootstrap.sh`, and the pinned ansible-core and collections it installs. | | [`compose/`](compose/README.md) | The Docker Compose deploy path: the node, `sponsord`, `sponsord-onramp` and Caddy, one profile each. | | [`charts/decdn-node/`](charts/decdn-node/README.md) | The Helm chart; it renders the node's dashboards and alert rules from `monitoring/`. | | [`monitoring/`](monitoring/README.md) | The deCDN Grafana dashboards and Prometheus alert rules, for the node (`decdn-node/`, every deploy path), sponsord (`sponsord/`, Ansible and Compose) and the iroh relay (`iroh-relay/`, Ansible). | diff --git a/ansible/Makefile b/ansible/Makefile index 23b4c49..32c8b27 100644 --- a/ansible/Makefile +++ b/ansible/Makefile @@ -187,7 +187,7 @@ decommission: # source-build-recovery (the node built from source on the host: sandbox, stamps; # then a rollback from a broken commit; then the install-method switch, an untrusted # build home and a lost restart), and cloud-init and -# cloud-init-sponsord (the cloud-init/ templates, booted for real). The glob in +# cloud-init-publisher (the cloud-init/ templates, booted for real). The glob in # scripts/molecule.sh is the authority; this list is a guide. # # make molecule every scenario, in parallel @@ -221,7 +221,7 @@ decommission: SCENARIOS ?= all JOBS ?= SLOW_FIRST ?= sponsord-onramp-source sponsord-install cloud-init source-build-recovery \ - iroh-relay-gate cloud-init-sponsord sponsord source-build sponsord-onramp \ + iroh-relay-gate cloud-init-publisher sponsord source-build sponsord-onramp \ sponsord-onramp-caddy iroh-relay source-build-rollback iroh-relay-certificate \ validation iroh-relay-install os-matrix grafana-cloud MOLECULE_LOCK_PREFIX ?= /tmp/decdn-devops-molecule diff --git a/ansible/README.md b/ansible/README.md index ae2db70..2217926 100644 --- a/ansible/README.md +++ b/ansible/README.md @@ -444,7 +444,7 @@ node built from source on the host: the build sandbox, stamps, a moved branch), `source-build-rollback` (the same converge, then a broken commit rolled back by SHA), `source-build-recovery` (the same converge, then the install-method switch, an untrusted earlier build home and a lost restart), and -`cloud-init` and `cloud-init-sponsord` (the `cloud-init/` templates booted for real). +`cloud-init` and `cloud-init-publisher` (the `cloud-init/` templates booted for real). A slow scenario is split along its side effects rather than allowed to set the wall clock on its own. They are independent, so they run concurrently, and each line of output is prefixed with its scenario name diff --git a/ansible/molecule/cloud-init-sponsord/converge.yml b/ansible/molecule/cloud-init-publisher/converge.yml similarity index 68% rename from ansible/molecule/cloud-init-sponsord/converge.yml rename to ansible/molecule/cloud-init-publisher/converge.yml index 794a8e4..6537c30 100644 --- a/ansible/molecule/cloud-init-sponsord/converge.yml +++ b/ansible/molecule/cloud-init-publisher/converge.yml @@ -1,20 +1,24 @@ --- -# First boot of cloud-init/user-data-sponsord.yaml, filled in the way an operator would -# with the values ../sponsord-onramp uses, and pointed at the local repo and mirror. -# Two edits go beyond a placeholder: the keyrings (the mirror's throwaway key, as in -# ../cloud-init) and `sponsord_onramp_caddy_tls: internal` (no ACME in a container). +# First boot of cloud-init/user-data-publisher.yaml, filled in the way an operator would +# with the values ../cloud-init and ../sponsord-onramp use (an http origin backend for +# the node), and pointed at the local repo and mirror. Two edits go beyond a +# placeholder: the keyrings (the mirror's throwaway key, as in ../cloud-init) and +# `sponsord_onramp_caddy_tls: internal` (no ACME in a container). # -# It then asserts the host stopped at the secret gate: state "awaiting-secret", all -# four sponsor secrets listed, nothing of either service installed. -- name: Converge — first boot of the sponsor user-data +# It then asserts the host stopped at the secret gate: state "awaiting-secret", the +# node's and all four sponsor secrets listed, nothing of any service installed. +- name: Converge — first boot of the publisher user-data hosts: all become: true vars: userdata_src: >- - {{ lookup('ansible.builtin.env', 'MOLECULE_PROJECT_DIRECTORY') }}/../cloud-init/user-data-sponsord.yaml + {{ lookup('ansible.builtin.env', 'MOLECULE_PROJECT_DIRECTORY') }}/../cloud-init/user-data-publisher.yaml # Stood up by prepare.yml. mirror_base: http://127.0.0.1:8099/releases mirror_key: /srv/mirror/KEYS.asc + # Each repository's releases under their own base (prepare.yml). + node_base: "{{ mirror_base }}/decdn" + sponsor_base: "{{ mirror_base }}/sponsord" userdata_edits: - regexp: '^(\s*)DEVOPS_REPO=https://github\.com/decdn/devops\.git$' replace: '\1DEVOPS_REPO=file:///srv/devops-src' @@ -24,11 +28,20 @@ replace: '\1- name: molecule' - regexp: '^(\s*)- "CHANGE_ME"(.*)$' replace: '\1- "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIMolecule0000000000000000000000000000000000 molecule"' + # the origin node + - regexp: '^(\s*)decdn_node_install_method: release$' + replace: '\1decdn_node_install_method: release\n\1decdn_node_version: "0.0.0-molecule-stub"' + - regexp: '^(\s*)# decdn_node_release_base: .*$' + replace: '\1decdn_node_release_base: "{{ node_base }}"\n\1decdn_release_keyring: {{ mirror_key }}' + - regexp: '^(\s*)decdn_region: "CHANGE_ME".*$' + replace: '\1decdn_region: "DE"' + - regexp: '^(\s*)decdn_cache_origin_url: "CHANGE_ME".*$' + replace: '\1decdn_cache_origin_url: "https://content.example.invalid/"' # sponsord - regexp: '^(\s*)sponsord_install_method: release$' replace: '\1sponsord_install_method: release\n\1sponsord_version: "0.0.0-molecule-stub"' - regexp: '^(\s*)# sponsord_release_base: .*$' - replace: '\1sponsord_release_base: "{{ mirror_base }}"\n\1sponsord_release_keyring: {{ mirror_key }}' + replace: '\1sponsord_release_base: "{{ sponsor_base }}"\n\1sponsord_release_keyring: {{ mirror_key }}' - regexp: '^(\s*)sponsord_pool_id: "CHANGE_ME".*$' replace: '\1sponsord_pool_id: "0x00000000000000000000000000000000000000000000000000000000000000aa"' # sponsord-onramp @@ -36,7 +49,7 @@ replace: '\1sponsord_onramp_install_method: release\n\1sponsord_onramp_version: "0.0.0-molecule-stub"' - regexp: '^(\s*)# sponsord_onramp_release_base: .*$' replace: >- - \1sponsord_onramp_release_base: "{{ mirror_base }}"\n\1sponsord_onramp_release_keyring: {{ mirror_key }} + \1sponsord_onramp_release_base: "{{ sponsor_base }}"\n\1sponsord_onramp_release_keyring: {{ mirror_key }} - regexp: '^(\s*)sponsord_onramp_domain: "CHANGE_ME".*$' replace: '\1sponsord_onramp_domain: onramp.molecule.test\n\1sponsord_onramp_caddy_tls: internal' - regexp: '^(\s*)sponsord_onramp_rpc_url: "CHANGE_ME".*$' @@ -56,6 +69,9 @@ path: "{{ item }}" register: premature loop: + - /usr/local/bin/decdn-node + - /etc/systemd/system/decdn-node.service + - /var/lib/decdn/keystore.json - /usr/local/bin/sponsord - /usr/local/bin/sponsord-onramp - /etc/systemd/system/sponsord.service @@ -70,6 +86,7 @@ - name: Assert the host is waiting for its secrets, and nothing more vars: want: + - /etc/decdn/decdn.env - /etc/sponsord/secret.env - /etc/sponsord/treasury-keystore.json - /etc/sponsord/treasury-password @@ -81,6 +98,7 @@ - premature.results | selectattr('stat.exists') | list | length == 0 - login_hint.stdout is search('waiting for these secrets') - login_hint.stdout is search('/etc/sponsord/treasury-keystore\.json') + - login_hint.stdout is search('/etc/decdn/decdn\.env') fail_msg: >- Expected state awaiting-secret listing {{ want }} with nothing installed. Got state {{ bootstrap_state.content | b64decode | trim }}, awaiting diff --git a/ansible/molecule/cloud-init-sponsord/molecule.yml b/ansible/molecule/cloud-init-publisher/molecule.yml similarity index 56% rename from ansible/molecule/cloud-init-sponsord/molecule.yml rename to ansible/molecule/cloud-init-publisher/molecule.yml index a3a5e01..48e0c90 100644 --- a/ansible/molecule/cloud-init-sponsord/molecule.yml +++ b/ansible/molecule/cloud-init-publisher/molecule.yml @@ -1,23 +1,26 @@ --- -# Boots the REAL cloud-init/user-data-sponsord.yaml (a sponsor host: sponsord, then -# sponsord-onramp behind Caddy) in a systemd container, following the "Sponsor host" -# walkthrough in cloud-init/README.md. Built from ../cloud-init/pack.yml and -# ../cloud-init/includes/, so the boot plumbing is the node scenario's; this one covers -# what differs: +# Boots the REAL cloud-init/user-data-publisher.yaml (a publisher host: an origin node, +# sponsord, then sponsord-onramp behind Caddy) in a systemd container, following the +# "Publisher host" walkthrough in cloud-init/README.md. Built from ../cloud-init/pack.yml +# and ../cloud-init/includes/, so the boot plumbing is the node scenario's; this one +# covers what differs: # prepare cloud-init and the repo (as ../cloud-init), shared mount propagation -# (as ../sponsord), and a signed loopback mirror holding one v* release -# of the shared stubs (sponsord and sponsord-onramp, as upstream ships -# them). -# converge seeds the template (placeholders filled; release bases, keyrings and -# Caddy's `tls internal` pointed at local ones) and boots. Asserts the -# host stopped at the secret gate with all four sponsor secrets listed. -# side_effect refuses an inventory with localhost in neither base group, and one -# with the onramp outside sponsord_hosts (stage 2's checks, each -# recording "failed"). Then writes only sponsord's three secrets and -# re-runs: the gate must still hold, now for the Turnstile secret alone. -# Then writes it, moves DEVOPS_REF to a tag, and re-runs. -# verify both services and Caddy running from the signed releases, the API -# token generated on the host, https through Caddy, no hint left. +# (as ../sponsord), and a signed loopback mirror holding a decdn/decdn +# release of the node stubs and a decdn/sponsord release of the shared +# sponsor stubs, each under its own base. +# converge seeds the template (placeholders filled, an http origin backend; +# release bases, keyrings and Caddy's `tls internal` pointed at local +# ones) and boots. Asserts the host stopped at the secret gate with the +# node's and all four sponsor secrets listed. +# side_effect refuses an inventory with localhost in neither base group, one with +# the origin outside decdn_nodes and one with the onramp outside +# sponsord_hosts (stage 2's checks, each recording "failed"). Then writes +# the node's and sponsord's secrets and re-runs: the gate must still hold, +# now for the Turnstile secret alone. Then writes it, moves DEVOPS_REF to +# a tag, and re-runs. +# verify the origin node, both sponsor services and Caddy running from the signed +# releases, the origin backend in node.toml, the API token generated on +# the host, https through Caddy, no hint left. # # The release download and verify path of sponsord-onramp runs here and nowhere else # in the suite (../validation-sponsord sets its release mode, but stops at its @@ -35,7 +38,7 @@ driver: name: docker platforms: - - name: decdn-cloud-init-sponsord-u2604 + - name: decdn-cloud-init-publisher-u2604 # Same digest as ../cloud-init's Ubuntu 26.04 platform; bump them together. image: geerlingguy/docker-ubuntu2604-ansible@sha256:a81f2af83a18b609eabdc926d56bc4813d3d7558d60dc891db6056f868fe6054 pre_build_image: true diff --git a/ansible/molecule/cloud-init-sponsord/prepare.yml b/ansible/molecule/cloud-init-publisher/prepare.yml similarity index 66% rename from ansible/molecule/cloud-init-sponsord/prepare.yml rename to ansible/molecule/cloud-init-publisher/prepare.yml index 0066ed9..8907c5f 100644 --- a/ansible/molecule/cloud-init-sponsord/prepare.yml +++ b/ansible/molecule/cloud-init-publisher/prepare.yml @@ -1,5 +1,5 @@ --- -# Everything the sponsor user-data needs from the outside world, stood up inside the +# Everything the publisher user-data needs from the outside world, stood up inside the # container (see molecule.yml). The secrets are NOT staged here: side_effect writes # them after the first boot, as the operator would. - name: Pack the working tree @@ -11,10 +11,16 @@ vars: stub_version: "0.0.0-molecule-stub" stubs: "{{ lookup('ansible.builtin.env', 'MOLECULE_PROJECT_DIRECTORY') }}/molecule" - # One v release holding both archives under one SHA256SUMS, as - # decdn/sponsord releases its workspace. + # Two repositories' releases of the same version, so each gets its own base + # (converge.yml): decdn/decdn's node and CLI (../cloud-init's stub), and + # decdn/sponsord's one release holding both archives under one SHA256SUMS, as it + # releases its workspace. mirror_releases: - - dir: "v{{ stub_version }}" + - dir: "decdn/v{{ stub_version }}" + bins: + - {name: decdn-node, src: "{{ stubs }}/default/files/decdn-node-stub"} + - {name: decdn, src: "{{ stubs }}/default/files/decdn-node-stub"} + - dir: "sponsord/v{{ stub_version }}" bins: - {name: sponsord, src: "{{ stubs }}/sponsord/files/sponsord-stub"} - {name: sponsord-onramp, src: "{{ stubs }}/sponsord-onramp/files/sponsord-onramp-stub"} diff --git a/ansible/molecule/cloud-init-sponsord/side_effect.yml b/ansible/molecule/cloud-init-publisher/side_effect.yml similarity index 80% rename from ansible/molecule/cloud-init-sponsord/side_effect.yml rename to ansible/molecule/cloud-init-publisher/side_effect.yml index 1d155e0..87ad2b3 100644 --- a/ansible/molecule/cloud-init-sponsord/side_effect.yml +++ b/ansible/molecule/cloud-init-publisher/side_effect.yml @@ -2,11 +2,12 @@ # First, the inventory mistakes stage 2 must refuse, each recording "failed" and saying # so at login (../cloud-init/includes/refuse.yml). Each case costs about a minute: pip # and Galaxy run before the check. -# Then the operator's part, as the "Sponsor host" walkthrough in cloud-init/README.md +# Then the operator's part, as the "Publisher host" walkthrough in cloud-init/README.md # tells it: -# 1. Write sponsord's three secrets only, and re-run. The onramp's Turnstile secret is -# still missing, so the run must stop at the gate again, listing just that file: -# the gate covers every group the host is in, not only the first. +# 1. Write the node's secret and sponsord's three only, and re-run. The onramp's +# Turnstile secret is still missing, so the run must stop at the gate again, +# listing just that file: the gate covers every group the host is in, not only +# the first. # 2. Write the Turnstile secret, move DEVOPS_REF to a tag on a newer commit (the # upgrade path), and re-run for the whole playbook. The values are placeholders: # the stubs never dial the RPC URL or Cloudflare. @@ -22,14 +23,22 @@ dest: "{{ bootstrap_inventory }}" content: >- {{ good_inventory.content | b64decode - | regex_replace('(?m)^sponsord_hosts:$', 'molecule_hosts:') }} + | regex_replace('(?m)^sponsord_hosts:$', 'molecule_hosts:') + | regex_replace('(?m)^decdn_nodes:$', 'molecule_nodes:') }} mode: "0600" expect: neither decdn_nodes nor sponsord_hosts + - name: the origin outside decdn_nodes + dest: "{{ bootstrap_inventory }}" + content: >- + {{ good_inventory.content | b64decode + | regex_replace('(?m)^decdn_nodes:$', 'molecule_nodes:') }} + mode: "0600" + expect: in decdn_origin_nodes but not in decdn_nodes - name: the onramp outside sponsord_hosts dest: "{{ bootstrap_inventory }}" content: >- {{ good_inventory.content | b64decode - | regex_replace('(?m)^sponsord_hosts:$', 'decdn_nodes:') }} + | regex_replace('(?m)^sponsord_hosts:$', 'molecule_hosts:') }} mode: "0600" expect: in sponsord_onramp_hosts but not in sponsord_hosts tasks: @@ -97,13 +106,22 @@ group: root mode: "0600" - - name: Create /etc/sponsord + - name: Create /etc/decdn and /etc/sponsord ansible.builtin.file: - path: /etc/sponsord + path: "{{ item }}" state: directory owner: root group: root mode: "0700" + loop: [/etc/decdn, /etc/sponsord] + + - name: Write the node's RPC endpoint on the host + ansible.builtin.copy: + dest: /etc/decdn/decdn.env + content: "DECDN_RPC_URL=https://rpc.example.invalid/\n" + owner: root + group: root + mode: "0600" - name: Write sponsord's secrets on the host ansible.builtin.copy: @@ -129,10 +147,11 @@ - name: Read the state and the list of missing secrets after the partial re-run ansible.builtin.include_tasks: ../cloud-init/includes/read-state.yml - - name: Stat the daemon (not installed while any secret is missing) + - name: Stat the daemons (not installed while any secret is missing) ansible.builtin.stat: - path: /usr/local/bin/sponsord + path: "{{ item }}" register: partial_bin + loop: [/usr/local/bin/sponsord, /usr/local/bin/decdn-node] - name: Assert the gate still holds, for the Turnstile secret alone ansible.builtin.assert: @@ -142,11 +161,12 @@ - awaiting.content | b64decode | split | list == ['/etc/sponsord/turnstile-secret'] - partial.stdout is search('Cloudflare Turnstile secret') - partial.stdout is not search('treasury wallet') - - not partial_bin.stat.exists + - partial.stdout is not search('RPC endpoint') + - partial_bin.results | selectattr('stat.exists') | list | length == 0 fail_msg: >- rc={{ partial.rc }} state={{ bootstrap_state.content | b64decode | trim }} awaiting={{ awaiting.content | b64decode | to_json }} - sponsord installed={{ partial_bin.stat.exists }} + installed={{ partial_bin.results | selectattr('stat.exists') | map(attribute='item') | list }} output={{ (partial.stdout_lines[-30:] + partial.stderr_lines) | to_json }} - name: Write the Turnstile secret on the host diff --git a/ansible/molecule/cloud-init-sponsord/verify.yml b/ansible/molecule/cloud-init-publisher/verify.yml similarity index 73% rename from ansible/molecule/cloud-init-sponsord/verify.yml rename to ansible/molecule/cloud-init-publisher/verify.yml index df20b9f..b6e0194 100644 --- a/ansible/molecule/cloud-init-sponsord/verify.yml +++ b/ansible/molecule/cloud-init-publisher/verify.yml @@ -1,7 +1,8 @@ --- -# After the secrets: the host moved to the pinned tag, both services came from their -# (mirrored, signed) releases and are running, sponsord generated its own API token, -# and Caddy serves the onramp over https. The toolchain pins are ../cloud-init's check. +# After the secrets: the host moved to the pinned tag, the origin node and both sponsor +# services came from their (mirrored, signed) releases and are running, with the +# origin backend in node.toml, sponsord generated its own API token, and Caddy serves +# the onramp over https. The toolchain pins are ../cloud-init's check. - name: Verify hosts: all become: true @@ -31,13 +32,25 @@ ansible.builtin.slurp: src: "/usr/local/lib/{{ item }}/installed-version" register: stamps - loop: [sponsord, sponsord-onramp] + loop: [decdn, sponsord, sponsord-onramp] - name: Stat the host-generated API token ansible.builtin.stat: path: /etc/sponsord/api-token register: api_token + - name: Read the origin backend from the node's config + ansible.builtin.command: + argv: + - python3 + - -c + - | + import json, tomllib + with open("/etc/decdn/node.toml", "rb") as f: + print(json.dumps(tomllib.load(f).get("cache", {}).get("origin"))) + register: node_origin + changed_when: false + - name: Gather service facts ansible.builtin.service_facts: @@ -56,9 +69,10 @@ register: login_hint changed_when: false - - name: Assert both services are installed from their releases and running + - name: Assert every service is installed from its release and running vars: - services: ['caddy.service', 'sponsord-onramp.service', 'sponsord.service'] + services: ['caddy.service', 'decdn-node.service', 'sponsord-onramp.service', 'sponsord.service'] + origin: "{{ node_origin.stdout | from_json }}" # A unit that is absent drops out here, rather than failing the template. units: "{{ ansible_facts.services | dict2items | selectattr('key', 'in', services) }}" running: "{{ units | selectattr('value.state', 'equalto', 'running') | map(attribute='key') | sort }}" @@ -69,6 +83,7 @@ - checkout_head.content | b64decode | trim == tag_sha.content | b64decode | trim - stamps.results | map(attribute='content') | map('b64decode') | map('trim') | unique == ['0.0.0-molecule-stub'] + - "origin == {'kind': 'http', 'url': 'https://content.example.invalid/'}" - api_token.stat.exists - api_token.stat.mode == '0600' - running == services @@ -78,6 +93,7 @@ state={{ bootstrap_state.content | b64decode | trim }} awaiting={{ awaiting.stat.exists }} head={{ checkout_head.content | b64decode | trim }} (tag {{ tag_sha.content | b64decode | trim }}) stamps={{ stamps.results | map(attribute='content') | map('b64decode') | list }} + origin={{ origin | to_json }} api-token={{ api_token.stat.mode | default('absent') }} running={{ running }} of {{ services }} https rc={{ https_health.rc }} {{ https_health.stderr }} diff --git a/ansible/molecule/cloud-init/converge.yml b/ansible/molecule/cloud-init/converge.yml index e04b250..168e978 100644 --- a/ansible/molecule/cloud-init/converge.yml +++ b/ansible/molecule/cloud-init/converge.yml @@ -1,5 +1,5 @@ --- -# First boot. The seed is cloud-init/user-data.yaml as committed, with each MUST-EDIT +# First boot. The seed is cloud-init/user-data-node.yaml as committed, with each MUST-EDIT # filled in the way an operator would and the three outside dependencies redirected # (repo, release mirror, signing key); includes/boot.yml seeds it and boots. # @@ -10,7 +10,7 @@ hosts: all become: true vars: - userdata_src: "{{ lookup('ansible.builtin.env', 'MOLECULE_PROJECT_DIRECTORY') }}/../cloud-init/user-data.yaml" + userdata_src: "{{ lookup('ansible.builtin.env', 'MOLECULE_PROJECT_DIRECTORY') }}/../cloud-init/user-data-node.yaml" # Stood up by prepare.yml. mirror_base: http://127.0.0.1:8099/releases mirror_key: /srv/mirror/KEYS.asc diff --git a/ansible/molecule/cloud-init/includes/refuse.yml b/ansible/molecule/cloud-init/includes/refuse.yml index 52a0838..443d8c4 100644 --- a/ansible/molecule/cloud-init/includes/refuse.yml +++ b/ansible/molecule/cloud-init/includes/refuse.yml @@ -1,5 +1,5 @@ --- -# One refusal case for ../side_effect.yml and ../../cloud-init-sponsord/side_effect.yml: +# One refusal case for ../side_effect.yml and ../../cloud-init-publisher/side_effect.yml: # write the broken file (refusal.dest, bootstrap.env unless set), run the bootstrap, # and check that it refused for the stated reason, recorded "failed" and did not move # the checkout. diff --git a/ansible/molecule/cloud-init/molecule.yml b/ansible/molecule/cloud-init/molecule.yml index 1ae0f7b..585a1dd 100644 --- a/ansible/molecule/cloud-init/molecule.yml +++ b/ansible/molecule/cloud-init/molecule.yml @@ -1,12 +1,12 @@ --- -# Boots the REAL cloud-init/user-data.yaml in a systemd container and follows the +# Boots the REAL cloud-init/user-data-node.yaml in a systemd container and follows the # operator walkthrough in cloud-init/README.md end to end: # prepare installs cloud-init. It stands up a local release mirror: the shared stub # daemon packaged as release tarballs, with a SHA256SUMS signed by a # throwaway GPG key, served on loopback. It also commits the working tree # into a git repo in the container, for stage 1 to clone. # (prepare and converge are built from pack.yml and includes/, which -# the ../cloud-init-sponsord scenario shares.) +# the ../cloud-init-publisher scenario shares.) # converge seeds NoCloud with the user-data (placeholders filled, the repo, mirror # and keyring pointed at the local ones) and runs cloud-init's boot stages. # It then asserts that the host stopped cleanly at the secret gate. @@ -33,7 +33,7 @@ # Oldest and newest supported distributions only. They bracket the controller Pythons # that cloud-init/requirements.txt pins two ansible-core versions for (3.11 gets 2.19, # 3.14 gets 2.21). The image digests are the same pins as ../default, ../os-matrix and -# ../cloud-init-sponsord; bump them together. +# ../cloud-init-publisher; bump them together. # # The stages need network access: apt (the user-data's packages), PyPI (the pinned # ansible-core) and Galaxy (the pinned collections). diff --git a/ansible/molecule/cloud-init/prepare.yml b/ansible/molecule/cloud-init/prepare.yml index d33e6f1..2b9efed 100644 --- a/ansible/molecule/cloud-init/prepare.yml +++ b/ansible/molecule/cloud-init/prepare.yml @@ -1,6 +1,6 @@ --- # Everything the user-data needs from the outside world, stood up inside the container -# by the includes the cloud-init-sponsord scenario shares: +# by the includes the cloud-init-publisher scenario shares: # * cloud-init itself, and the repo committed from the WORKING TREE # (pack.yml, includes/repo.yml); # * a release mirror: the shared stub daemon packaged as the node's two release diff --git a/ansible/roles/sponsord/README.md b/ansible/roles/sponsord/README.md index 48ee9b3..bbba58a 100644 --- a/ansible/roles/sponsord/README.md +++ b/ansible/roles/sponsord/README.md @@ -228,7 +228,7 @@ export it, so watch the address yourself. members, decommission refused for a held top-up and a missing or wrong confirmation, then the units removed and every credential kept. - `molecule/grafana-cloud` co-locates sponsord with a node. -- `molecule/cloud-init-sponsord` deploys it from `cloud-init/user-data-sponsord.yaml` +- `molecule/cloud-init-publisher` deploys it from `cloud-init/user-data-publisher.yaml` (no control machine), in release mode, beside the onramp. - `molecule/validation-sponsord` holds the negative cases. diff --git a/ansible/roles/sponsord_onramp/README.md b/ansible/roles/sponsord_onramp/README.md index 07b7eda..bd0cf39 100644 --- a/ansible/roles/sponsord_onramp/README.md +++ b/ansible/roles/sponsord_onramp/README.md @@ -217,7 +217,7 @@ collector; its access logs are not shipped. - `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 real Alloy. -- `molecule/cloud-init-sponsord` boots `cloud-init/user-data-sponsord.yaml`, which +- `molecule/cloud-init-publisher` boots `cloud-init/user-data-publisher.yaml`, which installs both services in release mode from a locally signed mirror, and checks the https chain through Caddy. It is the only test of this role's release mode, whose tasks mirror the `sponsord` role's (`molecule/sponsord-install` tests those in more depth). diff --git a/cloud-init/README.md b/cloud-init/README.md index e7c9ba2..fbf5190 100644 --- a/cloud-init/README.md +++ b/cloud-init/README.md @@ -1,15 +1,16 @@ -# deCDN node (or sponsor host) from cloud-init user-data +# deCDN node (or publisher host) from cloud-init user-data -A deCDN node with no machine of your own in the loop. Paste -[`user-data.yaml`](user-data.yaml) into your provider's "create server" form, and a +A deCDN node, or a publisher's origin and sponsor, with no machine of your own in the +loop. Paste +[`user-data-node.yaml`](user-data-node.yaml) into your provider's "create server" form, and a fresh Debian 12/13 or Ubuntu 24.04/26.04 VM (x86_64 or aarch64) sets itself up. It hardens itself and stops to wait for its secrets (a node's is its RPC endpoint). You then SSH in once to write them. On the host it runs the [Ansible project](../ansible/README.md)'s `site.yml` against localhost. That means the same `baseline` hardening (nftables default-deny with only -SSH and the host's public services open: udp/4433 for a node, tcp/80 and tcp/443 for a -sponsor host; DevSec SSH/OS hardening, fail2ban, unattended upgrades) and the same +SSH and the host's public services open: udp/4433 for a node, an origin included, and +tcp/80 and tcp/443 for the onramp's Caddy; DevSec SSH/OS hardening, fail2ban, unattended upgrades) and the same `decdn_node` role (or the `sponsord` roles), with no second copy of any. Nearly every provider accepts cloud-init: Hetzner, DigitalOcean, OVHcloud, Vultr, AWS, Scaleway and others. @@ -18,24 +19,27 @@ Pick this path for one VM when you don't want a control machine. For a fleet, or repeated deploys from your workstation, use the [Ansible project](../ansible/README.md) directly. [`docs/requirements.md`](../docs/requirements.md) compares the paths. -There are two templates. They share the bootstrap and differ only in their inventory: +There are two templates, one per kind of operator. They share the bootstrap and differ +only in their inventory: -| Template | Host | Waits for | -|----------|------|-----------| -| [`user-data.yaml`](user-data.yaml) | a deCDN node | `/etc/decdn/decdn.env` | -| [`user-data-sponsord.yaml`](user-data-sponsord.yaml) | a sponsor host: `sponsord` and `sponsord-onramp` behind Caddy ([Sponsor host](#sponsor-host)) | `/etc/sponsord/{secret.env,treasury-keystore.json,treasury-password,turnstile-secret}` | +| Template | For | Host | Waits for | +|----------|-----|------|-----------| +| [`user-data-node.yaml`](user-data-node.yaml) | node operators | a deCDN cache node | `/etc/decdn/decdn.env` | +| [`user-data-publisher.yaml`](user-data-publisher.yaml) | publishers | an origin node, `sponsord` and `sponsord-onramp` behind Caddy ([Publisher host](#publisher-host)) | `/etc/decdn/decdn.env`, `/etc/sponsord/{secret.env,treasury-keystore.json,treasury-password,turnstile-secret}` | -To run both on one VM, copy the `decdn_nodes` group from `user-data.yaml` into the sponsor -template's inventory, leaving out its `baseline_*` settings. Ansible applies one -`baseline_sudo_users` list, not the union of two groups', so the lint wants it set once. -The bootstrap then waits for both sets of secrets. +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` +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. > **Releases only.** The node installs only from a GPG-verified release tarball > (`release` mode). The `manual` mode would install binaries that nothing verified, > and the Ansible path's `source` mode builds whatever its git ref points at (and > takes many minutes on first boot), so the lint refuses both. The release is the > one the roles pin at `DEVOPS_REF` (decdn/decdn `v0.0.1` today, decdn/sponsord -> `v0.0.2` for a sponsor host); set `decdn_node_version` in the user-data to pin +> `v0.0.2` for the sponsor); set `decdn_node_version` in the user-data to pin > another. `decdn_node_release_base` points the download at a mirror that serves > `v/{decdn-node,decdn}--.tar.gz`, `SHA256SUMS` and > `SHA256SUMS.asc`; the signature is still checked against deCDN's release keys. @@ -83,7 +87,7 @@ log is in `/var/log/cloud-init-output.log`. ## Set up -1. **Fill in the user-data.** Copy [`user-data.yaml`](user-data.yaml) and replace every +1. **Fill in the user-data.** Copy [`user-data-node.yaml`](user-data-node.yaml) and replace every `CHANGE_ME`: - `DEVOPS_REF`: a full 40-character commit SHA of this repo (recommended; it is verified after checkout) or a tag; a `node-collection-vX.Y.Z` or @@ -177,15 +181,25 @@ log is in `/var/log/cloud-init-output.log`. `/etc/decdn/keystore.password`. Fund the wallet before you run the command without `--dry-run`. -## Sponsor host +## Publisher host -[`user-data-sponsord.yaml`](user-data-sponsord.yaml) boots a host for the deCDN -onboarding sponsor: the [`sponsord`](../ansible/roles/sponsord/README.md) daemon, which -holds the treasury wallet, and -[`sponsord-onramp`](../ansible/roles/sponsord_onramp/README.md), its public gate, with -Caddy terminating TLS. The flow is the node's. Only the values and the secrets differ. +[`user-data-publisher.yaml`](user-data-publisher.yaml) boots what a publisher runs on +one host: -> **Releases only.** Both services install only from GPG-verified release tarballs +- an **origin node**: the deCDN node with an origin backend, the canonical source of + your namespace's content (`decdn_origin_nodes`, which is also in `decdn_nodes`); +- the deCDN onboarding sponsor: the [`sponsord`](../ansible/roles/sponsord/README.md) + daemon, which holds the treasury wallet and pays for your users' downloads, and + [`sponsord-onramp`](../ansible/roles/sponsord_onramp/README.md), its public gate, + with Caddy terminating TLS. + +The flow is the node's. Only the values and the secrets differ. Configuring the backend +does not make the node your origin on-chain: as the namespace's publisher you seat its +operator with `OriginAssignment.addOrigin` +([ADR 011](https://github.com/decdn/decdn/blob/main/adr/011-content-takedown.md#origin-assignment-authority)). +Until then it serves its bytes as a cache does. + +> **Releases only.** The sponsor's services install only from GPG-verified release tarballs > (`release` mode), and the lint refuses `manual` and `source`. Both come from one > decdn/sponsord release (`v`), the one the roles pin at `DEVOPS_REF` > unless the user-data sets `sponsord_version` and `sponsord_onramp_version`. @@ -199,24 +213,33 @@ Caddy terminating TLS. The flow is the node's. Only the values and the secrets d - check the `decdn` and `decdn-sponsored` releases the installers install: the roles' pins at `DEVOPS_REF` unless you set all four `sponsord_onramp_*_release` / `_sums_sha256` values. -2. **Fill in the user-data.** Replace every `CHANGE_ME`: `DEVOPS_REF` and - `baseline_sudo_users` (as for a node), `sponsord_pool_id`, - `sponsord_onramp_domain`, `sponsord_onramp_rpc_url` and +2. **Fill in the user-data.** Replace every `CHANGE_ME`: `DEVOPS_REF`, + `baseline_sudo_users` and `decdn_region` (as for a node), the origin backend + (`decdn_cache_origin_url` for an http store; or `decdn_cache_origin_kind: s3` with + its bucket and region, or `fs` with a path, as the template's comment shows), + `sponsord_pool_id`, `sponsord_onramp_domain`, `sponsord_onramp_rpc_url` and `sponsord_onramp_turnstile_sitekey`. - `sponsord_onramp_rpc_url` is served to every user, so it must be a **public** endpoint with no API key in it. sponsord's own RPC URL may embed a key, so it is written on the host instead (step 5). - Check the file with `make lint-cloud-init CLOUD_INIT_FILE=path/to/your-user-data.yaml`. -3. **Point DNS** for the domain (A/AAAA) at the VM, and open **tcp/80 and tcp/443** - in the provider's firewall, if it has one. The host's nftables already allows both. +3. **Point DNS** for the domain (A/AAAA) at the VM, and open **udp/4433** (the node) + and **tcp/80 and tcp/443** (Caddy) in the provider's firewall, if it has one. The + host's nftables already allows all three. Caddy needs them to get its certificate. 4. **Create the VM and wait for the first boot.** It ends at `awaiting-secret`, with - the four sponsor secrets listed in `/var/lib/decdn-bootstrap/awaiting`. -5. **Write the secrets on the host**, as your admin account: + the node's and the four sponsor secrets listed in `/var/lib/decdn-bootstrap/awaiting`. +5. **Write the secrets on the host**, as your admin account. The node's RPC endpoint, + and an S3 backend's keys if it has one, go in `/etc/decdn/decdn.env`: ```bash umask 077 + sudo mkdir -p /etc/decdn + echo 'DECDN_RPC_URL=https://…' | sudo tee /etc/decdn/decdn.env >/dev/null + # S3 origin backend only: + # printf 'AWS_ACCESS_KEY_ID=…\nAWS_SECRET_ACCESS_KEY=…\n' | sudo tee -a /etc/decdn/decdn.env >/dev/null + sudo chmod 600 /etc/decdn/decdn.env sudo mkdir -p /etc/sponsord sudo install -m 0600 treasury-keystore.json /etc/sponsord/treasury-keystore.json sudo install -m 0600 treasury-password /etc/sponsord/treasury-password @@ -228,9 +251,10 @@ Caddy terminating TLS. The flow is the node's. Only the values and the secrets d `secret.env` may hold `SPONSORD_RPC_URL` **only**: the role refuses any other key, because an `EnvironmentFile` would override every other setting. -6. **Install and start both services:** `sudo decdn-bootstrap`, which ends with - `decdn-bootstrap: complete`. The sponsord role generates sponsord's API token on - the host. sponsord starts only once its keystore decrypts and the treasury owns +6. **Install and start every service:** `sudo decdn-bootstrap`, which ends with + `decdn-bootstrap: complete`. The node generates its wallet on the host (fund and + stake it as for any node, then seat it as your origin), and the sponsord role + generates sponsord's API token there. sponsord starts only once its keystore decrypts and the treasury owns `sponsord_pool_id`. If either check fails, the run fails at sponsord's `/healthz` gate (`journalctl -u sponsord`). @@ -255,7 +279,7 @@ Day 2 is in the role READMEs. Rotate a secret by replacing its file and running - **Back up, migrate or decommission:** the host is an ordinary Ansible host. Add it to an inventory on your workstation with the same groups and variables and use `make backup`, `make decommission` and the rest - ([`docs/lifecycle.md`](../docs/lifecycle.md)). Both cover a sponsor host too: its + ([`docs/lifecycle.md`](../docs/lifecycle.md)). Both cover a publisher host too: its backup carries the treasury keystore and password, the API token and the Turnstile secret, and decommission keeps `/etc/sponsord` and never touches the pool on chain. From then on, @@ -325,12 +349,12 @@ What CI proves: - `make lint-cloud-init` (CI job `cloud-init`) checks the schema and the invariants above, and `make test-scripts` checks that it rejects broken variants. -- The molecule `cloud-init` scenario boots `user-data.yaml` with cloud-init in Debian +- The molecule `cloud-init` scenario boots `user-data-node.yaml` with cloud-init in Debian 12 and Ubuntu 26.04 containers. It covers the secret gate first. It then checks that stage 1 refuses a branch name, the placeholder ref, an unknown key and a loose file mode, each recording `failed`. Finally, after an upgrade to a tag and `decdn.env`, it covers a running node installed from a locally signed mirror. -- The molecule `cloud-init-sponsord` scenario boots `user-data-sponsord.yaml` in an +- The molecule `cloud-init-publisher` scenario boots `user-data-publisher.yaml` in an Ubuntu 26.04 container. It checks that stage 2 refuses an inventory with localhost in neither base group, and one with the onramp outside `sponsord_hosts`, each recording `failed`. It writes sponsord's secrets but not the Turnstile secret, and diff --git a/cloud-init/bootstrap.sh b/cloud-init/bootstrap.sh index 6bc7284..46951e3 100755 --- a/cloud-init/bootstrap.sh +++ b/cloud-init/bootstrap.sh @@ -5,16 +5,17 @@ # script from it. # # This script converges the host by running ansible/playbooks/site.yml against -# localhost. site.yml imports sponsord.yml, so one playbook serves a node -# (user-data.yaml), a sponsor host (user-data-sponsord.yaml) or both, by the inventory's -# groups: +# localhost. site.yml imports node.yml and publisher.yml, so one playbook serves a node +# operator's cache node (user-data-node.yaml), a publisher's host (user-data-publisher.yaml: +# an origin node, sponsord and its onramp) or any mix, by the inventory's groups: # 1. Install the pinned ansible-core into a venv, with hashes enforced # (requirements.txt), and the pinned Galaxy collections (collections.lock.yml). # 2. Syntax-check site.yml. Check that the inventory puts localhost in decdn_nodes or -# sponsord_hosts, and in sponsord_hosts whenever it is in sponsord_onramp_hosts. -# Without that membership the plays match no host and exit 0, and the firewall -# holes in playbooks/group_vars/ never load. Then check that, under -# `--tags baseline`, each decdn_nodes and sponsord_hosts play the host is in still +# sponsord_hosts, in decdn_nodes whenever it is in decdn_origin_nodes, and in +# sponsord_hosts whenever it is in sponsord_onramp_hosts. Without that membership +# the plays match no host and exit 0, and the firewall holes in +# playbooks/group_vars/ never load. Then check that, under `--tags baseline`, each +# play that runs on the host (node.yml's or origin.yml's, and sponsord.yml's) still # selects the baseline role (baseline-plays.sh), since phase 1 relies on it. # 3. Pick the phase. Each of the host's groups needs its secrets on the host (SECRETS # below): @@ -39,14 +40,17 @@ readonly AWAITING_FILE=$STATE_DIR/awaiting # The secrets each group's role reads on the host, at the role defaults that # `make lint-cloud-init` forbids a user-data to move: # decdn_nodes: decdn_env_file (roles/decdn_node/defaults/main.yml). +# decdn_origin_nodes: none of its own. An origin is in decdn_nodes too, whose +# decdn.env also carries an S3 backend's keys; an http or fs backend needs none. # sponsord_hosts: sponsord_secret_env_file, sponsord_treasury_keystore_file, # sponsord_treasury_password_file (roles/sponsord/defaults/main.yml). Not the API # token: the role generates it. # sponsord_onramp_hosts: sponsord_onramp_turnstile_secret_file # (roles/sponsord_onramp/defaults/main.yml). -readonly HOST_GROUPS=(decdn_nodes sponsord_hosts sponsord_onramp_hosts) +readonly HOST_GROUPS=(decdn_nodes decdn_origin_nodes sponsord_hosts sponsord_onramp_hosts) declare -rA SECRETS=( [decdn_nodes]=/etc/decdn/decdn.env + [decdn_origin_nodes]="" [sponsord_hosts]="/etc/sponsord/secret.env /etc/sponsord/treasury-keystore.json /etc/sponsord/treasury-password" [sponsord_onramp_hosts]=/etc/sponsord/turnstile-secret ) @@ -145,20 +149,29 @@ 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. +[[ -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. [[ -z ${member[sponsord_onramp_hosts]:-} || -n ${member[sponsord_hosts]:-} ]] \ || die "$INVENTORY puts localhost in sponsord_onramp_hosts but not in sponsord_hosts (the onramp runs beside the daemon)" echo "decdn-bootstrap: localhost is in ${groups_in[*]}" -# Phase 1 below relies on every decdn_nodes and sponsord_hosts play the host is in -# tagging the baseline role `baseline`. If one lost the role or its tag, -# --tags baseline would skip it and exit 0, and the host would be reported hardened -# without being so. The check is per play: --list-tasks lists every play, hostless -# ones too, so another group's play would satisfy a check over the whole output. +# Phase 1 below relies on every play that runs baseline on the host tagging the +# baseline role `baseline`. If one lost the role or its tag, --tags baseline would +# skip it and exit 0, and the host would be reported hardened without being so. The +# check is per play: --list-tasks lists every play, hostless ones too, so another +# group's play would satisfy a check over the whole output. An origin runs origin.yml's +# play, not node.yml's (decdn_nodes:!decdn_origin_nodes), so it is checked as +# decdn_origin_nodes. base_groups=() -for g in decdn_nodes sponsord_hosts; do - [[ -z ${member[$g]:-} ]] || base_groups+=("$g") -done +if [[ -n ${member[decdn_origin_nodes]:-} ]]; then + base_groups+=(decdn_origin_nodes) +elif [[ -n ${member[decdn_nodes]:-} ]]; then + base_groups+=(decdn_nodes) +fi +[[ -z ${member[sponsord_hosts]:-} ]] || base_groups+=(sponsord_hosts) "$repo/cloud-init/baseline-plays.sh" "$INVENTORY" "${base_groups[@]}" \ || die "--tags baseline would not harden this host (see above; was the baseline role or its tag removed from a play?)" @@ -202,6 +215,9 @@ The node's RPC endpoint (the URL may embed an API key): sudo mkdir -p /etc/decdn echo 'DECDN_RPC_URL=https://…' | sudo tee /etc/decdn/decdn.env >/dev/null sudo chmod 600 /etc/decdn/decdn.env +An origin with an S3 backend also needs its keys in that file, one per line: + AWS_ACCESS_KEY_ID=… + AWS_SECRET_ACCESS_KEY=… EOF ;; sponsord_hosts) diff --git a/cloud-init/tests/lint.py b/cloud-init/tests/lint.py index 3835f2d..8340a8f 100755 --- a/cloud-init/tests/lint.py +++ b/cloud-init/tests/lint.py @@ -2,7 +2,8 @@ """Check the invariants cloud-init/README.md promises for a user-data file. Usage: lint.py (normally via `make lint-cloud-init`, which checks -user-data.yaml, the node, and user-data-sponsord.yaml, the sponsor host) +user-data-node.yaml, a node operator's cache node, and user-data-publisher.yaml, a +publisher's origin node with sponsord and its onramp) `cloud-init schema` checks the file's shape, including the `#cloud-config` header. This script checks what the schema cannot: @@ -24,6 +25,7 @@ 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; 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; @@ -64,9 +66,14 @@ # A user-data that creates it, by write_files or a command, ships an unhardened host. TEST_ONLY_MARKER = "TEST-ONLY-skip-baseline" -# The groups a user-data may use. site.yml (which imports sponsord.yml) targets the -# first two; the onramp runs beside sponsord, so its group needs sponsord_hosts too. -GROUPS = ("decdn_nodes", "sponsord_hosts", "sponsord_onramp_hosts") +# The groups a user-data may use. site.yml (node.yml, then publisher.yml) targets them +# 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. +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 @@ -85,7 +92,7 @@ # Knobs a user-data may not set at all: a different signing key would make "verified" # meaningless, and bootstrap.sh's secret gate looks for the secrets at the roles' # default paths. sponsord's API token is always generated on the host. A cloud-init -# sponsor host runs on an operator-provisioned treasury wallet: the gate waits for +# sponsor runs on an operator-provisioned treasury wallet: the gate waits for # the keystore before the role runs, so the role's own wallet generation (and the # decdn CLI it ships for that) would never get the chance. FORBIDDEN_VARS = { @@ -114,6 +121,14 @@ # user-data ships it. A key in the URL's path cannot be told from a public path. "sponsord_onramp_rpc_url", } +# The origin backend fields each kind requires, as roles/decdn_node/tasks/main.yml +# asserts them: the scalar form's variable names, and the list form's keys. +ORIGIN_KINDS = { + "http": (("decdn_cache_origin_url",), ("url",)), + "fs": (("decdn_cache_origin_path",), ("path",)), + "s3": (("decdn_cache_origin_s3_bucket", "decdn_cache_origin_s3_region"), ("bucket", "region")), +} + # 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 # published the key through the instance metadata. @@ -275,6 +290,8 @@ def check_inventory(inventory): f"not {sorted(set(inventory) - set(GROUPS))}") if not BASE_GROUPS & set(present): violation("inventory: localhost must be in decdn_nodes or sponsord_hosts (site.yml's host patterns)") + if "decdn_origin_nodes" in present and "decdn_nodes" not in present: + violation("inventory: decdn_origin_nodes needs decdn_nodes too (an origin is a deCDN node)") if "sponsord_onramp_hosts" in present and "sponsord_hosts" not in present: violation("inventory: sponsord_onramp_hosts needs sponsord_hosts too (the onramp runs beside the daemon)") groups = {} @@ -323,6 +340,32 @@ def check_inventory(inventory): violation("inventory: decdn_node_generate_keystore must be true (the wallet is generated on the host)") 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 + # 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") + + def filled(v): + return isinstance(v, str) and v != "" + if isinstance(origins, list) and origins: + for i, o in enumerate(origins): + k = o.get("kind") if isinstance(o, dict) else None + if k not in ORIGIN_KINDS: + violation(f"inventory: decdn_origin_nodes.vars.decdn_cache_origins[{i}] needs a kind of " + "http, fs or s3") + elif not all(filled(o.get(f)) for f in ORIGIN_KINDS[k][1]): + violation(f"inventory: decdn_origin_nodes.vars.decdn_cache_origins[{i}] ({k}) needs " + f"{' and '.join(ORIGIN_KINDS[k][1])}") + elif not filled(kind): + violation("inventory: decdn_origin_nodes.vars needs an origin backend: decdn_cache_origin_kind " + "(http, fs or s3) and its fields, or decdn_cache_origins") + elif kind not in ORIGIN_KINDS: + violation(f"inventory: decdn_origin_nodes.vars.decdn_cache_origin_kind is {kind!r}, not http, fs or s3") + elif not all(filled(ov.get(f)) for f in ORIGIN_KINDS[kind][0]): + violation(f"inventory: decdn_origin_nodes.vars: an origin of kind {kind} needs " + f"{' and '.join(ORIGIN_KINDS[kind][0])}") for g, prefix in (("sponsord_hosts", "sponsord"), ("sponsord_onramp_hosts", "sponsord_onramp")): if g not in gv: continue diff --git a/cloud-init/user-data.yaml b/cloud-init/user-data-node.yaml similarity index 96% rename from cloud-init/user-data.yaml rename to cloud-init/user-data-node.yaml index 739dedb..6a48871 100644 --- a/cloud-init/user-data.yaml +++ b/cloud-init/user-data-node.yaml @@ -1,6 +1,6 @@ #cloud-config -# deCDN node bootstrap for a fresh Debian 12/13 or Ubuntu 24.04/26.04 VM (x86_64 or -# aarch64). Paste this into your provider's "user data" / "cloud-init" field when creating +# deCDN cache node bootstrap (node operators) for a fresh Debian 12/13 or Ubuntu +# 24.04/26.04 VM (x86_64 or aarch64). Paste this into your provider's "user data" / "cloud-init" field when creating # the server. The full walkthrough is in cloud-init/README.md of github.com/decdn/devops. # # Edit every value marked MUST-EDIT first. The bootstrap refuses to run while any @@ -15,7 +15,8 @@ # On first boot the host clones github.com/decdn/devops at DEVOPS_REF, installs a pinned # Ansible, and hardens itself (SSH, firewall, automatic patching). It then stops and waits # for its secret, /etc/decdn/decdn.env. `cloud-init status --wait` returns when it is done. -# For a sponsor host (sponsord, sponsord-onramp), start from user-data-sponsord.yaml. +# Publishers (an origin node, sponsord, sponsord-onramp) start from +# user-data-publisher.yaml. --- package_update: true packages: diff --git a/cloud-init/user-data-sponsord.yaml b/cloud-init/user-data-publisher.yaml similarity index 74% rename from cloud-init/user-data-sponsord.yaml rename to cloud-init/user-data-publisher.yaml index cc50f9d..e7b958e 100644 --- a/cloud-init/user-data-sponsord.yaml +++ b/cloud-init/user-data-publisher.yaml @@ -1,24 +1,29 @@ #cloud-config -# deCDN sponsor host bootstrap: sponsord (the onboarding sponsor's treasury signer and -# PaymentPool keeper) and sponsord-onramp (its public gate, behind Caddy), for a fresh -# Debian 12/13 or Ubuntu 24.04/26.04 VM (x86_64 or aarch64). Paste this into your -# provider's "user data" / "cloud-init" field when creating the server. The full -# walkthrough is in cloud-init/README.md of github.com/decdn/devops ("Sponsor host"). +# deCDN publisher host bootstrap: an origin node (the deCDN node with an origin backend, +# the canonical source of your namespace's content), sponsord (the onboarding sponsor's +# treasury signer and PaymentPool keeper) and sponsord-onramp (its public gate, behind +# Caddy), for a fresh Debian 12/13 or Ubuntu 24.04/26.04 VM (x86_64 or aarch64). Paste +# this into your provider's "user data" / "cloud-init" field when creating the server. +# 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). # # Edit every value marked MUST-EDIT first. The bootstrap refuses to run while any # CHANGE_ME is left. # # NEVER PUT A SECRET IN THIS FILE. Any local process can read user-data from the instance # metadata service, and your provider keeps a copy in its console and API. That rules out -# sponsord's RPC URL (it may embed an API key), the treasury keystore and its password, -# and the Turnstile secret. Everything below is public: an SSH *public* key, versions, a -# domain, a public RPC URL, a Turnstile sitekey. The secrets are written over SSH after -# the first boot, and sponsord's API token is generated on the host. +# the node's and sponsord's RPC URLs (they may embed an API key), S3 keys for the origin +# backend, the treasury keystore and its password, and the Turnstile secret. Everything +# below is public: an SSH *public* key, versions, a region, the origin's store, a domain, +# a public RPC URL, a Turnstile sitekey. The secrets are written over SSH after the first +# boot; the node's wallet and sponsord's API token are generated on the host. # # On first boot the host clones github.com/decdn/devops at DEVOPS_REF, installs a pinned -# Ansible, and hardens itself (SSH, firewall with tcp/80 and tcp/443 open for Caddy, -# automatic patching). It then stops and waits for its secrets under /etc/sponsord. -# `cloud-init status --wait` returns when it is done. +# Ansible, and hardens itself (SSH, firewall with udp/4433 open for the node and tcp/80 +# and tcp/443 for Caddy, automatic patching). It then stops and waits for its secrets +# under /etc/decdn and /etc/sponsord. `cloud-init status --wait` returns when it is done. --- package_update: true packages: @@ -39,14 +44,14 @@ write_files: # 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.yaml without its + # the same host, add the decdn_nodes group from user-data-node.yaml without its # baseline_* settings: the admin account is set once, below. - path: /etc/decdn-bootstrap/inventory.yml owner: root:root permissions: "0600" content: | --- - sponsord_hosts: + decdn_nodes: hosts: localhost: ansible_connection: local @@ -65,6 +70,38 @@ write_files: # Optional: accept SSH only from these CIDRs. # ssh_allow_cidrs: ["203.0.113.0/24"] + # The node, installed from the GPG-verified release tarballs. The release is + # the decdn_node role's default at DEVOPS_REF (decdn_node_version); set + # decdn_node_version here to pin another one. + decdn_node_install_method: release + # Optional: a mirror that serves v/{decdn-node,decdn}--.tar.gz, + # SHA256SUMS and SHA256SUMS.asc. The signature is still checked against + # deCDN's release keys. + # decdn_node_release_base: "https://mirror.example/decdn/releases/download" + decdn_node_generate_keystore: true # the wallet is generated on the host + decdn_network: arbitrum-sepolia + decdn_region: "CHANGE_ME" # MUST-EDIT: ISO 3166-1 alpha-2 of the VM's location, e.g. "DE" + + # The node is your origin: localhost is in decdn_origin_nodes too (a deCDN node with + # an origin backend). The chain recognises it as the origin once you seat its + # operator with OriginAssignment.addOrigin; until then it serves as a cache does. + decdn_origin_nodes: + hosts: + localhost: + vars: + # MUST-EDIT: where the origin reads your content. http shown; for S3 (R2, B2, + # MinIO) set decdn_cache_origin_kind: s3 with decdn_cache_origin_s3_bucket, + # decdn_cache_origin_s3_region (and decdn_cache_origin_s3_endpoint_url) and + # decdn_cache_origin_s3_use_default_chain: true, and put the keys in + # /etc/decdn/decdn.env on the host, never here. For a local path, kind fs and + # decdn_cache_origin_path. + decdn_cache_origin_kind: http + decdn_cache_origin_url: "CHANGE_ME" # MUST-EDIT: e.g. "https://content.example.org/" + + sponsord_hosts: + hosts: + localhost: + vars: # sponsord, installed from the GPG-verified release tarball. The release is # the sponsord role's default at DEVOPS_REF (sponsord_version); set # sponsord_version here, and sponsord_onramp_version to the same, to pin diff --git a/tests/scripts-test.sh b/tests/scripts-test.sh index db83b9d..97f7308 100755 --- a/tests/scripts-test.sh +++ b/tests/scripts-test.sh @@ -1038,8 +1038,8 @@ variant "caddy always on" "caddy: profiles are [caddy]" # Skipped without cloud-init on PATH, except in CI (which installs it), so a broken # install step cannot quietly drop these cases. if command -v cloud-init >/dev/null; then - userdata="$repo/cloud-init/user-data.yaml" - sponsorud="$repo/cloud-init/user-data-sponsord.yaml" + userdata="$repo/cloud-init/user-data-node.yaml" + sponsorud="$repo/cloud-init/user-data-publisher.yaml" expect 0 "lint-cloud-init accepts both cloud-init/ templates" make -s -C "$repo" lint-cloud-init expect 0 "lint-cloud-init accepts the sponsor template alone" make -s -C "$repo" lint-cloud-init CLOUD_INIT_FILE="$sponsorud" sed '1s/^#cloud-config$/# cloud-config/' "$userdata" > "$work/ci-header.yaml" @@ -1050,7 +1050,7 @@ if command -v cloud-init >/dev/null; then pass "lint-cloud-init rejects: no #cloud-config header (schema)" # [