From 8123d0b1c78f42a861ee35fca6852c94b1f77e58 Mon Sep 17 00:00:00 2001 From: Ant Somers Date: Sat, 11 Jul 2026 19:00:08 +0300 Subject: [PATCH 1/2] feat(decdn_node): add manual local-binary install method MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a `decdn_node_install_method` toggle (release | manual, default release) so operators can deploy locally-built binaries when no GitHub Release exists yet. In manual mode the role copies the decdn-node daemon and decdn CLI verbatim from control-machine paths (decdn_node_manual_bin_src / decdn_cli_manual_bin_src) into place, with copy's checksum idempotency replacing the release version stamp. The version assert is now gated to release mode, a manual-mode assert requires both source paths, and the --version backstop is mode-agnostic (rc != 0 always fails; the version-substring match applies only when a version is set). Templates and the systemd unit are unchanged — both modes land the daemon at decdn_bin. Co-Authored-By: Claude Opus 4.8 (1M context) --- ansible/roles/decdn_node/README.md | 54 ++++++-- ansible/roles/decdn_node/defaults/main.yml | 12 +- ansible/roles/decdn_node/meta/main.yml | 4 +- ansible/roles/decdn_node/tasks/install.yml | 147 +++++++++++++-------- ansible/roles/decdn_node/tasks/main.yml | 20 +++ 5 files changed, 163 insertions(+), 74 deletions(-) diff --git a/ansible/roles/decdn_node/README.md b/ansible/roles/decdn_node/README.md index dc360ba..c823334 100644 --- a/ansible/roles/decdn_node/README.md +++ b/ansible/roles/decdn_node/README.md @@ -1,8 +1,11 @@ # roles/decdn_node -Provisions a **public deCDN node** (`decdn-node` daemon) from a pinned GitHub -Release tarball, under a hardened systemd unit. This is the repo's **primary** -deployment (`playbooks/site.yml`); the anvil devnet is separate internal tooling. +Provisions a **public deCDN node** (`decdn-node` daemon) under a hardened systemd +unit. Two install methods (`decdn_node_install_method`): the default `release` +pulls a pinned GitHub Release tarball, and `manual` copies locally-built binaries +from the Ansible control machine — the pre-release path for when no release exists +yet. This is the repo's **primary** deployment (`playbooks/site.yml`); the anvil +devnet is separate internal tooling. ## What this role does (and does not) @@ -18,11 +21,22 @@ Per `decdn/adr/019-node-onboarding.md`, a node only serves paid traffic after ## Prerequisites -1. **A published release.** The role downloads - `decdn-node--.tar.gz` (and the `decdn` CLI) from - `github.com/decdn/decdn/releases`. Set `decdn_node_version` to a real - `v` release. *(No release exists yet — cut one with the upstream - `release.yml` workflow first.)* +1. **The binaries.** Pick an install method with `decdn_node_install_method`: + + - **`release`** (default) — the role downloads + `decdn-node--.tar.gz` (and the `decdn` CLI) from + `github.com/decdn/decdn/releases`. Set `decdn_node_version` to a real + `v` release. *(No release exists yet — either cut one with the + upstream `release.yml` workflow, or use `manual` below in the meantime.)* + - **`manual`** — the role copies the two binaries **verbatim** from the paths + you give it (`decdn_node_manual_bin_src` + `decdn_cli_manual_bin_src`) on the + Ansible control machine; it does *not* consult `decdn_node_target`, so you are + responsible for building for the host's architecture (the default target is + `x86_64-unknown-linux-gnu`). Build both `decdn-node` and `decdn` from the + upstream `decdn` repo, then set the two paths. `decdn_node_version` is **not** + required in this mode — but if it is set (e.g. left over from a `release` + deploy) the `--version` backstop still enforces it, so clear it when switching + to `manual` unless you intend that binary to report that exact version. 2. **Eth wallet (operator-provisioned).** Generate the node identity + eth keystore on the host, as the `decdn` user, into the data dir: @@ -46,9 +60,10 @@ Per `decdn/adr/019-node-onboarding.md`, a node only serves paid traffic after ## Required variables (set in `host_vars//`) -`decdn_node_version`, `decdn_rpc_url` (sensitive — may embed an API key; goes in the -git-ignored `secret.yml`, everything else in the committed `main.yml`), -`decdn_payment_channel_address`, `decdn_capacity_bond_address`, +`decdn_node_version` (`release` mode only) **or** `decdn_node_manual_bin_src` + +`decdn_cli_manual_bin_src` (`manual` mode), `decdn_rpc_url` (sensitive — may embed an +API key; goes in the git-ignored `secret.yml`, everything else in the committed +`main.yml`), `decdn_payment_channel_address`, `decdn_capacity_bond_address`, `decdn_slash_judge_address` (all `0x`+40-hex; SlashJudge non-zero), `decdn_region` (ISO 3166-1 alpha-2). Contract addresses/chain-id are protocol facts — source them from the deployment / an ADR, never guess. @@ -106,6 +121,17 @@ decdn node peers decdn appeal slash ``` -Upgrades: bump `decdn_node_version` (+ `decdn_node_sha256`) and re-deploy — the -version stamp triggers re-install + restart; the persistent `node.secret` and -`keystore.json` are untouched. +Upgrades (`release` mode): bump `decdn_node_version` (+ `decdn_node_sha256`) and +re-deploy — the version stamp triggers re-install + restart; the persistent +`node.secret` and `keystore.json` are untouched. + +Upgrades (`manual` mode): there is **no** version stamp — rebuild the binaries +locally and re-deploy. `copy` compares checksums and re-pushes (and restarts) only +when the control-machine binary actually changed. + +Switching methods: the version stamp is written only by `release` deploys, so a +`release` → `manual` → `release` round-trip that reuses the **same** +`decdn_node_version` will find a matching stamp and skip the re-download, leaving +the manual build in place. When returning to `release` after a manual detour, bump +`decdn_node_version` (or delete `/usr/local/lib/decdn/installed-version` on the +host) so the official tarball is re-fetched. diff --git a/ansible/roles/decdn_node/defaults/main.yml b/ansible/roles/decdn_node/defaults/main.yml index 3264f53..0e86762 100644 --- a/ansible/roles/decdn_node/defaults/main.yml +++ b/ansible/roles/decdn_node/defaults/main.yml @@ -3,8 +3,16 @@ # non-secret config (contract addresses, region, version) in the committed main.yml, # and the sensitive rpc_url in a git-ignored secret.yml (see secret.yml.example). -# --- Release to install (REQUIRED — a v GitHub Release must exist) --- -decdn_node_version: "" # e.g. "0.1.0" (asserted non-empty) +# --- Install method ----------------------------------------------------------- +# "release" (default): download the pinned GitHub Release tarballs (a v +# release must exist). "manual": copy locally-built binaries from the Ansible +# control machine — for pre-release / dev deploys where no release exists yet. +decdn_node_install_method: release # "release" | "manual" +decdn_node_manual_bin_src: "" # manual: control-machine path to the decdn-node daemon binary +decdn_cli_manual_bin_src: "" # manual: control-machine path to the decdn CLI binary + +# --- Release to install (REQUIRED in "release" mode — a v GitHub Release must exist) --- +decdn_node_version: "" # e.g. "0.1.0" (asserted non-empty in "release" mode) decdn_node_target: x86_64-unknown-linux-gnu decdn_node_sha256: "" # optional "abc123..." to pin the daemon tarball decdn_cli_sha256: "" # optional, for the decdn CLI tarball diff --git a/ansible/roles/decdn_node/meta/main.yml b/ansible/roles/decdn_node/meta/main.yml index 399dfa2..617a982 100644 --- a/ansible/roles/decdn_node/meta/main.yml +++ b/ansible/roles/decdn_node/meta/main.yml @@ -2,7 +2,9 @@ galaxy_info: role_name: decdn_node author: deCDN Contributors - description: Public deCDN node — release-tarball install, hardened systemd unit, loopback metrics/admin, public QUIC. + description: >- + Public deCDN node — release-tarball or manual local-binary install, hardened + systemd unit, loopback metrics/admin, public QUIC. license: MIT min_ansible_version: "2.15" galaxy_tags: [decdn, cdn, node, systemd, web3, debian] diff --git a/ansible/roles/decdn_node/tasks/install.yml b/ansible/roles/decdn_node/tasks/install.yml index 221579b..b866b8e 100644 --- a/ansible/roles/decdn_node/tasks/install.yml +++ b/ansible/roles/decdn_node/tasks/install.yml @@ -1,74 +1,107 @@ --- -# Install the pinned decdn-node daemon + decdn CLI from GitHub Release tarballs. -# Idempotent and upgrade-aware via a version stamp; re-extracts only on change. -# NOTE: requires a published v release (see role README). +# Install the decdn-node daemon + decdn CLI. Two methods, selected by +# decdn_node_install_method: +# "release" (default): download the pinned GitHub Release tarballs. Idempotent +# and upgrade-aware via a version stamp; re-extracts only on change. NOTE: +# requires a published v release (see role README). +# "manual": copy locally-built binaries from the Ansible control machine (for +# pre-release / dev deploys). copy's checksum idempotency replaces the stamp. +# A --version backstop runs in both modes. -- name: Build the release tarball list - ansible.builtin.set_fact: - decdn_release_tarballs: - - {archive: decdn-node, sha: "{{ decdn_node_sha256 }}"} - - {archive: decdn, sha: "{{ decdn_cli_sha256 }}"} +- name: Install from pinned GitHub Release tarballs + when: decdn_node_install_method == "release" + block: + - name: Build the release tarball list + ansible.builtin.set_fact: + decdn_release_tarballs: + - {archive: decdn-node, sha: "{{ decdn_node_sha256 }}"} + - {archive: decdn, sha: "{{ decdn_cli_sha256 }}"} -- name: Read the installed-version stamp - ansible.builtin.slurp: - src: /usr/local/lib/decdn/installed-version - register: decdn_stamp - failed_when: false - changed_when: false + - name: Read the installed-version stamp + ansible.builtin.slurp: + src: /usr/local/lib/decdn/installed-version + register: decdn_stamp + failed_when: false + changed_when: false -- name: Decide whether (re)install is needed - ansible.builtin.set_fact: - decdn_need_install: >- - {{ (decdn_stamp.content is not defined) - or ((decdn_stamp.content | b64decode | trim) != decdn_node_version) }} + - name: Decide whether (re)install is needed + ansible.builtin.set_fact: + decdn_need_install: >- + {{ (decdn_stamp.content is not defined) + or ((decdn_stamp.content | b64decode | trim) != decdn_node_version) }} -- name: Install decdn-node + decdn CLI {{ decdn_node_version }} - when: decdn_need_install - block: - - name: Download release tarballs - ansible.builtin.get_url: - url: "{{ decdn_node_release_base }}/v{{ decdn_node_version }}/\ - {{ item.archive }}-{{ decdn_node_version }}-{{ decdn_node_target }}.tar.gz" - dest: "/tmp/{{ item.archive }}-{{ decdn_node_version }}.tar.gz" - mode: "0644" - checksum: "{{ ('sha256:' + item.sha) if (item.sha | length > 0) else omit }}" - loop: "{{ decdn_release_tarballs }}" - loop_control: - label: "{{ item.archive }}" + - name: Install decdn-node + decdn CLI {{ decdn_node_version }} + when: decdn_need_install + block: + - name: Download release tarballs + ansible.builtin.get_url: + url: "{{ decdn_node_release_base }}/v{{ decdn_node_version }}/\ + {{ item.archive }}-{{ decdn_node_version }}-{{ decdn_node_target }}.tar.gz" + dest: "/tmp/{{ item.archive }}-{{ decdn_node_version }}.tar.gz" + mode: "0644" + checksum: "{{ ('sha256:' + item.sha) if (item.sha | length > 0) else omit }}" + loop: "{{ decdn_release_tarballs }}" + loop_control: + label: "{{ item.archive }}" - - name: Extract binaries into /usr/local/bin - ansible.builtin.unarchive: - src: "/tmp/{{ item.archive }}-{{ decdn_node_version }}.tar.gz" - dest: /usr/local/bin - remote_src: true - owner: root - group: root - mode: "0755" - loop: "{{ decdn_release_tarballs }}" - loop_control: - label: "{{ item.archive }}" - notify: Restart decdn-node + - name: Extract binaries into /usr/local/bin + ansible.builtin.unarchive: + src: "/tmp/{{ item.archive }}-{{ decdn_node_version }}.tar.gz" + dest: /usr/local/bin + remote_src: true + owner: root + group: root + mode: "0755" + loop: "{{ decdn_release_tarballs }}" + loop_control: + label: "{{ item.archive }}" + notify: Restart decdn-node - - name: Ensure the stamp directory exists - ansible.builtin.file: - path: /usr/local/lib/decdn - state: directory - owner: root - group: root - mode: "0755" + - name: Ensure the stamp directory exists + ansible.builtin.file: + path: /usr/local/lib/decdn + state: directory + owner: root + group: root + mode: "0755" + + - name: Record the installed version + ansible.builtin.copy: + dest: /usr/local/lib/decdn/installed-version + content: "{{ decdn_node_version }}\n" + owner: root + group: root + mode: "0644" - - name: Record the installed version +- name: Install locally-built binaries from the control machine + when: decdn_node_install_method == "manual" + block: + - name: Copy the decdn-node daemon + decdn CLI from the control machine ansible.builtin.copy: - dest: /usr/local/lib/decdn/installed-version - content: "{{ decdn_node_version }}\n" + src: "{{ item.src }}" + dest: "{{ item.dest }}" owner: root group: root - mode: "0644" + mode: "0755" + loop: + - {src: "{{ decdn_node_manual_bin_src }}", dest: "{{ decdn_bin }}"} + - {src: "{{ decdn_cli_manual_bin_src }}", dest: "{{ decdn_cli_bin }}"} + loop_control: + label: "{{ item.dest }}" + notify: Restart decdn-node # Integrity backstop: runs EVERY converge (not just on install), so a host whose # binary drifted from the pin is caught loudly rather than trusted via the stamp. -- name: Verify the installed decdn-node reports the pinned version +# rc != 0 is included because a custom failed_when suppresses the module's default +# non-zero-exit check — else a broken manual binary (rc != 0) would pass silently. +# In "release" mode decdn_node_version is always set (asserted), so the version +# match is enforced; in "manual" mode with no version it degrades to a liveness +# check (rc == 0). +- name: Verify the installed decdn-node runs (version backstop) ansible.builtin.command: "{{ decdn_bin }} --version" register: decdn_installed_version changed_when: false - failed_when: decdn_node_version not in decdn_installed_version.stdout + failed_when: >- + decdn_installed_version.rc != 0 + or (decdn_node_version | length > 0 + and decdn_node_version not in decdn_installed_version.stdout) diff --git a/ansible/roles/decdn_node/tasks/main.yml b/ansible/roles/decdn_node/tasks/main.yml index 327c05d..65d8c98 100644 --- a/ansible/roles/decdn_node/tasks/main.yml +++ b/ansible/roles/decdn_node/tasks/main.yml @@ -4,6 +4,14 @@ # roles/decdn_node/README.md. This role covers host prep + Phase-3 startup. # --- Fail loud on missing required config ------------------------------------- +- name: Require a valid install method + ansible.builtin.assert: + that: + - decdn_node_install_method in ["release", "manual"] + fail_msg: >- + decdn_node_install_method must be "release" (pinned GitHub Release tarballs) + or "manual" (copy locally-built binaries from the control machine). + - name: Require a pinned release version ansible.builtin.assert: that: @@ -11,6 +19,18 @@ fail_msg: >- decdn_node_version must be set to a published release (e.g. "0.1.0"). A matching v GitHub Release must exist. Set it in host_vars. + when: decdn_node_install_method == "release" + +- name: Require manual binary sources when install method is manual + ansible.builtin.assert: + that: + - decdn_node_manual_bin_src | length > 0 + - decdn_cli_manual_bin_src | length > 0 + fail_msg: >- + install_method "manual" requires decdn_node_manual_bin_src and + decdn_cli_manual_bin_src — control-machine paths to the locally-built + decdn-node daemon and decdn CLI binaries. Set them in host_vars. + when: decdn_node_install_method == "manual" - name: Require chain + contract configuration ansible.builtin.assert: From f46e8f77f847e595ec7ec2e642a9ceab16521115 Mon Sep 17 00:00:00 2001 From: Ant Somers Date: Sat, 11 Jul 2026 19:05:25 +0300 Subject: [PATCH 2/2] fix(decdn_node): clear release stamp on manual install; fix assert wording Address PR #18 review: - Manual mode now removes /usr/local/lib/decdn/installed-version so a release -> manual -> release round-trip with an unchanged version always re-fetches the official tarball (was a documented footgun; now automatic). README "Switching methods" simplified accordingly. - The manual-mode assert message referenced `install_method` instead of the real variable `decdn_node_install_method`. Co-Authored-By: Claude Opus 4.8 (1M context) --- ansible/roles/decdn_node/README.md | 11 +++++------ ansible/roles/decdn_node/tasks/install.yml | 9 +++++++++ ansible/roles/decdn_node/tasks/main.yml | 2 +- 3 files changed, 15 insertions(+), 7 deletions(-) diff --git a/ansible/roles/decdn_node/README.md b/ansible/roles/decdn_node/README.md index c823334..0f812d3 100644 --- a/ansible/roles/decdn_node/README.md +++ b/ansible/roles/decdn_node/README.md @@ -129,9 +129,8 @@ Upgrades (`manual` mode): there is **no** version stamp — rebuild the binaries locally and re-deploy. `copy` compares checksums and re-pushes (and restarts) only when the control-machine binary actually changed. -Switching methods: the version stamp is written only by `release` deploys, so a -`release` → `manual` → `release` round-trip that reuses the **same** -`decdn_node_version` will find a matching stamp and skip the re-download, leaving -the manual build in place. When returning to `release` after a manual detour, bump -`decdn_node_version` (or delete `/usr/local/lib/decdn/installed-version` on the -host) so the official tarball is re-fetched. +Switching methods: a `manual` deploy clears the release version stamp on the host, +so returning to `release` afterwards always re-fetches and re-installs the official +tarball — even when `decdn_node_version` is unchanged. (This is separate from the +`--version` backstop: a `decdn_node_version` left set in `manual` mode is still +enforced against the local build — see the `manual` prerequisite above.) diff --git a/ansible/roles/decdn_node/tasks/install.yml b/ansible/roles/decdn_node/tasks/install.yml index b866b8e..b2bb6a3 100644 --- a/ansible/roles/decdn_node/tasks/install.yml +++ b/ansible/roles/decdn_node/tasks/install.yml @@ -90,6 +90,15 @@ label: "{{ item.dest }}" notify: Restart decdn-node + # Manual mode never reads the release version stamp, but a leftover stamp from + # a prior release deploy would make a later release re-deploy of the SAME + # version skip the download (decdn_need_install stays false). Clear it so a + # release <-> manual round-trip always re-fetches the official tarball. + - name: Clear the release version stamp in manual mode + ansible.builtin.file: + path: /usr/local/lib/decdn/installed-version + state: absent + # Integrity backstop: runs EVERY converge (not just on install), so a host whose # binary drifted from the pin is caught loudly rather than trusted via the stamp. # rc != 0 is included because a custom failed_when suppresses the module's default diff --git a/ansible/roles/decdn_node/tasks/main.yml b/ansible/roles/decdn_node/tasks/main.yml index 65d8c98..481ac57 100644 --- a/ansible/roles/decdn_node/tasks/main.yml +++ b/ansible/roles/decdn_node/tasks/main.yml @@ -27,7 +27,7 @@ - decdn_node_manual_bin_src | length > 0 - decdn_cli_manual_bin_src | length > 0 fail_msg: >- - install_method "manual" requires decdn_node_manual_bin_src and + decdn_node_install_method "manual" requires decdn_node_manual_bin_src and decdn_cli_manual_bin_src — control-machine paths to the locally-built decdn-node daemon and decdn CLI binaries. Set them in host_vars. when: decdn_node_install_method == "manual"