From bc107c9023c39223246e60a8f0f6835b2829db4a Mon Sep 17 00:00:00 2001 From: Ant Somers Date: Wed, 14 Oct 2026 20:44:43 +0300 Subject: [PATCH 1/2] feat: follow the decdn and sponsord release lines, not one release decdn_node_version, sponsord_version and sponsord_onramp_version default to a release line ("0.1"), and every run installs its newest finished vX.Y.N (tasks/resolve-release.yml, copied into all three roles): the highest release in GitHub's release list that is not a draft or a prerelease and has a SHA256SUMS.asc. The list only chooses the tag; the download is verified as before, and the stamp and the --version backstop hold the resolved version. An exact "X.Y.Z" still pins one release, with no lookup. The onramp's installer pin follows decdn's line too (v0.1): the role reads the resolved release's SHA256SUMS from where the installers fetch it, verifies it against a vendored copy of decdn's KEYS and serves its digest. A tag still takes the operator's digest. The chart's appVersion is the line and its default image tag the line's floating tag, pulled Always (decdn-node.pullPolicy); fetch-decdn-cli.sh resolves the line for CI's config validation. Compose stays pinned by digest. bump_decdn.py and open-decdn-bump.sh now tell a patch release (repo-only: Compose, the example digests and pins, the mirrors) from a new line, and upstream-drift checks the exact copies against each line's newest release and flags only a new line. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 8 +- .github/workflows/upstream-drift.yml | 89 +++++++---- AGENTS.md | 69 +++++---- CONTRIBUTING.md | 9 +- RELEASING.md | 6 +- ansible/README.md | 12 +- ansible/galaxy/node/README.md | 4 +- .../inventory/group_vars/sponsord_hosts.yml | 6 +- .../group_vars/sponsord_onramp_hosts.yml | 7 +- .../inventory/host_vars/decdn-node-1/main.yml | 3 +- ansible/inventory/host_vars/origin-1/main.yml | 3 +- .../cloud-init-publisher/converge.yml | 10 +- .../molecule/cloud-init-publisher/prepare.yml | 12 +- .../molecule/cloud-init-publisher/verify.yml | 18 +++ .../molecule/cloud-init/includes/mirror.yml | 49 +++++- ansible/molecule/cloud-init/prepare.yml | 14 ++ ansible/molecule/cloud-init/verify.yml | 89 +++++++++++ ansible/molecule/schema/README.md | 5 +- .../molecule/schema/files/gen-schema-keys.py | 6 +- ansible/molecule/schema/files/schema-keys.txt | 6 +- ansible/molecule/sponsord-install/prepare.yml | 31 +++- .../molecule/sponsord-install/side_effect.yml | 26 +++- .../molecule/validation-sponsord/converge.yml | 61 ++++++-- ansible/molecule/validation/converge.yml | 45 ++++++ ansible/roles/decdn_node/README.md | 119 +++++++++------ ansible/roles/decdn_node/defaults/main.yml | 18 ++- ansible/roles/decdn_node/tasks/install.yml | 51 +++++-- ansible/roles/decdn_node/tasks/main.yml | 17 ++- .../decdn_node/tasks/resolve-release.yml | 141 ++++++++++++++++++ ansible/roles/sponsord/README.md | 39 +++-- ansible/roles/sponsord/defaults/main.yml | 15 +- ansible/roles/sponsord/tasks/install.yml | 40 +++-- ansible/roles/sponsord/tasks/main.yml | 3 +- .../roles/sponsord/tasks/resolve-release.yml | 141 ++++++++++++++++++ ansible/roles/sponsord_onramp/README.md | 26 ++-- .../roles/sponsord_onramp/defaults/main.yml | 32 ++-- .../files/decdn-release-KEYS.asc | 80 ++++++++++ .../sponsord_onramp/tasks/decdn-release.yml | 138 +++++++++++++++++ .../roles/sponsord_onramp/tasks/install.yml | 48 ++++-- ansible/roles/sponsord_onramp/tasks/main.yml | 50 ++++--- .../sponsord_onramp/tasks/resolve-release.yml | 141 ++++++++++++++++++ .../templates/sponsord-onramp.env.j2 | 4 +- charts/decdn-node/Chart.yaml | 14 +- charts/decdn-node/README.md | 15 +- charts/decdn-node/templates/_helpers.tpl | 16 ++ charts/decdn-node/templates/statefulset.yaml | 4 +- charts/decdn-node/tests/render-test.sh | 18 ++- charts/decdn-node/values.schema.json | 1 + charts/decdn-node/values.yaml | 10 +- cloud-init/README.md | 25 ++-- cloud-init/tests/lint.py | 20 ++- cloud-init/user-data-node.yaml | 10 +- cloud-init/user-data-publisher.yaml | 25 ++-- compose/.env.example | 6 +- compose/README.md | 8 +- compose/sponsord-onramp.env.example | 4 +- docs/node-operators.md | 8 + docs/publishers.md | 8 + .../inventory/group_vars/sponsord_hosts.yml | 6 +- .../group_vars/sponsord_onramp_hosts.yml | 7 +- .../inventory/host_vars/decdn-node-1/main.yml | 3 +- .../inventory/host_vars/origin-1/main.yml | 3 +- scripts/bump_decdn.py | 119 ++++++++++----- scripts/fetch-decdn-cli.sh | 40 ++++- scripts/open-decdn-bump.sh | 67 +++++++-- scripts/sync-network-profiles.py | 21 ++- scripts/tests/test_bump_decdn.py | 114 +++++++++----- tests/scripts-test.sh | 74 +++++++-- 68 files changed, 1897 insertions(+), 440 deletions(-) create mode 100644 ansible/roles/decdn_node/tasks/resolve-release.yml create mode 100644 ansible/roles/sponsord/tasks/resolve-release.yml create mode 100644 ansible/roles/sponsord_onramp/files/decdn-release-KEYS.asc create mode 100644 ansible/roles/sponsord_onramp/tasks/decdn-release.yml create mode 100644 ansible/roles/sponsord_onramp/tasks/resolve-release.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2204c91..c23851c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -213,8 +213,12 @@ jobs: python-version: '3.12' - name: Chart lint + render tests # 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. + # newest release of the line the chart pins (appVersion), resolved through + # GitHub's API (with the job token: runners share the unauthenticated limit) and + # signature-checked against the vendored KEYS, so a decdn bump PR validates the + # chart against the new release. + env: + GITHUB_TOKEN: ${{ github.token }} run: | DECDN_CLI="$(scripts/fetch-decdn-cli.sh "$RUNNER_TEMP/decdn")" export DECDN_CLI diff --git a/.github/workflows/upstream-drift.yml b/.github/workflows/upstream-drift.yml index d89969d..053b61e 100644 --- a/.github/workflows/upstream-drift.yml +++ b/.github/workflows/upstream-drift.yml @@ -1,17 +1,19 @@ --- name: Upstream drift -# The repo pins one decdn/decdn and one decdn/sponsord release (decdn_node_version, -# sponsord_version) and carries copies of what they publish. This job checks, weekly: -# - the generated mirrors of decdn/decdn at the pinned tag: +# The repo pins one decdn/decdn and one decdn/sponsord release line (decdn_node_version, +# sponsord_version: "X.Y", whose newest finished vX.Y.N the roles install) and carries +# copies of what that release publishes. This job resolves each line as the roles do +# (tasks/resolve-release.yml) and checks, weekly: +# - the generated mirrors of decdn/decdn at that release's tag: # roles/decdn_node/vars/main/networks.yml (contract addresses) and its subsets in # roles/sponsord{,_onramp}/vars/main/, and ansible/molecule/schema/files/ # schema-keys.txt (the node.toml key inventory). They describe the binary a # default deploy installs, so they are generated from the tag, not from main; -# - the hand-copied release facts: the onramp's decdn SHA256SUMS digest (role defaults) -# and the Compose image digests (compose/.env.example), against the pinned -# releases' assets; -# - that each repo's newest vX.Y.Z tag is still the pin. +# - the hand-copied release facts: the onramp's decdn tag and SHA256SUMS digest +# (compose/sponsord-onramp.env.example) and the Compose image digests +# (compose/.env.example), against that release's assets; +# - that each repo's newest vX.Y.Z tag is still in the pinned line. # It is a scheduled early warning, not a PR gate: an upstream release must not turn # unrelated PRs red. Fix drift by re-running the generator it names; act on a new # release with the bump checklist (roles/decdn_node/README.md, roles/sponsord/README.md); @@ -38,24 +40,45 @@ jobs: shell: bash steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - # The pins, read from the role defaults (plain scalars, so no YAML parser). - - name: Read the pinned releases + # The pins, read from the role defaults (plain scalars, so no YAML parser), and + # each line resolved as the roles resolve it: the highest vX.Y.N that is not a + # draft or a prerelease and has a SHA256SUMS.asc asset. An exact pin is itself. + - name: Read and resolve the pinned releases id: pins + env: + GH_TOKEN: ${{ github.token }} run: | pin() { sed -nE "s/^$2: *\"?([^\" #]*)\"?.*/\\1/p" "ansible/roles/$1/defaults/main.yml"; } + resolve() { # : the vX.Y.Z tag a default deploy installs + if [[ "$2" =~ ^[0-9]+\.[0-9]+$ ]]; then + gh api "repos/$1/releases?per_page=100" --jq " + [.[] | select(.draft | not) | select(.prerelease | not) + | select([.assets[].name] | index(\"SHA256SUMS.asc\")) + | .tag_name | capture(\"^v${2//./\\\\.}\\\\.(?0|[1-9][0-9]*)$\") + | .n | tonumber] | max // empty | \"v$2.\\(.)\"" + else + echo "v$2" + fi + } decdn="$(pin decdn_node decdn_node_version)" sponsord="$(pin sponsord sponsord_version)" - echo "decdn_sums=$(pin sponsord_onramp sponsord_onramp_decdn_sums_sha256)" >> "$GITHUB_OUTPUT" if [ -z "$decdn" ] || [ -z "$sponsord" ]; then echo "::error::a role has no pinned version" exit 1 fi - { echo "decdn=$decdn"; echo "sponsord=$sponsord"; } >> "$GITHUB_OUTPUT" - # Public repo, the pinned tag. The sync script reads HEAD through git. + decdn_tag="$(resolve decdn/decdn "$decdn")" + sponsord_tag="$(resolve decdn/sponsord "$sponsord")" + if [ -z "$decdn_tag" ] || [ -z "$sponsord_tag" ]; then + echo "::error::no finished release in decdn/decdn $decdn or decdn/sponsord $sponsord" + exit 1 + fi + { echo "decdn=$decdn"; echo "sponsord=$sponsord" + echo "decdn_tag=$decdn_tag"; echo "sponsord_tag=$sponsord_tag"; } >> "$GITHUB_OUTPUT" + # Public repo, the resolved tag. The sync script reads HEAD through git. - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: decdn/decdn - ref: v${{ steps.pins.outputs.decdn }} + ref: ${{ steps.pins.outputs.decdn_tag }} path: upstream persist-credentials: false - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 @@ -66,9 +89,10 @@ jobs: # upstream change. Full output goes to the job summary; annotations stay one line. - name: Compare the mirrors and the pins with upstream env: - DECDN_PIN: v${{ steps.pins.outputs.decdn }} - SPONSORD_PIN: v${{ steps.pins.outputs.sponsord }} - DECDN_SUMS: ${{ steps.pins.outputs.decdn_sums }} + DECDN_PIN: ${{ steps.pins.outputs.decdn }} + SPONSORD_PIN: ${{ steps.pins.outputs.sponsord }} + DECDN_TAG: ${{ steps.pins.outputs.decdn_tag }} + SPONSORD_TAG: ${{ steps.pins.outputs.sponsord_tag }} run: | rc=0 check() { @@ -93,7 +117,8 @@ jobs: python3 ansible/molecule/schema/files/gen-schema-keys.py upstream > "$fresh" || return 2 diff -u ansible/molecule/schema/files/schema-keys.txt "$fresh" } - # Exit 1 when the repo's newest vX.Y.Z tag (pre-releases skipped) is not the pin. + # Exit 1 when the repo's newest vX.Y.Z tag (pre-releases skipped) is not in the + # pinned line (or, for an exact pin, is not the pin): a new line is a bump. # shellcheck disable=SC2329 # invoked indirectly, through check() release_check() { local repo="$1" pin="$2" tags latest @@ -101,7 +126,7 @@ jobs: latest="$(sed -n 's#.*refs/tags/##p' <<<"$tags" | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' \ | sort -V | tail -n 1)" || true [ -n "$latest" ] || { echo "$repo: no vX.Y.Z tag found"; return 2; } - [ "$latest" = "$pin" ] && return 0 + [ "$latest" = "v$pin" ] || [ "${latest%.*}" = "v$pin" ] && return 0 echo "$repo: pinned $pin, newest vX.Y.Z tag $latest" return 1 } @@ -109,16 +134,20 @@ jobs: asset() { # : a release asset on stdout curl -fsSL --retry 3 "https://github.com/$1/releases/download/$2/$3" } - # Exit 1 when a pinned SHA256SUMS digest is not the release's. The installers - # the onramp serves check the downloaded SHA256SUMS against it, so a stale - # one would only fail on users' machines. + # Exit 1 when Compose's onramp installer pin (compose/sponsord-onramp.env.example) + # is not the release a default deploy serves, or its SHA256SUMS digest is not + # that release's. The installers check the downloaded SHA256SUMS against it, + # so a stale one would only fail on users' machines. # shellcheck disable=SC2329 # invoked indirectly, through check() sums_check() { - local repo="$1" tag="$2" want="$3" got + local repo="$1" tag="$2" env=compose/sponsord-onramp.env.example release want got + release="$(sed -nE 's/^ONRAMP_DECDN_RELEASE=//p' "$env")" + want="$(sed -nE 's/^ONRAMP_DECDN_SUMS_SHA256=//p' "$env")" + [ "$release" = "$tag" ] || { echo "$env: ONRAMP_DECDN_RELEASE is $release, not $tag"; return 1; } got="$(asset "$repo" "$tag" SHA256SUMS | sha256sum)" || return 2 got="${got%% *}" [ "$got" = "$want" ] && return 0 - echo "$repo $tag: SHA256SUMS is $got, the role pins $want" + echo "$repo $tag: SHA256SUMS is $got, $env pins $want" return 1 } # Exit 1 when a compose/.env.example digest is not the one the pinned release's @@ -136,16 +165,16 @@ jobs: echo "$repo $tag: $var should be $got, compose/.env.example has $want" return 1 } - echo "### Upstream drift (decdn/decdn $DECDN_PIN, $(git -C upstream rev-parse --short HEAD))" >> "$GITHUB_STEP_SUMMARY" + echo "### Upstream drift (decdn/decdn $DECDN_PIN: $DECDN_TAG, $(git -C upstream rev-parse --short HEAD); decdn/sponsord $SPONSORD_PIN: $SPONSORD_TAG)" >> "$GITHUB_STEP_SUMMARY" check "network profiles" python3 scripts/sync-network-profiles.py upstream --ref HEAD --check check "config schema keys" schema_check - check "decdn/decdn release" release_check decdn/decdn "$DECDN_PIN" - check "decdn/sponsord release" release_check decdn/sponsord "$SPONSORD_PIN" - check "onramp decdn SHA256SUMS digest" sums_check decdn/decdn "$DECDN_PIN" "$DECDN_SUMS" - check "Compose decdn-node digest" digest_check decdn/decdn "$DECDN_PIN" DECDN_IMAGE_DIGEST \ + check "decdn/decdn release line" release_check decdn/decdn "$DECDN_PIN" + check "decdn/sponsord release line" release_check decdn/sponsord "$SPONSORD_PIN" + check "Compose onramp decdn pin" sums_check decdn/decdn "$DECDN_TAG" + check "Compose decdn-node digest" digest_check decdn/decdn "$DECDN_TAG" DECDN_IMAGE_DIGEST \ decdn-node-image-digest.txt - check "Compose sponsord digest" digest_check decdn/sponsord "$SPONSORD_PIN" SPONSORD_IMAGE_DIGEST \ + check "Compose sponsord digest" digest_check decdn/sponsord "$SPONSORD_TAG" SPONSORD_IMAGE_DIGEST \ sponsord-image-digest.txt - check "Compose sponsord-onramp digest" digest_check decdn/sponsord "$SPONSORD_PIN" \ + check "Compose sponsord-onramp digest" digest_check decdn/sponsord "$SPONSORD_TAG" \ SPONSORD_ONRAMP_IMAGE_DIGEST sponsord-onramp-image-digest.txt exit "$rc" diff --git a/AGENTS.md b/AGENTS.md index ee00b68..843abea 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -129,9 +129,9 @@ scripts/ # upstream-mirror generators, the compose Alloy config g `ansible/roles/sponsord/vars/main/networks.yml` and `ansible/roles/sponsord_onramp/vars/main/networks.yml` (all `scripts/sync-network-profiles.py`) and `ansible/molecule/schema/files/schema-keys.txt` (`gen-schema-keys.py`), both -generated from the decdn/decdn tag `decdn_node_version` pins. The weekly -`upstream-drift` workflow flags staleness against that tag, and a newer upstream -release of either repo. These are the only protocol facts (contract addresses) the +generated from the tag of the newest decdn/decdn release in the line +`decdn_node_version` pins. The weekly `upstream-drift` workflow flags staleness +against that tag, and a new upstream release line of either repo. These are the only protocol facts (contract addresses) the repo carries, and `networks.yml` carries its upstream commit. **Generated from this repo — regenerate, never hand-edit:** `compose/alloy/config.alloy` @@ -141,23 +141,33 @@ runtime (`scripts/render-compose-alloy.sh`, which runs the `compose`-tagged task test-scripts` fails while the committed file differs from a fresh render, and `make lint-alloy` validates and runs it. -**Pinned upstream releases.** One decdn/decdn release (`decdn_node_version`, today -`0.1.0`) and one decdn/sponsord release (`sponsord_version` = `sponsord_onramp_version`, -today `0.1.0`; decdn/sponsord tags its whole workspace `vX.Y.Z`, there are no per-crate -tags) are pinned across every path: the role defaults, the onramp's installer pin -(`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`. 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`; +**Pinned upstream releases.** One decdn/decdn release line (`decdn_node_version`, today +`0.1`) and one decdn/sponsord release line (`sponsord_version` = `sponsord_onramp_version`, +today `0.1`; decdn/sponsord tags its whole workspace `vX.Y.Z`, there are no per-crate +tags) are pinned across every path. A line, `X.Y`, is what the role defaults, the +onramp's installer pin (`sponsord_onramp_decdn_release`, `vX.Y`) and the chart's +`appVersion` name: the roles install its newest finished `vX.Y.N` on every run +(`tasks/resolve-release.yml`, copied into all three roles: GitHub's release list, then +the same signed `SHA256SUMS` and exact `--version` check against the resolved version; +the onramp reads the installers' digest from that release's verified `SHA256SUMS`), +and the chart's default image tag is the line's floating tag, pulled `Always`. An +exact `X.Y.Z` in inventory (or `image.tag`/`image.digest`) holds a host on one release. +So an upstream patch release needs no release of the collections or the chart; a new +line does. What cannot resolve a line names one release of it and moves on every +upstream release, repo-only: `compose/.env.example`'s image digests, the onramp's +installer pin in `compose/sponsord-onramp.env.example` (tag + `SHA256SUMS` digest), the +chart's example digest, the commented exact-pin examples, and the generated mirrors +(which a patch is not expected to change; when one does, the bump PR says so and the +collections need a release). cloud-init takes the role defaults at `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 (a release of the line), `appVersion`, the chart's +`artifacthub.io/images` tag and this paragraph, and that the vendored KEYS hold the +keys SECURITY.md publishes; the weekly `upstream-drift` job checks the exact copies and +the mirrors against each line's newest release and flags a newer upstream line. Bump +with the checklists in `roles/decdn_node/README.md` and `roles/sponsord/README.md`; `scripts/open-decdn-bump.sh` opens the decdn bump's PR (decdn's `publish-crates.sh` runs it after each release). Both upstream repos are public and also publish their binaries as crates (`decdn-node`, `decdn-cli`, `sponsord`, `sponsord-onramp`); the @@ -199,8 +209,9 @@ the pinned `iroh-relay` (re-capture it when bumping `iroh_relay_version`). backend holds (`relay_foreign_namespaces`, ADR 002 § Retrieval by namespace, ADR 037). Recognition as an origin is on-chain (`OriginAssignment.addOrigin`, 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`); `source` — a git ref (any tag/branch/SHA) cloned and `cargo build`-ed **on the + `release` (the default) — a GitHub release tarball, the newest of the pinned line or + an exact release (`decdn_node_version`, `tasks/resolve-release.yml`), verified + against the release's GPG-signed `SHA256SUMS`; `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 @@ -297,7 +308,8 @@ 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`), a host-side `source` build, or a manual binary. + (`sponsord_version`: the newest of its line, or an exact release), 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), @@ -647,8 +659,9 @@ the pinned `iroh-relay` (re-capture it when bumping `iroh_relay_version`). - **`charts/decdn-node/`** — the same node on Kubernetes: a one-replica StatefulSet (one release = one identity) on the upstream `decdn-node` image (`ghcr.io/decdn/decdn-node`, - tag `appVersion` = the role's `decdn_node_version` unless `image.tag`/`image.digest` - is set), PVC data dir, a `prepare` init + tag `appVersion` = the role's `decdn_node_version` line, the floating tag upstream + moves to each release in it, pulled `Always` (`decdn-node.pullPolicy`), unless + `image.tag`/`image.digest` is set), PVC data dir, a `prepare` init container that installs the identity files from an `existingSecret` onto the PVC at `0600` (upstream rejects symlinked or group/world-readable key files) and the password into an in-memory volume, `DECDN_RPC_URL` via `secretKeyRef` (named keys only), public @@ -662,9 +675,9 @@ the pinned `iroh-relay` (re-capture it when bumping `iroh_relay_version`). 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. 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 + newest `decdn` of the line `appVersion` pins (`scripts/fetch-decdn-cli.sh`: the + role's line resolution and 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 2ec2185..5eb7236 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -58,10 +58,11 @@ Two things here are generated from `decdn/decdn`; regenerate, never hand-edit: | `ansible/roles/{decdn_node,sponsord,sponsord_onramp}/vars/main/networks.yml` (contract addresses per network) | `scripts/sync-network-profiles.py ` | | `ansible/molecule/schema/files/schema-keys.txt` (node.toml keys) | `ansible/molecule/schema/files/gen-schema-keys.py > …` | -Both describe the release the roles pin (`decdn_node_version`), so generate them from -its tag. `scripts/sync-network-profiles.py` reads git at `--ref`, which defaults to -that tag, whatever the checkout's own branch; `gen-schema-keys.py` reads the -checkout's working tree, so check out the tag first. +Both describe the newest release of the line the roles pin (`decdn_node_version`), so +generate them from its tag. `scripts/sync-network-profiles.py` reads git at `--ref`, +which defaults to that tag (the checkout's highest `v.N`), whatever the +checkout's own branch; `gen-schema-keys.py` reads the checkout's working tree, so +check out the tag first. The full bump checklist is in `ansible/roles/decdn_node/README.md`; `scripts/open-decdn-bump.sh` does most of it (regenerating both) and opens the PR, listing the rest in the PR body. diff --git a/RELEASING.md b/RELEASING.md index 642062f..c8ad899 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -16,7 +16,7 @@ refuses any publisher release whose `decdn.node: ">=X.Y.Z"` no `node-collection- tag on origin satisfies yet. A tag is not a Galaxy upload, so the publish job also asks Galaxy, before uploading a `decdn.publisher`, for a `decdn.node` that satisfies it, and fails while there is none: publish that node release, then re-run the job. What the artifacts must agree on is checked by CI, not by a shared -version: the decdn version (the chart's `appVersion` and the role's +version: the decdn release line (the chart's `appVersion` and the role's `decdn_node_version`) on every PR (`make test-scripts`), and the config keys each one renders against the same upstream schema-key inventory whenever either changes (`make lint-helm`, `molecule/schema`). When a change lands in several (a decdn bump @@ -75,7 +75,9 @@ would commit, and changes no file. The script: `charts/decdn-node/Chart.yaml`. The chart's `appVersion` and its `artifacthub.io/images` tag already follow the role's `decdn_node_version` (`make test-scripts` refuses a PR where they differ), so - a decdn bump is a chart change too: release the chart. + a new decdn line is a chart change too: release the chart. A decdn patch release + in the pinned line needs no release of anything (the roles and the chart's default + tag follow the line); its bump PR only moves Compose and the examples. 4. **Writes the changelog section.** It renders the same commits into a dated `## [X.Y.Z] — YYYY-MM-DD` section at the top of the artifact's `CHANGELOG.md`: `feat` under Added, `fix` under Fixed, `revert` under Removed, `security` under diff --git a/ansible/README.md b/ansible/README.md index c70abd0..7f6be87 100644 --- a/ansible/README.md +++ b/ansible/README.md @@ -181,11 +181,13 @@ ignored on the first converge. **Prerequisites** (see `roles/decdn_node/README.md` for the full flow): 1. **Binaries.** Pick `decdn_node_install_method`: - - **`release`** (the default) downloads the `v` tarballs from + - **`release`** (the default) downloads the release 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 (the - role's `defaults/main.yml`); the sponsord roles pin a decdn/sponsord release the - same way (`sponsord_version`). + `decdn_node_version` defaults to the decdn/decdn release line this repo pins (the + role's `defaults/main.yml`, `"X.Y"`): each run installs the line's newest + finished release, so a patch release arrives with the next deploy. Set an exact + `"X.Y.Z"` to hold a host on one release. The sponsord roles pin a decdn/sponsord + line 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 @@ -509,7 +511,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`), `source` (build `decdn_node_source_ref` on the node) or `manual` (copy a control-machine build). | +| `decdn_node_install_method` | `release` | `release` (the newest release of the `decdn_node_version` line, or that exact release), `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/node/README.md b/ansible/galaxy/node/README.md index 875f357..0a347d4 100644 --- a/ansible/galaxy/node/README.md +++ b/ansible/galaxy/node/README.md @@ -123,8 +123,8 @@ key lands before SSH hardening), then the node: vars: decdn_network: arbitrum-sepolia # chain_id + every contract address, from upstream's manifest # Also REQUIRED per host (host_vars): decdn_region, the binaries (nothing - # for the default release method, which installs the pinned - # decdn_node_version; decdn_node_install_method: source + + # for the default release method, which installs the newest release of the + # pinned decdn_node_version line; decdn_node_install_method: source + # decdn_node_source_ref, or manual + decdn_release_target_dir), and the RPC URL (host-provisioned # /etc/decdn/decdn.env, or decdn_rpc_url in a git-ignored secret.yml). ``` diff --git a/ansible/inventory/group_vars/sponsord_hosts.yml b/ansible/inventory/group_vars/sponsord_hosts.yml index 7c86e36..2a3934e 100644 --- a/ansible/inventory/group_vars/sponsord_hosts.yml +++ b/ansible/inventory/group_vars/sponsord_hosts.yml @@ -11,9 +11,9 @@ # The API token is generated on the host at /etc/sponsord/api-token. # --- Binary ------------------------------------------------------------------------ -# The default method, "release", installs the decdn/sponsord release the role pins -# (tag v); set it only to hold the host on another one, together -# with sponsord_onramp_version: +# The default method, "release", installs the newest release of the decdn/sponsord +# line the role pins (sponsord_version "X.Y"), on each deploy; set an exact one only +# to hold the host on it, together with sponsord_onramp_version: # sponsord_version: "0.1.0" # Or build on the host from a git ref (tag, branch or SHA; sponsord_version is not # checked in this mode): diff --git a/ansible/inventory/group_vars/sponsord_onramp_hosts.yml b/ansible/inventory/group_vars/sponsord_onramp_hosts.yml index 78d281a..7b1d476 100644 --- a/ansible/inventory/group_vars/sponsord_onramp_hosts.yml +++ b/ansible/inventory/group_vars/sponsord_onramp_hosts.yml @@ -32,9 +32,10 @@ # PUBLIC: served to every user. A keyless endpoint, never sponsord_rpc_url. # 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 -# v), with the SHA-256 of its SHA256SUMS. To serve another, -# set both: +# The decdn release the installers install defaults to the newest release of the +# role's sponsord_onramp_decdn_release line (decdn/decdn, the decdn_node role's default +# line; a host's decdn_node_version does not move it), with the SHA-256 of its +# verified SHA256SUMS, on each deploy. To serve one exact release, set its tag and digest: # sponsord_onramp_decdn_release: v0.1.0 # sponsord_onramp_decdn_sums_sha256: "…" # CapacityBond and SlashJudge come from sponsord_network (group_vars/sponsord_hosts.yml). diff --git a/ansible/inventory/host_vars/decdn-node-1/main.yml b/ansible/inventory/host_vars/decdn-node-1/main.yml index c1739ac..4ec5209 100644 --- a/ansible/inventory/host_vars/decdn-node-1/main.yml +++ b/ansible/inventory/host_vars/decdn-node-1/main.yml @@ -21,7 +21,8 @@ # --- Which binaries to install ------------------------------------------------ # "release" (the default): the v GitHub Release tarballs, verified against # the GPG-signed SHA256SUMS manifest. The version defaults to the decdn/decdn release -# the role pins; set it only to hold a node on another one. +# line the role pins ("X.Y": its newest release, installed by each deploy); set an +# exact one only to hold a node on it. decdn_node_install_method: release # decdn_node_version: "0.1.0" # diff --git a/ansible/inventory/host_vars/origin-1/main.yml b/ansible/inventory/host_vars/origin-1/main.yml index 01cae67..1b0b93e 100644 --- a/ansible/inventory/host_vars/origin-1/main.yml +++ b/ansible/inventory/host_vars/origin-1/main.yml @@ -23,7 +23,8 @@ # --- Which binaries to install ------------------------------------------------ # "release" (the default): the v GitHub Release tarballs, verified against # the GPG-signed SHA256SUMS manifest. The version defaults to the decdn/decdn release -# the role pins; set it only to hold a node on another one. +# line the role pins ("X.Y": its newest release, installed by each deploy); set an +# exact one only to hold a node on it. decdn_node_install_method: release # decdn_node_version: "0.1.0" # diff --git a/ansible/molecule/cloud-init-publisher/converge.yml b/ansible/molecule/cloud-init-publisher/converge.yml index 6537c30..12524d2 100644 --- a/ansible/molecule/cloud-init-publisher/converge.yml +++ b/ansible/molecule/cloud-init-publisher/converge.yml @@ -3,7 +3,8 @@ # 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). +# `sponsord_onramp_caddy_tls: internal` (no ACME in a container). The onramp's +# installer pin is the line v0.0, resolved against the mirror. # # 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. @@ -50,6 +51,13 @@ - regexp: '^(\s*)# sponsord_onramp_release_base: .*$' replace: >- \1sponsord_onramp_release_base: "{{ sponsor_base }}"\n\1sponsord_onramp_release_keyring: {{ mirror_key }} + # The installers' decdn release, a line resolved through the mirror's release list. + - regexp: '^(\s*)# sponsord_onramp_decdn_release: .*$' + replace: >- + \1sponsord_onramp_decdn_release: v0.0\n\1sponsord_onramp_decdn_release_index: "{{ node_base }}/releases.json" + - regexp: '^(\s*)# sponsord_onramp_decdn_sums_sha256: .*$' + replace: >- + \1sponsord_onramp_decdn_release_base: "{{ node_base }}"\n\1sponsord_onramp_decdn_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".*$' diff --git a/ansible/molecule/cloud-init-publisher/prepare.yml b/ansible/molecule/cloud-init-publisher/prepare.yml index 8907c5f..5326ca0 100644 --- a/ansible/molecule/cloud-init-publisher/prepare.yml +++ b/ansible/molecule/cloud-init-publisher/prepare.yml @@ -11,8 +11,8 @@ vars: stub_version: "0.0.0-molecule-stub" stubs: "{{ lookup('ansible.builtin.env', 'MOLECULE_PROJECT_DIRECTORY') }}/molecule" - # 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 + # Two repositories' releases, so each gets its own base (converge.yml) and release + # list: 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: @@ -20,6 +20,14 @@ bins: - {name: decdn-node, src: "{{ stubs }}/default/files/decdn-node-stub"} - {name: decdn, src: "{{ stubs }}/default/files/decdn-node-stub"} + # A finished release of decdn's line 0.0 (stub_version is a prerelease tag, which + # no line takes): the onramp's installer pin resolves v0.0 to it and serves the + # SHA-256 of its SHA256SUMS (converge.yml, verify.yml). + - dir: decdn/v0.0.5 + version: 0.0.5 + 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"} diff --git a/ansible/molecule/cloud-init-publisher/verify.yml b/ansible/molecule/cloud-init-publisher/verify.yml index b6e0194..dc93bca 100644 --- a/ansible/molecule/cloud-init-publisher/verify.yml +++ b/ansible/molecule/cloud-init-publisher/verify.yml @@ -51,6 +51,18 @@ register: node_origin changed_when: false + - name: Read the decdn release the onramp serves its installers + ansible.builtin.slurp: + src: /etc/sponsord/sponsord-onramp.env + register: onramp_env + + - name: Hash the mirror's decdn v0.0.5 SHA256SUMS + ansible.builtin.stat: + path: /srv/mirror/www/releases/decdn/v0.0.5/SHA256SUMS + get_checksum: true + checksum_algorithm: sha256 + register: decdn_sums + - name: Gather service facts ansible.builtin.service_facts: @@ -89,6 +101,10 @@ - running == services - https_health.rc == 0 - login_hint.stdout == '' + # The line v0.0 resolved to the mirror's one finished release in it, and its + # digest was read from that release's verified SHA256SUMS. + - onramp_env.content | b64decode is search('(?m)^ONRAMP_DECDN_RELEASE=v0\.0\.5$') + - onramp_env.content | b64decode is search('(?m)^ONRAMP_DECDN_SUMS_SHA256=' ~ decdn_sums.stat.checksum ~ '$') fail_msg: >- state={{ bootstrap_state.content | b64decode | trim }} awaiting={{ awaiting.stat.exists }} head={{ checkout_head.content | b64decode | trim }} (tag {{ tag_sha.content | b64decode | trim }}) @@ -98,3 +114,5 @@ running={{ running }} of {{ services }} https rc={{ https_health.rc }} {{ https_health.stderr }} hint={{ login_hint.stdout | to_json }} + onramp decdn pin={{ onramp_env.content | b64decode | regex_findall('ONRAMP_DECDN_\w+=\S*') }} + (want v0.0.5, {{ decdn_sums.stat.checksum }}) diff --git a/ansible/molecule/cloud-init/includes/mirror.yml b/ansible/molecule/cloud-init/includes/mirror.yml index a63e42f..3f11374 100644 --- a/ansible/molecule/cloud-init/includes/mirror.yml +++ b/ansible/molecule/cloud-init/includes/mirror.yml @@ -8,10 +8,19 @@ # (/srv/mirror/KEYS.asc, passed as their release keyring) rather than the vendored # upstream KEYS, which is the only difference from a real release. # +# Each repository directory (the part of a release's dir before its last "/", or the +# mirror root) also gets a releases.json in the shape of GitHub's GET /releases, so +# the roles' release-line lookup (tasks/resolve-release.yml) runs against the mirror: +# one entry per release, its assets the files the directory really holds. +# # Input, from the including play: # mirror_releases: [{dir: , version: , bins: [{name: , src: }]}] -# stub_version: the version archives are named for unless the release sets one +# stub_version>, bins: [{name: , src: }], +# reports: , +# draft / prerelease: , +# asc: }] +# stub_version: the version archives are named for unless the release sets one, +# and the one the stubs print (their VERSION literal) - name: Name the release target triple ansible.builtin.set_fact: _mirror_target: >- @@ -70,9 +79,15 @@ set -euo pipefail export GNUPGHOME=/srv/mirror/gnupg ver={{ item.version | default(stub_version) | quote }} + reports={{ item.reports | default(stub_version) | quote }} + work=$(mktemp -d -p /srv/mirror/stage) for bin in {{ item.bins | map(attribute='name') | map('quote') | join(' ') }}; do - tar -C /srv/mirror/stage -czf "$bin-$ver-{{ _mirror_target }}.tar.gz" "$bin" + cp -p "/srv/mirror/stage/$bin" "$work/$bin" + # The stubs print stub_version; a release that reports another rewrites it. + sed -i "s/{{ stub_version | regex_escape(re_type='posix_basic') }}/$reports/" "$work/$bin" + tar -C "$work" -czf "$bin-$ver-{{ _mirror_target }}.tar.gz" "$bin" done + rm -rf "$work" sha256sum -- *.tar.gz > SHA256SUMS gpg --batch --armor --detach-sign --output SHA256SUMS.asc SHA256SUMS chmod 0644 -- * @@ -83,6 +98,34 @@ loop_control: label: "{{ item.dir }}" +- name: Write each repository's release list + ansible.builtin.command: + argv: + - python3 + - -I + - -c + - | + import json, os, sys + root = "/srv/mirror/www/releases" + index = {} + for r in json.loads(sys.argv[1]): + repo, _, tag = r["dir"].rpartition("/") + assets = sorted(os.listdir(os.path.join(root, r["dir"]))) + if not r.get("asc", True): + assets.remove("SHA256SUMS.asc") + index.setdefault(repo, []).append({ + "tag_name": tag, "draft": bool(r.get("draft", False)), + "prerelease": bool(r.get("prerelease", False)), + "assets": [{"name": a} for a in assets], + }) + for repo, releases in index.items(): + path = os.path.join(root, repo, "releases.json") + with open(path, "w") as f: + json.dump(releases, f, indent=1) + os.chmod(path, 0o644) + - "{{ mirror_releases | to_json }}" + changed_when: true + - name: Install the mirror's HTTP server unit ansible.builtin.copy: dest: /etc/systemd/system/molecule-mirror.service diff --git a/ansible/molecule/cloud-init/prepare.yml b/ansible/molecule/cloud-init/prepare.yml index 2b9efed..d6ed8b5 100644 --- a/ansible/molecule/cloud-init/prepare.yml +++ b/ansible/molecule/cloud-init/prepare.yml @@ -26,6 +26,20 @@ bins: - {name: decdn-node, src: "{{ stub_bin }}"} - {name: decdn, src: "{{ stub_bin }}"} + # The release line 0.7, for verify.yml's line play: 0.7.10 is the newest finished + # release (newer than 0.7.2 numerically, not as text). The prerelease, the draft + # and the release the index lists without SHA256SUMS.asc are all newer, and each + # reports another version, so installing any of them fails the version backstop. + - dir: v0.7.2 + version: 0.7.2 + reports: 0.7.2 + bins: &line_bins + - {name: decdn-node, src: "{{ stub_bin }}"} + - {name: decdn, src: "{{ stub_bin }}"} + - {dir: v0.7.10, version: 0.7.10, reports: 0.7.10, bins: *line_bins} + - {dir: v0.7.11, version: 0.7.11, prerelease: true, bins: *line_bins} + - {dir: v0.7.12, version: 0.7.12, draft: true, bins: *line_bins} + - {dir: v0.7.13, version: 0.7.13, asc: false, bins: *line_bins} tasks: - name: The repo, as a git remote ansible.builtin.include_tasks: includes/repo.yml diff --git a/ansible/molecule/cloud-init/verify.yml b/ansible/molecule/cloud-init/verify.yml index fae907e..663c312 100644 --- a/ansible/molecule/cloud-init/verify.yml +++ b/ansible/molecule/cloud-init/verify.yml @@ -168,3 +168,92 @@ that: - not refused_stamp.stat.exists fail_msg: A release refused by the version backstop left a version stamp. + +# A release line resolves through the mirror's release list (includes/mirror.yml) to +# its newest finished release, which is what gets downloaded, verified and stamped. +- name: Verify — a release line installs the newest finished release in it + hosts: all + become: true + vars: + _eph: "{{ lookup('ansible.builtin.env', 'MOLECULE_EPHEMERAL_DIRECTORY') }}" + _keys: "{{ _eph }}/cloud-init-mirror-KEYS-{{ inventory_hostname }}.asc" + tasks: + - name: Run the release install of the line 0.7 + ansible.builtin.include_role: + name: decdn_node + tasks_from: install + vars: + decdn_node_install_method: release + decdn_node_version: "0.7" + decdn_node_release_base: http://127.0.0.1:8099/releases + decdn_node_release_index: http://127.0.0.1:8099/releases/releases.json + decdn_release_keyring: "{{ _keys }}" + + - name: Read the version stamp after the line install + ansible.builtin.slurp: + src: /usr/local/lib/decdn/installed-version + register: line_stamp + + - name: Run the installed decdn-node + ansible.builtin.command: + argv: [/usr/local/bin/decdn-node, --version] + register: line_binary + changed_when: false + + - name: Require the newest finished release of the line + ansible.builtin.assert: + that: + - line_stamp.content | b64decode | trim == '0.7.10' + - line_binary.stdout | trim == 'decdn-node 0.7.10' + fail_msg: >- + The line 0.7 should install 0.7.10 (not the prerelease, the draft or the + release without SHA256SUMS.asc). Got stamp + "{{ line_stamp.content | b64decode | trim }}" and "{{ line_binary.stdout | trim }}". + + # With the release list unreachable, a host already on a release of the line keeps + # it (an outage must not fail a deploy); one with no release of the line fails. + - name: Run the release install of the line 0.7 with the release list unreachable + ansible.builtin.include_role: + name: decdn_node + tasks_from: install + vars: + decdn_node_install_method: release + decdn_node_version: "0.7" + decdn_node_release_base: http://127.0.0.1:8099/releases + decdn_node_release_index: http://127.0.0.1:8099/releases/no-such-list.json + decdn_release_keyring: "{{ _keys }}" + + - name: Read the version stamp after the unreachable list + ansible.builtin.slurp: + src: /usr/local/lib/decdn/installed-version + register: kept_stamp + + - name: Require the installed release of the line to be kept + ansible.builtin.assert: + that: + - kept_stamp.content | b64decode | trim == '0.7.10' + fail_msg: "Expected 0.7.10 kept, got \"{{ kept_stamp.content | b64decode | trim }}\"." + + - name: Install another line with the release list unreachable + block: + - name: Run the release install of the line 0.8 with the release list unreachable + ansible.builtin.include_role: + name: decdn_node + tasks_from: install + vars: + decdn_node_install_method: release + decdn_node_version: "0.8" + decdn_node_release_base: http://127.0.0.1:8099/releases + decdn_node_release_index: http://127.0.0.1:8099/releases/no-such-list.json + decdn_release_keyring: "{{ _keys }}" + + - name: Fail — a line with no installed release resolved without its list + ansible.builtin.fail: + msg: The line 0.8 resolved with no release list and no release of it installed. + rescue: + - name: Require the release-list check to be what stopped it + ansible.builtin.assert: + that: + # The name is templated ({{ _rr_name }}), so match its fixed part. + - ansible_failed_task.name is match('^Require a readable release list for ') + fail_msg: "Expected the release-list check, got \"{{ ansible_failed_task.name }}\"." diff --git a/ansible/molecule/schema/README.md b/ansible/molecule/schema/README.md index 0ae2c2c..09d789f 100644 --- a/ansible/molecule/schema/README.md +++ b/ansible/molecule/schema/README.md @@ -26,8 +26,9 @@ more. Every test passed. This scenario is that regression. ## Keeping it current `files/schema-keys.txt` is generated from the upstream source; its header carries -the exact regeneration command. Re-run it whenever you bump `decdn_node_version` -to a new `decdn/decdn` release (from a checkout of its tag), **before** touching the template — the diff on that +the exact regeneration command. Re-run it whenever the `decdn/decdn` release a +default deploy installs moves (a new release in the `decdn_node_version` line, or a +new line; from a checkout of its tag), **before** touching the template — the diff on that file is the changelog of what the config schema did. When you add a knob to the role, add it to `converge.yml` as well. A knob that is diff --git a/ansible/molecule/schema/files/gen-schema-keys.py b/ansible/molecule/schema/files/gen-schema-keys.py index 6ee2b3a..3681cc9 100755 --- a/ansible/molecule/schema/files/gen-schema-keys.py +++ b/ansible/molecule/schema/files/gen-schema-keys.py @@ -130,9 +130,9 @@ def main(): "#\n" "# ./gen-schema-keys.py > schema-keys.txt\n" "#\n" - "# from a checkout of v, the decdn/decdn release the roles pin\n" - "# (ansible/roles/decdn_node/defaults/main.yml): the generator reads the working\n" - "# tree, so check the tag out first.\n" + "# from a checkout of the newest release of the decdn/decdn line the roles pin\n" + "# (decdn_node_version in ansible/roles/decdn_node/defaults/main.yml): the\n" + "# generator reads the working tree, so check that tag out first.\n" "#\n" "# Paths, not bare leaf names: `sketch_bytes` is legal at\n" "# cache.tinylfu.sketch_bytes and a startup failure at cache.sketch_bytes, and\n" diff --git a/ansible/molecule/schema/files/schema-keys.txt b/ansible/molecule/schema/files/schema-keys.txt index ef057ee..d13423b 100644 --- a/ansible/molecule/schema/files/schema-keys.txt +++ b/ansible/molecule/schema/files/schema-keys.txt @@ -4,9 +4,9 @@ # # ./gen-schema-keys.py > schema-keys.txt # -# from a checkout of v, the decdn/decdn release the roles pin -# (ansible/roles/decdn_node/defaults/main.yml): the generator reads the working -# tree, so check the tag out first. +# from a checkout of the newest release of the decdn/decdn line the roles pin +# (decdn_node_version in ansible/roles/decdn_node/defaults/main.yml): the +# generator reads the working tree, so check that tag out first. # # Paths, not bare leaf names: `sketch_bytes` is legal at # cache.tinylfu.sketch_bytes and a startup failure at cache.sketch_bytes, and diff --git a/ansible/molecule/sponsord-install/prepare.yml b/ansible/molecule/sponsord-install/prepare.yml index e5d0d23..e97b58d 100644 --- a/ansible/molecule/sponsord-install/prepare.yml +++ b/ansible/molecule/sponsord-install/prepare.yml @@ -1,8 +1,10 @@ --- # Everything ../sponsord/prepare.yml stages, then a release mirror on loopback # (127.0.0.1:8081): the stub packaged the way upstream names a sponsord release, a -# SHA256SUMS signed by a throwaway key, and a second release whose binary reports -# the wrong version. side_effect.yml installs from it; the role verifies against +# SHA256SUMS signed by a throwaway key, a second release whose binary reports +# the wrong version, a release of the line 0.3, and the release list (GitHub's +# GET /releases shape) the role resolves that line from. side_effect.yml installs +# from it; the role verifies against # the mirror's key (fetched to the controller, since sponsord_release_keyring is a # controller-side file). - name: Prepare the host as ../sponsord does @@ -31,6 +33,7 @@ - {path: /srv/sponsord-mirror/www/releases, mode: "0755"} - {path: /srv/sponsord-mirror/www/releases/v0.0.0-molecule-stub, mode: "0755"} - {path: /srv/sponsord-mirror/www/releases/v0.0.9, mode: "0755"} + - {path: /srv/sponsord-mirror/www/releases/v0.3.4, mode: "0755"} - {path: /srv/sponsord-mirror/gnupg, mode: "0700"} - {path: /srv/sponsord-mirror/stage, mode: "0700"} loop_control: @@ -47,6 +50,7 @@ # v/sponsord--.tar.gz with the binary at the archive # root, as upstream's release.yml publishes. v0.0.9 holds the same stub, which # reports 0.0.0-molecule-stub: a release whose binary is not the pinned version. + # v0.3.4's stub reports 0.3.4, so the line 0.3 installs and passes the backstop. - name: Build, checksum and sign the mirror's releases ansible.builtin.shell: cmd: | @@ -55,9 +59,13 @@ gpg --batch --pinentry-mode loopback --passphrase '' \ --quick-gen-key 'molecule sponsord mirror ' ed25519 sign 1d gpg --batch --armor --export > /srv/sponsord-mirror/KEYS.asc - for ver in 0.0.0-molecule-stub 0.0.9; do + for ver in 0.0.0-molecule-stub 0.0.9 0.3.4; do cd /srv/sponsord-mirror/www/releases/v$ver - tar -C /srv/sponsord-mirror/stage -czf "sponsord-$ver-{{ _sp_triple }}.tar.gz" sponsord + work=$(mktemp -d -p /srv/sponsord-mirror/stage) + cp -p /srv/sponsord-mirror/stage/sponsord "$work/sponsord" + [ "$ver" != 0.3.4 ] || sed -i 's/0\.0\.0-molecule-stub/0.3.4/' "$work/sponsord" + tar -C "$work" -czf "sponsord-$ver-{{ _sp_triple }}.tar.gz" sponsord + rm -rf "$work" sha256sum -- *.tar.gz > SHA256SUMS gpg --batch --armor --detach-sign --output SHA256SUMS.asc SHA256SUMS chmod 0644 -- * @@ -69,6 +77,21 @@ {{ {'x86_64': 'x86_64-unknown-linux-gnu', 'aarch64': 'aarch64-unknown-linux-gnu'}[ansible_facts['architecture']] }} + # v0.3.5 is newer but a prerelease, and is never downloaded: the line skips it. + - name: Publish the mirror's release list + ansible.builtin.copy: + dest: /srv/sponsord-mirror/www/releases/releases.json + owner: root + group: root + mode: "0644" + content: "{{ _sp_list | to_nice_json }}\n" + vars: + _sp_assets: [{name: SHA256SUMS}, {name: SHA256SUMS.asc}] + _sp_list: + - {tag_name: v0.3.5, draft: false, prerelease: true, assets: "{{ _sp_assets }}"} + - {tag_name: v0.3.4, draft: false, prerelease: false, assets: "{{ _sp_assets }}"} + - {tag_name: v0.0.9, draft: false, prerelease: false, assets: "{{ _sp_assets }}"} + - name: Fetch the mirror's public key to the controller ansible.builtin.fetch: src: /srv/sponsord-mirror/KEYS.asc diff --git a/ansible/molecule/sponsord-install/side_effect.yml b/ansible/molecule/sponsord-install/side_effect.yml index 1557ff8..1c78a02 100644 --- a/ansible/molecule/sponsord-install/side_effect.yml +++ b/ansible/molecule/sponsord-install/side_effect.yml @@ -4,7 +4,8 @@ # 1. release mode against prepare.yml's signed loopback mirror: a verified # install writes the version stamp; a re-run needs no download; a bad # checksum, a bad signature and a binary that is not the pinned version all -# fail at their own task and leave no stamp; manual mode clears the stamp. +# fail at their own task and leave no stamp; a release line installs its newest +# finished release; manual mode clears the stamp. # 2. sponsord_generate_treasury_wallet: with no keystore, the role mints one on # the host through the decdn CLI (the default scenario's key-gen stub), records # the address and stops at its pool-id step; a re-run keeps that wallet; with a @@ -229,6 +230,29 @@ - not _sp_stamp_failed.stat.exists fail_msg: A failed release install left a version stamp, so the next run would skip the download. + # 5b. A release line installs the newest finished release in it (the mirror's + # release list has a newer prerelease, which it skips). + - name: Install the release line 0.3 + ansible.builtin.include_role: + name: sponsord + vars: + sponsord_install_method: release + sponsord_version: "0.3" + sponsord_release_base: "{{ _sp_base }}" + sponsord_release_index: "{{ _sp_base }}/releases.json" + sponsord_release_keyring: "{{ _sp_keys }}" + + - name: Read the version stamp after the line install + ansible.builtin.slurp: + src: "{{ _sp_stamp }}" + register: _sp_stamp_line + + - name: Require the line to install its newest finished release + ansible.builtin.assert: + that: + - (_sp_stamp_line.content | b64decode | trim) == '0.3.4' + fail_msg: "The line 0.3 should install 0.3.4, got \"{{ _sp_stamp_line.content | b64decode | trim }}\"." + # 6. Back to manual (the scenario's group_vars): the stamp must go. - name: Converge back to the manual install ansible.builtin.include_role: diff --git a/ansible/molecule/validation-sponsord/converge.yml b/ansible/molecule/validation-sponsord/converge.yml index 0d36d5d..6ca7a5e 100644 --- a/ansible/molecule/validation-sponsord/converge.yml +++ b/ansible/molecule/validation-sponsord/converge.yml @@ -58,6 +58,20 @@ decdn_rejected: "{{ decdn_rejected + ['sponsord-release-version'] }}" when: ansible_failed_task.name == "Require a pinned release version" + - name: "Case sponsord-release-version — release mode with a v-prefixed line" + block: + - name: Run sponsord with release mode with a v-prefixed line + ansible.builtin.include_role: + name: sponsord + vars: + sponsord_install_method: release + sponsord_version: "v0.1" + rescue: + - name: Record sponsord-release-version rejection (only if its assert failed) + ansible.builtin.set_fact: + decdn_rejected: "{{ decdn_rejected + ['sponsord-release-version'] }}" + when: ansible_failed_task.name == "Require a pinned release version" + - name: "Case sponsord-manual-src — manual mode with no binary source" block: - name: Run sponsord with manual mode with no binary source @@ -714,6 +728,20 @@ decdn_rejected: "{{ decdn_rejected + ['onramp-release-version'] }}" when: ansible_failed_task.name == "Require a pinned release version" + - name: "Case onramp-release-version — release mode with a wildcard" + block: + - name: Run sponsord_onramp with release mode with a wildcard + ansible.builtin.include_role: + name: sponsord_onramp + vars: + sponsord_onramp_install_method: release + sponsord_onramp_version: "0.1.x" + rescue: + - name: Record onramp-release-version rejection (only if its assert failed) + ansible.builtin.set_fact: + decdn_rejected: "{{ decdn_rejected + ['onramp-release-version'] }}" + when: ansible_failed_task.name == "Require a pinned release version" + - name: "Case onramp-manual-src — manual mode with no binary source" block: - name: Run sponsord_onramp with manual mode with no binary source @@ -940,20 +968,33 @@ decdn_rejected: "{{ decdn_rejected + ['onramp-pin'] }}" when: ansible_failed_task.name == "Validate the release pin the installers use" - # The play pins a non-default pair; moving one half back to its default - # leaves a tag served with another release's digest. - - name: "Case onramp-pin-pair — the decdn digest moved without its tag" + # The play pins an exact tag with its digest. A tag needs its digest, and a line + # (the default) takes none: the role reads it from the release it resolves to. + - name: "Case onramp-pin-pair — an exact decdn tag without its digest" + block: + - name: Run sponsord_onramp with an exact decdn tag and no digest + ansible.builtin.include_role: + name: sponsord_onramp + vars: + sponsord_onramp_decdn_sums_sha256: "" + rescue: + - name: Record onramp-pin-pair rejection (only if its assert failed) + ansible.builtin.set_fact: + decdn_rejected: "{{ decdn_rejected + ['onramp-pin-pair'] }}" + when: ansible_failed_task.name == "Require a SHA256SUMS digest exactly when the release pin is a tag" + + - name: "Case onramp-pin-pair — a decdn release line with a digest" block: - - name: Run sponsord_onramp with a non-default decdn tag and the default digest + - name: Run sponsord_onramp with a decdn release line and a digest ansible.builtin.include_role: name: sponsord_onramp vars: - sponsord_onramp_decdn_sums_sha256: c88ad5479f31bb0857db2c0205c54b240cbceb33a96724e4f8a288b6d866ab66 + sponsord_onramp_decdn_release: v0.1 rescue: - name: Record onramp-pin-pair rejection (only if its assert failed) ansible.builtin.set_fact: decdn_rejected: "{{ decdn_rejected + ['onramp-pin-pair'] }}" - when: ansible_failed_task.name == "Require the release pin's tag and digest to move together" + when: ansible_failed_task.name == "Require a SHA256SUMS digest exactly when the release pin is a tag" - name: "Case onramp-pin — an upper-case SHA256SUMS digest" block: @@ -1300,7 +1341,8 @@ the daemon/crash-loop it at config load, or ship credentials insecurely). vars: _decdn_expected: - ["sponsord-release-version", "sponsord-manual-src", "sponsord-network-unknown", + ["sponsord-release-version", "sponsord-release-version", "sponsord-manual-src", + "sponsord-network-unknown", "sponsord-network-chain", "sponsord-pool-id", "sponsord-pool-id-empty", "sponsord-wallet-bool", "sponsord-wallet-cli", "sponsord-zero-address", "sponsord-bind", "sponsord-limit", "sponsord-limit", "sponsord-bool", "sponsord-bool", @@ -1312,11 +1354,12 @@ "sponsord-bind", "sponsord-overwrite-untracked", "sponsord-token", "sponsord-rpc-unusable", "sponsord-secret-env-keys", "sponsord-secret-env-keys", "sponsord-secret-env-symlink", - "onramp-release-version", "onramp-manual-src", "onramp-network-unknown", "onramp-network-mismatch", + "onramp-release-version", "onramp-release-version", "onramp-manual-src", + "onramp-network-unknown", "onramp-network-mismatch", "onramp-zero-address", "onramp-bind", "onramp-bind", "onramp-proxy", "onramp-domain", "onramp-public-url", "onramp-public-url", "onramp-rpc", "onramp-rpc", "onramp-rpc", "onramp-rpc", "onramp-public-url", "onramp-sitekey", "onramp-pin", - "onramp-pin", "onramp-pin-pair", + "onramp-pin", "onramp-pin-pair", "onramp-pin-pair", "onramp-limit", "onramp-ip-header", "onramp-token", "onramp-turnstile", "onramp-turnstile", "onramp-turnstile-empty", "onramp-turnstile-inventory", diff --git a/ansible/molecule/validation/converge.yml b/ansible/molecule/validation/converge.yml index 4598506..474ad3f 100644 --- a/ansible/molecule/validation/converge.yml +++ b/ansible/molecule/validation/converge.yml @@ -276,6 +276,50 @@ decdn_rejected: "{{ decdn_rejected + ['release-version'] }}" when: ansible_failed_task.name == "Require a pinned release version" + # A pin is a release line (X.Y) or an exact X.Y.Z, as a string: "v0.1" and + # "0.1.x" would only 404, and an unquoted 0.10 is the number 0.1. + - name: "Case release-version — release mode with a v-prefixed line" + block: + - name: Run decdn_node with release mode and a v-prefixed line + ansible.builtin.include_role: + name: decdn_node + vars: + decdn_node_install_method: release + decdn_node_version: "v0.1" + rescue: + - name: Record release-version rejection (only if its assert failed) + ansible.builtin.set_fact: + decdn_rejected: "{{ decdn_rejected + ['release-version'] }}" + when: ansible_failed_task.name == "Require a pinned release version" + + - name: "Case release-version — release mode with a wildcard" + block: + - name: Run decdn_node with release mode and a wildcard + ansible.builtin.include_role: + name: decdn_node + vars: + decdn_node_install_method: release + decdn_node_version: "0.1.x" + rescue: + - name: Record release-version rejection (only if its assert failed) + ansible.builtin.set_fact: + decdn_rejected: "{{ decdn_rejected + ['release-version'] }}" + when: ansible_failed_task.name == "Require a pinned release version" + + - name: "Case release-version — release mode with an unquoted number" + block: + - name: Run decdn_node with release mode and an unquoted number + ansible.builtin.include_role: + name: decdn_node + vars: + decdn_node_install_method: release + decdn_node_version: 0.1 + rescue: + - name: Record release-version rejection (only if its assert failed) + ansible.builtin.set_fact: + decdn_rejected: "{{ decdn_rejected + ['release-version'] }}" + when: ansible_failed_task.name == "Require a pinned release version" + # --- Case: network-unknown / network-chain (decdn_network profile guards) ----- - name: "Case network-unknown — decdn_network names no known deployment" block: @@ -936,6 +980,7 @@ vars: _decdn_expected: ["cross-field", "binary-format", "binary-check-mode", "arch-mismatch", "arch-unset", "release-version", + "release-version", "release-version", "release-version", "network-unknown", "network-unknown-noaddr", "network-chain", "backup-secret-key", "backup-bare-string", "backup-empty", "path-cache-home", "path-backup-home", "path-decommission-cache", "range", diff --git a/ansible/roles/decdn_node/README.md b/ansible/roles/decdn_node/README.md index 1c43296..43bd291 100644 --- a/ansible/roles/decdn_node/README.md +++ b/ansible/roles/decdn_node/README.md @@ -7,12 +7,16 @@ 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 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. +**Pinned release line.** `decdn_node_version` defaults to the decdn/decdn release +line this repo pins ([`defaults/main.yml`](defaults/main.yml)), `"X.Y"`: every +run installs the newest finished `vX.Y.N` release, so a new patch release reaches the +node (and restarts it) on the next deploy, with no new release of this role. Set an +exact `"X.Y.Z"` to hold a host on one release. See [Bumping the decdn release](#bumping-the-decdn-release) +to move the line. CI tests the role against stub binaries, not these releases. **Schema tracking.** This role renders `node.toml` against the config schema of -the `decdn/decdn` release `decdn_node_version` pins. Upstream marks every config +the newest `decdn/decdn` release of the line `decdn_node_version` pins (a patch +release is not expected to change it). 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,9 +55,22 @@ 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: the pinned release) names the `v` release, which - must be reachable from the target host; override `decdn_node_release_base` - for a mirror. + `decdn_node_version` (default: the pinned release line) names the release: + - **A line, `"X.Y"`:** the target host reads `decdn_node_release_index` + (GitHub's release list for decdn/decdn) on every run and takes the highest + `vX.Y.N` that is not a draft or a prerelease and has a `SHA256SUMS.asc` + (`tasks/resolve-release.yml`). The list only chooses the tag: the download + is verified exactly as for an exact pin, against the resolved version. Check + mode reads the list too, so `make check` shows the upgrade a run would make. + GitHub allows 60 unauthenticated API requests an hour per address, one per + host per run: a large fleet behind one address pins an exact version, or + serves the list from a mirror. + - **An exact `"X.Y.Z"`:** that release, with no lookup. + + Quote it: an unquoted `0.10` is the number `0.1`, which the role refuses. The + release must be reachable from the target host; override + `decdn_node_release_base` (and, for a line, `decdn_node_release_index`) for a + mirror. - **`source`** — the role builds the two binaries **on the node** from `decdn_node_source_repo` (default `https://github.com/decdn/decdn.git`) at `decdn_node_source_ref`, which is required and may be any tag, branch or SHA: @@ -658,9 +675,11 @@ decdn appeal slash Backups, restores, moving a node to a new host and decommissioning: `make backup`, `make decommission` and the procedures in [`docs/lifecycle.md`](../../../docs/lifecycle.md). -Upgrades (`release` mode): bump `decdn_node_version` and re-deploy — the version stamp triggers re-install + restart; the persistent -`node.secret` and `keystore.json` are untouched. A new upstream release is bumped -in this repo as below. +Upgrades (`release` mode): with a release line, re-deploy — a newer release in the +line no longer matches the version stamp, so the node re-installs and restarts. With +an exact pin, or to move to a new line, change `decdn_node_version` and re-deploy. +The persistent `node.secret` and `keystore.json` are untouched. A new upstream line is +bumped in this repo as below. Upgrades (`manual` mode): there is **no** version stamp — rebuild the binaries locally and re-deploy. `copy` compares checksums and re-pushes (and restarts) only @@ -696,50 +715,62 @@ and drop its line from `/etc/cron.deny` and `/etc/at.deny`. ## Bumping the decdn release -The repo pins one decdn/decdn release everywhere, and a new one moves every pin in -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 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: 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 role defaults, the onramp's installer pin and the chart's `appVersion` name a +release line, so a decdn/decdn release in that line reaches deployments on its own. +What cannot resolve a line names one release, and moves on every upstream release: +Compose's image digest and onramp installer pin, the chart's example digest, the +commented exact-pin examples, and the generated mirrors. `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 works 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: any schema-key +or KEYS change, a generated mirror that changed within the line, and any generator +that failed. `--dry-run` prints the diff and the PR body instead. The weekly +`upstream-drift` workflow fails while the exact copies are behind the line's newest +release, or a new line is out. + +- **A patch release (a `vX.Y.N` in the pinned line `X.Y`)** is a repo-only change: steps 2 + to 4, 6 and 7 below. No release of the collections or the chart is needed, unless + the mirrors changed (the deployed roles carry them), which the PR body says. +- **A new line (a release outside `X.Y`)** is every step, and the collections and the chart need a + release after it (RELEASING.md). 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. The steps: -1. `decdn_node_version` in `defaults/main.yml`. +1. The line: `decdn_node_version` in `defaults/main.yml`, and + `sponsord_onramp_decdn_release` (`vX.Y`) in `roles/sponsord_onramp/defaults/main.yml`. 2. The generated mirrors, from a decdn checkout at the new tag: `scripts/sync-network-profiles.py --ref v` and `ansible/molecule/schema/files/gen-schema-keys.py > ansible/molecule/schema/files/schema-keys.txt` (run on a checkout of the tag). If the schema keys changed, re-sync `templates/node.toml.j2` (and the chart's `values.config`). -3. The onramp's installer pin: `sponsord_onramp_decdn_release` (`v`) and - `sponsord_onramp_decdn_sums_sha256` (`sha256sum SHA256SUMS` once - `SHA256SUMS.asc` verifies), in `roles/sponsord_onramp/defaults/main.yml` and - `compose/sponsord-onramp.env.example`. +3. Compose's onramp installer pin: `ONRAMP_DECDN_RELEASE` (`v`) and + `ONRAMP_DECDN_SUMS_SHA256` (`sha256sum SHA256SUMS` once `SHA256SUMS.asc` + verifies) in `compose/sponsord-onramp.env.example`. The role reads both from the + release itself. 4. `DECDN_IMAGE_DIGEST` in `compose/.env.example` (and the example digest in `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. 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 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, 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 +5. The line again: `appVersion` in `charts/decdn-node/Chart.yaml` and the image tag in + its `artifacthub.io/images` annotation. CI's `helm` job then validates the chart + against the line's newest `decdn` (`scripts/fetch-decdn-cli.sh`, the same + resolution and signature check as this role's). +6. `files/decdn-release-KEYS.asc`, if upstream's `KEYS` changed (and the sponsord and + sponsord_onramp copies, and `SECURITY.md`'s fingerprints). +7. The commented exact-pin examples (`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 + for a new line 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 line, the chart's +`appVersion` and its `artifacthub.io/images` image or AGENTS.md's pinned line +disagree, while Compose's onramp pin is not a release of the line, or while the KEYS +and SECURITY.md disagree; the weekly `upstream-drift` job checks the exact copies +against the line's newest 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/decdn_node/defaults/main.yml b/ansible/roles/decdn_node/defaults/main.yml index 523be9c..14cfa96 100644 --- a/ansible/roles/decdn_node/defaults/main.yml +++ b/ansible/roles/decdn_node/defaults/main.yml @@ -57,11 +57,14 @@ decdn_rustup_sha256: aarch64-unknown-linux-gnu: 15f6e4ce9f583b929c996c91562bad6d4454f3281de858b02cdfdef615fac433 # --- Release to install ("release" mode) -------------------------------------- -# The decdn/decdn release this repo pins (cloud-init takes it from here too). A bump -# also moves the generated mirrors, the onramp's decdn pin (role default and -# compose/sponsord-onramp.env.example), the Compose image digest and the chart's -# appVersion (README.md, "Bumping the decdn release"). -decdn_node_version: "0.1.0" # asserted non-empty in "release" mode +# The decdn/decdn release to install (cloud-init takes it from here too): a release +# line, "X.Y", installs the newest finished vX.Y.N release (looked up in +# decdn_node_release_index on every run, so a new patch is installed and the daemon +# restarted by the next run), or an exact "X.Y.Z" pins that one release. The +# default is the line this repo is synced against; a new line also moves the +# generated mirrors, the onramp's decdn pin and the chart's appVersion (README.md, +# "Bumping the decdn release"). +decdn_node_version: "0.1" # Release target triple. Derived from the host's gathered architecture (x86_64 or # aarch64, see vars/main/arch.yml), so arm64 hosts (Graviton, Ampere, Hetzner CAX) # get the aarch64 tarball with no inventory change. Needs facts: a play with @@ -69,6 +72,11 @@ decdn_node_version: "0.1.0" # asserted non-empty in "release" mode # loud when it resolves to an unsupported value or disagrees with the host. decdn_node_target: "{{ _decdn_arch_targets.get(ansible_facts.get('architecture', ''), '') }}" decdn_node_release_base: "https://github.com/decdn/decdn/releases/download" +# Where a release line is resolved: a JSON list in the shape of GitHub's +# GET /repos/decdn/decdn/releases (newest first, one page). Unauthenticated, GitHub +# allows 60 requests an hour per address; a large fleet behind one address pins an +# exact decdn_node_version, or serves this list (and the releases) from a mirror. +decdn_node_release_index: "https://api.github.com/repos/decdn/decdn/releases?per_page=100" # Release integrity. Upstream publishes a SHA256SUMS manifest covering every # archive, GPG-signed by a maintainer key from decdn/decdn's root KEYS file # (SHA256SUMS.asc). The role verifies the signature, then checks the tarballs diff --git a/ansible/roles/decdn_node/tasks/install.yml b/ansible/roles/decdn_node/tasks/install.yml index cf3e088..7e21081 100644 --- a/ansible/roles/decdn_node/tasks/install.yml +++ b/ansible/roles/decdn_node/tasks/install.yml @@ -1,9 +1,12 @@ --- # Install the decdn-node daemon + decdn CLI. Three methods, selected by # decdn_node_install_method: -# "release" (default): download the pinned GitHub Release tarballs, verify them -# against the GPG-signed SHA256SUMS manifest upstream publishes, then extract. -# Idempotent and upgrade-aware via a version stamp; re-extracts only on change. +# "release" (default): download the GitHub Release tarballs of decdn_node_version +# (an exact version, or the newest release of a line "X.Y", +# tasks/resolve-release.yml), verify them against the GPG-signed SHA256SUMS +# manifest upstream publishes, then extract. Idempotent and upgrade-aware via a +# version stamp; re-extracts only on change, so a new release in the line is +# installed (and the daemon restarted) by the next run. # "source": clone decdn_node_source_repo at decdn_node_source_ref on the node and # cargo-build it there as an unprivileged user (tasks/source.yml). Idempotent # via a @ stamp. @@ -38,6 +41,22 @@ when: decdn_bin_pre_stat.stat.checksum is defined +# A release line ("X.Y") becomes the newest finished release in it, an exact pin +# stays as it is; every use below is of the resolved version. +- name: Resolve the decdn release to install + ansible.builtin.include_tasks: resolve-release.yml + vars: + _rr_spec: "{{ decdn_node_version }}" + _rr_index: "{{ decdn_node_release_index }}" + _rr_name: decdn_node_version + _rr_stamp: /usr/local/lib/decdn/installed-version + when: decdn_node_install_method == "release" + +- name: Record the decdn release to install + ansible.builtin.set_fact: + _decdn_node_resolved_version: "{{ _rr_version }}" + when: decdn_node_install_method == "release" + - name: Install from pinned GitHub Release tarballs when: decdn_node_install_method == "release" block: @@ -60,7 +79,7 @@ ansible.builtin.set_fact: decdn_need_install: >- {{ (decdn_stamp.content is not defined) - or ((decdn_stamp.content | b64decode | trim) != decdn_node_version) }} + or ((decdn_stamp.content | b64decode | trim) != _decdn_node_resolved_version) }} # `ansible.builtin.tempfile` does not declare supports_check_mode, so under # `make check` it is skipped and registers WITHOUT a `.path`; the tasks below @@ -71,13 +90,13 @@ ansible.builtin.debug: msg: >- check mode: skipping the download + signature verification of - decdn {{ decdn_node_version }}. The binaries on the target are NOT + decdn {{ _decdn_node_resolved_version }}. The binaries on the target are NOT verified by this run; the config and unit below still diff normally. when: - decdn_need_install - ansible_check_mode - - name: Install decdn-node + decdn CLI {{ decdn_node_version }} + - name: Install decdn-node + decdn CLI {{ _decdn_node_resolved_version }} when: - decdn_need_install - not ansible_check_mode @@ -120,9 +139,9 @@ - name: Download release tarballs ansible.builtin.get_url: - url: "{{ decdn_node_release_base }}/v{{ decdn_node_version }}/\ - {{ item }}-{{ decdn_node_version }}-{{ decdn_node_target }}.tar.gz" - dest: "{{ decdn_stage.path }}/{{ item }}-{{ decdn_node_version }}-{{ decdn_node_target }}.tar.gz" + url: "{{ decdn_node_release_base }}/v{{ _decdn_node_resolved_version }}/\ + {{ item }}-{{ _decdn_node_resolved_version }}-{{ decdn_node_target }}.tar.gz" + dest: "{{ decdn_stage.path }}/{{ item }}-{{ _decdn_node_resolved_version }}-{{ decdn_node_target }}.tar.gz" owner: root group: root mode: "0600" @@ -132,7 +151,7 @@ # per-tarball hand-pasted sha256 pins. - name: Download the SHA256SUMS manifest ansible.builtin.get_url: - url: "{{ decdn_node_release_base }}/v{{ decdn_node_version }}/SHA256SUMS" + url: "{{ decdn_node_release_base }}/v{{ _decdn_node_resolved_version }}/SHA256SUMS" dest: "{{ decdn_stage.path }}/SHA256SUMS" owner: root group: root @@ -140,7 +159,7 @@ - name: Download the SHA256SUMS signature ansible.builtin.get_url: - url: "{{ decdn_node_release_base }}/v{{ decdn_node_version }}/SHA256SUMS.asc" + url: "{{ decdn_node_release_base }}/v{{ _decdn_node_resolved_version }}/SHA256SUMS.asc" dest: "{{ decdn_stage.path }}/SHA256SUMS.asc" owner: root group: root @@ -225,7 +244,7 @@ cmd: | set -euo pipefail {% for archive in decdn_release_tarballs %} - f={{ (archive ~ '-' ~ decdn_node_version ~ '-' ~ decdn_node_target ~ '.tar.gz') | quote }} + f={{ (archive ~ '-' ~ _decdn_node_resolved_version ~ '-' ~ decdn_node_target ~ '.tar.gz') | quote }} # Match the filename FIELD exactly, not a substring. `grep -F " $f"` # collides with any sibling entry that merely STARTS with $f (a # "$f.sig"/"$f.sha256" line makes the count 2 and aborts a perfectly @@ -258,7 +277,7 @@ - name: Extract binaries into /usr/local/bin ansible.builtin.unarchive: - src: "{{ decdn_stage.path }}/{{ item }}-{{ decdn_node_version }}-{{ decdn_node_target }}.tar.gz" + src: "{{ decdn_stage.path }}/{{ item }}-{{ _decdn_node_resolved_version }}-{{ decdn_node_target }}.tar.gz" dest: /usr/local/bin remote_src: true owner: root @@ -350,7 +369,7 @@ failed_when: >- decdn_installed_version.rc != 0 or (decdn_node_install_method == 'release' - and (decdn_installed_version.stdout | trim) != 'decdn-node ' ~ decdn_node_version) + and (decdn_installed_version.stdout | trim) != 'decdn-node ' ~ _decdn_node_resolved_version) # The `decdn` CLI is what runs `config validate`, `key-gen` and every day-2 command, # so a half-installed pair (daemon present, CLI missing) must fail here rather than @@ -363,7 +382,7 @@ failed_when: >- decdn_installed_cli_version.rc != 0 or (decdn_node_install_method == 'release' - and (decdn_installed_cli_version.stdout | trim) != 'decdn ' ~ decdn_node_version) + and (decdn_installed_cli_version.stdout | trim) != 'decdn ' ~ _decdn_node_resolved_version) # Restart onto a daemon binary the running process may not be on. Every install # path above notifies on a changed binary, but a handler queued by a run that then @@ -419,7 +438,7 @@ dest: >- /usr/local/lib/decdn/{{ 'installed-version' if decdn_node_install_method == 'release' else 'installed-source' }} - content: "{{ decdn_node_version if decdn_node_install_method == 'release' else decdn_source_id }}\n" + content: "{{ _decdn_node_resolved_version if decdn_node_install_method == 'release' else decdn_source_id }}\n" owner: root group: root mode: "0644" diff --git a/ansible/roles/decdn_node/tasks/main.yml b/ansible/roles/decdn_node/tasks/main.yml index 84b9242..8010c3e 100644 --- a/ansible/roles/decdn_node/tasks/main.yml +++ b/ansible/roles/decdn_node/tasks/main.yml @@ -13,16 +13,19 @@ "source" (build decdn_node_source_ref on the node) or "manual" (copy locally-built binaries from the control machine). -# decdn_node_version defaults to the release this repo pins; an inventory -# that blanks it gets this message instead of a bare 404 from get_url. +# decdn_node_version defaults to the release line this repo pins; an inventory +# that blanks or misspells it gets this message instead of a bare 404 from get_url. - name: Require a pinned release version ansible.builtin.assert: that: - - decdn_node_version | length > 0 + - decdn_node_version is string + - decdn_node_version is match('^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(\.(0|[1-9][0-9]*)(-[0-9A-Za-z.-]+)?)?$') fail_msg: >- - decdn_node_install_method "release" (the default) requires decdn_node_version - (e.g. "0.0.1") naming a v release of decdn/decdn (or of the mirror in - decdn_node_release_base). To run something unreleased, set + decdn_node_install_method "release" (the default) requires decdn_node_version: + a release line (e.g. "0.1", the newest v0.1.N release of decdn/decdn, looked + up in decdn_node_release_index) or an exact version (e.g. "0.1.0", the + v0.1.0 release), as a quoted string without the "v" (got + "{{ decdn_node_version }}"). To run something unreleased, set decdn_node_install_method "source" with a decdn_node_source_ref to build on the node, or use "manual" to copy binaries built on the control machine. when: decdn_node_install_method == "release" @@ -1715,7 +1718,7 @@ or decdn_config_validate.stdout | default('', true) or decdn_config_validate.msg | default('(no output)', true) }} — this role renders a key the installed decdn - {{ decdn_node_version if decdn_node_install_method == 'release' + {{ _decdn_node_resolved_version if decdn_node_install_method == 'release' else '(' ~ decdn_node_install_method ~ ' install)' }} does not accept, or a value outside its range. Config sections are deny_unknown_fields with no aliases upstream, so re-sync roles/decdn_node/templates/node.toml.j2 against diff --git a/ansible/roles/decdn_node/tasks/resolve-release.yml b/ansible/roles/decdn_node/tasks/resolve-release.yml new file mode 100644 index 0000000..37f6c3e --- /dev/null +++ b/ansible/roles/decdn_node/tasks/resolve-release.yml @@ -0,0 +1,141 @@ +--- +# Resolve a release pin to the exact version to install. Copied verbatim into the +# sponsord and sponsord_onramp roles; make test-scripts keeps the copies identical. +# +# In: _rr_spec the pin, already validated: "X.Y.Z[-pre]" (exact) or "X.Y" (a line) +# _rr_index URL of the upstream release list, in GitHub's REST API shape +# (GET /repos///releases: a JSON list of objects with +# tag_name, draft, prerelease and assets[].name) +# _rr_name the variable the pin came from, for messages +# _rr_stamp the role's installed-version stamp on the host, or "" for none +# Out: _rr_version the exact version: the pin itself, or the newest finished release +# of the line +# +# An exact pin is passed through with no network call. A line resolves to the highest +# vX.Y.N that is neither a draft nor a prerelease and lists SHA256SUMS.asc among its +# assets, so a release still being published, or one that cannot be verified, is +# never picked. The index only chooses the tag: the caller still verifies SHA256SUMS +# against its vendored KEYS and holds the binary to the resolved version, so a forged +# index can at worst choose another signed release of the same line. +# +# The lookup is read-only, so it runs in check mode too (check_mode: false): `make +# check` then reports the install a real run would make. When the list cannot be read +# (GitHub down, or its unauthenticated limit of 60 requests an hour per address hit) +# and the stamp names a release of the line, that release is kept with a warning, so +# an outage does not fail a deploy that changes nothing else; it fails only a host +# with no release of the line yet. +- name: Take an exact pin as is, for {{ _rr_name }} + ansible.builtin.set_fact: + _rr_version: "{{ _rr_spec }}" + when: _rr_spec is not match('^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$') + +- name: Resolve a release line to its newest release, for {{ _rr_name }} + when: _rr_spec is match('^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$') + block: + # Retried for a transient error, but not on a rate limit (403/429), which does + # not clear in seconds. A failure goes to the rescue, and the tasks below decide. + - name: Read the release list for {{ _rr_name }} + block: + - name: Fetch the release list for {{ _rr_name }} + ansible.builtin.uri: + url: "{{ _rr_index }}" + headers: + Accept: application/vnd.github+json + return_content: true + timeout: 30 + register: _rr_response + check_mode: false + retries: 3 + delay: 5 + until: _rr_response.status | default(0) in [200, 403, 429] + rescue: + - name: Note that the release list could not be read + ansible.builtin.debug: + msg: "{{ _rr_index }} did not answer; the next tasks decide what to do." + + - name: Decide whether the release list was read, for {{ _rr_name }} + ansible.builtin.set_fact: + _rr_listed: >- + {{ _rr_response.status | default(0) == 200 + and _rr_response.content | default('') is match('\s*\[') }} + + - name: Read the installed release, for {{ _rr_name }} + ansible.builtin.slurp: + src: "{{ _rr_stamp }}" + register: _rr_stamp_read + failed_when: false + when: + - not _rr_listed + - _rr_stamp | default('') | length > 0 + + - name: Keep an installed release of the line, for {{ _rr_name }} + ansible.builtin.set_fact: + _rr_kept: >- + {{ _rr_stamp_read.content | b64decode | trim + if (not _rr_listed and _rr_stamp_read.content is defined + and (_rr_stamp_read.content | b64decode | trim) + is match('^' ~ (_rr_spec | regex_escape) ~ '\.(0|[1-9][0-9]*)$')) + else '' }} + + - name: Warn that the installed release is kept, for {{ _rr_name }} + ansible.builtin.debug: + msg: >- + WARNING: {{ _rr_index }} could not be read (HTTP + {{ _rr_response.status | default('none') }}), so {{ _rr_name }} + "{{ _rr_spec }}" keeps the installed {{ _rr_kept }}; a newer release of the + line, if any, is installed by the next run that can read the list. + when: _rr_kept | length > 0 + + - name: Require a readable release list for {{ _rr_name }} + ansible.builtin.assert: + that: + - _rr_listed or _rr_kept | length > 0 + fail_msg: >- + {{ _rr_name }} is the release line "{{ _rr_spec }}", which is resolved from + {{ _rr_index }}, but that did not answer with a JSON list (HTTP + {{ _rr_response.status | default('none') }}{{ ': ' ~ _rr_response.msg + if _rr_response.msg | default('') else '' }}), and no release of the line + is installed to keep. GitHub allows 60 unauthenticated API requests an hour + per address. Pin an exact version (e.g. "{{ _rr_spec }}.0") or point the + index variable at a mirror. + quiet: true + + - name: Pick the newest finished release of the line + when: _rr_listed + ansible.builtin.set_fact: + _rr_patch: >- + {%- set ns = namespace(best=-1) -%} + {%- set want = '^v' ~ (_rr_spec | regex_escape) ~ '\.(0|[1-9][0-9]*)$' -%} + {%- for r in _rr_response.content | from_json -%} + {%- if r is mapping + and r.tag_name | default('') is string + and r.tag_name | default('') is match(want) + and not (r.draft | default(true)) + and not (r.prerelease | default(true)) + and 'SHA256SUMS.asc' in (r.assets | default([]) | map(attribute='name') | list) -%} + {%- set n = r.tag_name.split('.')[-1] | int -%} + {%- if n > ns.best -%}{%- set ns.best = n -%}{%- endif -%} + {%- endif -%} + {%- endfor -%} + {{- ns.best -}} + + - name: Require a release in the line + when: _rr_listed + ansible.builtin.assert: + that: + - _rr_patch | int >= 0 + fail_msg: >- + {{ _rr_name }} is the release line "{{ _rr_spec }}", but {{ _rr_index }} + lists no finished v{{ _rr_spec }}.N release (one that is not a draft or a + prerelease and has a SHA256SUMS.asc asset). Pin an existing exact version or + another line. + quiet: true + + - name: Record the resolved {{ _rr_name }} + ansible.builtin.set_fact: + _rr_version: "{{ (_rr_spec ~ '.' ~ (_rr_patch | int)) if _rr_listed else _rr_kept }}" + +- name: Report the resolved {{ _rr_name }} + ansible.builtin.debug: + msg: "{{ _rr_name }} {{ _rr_spec }} resolves to {{ _rr_version }}" + when: _rr_version != _rr_spec diff --git a/ansible/roles/sponsord/README.md b/ansible/roles/sponsord/README.md index c6452d7..ebb5c4c 100644 --- a/ansible/roles/sponsord/README.md +++ b/ansible/roles/sponsord/README.md @@ -20,10 +20,15 @@ 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`, 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. + `v` release of `decdn/sponsord` (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_version` defaults to the release line this repo pins (`"X.Y"`): every + run takes the newest finished `vX.Y.N` from `sponsord_release_index` (GitHub's + release list, as the decdn_node role does; see its README), so a patch release + is installed by the next deploy. An exact `"X.Y.Z"` holds the host on one + release. `sponsord_release_base` (and `sponsord_release_index`) point it at a + mirror. - `source` clones `sponsord_source_repo` (default `decdn/sponsord` on GitHub) at `sponsord_source_ref` (any tag, branch or SHA) on the host and runs `cargo build --release --locked -p sponsord` as the unprivileged `decdn-build` user, exactly @@ -127,7 +132,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` | the pinned release | `release` mode: the `decdn/sponsord` release (tag `v`); see [`defaults/main.yml`](defaults/main.yml). Equal to `sponsord_onramp_version`. | +| `sponsord_version` | the pinned release line | `release` mode: a `decdn/sponsord` release line (`"X.Y"`, its newest finished release) or an exact `"X.Y.Z"`, quoted; 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. | @@ -234,12 +239,18 @@ export it, so watch the address yourself. ## Bumping the sponsord release -decdn/sponsord releases `sponsord` and `sponsord-onramp` from one `vX.Y.Z` tag. -When upstream cuts a new one (the weekly `upstream-drift` workflow fails on it), -move every pin in one change: +decdn/sponsord releases `sponsord` and `sponsord-onramp` from one `vX.Y.Z` tag. Both +roles pin its release line, so a patch release in the line reaches deployments on +their next run; only Compose names one release. When upstream cuts a new release (the +weekly `upstream-drift` workflow fails on it): -1. `sponsord_version` here and `sponsord_onramp_version` in - `roles/sponsord_onramp/defaults/main.yml`, to the same version. +- **A patch release (a `vX.Y.N` in the pinned line `X.Y`)** is a repo-only change: step 2, + plus steps 3 and 4 if they apply. +- **A new line (a release outside `X.Y`)** is every step, and `decdn.publisher` needs a release after + it (RELEASING.md). + +1. The line: `sponsord_version` here and `sponsord_onramp_version` in + `roles/sponsord_onramp/defaults/main.yml`, to the same `"X.Y"`. 2. `SPONSORD_IMAGE_DIGEST` and `SPONSORD_ONRAMP_IMAGE_DIGEST` in `compose/.env.example`, from the release's signed `sponsord-image-digest.txt` and `sponsord-onramp-image-digest.txt`. @@ -247,14 +258,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 commented examples that name the version (`sponsord_version`, +5. The commented examples that name an exact 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, 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 +`make test-scripts` fails while the two lines, or AGENTS.md's pinned line, disagree; +the weekly `upstream-drift` job checks the Compose digests against the line's newest +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/defaults/main.yml b/ansible/roles/sponsord/defaults/main.yml index d00b982..9d1a1c0 100644 --- a/ansible/roles/sponsord/defaults/main.yml +++ b/ansible/roles/sponsord/defaults/main.yml @@ -49,14 +49,21 @@ decdn_rustup_sha256: aarch64-unknown-linux-gnu: 15f6e4ce9f583b929c996c91562bad6d4454f3281de858b02cdfdef615fac433 # --- Release to install ("release" mode) ---------------------------------------- -# The decdn/sponsord release (tag v) this repo pins. Keep it equal to -# sponsord_onramp_version: both come from one workspace release (README.md, -# "Bumping the sponsord release"; make test-scripts checks the defaults agree). -sponsord_version: "0.1.0" # asserted non-empty in "release" mode +# The decdn/sponsord release to install: a release line, "X.Y", installs the newest +# finished vX.Y.N release (looked up in sponsord_release_index on every run, so a new +# patch is installed and the daemon restarted by the next run), or an exact "X.Y.Z" +# pins that one release. Keep it equal to sponsord_onramp_version: both come from +# one workspace release (README.md, "Bumping the sponsord release"; make +# test-scripts checks the defaults agree). +sponsord_version: "0.1" # Release target triple, derived from the host's gathered architecture (see # vars/main/arch.yml). Set it explicitly only for a play with gather_facts: false. sponsord_target: "{{ _sponsord_arch_targets.get(ansible_facts.get('architecture', ''), '') }}" sponsord_release_base: "https://github.com/decdn/sponsord/releases/download" +# Where a release line is resolved: a JSON list in the shape of GitHub's +# GET /repos/decdn/sponsord/releases. Unauthenticated, GitHub allows 60 requests an +# hour per address; pin an exact sponsord_version, or serve this list from a mirror. +sponsord_release_index: "https://api.github.com/repos/decdn/sponsord/releases?per_page=100" # Verify SHA256SUMS.asc against sponsord_release_keyring before trusting the # manifest. Set false only for an air-gapped mirror that strips the signature. sponsord_verify_release_signature: true diff --git a/ansible/roles/sponsord/tasks/install.yml b/ansible/roles/sponsord/tasks/install.yml index 7a980b8..a4f09cf 100644 --- a/ansible/roles/sponsord/tasks/install.yml +++ b/ansible/roles/sponsord/tasks/install.yml @@ -1,8 +1,9 @@ --- # Install the sponsord binary. Three methods, selected by sponsord_install_method: -# "release" (default): download the tarball from the pinned v GitHub -# Release (one tag releases the decdn/sponsord workspace), verify it against -# the GPG-signed SHA256SUMS manifest, then extract. +# "release" (default): download the tarball from the GitHub Release of +# sponsord_version (one tag releases the decdn/sponsord workspace): an exact +# version, or the newest release of a line "X.Y" (tasks/resolve-release.yml). +# Verify it against the GPG-signed SHA256SUMS manifest, then extract. # Idempotent and upgrade-aware via a version stamp. # "source": clone sponsord_source_repo at sponsord_source_ref on the host and # cargo-build it as an unprivileged user (tasks/source.yml). @@ -13,14 +14,31 @@ # the full rationale for each (status-parsed gpg, exact manifest entry, private # staging dir kept on failure). + +# A release line ("X.Y") becomes the newest finished release in it, an exact pin +# stays as it is; every use below is of the resolved version. +- name: Resolve the sponsord release to install + ansible.builtin.include_tasks: resolve-release.yml + vars: + _rr_spec: "{{ sponsord_version }}" + _rr_index: "{{ sponsord_release_index }}" + _rr_name: sponsord_version + _rr_stamp: /usr/local/lib/sponsord/installed-version + when: sponsord_install_method == "release" + +- name: Record the sponsord release to install + ansible.builtin.set_fact: + _sponsord_resolved_version: "{{ _rr_version }}" + when: sponsord_install_method == "release" + - name: Install from the pinned GitHub Release tarball when: sponsord_install_method == "release" block: - name: Name the release archive ansible.builtin.set_fact: # The archive holds ONE flat binary at its root (upstream release.yml). - sponsord_release_archive: "sponsord-{{ sponsord_version }}-{{ sponsord_target }}.tar.gz" - sponsord_release_url: "{{ sponsord_release_base }}/v{{ sponsord_version }}" + sponsord_release_archive: "sponsord-{{ _sponsord_resolved_version }}-{{ sponsord_target }}.tar.gz" + sponsord_release_url: "{{ sponsord_release_base }}/v{{ _sponsord_resolved_version }}" - name: Read the installed-version stamp ansible.builtin.slurp: @@ -33,18 +51,18 @@ ansible.builtin.set_fact: sponsord_need_install: >- {{ (sponsord_stamp.content is not defined) - or ((sponsord_stamp.content | b64decode | trim) != sponsord_version) }} + or ((sponsord_stamp.content | b64decode | trim) != _sponsord_resolved_version) }} - name: Report that the release download is not simulated in check mode ansible.builtin.debug: msg: >- check mode: skipping the download + signature verification of sponsord - {{ sponsord_version }}. The config and unit below still diff normally. + {{ _sponsord_resolved_version }}. The config and unit below still diff normally. when: - sponsord_need_install - ansible_check_mode - - name: Install sponsord {{ sponsord_version }} + - name: Install sponsord {{ _sponsord_resolved_version }} when: - sponsord_need_install - not ansible_check_mode @@ -253,11 +271,11 @@ - sponsord_installed_version.rc == 0 - >- sponsord_install_method != 'release' - or (sponsord_installed_version.stdout | trim) == 'sponsord ' ~ sponsord_version + or (sponsord_installed_version.stdout | trim) == 'sponsord ' ~ _sponsord_resolved_version fail_msg: >- {{ sponsord_bin }} --version exited {{ sponsord_installed_version.rc }} with "{{ sponsord_installed_version.stdout | trim }}"{{ - (', expected "sponsord ' ~ sponsord_version ~ '"') + (', expected "sponsord ' ~ _sponsord_resolved_version ~ '"') if sponsord_install_method == 'release' else '' }}. The binary on the host is broken or is not the pinned release. In release mode, remove /usr/local/lib/sponsord/installed-version to force a fresh download. @@ -277,7 +295,7 @@ vars: # [stamp name suffix, content] for the method in use. sponsord_stamp_record: >- - {{ ['version', sponsord_version] if sponsord_install_method == 'release' + {{ ['version', _sponsord_resolved_version] if sponsord_install_method == 'release' else ['source', sponsord_source_id | default('')] }} block: - name: Ensure the stamp directory exists diff --git a/ansible/roles/sponsord/tasks/main.yml b/ansible/roles/sponsord/tasks/main.yml index 60bb942..215c06f 100644 --- a/ansible/roles/sponsord/tasks/main.yml +++ b/ansible/roles/sponsord/tasks/main.yml @@ -20,7 +20,8 @@ - name: Require a pinned release version ansible.builtin.assert: that: - - sponsord_version | length > 0 + - sponsord_version is string + - sponsord_version is match('^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(\.(0|[1-9][0-9]*)(-[0-9A-Za-z.-]+)?)?$') fail_msg: >- sponsord_install_method "release" (the default) requires sponsord_version (e.g. "0.0.1") naming a v release of decdn/sponsord (or of the mirror in diff --git a/ansible/roles/sponsord/tasks/resolve-release.yml b/ansible/roles/sponsord/tasks/resolve-release.yml new file mode 100644 index 0000000..37f6c3e --- /dev/null +++ b/ansible/roles/sponsord/tasks/resolve-release.yml @@ -0,0 +1,141 @@ +--- +# Resolve a release pin to the exact version to install. Copied verbatim into the +# sponsord and sponsord_onramp roles; make test-scripts keeps the copies identical. +# +# In: _rr_spec the pin, already validated: "X.Y.Z[-pre]" (exact) or "X.Y" (a line) +# _rr_index URL of the upstream release list, in GitHub's REST API shape +# (GET /repos///releases: a JSON list of objects with +# tag_name, draft, prerelease and assets[].name) +# _rr_name the variable the pin came from, for messages +# _rr_stamp the role's installed-version stamp on the host, or "" for none +# Out: _rr_version the exact version: the pin itself, or the newest finished release +# of the line +# +# An exact pin is passed through with no network call. A line resolves to the highest +# vX.Y.N that is neither a draft nor a prerelease and lists SHA256SUMS.asc among its +# assets, so a release still being published, or one that cannot be verified, is +# never picked. The index only chooses the tag: the caller still verifies SHA256SUMS +# against its vendored KEYS and holds the binary to the resolved version, so a forged +# index can at worst choose another signed release of the same line. +# +# The lookup is read-only, so it runs in check mode too (check_mode: false): `make +# check` then reports the install a real run would make. When the list cannot be read +# (GitHub down, or its unauthenticated limit of 60 requests an hour per address hit) +# and the stamp names a release of the line, that release is kept with a warning, so +# an outage does not fail a deploy that changes nothing else; it fails only a host +# with no release of the line yet. +- name: Take an exact pin as is, for {{ _rr_name }} + ansible.builtin.set_fact: + _rr_version: "{{ _rr_spec }}" + when: _rr_spec is not match('^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$') + +- name: Resolve a release line to its newest release, for {{ _rr_name }} + when: _rr_spec is match('^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$') + block: + # Retried for a transient error, but not on a rate limit (403/429), which does + # not clear in seconds. A failure goes to the rescue, and the tasks below decide. + - name: Read the release list for {{ _rr_name }} + block: + - name: Fetch the release list for {{ _rr_name }} + ansible.builtin.uri: + url: "{{ _rr_index }}" + headers: + Accept: application/vnd.github+json + return_content: true + timeout: 30 + register: _rr_response + check_mode: false + retries: 3 + delay: 5 + until: _rr_response.status | default(0) in [200, 403, 429] + rescue: + - name: Note that the release list could not be read + ansible.builtin.debug: + msg: "{{ _rr_index }} did not answer; the next tasks decide what to do." + + - name: Decide whether the release list was read, for {{ _rr_name }} + ansible.builtin.set_fact: + _rr_listed: >- + {{ _rr_response.status | default(0) == 200 + and _rr_response.content | default('') is match('\s*\[') }} + + - name: Read the installed release, for {{ _rr_name }} + ansible.builtin.slurp: + src: "{{ _rr_stamp }}" + register: _rr_stamp_read + failed_when: false + when: + - not _rr_listed + - _rr_stamp | default('') | length > 0 + + - name: Keep an installed release of the line, for {{ _rr_name }} + ansible.builtin.set_fact: + _rr_kept: >- + {{ _rr_stamp_read.content | b64decode | trim + if (not _rr_listed and _rr_stamp_read.content is defined + and (_rr_stamp_read.content | b64decode | trim) + is match('^' ~ (_rr_spec | regex_escape) ~ '\.(0|[1-9][0-9]*)$')) + else '' }} + + - name: Warn that the installed release is kept, for {{ _rr_name }} + ansible.builtin.debug: + msg: >- + WARNING: {{ _rr_index }} could not be read (HTTP + {{ _rr_response.status | default('none') }}), so {{ _rr_name }} + "{{ _rr_spec }}" keeps the installed {{ _rr_kept }}; a newer release of the + line, if any, is installed by the next run that can read the list. + when: _rr_kept | length > 0 + + - name: Require a readable release list for {{ _rr_name }} + ansible.builtin.assert: + that: + - _rr_listed or _rr_kept | length > 0 + fail_msg: >- + {{ _rr_name }} is the release line "{{ _rr_spec }}", which is resolved from + {{ _rr_index }}, but that did not answer with a JSON list (HTTP + {{ _rr_response.status | default('none') }}{{ ': ' ~ _rr_response.msg + if _rr_response.msg | default('') else '' }}), and no release of the line + is installed to keep. GitHub allows 60 unauthenticated API requests an hour + per address. Pin an exact version (e.g. "{{ _rr_spec }}.0") or point the + index variable at a mirror. + quiet: true + + - name: Pick the newest finished release of the line + when: _rr_listed + ansible.builtin.set_fact: + _rr_patch: >- + {%- set ns = namespace(best=-1) -%} + {%- set want = '^v' ~ (_rr_spec | regex_escape) ~ '\.(0|[1-9][0-9]*)$' -%} + {%- for r in _rr_response.content | from_json -%} + {%- if r is mapping + and r.tag_name | default('') is string + and r.tag_name | default('') is match(want) + and not (r.draft | default(true)) + and not (r.prerelease | default(true)) + and 'SHA256SUMS.asc' in (r.assets | default([]) | map(attribute='name') | list) -%} + {%- set n = r.tag_name.split('.')[-1] | int -%} + {%- if n > ns.best -%}{%- set ns.best = n -%}{%- endif -%} + {%- endif -%} + {%- endfor -%} + {{- ns.best -}} + + - name: Require a release in the line + when: _rr_listed + ansible.builtin.assert: + that: + - _rr_patch | int >= 0 + fail_msg: >- + {{ _rr_name }} is the release line "{{ _rr_spec }}", but {{ _rr_index }} + lists no finished v{{ _rr_spec }}.N release (one that is not a draft or a + prerelease and has a SHA256SUMS.asc asset). Pin an existing exact version or + another line. + quiet: true + + - name: Record the resolved {{ _rr_name }} + ansible.builtin.set_fact: + _rr_version: "{{ (_rr_spec ~ '.' ~ (_rr_patch | int)) if _rr_listed else _rr_kept }}" + +- name: Report the resolved {{ _rr_name }} + ansible.builtin.debug: + msg: "{{ _rr_name }} {{ _rr_spec }} resolves to {{ _rr_version }}" + when: _rr_version != _rr_spec diff --git a/ansible/roles/sponsord_onramp/README.md b/ansible/roles/sponsord_onramp/README.md index b23a555..56b36c7 100644 --- a/ansible/roles/sponsord_onramp/README.md +++ b/ansible/roles/sponsord_onramp/README.md @@ -23,8 +23,10 @@ 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`, - 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. + the same release line, or exact release, as the daemon's `sponsord_version`; a + line resolves to its newest finished release on every run, from + `sponsord_onramp_release_index`) 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` on the host (`cargo build -p sponsord-onramp`) as the `sponsord` role's `source` @@ -99,10 +101,14 @@ 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 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. +5. **The `decdn` release the installers install** defaults to the release line this + repo pins (`sponsord_onramp_decdn_release`, the `v` form of the decdn_node role's + default `decdn_node_version`): every run serves the line's newest finished release, + with the SHA-256 of its `SHA256SUMS`, which the role downloads from where the + installers download it and verifies against `files/decdn-release-KEYS.asc` first. + A new decdn patch release therefore reaches new installs on the next run. To serve + one exact release, set its tag (`vX.Y.Z`) together with that digest: the role + refuses a tag without a digest, and a line with one. 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 +124,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` | 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_version` | the pinned release line | `release` mode: a `decdn/sponsord` release line (`"X.Y"`, its newest finished release) or an exact `"X.Y.Z"`, quoted; 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 +138,8 @@ 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` | 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_decdn_release`, `_decdn_sums_sha256` | the `decdn_node` role's default line as a `v` tag, `""` | A decdn/decdn line (`vX.Y`, with no digest: the role reads it from the resolved release) or a tag (`vX.Y.Z`) with its 64-lowercase-hex digest. The default follows the `decdn_node` role's pin; a host override of `decdn_node_version` does not move it. | +| `sponsord_onramp_decdn_release_index`, `_decdn_release_base`, `_decdn_keyring` | GitHub's decdn/decdn release list, `/decdn/releases/download`, the vendored KEYS | Where a line is resolved, where its `SHA256SUMS` is read (the installers' own download location), and the keys it must verify against. | | `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). | @@ -166,7 +173,8 @@ collector; its access logs are not shipped. - **Rotate the Turnstile secret:** change `sponsord_onramp_turnstile_secret` (or, for a host-provisioned secret, write the new one to `/etc/sponsord/turnstile-secret`) and re-run the role. It restarts the onramp. -- **Ship a new `decdn`:** point `sponsord_onramp_decdn_release` / +- **Ship a new `decdn`:** with a release line, re-run the role once the release is + out; with an exact tag, point `sponsord_onramp_decdn_release` / `_decdn_sums_sha256` at the new release and re-run. New installs get it. - **Certificate trouble:** `sudo journalctl -u caddy -e`. Check that the domain resolves to the host and that tcp/80 and tcp/443 reach it. diff --git a/ansible/roles/sponsord_onramp/defaults/main.yml b/ansible/roles/sponsord_onramp/defaults/main.yml index 6c3ed58..d47dc8b 100644 --- a/ansible/roles/sponsord_onramp/defaults/main.yml +++ b/ansible/roles/sponsord_onramp/defaults/main.yml @@ -48,12 +48,15 @@ decdn_rustup_sha256: aarch64-unknown-linux-gnu: 15f6e4ce9f583b929c996c91562bad6d4454f3281de858b02cdfdef615fac433 # --- Release to install ("release" mode) ---------------------------------------- -# The decdn/sponsord release (tag v) this repo pins, the -# same one as the sponsord role's sponsord_version. -sponsord_onramp_version: "0.1.0" # asserted non-empty in "release" mode +# The decdn/sponsord release to install, a line ("X.Y", the newest finished vX.Y.N) +# or an exact "X.Y.Z": the same one as the sponsord role's sponsord_version. +sponsord_onramp_version: "0.1" # Derived from the host's gathered architecture (vars/main/arch.yml). sponsord_onramp_target: "{{ _sponsord_onramp_arch_targets.get(ansible_facts.get('architecture', ''), '') }}" sponsord_onramp_release_base: "https://github.com/decdn/sponsord/releases/download" +# Where a release line is resolved (GitHub's GET /repos/decdn/sponsord/releases +# shape; 60 unauthenticated requests an hour per address). +sponsord_onramp_release_index: "https://api.github.com/repos/decdn/sponsord/releases?per_page=100" sponsord_onramp_verify_release_signature: true # Upstream signs every decdn/sponsord release with the keys in its one KEYS file, # vendored once, in the sponsord role. Both roles ship side by side (this repo and @@ -136,12 +139,23 @@ sponsord_onramp_brand_name: "" # [deCDN] name on the gate page sponsord_onramp_gate_template_src: "" # --- Release the installers install -------------------------------------------- -# The decdn/decdn release the installers download `decdn` from: a tag and the -# SHA-256 of that release's SHA256SUMS file (`sha256sum SHA256SUMS` after -# verifying its .asc). Default: decdn/decdn v0.1.0. Override the tag together with -# its digest: preflight refuses a pair where only one half moved. -sponsord_onramp_decdn_release: v0.1.0 # decdn/decdn tag -sponsord_onramp_decdn_sums_sha256: c88ad5479f31bb0857db2c0205c54b240cbceb33a96724e4f8a288b6d866ab66 +# The decdn/decdn release the installers download `decdn` from. A release line, +# "vX.Y" (the default follows the decdn_node role's), serves the newest finished +# vX.Y.N, looked up in sponsord_onramp_decdn_release_index on every run, with the +# SHA-256 of its SHA256SUMS read from sponsord_onramp_decdn_release_base and +# verified against sponsord_onramp_decdn_keyring first. An exact tag, "vX.Y.Z", +# needs that digest set beside it (`sha256sum SHA256SUMS` after verifying its .asc); +# preflight refuses a tag without one and a line with one. +sponsord_onramp_decdn_release: v0.1 # decdn/decdn line or tag +sponsord_onramp_decdn_sums_sha256: "" # with a tag only +sponsord_onramp_decdn_release_index: "https://api.github.com/repos/decdn/decdn/releases?per_page=100" +# Where the role reads a line's SHA256SUMS: the installers' own download location +# (/decdn/releases/download), unless a test mirror serves it. +sponsord_onramp_decdn_release_base: >- + {{ (sponsord_onramp_releases_base or 'https://github.com/decdn') ~ '/decdn/releases/download' }} +# decdn/decdn's KEYS, vendored with this role (a copy of the decdn_node role's; +# make test-scripts keeps them identical). +sponsord_onramp_decdn_keyring: decdn-release-KEYS.asc sponsord_onramp_releases_base: "" # [https://github.com/decdn] # --- Limits ("" = the onramp's own default, shown in brackets) ----------------- diff --git a/ansible/roles/sponsord_onramp/files/decdn-release-KEYS.asc b/ansible/roles/sponsord_onramp/files/decdn-release-KEYS.asc new file mode 100644 index 0000000..b7a6133 --- /dev/null +++ b/ansible/roles/sponsord_onramp/files/decdn-release-KEYS.asc @@ -0,0 +1,80 @@ +Public keys trusted to sign deCDN releases. Import with `gpg --import KEYS`. + +A release tag, the SHA256SUMS manifest, and each container image's SBOM +and digest file are each signed by one maintainer's personal key. A good signature from ANY key in this +file is authentic — see SECURITY.md. Adding or removing a maintainer is a +change to this file. + + Ant Somers + DA75 1570 6F18 73D2 74D8 A369 9E11 A9FF D62D AADB + +-----BEGIN PGP PUBLIC KEY BLOCK----- + +mDMEanigZRYJKwYBBAHaRw8BAQdAXB6xg4XVGmve59MN5EUHPlEVhZuSO2V0TYQa +j0l7ASu0GkFudCBTb21lcnMgPGFudEBkZWNkbi5vcmc+iJkEExYKAEEWIQTadRVw +bxhz0nTYo2meEan/1i2q2wUCanigZQIbAwUJBaOagAULCQgHAgIiAgYVCgkICwIE +FgIDAQIeBwIXgAAKCRCeEan/1i2q2z4VAP9R8ha+nmRqgWnZh7N8VInlO71pXbeW +8GIYaZxp1FdgBQD+L+U2XomAtXAK7Yh52DOUfhQhq2ME2AXutkGeA2UEoQm4OARq +eKBlEgorBgEEAZdVAQUBAQdA0M8mYVvDIMRDYQHurFQdYhmCKjjxuLFNyyEtb82H +/VEDAQgHiHgEGBYKACAWIQTadRVwbxhz0nTYo2meEan/1i2q2wUCanigZQIbDAAK +CRCeEan/1i2q2yLLAQC/TKs0jU6nQu7oot+sAJI754t0fktoAks1dfb8k8rKOgEA +2JAAMJACUKFQArfPQrekJvVL2ZMINf8AdU7FXPt2Jwc= +=ulJ+ +-----END PGP PUBLIC KEY BLOCK----- + + Alper Gundogdu + E27B 9A2D 2519 1E8E C90B F8AE 57E2 823C 16CC D376 + +-----BEGIN PGP PUBLIC KEY BLOCK----- + +mQINBGqz2LQBEACyX15adtYJ0hV7kisaybQWy0HVQFWDJ+kQqRPYXtACEfDbkMZL +uaupsHoXYqTykAjK4tchWZFJNppUvO8zXIM+c4BPwZxW004k5Zh5CtkaLRCqco9m +xkrnsbp0zb9QWPMlWKJzFk3j8D2mARgdYZLNG6fonmaeVIyD2F2VTBeNUZ01KAhX +49Yd+7IHPyCiV73TOIoqgZ3U3EFz+UVmOa8QSuUce4OelUkJcgVLOUxu2LQVLm3A +qK1wtxLJSi5mr526lCbkaIoQZUrg2Z8NN8LbYIycYZkZS8hgGl4hr88uYTSSx0/H +JEuBCqvdylPlNEoDe/ZGdbBC0pmFNCyWn2bIM6FJWdEcAJtitDaIUocDSEcSewHZ +rxkBSq0HMh7T8nsaRksl+NvH1mIphDFmjFk4EvxFHZztHi1ncucBiEnpNaM8AciV +xNUsGQFNB/7MQbvj8KWTfA0gq0ijEkr48/vxF0tMiffTC+z9ThtnHyltbJnqMiAm +p12x5jtX9Cv8Wp4dq3utHJt/86jL31Q1X+7g3gEPFH+wa+QCG/vT4LL+zk/XXWEd +R3JBVbnWQ5yIddkJ47oHoJVxSVUbXLnvE/dZSxh/opMpvs5edmMyqxdOXu/L/VwF +2wJHV6WMMMgxOEmxviMRBWpFyolxNAfkcapowJYW9zCgGw2pRjbbRfj4rQARAQAB +tCBBbHBlciBHdW5kb2dkdSA8YWxwZXJAZGVjZG4ub3JnPokCbQQTAQgAVxYhBOJ7 +mi0lGR6OyQv4rlfigjwWzNN2BQJqs9i0GxSAAAAAAAQADm1hbnUyLDIuNSsxLjEy +LDAsMwIbAwULCQgHAgIiAgYVCgkICwIEFgIDAQIeBwIXgAAKCRBX4oI8FszTdo7e +D/9JI0fYDXPwmz4jF1dK7I9WVtze1ZzWBLsydUV2omYrrzB8/DN1uWWsInMIBB37 +qRYwoUvY3ZSM8kSiNNtmFYITw0W4/a15+bWQYzYbTKdS1SDx/7eQghKdWzzDko0u +Y/txF2GbE3CgsVVsOiFmvWj8Bet9vP2uTtYF8YCBdq1PDbX8mobSd7GCUQA3i4Wv +t01SSqDFEoHSKbg+MLMVFrxuWIfeF1UFuSb77fU0fh6XC8d8pec5WJy7De6Xds7H +XaEbxaLAtWEulnKzZxOOciLf/Ii7obOfREfAhqaN679VqtMYPjU/rhHTUa7dtxQr +459joc3FnjVeI39lMiNdlRfqEzkPYViTQ/k9yLnogV/oHnsKsowUdWDlnrHOX71j +5L5F/CUbtvedV/fqe+cqcETdoK5+4BMepbBM7axGx3cmpWoAYiUCdlJtJ7J7cxe3 +q1d56qvH4o88rYVzwkVJaGMNtk0wraUURBKR9L1RgWY7zgjuBozEy51gBViRS1S1 +/Is7AG98TMjMODcQ7QBLYlP+dc5rncvz9aTFY/PwKOyHoV9kKRXfGurps/Eqy+jv +obf7pn729wmBoAzcTZd8AS2HqLWRAJLINe52s+lSMQtpChgmIELglK4K/9BsYXAX +nw3151Nw2xno1GeIN6x5gd2kp4t66y8EHmGidIdYZGvGsLkCDQRqs9i0ARAAwv89 +dMeXmgQYsYGm00pQTa4Cq5soaO8YkLWSFNznW5MPeLCOXa/gMq+73m1M1MMZQlrn +udSTbm7jpGQTJ6dXfT0l3DdjghBEJ/wb54DqVj7ft23ai5sOF6WO5EhRz5c2niBZ +5qE3qi0XAN/OFOOrvxQhpKr7NOupE2uOFggVmOdd4zssQuaB4kE7+UZSNhFoE2pD +OLKEV1qWj/pYBQ9NnL9W8luPcKbyZzNtc4kIYcXdNdNFKHZFezIDWhuxyLcLFyJm +MP7A1HXaP8h+wrU1DfrM/AmnXGksQWMQw1cejuzKbsWc+G2qnIASR6p+oquofvGj +wh5FSC6LKXgqqsS78lPK34DinK4ZEsv7tVzbKItOMaHS8I5mFmhR0b7eu2PYV7F7 +DHcd/eAgoY2cF36dEoG6c+lfnwkC9kyoTBY2GLJJFZPVu0dKoc8f0DHwjZEXpTId +veoKVWopo9LUUvHLT0EvIft4bcp/k87wPbs3u91PIkEgIK+8L7q1XJ9vq/7X4kJO +PAKTWJ8rMmvIhaKsC/TAeWEkvToDXGslv1VPYVE7IANRBACTu52246WB3IeUqllz +LdA6FKF9zkpYGFS5BLZRlLKU5fIm1ZDuB88d/MXcv+dECUK5KMRlGlF2wJEnydqW +gnFa9dHBMG25A13ZV/f2l6+oIOAZoUlhm5Hgw/kAEQEAAYkCUgQYAQgAPBYhBOJ7 +mi0lGR6OyQv4rlfigjwWzNN2BQJqs9i0GxSAAAAAAAQADm1hbnUyLDIuNSsxLjEy +LDAsMwIbDAAKCRBX4oI8FszTduMKD/9nTgAd6driyrvKlHv+xXkvq0/L0Jc4xi6j +cW0K1mfQTJrEn8swV/kvmCYWioSUGrg8eVioW5eyjQaG4E/paZSw72ybDwyzPvQh +Rhv14P+ZseebjXb9s3Woj1sCZ3ICZCkdHfsMbVzZUc+XJWPvPd3RSNnK8LbTbpwl +B316lAauEIatWOKNEHJiV89H2WbZJ8+emk2Q7GcLoMEeY7v5PdAZzvmsM0p0MjLo +Fc9HZVBDEe9pzKCw8E2VvvBreOlJcQe9d0XojEhoRN2ozV8RaX3R4GclCAolRxvz +xqvJNndDHEKdOBe6zfYWpCQdM2i4xYOqFtTIn6NKjhMonpSG9ZG5tlmeCG+daTpg +B0auw+G9ztRbs2QnC7oC0M1EEnFbKqEViywEaSfhEOp/GtdIBo28FwGF9scfLa5M +U13Wz16lSoCkdkMFpnEydmv/q5sZU7qZ9CMkNzXrtD/fNz8Khajd7G1ldh4SlAGw +bc/wi9USJO0a3QLXpJIrHdSezZ2TnOvdWU13MUblKQiHol7sAWFFLcz5gAu3+8JN +z8dNMGcLB3hl5gavPynzORCzJxfkSBtB9Ix5sODe74otk3hH2fzkRQj2nSbm+G+Q +qbh5wnX7qLwH80wKd+N6qqCuZ8UCq4d+2qarPvY61Ig/7r1bsA/y78k9JE/lREUB +ru+pjA/vJw== +=i6Bj +-----END PGP PUBLIC KEY BLOCK----- diff --git a/ansible/roles/sponsord_onramp/tasks/decdn-release.yml b/ansible/roles/sponsord_onramp/tasks/decdn-release.yml new file mode 100644 index 0000000..675280c --- /dev/null +++ b/ansible/roles/sponsord_onramp/tasks/decdn-release.yml @@ -0,0 +1,138 @@ +--- +# The decdn/decdn release the installers install `decdn` from, as the tag and +# SHA256SUMS digest the onramp serves (ONRAMP_DECDN_RELEASE, _SUMS_SHA256). +# +# An exact sponsord_onramp_decdn_release comes with the operator's +# sponsord_onramp_decdn_sums_sha256 and is served as it is. A line ("v0.1") resolves +# to the newest finished release in it (tasks/resolve-release.yml), and its digest +# is taken from that release's SHA256SUMS, fetched from where the installers fetch +# it and verified against the decdn/decdn KEYS vendored with this role first: every +# installer trusts this digest, so it is never taken from an unsigned file. A new +# release in the line therefore reaches the installers on the next run (and +# restarts the onramp, which reads its env file only at start). +# +# Check mode fetches and hashes the manifest to report the env file's diff, but +# does not verify it (gnupg may not be installed yet); the real run does. The release +# served is recorded on the host, so when GitHub's release list cannot be read the +# resolver keeps serving it (its SHA256SUMS still comes from the release itself). +- name: Serve an exact decdn release as pinned + ansible.builtin.set_fact: + _onramp_decdn_release: "{{ sponsord_onramp_decdn_release }}" + _onramp_decdn_sums_sha256: "{{ sponsord_onramp_decdn_sums_sha256 }}" + when: sponsord_onramp_decdn_sums_sha256 | length > 0 + +- name: Resolve the decdn release line the installers use + when: sponsord_onramp_decdn_sums_sha256 | length == 0 + vars: + _onramp_decdn_record: /usr/local/lib/sponsord-onramp/decdn-release + block: + - name: Resolve sponsord_onramp_decdn_release + ansible.builtin.include_tasks: resolve-release.yml + vars: + _rr_spec: "{{ sponsord_onramp_decdn_release[1:] }}" + _rr_index: "{{ sponsord_onramp_decdn_release_index }}" + _rr_name: sponsord_onramp_decdn_release + _rr_stamp: "{{ _onramp_decdn_record }}" + + - name: Name the resolved decdn release + ansible.builtin.set_fact: + _onramp_decdn_release: "v{{ _rr_version }}" + + - name: Fetch and verify the SHA256SUMS of decdn {{ _onramp_decdn_release }} + block: + - name: Create a private staging directory for the decdn manifest + ansible.builtin.tempfile: + state: directory + prefix: onramp-decdn- + register: _onramp_decdn_stage + check_mode: false + changed_when: false + + - name: Download the decdn SHA256SUMS and its signature + ansible.builtin.get_url: + url: "{{ sponsord_onramp_decdn_release_base }}/{{ _onramp_decdn_release }}/{{ item }}" + dest: "{{ _onramp_decdn_stage.path }}/{{ item }}" + owner: root + group: root + mode: "0600" + loop: [SHA256SUMS, SHA256SUMS.asc] + check_mode: false + changed_when: false + + - name: Ensure gnupg is available to verify the decdn manifest + ansible.builtin.package: + name: gnupg + state: present + when: not ansible_check_mode + + - name: Stage the decdn release signing keyring + ansible.builtin.copy: + src: "{{ sponsord_onramp_decdn_keyring }}" + dest: "{{ _onramp_decdn_stage.path }}/KEYS.asc" + owner: root + group: root + mode: "0600" + when: not ansible_check_mode + + # As roles/decdn_node/tasks/install.yml: an isolated GNUPGHOME, and the + # status lines parsed, since gpg exits 0 for a good signature from an + # expired or revoked key. + - name: Verify the decdn SHA256SUMS signature + ansible.builtin.shell: + chdir: "{{ _onramp_decdn_stage.path }}" + executable: /bin/bash + cmd: | + set -euo pipefail + export GNUPGHOME="{{ _onramp_decdn_stage.path }}/gnupg" + mkdir -p "$GNUPGHOME" + chmod 700 "$GNUPGHOME" + gpg --batch --quiet --no-autostart --import KEYS.asc + gpg --batch --no-autostart --status-fd 3 --verify SHA256SUMS.asc SHA256SUMS \ + 3>gpg-status.txt + if grep -qE '^\[GNUPG:\] (EXPKEYSIG|REVKEYSIG|EXPSIG)' gpg-status.txt; then + echo "decdn's SHA256SUMS carries a good signature from an EXPIRED or" >&2 + echo "REVOKED release key. Re-sync" >&2 + echo "roles/sponsord_onramp/files/decdn-release-KEYS.asc from upstream KEYS," >&2 + echo "or pin an exact release and its digest." >&2 + exit 1 + fi + grep -q '^\[GNUPG:\] VALIDSIG ' gpg-status.txt + register: _onramp_decdn_sig + changed_when: false + when: not ansible_check_mode + + - name: Hash the decdn SHA256SUMS + ansible.builtin.stat: + path: "{{ _onramp_decdn_stage.path }}/SHA256SUMS" + get_checksum: true + checksum_algorithm: sha256 + register: _onramp_decdn_sums_stat + + - name: Record the decdn SHA256SUMS digest + ansible.builtin.set_fact: + _onramp_decdn_sums_sha256: "{{ _onramp_decdn_sums_stat.stat.checksum }}" + + - name: Ensure the onramp's state directory exists + ansible.builtin.file: + path: "{{ _onramp_decdn_record | dirname }}" + state: directory + owner: root + group: root + mode: "0755" + + - name: Record the decdn release the installers are served + ansible.builtin.copy: + dest: "{{ _onramp_decdn_record }}" + content: "{{ _rr_version }}\n" + owner: root + group: root + mode: "0644" + + always: + - name: Remove the decdn manifest staging directory + ansible.builtin.file: + path: "{{ _onramp_decdn_stage.path }}" + state: absent + check_mode: false + changed_when: false + when: _onramp_decdn_stage.path is defined diff --git a/ansible/roles/sponsord_onramp/tasks/install.yml b/ansible/roles/sponsord_onramp/tasks/install.yml index e68687e..1ca6ba5 100644 --- a/ansible/roles/sponsord_onramp/tasks/install.yml +++ b/ansible/roles/sponsord_onramp/tasks/install.yml @@ -1,8 +1,10 @@ --- # Install the sponsord-onramp binary. Three methods, selected by sponsord_onramp_install_method: -# "release" (default): download the tarball from the pinned v GitHub -# Release (the decdn/sponsord workspace release that also carries sponsord), -# verify it against the GPG-signed SHA256SUMS manifest, then extract. +# "release" (default): download the tarball from the GitHub Release of +# sponsord_onramp_version (the decdn/sponsord workspace release that also carries +# sponsord): an exact version, or the newest release of a line "X.Y" +# (tasks/resolve-release.yml). Verify it against the GPG-signed SHA256SUMS +# manifest, then extract. # Idempotent and upgrade-aware via a version stamp. # "source": clone sponsord_onramp_source_repo at sponsord_onramp_source_ref on the host and # cargo-build it as an unprivileged user (tasks/source.yml). @@ -13,6 +15,30 @@ # the full rationale for each (status-parsed gpg, exact manifest entry, private # staging dir kept on failure). + +# A release line ("X.Y") becomes the newest finished release in it, an exact pin +# stays as it is; every use below is of the resolved version. +- name: Resolve the sponsord-onramp release to install + ansible.builtin.include_tasks: resolve-release.yml + vars: + # sponsord's play resolved the same line on this host moments ago: take its answer, + # so a release published between the two plays cannot split the one workspace + # release across the two binaries. + _rr_spec: >- + {{ _sponsord_resolved_version + if (_sponsord_resolved_version is defined + and _sponsord_resolved_version is match('^' ~ (sponsord_onramp_version | regex_escape) ~ '\.[0-9]+$')) + else sponsord_onramp_version }} + _rr_index: "{{ sponsord_onramp_release_index }}" + _rr_name: sponsord_onramp_version + _rr_stamp: /usr/local/lib/sponsord-onramp/installed-version + when: sponsord_onramp_install_method == "release" + +- name: Record the sponsord-onramp release to install + ansible.builtin.set_fact: + _sponsord_onramp_resolved_version: "{{ _rr_version }}" + when: sponsord_onramp_install_method == "release" + - name: Install from the pinned GitHub Release tarball when: sponsord_onramp_install_method == "release" block: @@ -20,8 +46,8 @@ ansible.builtin.set_fact: # The archive holds ONE flat binary at its root (upstream release.yml). sponsord_onramp_release_archive: >- - sponsord-onramp-{{ sponsord_onramp_version }}-{{ sponsord_onramp_target }}.tar.gz - sponsord_onramp_release_url: "{{ sponsord_onramp_release_base }}/v{{ sponsord_onramp_version }}" + sponsord-onramp-{{ _sponsord_onramp_resolved_version }}-{{ sponsord_onramp_target }}.tar.gz + sponsord_onramp_release_url: "{{ sponsord_onramp_release_base }}/v{{ _sponsord_onramp_resolved_version }}" - name: Read the installed-version stamp ansible.builtin.slurp: @@ -34,18 +60,18 @@ ansible.builtin.set_fact: sponsord_onramp_need_install: >- {{ (sponsord_onramp_stamp.content is not defined) - or ((sponsord_onramp_stamp.content | b64decode | trim) != sponsord_onramp_version) }} + or ((sponsord_onramp_stamp.content | b64decode | trim) != _sponsord_onramp_resolved_version) }} - name: Report that the release download is not simulated in check mode ansible.builtin.debug: msg: >- check mode: skipping the download + signature verification of sponsord-onramp - {{ sponsord_onramp_version }}. The config and unit below still diff normally. + {{ _sponsord_onramp_resolved_version }}. The config and unit below still diff normally. when: - sponsord_onramp_need_install - ansible_check_mode - - name: Install sponsord-onramp {{ sponsord_onramp_version }} + - name: Install sponsord-onramp {{ _sponsord_onramp_resolved_version }} when: - sponsord_onramp_need_install - not ansible_check_mode @@ -257,11 +283,11 @@ - sponsord_onramp_installed_version.rc == 0 - >- sponsord_onramp_install_method != 'release' - or (sponsord_onramp_installed_version.stdout | trim) == 'sponsord-onramp ' ~ sponsord_onramp_version + or (sponsord_onramp_installed_version.stdout | trim) == 'sponsord-onramp ' ~ _sponsord_onramp_resolved_version fail_msg: >- {{ sponsord_onramp_bin }} --version exited {{ sponsord_onramp_installed_version.rc }} with "{{ sponsord_onramp_installed_version.stdout | trim }}"{{ - (', expected "sponsord-onramp ' ~ sponsord_onramp_version ~ '"') + (', expected "sponsord-onramp ' ~ _sponsord_onramp_resolved_version ~ '"') if sponsord_onramp_install_method == 'release' else '' }}. The binary on the host is broken or is not the pinned release. In release mode, remove /usr/local/lib/sponsord-onramp/installed-version to force a fresh download. @@ -281,7 +307,7 @@ vars: # [stamp name suffix, content] for the method in use. sponsord_onramp_stamp_record: >- - {{ ['version', sponsord_onramp_version] if sponsord_onramp_install_method == 'release' + {{ ['version', _sponsord_onramp_resolved_version] if sponsord_onramp_install_method == 'release' else ['source', sponsord_onramp_source_id | default('')] }} block: - name: Ensure the stamp directory exists diff --git a/ansible/roles/sponsord_onramp/tasks/main.yml b/ansible/roles/sponsord_onramp/tasks/main.yml index da3d4fe..4ea8312 100644 --- a/ansible/roles/sponsord_onramp/tasks/main.yml +++ b/ansible/roles/sponsord_onramp/tasks/main.yml @@ -23,7 +23,8 @@ - name: Require a pinned release version ansible.builtin.assert: that: - - sponsord_onramp_version | length > 0 + - sponsord_onramp_version is string + - sponsord_onramp_version is match('^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(\.(0|[1-9][0-9]*)(-[0-9A-Za-z.-]+)?)?$') fail_msg: >- sponsord_onramp_install_method "release" (the default) requires sponsord_onramp_version (e.g. "0.0.1") naming a v release of @@ -377,39 +378,39 @@ - name: Validate the release pin the installers use ansible.builtin.assert: that: - - sponsord_onramp_decdn_release is match('^v' ~ _semver ~ '$') - - sponsord_onramp_decdn_sums_sha256 is match('^[0-9a-f]{64}$') + - sponsord_onramp_decdn_release is string + - sponsord_onramp_decdn_release is match('^v' ~ _line ~ '(\.' ~ _patch ~ ')?$') + - sponsord_onramp_decdn_sums_sha256 is match('^([0-9a-f]{64})?$') - >- sponsord_onramp_releases_base == '' or (sponsord_onramp_releases_base is match('^https://[^/?#@]+(/[^?#]*)?$') and sponsord_onramp_releases_base is not search('[\s\x27"`$\x5c\x5c{}]')) fail_msg: >- - The installers need sponsord_onramp_decdn_release (a decdn/decdn tag, - vMAJOR.MINOR.PATCH) and the SHA-256 of that release's SHA256SUMS in - sponsord_onramp_decdn_sums_sha256 (64 lowercase hex). - sponsord_onramp_releases_base must be "" or an https base URL. + The installers need sponsord_onramp_decdn_release, a decdn/decdn release line + (vMAJOR.MINOR, its newest release) or tag (vMAJOR.MINOR.PATCH), and with a tag + the SHA-256 of that release's SHA256SUMS in sponsord_onramp_decdn_sums_sha256 + (64 lowercase hex). sponsord_onramp_releases_base must be "" or an https base + URL. vars: - _semver: '(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-[0-9A-Za-z.-]+)?' - -# The pin is a tag and its SHA256SUMS digest, and the defaults pin a real pair. An -# inventory that moves one but not the other passes the format checks above and -# serves installers that refuse the release on every user's machine, so the pair -# must keep both defaults or change both. The defaults are read from this role's -# own file, so they are not repeated here. -- name: Require the release pin's tag and digest to move together + _line: '(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)' + _patch: '(0|[1-9][0-9]*)(-[0-9A-Za-z.-]+)?' + +# A line takes its digest from the release it resolves to (tasks/decdn-release.yml); +# an exact tag needs the operator's. A digest beside a line could only be stale, and +# a tag without one would leave the installers nothing to check the release with. +- name: Require a SHA256SUMS digest exactly when the release pin is a tag ansible.builtin.assert: that: - >- - (sponsord_onramp_decdn_release == _pin_defaults.sponsord_onramp_decdn_release) - == (sponsord_onramp_decdn_sums_sha256 == _pin_defaults.sponsord_onramp_decdn_sums_sha256) + (sponsord_onramp_decdn_release is match('^v[0-9]+\.[0-9]+$')) + == (sponsord_onramp_decdn_sums_sha256 == '') fail_msg: >- - Set the release pin's tag and its SHA256SUMS digest together: either keep both - defaults ({{ _pin_defaults.sponsord_onramp_decdn_release }}) or set both. Got sponsord_onramp_decdn_release={{ sponsord_onramp_decdn_release }} with - sponsord_onramp_decdn_sums_sha256={{ sponsord_onramp_decdn_sums_sha256 }}. A tag - served with another release's digest fails every installer. - vars: - _pin_defaults: "{{ lookup('ansible.builtin.file', role_path ~ '/defaults/main.yml') | from_yaml }}" + sponsord_onramp_decdn_sums_sha256="{{ sponsord_onramp_decdn_sums_sha256 }}": a + release line (vX.Y) takes no digest, since the role reads it from the release + it resolves to; a tag (vX.Y.Z) needs the SHA-256 of that release's SHA256SUMS + (`sha256sum SHA256SUMS` after verifying its .asc). A tag served with another + release's digest fails every installer. - name: Validate the onramp's limits ansible.builtin.assert: @@ -737,6 +738,9 @@ notify: Restart sponsord-onramp when: sponsord_onramp_gate_template_src | length == 0 +- name: Settle the decdn release the installers use + ansible.builtin.include_tasks: decdn-release.yml + - name: Template the non-secret sponsord-onramp.env ansible.builtin.template: src: sponsord-onramp.env.j2 diff --git a/ansible/roles/sponsord_onramp/tasks/resolve-release.yml b/ansible/roles/sponsord_onramp/tasks/resolve-release.yml new file mode 100644 index 0000000..37f6c3e --- /dev/null +++ b/ansible/roles/sponsord_onramp/tasks/resolve-release.yml @@ -0,0 +1,141 @@ +--- +# Resolve a release pin to the exact version to install. Copied verbatim into the +# sponsord and sponsord_onramp roles; make test-scripts keeps the copies identical. +# +# In: _rr_spec the pin, already validated: "X.Y.Z[-pre]" (exact) or "X.Y" (a line) +# _rr_index URL of the upstream release list, in GitHub's REST API shape +# (GET /repos///releases: a JSON list of objects with +# tag_name, draft, prerelease and assets[].name) +# _rr_name the variable the pin came from, for messages +# _rr_stamp the role's installed-version stamp on the host, or "" for none +# Out: _rr_version the exact version: the pin itself, or the newest finished release +# of the line +# +# An exact pin is passed through with no network call. A line resolves to the highest +# vX.Y.N that is neither a draft nor a prerelease and lists SHA256SUMS.asc among its +# assets, so a release still being published, or one that cannot be verified, is +# never picked. The index only chooses the tag: the caller still verifies SHA256SUMS +# against its vendored KEYS and holds the binary to the resolved version, so a forged +# index can at worst choose another signed release of the same line. +# +# The lookup is read-only, so it runs in check mode too (check_mode: false): `make +# check` then reports the install a real run would make. When the list cannot be read +# (GitHub down, or its unauthenticated limit of 60 requests an hour per address hit) +# and the stamp names a release of the line, that release is kept with a warning, so +# an outage does not fail a deploy that changes nothing else; it fails only a host +# with no release of the line yet. +- name: Take an exact pin as is, for {{ _rr_name }} + ansible.builtin.set_fact: + _rr_version: "{{ _rr_spec }}" + when: _rr_spec is not match('^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$') + +- name: Resolve a release line to its newest release, for {{ _rr_name }} + when: _rr_spec is match('^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$') + block: + # Retried for a transient error, but not on a rate limit (403/429), which does + # not clear in seconds. A failure goes to the rescue, and the tasks below decide. + - name: Read the release list for {{ _rr_name }} + block: + - name: Fetch the release list for {{ _rr_name }} + ansible.builtin.uri: + url: "{{ _rr_index }}" + headers: + Accept: application/vnd.github+json + return_content: true + timeout: 30 + register: _rr_response + check_mode: false + retries: 3 + delay: 5 + until: _rr_response.status | default(0) in [200, 403, 429] + rescue: + - name: Note that the release list could not be read + ansible.builtin.debug: + msg: "{{ _rr_index }} did not answer; the next tasks decide what to do." + + - name: Decide whether the release list was read, for {{ _rr_name }} + ansible.builtin.set_fact: + _rr_listed: >- + {{ _rr_response.status | default(0) == 200 + and _rr_response.content | default('') is match('\s*\[') }} + + - name: Read the installed release, for {{ _rr_name }} + ansible.builtin.slurp: + src: "{{ _rr_stamp }}" + register: _rr_stamp_read + failed_when: false + when: + - not _rr_listed + - _rr_stamp | default('') | length > 0 + + - name: Keep an installed release of the line, for {{ _rr_name }} + ansible.builtin.set_fact: + _rr_kept: >- + {{ _rr_stamp_read.content | b64decode | trim + if (not _rr_listed and _rr_stamp_read.content is defined + and (_rr_stamp_read.content | b64decode | trim) + is match('^' ~ (_rr_spec | regex_escape) ~ '\.(0|[1-9][0-9]*)$')) + else '' }} + + - name: Warn that the installed release is kept, for {{ _rr_name }} + ansible.builtin.debug: + msg: >- + WARNING: {{ _rr_index }} could not be read (HTTP + {{ _rr_response.status | default('none') }}), so {{ _rr_name }} + "{{ _rr_spec }}" keeps the installed {{ _rr_kept }}; a newer release of the + line, if any, is installed by the next run that can read the list. + when: _rr_kept | length > 0 + + - name: Require a readable release list for {{ _rr_name }} + ansible.builtin.assert: + that: + - _rr_listed or _rr_kept | length > 0 + fail_msg: >- + {{ _rr_name }} is the release line "{{ _rr_spec }}", which is resolved from + {{ _rr_index }}, but that did not answer with a JSON list (HTTP + {{ _rr_response.status | default('none') }}{{ ': ' ~ _rr_response.msg + if _rr_response.msg | default('') else '' }}), and no release of the line + is installed to keep. GitHub allows 60 unauthenticated API requests an hour + per address. Pin an exact version (e.g. "{{ _rr_spec }}.0") or point the + index variable at a mirror. + quiet: true + + - name: Pick the newest finished release of the line + when: _rr_listed + ansible.builtin.set_fact: + _rr_patch: >- + {%- set ns = namespace(best=-1) -%} + {%- set want = '^v' ~ (_rr_spec | regex_escape) ~ '\.(0|[1-9][0-9]*)$' -%} + {%- for r in _rr_response.content | from_json -%} + {%- if r is mapping + and r.tag_name | default('') is string + and r.tag_name | default('') is match(want) + and not (r.draft | default(true)) + and not (r.prerelease | default(true)) + and 'SHA256SUMS.asc' in (r.assets | default([]) | map(attribute='name') | list) -%} + {%- set n = r.tag_name.split('.')[-1] | int -%} + {%- if n > ns.best -%}{%- set ns.best = n -%}{%- endif -%} + {%- endif -%} + {%- endfor -%} + {{- ns.best -}} + + - name: Require a release in the line + when: _rr_listed + ansible.builtin.assert: + that: + - _rr_patch | int >= 0 + fail_msg: >- + {{ _rr_name }} is the release line "{{ _rr_spec }}", but {{ _rr_index }} + lists no finished v{{ _rr_spec }}.N release (one that is not a draft or a + prerelease and has a SHA256SUMS.asc asset). Pin an existing exact version or + another line. + quiet: true + + - name: Record the resolved {{ _rr_name }} + ansible.builtin.set_fact: + _rr_version: "{{ (_rr_spec ~ '.' ~ (_rr_patch | int)) if _rr_listed else _rr_kept }}" + +- name: Report the resolved {{ _rr_name }} + ansible.builtin.debug: + msg: "{{ _rr_name }} {{ _rr_spec }} resolves to {{ _rr_version }}" + when: _rr_version != _rr_spec diff --git a/ansible/roles/sponsord_onramp/templates/sponsord-onramp.env.j2 b/ansible/roles/sponsord_onramp/templates/sponsord-onramp.env.j2 index 92a7fa0..8bf4f8f 100644 --- a/ansible/roles/sponsord_onramp/templates/sponsord-onramp.env.j2 +++ b/ansible/roles/sponsord_onramp/templates/sponsord-onramp.env.j2 @@ -9,8 +9,8 @@ ONRAMP_RPC_URL={{ sponsord_onramp_rpc_url }} ONRAMP_CAPACITY_BOND_ADDR={{ sponsord_onramp_capacity_bond_address }} ONRAMP_GATE=turnstile ONRAMP_TURNSTILE_SITEKEY={{ sponsord_onramp_turnstile_sitekey }} -ONRAMP_DECDN_RELEASE={{ sponsord_onramp_decdn_release }} -ONRAMP_DECDN_SUMS_SHA256={{ sponsord_onramp_decdn_sums_sha256 }} +ONRAMP_DECDN_RELEASE={{ _onramp_decdn_release }} +ONRAMP_DECDN_SUMS_SHA256={{ _onramp_decdn_sums_sha256 }} {% if sponsord_onramp_brand_name | length > 0 %} {# to_json: double-quoted with \" and \\ escaped, which systemd's EnvironmentFile parser unescapes (preflight refuses control characters, so no other escape is diff --git a/charts/decdn-node/Chart.yaml b/charts/decdn-node/Chart.yaml index 5c99e5e..ef0cbc2 100644 --- a/charts/decdn-node/Chart.yaml +++ b/charts/decdn-node/Chart.yaml @@ -9,11 +9,13 @@ type: application # Chart version (SemVer), set only by scripts/release.sh (RELEASING.md); 0.0.0 means # never released. version: 0.1.3 -# The decdn/decdn release the chart's config rendering is synced against, and the -# image tag used when neither image.tag nor image.digest is set. Keep it equal to -# the decdn_node role's decdn_node_version, and the tag of the one image in the -# artifacthub.io/images annotation below (make test-scripts checks both). -appVersion: "0.1.0" +# The decdn/decdn release line the chart's config rendering is synced against, and +# the image tag used when neither image.tag nor image.digest is set: a line ("X.Y") +# is the floating tag upstream moves to each vX.Y.N release (pulled Always, so a +# restarted pod runs the newest). Keep it equal to the decdn_node role's +# decdn_node_version, and the tag of the one image in the artifacthub.io/images +# annotation below (make test-scripts checks both). +appVersion: "0.1" kubeVersion: ">=1.25.0-0" home: https://decdn.org icon: https://decdn.org/icon.svg @@ -37,7 +39,7 @@ annotations: artifacthub.io/license: MIT artifacthub.io/images: | - name: decdn-node - image: ghcr.io/decdn/decdn-node:0.1.0 + image: ghcr.io/decdn/decdn-node:0.1 artifacthub.io/links: | - name: Chart source url: https://github.com/decdn/devops/tree/main/charts/decdn-node diff --git a/charts/decdn-node/README.md b/charts/decdn-node/README.md index f9ca63b..aba2fd5 100644 --- a/charts/decdn-node/README.md +++ b/charts/decdn-node/README.md @@ -59,9 +59,16 @@ What it does **not** do: With the defaults (10240 + 8192 MiB), the 20Gi `persistence.size` is tight. - **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:` - (the decdn/decdn release the chart is synced against). Pin - `image.digest` instead in production: take it from the release's +- **An image.** By default the chart runs `ghcr.io/decdn/decdn-node:`: + `appVersion` is the decdn/decdn release line the chart is synced against (`X.Y`), + a tag upstream moves to each new release in the line. With that default + `image.pullPolicy` resolves to `Always`, so a restarted pod (`kubectl rollout + restart statefulset/`) runs the line's newest release; Helm does not roll + the pod on its own when the tag moves. `Always` also means every container start, + a crash restart included, needs the registry: while ghcr.io is unreachable the pod + cannot start, even with the image cached. Set `image.tag` to an exact `X.Y.Z` to hold + the node on one release (`IfNotPresent`), or pin `image.digest` 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 [SECURITY.md](https://github.com/decdn/devops/blob/main/SECURITY.md#release-verification). @@ -488,7 +495,7 @@ make lint-helm # helm lint --strict, positive/negative render tests, kubec 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) + # with the newest decdn release of the appVersion line (signature-checked) DECDN_CLI=../decdn/target/release/decdn make lint-helm # the same, with a local decdn build ``` diff --git a/charts/decdn-node/templates/_helpers.tpl b/charts/decdn-node/templates/_helpers.tpl index 5834b55..3ff6940 100644 --- a/charts/decdn-node/templates/_helpers.tpl +++ b/charts/decdn-node/templates/_helpers.tpl @@ -66,6 +66,22 @@ Image: the digest when set, else the tag, which defaults to appVersion. {{- end }} {{- end }} +{{/* --------------------------------------------------------------------------- +Pull policy: image.pullPolicy when set, else Always for a release-line tag (X.Y, +which upstream moves to each new release) and IfNotPresent for an exact tag or a +digest. +--------------------------------------------------------------------------- */}} +{{- define "decdn-node.pullPolicy" -}} +{{- $img := .Values.image }} +{{- if $img.pullPolicy }} +{{- $img.pullPolicy }} +{{- else if and (not $img.digest) (regexMatch "^[0-9]+\\.[0-9]+$" (default .Chart.AppVersion $img.tag)) }} +{{- "Always" }} +{{- else }} +{{- "IfNotPresent" }} +{{- end }} +{{- end }} + {{/* --------------------------------------------------------------------------- In-pod paths. data_dir is a SUBDIRECTORY of the PVC mount: the mount root carries fsGroup bits, and upstream refuses a data_dir with any group/world permission. diff --git a/charts/decdn-node/templates/statefulset.yaml b/charts/decdn-node/templates/statefulset.yaml index 1a88c64..e9d03a9 100644 --- a/charts/decdn-node/templates/statefulset.yaml +++ b/charts/decdn-node/templates/statefulset.yaml @@ -60,7 +60,7 @@ spec: # never the PVC: a volume snapshot must not hold the keystore and its password. - name: prepare image: {{ include "decdn-node.image" . }} - imagePullPolicy: {{ .Values.image.pullPolicy }} + imagePullPolicy: {{ include "decdn-node.pullPolicy" . }} command: ["/bin/sh", "-euc"] args: - | @@ -105,7 +105,7 @@ spec: containers: - name: decdn-node image: {{ include "decdn-node.image" . }} - imagePullPolicy: {{ .Values.image.pullPolicy }} + imagePullPolicy: {{ include "decdn-node.pullPolicy" . }} args: - --config - {{ include "decdn-node.configFile" . }} diff --git a/charts/decdn-node/tests/render-test.sh b/charts/decdn-node/tests/render-test.sh index 2181633..3e9fd6d 100755 --- a/charts/decdn-node/tests/render-test.sh +++ b/charts/decdn-node/tests/render-test.sh @@ -169,21 +169,27 @@ done # The image is the digest when set, else the tag, else appVersion (which `make # test-scripts` holds equal to the decdn_node role's pin). Both containers run it. +# The pull policy is Always for a release-line tag (X.Y, which upstream moves), else +# IfNotPresent, unless image.pullPolicy is set. app="$(yq '.appVersion' "$chart/Chart.yaml")" +app_policy=IfNotPresent +[[ "$app" =~ ^[0-9]+\.[0-9]+$ ]] && app_policy=Always digest="sha256:$(printf 'a%.0s' {1..64})" -image_of() { # : the StatefulSet's container and init container images, one per line +image_of() { # : " " of the StatefulSet's container and init container, one per line helm template t "$chart" -f "$chart/ci/ci-values.yaml" "$@" \ - | yq 'select(.kind == "StatefulSet") | (.spec.template.spec.initContainers[].image, .spec.template.spec.containers[].image)' + | yq 'select(.kind == "StatefulSet") | (.spec.template.spec.initContainers[], .spec.template.spec.containers[]) | .image + " " + .imagePullPolicy' } -for c in "unpinned|ghcr.io/decdn/decdn-node:$app|--set image.tag= --set image.digest=" \ - "tag|ghcr.io/decdn/decdn-node:9.9.9|--set image.tag=9.9.9 --set image.digest=" \ - "digest over tag|ghcr.io/decdn/decdn-node@$digest|--set image.tag=9.9.9 --set image.digest=$digest"; do +for c in "unpinned|ghcr.io/decdn/decdn-node:$app $app_policy|--set image.tag= --set image.digest=" \ + "tag|ghcr.io/decdn/decdn-node:9.9.9 IfNotPresent|--set image.tag=9.9.9 --set image.digest=" \ + "line tag|ghcr.io/decdn/decdn-node:9.9 Always|--set image.tag=9.9 --set image.digest=" \ + "digest over tag|ghcr.io/decdn/decdn-node@$digest IfNotPresent|--set image.tag=9.9 --set image.digest=$digest" \ + "explicit pull policy|ghcr.io/decdn/decdn-node:9.9 Never|--set image.tag=9.9 --set image.digest= --set image.pullPolicy=Never"; do IFS='|' read -r what want args <<<"$c" # shellcheck disable=SC2086 # helm args, split on purpose got="$(image_of $args)" || fail "helm template (image: $what)" [ "$(sort -u <<<"$got")" = "$want" ] || fail "image ($what) renders '$got', want '$want' in every container" done -pass "image precedence: digest, then tag, then appVersion ($app), in every container" +pass "image precedence: digest, then tag, then appVersion ($app, $app_policy), and its pull policy, in every container" toml="$work/ci-values.toml" diff --git a/charts/decdn-node/values.schema.json b/charts/decdn-node/values.schema.json index 6223e09..34a3509 100644 --- a/charts/decdn-node/values.schema.json +++ b/charts/decdn-node/values.schema.json @@ -66,6 +66,7 @@ }, "pullPolicy": { "enum": [ + "", "Always", "IfNotPresent", "Never" diff --git a/charts/decdn-node/values.yaml b/charts/decdn-node/values.yaml index e5928bc..c4ff8f7 100644 --- a/charts/decdn-node/values.yaml +++ b/charts/decdn-node/values.yaml @@ -8,15 +8,19 @@ image: repository: ghcr.io/decdn/decdn-node - # Defaults to the chart appVersion (the decdn/decdn release the chart is synced - # against). + # Defaults to the chart appVersion: the decdn/decdn release line the chart is + # synced against, a floating tag upstream moves to each new release in the line. + # Set an exact X.Y.Z (or a digest) to hold the node on one release. tag: "" # sha256:... — takes precedence over tag when set. Prefer it: a tag can be moved. # Take it from the release's signed decdn-node-image-digest.txt (v0.0.1: # image-digest.txt), e.g. for v0.1.0 # sha256:848532722735b4a115aca9606230d951c25f2dab06a7345305ecf67c7a0244b5 digest: "" - pullPolicy: IfNotPresent + # "" picks Always for a release-line tag (X.Y, the default), so a restarted pod + # pulls the newest release in it rather than whatever the node cached first, and + # IfNotPresent for an exact tag or a digest. + pullPolicy: "" imagePullSecrets: [] # The full name these yield must be 52 characters or fewer (README "Name length"). diff --git a/cloud-init/README.md b/cloud-init/README.md index 0213e68..9b508e5 100644 --- a/cloud-init/README.md +++ b/cloud-init/README.md @@ -38,9 +38,10 @@ 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_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 +> newest of the release line the roles pin at `DEVOPS_REF` (`decdn_node_version` for +> decdn/decdn, `sponsord_version` for the sponsor), looked up on every run; set +> `decdn_node_version` in the user-data to another line, or to an exact `"X.Y.Z"`. +> `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. @@ -103,7 +104,7 @@ log is in `/var/log/cloud-init-output.log`. Optional: - `ssh_allow_cidrs`, to accept SSH only from your addresses; - - `decdn_node_version`, to install another release than the one `DEVOPS_REF` pins; + - `decdn_node_version`, to install another release line, or one exact release, than the line `DEVOPS_REF` pins; - `decdn_node_release_base`, for a mirror (see the note above); - `decdn_network`, `arbitrum-sepolia` today. @@ -205,8 +206,9 @@ 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`. +> decdn/sponsord release (`v`), the newest of the line the roles pin at +> `DEVOPS_REF` unless the user-data sets `sponsord_version` and +> `sponsord_onramp_version`. > `sponsord_release_base` and `sponsord_onramp_release_base` point at a mirror; the > signature is still checked against the KEYS vendored in the `sponsord` role. @@ -214,9 +216,9 @@ Until then it serves its bytes as a cache does. - create the treasury wallet, open its pool (`decdn pool open`, which prints the pool id) and fund it; - create a Cloudflare Turnstile widget for your domain; - - check the `decdn` release the installers install: the role's pin at - `DEVOPS_REF` unless you set both `sponsord_onramp_decdn_release` and - `sponsord_onramp_decdn_sums_sha256`. + - check the `decdn` release the installers install: the newest release of the + role's line at `DEVOPS_REF`, unless you set an exact + `sponsord_onramp_decdn_release` with its `sponsord_onramp_decdn_sums_sha256`. 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 @@ -277,8 +279,9 @@ Day 2 is in the role READMEs. Rotate a secret by replacing its file and running that revision. Nothing pulls on a timer: the host only runs code you pinned. A tag is resolved again on every run, so if someone re-points it, the next run follows. Pin a SHA if that matters to you. -- **Upgrade the node or the sponsor:** move `DEVOPS_REF` to a revision that pins the - new release, or set `decdn_node_version` (or `sponsord_version` and +- **Upgrade the node or the sponsor:** re-run `decdn-bootstrap`: it installs the + newest release of the pinned line. For a new line, move `DEVOPS_REF` to a revision + that pins it, or set `decdn_node_version` (or `sponsord_version` and `sponsord_onramp_version`) in the inventory, and re-run. - **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 diff --git a/cloud-init/tests/lint.py b/cloud-init/tests/lint.py index 6c1c393..4b8dda2 100755 --- a/cloud-init/tests/lint.py +++ b/cloud-init/tests/lint.py @@ -101,7 +101,7 @@ # decdn CLI it ships for that) would never get the chance. FORBIDDEN_VARS = { "decdn_release_keyring", "decdn_env_file", - "sponsord_release_keyring", "sponsord_onramp_release_keyring", + "sponsord_release_keyring", "sponsord_onramp_release_keyring", "sponsord_onramp_decdn_keyring", "sponsord_etc", "sponsord_secret_env_file", "sponsord_treasury_keystore_file", "sponsord_treasury_password_file", "sponsord_api_token_file", "sponsord_generate_api_token", "sponsord_generate_treasury_wallet", "sponsord_decdn_cli_bin_src", @@ -214,9 +214,10 @@ def values_of(node, key): return found -# The onramp's installer pin: the decdn release tag and its SHA256SUMS digest, which -# only make sense together (the role defaults pin both). +# The onramp's installer pin: a decdn release line (the role default, which takes +# its digest from the release it resolves to) or a tag with its SHA256SUMS digest. ONRAMP_PINS = ("sponsord_onramp_decdn_release", "sponsord_onramp_decdn_sums_sha256") +ONRAMP_LINE = re.compile(r"v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)") def walk_strings(node, path=""): @@ -397,10 +398,15 @@ def filled(v): if (sv or ov) and not (len(sv) == len(ov) == 1 and str(sv[0]) == str(ov[0])): violation("inventory: set sponsord_version and sponsord_onramp_version together, once each and " "equal (one decdn/sponsord release ships both)") - pins = [k for k in ONRAMP_PINS if values_of(inventory, k)] - if pins and len(pins) != len(ONRAMP_PINS): - violation(f"inventory: set both {' and '.join(ONRAMP_PINS)} or neither (a release tag without its " - f"SHA256SUMS digest fails every installer); set: {', '.join(pins)}") + releases = values_of(inventory, ONRAMP_PINS[0]) + sums = values_of(inventory, ONRAMP_PINS[1]) + line = len(releases) == 1 and isinstance(releases[0], str) and ONRAMP_LINE.fullmatch(releases[0]) + if releases and not line and len(sums) != 1: + violation(f"inventory: set both {' and '.join(ONRAMP_PINS)} for a release tag (vX.Y.Z), once each " + f"(a tag without its SHA256SUMS digest fails every installer)") + if sums and (not releases or line): + violation(f"inventory: {ONRAMP_PINS[0]} is a release line (vX.Y, the role default unless set), " + f"which takes no {ONRAMP_PINS[1]}: the role reads the digest from the release it resolves to") # Ansible applies one baseline_sudo_users list, the one from the highest-precedence # place that sets it, not the union. So it may be set once, and that list is checked. sudo_lists = {} diff --git a/cloud-init/user-data-node.yaml b/cloud-init/user-data-node.yaml index 6a48871..33395e9 100644 --- a/cloud-init/user-data-node.yaml +++ b/cloud-init/user-data-node.yaml @@ -62,12 +62,14 @@ write_files: # 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. + # the newest of the decdn_node role's default line at DEVOPS_REF + # (decdn_node_version, looked up on every run); set decdn_node_version here + # to another line, or to an exact "X.Y.Z" to hold the node on it. 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. + # SHA256SUMS and SHA256SUMS.asc (and, for a release line, its release list in + # GitHub's API shape, decdn_node_release_index). 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 diff --git a/cloud-init/user-data-publisher.yaml b/cloud-init/user-data-publisher.yaml index 3c0aea9..a8b6876 100644 --- a/cloud-init/user-data-publisher.yaml +++ b/cloud-init/user-data-publisher.yaml @@ -71,12 +71,14 @@ write_files: # 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. + # the newest of the decdn_node role's default line at DEVOPS_REF + # (decdn_node_version, looked up on every run); set decdn_node_version here + # to another line, or to an exact "X.Y.Z" to hold the node on it. 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. + # SHA256SUMS and SHA256SUMS.asc (and, for a release line, its release list in + # GitHub's API shape, decdn_node_release_index). 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 @@ -105,9 +107,9 @@ write_files: ansible_python_interpreter: /usr/bin/python3 vars: # sponsord, installed from the GPG-verified release tarball. The release is - # the sponsord role's default at DEVOPS_REF (sponsord_version); set - # sponsord_version here, and sponsord_onramp_version to the same, to pin - # another one. + # the newest of the sponsord role's default line at DEVOPS_REF + # (sponsord_version); set sponsord_version here, and sponsord_onramp_version + # to the same, to another line or an exact "X.Y.Z". sponsord_install_method: release # Optional: a mirror that serves v/sponsord--.tar.gz, # SHA256SUMS and SHA256SUMS.asc. The signature is still checked against the @@ -136,10 +138,11 @@ write_files: # It is served to every user, so never the keyed URL sponsord uses. sponsord_onramp_rpc_url: "CHANGE_ME" sponsord_onramp_turnstile_sitekey: "CHANGE_ME" # MUST-EDIT: the widget's public sitekey - # The decdn release the installers install defaults to the role's pin at - # DEVOPS_REF (sponsord_onramp_decdn_release, with the SHA-256 of that release's - # SHA256SUMS: take `sha256sum SHA256SUMS` once its SHA256SUMS.asc verifies). - # Override both together, e.g.: + # The decdn release the installers install defaults to the role's release + # line at DEVOPS_REF (sponsord_onramp_decdn_release): its newest release, with + # the digest read from it. To serve one exact release, set its tag with the + # SHA-256 of its SHA256SUMS (`sha256sum SHA256SUMS` once its SHA256SUMS.asc + # verifies), e.g.: # sponsord_onramp_decdn_release: "v0.1.0" # sponsord_onramp_decdn_sums_sha256: "<64 lowercase hex>" diff --git a/compose/.env.example b/compose/.env.example index d929e5e..0cbea2f 100644 --- a/compose/.env.example +++ b/compose/.env.example @@ -25,7 +25,8 @@ COMPOSE_PROFILES=node # file holds REPO@DIGEST: copy the part after `@`. Override a REPO for a mirror or # a local registry (README.md, "A local image"). Set only the ones your profiles # use; an unset one fails only the service that needs it. The values below are the -# releases the Ansible roles pin (README.md, the node's "Operate"). +# newest releases of the lines the Ansible roles pin: the roles follow the line, +# Compose names one release of it (README.md, the node's "Operate"). # --- node ----------------------------------------------------------------------- # decdn/decdn v0.1.0 @@ -41,7 +42,8 @@ DECDN_GID=999 DECDN_ENV_FILE=/etc/decdn/decdn.env # --- sponsord and onramp ---------------------------------------------------------- -# The decdn/sponsord release sponsord_version pins: one release for both images +# The newest release of the decdn/sponsord line sponsord_version pins: one release +# for both images SPONSORD_IMAGE_DIGEST=sha256:8f82f8f88ce75be0072abc8f72e0462e03c0afba8035fe8671d6ee79ebbc930b # SPONSORD_IMAGE_REPO=ghcr.io/decdn/sponsord SPONSORD_ONRAMP_IMAGE_DIGEST=sha256:f0a2701a717c9cdd0ec0969866be30ccbf82773e08c7f25405413f543281a2df diff --git a/compose/README.md b/compose/README.md index ea9e771..564052d 100644 --- a/compose/README.md +++ b/compose/README.md @@ -68,9 +68,11 @@ onramp's domain. Nothing in it is secret. It is the only place Compose's inputs from: the wrapper always names the project (`decdn`) and refuses to run while the 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_node_version` and `sponsord_version`), each copied from that -release's signed digest file; [Operate](#operate) says how to take a newer one. +starts it from `.env.example`, which carries the digests of the newest releases of +the lines the Ansible roles pin (`decdn_node_version` and `sponsord_version`), each +copied from that release's signed digest file. Compose pins by digest only, so it +names one release where the roles follow the line; [Operate](#operate) says how to +take a newer one. ## The node diff --git a/compose/sponsord-onramp.env.example b/compose/sponsord-onramp.env.example index 5503ace..8145d66 100644 --- a/compose/sponsord-onramp.env.example +++ b/compose/sponsord-onramp.env.example @@ -20,7 +20,9 @@ ONRAMP_GATE=turnstile ONRAMP_TURNSTILE_SITEKEY= # The decdn release the installers install: a decdn/decdn vX.Y.Z tag and the # SHA-256 of that release's SHA256SUMS file (`sha256sum SHA256SUMS` once its .asc -# verifies). This is the pin the Ansible role defaults to: decdn/decdn v0.1.0. +# verifies). The Ansible role serves the newest release of its decdn line; Compose +# has no resolver, so this names one, the newest when this file last moved: +# decdn/decdn v0.1.0. ONRAMP_DECDN_RELEASE=v0.1.0 ONRAMP_DECDN_SUMS_SHA256=c88ad5479f31bb0857db2c0205c54b240cbceb33a96724e4f8a288b6d866ab66 diff --git a/docs/node-operators.md b/docs/node-operators.md index 59ca7a1..e0d35d5 100644 --- a/docs/node-operators.md +++ b/docs/node-operators.md @@ -71,6 +71,14 @@ path. This repository stops at host preparation and startup. ## Day 2 +- Upgrades: every path pins a decdn/decdn release line (`X.Y`), not one release. + On Ansible and cloud-init each deploy installs the line's newest release and restarts + the node onto it (set `decdn_node_version` to an exact `"X.Y.Z"` to hold a host). + The chart's default image tag is the line's, pulled `Always`, so + `kubectl rollout restart` picks a new release up (set `image.tag` or `image.digest` + to hold it). Compose pins a digest: take a newer one as its README's + [Operate](../compose/README.md#operate) says. A new line comes with a new release of + the collections and the chart. - Backups, restores, host migration and decommissioning: [`lifecycle.md`](lifecycle.md). - Dashboards and alert rules: [`monitoring/`](../monitoring/README.md). Grafana Cloud shipping is opt-in on Ansible (`decdn_grafana_cloud_enabled`). diff --git a/docs/publishers.md b/docs/publishers.md index 5924417..4a8636b 100644 --- a/docs/publishers.md +++ b/docs/publishers.md @@ -112,6 +112,14 @@ details: [`iroh_relay`](../ansible/roles/iroh_relay/README.md), ## Day 2 +Upgrades: the roles pin a release line for decdn/decdn (`decdn_node_version`) and for +decdn/sponsord (`sponsord_version` = `sponsord_onramp_version`), and each deploy installs +the line's newest release; the onramp also serves its installers the newest decdn of +the line (`sponsord_onramp_decdn_release`). Set an exact `"X.Y.Z"` (for the installers, +a `vX.Y.Z` tag with its `SHA256SUMS` digest) to hold a host on one release. Origin nodes +on the chart and Compose follow the node operators' notes +([`node-operators.md`](node-operators.md#day-2)). + [`lifecycle.md`](lifecycle.md) covers backup, restore, migration and decommission for every service above. Decommissioning an origin stops the node but leaves its operator seated on-chain, where `OriginAssignment.getOrigins` still lists it. Unseat it with diff --git a/examples/collection-fleet/inventory/group_vars/sponsord_hosts.yml b/examples/collection-fleet/inventory/group_vars/sponsord_hosts.yml index 7c86e36..2a3934e 100644 --- a/examples/collection-fleet/inventory/group_vars/sponsord_hosts.yml +++ b/examples/collection-fleet/inventory/group_vars/sponsord_hosts.yml @@ -11,9 +11,9 @@ # The API token is generated on the host at /etc/sponsord/api-token. # --- Binary ------------------------------------------------------------------------ -# The default method, "release", installs the decdn/sponsord release the role pins -# (tag v); set it only to hold the host on another one, together -# with sponsord_onramp_version: +# The default method, "release", installs the newest release of the decdn/sponsord +# line the role pins (sponsord_version "X.Y"), on each deploy; set an exact one only +# to hold the host on it, together with sponsord_onramp_version: # sponsord_version: "0.1.0" # Or build on the host from a git ref (tag, branch or SHA; sponsord_version is not # checked in this mode): 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 78d281a..7b1d476 100644 --- a/examples/collection-fleet/inventory/group_vars/sponsord_onramp_hosts.yml +++ b/examples/collection-fleet/inventory/group_vars/sponsord_onramp_hosts.yml @@ -32,9 +32,10 @@ # PUBLIC: served to every user. A keyless endpoint, never sponsord_rpc_url. # 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 -# v), with the SHA-256 of its SHA256SUMS. To serve another, -# set both: +# The decdn release the installers install defaults to the newest release of the +# role's sponsord_onramp_decdn_release line (decdn/decdn, the decdn_node role's default +# line; a host's decdn_node_version does not move it), with the SHA-256 of its +# verified SHA256SUMS, on each deploy. To serve one exact release, set its tag and digest: # sponsord_onramp_decdn_release: v0.1.0 # sponsord_onramp_decdn_sums_sha256: "…" # CapacityBond and SlashJudge come from sponsord_network (group_vars/sponsord_hosts.yml). diff --git a/examples/collection-fleet/inventory/host_vars/decdn-node-1/main.yml b/examples/collection-fleet/inventory/host_vars/decdn-node-1/main.yml index c1739ac..4ec5209 100644 --- a/examples/collection-fleet/inventory/host_vars/decdn-node-1/main.yml +++ b/examples/collection-fleet/inventory/host_vars/decdn-node-1/main.yml @@ -21,7 +21,8 @@ # --- Which binaries to install ------------------------------------------------ # "release" (the default): the v GitHub Release tarballs, verified against # the GPG-signed SHA256SUMS manifest. The version defaults to the decdn/decdn release -# the role pins; set it only to hold a node on another one. +# line the role pins ("X.Y": its newest release, installed by each deploy); set an +# exact one only to hold a node on it. decdn_node_install_method: release # decdn_node_version: "0.1.0" # diff --git a/examples/collection-fleet/inventory/host_vars/origin-1/main.yml b/examples/collection-fleet/inventory/host_vars/origin-1/main.yml index 01cae67..1b0b93e 100644 --- a/examples/collection-fleet/inventory/host_vars/origin-1/main.yml +++ b/examples/collection-fleet/inventory/host_vars/origin-1/main.yml @@ -23,7 +23,8 @@ # --- Which binaries to install ------------------------------------------------ # "release" (the default): the v GitHub Release tarballs, verified against # the GPG-signed SHA256SUMS manifest. The version defaults to the decdn/decdn release -# the role pins; set it only to hold a node on another one. +# line the role pins ("X.Y": its newest release, installed by each deploy); set an +# exact one only to hold a node on it. decdn_node_install_method: release # decdn_node_version: "0.1.0" # diff --git a/scripts/bump_decdn.py b/scripts/bump_decdn.py index 7e6d2ce..ad866b8 100755 --- a/scripts/bump_decdn.py +++ b/scripts/bump_decdn.py @@ -1,22 +1,32 @@ #!/usr/bin/env python3 -"""Move the decdn/decdn pin to a new release, for scripts/open-decdn-bump.sh. +"""Move the decdn/decdn pins to a new release, for scripts/open-decdn-bump.sh. scripts/bump_decdn.py [--root ] [args] Subcommands: - pinned print decdn_node_version - compare print how the pin relates to : same, ahead or behind + pinned print decdn_node_version (the release line, X.Y) + synced print the release the exact copies name (X.Y.Z) + compare print how the synced release relates to : same, ahead + or behind + kind print patch ( is in the pinned line) or line (a new one) rewrite --sums-sha256 --image-digest sha256: 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 roles and the chart pin a release line (decdn_node_version "0.1"): the roles +install its newest release, and the chart's default tag is the line's floating +image tag. Those copies, and AGENTS.md's "Pinned upstream releases", move only when + starts a new line. What cannot resolve a line names one release, and moves +on every bump: Compose's image digest and the onramp's installer pin in its env +example, the chart's example digest, and the commented exact-pin examples in the +inventory templates and the cloud-init publisher template. + 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. 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. +version, only what is keyed to a pin moves: the comments beside the pinned values, +the commented examples, keyed by a variable name, and AGENTS.md. 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 @@ -37,9 +47,11 @@ from pathlib import Path VERSION = re.compile(r"^[0-9]+\.[0-9]+\.[0-9]+$") +LINE = re.compile(r"^[0-9]+\.[0-9]+$") TAG = re.compile(r"^v([0-9]+\.[0-9]+\.[0-9]+)$") HEX64 = re.compile(r"^[0-9a-f]{64}$") SEMVER = r"[0-9]+\.[0-9]+\.[0-9]+" +LINE_RE = r"[0-9]+\.[0-9]+" NODE_DEFAULTS = "ansible/roles/decdn_node/defaults/main.yml" ONRAMP_DEFAULTS = "ansible/roles/sponsord_onramp/defaults/main.yml" @@ -48,8 +60,8 @@ 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 +# (file, before, after): an exact-pin 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 = [ *( @@ -63,9 +75,9 @@ 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+`", "`"), ] +# The one place prose names the pinned line (its line may wrap inside the phrase). +AGENTS = ("AGENTS.md", r"`decdn_node_version`, today\s+`", "`") class ShapeError(Exception): @@ -95,21 +107,42 @@ def env_value(text: str, key: str, rel: str) -> str: def pinned(root: Path) -> str: - return scalar(read(root, NODE_DEFAULTS), "decdn_node_version", NODE_DEFAULTS) + """decdn_node_version, which must be a release line.""" + current = scalar(read(root, NODE_DEFAULTS), "decdn_node_version", NODE_DEFAULTS) + if not LINE.match(current): + raise ShapeError(f"{NODE_DEFAULTS}: decdn_node_version {current!r} is not a release line (X.Y)") + return current -def compare(root: Path, tag: str) -> str: +def synced(root: Path) -> str: + """The release the exact copies name: the one Compose's image digest is of.""" + m = re.findall(rf"^# decdn/decdn v({SEMVER})\nDECDN_IMAGE_DIGEST=", read(root, COMPOSE_ENV), re.M) + if len(m) != 1: + raise ShapeError(f"{COMPOSE_ENV}: {len(m)} `# decdn/decdn vX.Y.Z` lines above DECDN_IMAGE_DIGEST, expected 1") + return m[0] + + +def tag_version(tag: str) -> str: m = TAG.match(tag) if not m: raise ShapeError(f"{tag} is not a vX.Y.Z tag") - current = pinned(root) - if not VERSION.match(current): - raise ShapeError(f"{NODE_DEFAULTS}: decdn_node_version {current!r} is not X.Y.Z") - want = tuple(int(p) for p in m.group(1).split(".")) - have = tuple(int(p) for p in current.split(".")) + return m.group(1) + + +def compare(root: Path, tag: str) -> str: + want = tuple(int(p) for p in tag_version(tag).split(".")) + have = tuple(int(p) for p in synced(root).split(".")) return "same" if have == want else "ahead" if have > want else "behind" +def line_of(version: str) -> str: + return version.rsplit(".", 1)[0] + + +def kind(root: Path, tag: str) -> str: + return "patch" if line_of(tag_version(tag)) == pinned(root) else "line" + + def sub_once(text: str, pattern: str, repl: str, rel: str, flags: int = re.M) -> str: new, n = re.subn(pattern, repl, text, flags=flags) if n != 1: @@ -159,10 +192,12 @@ def rewrite(root: Path, version: str, sums: str, digest: str) -> list[str]: raise ShapeError(f"--sums-sha256 {sums!r} is not 64 lowercase hex") if not (digest.startswith("sha256:") and HEX64.match(digest[len("sha256:"):])): raise ShapeError(f"--image-digest {digest!r} is not sha256:<64 lowercase hex>") + line = line_of(version) + pinned(root) # a line, or the shape is not one this script moves - old_sums = scalar(read(root, ONRAMP_DEFAULTS), "sponsord_onramp_decdn_sums_sha256", ONRAMP_DEFAULTS) + old_sums = env_value(read(root, ONRAMP_ENV), "ONRAMP_DECDN_SUMS_SHA256", ONRAMP_ENV) old_digest = env_value(read(root, COMPOSE_ENV), "DECDN_IMAGE_DIGEST", COMPOSE_ENV) - for name, old in (("sponsord_onramp_decdn_sums_sha256", old_sums), ("DECDN_IMAGE_DIGEST", old_digest)): + for name, old in (("ONRAMP_DECDN_SUMS_SHA256", old_sums), ("DECDN_IMAGE_DIGEST", old_digest)): bare = old.removeprefix("sha256:") if not HEX64.match(bare): raise ShapeError(f"the current {name} {old!r} is not a digest") @@ -173,17 +208,18 @@ def rewrite(root: Path, version: str, sums: str, digest: str) -> list[str]: if old_digest != digest: changed.update(replace_everywhere(root, old_digest.removeprefix("sha256:"), digest.removeprefix("sha256:"))) - # (file, pattern, replacement): the version-bearing lines. + # (file, pattern, replacement): the version-bearing lines. The line copies first. + agents_file, agents_before, agents_after = AGENTS edits = [ - (NODE_DEFAULTS, r'^(decdn_node_version: *")[^"]*(")', rf"\g<1>{version}\g<2>"), - (ONRAMP_DEFAULTS, rf"^(sponsord_onramp_decdn_release: *)v{SEMVER}", rf"\g<1>v{version}"), - (ONRAMP_DEFAULTS, rf"(Default: decdn/decdn )v{SEMVER}(\.)", rf"\g<1>v{version}\g<2>"), + (NODE_DEFAULTS, r'^(decdn_node_version: *")[^"]*(")', rf"\g<1>{line}\g<2>"), + (ONRAMP_DEFAULTS, rf"^(sponsord_onramp_decdn_release: *)v{LINE_RE}( )", rf"\g<1>v{line}\g<2>"), + (CHART, r'^(appVersion: *")[^"]*(")', rf"\g<1>{line}\g<2>"), + (CHART, rf"(image: ghcr\.io/decdn/decdn-node:){LINE_RE}$", rf"\g<1>{line}"), + (agents_file, rf"({agents_before}){LINE_RE}({agents_after})", rf"\g<1>{line}\g<2>"), (ONRAMP_ENV, rf"^ONRAMP_DECDN_RELEASE=v{SEMVER}$", f"ONRAMP_DECDN_RELEASE=v{version}"), - (ONRAMP_ENV, rf"(defaults to: decdn/decdn )v{SEMVER}(\.)", rf"\g<1>v{version}\g<2>"), + (ONRAMP_ENV, rf"(last moved:\n# decdn/decdn )v{SEMVER}(\.)", rf"\g<1>v{version}\g<2>"), (COMPOSE_ENV, rf"^# decdn/decdn v{SEMVER}(\nDECDN_IMAGE_DIGEST=)", rf"# decdn/decdn v{version}\g<1>"), (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: @@ -199,27 +235,30 @@ def rewrite(root: Path, version: str, sums: str, digest: str) -> list[str]: def verify(root: Path, version: str, sums: str, digest: str) -> None: """Read every pin back; a mismatch is a file this script does not understand.""" + line = line_of(version) onramp = read(root, ONRAMP_DEFAULTS) onramp_env = read(root, ONRAMP_ENV) chart = read(root, CHART) want = [ - (NODE_DEFAULTS, "decdn_node_version", pinned(root), version), + (NODE_DEFAULTS, "decdn_node_version", pinned(root), line), (ONRAMP_DEFAULTS, "sponsord_onramp_decdn_release", - scalar(onramp, "sponsord_onramp_decdn_release", ONRAMP_DEFAULTS), f"v{version}"), - (ONRAMP_DEFAULTS, "sponsord_onramp_decdn_sums_sha256", - scalar(onramp, "sponsord_onramp_decdn_sums_sha256", ONRAMP_DEFAULTS), sums), + scalar(onramp, "sponsord_onramp_decdn_release", ONRAMP_DEFAULTS), f"v{line}"), (ONRAMP_ENV, "ONRAMP_DECDN_RELEASE", env_value(onramp_env, "ONRAMP_DECDN_RELEASE", ONRAMP_ENV), f"v{version}"), (ONRAMP_ENV, "ONRAMP_DECDN_SUMS_SHA256", env_value(onramp_env, "ONRAMP_DECDN_SUMS_SHA256", ONRAMP_ENV), sums), (COMPOSE_ENV, "DECDN_IMAGE_DIGEST", env_value(read(root, COMPOSE_ENV), "DECDN_IMAGE_DIGEST", COMPOSE_ENV), digest), - (CHART, "appVersion", scalar(chart, "appVersion", CHART), version), + (COMPOSE_ENV, "the release above DECDN_IMAGE_DIGEST", synced(root), version), + (CHART, "appVersion", scalar(chart, "appVersion", CHART), line), ] for rel, key, got, expected in want: if got != expected: raise ShapeError(f"{rel}: {key} is {got!r} after the rewrite, expected {expected!r}") + # A line takes no digest: the role reads it from the release it resolves to. + if re.search(r'^sponsord_onramp_decdn_sums_sha256: *"" ', onramp, re.M) is None: + raise ShapeError(f"{ONRAMP_DEFAULTS}: sponsord_onramp_decdn_sums_sha256 is not \"\" beside a release line") images = re.findall(r"image: ghcr\.io/decdn/decdn-node:(\S+)", chart) - if images != [version]: - raise ShapeError(f"{CHART}: artifacthub.io/images names {images}, expected [{version!r}]") + if images != [line]: + raise ShapeError(f"{CHART}: artifacthub.io/images names {images}, expected [{line!r}]") 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}]") @@ -227,6 +266,10 @@ def verify(root: Path, version: str, sums: str, digest: str) -> None: 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}]") + rel, before, after = AGENTS + found = re.findall(rf"{before}({LINE_RE}){after}", read(root, rel)) + if found != [line]: + raise ShapeError(f"{rel}: /{before}X.Y{after}/ names {found}, expected [{line!r}]") def main() -> None: @@ -234,7 +277,9 @@ def main() -> None: ap.add_argument("--root", type=Path, default=Path.cwd(), help="devops checkout (default: the current directory)") sub = ap.add_subparsers(dest="cmd", required=True) sub.add_parser("pinned") + sub.add_parser("synced") sub.add_parser("compare").add_argument("tag") + sub.add_parser("kind").add_argument("tag") rw = sub.add_parser("rewrite") rw.add_argument("version") rw.add_argument("--sums-sha256", required=True) @@ -244,8 +289,12 @@ def main() -> None: try: if args.cmd == "pinned": print(pinned(args.root)) + elif args.cmd == "synced": + print(synced(args.root)) elif args.cmd == "compare": print(compare(args.root, args.tag)) + elif args.cmd == "kind": + print(kind(args.root, args.tag)) elif args.cmd == "rewrite": for rel in rewrite(args.root, args.version, args.sums_sha256, args.image_digest): print(f"wrote {rel}") diff --git a/scripts/fetch-decdn-cli.sh b/scripts/fetch-decdn-cli.sh index e735d9f..500ab81 100755 --- a/scripts/fetch-decdn-cli.sh +++ b/scripts/fetch-decdn-cli.sh @@ -22,10 +22,17 @@ # must report `decdn ` (roles/decdn_node/tasks/install.yml has the # reasons for each). # +# A release line (X.Y, as the chart's appVersion is) resolves to its newest +# finished release as roles/decdn_node/tasks/resolve-release.yml resolves one: the +# highest vX.Y.N that is not a draft or a prerelease and has a SHA256SUMS.asc +# asset, from GitHub's release list (sent with GITHUB_TOKEN when it is set, which +# lifts the unauthenticated rate limit). +# # 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. +# appVersion; X.Y or X.Y.Z), DECDN_RELEASE_INDEX (default: decdn/decdn's GitHub +# release list), DECDN_RELEASE_BASE (default: decdn/decdn's GitHub releases; any +# URL curl takes, file:// included, for both) 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 @@ -41,15 +48,36 @@ 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)" +[[ "$version" =~ ^[0-9]+\.[0-9]+(\.[0-9]+)?$ ]] || + die "the decdn version is '$version', not X.Y or X.Y.Z (charts/decdn-node/Chart.yaml appVersion)" +index="${DECDN_RELEASE_INDEX:-https://api.github.com/repos/decdn/decdn/releases?per_page=100}" 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 +for v in DECDN_VERSION DECDN_RELEASE_INDEX 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" +if [[ "$version" =~ ^[0-9]+\.[0-9]+$ ]]; then + auth=() + [[ -z "${GITHUB_TOKEN:-}" ]] || auth=(-H "Authorization: Bearer $GITHUB_TOKEN") + releases=$(curl -fsSL --retry 3 -H 'Accept: application/vnd.github+json' "${auth[@]}" "$index") || + die "cannot read the decdn release list at $index" + patch=$(python3 -I -c ' +import json, re, sys +want = re.compile(r"v" + re.escape(sys.argv[1]) + r"\.(0|[1-9][0-9]*)") +best = -1 +for r in json.loads(sys.stdin.read()): + m = want.fullmatch(r.get("tag_name") or "") + if m and not r.get("draft", True) and not r.get("prerelease", True) \ + and "SHA256SUMS.asc" in [a.get("name") for a in r.get("assets", [])]: + best = max(best, int(m.group(1))) +print(best) +' "$version" <<<"$releases") || die "the decdn release list at $index is not a JSON release list" + [[ "$patch" -ge 0 ]] || die "no finished v$version.N decdn release in $index" + echo "decdn $version resolves to $version.$patch" >&2 + version="$version.$patch" +fi # 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 diff --git a/scripts/open-decdn-bump.sh b/scripts/open-decdn-bump.sh index 75eb78d..408097c 100755 --- a/scripts/open-decdn-bump.sh +++ b/scripts/open-decdn-bump.sh @@ -15,7 +15,11 @@ # # 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). +# the chart against the new release's binary). The roles and the chart pin a +# release line, so a release in that line (a patch) moves only what names one +# exact release: Compose, the examples and the generated mirrors, a repo-only +# change. A release that starts a new line moves the line as well, and the +# collections and the chart need a release after it. # 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 @@ -116,20 +120,23 @@ bump() { python3 "$SCRIPTS/bump_decdn.py" --root "$WORKTREE" "$@"; } # ---- which release ------------------------------------------------------- -CURRENT=$(bump pinned) || die "could not read the decdn pin on origin/main" +LINE=$(bump pinned) || die "could not read the decdn release line on origin/main" +# The release the exact copies (Compose, the examples, the mirrors) name. +CURRENT=$(bump synced) || die "could not read the decdn release devops main is synced to" # Never move the pin backwards: not to a patch release on an older line, nor # to an older tag given by hand. relation=$(bump compare "$TAG") || die "could not compare the pin with $TAG" case "$relation" in same) - echo "devops main already pins decdn $TAG; nothing to do" + echo "devops main is already synced to decdn $TAG; nothing to do" exit 0 ;; ahead) - echo "devops main pins decdn v$CURRENT, later than $TAG; nothing to do" + echo "devops main is synced to decdn v$CURRENT, later than $TAG; nothing to do" exit 0 ;; behind) ;; *) die "unexpected comparison of the pin with $TAG: $relation" ;; esac +KIND=$(bump kind "$TAG") || die "could not tell whether $TAG is in the line $LINE" BRANCH="bump/decdn-${TAG}" existing=$(gh pr list -R "$DEVOPS" --head "$BRANCH" --state all \ @@ -186,7 +193,7 @@ UPSTREAM_SHA=$(git -C "$UPSTREAM" rev-parse HEAD) # ---- bump ---------------------------------------------------------------- -echo "==> Pinning decdn $TAG (from v$CURRENT)" +echo "==> Moving decdn to $TAG (from v$CURRENT; $KIND bump, line $LINE)" # What a human must finish; the PR still opens, and the exit code says so. NEEDS=() indent() { sed 's/^/ /'; } @@ -200,7 +207,11 @@ need() { bump rewrite "$VERSION" --sums-sha256 "$SUMS" --image-digest "$DIGEST" >/dev/null || die "could not move the pins to $TAG; no PR opened" -# Step 2: the generated mirrors, from the tag. +# Step 2: the generated mirrors, from the tag. --check first: each mirror records the +# upstream commit it came from, which moves on every tag, so only the generator can +# tell whether the contract addresses themselves changed. +MIRRORS_MOVED="" +python3 scripts/sync-network-profiles.py "$UPSTREAM" --ref HEAD --check >/dev/null 2>&1 || MIRRORS_MOVED=1 if ! out=$(python3 scripts/sync-network-profiles.py "$UPSTREAM" --ref HEAD 2>&1); then need "\`scripts/sync-network-profiles.py\` failed against $TAG: the role and its template need the change upstream made." "$out" @@ -220,10 +231,20 @@ else need "\`gen-schema-keys.py\` failed against $TAG." "$out" fi +# Within a line the deployed roles keep the mirrors they were released with, so +# a patch that changes them is not repo-only. +if [[ "$KIND" == patch ]] && { [[ -n "$MIRRORS_MOVED" ]] || ! git diff --quiet -- "$SCHEMA_KEYS"; }; then + need "$TAG changes the generated mirrors within the line $LINE: the released + collections still carry the old ones, so release \`decdn.node\` and + \`decdn.publisher\` after merging (RELEASING.md)." +fi + # Step 6: KEYS is a byte copy of upstream's. if ! cmp -s "$UPSTREAM/KEYS" "$KEYS"; then cp "$UPSTREAM/KEYS" "$KEYS" - need "decdn's \`KEYS\` changed and is copied to \`$KEYS\`: update the sponsord copy + cp "$UPSTREAM/KEYS" ansible/roles/sponsord_onramp/files/decdn-release-KEYS.asc + need "decdn's \`KEYS\` changed and is copied to \`$KEYS\` and the onramp's + \`ansible/roles/sponsord_onramp/files/decdn-release-KEYS.asc\`: update the sponsord copy (\`ansible/roles/sponsord/files/sponsord-release-KEYS.asc\`) and SECURITY.md's fingerprints." fi @@ -234,7 +255,14 @@ others=$(gh pr list -R "$DEVOPS" --state open --json number,headRefName \ others="" # only a note in the body; not worth stopping the bump BODY="$WORK/body.md" { - echo "Moves the decdn/decdn pin to [${TAG}](https://github.com/${DECDN}/releases/tag/${TAG}) from \`v${CURRENT}\`." + if [[ "$KIND" == patch ]]; then + echo "Moves what names one decdn/decdn release to [${TAG}](https://github.com/${DECDN}/releases/tag/${TAG}) from \`v${CURRENT}\`:" + echo "Compose's image digest and onramp installer pin, the chart's example digest, the exact-pin" + echo "examples and the generated mirrors. The roles and the chart pin the line \`${LINE}\` and" + echo "pick ${TAG} up on their own." + else + echo "Moves the decdn/decdn pin to the line \`${TAG%.*}\` and [${TAG}](https://github.com/${DECDN}/releases/tag/${TAG}), from \`${LINE}\` and \`v${CURRENT}\`." + fi echo echo "- decdn changes: https://github.com/${DECDN}/compare/v${CURRENT}...${TAG}" echo "- \`SHA256SUMS\` (\`${SUMS}\`) and the \`decdn-node\` image digest (\`${DIGEST}\`)" @@ -246,7 +274,11 @@ BODY="$WORK/body.md" printf -- '- %s\n' "${NEEDS[@]}" echo fi - echo "After merging, release each artifact the bump touched (RELEASING.md)." + if [[ "$KIND" == patch ]]; then + echo "Repo-only: no collection or chart release is needed (unless *Needs work* says otherwise)." + else + echo "After merging, release each artifact the bump touched (RELEASING.md)." + fi echo if [[ -n "$others" ]]; then echo "Older bump PRs still open: ${others}. This one supersedes them." @@ -270,14 +302,21 @@ git --no-pager diff --stat git add -u || die "git add failed; no PR opened" # --no-verify: CI is the gate on this PR. +if [[ "$KIND" == patch ]]; then + SUMMARY="Move what names one decdn/decdn release from v${CURRENT} to ${TAG} +(${UPSTREAM_SHA}): the Compose image digest and onramp installer pin, the +chart's example digest, the inventory and cloud-init exact-pin examples and the +generated mirrors. The roles and the chart pin the line ${LINE}." +else + SUMMARY="Move the decdn/decdn pin from the line ${LINE} (v${CURRENT}) to ${TAG} +(${UPSTREAM_SHA}): the role default, the onramp's installer line, the chart's +appVersion and Artifact Hub image, AGENTS.md's pinned line, and what names one +release (Compose, the examples, the generated mirrors)." +fi git commit --quiet --no-verify -F - <, the tag of the -release the roles pin (ansible/roles/decdn_node/defaults/main.yml): the mirror must -describe the binary a default deploy installs. Two +own branch does not matter. --ref defaults to the release a default deploy installs +(ansible/roles/decdn_node/defaults/main.yml): for a release line (decdn_node_version +"X.Y") the checkout's highest vX.Y.N tag, for an exact pin v, so +the mirror describes the binary a default deploy runs. A tag exists before its +release is published (upstream un-drafts it once signed), and the roles skip a +draft, so while a release is being cut the default ref is one the roles do not +install yet: pass --ref then (the upstream-drift job passes the resolved tag). Two upstream files are the source, and both the chain list AND the manifest-key mapping are parsed from the first, so this script holds no hand-written copy of either: @@ -201,7 +205,7 @@ def strip_commit(text: str) -> str: def main() -> None: ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) ap.add_argument("upstream", type=Path, help="path to a decdn/decdn git checkout") - ap.add_argument("--ref", help="git ref to read (default: v, the pinned release tag)") + ap.add_argument("--ref", help="git ref to read (default: the newest tag of the pinned release line)") ap.add_argument("--check", action="store_true", help="exit 1 if the committed file is stale") args = ap.parse_args() if args.ref is None: @@ -209,6 +213,15 @@ def main() -> None: if not pinned: die(f"no decdn_node_version default in {DEFAULTS.relative_to(REPO)}; pass --ref") args.ref = f"v{pinned.group(1)}" + if re.fullmatch(r"[0-9]+\.[0-9]+", pinned.group(1)): + tags = subprocess.run(["git", "-C", str(args.upstream), "tag", "-l", f"{args.ref}.*"], + check=True, capture_output=True, text=True).stdout.split() + patches = [int(m.group(1)) for tag in tags + if (m := re.fullmatch(rf"{re.escape(args.ref)}\.(0|[1-9][0-9]*)", tag))] + if not patches: + die(f"{args.upstream} has no {args.ref}.N tag (decdn_node_version is the line " + f"{pinned.group(1)}); fetch its tags or pass --ref") + args.ref = f"{args.ref}.{max(patches)}" commit, profiles = load(args.upstream, args.ref) outputs = [ diff --git a/scripts/tests/test_bump_decdn.py b/scripts/tests/test_bump_decdn.py index 8ad006d..4edb5af 100644 --- a/scripts/tests/test_bump_decdn.py +++ b/scripts/tests/test_bump_decdn.py @@ -32,8 +32,8 @@ bd.COMPOSE_ENV, bd.CHART, bd.CHART_VALUES, - "ansible/molecule/validation-sponsord/converge.yml", *dict.fromkeys(rel for rel, _, _ in bd.EXAMPLES), + bd.AGENTS[0], ] # rewrite never touches these: release.sh writes them. CHANGELOGS = [ @@ -60,9 +60,9 @@ def setUp(self) -> None: subprocess.run(["git", "init", "-q", str(self.root)], check=True) subprocess.run(["git", "-C", str(self.root), "add", "-A"], check=True) self.original = {rel: (self.root / rel).read_text() for rel in FILES} - onramp = self.text(bd.ONRAMP_DEFAULTS) - self.version = bd.pinned(self.root) - self.sums = bd.scalar(onramp, "sponsord_onramp_decdn_sums_sha256", bd.ONRAMP_DEFAULTS) + self.line = bd.pinned(self.root) + self.version = bd.synced(self.root) + self.sums = bd.env_value(self.text(bd.ONRAMP_ENV), "ONRAMP_DECDN_SUMS_SHA256", bd.ONRAMP_ENV) self.digest = bd.env_value(self.text(bd.COMPOSE_ENV), "DECDN_IMAGE_DIGEST", bd.COMPOSE_ENV) def tearDown(self) -> None: @@ -74,51 +74,86 @@ def text(self, rel: str) -> str: def write(self, rel: str, text: str) -> None: (self.root / rel).write_text(text) + def next_patch(self) -> str: + major, minor, patch = (int(p) for p in self.version.split(".")) + return f"{major}.{minor}.{patch + 1}" + class Compare(Fixture): + def test_the_synced_release_is_in_the_line(self) -> None: + self.assertEqual(self.version.rsplit(".", 1)[0], self.line) + def test_relations(self) -> None: - major, minor, patch = (int(p) for p in self.version.split(".")) + major, minor, _ = (int(p) for p in self.version.split(".")) self.assertEqual(bd.compare(self.root, f"v{self.version}"), "same") - self.assertEqual(bd.compare(self.root, f"v{major}.{minor}.{patch + 1}"), "behind") + self.assertEqual(bd.compare(self.root, f"v{self.next_patch()}"), "behind") self.assertEqual(bd.compare(self.root, f"v{major}.{minor + 1}.0"), "behind") def test_numeric_not_lexical(self) -> None: - self.write(bd.NODE_DEFAULTS, self.text(bd.NODE_DEFAULTS).replace( - f'decdn_node_version: "{self.version}"', 'decdn_node_version: "0.0.9"')) + self.write(bd.COMPOSE_ENV, self.text(bd.COMPOSE_ENV).replace( + f"# decdn/decdn v{self.version}\n", "# decdn/decdn v0.0.9\n")) self.assertEqual(bd.compare(self.root, "v0.0.10"), "behind") self.assertEqual(bd.compare(self.root, "v0.0.8"), "ahead") + def test_kind(self) -> None: + major, minor, _ = (int(p) for p in self.version.split(".")) + self.assertEqual(bd.kind(self.root, f"v{self.next_patch()}"), "patch") + self.assertEqual(bd.kind(self.root, f"v{major}.{minor + 1}.0"), "line") + self.assertEqual(bd.kind(self.root, f"v{major + 1}.{minor}.0"), "line") + def test_refuses_a_non_release_tag(self) -> None: for tag in ("0.0.3", "v0.0.3-rc.1", "v1.2"): with self.subTest(tag=tag), self.assertRaises(bd.ShapeError): bd.compare(self.root, tag) + with self.subTest(tag=tag), self.assertRaises(bd.ShapeError): + bd.kind(self.root, tag) + + def test_refuses_an_exact_role_pin(self) -> None: + self.write(bd.NODE_DEFAULTS, self.text(bd.NODE_DEFAULTS).replace( + f'decdn_node_version: "{self.line}"', f'decdn_node_version: "{self.version}"')) + with self.assertRaisesRegex(bd.ShapeError, "not a release line"): + bd.pinned(self.root) class Rewrite(Fixture): - def test_moves_every_pin(self) -> None: - bd.rewrite(self.root, "9.8.7", SUMS, DIGEST) - onramp = self.text(bd.ONRAMP_DEFAULTS) - self.assertEqual(bd.pinned(self.root), "9.8.7") - self.assertIn("sponsord_onramp_decdn_release: v9.8.7 ", onramp) - self.assertIn("Default: decdn/decdn v9.8.7.", onramp) - self.assertIn("defaults to: decdn/decdn v9.8.7.", self.text(bd.ONRAMP_ENV)) - self.assertIn(f"ONRAMP_DECDN_SUMS_SHA256={SUMS}\n", self.text(bd.ONRAMP_ENV)) - self.assertIn(f"# decdn/decdn v9.8.7\nDECDN_IMAGE_DIGEST={DIGEST}\n", self.text(bd.COMPOSE_ENV)) - 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)) + def assert_exact_copies(self, version: str, sums: str, digest: str) -> None: + self.assertIn(f"ONRAMP_DECDN_RELEASE=v{version}\n", self.text(bd.ONRAMP_ENV)) + self.assertIn(f"last moved:\n# decdn/decdn v{version}.\n", self.text(bd.ONRAMP_ENV)) + self.assertIn(f"ONRAMP_DECDN_SUMS_SHA256={sums}\n", self.text(bd.ONRAMP_ENV)) + self.assertIn(f"# decdn/decdn v{version}\nDECDN_IMAGE_DIGEST={digest}\n", self.text(bd.COMPOSE_ENV)) + self.assertIn(f"e.g. for v{version}\n # {digest}\n", self.text(bd.CHART_VALUES)) 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.assertIn(f'\n# decdn_node_version: "{version}"\n', host_vars) + self.assertIn(f" decdn_node_source_ref: v{version}\n", host_vars) + self.assertIn(f"\n# sponsord_onramp_decdn_release: v{version}\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")) + self.assertIn(f'# sponsord_onramp_decdn_release: "v{version}"\n', + self.text("cloud-init/user-data-publisher.yaml")) + + def assert_line_copies(self, line: str) -> None: + self.assertEqual(bd.pinned(self.root), line) + self.assertIn(f"sponsord_onramp_decdn_release: v{line} ", self.text(bd.ONRAMP_DEFAULTS)) + self.assertIn(f'appVersion: "{line}"', self.text(bd.CHART)) + self.assertIn(f"image: ghcr.io/decdn/decdn-node:{line}\n", self.text(bd.CHART)) + self.assertIn(f"`decdn_node_version`, today `{line}`)", flat(self.text("AGENTS.md"))) + + def test_a_new_line_moves_every_pin(self) -> None: + bd.rewrite(self.root, "9.8.7", SUMS, DIGEST) + self.assert_line_copies("9.8") + self.assert_exact_copies("9.8.7", SUMS, DIGEST) + self.assertEqual(bd.synced(self.root), "9.8.7") + + def test_a_patch_leaves_the_line(self) -> None: + line_files = (bd.NODE_DEFAULTS, bd.ONRAMP_DEFAULTS, bd.CHART, "AGENTS.md") + changed = bd.rewrite(self.root, self.next_patch(), SUMS, DIGEST) + for rel in line_files: + with self.subTest(rel=rel): + self.assertNotIn(rel, changed) + self.assertEqual(self.text(rel), self.original[rel]) + self.assert_line_copies(self.line) + self.assert_exact_copies(self.next_patch(), SUMS, DIGEST) def test_round_trip_is_byte_identical(self) -> None: bd.rewrite(self.root, "9.8.7", SUMS, DIGEST) @@ -147,7 +182,7 @@ def test_moves_agents_md_wrapped_either_way(self) -> None: 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")) + self.assertIn(f"`decdn_node_version`, today{sep}`9.8`", self.text("AGENTS.md")) bd.rewrite(self.root, self.version, self.sums, self.digest) def test_leaves_the_changelogs(self) -> None: @@ -166,7 +201,7 @@ def test_refuses_a_reshaped_file(self) -> None: bd.rewrite(self.root, "9.8.7", SUMS, DIGEST) def test_refuses_a_second_pin_line(self) -> None: - self.write(bd.CHART, self.text(bd.CHART) + f'appVersion: "{self.version}"\n') + self.write(bd.CHART, self.text(bd.CHART) + f'appVersion: "{self.line}"\n') with self.assertRaisesRegex(bd.ShapeError, "2 matches"): bd.rewrite(self.root, "9.8.7", SUMS, DIGEST) @@ -175,12 +210,11 @@ def test_refuses_bad_arguments(self) -> None: with self.subTest(args=args), self.assertRaises(bd.ShapeError): bd.rewrite(self.root, *args) - def test_refuses_a_drifted_copy(self) -> None: - # The copies are found by the old value, so one that drifted from it is left - # behind; the read-back must catch it. - drifted = "c" * 64 - self.write(bd.ONRAMP_ENV, self.text(bd.ONRAMP_ENV).replace(self.sums, drifted)) - with self.assertRaisesRegex(bd.ShapeError, "ONRAMP_DECDN_SUMS_SHA256"): + def test_refuses_a_digest_beside_the_line(self) -> None: + # The role reads a line's digest from the release; a default one would be stale. + self.write(bd.ONRAMP_DEFAULTS, self.text(bd.ONRAMP_DEFAULTS).replace( + 'sponsord_onramp_decdn_sums_sha256: "" ', f'sponsord_onramp_decdn_sums_sha256: {self.sums} ')) + with self.assertRaisesRegex(bd.ShapeError, "sponsord_onramp_decdn_sums_sha256"): bd.rewrite(self.root, "9.8.7", SUMS, DIGEST) def test_refuses_a_second_artifacthub_image(self) -> None: @@ -226,10 +260,13 @@ def run_cli(self, *args: str) -> subprocess.CompletedProcess[str]: capture_output=True, text=True, check=False, ) - def test_pinned_and_compare_print_one_word(self) -> None: - self.assertEqual(self.run_cli("pinned").stdout, f"{self.version}\n") + def test_pinned_synced_compare_and_kind_print_one_word(self) -> None: + self.assertEqual(self.run_cli("pinned").stdout, f"{self.line}\n") + self.assertEqual(self.run_cli("synced").stdout, f"{self.version}\n") r = self.run_cli("compare", "v99.0.0") self.assertEqual((r.returncode, r.stdout), (0, "behind\n")) + r = self.run_cli("kind", "v99.0.0") + self.assertEqual((r.returncode, r.stdout), (0, "line\n")) def test_a_refusal_exits_1(self) -> None: self.write(bd.COMPOSE_ENV, self.text(bd.COMPOSE_ENV).replace(f"# decdn/decdn v{self.version}\n", "")) @@ -237,6 +274,7 @@ def test_a_refusal_exits_1(self) -> None: self.assertEqual(r.returncode, 1) self.assertTrue(r.stderr.startswith("bump_decdn: "), r.stderr) self.assertEqual(self.run_cli("compare", "v1.2").returncode, 1) + self.assertEqual(self.run_cli("kind", "v1.2").returncode, 1) def test_usage_exits_2(self) -> None: self.assertEqual(self.run_cli().returncode, 2) diff --git a/tests/scripts-test.sh b/tests/scripts-test.sh index 1667f74..d651f4c 100755 --- a/tests/scripts-test.sh +++ b/tests/scripts-test.sh @@ -1484,8 +1484,34 @@ 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" +refuses badversion "the decdn version is '$fxv/../x', not X.Y or X.Y.Z" "$fxv/../x" expect 2 "fetch-decdn-cli refuses a missing destination" "$repo/scripts/fetch-decdn-cli.sh" +# A release line resolves through the release list, as the roles resolve it: the +# good release, not the newer prerelease, draft or release without SHA256SUMS.asc +# (none of which exist, so picking one fails the download). +fxr() { # : one release-list entry + local assets='[{"name": "SHA256SUMS"}]' + [[ $4 == no ]] || assets='[{"name": "SHA256SUMS"}, {"name": "SHA256SUMS.asc"}]' + printf '{"tag_name": "%s", "draft": %s, "prerelease": %s, "assets": %s}' "$1" "$2" "$3" "$assets" +} +printf '[%s, %s, %s, %s, %s]\n' "$(fxr "v${fxv%.*}.10" false false no)" "$(fxr "v${fxv%.*}.9" true false yes)" \ + "$(fxr "v${fxv%.*}.8" false true yes)" "$(fxr "v$fxv" false false yes)" "$(fxr v99.0.0 false false yes)" \ + >"$fx/releases.json" +echo 'not json' >"$fx/broken.json" +fetch_line() { # + env DECDN_VERSION="$2" DECDN_RELEASE_INDEX="file://$fx/$1" DECDN_RELEASE_BASE="file://$fx/good" \ + DECDN_RELEASE_KEYS="$fx/KEYS.asc" "$repo/scripts/fetch-decdn-cli.sh" "$fx/$3" +} +expect 0 "fetch-decdn-cli resolves a release line to its newest finished release" \ + fetch_line releases.json "${fxv%.*}" out-line +grep -qF "decdn ${fxv%.*} resolves to $fxv" "$work/out" || { cat "$work/out" >&2; fail "fetch-decdn-cli did not report the resolved release"; } +[[ -x "$fx/out-line/decdn" ]] || fail "fetch-decdn-cli wrote no binary for the release line" +for c in "releases.json|9.9|no finished v9.9.N decdn release" "broken.json|${fxv%.*}|is not a JSON release list"; do + IFS='|' read -r index line msg <<<"$c" + expect 1 "fetch-decdn-cli refuses the line $line in $index" fetch_line "$index" "$line" "out-$index" + grep -qF "$msg" "$work/out" || { cat "$work/out" >&2; fail "fetch-decdn-cli ($index, $line): not refused for '$msg'"; } + [[ ! -e "$fx/out-$index" ]] || fail "fetch-decdn-cli wrote a binary for the refused line $line" +done # --- 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 @@ -1640,6 +1666,8 @@ if command -v cloud-init >/dev/null; then make -s -C "$repo" lint-cloud-init CLOUD_INIT_FILE="$work/ci-origin-alone.yaml" ci_variant "one sponsord version moved" 'set sponsord_version and sponsord_onramp_version together' 's/^(\s*)sponsord_onramp_install_method: release$/&\n\1sponsord_onramp_version: "0.0.9"/' "$sponsorud" ci_variant "an onramp pin without its digest" 'set both sponsord_onramp_decdn_release' 's/^(\s*)sponsord_onramp_install_method: release$/&\n\1sponsord_onramp_decdn_release: v0.0.9/' "$sponsorud" + ci_variant "an onramp line with a digest" 'which takes no sponsord_onramp_decdn_sums_sha256' 's/^(\s*)sponsord_onramp_install_method: release$/&\n\1sponsord_onramp_decdn_release: v0.0\n\1sponsord_onramp_decdn_sums_sha256: "'"$(printf 'a%.0s' {1..64})"'"/' "$sponsorud" + ci_variant "an onramp digest without its tag" 'which takes no sponsord_onramp_decdn_sums_sha256' 's/^(\s*)sponsord_onramp_install_method: release$/&\n\1sponsord_onramp_decdn_sums_sha256: "'"$(printf 'a%.0s' {1..64})"'"/' "$sponsorud" ci_variant "onramp nested under children" 'sponsord_hosts may hold only hosts and vars, not' '/^ sponsord_onramp_hosts:$/,/^$/{s/^ / /}; s/^ sponsord_onramp_hosts:$/ children:\n&/' "$sponsorud" ci_variant "query in the onramp RPC URL" 'sponsord_onramp_rpc_url must be a public http(s) URL' 's#^(\s*)sponsord_onramp_rpc_url: "CHANGE_ME"$#\1sponsord_onramp_rpc_url: "https://rpc.example/?api_key=x"#' "$sponsorud" ci_variant "fragment in the onramp RPC URL" 'sponsord_onramp_rpc_url must be a public http(s) URL' 's#^(\s*)sponsord_onramp_rpc_url: "CHANGE_ME"$#\1sponsord_onramp_rpc_url: "https://rpc.example/rpc\#k"#' "$sponsorud" @@ -1802,6 +1830,17 @@ done cmp -s "$repo/ansible/roles/decdn_node/files/install-build-output.py" \ "$repo/ansible/roles/sponsord/files/install-build-output.py" \ || fail "decdn_node and sponsord files/install-build-output.py differ" +# The release-line resolver is copied into every role that installs a release, and +# the onramp carries its own copy of decdn's KEYS (it checks the installers' decdn +# release; decdn_node ships in the other collection). +for r in sponsord sponsord_onramp; do + cmp -s "$repo/ansible/roles/decdn_node/tasks/resolve-release.yml" \ + "$repo/ansible/roles/$r/tasks/resolve-release.yml" \ + || fail "decdn_node and $r tasks/resolve-release.yml differ" +done +cmp -s "$repo/ansible/roles/decdn_node/files/decdn-release-KEYS.asc" \ + "$repo/ansible/roles/sponsord_onramp/files/decdn-release-KEYS.asc" \ + || fail "decdn_node and sponsord_onramp files/decdn-release-KEYS.asc differ" # decdn_backup_local_dir and sponsord_backup_local_dir: one expression (backups/ beside # the repo's playbooks/, or beside the project's ansible.cfg from a collection), the # key's own name aside. Only decdn_node's runs in a molecule scenario. @@ -1811,7 +1850,7 @@ backup_dir_default() { # want="$(backup_dir_default "$repo/ansible/roles/decdn_node/defaults/main.yml" decdn_backup_local_dir)" [[ -n "$want" && "$(backup_dir_default "$repo/ansible/roles/sponsord/defaults/main.yml" sponsord_backup_local_dir)" == "$want" ]] \ || fail "sponsord_backup_local_dir's default differs from decdn_backup_local_dir's" -pass "source-build and decommission defaults, the backup directory and the installer, identical across roles" +pass "source-build and decommission defaults, the backup directory, the installer, the release resolver and decdn's KEYS, identical across roles" # iroh_relay and iroh_dns_server have no source build (and their own per-triple # sha256 pins), so only their decommission keys are compared. decom_keys() { grep -E '^decdn_decommission_(max_hosts|prompt_seconds):' "$1" || true; } @@ -1841,18 +1880,33 @@ pin() { # : a scalar default, quotes stripped sed -nE "s/^$2: *\"?([^\" #]*)\"?.*/\1/p" "$repo/ansible/roles/$1/defaults/main.yml" } envpin() { sed -nE "s/^$1=//p" "$repo/compose/sponsord-onramp.env.example"; } +# A pin is a release line (X.Y: the roles resolve its newest release) or an exact +# X.Y.Z[-pre]. +pin_re='^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(\.(0|[1-9][0-9]*)(-[0-9A-Za-z.-]+)?)?$' for p in "decdn_node decdn_node_version" "sponsord sponsord_version" "sponsord_onramp sponsord_onramp_version"; do # shellcheck disable=SC2086 # " ", split on purpose - [ -n "$(pin $p)" ] || fail "${p#* }: no default in roles/${p% *}/defaults/main.yml" + [[ "$(pin $p)" =~ $pin_re ]] || fail "${p#* }: the default in roles/${p% *}/defaults/main.yml is not X.Y or X.Y.Z" done [ "$(pin sponsord sponsord_version)" = "$(pin sponsord_onramp sponsord_onramp_version)" ] \ || fail "sponsord_version and sponsord_onramp_version differ (one decdn/sponsord release ships both)" [ "$(pin sponsord_onramp sponsord_onramp_decdn_release)" = "v$(pin decdn_node decdn_node_version)" ] \ || fail "sponsord_onramp_decdn_release is not v" -for k in DECDN_RELEASE:decdn_release DECDN_SUMS_SHA256:decdn_sums_sha256; do - [ "$(envpin "ONRAMP_${k%%:*}")" = "$(pin sponsord_onramp "sponsord_onramp_${k#*:}")" ] \ - || fail "compose/sponsord-onramp.env.example ONRAMP_${k%%:*} differs from sponsord_onramp_${k#*:}" -done +# Compose has no resolver: its env example names an exact release and digest, which +# must be the role's when that pin is exact, and in its line when it is one. +onramp_release="$(pin sponsord_onramp sponsord_onramp_decdn_release)" +if [[ "$onramp_release" =~ ^v[0-9]+\.[0-9]+$ ]]; then + [ -z "$(pin sponsord_onramp sponsord_onramp_decdn_sums_sha256)" ] \ + || fail "sponsord_onramp_decdn_release is the line $onramp_release, so its digest default must be empty" + [[ "$(envpin ONRAMP_DECDN_RELEASE)" =~ ^${onramp_release//./\\.}\.(0|[1-9][0-9]*)$ ]] \ + || fail "compose/sponsord-onramp.env.example ONRAMP_DECDN_RELEASE '$(envpin ONRAMP_DECDN_RELEASE)' is not a release of $onramp_release" + [[ "$(envpin ONRAMP_DECDN_SUMS_SHA256)" =~ ^[0-9a-f]{64}$ ]] \ + || fail "compose/sponsord-onramp.env.example ONRAMP_DECDN_SUMS_SHA256 is not 64 lowercase hex" +else + for k in DECDN_RELEASE:decdn_release DECDN_SUMS_SHA256:decdn_sums_sha256; do + [ "$(envpin "ONRAMP_${k%%:*}")" = "$(pin sponsord_onramp "sponsord_onramp_${k#*:}")" ] \ + || fail "compose/sponsord-onramp.env.example ONRAMP_${k%%:*} differs from sponsord_onramp_${k#*:}" + done +fi 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" @@ -2244,9 +2298,9 @@ fi # --- upstream-mirror generators (optional: needs a decdn/decdn checkout) -------------- if [[ -n "${UPSTREAM:-}" ]]; then - # Against the release the roles pin, as the upstream-drift workflow checks. - expect 0 "network profiles current" "$repo/scripts/sync-network-profiles.py" "$UPSTREAM" \ - --ref "v$(pin decdn_node decdn_node_version)" --check + # Against the newest release of the line the roles pin (the script's default ref), + # as the upstream-drift workflow checks. + expect 0 "network profiles current" "$repo/scripts/sync-network-profiles.py" "$UPSTREAM" --check expect 2 "network profiles: bad ref is an error, not drift" "$repo/scripts/sync-network-profiles.py" "$UPSTREAM" --ref no/such/ref --check else skipped+=("upstream-mirror generators (set UPSTREAM=)") From b5489d8f9747c4d9323ba8fa4da934e24dc47c23 Mon Sep 17 00:00:00 2001 From: Ant Somers Date: Wed, 14 Oct 2026 21:57:01 +0300 Subject: [PATCH 2/2] fix: keep the release-line review fixes honest - fetch-decdn-cli.sh sends GITHUB_TOKEN to GitHub's API only, never to a DECDN_RELEASE_INDEX override; a loopback index in make test-scripts asserts no Authorization header arrives. - The onramp records the decdn release it serves on both paths, exact pins included, so a later release-line lookup that cannot read GitHub falls back to the release actually served, never an older one. The record path is a role var (vars/main/decdn-release.yml); sponsord-onramp and cloud-init-publisher assert it. - The release-version failure messages of sponsord and sponsord_onramp (and decdn_node's wording) describe both accepted forms, quoting and the index. Co-Authored-By: Claude Opus 5.5 --- .../molecule/cloud-init-publisher/verify.yml | 6 +++ ansible/molecule/sponsord-onramp/verify.yml | 7 +++- ansible/roles/decdn_node/tasks/main.yml | 8 ++-- ansible/roles/sponsord/tasks/main.yml | 12 +++--- .../sponsord_onramp/tasks/decdn-release.yml | 41 +++++++++---------- ansible/roles/sponsord_onramp/tasks/main.yml | 8 ++-- .../vars/main/decdn-release.yml | 5 +++ scripts/fetch-decdn-cli.sh | 10 +++-- tests/scripts-test.sh | 26 ++++++++++++ 9 files changed, 86 insertions(+), 37 deletions(-) create mode 100644 ansible/roles/sponsord_onramp/vars/main/decdn-release.yml diff --git a/ansible/molecule/cloud-init-publisher/verify.yml b/ansible/molecule/cloud-init-publisher/verify.yml index dc93bca..bb76318 100644 --- a/ansible/molecule/cloud-init-publisher/verify.yml +++ b/ansible/molecule/cloud-init-publisher/verify.yml @@ -56,6 +56,11 @@ src: /etc/sponsord/sponsord-onramp.env register: onramp_env + - name: Read the decdn release the onramp recorded as served + ansible.builtin.slurp: + src: /usr/local/lib/sponsord-onramp/decdn-release + register: onramp_decdn_record + - name: Hash the mirror's decdn v0.0.5 SHA256SUMS ansible.builtin.stat: path: /srv/mirror/www/releases/decdn/v0.0.5/SHA256SUMS @@ -105,6 +110,7 @@ # digest was read from that release's verified SHA256SUMS. - onramp_env.content | b64decode is search('(?m)^ONRAMP_DECDN_RELEASE=v0\.0\.5$') - onramp_env.content | b64decode is search('(?m)^ONRAMP_DECDN_SUMS_SHA256=' ~ decdn_sums.stat.checksum ~ '$') + - onramp_decdn_record.content | b64decode == '0.0.5\n' fail_msg: >- state={{ bootstrap_state.content | b64decode | trim }} awaiting={{ awaiting.stat.exists }} head={{ checkout_head.content | b64decode | trim }} (tag {{ tag_sha.content | b64decode | trim }}) diff --git a/ansible/molecule/sponsord-onramp/verify.yml b/ansible/molecule/sponsord-onramp/verify.yml index 15d833d..873b1b2 100644 --- a/ansible/molecule/sponsord-onramp/verify.yml +++ b/ansible/molecule/sponsord-onramp/verify.yml @@ -38,6 +38,7 @@ loop: - /etc/sponsord/sponsord-onramp.env - /etc/caddy/Caddyfile + - /usr/local/lib/sponsord-onramp/decdn-release - name: Require the env file to carry the settings and no secret ansible.builtin.assert: @@ -55,7 +56,11 @@ - "'TOKEN' not in _env" - "'TURNSTILE_SECRET' not in _env" - "'1x0000000000000000000000000000000' not in _env" - fail_msg: "{{ _env }}" + # The exact pin is served as it is, and recorded, so a later outage on a + # release line cannot fall back to a release served before it. + - "'ONRAMP_DECDN_RELEASE=v9.9.9\n' in _env" + - _or_text.results[2].content | b64decode == '9.9.9\n' + fail_msg: "{{ _env }} (decdn-release record: {{ _or_text.results[2].content | b64decode | to_json }})" vars: _env: "{{ _or_text.results[0].content | b64decode }}" _profile: "{{ _sponsord_onramp_network_profiles['arbitrum-sepolia'] }}" diff --git a/ansible/roles/decdn_node/tasks/main.yml b/ansible/roles/decdn_node/tasks/main.yml index 8010c3e..e73c206 100644 --- a/ansible/roles/decdn_node/tasks/main.yml +++ b/ansible/roles/decdn_node/tasks/main.yml @@ -22,10 +22,10 @@ - decdn_node_version is match('^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(\.(0|[1-9][0-9]*)(-[0-9A-Za-z.-]+)?)?$') fail_msg: >- decdn_node_install_method "release" (the default) requires decdn_node_version: - a release line (e.g. "0.1", the newest v0.1.N release of decdn/decdn, looked - up in decdn_node_release_index) or an exact version (e.g. "0.1.0", the - v0.1.0 release), as a quoted string without the "v" (got - "{{ decdn_node_version }}"). To run something unreleased, set + a release line ("X.Y", the newest vX.Y.N release of decdn/decdn, looked up in + decdn_node_release_index) or an exact version ("X.Y.Z", the vX.Y.Z release), + as a quoted string without the "v" (got "{{ decdn_node_version }}"). + Downloads come from decdn_node_release_base. To run something unreleased, set decdn_node_install_method "source" with a decdn_node_source_ref to build on the node, or use "manual" to copy binaries built on the control machine. when: decdn_node_install_method == "release" diff --git a/ansible/roles/sponsord/tasks/main.yml b/ansible/roles/sponsord/tasks/main.yml index 215c06f..2c0f96b 100644 --- a/ansible/roles/sponsord/tasks/main.yml +++ b/ansible/roles/sponsord/tasks/main.yml @@ -15,17 +15,19 @@ "source" (build sponsord_source_ref on the host) or "manual" (copy a locally-built binary from the control machine). -# sponsord_version defaults to the release this repo pins; an inventory -# that blanks it gets this message instead of a bare 404 from get_url. +# sponsord_version defaults to the release line this repo pins; an inventory +# that blanks or misspells it gets this message instead of a bare 404 from get_url. - name: Require a pinned release version ansible.builtin.assert: that: - sponsord_version is string - sponsord_version is match('^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(\.(0|[1-9][0-9]*)(-[0-9A-Za-z.-]+)?)?$') fail_msg: >- - sponsord_install_method "release" (the default) requires sponsord_version (e.g. - "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 "release" (the default) requires sponsord_version: a + release line ("X.Y", the newest vX.Y.N release of decdn/sponsord, looked up in + sponsord_release_index) or an exact version ("X.Y.Z", the vX.Y.Z release), as + a quoted string without the "v" (got "{{ sponsord_version }}"). Downloads come + from 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. when: sponsord_install_method == "release" diff --git a/ansible/roles/sponsord_onramp/tasks/decdn-release.yml b/ansible/roles/sponsord_onramp/tasks/decdn-release.yml index 675280c..34e1517 100644 --- a/ansible/roles/sponsord_onramp/tasks/decdn-release.yml +++ b/ansible/roles/sponsord_onramp/tasks/decdn-release.yml @@ -13,8 +13,9 @@ # # Check mode fetches and hashes the manifest to report the env file's diff, but # does not verify it (gnupg may not be installed yet); the real run does. The release -# served is recorded on the host, so when GitHub's release list cannot be read the -# resolver keeps serving it (its SHA256SUMS still comes from the release itself). +# served is recorded on the host, by either path, so when GitHub's release list cannot +# be read the resolver keeps serving it (its SHA256SUMS still comes from the release +# itself), and never a release an exact pin has since moved the installers off. - name: Serve an exact decdn release as pinned ansible.builtin.set_fact: _onramp_decdn_release: "{{ sponsord_onramp_decdn_release }}" @@ -23,8 +24,6 @@ - name: Resolve the decdn release line the installers use when: sponsord_onramp_decdn_sums_sha256 | length == 0 - vars: - _onramp_decdn_record: /usr/local/lib/sponsord-onramp/decdn-release block: - name: Resolve sponsord_onramp_decdn_release ansible.builtin.include_tasks: resolve-release.yml @@ -32,7 +31,7 @@ _rr_spec: "{{ sponsord_onramp_decdn_release[1:] }}" _rr_index: "{{ sponsord_onramp_decdn_release_index }}" _rr_name: sponsord_onramp_decdn_release - _rr_stamp: "{{ _onramp_decdn_record }}" + _rr_stamp: "{{ _sponsord_onramp_decdn_record }}" - name: Name the resolved decdn release ansible.builtin.set_fact: @@ -112,22 +111,6 @@ ansible.builtin.set_fact: _onramp_decdn_sums_sha256: "{{ _onramp_decdn_sums_stat.stat.checksum }}" - - name: Ensure the onramp's state directory exists - ansible.builtin.file: - path: "{{ _onramp_decdn_record | dirname }}" - state: directory - owner: root - group: root - mode: "0755" - - - name: Record the decdn release the installers are served - ansible.builtin.copy: - dest: "{{ _onramp_decdn_record }}" - content: "{{ _rr_version }}\n" - owner: root - group: root - mode: "0644" - always: - name: Remove the decdn manifest staging directory ansible.builtin.file: @@ -136,3 +119,19 @@ check_mode: false changed_when: false when: _onramp_decdn_stage.path is defined + +- name: Ensure the onramp's state directory exists + ansible.builtin.file: + path: "{{ _sponsord_onramp_decdn_record | dirname }}" + state: directory + owner: root + group: root + mode: "0755" + +- name: Record the decdn release the installers are served + ansible.builtin.copy: + dest: "{{ _sponsord_onramp_decdn_record }}" + content: "{{ _onramp_decdn_release[1:] }}\n" + owner: root + group: root + mode: "0644" diff --git a/ansible/roles/sponsord_onramp/tasks/main.yml b/ansible/roles/sponsord_onramp/tasks/main.yml index 4ea8312..f7585fe 100644 --- a/ansible/roles/sponsord_onramp/tasks/main.yml +++ b/ansible/roles/sponsord_onramp/tasks/main.yml @@ -27,9 +27,11 @@ - sponsord_onramp_version is match('^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(\.(0|[1-9][0-9]*)(-[0-9A-Za-z.-]+)?)?$') fail_msg: >- sponsord_onramp_install_method "release" (the default) requires - 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_version: a release line ("X.Y", the newest vX.Y.N release of + decdn/sponsord, looked up in sponsord_onramp_release_index) or an exact version + ("X.Y.Z", the vX.Y.Z release), as a quoted string without the "v" (got + "{{ sponsord_onramp_version }}"), the same as sponsord_version. Downloads come + from 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 binary built on the control machine. when: sponsord_onramp_install_method == "release" diff --git a/ansible/roles/sponsord_onramp/vars/main/decdn-release.yml b/ansible/roles/sponsord_onramp/vars/main/decdn-release.yml new file mode 100644 index 0000000..979ce58 --- /dev/null +++ b/ansible/roles/sponsord_onramp/vars/main/decdn-release.yml @@ -0,0 +1,5 @@ +--- +# Where the role records the decdn/decdn release the installers are served +# (tasks/decdn-release.yml), whichever way it was pinned: the release-line lookup +# falls back to it when GitHub's release list cannot be read. A fact of the role, not a knob. +_sponsord_onramp_decdn_record: /usr/local/lib/sponsord-onramp/decdn-release diff --git a/scripts/fetch-decdn-cli.sh b/scripts/fetch-decdn-cli.sh index 500ab81..dce2dd8 100755 --- a/scripts/fetch-decdn-cli.sh +++ b/scripts/fetch-decdn-cli.sh @@ -25,8 +25,9 @@ # A release line (X.Y, as the chart's appVersion is) resolves to its newest # finished release as roles/decdn_node/tasks/resolve-release.yml resolves one: the # highest vX.Y.N that is not a draft or a prerelease and has a SHA256SUMS.asc -# asset, from GitHub's release list (sent with GITHUB_TOKEN when it is set, which -# lifts the unauthenticated rate limit). +# asset, from GitHub's release list (sent with GITHUB_TOKEN when it is set and the +# list is GitHub's API, which lifts the unauthenticated rate limit; an override +# never gets the token). # # Overrides, for the tests' fixture release: DECDN_VERSION (default: the chart's # appVersion; X.Y or X.Y.Z), DECDN_RELEASE_INDEX (default: decdn/decdn's GitHub @@ -59,8 +60,11 @@ done [[ -f "$keys" ]] || die "no release keyring at $keys" if [[ "$version" =~ ^[0-9]+\.[0-9]+$ ]]; then + # The token goes to GitHub's API only, never to an index override (a mirror). auth=() - [[ -z "${GITHUB_TOKEN:-}" ]] || auth=(-H "Authorization: Bearer $GITHUB_TOKEN") + if [[ -n "${GITHUB_TOKEN:-}" && "$index" == https://api.github.com/* ]]; then + auth=(-H "Authorization: Bearer $GITHUB_TOKEN") + fi releases=$(curl -fsSL --retry 3 -H 'Accept: application/vnd.github+json' "${auth[@]}" "$index") || die "cannot read the decdn release list at $index" patch=$(python3 -I -c ' diff --git a/tests/scripts-test.sh b/tests/scripts-test.sh index d651f4c..f00e6de 100755 --- a/tests/scripts-test.sh +++ b/tests/scripts-test.sh @@ -1506,6 +1506,32 @@ expect 0 "fetch-decdn-cli resolves a release line to its newest finished release fetch_line releases.json "${fxv%.*}" out-line grep -qF "decdn ${fxv%.*} resolves to $fxv" "$work/out" || { cat "$work/out" >&2; fail "fetch-decdn-cli did not report the resolved release"; } [[ -x "$fx/out-line/decdn" ]] || fail "fetch-decdn-cli wrote no binary for the release line" +# GITHUB_TOKEN goes to GitHub's API only: an index override (a mirror) must never +# receive it. A loopback index records the Authorization header of the one request. +python3 -I - "$fx" <<'PY' & +import http.server, pathlib, sys +fx = pathlib.Path(sys.argv[1]) +class H(http.server.BaseHTTPRequestHandler): + def do_GET(self): + (fx / "index-auth.txt").write_text(self.headers.get("Authorization", "none")) + body = (fx / "releases.json").read_bytes() + self.send_response(200); self.send_header("Content-Length", str(len(body))); self.end_headers() + self.wfile.write(body) + def log_message(self, *a): pass +s = http.server.HTTPServer(("127.0.0.1", 0), H) +(fx / "index-port").write_text(str(s.server_address[1])) +s.handle_request() +PY +for _ in $(seq 50); do [[ -s "$fx/index-port" ]] && break; sleep 0.1; done +expect 0 "fetch-decdn-cli reads an index override with GITHUB_TOKEN set" \ + env GITHUB_TOKEN=must-not-leak DECDN_VERSION="${fxv%.*}" \ + DECDN_RELEASE_INDEX="http://127.0.0.1:$(<"$fx/index-port")/releases.json" \ + DECDN_RELEASE_BASE="file://$fx/good" DECDN_RELEASE_KEYS="$fx/KEYS.asc" \ + "$repo/scripts/fetch-decdn-cli.sh" "$fx/out-override" +wait +[[ "$(<"$fx/index-auth.txt")" == none ]] \ + || fail "fetch-decdn-cli sent an Authorization header to an index override: $(<"$fx/index-auth.txt")" +pass "fetch-decdn-cli keeps GITHUB_TOKEN from an index override" for c in "releases.json|9.9|no finished v9.9.N decdn release" "broken.json|${fxv%.*}|is not a JSON release list"; do IFS='|' read -r index line msg <<<"$c" expect 1 "fetch-decdn-cli refuses the line $line in $index" fetch_line "$index" "$line" "out-$index"