From a117deacddad4629d6c1e697da5ba85aeac0cd7f Mon Sep 17 00:00:00 2001 From: Ant Somers Date: Wed, 14 Oct 2026 12:50:42 +0300 Subject: [PATCH] ci: validate the chart against the pinned decdn release and drop version-pinned prose CI's helm job now runs make lint-helm with the decdn CLI of the release the chart pins (appVersion), fetched by the new scripts/fetch-decdn-cli.sh and verified as the decdn_node role verifies a release: a good signature from a still-valid key in the vendored KEYS (gpg exit 0, VALIDSIG, no EXPKEYSIG/REVKEYSIG/EXPSIG), exactly one manifest entry for the archive and `decdn ` from the binary. Every chart PR and every decdn bump now gets the real `decdn config validate`. bump_decdn.py rewrite also moves the commented examples keyed by a variable name (both inventory trees, cloud-init/user-data-publisher.yaml) and AGENTS.md's pinned decdn version; prose that restated a pinned decdn or sponsord version now names the pin instead, and make test-scripts checks AGENTS.md's numbers against the role defaults. open-decdn-bump.sh drops the old-version grep and its "Before merging" checklist: a bump PR with no "Needs work" list and a green CI needs no hand edits. Closes #145 Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 21 ++-- AGENTS.md | 24 ++-- CONTRIBUTING.md | 2 +- ansible/README.md | 7 +- ansible/galaxy/publisher/README.md | 4 +- .../group_vars/sponsord_onramp_hosts.yml | 3 +- ansible/roles/decdn_node/README.md | 41 ++++--- ansible/roles/sponsord/README.md | 21 ++-- ansible/roles/sponsord/tasks/main.yml | 2 +- ansible/roles/sponsord_onramp/README.md | 10 +- ansible/roles/sponsord_onramp/tasks/main.yml | 2 +- charts/decdn-node/README.md | 14 ++- charts/decdn-node/tests/render-test.sh | 3 +- cloud-init/README.md | 6 +- compose/.env.example | 2 +- compose/README.md | 2 +- .../group_vars/sponsord_onramp_hosts.yml | 3 +- scripts/bump_decdn.py | 35 +++++- scripts/fetch-decdn-cli.sh | 108 +++++++++++++++++ scripts/open-decdn-bump.sh | 43 ++----- scripts/tests/test_bump_decdn.py | 32 +++++ tests/scripts-test.sh | 109 +++++++++++++++++- 22 files changed, 385 insertions(+), 109 deletions(-) create mode 100755 scripts/fetch-decdn-cli.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d99bf0f..2204c91 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -44,11 +44,14 @@ jobs: - '.github/workflows/ci.yml' # The chart shares the schema-key inventory and checker with molecule, # and renders monitoring/decdn-node/ through a symlink; lint-helm also - # checks monitoring/sponsord/ and monitoring/iroh-relay/. + # checks monitoring/sponsord/ and monitoring/iroh-relay/. The decdn CLI + # it validates against is verified with the role's vendored KEYS. helm: - 'charts/**' - 'monitoring/**' - 'ansible/molecule/schema/files/**' + - 'ansible/roles/decdn_node/files/decdn-release-KEYS.asc' + - 'scripts/fetch-decdn-cli.sh' - 'Makefile' - '.github/workflows/ci.yml' # lint-compose and security-compose live in the root Makefile. @@ -190,8 +193,9 @@ jobs: if-no-files-found: ignore # Helm chart: `helm lint --strict`, positive + negative render tests, kubeconform - # and promtool (digest-pinned images) and the upstream schema-key check shared - # with molecule — all via `make lint-helm`, the same command developers run locally. + # and promtool (digest-pinned images), the upstream schema-key check shared + # with molecule and the real `decdn config validate` — all via `make lint-helm`, + # the same command developers run locally. helm: needs: changes if: needs.changes.outputs.helm == 'true' @@ -208,10 +212,13 @@ jobs: with: python-version: '3.12' - name: Chart lint + render tests - # yq (mikefarah) and Docker ship on ubuntu-latest. No decdn binary here, so - # the real `decdn config validate` step reports SKIPPED; run it locally with - # DECDN_CLI=... before bumping the decdn version. - run: make lint-helm + # yq (mikefarah), curl, gpg and Docker ship on ubuntu-latest. The decdn CLI is the + # release the chart pins (appVersion), signature-checked against the vendored + # KEYS, so a decdn bump PR validates the chart against the new release. + run: | + DECDN_CLI="$(scripts/fetch-decdn-cli.sh "$RUNNER_TEMP/decdn")" + export DECDN_CLI + make lint-helm # cloud-init path: `cloud-init schema` plus the invariants cloud-init/README.md # promises (no secrets, release installs, host-generated node wallet, localhost in diff --git a/AGENTS.md b/AGENTS.md index ad2f87c..20edca2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -121,7 +121,7 @@ monitoring/ # Grafana dashboards + Prometheus alert rules (maintaine iroh-relay/ # the relay's (+ promtool unit tests, exported-metrics.txt), Ansible and Compose docs/ # cross-path operator docs: node-operators.md + publishers.md (the front doors), requirements.md, lifecycle.md examples/collection-fleet/ # a consumer project for the Galaxy collections: requirements.yml, ansible.cfg, .gitignore, inventory/ (ansible/inventory/'s templates: every tracked file identical but the hosts-*.yml.example headers, which end at the first blank line and hold only comments; make test-scripts checks, and that both parse to the same inventory, so change both. The root .gitignore keeps a hosts.yml filled in there out of this public repo) -scripts/ # upstream-mirror generators, the compose Alloy config generator (render-compose-alloy.sh), the decdn bump (open-decdn-bump.sh, bump_decdn.py), the release script (release.sh, git-cliff: ../cliff.toml) and its gate (+ its Artifact Hub changes generator), the molecule driver (molecule.sh), ansible/Makefile's LIMIT preflight (limit-guard.sh) +scripts/ # upstream-mirror generators, the compose Alloy config generator (render-compose-alloy.sh), the decdn bump (open-decdn-bump.sh, bump_decdn.py), the chart check's verified decdn CLI (fetch-decdn-cli.sh), the release script (release.sh, git-cliff: ../cliff.toml) and its gate (+ its Artifact Hub changes generator), the molecule driver (molecule.sh), ansible/Makefile's LIMIT preflight (limit-guard.sh) ``` **Generated mirrors of upstream — regenerate, never hand-edit:** @@ -148,9 +148,13 @@ tags) are pinned across every path: the role defaults, the onramp's installer pi (`sponsord_onramp_decdn_release` + `_decdn_sums_sha256`, copied into `compose/sponsord-onramp.env.example`), `compose/.env.example`'s image digests, the chart's `appVersion` and the generated mirrors. cloud-init takes the role defaults at -`DEVOPS_REF`. `make test-scripts` checks the pins agree with each other, the Compose -example, `appVersion` and the chart's `artifacthub.io/images` tag, and that both -vendored KEYS hold the keys SECURITY.md publishes; the weekly `upstream-drift` job checks the digests and the mirrors against +`DEVOPS_REF`. Docs name the pin (`decdn_node_version`, `sponsord_version`), never its +number: besides this paragraph, only the commented examples and the comments beside a +pinned value name a number, and they move with the pin (`bump_decdn.py` moves decdn's; +the sponsord checklist lists sponsord's). `make test-scripts` checks the pins agree +with each other, the Compose example, `appVersion`, the chart's `artifacthub.io/images` +tag and this paragraph, and that both vendored KEYS hold the keys SECURITY.md +publishes; the weekly `upstream-drift` job checks the digests and the mirrors against the releases and flags a newer upstream tag. The `--version` check compares the pin in `release` mode only (exactly, on ` `). Bump everything together with the checklists in `roles/decdn_node/README.md` and `roles/sponsord/README.md`; @@ -196,7 +200,7 @@ the pinned `iroh-relay` (re-capture it when bumping `iroh_relay_version`). the publisher's step). **The public deCDN node** (baseline + `decdn-node`), installed by `decdn_node_install_method`: `release` (the default) — a pinned GitHub release tarball verified against the - release's GPG-signed `SHA256SUMS` (`decdn_node_version`, default `0.0.4`); `source` — a git ref (any tag/branch/SHA) cloned and `cargo build`-ed **on the + release's GPG-signed `SHA256SUMS` (`decdn_node_version`); `source` — a git ref (any tag/branch/SHA) cloned and `cargo build`-ed **on the node** as the unprivileged `decdn-build` user, sha256-pinned rustup, a `@` stamp (`tasks/source.yml`, copied into both sponsord roles, which share the user, home and toolchain). **No build can poison a later one:** root owns the home, the @@ -293,7 +297,7 @@ the pinned `iroh-relay` (re-capture it when bumping `iroh_relay_version`). imported by `publisher.yml`) targets its own `sponsord_hosts` group, standalone or co-located with a node. - **Install:** a GPG-verified `v*` decdn/sponsord release by default - (`sponsord_version`, `0.0.4`), a host-side `source` build, or a manual binary. + (`sponsord_version`), a host-side `source` build, or a manual binary. The `--version` backstop checks the version in `release` mode only (the version has a default, so other modes would fail against it), as in the other two roles. - **Unit:** `DynamicUser`; the API token (generated on the host, never replaced), @@ -656,9 +660,11 @@ the pinned `iroh-relay` (re-capture it when bumping `iroh_relay_version`). behind a ClusterIP Service + NetworkPolicy. `values.config` mirrors `node.toml`; the chart injects the path/port keys and fails on collisions. **The config-schema coupling above applies here too:** `make lint-helm` runs the same `check-schema-keys.py` + - `schema-keys.txt` on the rendered ConfigMap, so a re-sync covers both paths. Unlike the - role, CI has no real-binary `decdn config validate` for the chart — run - `DECDN_CLI=… make lint-helm` locally when bumping the decdn version. Optional + `schema-keys.txt` on the rendered ConfigMap, so a re-sync covers both paths. CI's + `helm` job also runs the real `decdn config validate` on each `ci/*.yaml` render, against the + `decdn` of the release `appVersion` pins (`scripts/fetch-decdn-cli.sh`: the role's + GPG check against the vendored KEYS), so a decdn bump PR validates the chart against + the new release. Optional `PrometheusRule` + dashboard ConfigMaps render `files/monitoring/`, the symlink to `monitoring/decdn-node/` (never through `tpl`: the alert annotations carry Prometheus templates). diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3d00d42..2ec2185 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -38,7 +38,7 @@ targets, so a local pass means a CI pass. Deploy targets live in | `make lint` | every pre-commit hook on every file (CI job `pre-commit`) | | `make lint-ansible` | install Galaxy collections + `ansible-lint` (production profile, which includes the Ansible security rules) | | `make molecule` | every `ansible/molecule/*/` scenario in parallel, in privileged systemd containers (needs Docker and util-linux `flock`; cap with `JOBS=`, pick some with `SCENARIOS='a b'`). `make molecule-serial` runs them one at a time for readable failures. Each scenario takes its own host-wide lock, so two runs can share a host as long as they share no scenario (a run that would refuses to start: runs of one scenario share its container names). `make molecule-list` prints the selection as JSON (CI's matrix). The driver is `scripts/molecule.sh`. | -| `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-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 does, with the pinned release's (`cli="$(scripts/fetch-decdn-cli.sh )" && DECDN_CLI="$cli" make lint-helm`). | | `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. It also checks that `compose/alloy/config.alloy` (the Compose `alloy` profile's render of the same template) is current, and runs it in Alloy: every component healthy, each daemon scraped only under the profiles that start it. `ALLOY_BIN=` skips the download. | | `make lint-compose` | reads `compose/compose.yaml` as written (`yq`) against `compose/tests/bind-sources.jq` (every bind mount's `create_host_path`), then renders it with every profile on (with its example env, without env files, with an empty `.env`) and asserts `compose/tests/invariants.jq`, `inline-env.jq` and `fail-closed.jq` | | `make lint-cloud-init` | `cloud-init schema` on `cloud-init/user-data-node.yaml` and `cloud-init/user-data-publisher.yaml`, then `cloud-init/tests/lint.py` on each: only the templates' top-level modules and no YAML anchors, no secrets (only the bootstrap's own files, once each, plain `content` only, no secret-looking keys or assignments), no hardening skip, `release` installs verified against the vendored keys with a host-generated node wallet (trust knobs only in their own group's `vars`, no moved keyring or secret path), only localhost, in `decdn_nodes` and/or `sponsord_hosts` (an origin only beside `decdn_nodes` and with a backend, every backend key only in `decdn_origin_nodes.vars`, the onramp only beside sponsord), a keyed admin account, `runcmd` exactly stage 1, shellcheck-clean scripts, and a collection lock that covers `ansible/requirements.yml` (needs `cloud-init`, `shellcheck`, `yq`). `CLOUD_INIT_FILE=` checks your own filled-in copy. | diff --git a/ansible/README.md b/ansible/README.md index 0979bdf..c70abd0 100644 --- a/ansible/README.md +++ b/ansible/README.md @@ -183,8 +183,9 @@ ignored on the first converge. 1. **Binaries.** Pick `decdn_node_install_method`: - **`release`** (the default) downloads the `v` tarballs from `decdn_node_release_base` and verifies them against the GPG-signed `SHA256SUMS`. - `decdn_node_version` defaults to the decdn/decdn release this repo pins - (`0.0.4`); the sponsord roles pin decdn/sponsord `0.0.4` the same way. + `decdn_node_version` defaults to the decdn/decdn release this repo pins (the + role's `defaults/main.yml`); the sponsord roles pin a decdn/sponsord release the + same way (`sponsord_version`). - **`source`** clones `decdn_node_source_repo` (default `decdn/decdn` on GitHub) at `decdn_node_source_ref` (any tag, branch or SHA) **on the node** and builds it there as an unprivileged `decdn-build` user, in a fresh environment per commit, with a @@ -508,7 +509,7 @@ the RPC URL when it is not provisioned on the host instead). Highlights: | `baseline_extra_inbound` | `[]` | public inbound ports; `decdn_nodes` opens udp/4433. | | `baseline_preserve_ipv6_autoconf` | `true` | Keep IPv6 RA/autoconf under hardening; set `false` for static-IPv6 hosts. | | `baseline_rp_filter_loose` | `false` | `true` loosens reverse-path filtering (`rp_filter=2`) for multi-homed nodes. | -| `decdn_node_install_method` | `release` | `release` (the pinned `decdn_node_version`, `0.0.4`), `source` (build `decdn_node_source_ref` on the node) or `manual` (copy a control-machine build). | +| `decdn_node_install_method` | `release` | `release` (the pinned `decdn_node_version`), `source` (build `decdn_node_source_ref` on the node) or `manual` (copy a control-machine build). | | `decdn_node_source_repo` / `decdn_node_source_ref` | `decdn/decdn` on GitHub / `""` | `source` only: the repo (no credentials in the URL) and the tag, branch or SHA to build. | | `decdn_node_target` | from the host | The release target triple, derived from the host architecture (x86_64 or aarch64). | | `decdn_network` | `""` | `arbitrum-sepolia` sets `chain_id` and every contract address from the role's manifest mirror; inventory values still win. `""` = set them yourself. | diff --git a/ansible/galaxy/publisher/README.md b/ansible/galaxy/publisher/README.md index c0a648b..bc866a4 100644 --- a/ansible/galaxy/publisher/README.md +++ b/ansible/galaxy/publisher/README.md @@ -12,8 +12,8 @@ them and `decdn.node`'s (see [Usage](#usage)): | Role | Purpose | |------|---------| -| `decdn.publisher.sponsord` | The `sponsord` onboarding sponsor (treasury signer + PaymentPool keeper), standalone or beside a node — local binary, a host-side source build or a GPG-verified decdn/sponsord `v*` release (pinned to 0.0.4), `DynamicUser` unit with the API token and treasury wallet as systemd credentials, loopback-only API, `/healthz` deploy gate. `sponsord_network` sets the chain and PaymentPool from upstream's manifest. Restarts refuse while a pool top-up is held; `tasks_from: backup` / `decommission` for day 2. | -| `decdn.publisher.sponsord_onramp` | `sponsord-onramp`, sponsord's public side (Turnstile gate, installers, capability API), on the daemon's host — local binary, a host-side source build or a GPG-verified decdn/sponsord `v*` release (pinned to 0.0.4), `DynamicUser` unit with the daemon token and Turnstile secret as systemd credentials, loopback listener behind Caddy (distro package, ACME TLS, admin API off; or your own proxy), `/healthz` deploy gate; `tasks_from: decommission`. | +| `decdn.publisher.sponsord` | The `sponsord` onboarding sponsor (treasury signer + PaymentPool keeper), standalone or beside a node — local binary, a host-side source build or a GPG-verified decdn/sponsord `v*` release (pinned by `sponsord_version`), `DynamicUser` unit with the API token and treasury wallet as systemd credentials, loopback-only API, `/healthz` deploy gate. `sponsord_network` sets the chain and PaymentPool from upstream's manifest. Restarts refuse while a pool top-up is held; `tasks_from: backup` / `decommission` for day 2. | +| `decdn.publisher.sponsord_onramp` | `sponsord-onramp`, sponsord's public side (Turnstile gate, installers, capability API), on the daemon's host — local binary, a host-side source build or a GPG-verified decdn/sponsord `v*` release (pinned by `sponsord_onramp_version`, kept equal to `sponsord_version`), `DynamicUser` unit with the daemon token and Turnstile secret as systemd credentials, loopback listener behind Caddy (distro package, ACME TLS, admin API off; or your own proxy), `/healthz` deploy gate; `tasks_from: decommission`. | | `decdn.publisher.iroh_relay` | A self-hosted [iroh relay](https://github.com/n0-computer/iroh) (`iroh-relay`) for deCDN peers that cannot hole-punch — sha256-pinned upstream release (or a local binary), its own Let's Encrypt TLS, QUIC address discovery, `DynamicUser` unit holding only `CAP_NET_BIND_SERVICE`, loopback metrics, a deploy gate on every listener, a refusal of ports another process holds; access limited to an allowlist of endpoint IDs by default (`iroh_relay_access`; `tasks_from: node-ids` reads nodes' IDs); `tasks_from: decommission`. Point nodes at it with `decdn_relay_urls`. | | `decdn.publisher.iroh_dns_server` | A self-hosted [iroh DNS server](https://github.com/n0-computer/iroh) (`iroh-dns-server`): the pkarr relay deCDN nodes publish their address records to and the DNS server peers resolve them through, in place of n0's `dns.iroh.link` — sha256-pinned upstream release (or a local binary), its own Let's Encrypt TLS, DNS on one bind address (clear of systemd-resolved's stub), `DynamicUser` unit holding only `CAP_NET_BIND_SERVICE`, loopback metrics, a deploy gate on every listener, `/healthz`'s version and an SOA answer over udp and tcp, a refusal of ports another process holds; `tasks_from: decommission`. Point nodes at it with `decdn_discovery_pkarr_url` and `decdn_discovery_dns_origin`. | diff --git a/ansible/inventory/group_vars/sponsord_onramp_hosts.yml b/ansible/inventory/group_vars/sponsord_onramp_hosts.yml index 21a22f8..fab1c96 100644 --- a/ansible/inventory/group_vars/sponsord_onramp_hosts.yml +++ b/ansible/inventory/group_vars/sponsord_onramp_hosts.yml @@ -33,7 +33,8 @@ # sponsord_onramp_rpc_url: https://sepolia-rollup.arbitrum.io/rpc # sponsord_onramp_turnstile_sitekey: "0x…" # The decdn release the installers install defaults to the role's pin (decdn/decdn -# v0.0.4), with the SHA-256 of its SHA256SUMS. To serve another, set both: +# v), with the SHA-256 of its SHA256SUMS. To serve another, +# set both: # sponsord_onramp_decdn_release: v0.0.4 # sponsord_onramp_decdn_sums_sha256: "…" # CapacityBond and SlashJudge come from sponsord_network (group_vars/sponsord_hosts.yml). diff --git a/ansible/roles/decdn_node/README.md b/ansible/roles/decdn_node/README.md index 2c92a85..1c43296 100644 --- a/ansible/roles/decdn_node/README.md +++ b/ansible/roles/decdn_node/README.md @@ -7,12 +7,12 @@ pulls a pinned GitHub Release tarball and verifies it against the GPG-signed and `manual` copies locally-built binaries from the Ansible control machine. This is the repo's deployment (`playbooks/site.yml`). -**Pinned release.** `decdn_node_version` defaults to `0.0.4`, the decdn/decdn -release this repo pins; see [Bumping the decdn release](#bumping-the-decdn-release) +**Pinned release.** `decdn_node_version` defaults to the decdn/decdn release +this repo pins ([`defaults/main.yml`](defaults/main.yml)); see [Bumping the decdn release](#bumping-the-decdn-release) to move it. CI tests the role against stub binaries, not this release. **Schema tracking.** This role renders `node.toml` against the config schema of -`decdn/decdn` v0.0.4, the release `decdn_node_version` pins. Upstream marks every config +the `decdn/decdn` release `decdn_node_version` pins. Upstream marks every config section `#[serde(deny_unknown_fields)]` and defines **no** serde aliases, so a key this role emits that your binary does not know is a startup crash-loop, not a warning. The role runs `decdn config validate` against the installed binary after @@ -51,7 +51,7 @@ Per the deCDN node-onboarding ADR (019), a node only serves paid traffic after `decdn_verify_release_signature: false` for an air-gapped mirror that strips the signature — that takes the tarballs on trust. - `decdn_node_version` (default `0.0.4`) names the `v` release, which + `decdn_node_version` (default: the pinned release) names the `v` release, which must be reachable from the target host; override `decdn_node_release_base` for a mirror. - **`source`** — the role builds the two binaries **on the node** from @@ -103,7 +103,7 @@ Per the deCDN node-onboarding ADR (019), a node only serves paid traffic after rolling the ref back to the previous commit reinstalls that. - `decdn_node_version` is not checked in this mode: the backstop only requires the binaries to run. To build exactly a release, set - `decdn_node_source_ref` to its tag (`v0.0.4`). + `decdn_node_source_ref` to its tag (`v`). - The repo URL may not carry a secret: no password, no user on an http(s) URL, no query. The upstream repo is public; for a private fork, root does the fetch, so give root on the node an ssh deploy key and the git host's key @@ -700,11 +700,12 @@ The repo pins one decdn/decdn release everywhere, and a new one moves every pin one change. `scripts/open-decdn-bump.sh [vX.Y.Z]` opens that PR: decdn's `publish-crates.sh` runs it at the end of each decdn release, and a maintainer runs it by hand from a decdn/devops clone (with no tag, it takes decdn's latest release). -It does steps 1 to 6 in a worktree at `origin/main`, takes +It does steps 1 to 7 in a worktree at `origin/main`, takes the digests from the release's assets once their signatures verify against -`files/decdn-release-KEYS.asc`, and lists in the PR body what is left: the prose of -step 7, the chart check against the real binary in step 5, any schema-key or KEYS -change, and any generator that failed. `--dry-run` prints the diff and the PR body +`files/decdn-release-KEYS.asc`, and lists in the PR body what is left: any +schema-key or KEYS change, and any generator that failed. CI's `helm` job validates +the chart against the new release's binary (step 5), so a bump PR with no "Needs +work" list and a green CI needs no hand edits. `--dry-run` prints the diff and the PR body instead. The weekly `upstream-drift` workflow fails while no bump has landed. The steps: @@ -724,17 +725,21 @@ The steps: `charts/decdn-node/values.yaml`), from the release's signed `decdn-node-image-digest.txt` (`image-digest.txt` in v0.0.1). 5. `appVersion` in `charts/decdn-node/Chart.yaml` and the image tag in its - `artifacthub.io/images` annotation, then validate the chart against - the real binary: `DECDN_CLI= make lint-helm` (CI has no real - binary for the chart). + `artifacthub.io/images` annotation. CI's `helm` job then validates the chart + against that release's `decdn` (`scripts/fetch-decdn-cli.sh`, the same signature + check as this role's). 6. `files/decdn-release-KEYS.asc`, if upstream's `KEYS` changed (and the sponsord copy, and `SECURITY.md`'s fingerprints). -7. The prose that names the version: `git grep -n ''` outside the - changelogs. The changelog entry is the PR title (`feat`, `fix` or `chore`, not `ci` or - `build`, which the changelogs leave out; RELEASING.md). +7. The commented examples that name the version (`decdn_node_version`, + `decdn_node_source_ref` and `sponsord_onramp_decdn_release`, in the inventory + templates, their `examples/collection-fleet` copies and + `cloud-init/user-data-publisher.yaml`) and AGENTS.md's "Pinned upstream releases". + Other docs name the pin, never its number. The changelog entry is the PR title + (`feat`, `fix` or `chore`, not `ci` or `build`, which the changelogs leave out; + RELEASING.md). `make test-scripts` fails while the role, the onramp's decdn pin (role and Compose -example), the chart's `appVersion` and its `artifacthub.io/images` image, or the KEYS -and SECURITY.md disagree; the weekly `upstream-drift` job checks the digests against -the release. A decdn/sponsord release is bumped the same way +example), the chart's `appVersion` and its `artifacthub.io/images` image, AGENTS.md's +pinned version, or the KEYS and SECURITY.md disagree; the weekly `upstream-drift` job +checks the digests against the release. A decdn/sponsord release is bumped the same way ([sponsord's README](../sponsord/README.md#bumping-the-sponsord-release)). diff --git a/ansible/roles/sponsord/README.md b/ansible/roles/sponsord/README.md index 0d98a89..c6452d7 100644 --- a/ansible/roles/sponsord/README.md +++ b/ansible/roles/sponsord/README.md @@ -20,8 +20,8 @@ gate, installers, behind a TLS reverse proxy), is the - **Installs the binary.** - `release` (the default) downloads `sponsord--.tar.gz` from the - `v` release of `decdn/sponsord` (`sponsord_version`, default `0.0.4`; - one tag releases the whole workspace) and verifies it against the GPG-signed + `v` release of `decdn/sponsord` (`sponsord_version`, which defaults to + the release this repo pins; one tag releases the whole workspace) and verifies it against the GPG-signed `SHA256SUMS`, using `files/sponsord-release-KEYS.asc`, a copy of upstream's `KEYS`. `sponsord_release_base` points it at a mirror. - `source` clones `sponsord_source_repo` (default `decdn/sponsord` on GitHub) at @@ -32,8 +32,8 @@ gate, installers, behind a TLS reverse proxy), is the build environment for every new commit, a `@` stamp so an unchanged commit is not rebuilt, and a root-side install that refuses symlinks. `sponsord_version` is not checked in this mode; set the ref to a - release tag (`v0.0.4`) to build exactly that release. The build user, its home and the toolchain are - shared with the other roles on the host. cargo also fetches upstream's + release tag (`v`) to build exactly that release. The build user, its + home and the toolchain are shared with the other roles on the host. cargo also fetches upstream's `decdn/decdn` git dependencies, pinned by `Cargo.lock`. - `manual` copies a binary built on the control machine, from a `decdn/sponsord` checkout or the published crate (`cargo install --locked sponsord@`, @@ -127,7 +127,7 @@ See [`defaults/main.yml`](defaults/main.yml) for the full list with comments. | `sponsord_source_repo` / `sponsord_source_ref` | `decdn/sponsord` on GitHub / `""` | `source` only. The ref is required: a tag, branch or SHA. | | `sponsord_source_build_jobs` | `""` | `source` only: `CARGO_BUILD_JOBS` (`""` = one per CPU). | | `sponsord_manual_bin_src` / `sponsord_release_target_dir` | `""` | `manual` only. Set one. | -| `sponsord_version` | `0.0.4` | `release` mode: the `decdn/sponsord` release (tag `v0.0.4`). Equal to `sponsord_onramp_version`. | +| `sponsord_version` | the pinned release | `release` mode: the `decdn/sponsord` release (tag `v`); see [`defaults/main.yml`](defaults/main.yml). Equal to `sponsord_onramp_version`. | | `sponsord_network` | `""` | Network profile: `arbitrum-sepolia` supplies chain id and PaymentPool. | | `sponsord_chain_id`, `sponsord_payment_pool_address` | from profile | Explicit values win over the profile. | | `sponsord_pool_id` | `""` | **Required.** 0x + 64 hex. `""` only on the wallet-creation run. | @@ -247,11 +247,14 @@ move every pin in one change: decdn_node copy, and `SECURITY.md`'s fingerprints). 4. Re-check the env var names the Compose file and the role render against the new `crates/*/src/config.rs` (AGENTS.md, `compose/`). -5. The prose that names the version: `git grep -n ''` outside the - changelogs. The changelog entry is the PR title (`feat`, `fix` or `chore`, not `ci` or +5. The commented examples that name the version (`sponsord_version`, + `sponsord_source_ref`, `sponsord_onramp_version` and `sponsord_onramp_source_ref`, + in the inventory templates and their `examples/collection-fleet` copies) and + AGENTS.md's "Pinned upstream releases". Other docs name the pin, never its + number. The changelog entry is the PR title (`feat`, `fix` or `chore`, not `ci` or `build`, which the changelogs leave out; RELEASING.md). -`make test-scripts` fails while the two versions disagree; the weekly `upstream-drift` job checks the digests against the -release. A +`make test-scripts` fails while the two versions, or AGENTS.md's pinned version, +disagree; the weekly `upstream-drift` job checks the digests against the release. A decdn/decdn release is bumped the same way ([decdn_node's README](../decdn_node/README.md#bumping-the-decdn-release)). diff --git a/ansible/roles/sponsord/tasks/main.yml b/ansible/roles/sponsord/tasks/main.yml index 63ca462..60bb942 100644 --- a/ansible/roles/sponsord/tasks/main.yml +++ b/ansible/roles/sponsord/tasks/main.yml @@ -23,7 +23,7 @@ - sponsord_version | length > 0 fail_msg: >- sponsord_install_method "release" (the default) requires sponsord_version (e.g. - "0.0.4") naming a v release of decdn/sponsord (or of the mirror in + "0.0.1") naming a v release of decdn/sponsord (or of the mirror in sponsord_release_base). To run something unreleased, set sponsord_install_method "source" with a sponsord_source_ref to build on the host, or use "manual" to copy a binary built on the control machine. diff --git a/ansible/roles/sponsord_onramp/README.md b/ansible/roles/sponsord_onramp/README.md index ca46d67..bf1b2e5 100644 --- a/ansible/roles/sponsord_onramp/README.md +++ b/ansible/roles/sponsord_onramp/README.md @@ -23,7 +23,7 @@ is unreachable. - **Installs the binary**, exactly as the `sponsord` role does: - `release` (the default) downloads `sponsord-onramp--.tar.gz` from the `v` release of `decdn/sponsord` (`sponsord_onramp_version`, - default `0.0.4`, the same release as the daemon's) and verifies it against the + pinned to the same release as the daemon's `sponsord_version`) and verifies it against the GPG-signed `SHA256SUMS`, with the KEYS file vendored in the `sponsord` role. `sponsord_onramp_release_base` points it at a mirror. - `source` builds `sponsord_onramp_source_ref` of `sponsord_onramp_source_repo` @@ -100,8 +100,8 @@ is unreachable. 4. **Point DNS** for the domain (A/AAAA) at the host, so Caddy can get a certificate. 5. **The `decdn` release the installers install** defaults to the one this repo - pins: the decdn/decdn `v0.0.4` tag and the SHA-256 of that release's - `SHA256SUMS`. To serve another, set the tag together with its digest: the role + pins: the tag of the decdn_node role's default `decdn_node_version` and the + SHA-256 of that release's `SHA256SUMS`. To serve another, set the tag together with its digest: the role refuses a pair where one half keeps its default. 6. **Set inventory** (`group_vars/sponsord_onramp_hosts.yml`): the domain, the public RPC URL and the sitekey. The contracts come from `sponsord_network`. @@ -118,7 +118,7 @@ See [`defaults/main.yml`](defaults/main.yml) for the full list with comments. | `sponsord_onramp_source_repo` / `_source_ref` | `decdn/sponsord` on GitHub / `""` | `source` only. The ref is required: a tag, branch or SHA. | | `sponsord_onramp_source_build_jobs` | `""` | `source` only: `CARGO_BUILD_JOBS` (`""` = one per CPU). | | `sponsord_onramp_manual_bin_src` / `_release_target_dir` | `""` / `sponsord_release_target_dir` | `manual` only. | -| `sponsord_onramp_version` | `0.0.4` | `release` mode: the `decdn/sponsord` release (tag `v0.0.4`). Keep it equal to `sponsord_version`: overriding one does not move the other. | +| `sponsord_onramp_version` | the pinned release | `release` mode: the `decdn/sponsord` release (tag `v`); see [`defaults/main.yml`](defaults/main.yml). Keep it equal to `sponsord_version`: overriding one does not move the other. | | `sponsord_onramp_network` | `sponsord_network` | Supplies CapacityBond and SlashJudge. Must match the daemon's network. | | `sponsord_onramp_capacity_bond_address`, `_slash_judge_address` | from profile | Explicit values win. SlashJudge may be `""`. | | `sponsord_onramp_rpc_url` | `""` | **Required. Public**: served to every user. No credentials, query or API key. | @@ -132,7 +132,7 @@ See [`defaults/main.yml`](defaults/main.yml) for the full list with comments. | `sponsord_onramp_turnstile_sitekey` | `""` | **Required.** | | `sponsord_onramp_turnstile_secret` | `""` | **Sensitive.** Leave empty to provision `turnstile-secret` on the host. | | `sponsord_onramp_turnstile_secret_overwrite_host_file` | `false` | Confirm that the inventory secret may replace a file the role did not write. | -| `sponsord_onramp_decdn_release`, `_decdn_sums_sha256` | `v0.0.4`, its digest | A decdn/decdn `vX.Y.Z` tag and 64 lowercase hex. The default follows the `decdn_node` role's pin; a host override of `decdn_node_version` does not move it. | +| `sponsord_onramp_decdn_release`, `_decdn_sums_sha256` | the `decdn_node` role's default version as a `v` tag, its digest | A decdn/decdn `vX.Y.Z` tag and 64 lowercase hex. The default follows the `decdn_node` role's pin; a host override of `decdn_node_version` does not move it. | | `sponsord_onramp_brand_name`, `_gate_template_src` | `""` | The gate page's name, or your own HTML page (a control-machine file). | | `sponsord_onramp_spending_cap_micro_usdc`, `_ttl_secs` | `""` | Requested per capability. `""` takes the daemon's maximum; more than it fails the deploy. | | `sponsord_onramp_fund_rate_per_min`, `_poll_rate_per_min` | `""` | Per-address rate limits (upstream 10 and 120; `0` turns one off). | diff --git a/ansible/roles/sponsord_onramp/tasks/main.yml b/ansible/roles/sponsord_onramp/tasks/main.yml index 49d9cbc..da3d4fe 100644 --- a/ansible/roles/sponsord_onramp/tasks/main.yml +++ b/ansible/roles/sponsord_onramp/tasks/main.yml @@ -26,7 +26,7 @@ - sponsord_onramp_version | length > 0 fail_msg: >- sponsord_onramp_install_method "release" (the default) requires - sponsord_onramp_version (e.g. "0.0.4") naming a v release of + sponsord_onramp_version (e.g. "0.0.1") naming a v release of decdn/sponsord (or of the mirror in sponsord_onramp_release_base). To run something unreleased, set sponsord_onramp_install_method "source" with a sponsord_onramp_source_ref to build on the host, or use "manual" to copy a diff --git a/charts/decdn-node/README.md b/charts/decdn-node/README.md index 2ef026f..f9ca63b 100644 --- a/charts/decdn-node/README.md +++ b/charts/decdn-node/README.md @@ -60,7 +60,7 @@ What it does **not** do: - **UDP reachability.** Either a load balancer that supports UDP Services, or `quic.hostPort.enabled` on nodes with a public IP. - **An image.** By default the chart runs `ghcr.io/decdn/decdn-node:` - (today `0.0.4`, the decdn/decdn release the chart is synced against). Pin + (the decdn/decdn release the chart is synced against). Pin `image.digest` instead in production: take it from the release's `decdn-node-image-digest.txt` (`image-digest.txt` in v0.0.1), after checking its `.asc` against the keys in @@ -486,12 +486,18 @@ endpoint from `--rpc-url` or its config's `blockchain.rpc_url`, never `DECDN_RPC ```bash make lint-helm # helm lint --strict, positive/negative render tests, kubeconform, schema keys make security-helm # KICS over the rendered manifests (fail on HIGH) +cli="$(scripts/fetch-decdn-cli.sh /tmp/decdn)" && DECDN_CLI="$cli" make lint-helm + # also run the real `decdn config validate` on each ci/*.yaml render, + # with the decdn release appVersion pins (signature-checked) DECDN_CLI=../decdn/target/release/decdn make lint-helm - # also run the real `decdn config validate` on every CI render + # the same, with a local decdn build ``` -CI has no decdn binary, so it reports `SKIPPED: decdn config validate`. Run the -`DECDN_CLI` form locally whenever you bump the decdn version or change the renderer. +CI's `helm` job runs `make lint-helm` with the fetched binary, so every chart PR and +every decdn bump is validated against the pinned release's. Without `DECDN_CLI`, +`make lint-helm` reports `SKIPPED: decdn config validate`. Keep the fetch out of a +prefix assignment (`DECDN_CLI="$(…)" make …`): that drops its exit status, so a +failed fetch would skip the check rather than fail. The `ci/*.yaml` values files mirror the three plays of the molecule `schema` scenario: every knob with an S3 origin, the multi-origin list, and resolve-only discovery. When you diff --git a/charts/decdn-node/tests/render-test.sh b/charts/decdn-node/tests/render-test.sh index bf05ccd..2181633 100755 --- a/charts/decdn-node/tests/render-test.sh +++ b/charts/decdn-node/tests/render-test.sh @@ -18,7 +18,8 @@ # Skipped with PROMTOOL. # DECDN_CLI path to a real `decdn` binary: also run `decdn config validate` # on every positive render (catches value/type errors the key check -# can't). Skipped, loudly, when unset. +# can't). Skipped, loudly, when unset. CI sets it to the pinned +# release's (scripts/fetch-decdn-cli.sh). set -euo pipefail here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" diff --git a/cloud-init/README.md b/cloud-init/README.md index fe3c9bf..0213e68 100644 --- a/cloud-init/README.md +++ b/cloud-init/README.md @@ -38,9 +38,9 @@ for the secrets of every group the host is in. > (`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.4` today, decdn/sponsord -> `v0.0.4` 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 +> one the roles pin at `DEVOPS_REF` (`decdn_node_version` for decdn/decdn, +> `sponsord_version` 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. diff --git a/compose/.env.example b/compose/.env.example index 8da70be..bca4c88 100644 --- a/compose/.env.example +++ b/compose/.env.example @@ -41,7 +41,7 @@ DECDN_GID=999 DECDN_ENV_FILE=/etc/decdn/decdn.env # --- sponsord and onramp ---------------------------------------------------------- -# decdn/sponsord v0.0.4, one release for both images +# The decdn/sponsord release sponsord_version pins: one release for both images SPONSORD_IMAGE_DIGEST=sha256:120982625257b28cf785c776cb2cc7a6c6cd8e2e5958b274f1a43d27ab857eea # SPONSORD_IMAGE_REPO=ghcr.io/decdn/sponsord SPONSORD_ONRAMP_IMAGE_DIGEST=sha256:76362c0de04d3febf43b39ee1d00bd29d45a1b4338fc3f8fb0427d52d5b13805 diff --git a/compose/README.md b/compose/README.md index 0999723..f7901e4 100644 --- a/compose/README.md +++ b/compose/README.md @@ -69,7 +69,7 @@ from: the wrapper always names the project (`decdn`) and refuses to run while th shell sets a `COMPOSE_*` variable or one `compose.yaml` reads, which Compose would otherwise take over `.env`, so its checks see what Compose runs. `init` starts it from `.env.example`, which carries the digests of the releases the Ansible -roles pin (decdn/decdn v0.0.4, decdn/sponsord v0.0.4), each copied from that +roles pin (`decdn_node_version` and `sponsord_version`), each copied from that release's signed digest file; [Operate](#operate) says how to take a newer one. ## The node diff --git a/examples/collection-fleet/inventory/group_vars/sponsord_onramp_hosts.yml b/examples/collection-fleet/inventory/group_vars/sponsord_onramp_hosts.yml index 21a22f8..fab1c96 100644 --- a/examples/collection-fleet/inventory/group_vars/sponsord_onramp_hosts.yml +++ b/examples/collection-fleet/inventory/group_vars/sponsord_onramp_hosts.yml @@ -33,7 +33,8 @@ # sponsord_onramp_rpc_url: https://sepolia-rollup.arbitrum.io/rpc # sponsord_onramp_turnstile_sitekey: "0x…" # The decdn release the installers install defaults to the role's pin (decdn/decdn -# v0.0.4), with the SHA-256 of its SHA256SUMS. To serve another, set both: +# v), with the SHA-256 of its SHA256SUMS. To serve another, +# set both: # sponsord_onramp_decdn_release: v0.0.4 # sponsord_onramp_decdn_sums_sha256: "…" # CapacityBond and SlashJudge come from sponsord_network (group_vars/sponsord_hosts.yml). diff --git a/scripts/bump_decdn.py b/scripts/bump_decdn.py index 66f03aa..7e6d2ce 100755 --- a/scripts/bump_decdn.py +++ b/scripts/bump_decdn.py @@ -7,13 +7,16 @@ pinned print decdn_node_version compare print how the pin relates to : same, ahead or behind rewrite --sums-sha256 --image-digest sha256: - move every hand-copied pin (checklist steps 1, 3, 4 and 5 of + move every hand-copied pin (checklist steps 1, 3, 4, 5 and 7 of ansible/roles/decdn_node/README.md, "Bumping the decdn release") The generated mirrors (step 2) and the vendored KEYS (step 6) are open-decdn-bump.sh's: -they come from a checkout of the tag, not from this repo. Prose that names a version -(step 7) is a reviewer's: decdn/sponsord carries version numbers too, so no rewrite -here can tell which pin a bare "0.0.2" names. +they come from a checkout of the tag, not from this repo. Of the text that names the +version (steps 3 to 5 and 7), only what is keyed to the pin moves: the comments +beside the pinned values, the commented examples, keyed by a variable name, and +AGENTS.md's "Pinned upstream releases". Docs name the pin, never its number: +decdn/sponsord carries version numbers too, so no rewrite here could tell which pin a +bare "0.0.2" names. Every edit is anchored on a pattern that must match exactly once, and the result is read back, so a reshaped file fails the command instead of reporting a bump. Files @@ -45,6 +48,25 @@ CHART = "charts/decdn-node/Chart.yaml" CHART_VALUES = "charts/decdn-node/values.yaml" +# (file, before, after): a version-bearing example, the regex fragments on either side +# of the bare X.Y.Z. examples/collection-fleet/inventory/'s host_vars and group_vars are +# byte copies of ansible/inventory/'s (make test-scripts), so both trees move. +EXAMPLES = [ + *( + (f"{tree}/host_vars/{host}/main.yml", before, after) + for tree in ("ansible/inventory", "examples/collection-fleet/inventory") + for host in ("decdn-node-1", "origin-1") + for before, after in ((r'^# decdn_node_version: *"', '"'), (r"^# +decdn_node_source_ref: *v", "$")) + ), + *( + (f"{tree}/group_vars/sponsord_onramp_hosts.yml", r"^# sponsord_onramp_decdn_release: *v", "$") + for tree in ("ansible/inventory", "examples/collection-fleet/inventory") + ), + ("cloud-init/user-data-publisher.yaml", r'^ *# sponsord_onramp_decdn_release: *"v', '"'), + # The one place prose names the pin's number (its line may wrap inside the phrase). + ("AGENTS.md", r"`decdn_node_version`, today\s+`", "`"), +] + class ShapeError(Exception): """A file is not in the shape the bump expects.""" @@ -162,6 +184,7 @@ def rewrite(root: Path, version: str, sums: str, digest: str) -> list[str]: (CHART_VALUES, rf"(e\.g\. for )v{SEMVER}(\n *# *sha256:)", rf"\g<1>v{version}\g<2>"), (CHART, r'^(appVersion: *")[^"]*(")', rf"\g<1>{version}\g<2>"), (CHART, rf"(image: ghcr\.io/decdn/decdn-node:){SEMVER}$", rf"\g<1>{version}"), + *((rel, rf"({before}){SEMVER}({after})", rf"\g<1>{version}\g<2>") for rel, before, after in EXAMPLES), ] for rel, pattern, repl in edits: text = read(root, rel) @@ -200,6 +223,10 @@ def verify(root: Path, version: str, sums: str, digest: str) -> None: example = re.findall(rf"e\.g\. for v{re.escape(version)}\n *# *(sha256:[0-9a-f]{{64}})", read(root, CHART_VALUES)) if example != [digest]: raise ShapeError(f"{CHART_VALUES}: the example digest for v{version} is {example}, expected [{digest!r}]") + for rel, before, after in EXAMPLES: + found = re.findall(rf"{before}({SEMVER}){after}", read(root, rel), re.M) + if found != [version]: + raise ShapeError(f"{rel}: /{before}X.Y.Z{after}/ names {found}, expected [{version!r}]") def main() -> None: diff --git a/scripts/fetch-decdn-cli.sh b/scripts/fetch-decdn-cli.sh new file mode 100755 index 0000000..e735d9f --- /dev/null +++ b/scripts/fetch-decdn-cli.sh @@ -0,0 +1,108 @@ +#!/usr/bin/env bash +# Downloads the `decdn` CLI of the decdn/decdn release the chart pins +# (`appVersion`), verified as the decdn_node role verifies a release, and prints +# its path. CI's `helm` job runs the chart's render tests against it: +# +# DECDN_CLI="$(scripts/fetch-decdn-cli.sh "$RUNNER_TEMP/decdn")" +# export DECDN_CLI +# make lint-helm +# +# (Not `DECDN_CLI="$(…)" make lint-helm` on one line: that drops the fetch's exit +# status, and lint-helm then skips the check instead of failing.) +# +# Usage: scripts/fetch-decdn-cli.sh +# +# Linux only (the release ships linux-gnu binaries). is created if missing +# and receives the binary; nothing else is written there, and nothing at all +# unless every check passes. Progress goes to stderr, the path alone to stdout. +# +# SHA256SUMS must carry a good signature from a still-valid key in the vendored +# ansible/roles/decdn_node/files/decdn-release-KEYS.asc, and no other signature +# gpg rejects; the archive must match exactly one manifest entry; and the binary +# must report `decdn ` (roles/decdn_node/tasks/install.yml has the +# reasons for each). +# +# Overrides, for the tests' fixture release: DECDN_VERSION (default: the chart's +# appVersion), DECDN_RELEASE_BASE (default: decdn/decdn's GitHub releases; any +# URL curl takes, file:// included) and DECDN_RELEASE_KEYS (default: the vendored +# keyring). Each override is reported on stderr. +# +# Exit: 0 done; 1 any failure; 2 usage. +set -euo pipefail + +die() { echo "error: $*" >&2; exit 1; } + +[[ $# -eq 1 && -n "$1" && "$1" != -* ]] || { + sed -n 's/^# Usage: *//p' "${BASH_SOURCE[0]}" >&2 + exit 2 +} +dest="$1" + +root=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) +version="${DECDN_VERSION:-$(sed -nE 's/^appVersion: *"?([^" #]*)"?.*/\1/p' "$root/charts/decdn-node/Chart.yaml")}" +# The version lands in a URL and a file name: a release number and nothing else. +[[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || + die "the decdn version is '$version', not X.Y.Z (charts/decdn-node/Chart.yaml appVersion)" +base="${DECDN_RELEASE_BASE:-https://github.com/decdn/decdn/releases/download}" +keys="${DECDN_RELEASE_KEYS:-$root/ansible/roles/decdn_node/files/decdn-release-KEYS.asc}" +for v in DECDN_VERSION DECDN_RELEASE_BASE DECDN_RELEASE_KEYS; do + [[ -z "${!v:-}" ]] || echo "note: $v=${!v} overrides the default" >&2 +done +[[ -f "$keys" ]] || die "no release keyring at $keys" + +# The triples roles/decdn_node/vars/main/arch.yml maps. +[[ "$(uname -s)" == Linux ]] || die "decdn releases Linux binaries only; this is $(uname -s)" +case "$(uname -m)" in + x86_64) target=x86_64-unknown-linux-gnu ;; + aarch64) target=aarch64-unknown-linux-gnu ;; + *) die "unsupported architecture $(uname -m): decdn releases x86_64 and aarch64 only" ;; +esac +archive="decdn-$version-$target.tar.gz" + +work=$(mktemp -d) +trap 'rm -rf "$work"' EXIT + +echo "fetching decdn v$version ($target)" >&2 +for f in SHA256SUMS SHA256SUMS.asc "$archive"; do + curl -fsSL --retry 3 --retry-connrefused -o "$work/$f" "$base/v$version/$f" || + die "could not download $base/v$version/$f" +done + +# An isolated GNUPGHOME: "signed by a key in the vendored KEYS", never "by any key +# this machine trusts". gpg must exit 0 (no signature it rejects, as in the role), +# and its status output must show a good signature from a key that is neither +# expired nor revoked: `gpg --verify` exits 0 for those. On failure, gpg's own +# output says why (a NO_PUBKEY names the key a rotated KEYS would need). +export GNUPGHOME="$work/gnupg" +mkdir -m 0700 "$GNUPGHOME" +gpgfail() { cat "$work/gpg.log" "$work/gpg-status" >&2 2>/dev/null || true; die "$@"; } +: >"$work/gpg-status" +gpg --batch --quiet --no-autostart --import "$keys" 2>"$work/gpg.log" || + gpgfail "could not import $keys" +rc=0 +gpg --batch --no-autostart --status-fd 3 --verify "$work/SHA256SUMS.asc" "$work/SHA256SUMS" \ + 3>"$work/gpg-status" 2>"$work/gpg.log" || rc=$? +if grep -qE '^\[GNUPG:\] (EXPKEYSIG|REVKEYSIG|EXPSIG)' "$work/gpg-status"; then + gpgfail "SHA256SUMS of v$version is signed by an EXPIRED or REVOKED key, or the signature expired: re-sync $keys from upstream KEYS" +fi +if ((rc != 0)) || ! grep -q '^\[GNUPG:\] VALIDSIG ' "$work/gpg-status"; then + gpgfail "SHA256SUMS of v$version carries no good signature from a key in $keys, or one gpg rejects (gpg exited $rc)" +fi + +# The archive's name field, matched exactly once (not a substring, not a count of OKs). +entry=$(awk -v f="$archive" 'substr($0, index($0, " ") + 2) == f' "$work/SHA256SUMS") +n=$(printf '%s\n' "$entry" | grep -c . || true) +[[ "$n" -eq 1 ]] || die "SHA256SUMS has $n entries for $archive (expected exactly 1)" +(cd "$work" && printf '%s\n' "$entry" | sha256sum --check --strict --quiet -) || + die "$archive does not match SHA256SUMS" + +# Each archive holds exactly one flat binary. Checked before it is installed, with +# the role's release-mode backstop: exactly `decdn `. +mkdir "$work/bin" +tar -xzf "$work/$archive" -C "$work/bin" decdn || die "$archive holds no flat \`decdn\` binary" +got=$("$work/bin/decdn" --version) || die "decdn --version failed" +[[ "$got" == "decdn $version" ]] || die "the binary reports '$got', not 'decdn $version'" +echo "$got" >&2 +mkdir -p "$dest" +install -m 0755 "$work/bin/decdn" "$dest/decdn" +printf '%s\n' "$dest/decdn" diff --git a/scripts/open-decdn-bump.sh b/scripts/open-decdn-bump.sh index 60011db..75eb78d 100755 --- a/scripts/open-decdn-bump.sh +++ b/scripts/open-decdn-bump.sh @@ -13,14 +13,16 @@ # `origin` credentials and opens the PR with your `gh` login: a PR opened that # way runs CI, which one opened by Actions' GITHUB_TOKEN would not. # -# The bump does checklist steps 1 to 6. +# The bump does checklist steps 1 to 7 (bump_decdn.py rewrite moves every pin, +# the commented examples and AGENTS.md's pinned version; CI's helm job validates +# the chart against the new release's binary). # Release facts (the SHA256SUMS digest, the decdn-node image digest) come from # the release's assets once their signatures verify against the vendored # decdn-release-KEYS.asc; the generated mirrors and KEYS come from a clone of the # tag, whose signature is checked the same way. What needs a human is listed in -# the PR body: prose naming the old version, a schema-key change (node.toml.j2 -# and the chart's values.config follow it by hand), a KEYS change (the sponsord -# copy and SECURITY.md follow it), and the chart check against the real binary. +# the PR body: a schema-key change (node.toml.j2 and the chart's values.config +# follow it by hand), a KEYS change (the sponsord copy and SECURITY.md follow +# it), and any generator that failed. # # Nothing happens when the pin is already at that release or a later one, or # when a PR from bump/decdn- exists in any state: a closed bump PR is never @@ -32,8 +34,7 @@ # Exit: 0 done or nothing to do; 1 error (no PR opened unless the output says # so), the PR is open but needs work, or (--dry-run) the bump would need work; # 2 usage. "Needs work" is the body's "Needs work" list: what the script could -# not finish. Its "Before merging" checklist (the real-binary chart check, the -# prose) comes with every bump and does not change the exit code. +# not finish. A PR with no "Needs work" list and a green CI needs no hand edits. set -euo pipefail DECDN=decdn/decdn @@ -226,16 +227,6 @@ if ! cmp -s "$UPSTREAM/KEYS" "$KEYS"; then (\`ansible/roles/sponsord/files/sponsord-release-KEYS.asc\`) and SECURITY.md's fingerprints." fi -# Step 7 is a reviewer's: decdn/sponsord pins carry version numbers too, so a -# bare old version is not always decdn's. The list holds the old version as a -# whole word (not inside 127.0.0.1, nor a prefix of 0.0.10), outside the -# changelogs, the test fixtures and the scripts, whose versions are examples. -# git grep exits 1 for no match; anything else is an error, not a clean tree. -rc=0 -prose=$(git grep -nE -e "(^|[^0-9.])${CURRENT//./\\.}([^0-9]|\$)" -- . \ - ':(exclude,glob)**/CHANGELOG.md' ':(exclude,glob)**/tests/**' ':(exclude)scripts') || rc=$? -((rc <= 1)) || die "could not search for the old version (git grep exited $rc); no PR opened" - # ---- PR body ------------------------------------------------------------- others=$(gh pr list -R "$DEVOPS" --state open --json number,headRefName \ @@ -255,19 +246,6 @@ BODY="$WORK/body.md" printf -- '- %s\n' "${NEEDS[@]}" echo fi - echo "Before merging:" - echo - echo "- [ ] Validate the chart against the release's binary (CI has none):" - echo " \`DECDN_CLI=<${TAG}'s decdn> make lint-helm\`." - if [[ -n "$prose" ]]; then - echo "- [ ] Move the prose that still names \`${CURRENT}\` (a decdn/sponsord pin may" - echo " share the number; leave those):" - echo - echo ' ```' - indent <<<"$prose" - echo ' ```' - fi - echo echo "After merging, release each artifact the bump touched (RELEASING.md)." echo if [[ -n "$others" ]]; then @@ -296,9 +274,10 @@ git commit --quiet --no-verify -F - < str: + """Whitespace runs as single spaces: AGENTS.md's wrapping is not the test's concern.""" + return " ".join(text.split()) + + class Fixture(unittest.TestCase): def setUp(self) -> None: self._tmp = tempfile.TemporaryDirectory() @@ -100,6 +107,15 @@ def test_moves_every_pin(self) -> None: self.assertIn(f"e.g. for v9.8.7\n # {DIGEST}\n", self.text(bd.CHART_VALUES)) self.assertIn('appVersion: "9.8.7"', self.text(bd.CHART)) self.assertIn("image: ghcr.io/decdn/decdn-node:9.8.7\n", self.text(bd.CHART)) + for tree in ("ansible/inventory", "examples/collection-fleet/inventory"): + for host in ("decdn-node-1", "origin-1"): + host_vars = self.text(f"{tree}/host_vars/{host}/main.yml") + self.assertIn('\n# decdn_node_version: "9.8.7"\n', host_vars) + self.assertIn(" decdn_node_source_ref: v9.8.7\n", host_vars) + self.assertIn("\n# sponsord_onramp_decdn_release: v9.8.7\n", + self.text(f"{tree}/group_vars/sponsord_onramp_hosts.yml")) + self.assertIn('# sponsord_onramp_decdn_release: "v9.8.7"\n', self.text("cloud-init/user-data-publisher.yaml")) + self.assertIn("`decdn_node_version`, today `9.8.7`)", flat(self.text("AGENTS.md"))) # The pin-pair molecule case carries a copy of the digest. self.assertIn(SUMS, self.text("ansible/molecule/validation-sponsord/converge.yml")) self.assertNotIn(self.sums, self.text("ansible/molecule/validation-sponsord/converge.yml")) @@ -114,9 +130,25 @@ def test_round_trip_is_byte_identical(self) -> None: def test_leaves_the_sponsord_pin(self) -> None: onramp = self.text(bd.ONRAMP_DEFAULTS) sponsord = bd.scalar(onramp, "sponsord_onramp_version", bd.ONRAMP_DEFAULTS) + agents = self.text("AGENTS.md") bd.rewrite(self.root, "9.8.7", SUMS, DIGEST) self.assertEqual(bd.scalar(self.text(bd.ONRAMP_DEFAULTS), "sponsord_onramp_version", bd.ONRAMP_DEFAULTS), sponsord) + # AGENTS.md names both pins in one sentence; only decdn's moves. + sponsord_today = f"`sponsord_onramp_version`, today `{sponsord}`;" + self.assertIn(sponsord_today, flat(agents)) + self.assertIn(sponsord_today, flat(self.text("AGENTS.md"))) + + def test_moves_agents_md_wrapped_either_way(self) -> None: + original = self.text("AGENTS.md") + for sep in (" ", "\n"): + with self.subTest(sep=sep): + text, n = re.subn(r"(`decdn_node_version`, today)\s+`", rf"\g<1>{sep}`", original) + self.assertEqual(n, 1) + self.write("AGENTS.md", text) + bd.rewrite(self.root, "9.8.7", SUMS, DIGEST) + self.assertIn(f"`decdn_node_version`, today{sep}`9.8.7`", self.text("AGENTS.md")) + bd.rewrite(self.root, self.version, self.sums, self.digest) def test_leaves_the_changelogs(self) -> None: image = self.digest.removeprefix("sha256:") diff --git a/tests/scripts-test.sh b/tests/scripts-test.sh index 148d9c6..1667f74 100755 --- a/tests/scripts-test.sh +++ b/tests/scripts-test.sh @@ -1401,6 +1401,92 @@ expect 0 "bump_decdn unit tests pass" \ env PYTHONDONTWRITEBYTECODE=1 python3 -m unittest discover -s "$repo/scripts/tests" -p 'test_*.py' expect 2 "open-decdn-bump refuses an unknown argument" "$repo/scripts/open-decdn-bump.sh" --execute +# --- scripts/fetch-decdn-cli.sh: CI's chart check verifies the decdn CLI it runs ---- +# A fixture release per case, signed by throwaway keys and served over file://. +# Each refusal must be the one its case is about (the message), not any exit 1. +fx="$work/fetch"; mkdir -p "$fx" +fxv=9.8.7 +case "$(uname -m)" in + x86_64) fxt=x86_64-unknown-linux-gnu ;; + aarch64) fxt=aarch64-unknown-linux-gnu ;; + *) fail "fetch-decdn-cli fixture: unsupported architecture $(uname -m)" ;; +esac +fxa="decdn-$fxv-$fxt.tar.gz" +fxgpg() { gpg --homedir "$fx/$1" --batch --quiet --pinentry-mode loopback --passphrase '' "${@:2}"; } +# signer: the KEYS key; stranger: outside KEYS; expired: lapsed in 2020; revoked: +# in its KEYS export with its revocation (gpg writes the certificate at key generation). +then='--faked-system-time 20200101T000000!' +for k in signer stranger expired revoked; do + mkdir -m 0700 "$fx/$k" + if [[ $k == expired ]]; then + # shellcheck disable=SC2086 # $then is one option + fxgpg "$k" $then --quick-gen-key "$k <$k@example.invalid>" ed25519 sign 1d + else + fxgpg "$k" --quick-gen-key "$k <$k@example.invalid>" ed25519 sign never + fi +done +# fxrel []: a release broken as says. +fxrel() { + local d="$fx/$1/v$fxv" sum signer=signer + mkdir -p "$d" "$fx/$1.src" + printf '#!/bin/sh\necho "decdn %s"\n' "${2:-$fxv}" >"$fx/$1.src/decdn"; chmod 0755 "$fx/$1.src/decdn" + tar -czf "$d/$fxa" -C "$fx/$1.src" decdn + sum="$(sha256sum "$d/$fxa" | cut -d' ' -f1)" + case "$1" in + missing) echo "$sum decdn-node-$fxv-$fxt.tar.gz" ;; + twice) printf '%s %s\n' "$sum" "$fxa" "$sum" "$fxa" ;; + *) echo "$sum $fxa" ;; + esac >"$d/SHA256SUMS" + case "$1" in stranger | expired | revoked) signer="$1" ;; esac + if [[ $1 == expired ]]; then + # shellcheck disable=SC2086 # $then is one option + fxgpg expired ${then%T*}T010000! --armor --detach-sign -o "$d/SHA256SUMS.asc" "$d/SHA256SUMS" + else + fxgpg "$signer" --armor --detach-sign -o "$d/SHA256SUMS.asc" "$d/SHA256SUMS" + fi + # Signed by its own key alongside the KEYS key: gpg exits 2 on the unknown one. + if [[ $1 == cosigned ]]; then + fxgpg stranger --armor --detach-sign -o "$fx/cosigned.asc" "$d/SHA256SUMS" + cat "$fx/cosigned.asc" >>"$d/SHA256SUMS.asc" + fi + # A different, well-formed archive in place of the signed one: only the sha256 check sees it. + if [[ $1 == tampered ]]; then + printf '#!/bin/sh\necho "decdn %s"\n# not the release\n' "$fxv" >"$fx/$1.src/decdn" + tar -czf "$d/$fxa" -C "$fx/$1.src" decdn + fi +} +for c in good stranger expired revoked cosigned missing twice tampered; do fxrel "$c"; done +fxrel mislabelled 1.2.3 +fxgpg revoked --import <(sed 's/^:-----/-----/' "$fx"/revoked/openpgp-revocs.d/*.rev) +for k in signer expired revoked; do fxgpg "$k" --armor --export >"$fx/KEYS-$k.asc"; done +cat "$fx/KEYS-signer.asc" >"$fx/KEYS.asc" +for k in signer stranger expired revoked; do gpgconf --homedir "$fx/$k" --kill gpg-agent; done +fetch() { # []: KEYS holds the signer, plus the case's own key for expired/revoked + local keys="$fx/KEYS.asc" + [[ $1 != expired && $1 != revoked ]] || keys="$fx/KEYS-$1.asc" + env DECDN_VERSION="${2:-$fxv}" DECDN_RELEASE_BASE="file://$fx/$1" DECDN_RELEASE_KEYS="$keys" \ + "$repo/scripts/fetch-decdn-cli.sh" "$fx/out-$1" +} +refuses() { # []: exit 1, with that message, and no binary written + expect 1 "fetch-decdn-cli refuses $1: $2" fetch "$1" "${3:-}" + grep -qF "$2" "$work/out" || { cat "$work/out" >&2; fail "fetch-decdn-cli ($1): not refused for '$2'"; } + [[ ! -e "$fx/out-$1" ]] || fail "fetch-decdn-cli wrote a binary for $1, which it refused" +} +out="$(fetch good 2>/dev/null)" || fail "fetch-decdn-cli refused a release signed by a KEYS key" +[[ "$out" == "$fx/out-good/decdn" && -x "$out" ]] \ + || fail "fetch-decdn-cli: stdout must be exactly the installed binary's path, got: $out" +pass "fetch-decdn-cli installs a release signed by a KEYS key and prints only its path" +refuses stranger "no good signature from a key in" +refuses cosigned "or one gpg rejects (gpg exited 2)" +refuses expired "EXPIRED or REVOKED" +refuses revoked "EXPIRED or REVOKED" +refuses missing "SHA256SUMS has 0 entries for $fxa" +refuses twice "SHA256SUMS has 2 entries for $fxa" +refuses tampered "$fxa does not match SHA256SUMS" +refuses mislabelled "the binary reports 'decdn 1.2.3', not 'decdn $fxv'" +refuses badversion "the decdn version is '$fxv/../x', not X.Y.Z" "$fxv/../x" +expect 2 "fetch-decdn-cli refuses a missing destination" "$repo/scripts/fetch-decdn-cli.sh" + # --- lint-cloud-init negatives: each broken variant must be rejected ------------------- # Skipped without cloud-init on PATH, except in CI (which installs it), so a broken # install step cannot quietly drop these cases. @@ -1770,6 +1856,17 @@ done app="$(sed -nE 's/^appVersion: *"?([^" #]*)"?.*/\1/p' "$repo/charts/decdn-node/Chart.yaml")" [ "$app" = "$(pin decdn_node decdn_node_version)" ] \ || fail "charts/decdn-node/Chart.yaml appVersion '$app' is not decdn_node_version" +# AGENTS.md's "Pinned upstream releases" is the one doc that names the numbers +# (bump_decdn.py moves decdn's). Joined first: the phrases wrap across lines. +agents="$(tr '\n' ' ' <"$repo/AGENTS.md")" +for p in "decdn_node decdn_node_version" "sponsord sponsord_version"; do + role="${p% *}" var="${p#* }" + named="$(sed -nE "s/.*\*\*Pinned upstream releases\.\*\*.*\`$var\`[^,]*, +today +\`([0-9][0-9.]*)\`.*/\1/p" <<<"$agents")" + # Two empty strings agree: a missing number or pin must not. + if [ -z "$named" ] || [ "$named" != "$(pin "$role" "$var")" ]; then + fail "AGENTS.md \"Pinned upstream releases\" names $var '$named', not its default '$(pin "$role" "$var")'" + fi +done # compose.yaml pins n0's iroh images by digest, with the release they are named in # the comment above each image line; it must be the release the roles install. The # wrapper's DNS_VERSION (the DNS server has no --version; health reads /healthz) too. @@ -1817,7 +1914,7 @@ want_fprs="$(fprs "$repo/ansible/roles/decdn_node/files/decdn-release-KEYS.asc") || fail "the decdn and sponsord release KEYS hold different keys" [ "$(sed -nE 's/^Fingerprint: *//p' "$repo/SECURITY.md" | tr -d ' ' | sort)" = "$want_fprs" ] \ || fail "SECURITY.md's Fingerprint: lines differ from the vendored KEYS" -pass "release pins agree across roles, Compose (incl. its iroh images), the chart and its artifacthub.io/images; the vendored KEYS match SECURITY.md" +pass "release pins agree across roles, Compose (incl. its iroh images), the chart and its artifacthub.io/images, and AGENTS.md; the vendored KEYS match SECURITY.md" # Compose's Alloy image is the release the grafana_alloy role installs: the comment # above its digest names the tag the digest was taken from. alloy_tag="$(sed -nE 's|^ *# grafana/alloy v([0-9][0-9.]*), the grafana_alloy role.*|\1|p' "$repo/compose/compose.yaml")" @@ -1880,12 +1977,14 @@ fi # The chart renders monitoring/ through a symlink, and lint-helm is what checks the # dashboards and rules there (promtool, the sponsord unit tests, uids). A PR that -# touches only monitoring/ runs it only if ci.yml's helm path filter lists it. +# touches only monitoring/ runs it only if ci.yml's helm path filter lists it; the +# same goes for the decdn CLI's fetch script and the KEYS it verifies against. if command -v yq >/dev/null; then + helm_paths='["charts/**", "monitoring/**", "scripts/fetch-decdn-cli.sh", "ansible/roles/decdn_node/files/decdn-release-KEYS.asc"]' yq '.jobs.changes.steps[] | select(.id == "filter") | .with.filters' "$repo/.github/workflows/ci.yml" \ - | yq -e '.helm | (contains(["charts/**"]) and contains(["monitoring/**"]))' >/dev/null \ - || fail "ci.yml: the helm path filter must list charts/** and monitoring/**" - pass "ci.yml runs lint-helm on charts/** and monitoring/**" + | yq -e ".helm | contains($helm_paths)" >/dev/null \ + || fail "ci.yml: the helm path filter must list $helm_paths" + pass "ci.yml runs lint-helm on charts/**, monitoring/**, the decdn CLI fetch script and its KEYS" elif [[ -n ${CI:-} ]]; then fail "yq is not on PATH in CI; the helm path-filter check would be skipped" else