From 115ed506a3cada3d6db8f868017063c4e88e785b Mon Sep 17 00:00:00 2001 From: Alper Gundogdu Date: Wed, 9 Sep 2026 12:47:30 +0100 Subject: [PATCH 1/3] feat(decdn_node): validate manual binaries are ELF + drain-safe stop timeout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two independent hardening changes to the decdn_node role. 1. decdn_release_target_dir — a manual-mode knob pointing at the Cargo target/release dir. The role derives both binary paths from it, falls back to the cross/`--target` output dir (target//release), and validates on the control machine (via `file`) that both binaries are ELF for decdn_node_target BEFORE copying them to the node. This catches the common footgun of a host-native `cargo build --release` on macOS (a Mach-O the Linux node can't exec) with an actionable error instead of an opaque first-start crash. Explicit *_manual_bin_src paths remain the escape hatch (copied verbatim, not format-checked) so test stubs still work. Reuses the existing decdn_node_target for both the fallback triple and the expected arch — no new arch knob. 2. decdn_node_stop_timeout_sec (default 300) replaces the hardcoded TimeoutStopSec=30 in the unit. SIGTERM enters the same graceful drain as `decdn node drain` (await in-flight streams, flush settlement + receipt/audit tail); the process exits the instant that finishes, so the timeout is only the cap. 30s risked a SIGKILL truncating an in-flight drain — dropping paid deliveries and possibly the audit tail. Adds a molecule `validation` negative case proving the ELF format assert rejects a non-ELF binary in dir-mode. README updated. Co-Authored-By: Claude Opus 4.8 --- ansible/molecule/validation/converge.yml | 46 ++++++- ansible/roles/decdn_node/README.md | 37 ++++-- ansible/roles/decdn_node/defaults/main.yml | 17 +++ ansible/roles/decdn_node/tasks/main.yml | 123 ++++++++++++++++-- .../templates/decdn-node.service.j2 | 10 +- 5 files changed, 208 insertions(+), 25 deletions(-) diff --git a/ansible/molecule/validation/converge.yml b/ansible/molecule/validation/converge.yml index c13d716..7cd6ac7 100644 --- a/ansible/molecule/validation/converge.yml +++ b/ansible/molecule/validation/converge.yml @@ -63,6 +63,46 @@ decdn_rejected: "{{ decdn_rejected + ['cross-field'] }}" when: ansible_failed_task.name is match('^Validate optional') + # --- Case: binary-format (dir-mode ships a non-ELF binary) -------------------- + # decdn_release_target_dir mode validates on the CONTROL MACHINE that the resolved + # binaries are ELF for decdn_node_target before shipping them. Point it at a dir + # holding the (non-ELF Python) stub named as the two binaries and confirm the + # format assert rejects it — the "built a macOS Mach-O and shipped it" guard. + # Staged + validated on the controller (delegate_to: localhost), so it aborts + # before any host mutation, like the other assert-only cases. + - name: "Case binary-format — dir-mode resolves a non-ELF binary" + vars: + _decdn_badbin_dir: >- + {{ lookup('ansible.builtin.env', 'MOLECULE_PROJECT_DIRECTORY') }}/.molecule-badbin/target/release + block: + - name: Stage a non-ELF binary pair on the control machine + ansible.builtin.copy: + src: "{{ stub_bin }}" + dest: "{{ _decdn_badbin_dir }}/{{ item }}" + mode: "0755" + loop: [decdn-node, decdn] + delegate_to: localhost + become: false + - name: Run decdn_node pointed at the non-ELF target dir + ansible.builtin.include_role: + name: decdn_node + vars: + decdn_node_manual_bin_src: "" + decdn_cli_manual_bin_src: "" + decdn_release_target_dir: "{{ _decdn_badbin_dir }}" + rescue: + - name: Record binary-format rejection (only if the ELF assert failed) + ansible.builtin.set_fact: + decdn_rejected: "{{ decdn_rejected + ['binary-format'] }}" + when: ansible_failed_task.name is match('^Require both binaries to be ELF') + always: + - name: Remove the staged non-ELF binaries + ansible.builtin.file: + path: "{{ lookup('ansible.builtin.env', 'MOLECULE_PROJECT_DIRECTORY') }}/.molecule-badbin" + state: absent + delegate_to: localhost + become: false + # --- Case: range (numeric but below the daemon's minimum) --------------------- - name: "Case range — event_poll_interval_ms below 250" block: @@ -354,6 +394,6 @@ the daemon and crash-loop it at config load). vars: _decdn_expected: - ["cross-field", "range", "bounded-range", "shape", "bool", "enum", "dict", - "float", "list", "string", "env-gate", "env-extra", "env-content", - "config-gate"] + ["cross-field", "binary-format", "range", "bounded-range", "shape", "bool", + "enum", "dict", "float", "list", "string", "env-gate", "env-extra", + "env-content", "config-gate"] diff --git a/ansible/roles/decdn_node/README.md b/ansible/roles/decdn_node/README.md index e35a316..831a185 100644 --- a/ansible/roles/decdn_node/README.md +++ b/ansible/roles/decdn_node/README.md @@ -55,15 +55,29 @@ Per the deCDN node-onboarding ADR (019), a node only serves paid traffic after from the target host; override `decdn_node_release_base` for a mirror. **No upstream release exists yet**, so this mode currently has nothing to fetch — the role's assert says so rather than surfacing a bare 404. - - **`manual`** (current default) — 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. + - **`manual`** (current default) — the role copies the two binaries from the + Ansible control machine. Point it at them **either** way: + - **`decdn_release_target_dir`** (recommended) — the Cargo `target/release` + dir. The role derives both binary paths from it, **falls back** to the + cross / `--target` output dir (`target/{{ decdn_node_target }}/release`) + when the plain dir lacks them, and — crucially — **validates on the control + machine that both binaries are ELF for `decdn_node_target`** before shipping + them. A host-native `cargo build --release` on a non-Linux control machine + (macOS) silently produces a Mach-O the Linux node cannot exec; this catches + that with an actionable error instead of an opaque first-start failure. + Cross-compile for the node: `cross build --release --target + {{ decdn_node_target }}` (or `cargo build --release --target …`). + - **`decdn_node_manual_bin_src` + `decdn_cli_manual_bin_src`** — explicit paths, + copied **verbatim**. This is the escape hatch: the role trusts them and does + *not* consult `decdn_node_target` (so a test-harness stub that is legitimately + not an ELF for the node's arch still works). You own building for the host's + architecture (default target `x86_64-unknown-linux-gnu`). + + Build both `decdn-node` and `decdn` from the upstream `decdn` repo. + `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, directly into the data dir. `key-gen` @@ -101,8 +115,9 @@ Per the deCDN node-onboarding ADR (019), a node only serves paid traffic after ## Required variables (set in `host_vars//`) -`decdn_node_version` (`release` mode only) **or** `decdn_node_manual_bin_src` + -`decdn_cli_manual_bin_src` (`manual` mode), an RPC endpoint (sensitive — may embed an +`decdn_node_version` (`release` mode only) **or**, in `manual` mode, either +`decdn_release_target_dir` or `decdn_node_manual_bin_src` + `decdn_cli_manual_bin_src` +(see [Prerequisites](#prerequisites) above), an RPC endpoint (sensitive — may embed an API key; provision it on the host or set `decdn_rpc_url` in the git-ignored `secret.yml` — see [Secrets](#secrets)), `decdn_region` (ISO 3166-1 alpha-2), and **four** contract addresses (all `0x`+40-hex, none the zero address): diff --git a/ansible/roles/decdn_node/defaults/main.yml b/ansible/roles/decdn_node/defaults/main.yml index 6be20ee..9795e82 100644 --- a/ansible/roles/decdn_node/defaults/main.yml +++ b/ansible/roles/decdn_node/defaults/main.yml @@ -17,6 +17,15 @@ decdn_node_install_method: manual # "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 +# Alternative to the two explicit *_manual_bin_src paths: point at the control- +# machine `target/release` dir and the role derives BOTH binary paths from it. It +# also (a) falls back to the cross / `--target` output dir +# (target/{{ decdn_node_target }}/release) when the plain dir lacks the binaries, +# and (b) validates on the control machine that the binaries are ELF for +# decdn_node_target BEFORE shipping them — because a host-native `cargo build +# --release` on a non-Linux control machine (macOS) silently produces a Mach-O the +# Linux node cannot exec. Leave "" to use the explicit paths above instead. +decdn_release_target_dir: "" # --- Release to install (REQUIRED in "release" mode — a v GitHub Release must exist) --- decdn_node_version: "" # e.g. "0.1.1" (asserted non-empty in "release" mode) @@ -352,3 +361,11 @@ decdn_node_generate_keystore: false decdn_cache_dir: /var/lib/decdn/cache decdn_bin: /usr/local/bin/decdn-node decdn_cli_bin: /usr/local/bin/decdn + +# systemd TimeoutStopSec for the node unit (seconds). SIGTERM triggers a full +# graceful drain (await in-flight streams, flush settlement + receipt/audit tail); +# the process exits the instant that finishes, so this is only the CAP before a +# SIGKILL. It is generous on purpose — a SIGKILL mid-drain truncates paid, in-flight +# deliveries (a money-losing cut), so prefer waiting over cutting. See +# templates/decdn-node.service.j2. +decdn_node_stop_timeout_sec: 300 diff --git a/ansible/roles/decdn_node/tasks/main.yml b/ansible/roles/decdn_node/tasks/main.yml index 21b2e3f..79ed759 100644 --- a/ansible/roles/decdn_node/tasks/main.yml +++ b/ansible/roles/decdn_node/tasks/main.yml @@ -40,16 +40,121 @@ it off means the tarballs are taken on trust. 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: >- - 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. +# Resolve + validate the manual binary sources on the CONTROL MACHINE before they +# are copied to the node. Two ways to point at them: decdn_release_target_dir (a +# target/release dir the role searches — plus the cross/`--target` output dir +# target//release as a fallback — for a dir holding both binaries), or the +# two explicit *_manual_bin_src paths (which win outright). Whatever is resolved is +# then checked to be ELF for decdn_node_target: a host-native `cargo build --release` +# on a non-Linux control machine (macOS) yields a Mach-O the Linux node cannot exec, +# and copying it would only fail opaquely at first start. +- name: Resolve and validate the manual binary sources when: decdn_node_install_method == "manual" + block: + - name: Require a manual binary source (target dir or explicit paths) + ansible.builtin.assert: + that: + - >- + (decdn_release_target_dir | length > 0) + or (decdn_node_manual_bin_src | length > 0 + and decdn_cli_manual_bin_src | length > 0) + fail_msg: >- + decdn_node_install_method "manual" needs EITHER decdn_release_target_dir + (the control-machine target/release dir — the role derives + validates both + binaries from it) OR both decdn_node_manual_bin_src and + decdn_cli_manual_bin_src (explicit control-machine paths). Set one in host_vars. + + - name: Build the candidate source directories + ansible.builtin.set_fact: + _decdn_bin_candidate_dirs: >- + {{ + [decdn_release_target_dir, + (decdn_release_target_dir | dirname) ~ '/' ~ decdn_node_target ~ '/release'] + if decdn_release_target_dir | length > 0 else [] + }} + + - name: Stat the daemon binary in each candidate directory + ansible.builtin.stat: + path: "{{ item }}/decdn-node" + register: _decdn_node_bin_stat + loop: "{{ _decdn_bin_candidate_dirs }}" + delegate_to: localhost + become: false + when: decdn_release_target_dir | length > 0 + + - name: Select the first candidate directory that holds the daemon binary + ansible.builtin.set_fact: + _decdn_resolved_dir: >- + {{ (_decdn_node_bin_stat.results + | selectattr('stat.exists') + | map(attribute='item') + | first) | default('', true) }} + when: decdn_release_target_dir | length > 0 + + - name: Fail if no candidate directory held the daemon binary + ansible.builtin.assert: + that: + - _decdn_resolved_dir | length > 0 + fail_msg: >- + No decdn-node binary found under decdn_release_target_dir + ({{ decdn_release_target_dir }}) or its cross output dir + ({{ (decdn_release_target_dir | dirname) ~ '/' ~ decdn_node_target ~ '/release' }}). + Build it first — `cargo build --release` (native Linux control machine) or, + cross-compiling for the node, `cross build --release --target {{ decdn_node_target }}`. + when: decdn_release_target_dir | length > 0 + + # Derive the effective source paths: from the resolved dir when a target dir was + # given, else the operator's explicit paths (which the copy step already reads). + - name: Derive the effective binary source paths + ansible.builtin.set_fact: + decdn_node_manual_bin_src: >- + {{ (_decdn_resolved_dir ~ '/decdn-node') + if decdn_release_target_dir | length > 0 else decdn_node_manual_bin_src }} + decdn_cli_manual_bin_src: >- + {{ (_decdn_resolved_dir ~ '/decdn') + if decdn_release_target_dir | length > 0 else decdn_cli_manual_bin_src }} + + # Format validation (and the cross-dir fallback above) apply ONLY to the + # decdn_release_target_dir workflow — the footgun is "I ran `cargo build + # --release` into target/release and pointed the dir here" on a non-Linux box. + # Explicit *_manual_bin_src paths are the deliberate escape hatch: the role + # trusts them and does not consult decdn_node_target (a stub binary in a test + # harness, for instance, is legitimately not an ELF for the node's arch). + - name: Identify the on-disk format of both resolved binaries + ansible.builtin.command: "file -b {{ item }}" + register: _decdn_bin_file + changed_when: false + loop: + - "{{ decdn_node_manual_bin_src }}" + - "{{ decdn_cli_manual_bin_src }}" + loop_control: + label: "{{ item }}" + delegate_to: localhost + become: false + when: decdn_release_target_dir | length > 0 + + - name: Require both binaries to be ELF for the node's target architecture + ansible.builtin.assert: + that: + - "'ELF' in item.stdout" + - _decdn_expect_arch in item.stdout + fail_msg: >- + {{ item.item }} is "{{ item.stdout }}" — not an ELF {{ _decdn_expect_arch }} + binary for {{ decdn_node_target }}. A host-native build on a non-Linux control + machine (e.g. `cargo build --release` on macOS) produces a Mach-O the node + cannot exec. Cross-compile for the node: `cross build --release --target + {{ decdn_node_target }}` (outputs to target/{{ decdn_node_target }}/release), + or `cargo build --release --target {{ decdn_node_target }}`. + quiet: true + loop: "{{ _decdn_bin_file.results | default([]) }}" + loop_control: + label: "{{ item.item }}" + vars: + _decdn_arch_file_tokens: + x86_64-unknown-linux-gnu: x86-64 + aarch64-unknown-linux-gnu: aarch64 + _decdn_expect_arch: "{{ _decdn_arch_file_tokens[decdn_node_target] | default('x86-64') }}" + when: decdn_release_target_dir | length > 0 - name: Validate decdn_node_generate_keystore is a real boolean ansible.builtin.assert: diff --git a/ansible/roles/decdn_node/templates/decdn-node.service.j2 b/ansible/roles/decdn_node/templates/decdn-node.service.j2 index 5feda3a..15b6148 100644 --- a/ansible/roles/decdn_node/templates/decdn-node.service.j2 +++ b/ansible/roles/decdn_node/templates/decdn-node.service.j2 @@ -34,9 +34,15 @@ ExecStart={{ decdn_bin }} run \ # diff touched, so reload is an operator's deliberate call. ExecReload=/bin/kill -HUP $MAINPID -# Graceful stop so the node can drain/flush. SIGTERM is its shutdown signal. +# Graceful stop so the node can drain/flush. SIGTERM is its shutdown signal, and +# it enters the SAME teardown as `decdn node drain`: it stops accepting work, awaits +# in-flight streams to zero, then flushes settlement + the receipt/audit tail before +# exiting. The process exits the instant that drain completes, so systemd proceeds +# immediately — TimeoutStopSec is only the CAP. Keep it generous: a SIGKILL at the +# cap would truncate an in-flight drain, dropping paid deliveries and possibly the +# audit tail (a money-losing cut). Hence the 300s default, not systemd's 90s norm. KillSignal=SIGTERM -TimeoutStopSec=30 +TimeoutStopSec={{ decdn_node_stop_timeout_sec }} Restart=always RestartSec=5 From 71fc3dc9e62200d0330dc9de34d90987a875665c Mon Sep 17 00:00:00 2001 From: Alper Gundogdu Date: Wed, 9 Sep 2026 12:55:24 +0100 Subject: [PATCH 2/3] test(molecule): restore baseline bin sources after binary-format case The role's derive-paths set_fact leaves decdn_node_manual_bin_src as a host fact (outranking the play var) pointing at the removed badbin dir; reset it in always so the later config-gate case (which reaches the copy step and does not re-pass the path) resolves the stub again. Co-Authored-By: Claude Opus 4.8 --- ansible/molecule/validation/converge.yml | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/ansible/molecule/validation/converge.yml b/ansible/molecule/validation/converge.yml index 7cd6ac7..97fca54 100644 --- a/ansible/molecule/validation/converge.yml +++ b/ansible/molecule/validation/converge.yml @@ -102,6 +102,15 @@ state: absent delegate_to: localhost become: false + # The role's derive-paths set_fact left decdn_node_manual_bin_src pointing at + # the (now-removed) badbin dir as a HOST FACT, which outranks the play var. + # Reset the baseline so later cases — notably config-gate, which reaches the + # copy step and does NOT re-pass the path — resolve the stub again. + - name: Restore the baseline binary sources for the remaining cases + ansible.builtin.set_fact: + decdn_node_manual_bin_src: "{{ stub_bin }}" + decdn_cli_manual_bin_src: "{{ stub_bin }}" + decdn_release_target_dir: "" # --- Case: range (numeric but below the daemon's minimum) --------------------- - name: "Case range — event_poll_interval_ms below 250" From 2f6f3850756beef20b0f271b920ca5721613af15 Mon Sep 17 00:00:00 2001 From: Alper Gundogdu Date: Wed, 9 Sep 2026 13:13:51 +0100 Subject: [PATCH 3/3] fix(decdn_node): explicit-path precedence, both-binary select, robust file check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address review feedback on the manual-binary resolution: - Explicit *_manual_bin_src (when BOTH set) now take precedence over a fleet-wide decdn_release_target_dir, so a per-host override works without blanking the group var; a partial (one-of-two) explicit override is now rejected loud instead of silently ignored. - Candidate-dir selection requires BOTH decdn-node AND decdn present, so a dir with only the daemon yields to the cross dir that has the pair. - `file` format check uses argv + `--` (robust to spaces / leading dash). - molecule: create the staging dir before copying the stub pair (copy does not create parent dirs — this was the failing binary-format case). Co-Authored-By: Claude Opus 4.8 --- ansible/molecule/validation/converge.yml | 7 ++ ansible/roles/decdn_node/README.md | 5 +- ansible/roles/decdn_node/tasks/main.yml | 110 +++++++++++++++-------- 3 files changed, 86 insertions(+), 36 deletions(-) diff --git a/ansible/molecule/validation/converge.yml b/ansible/molecule/validation/converge.yml index 97fca54..081eb3f 100644 --- a/ansible/molecule/validation/converge.yml +++ b/ansible/molecule/validation/converge.yml @@ -75,6 +75,13 @@ _decdn_badbin_dir: >- {{ lookup('ansible.builtin.env', 'MOLECULE_PROJECT_DIRECTORY') }}/.molecule-badbin/target/release block: + - name: Create the staging directory on the control machine + ansible.builtin.file: + path: "{{ _decdn_badbin_dir }}" + state: directory + mode: "0755" + delegate_to: localhost + become: false - name: Stage a non-ELF binary pair on the control machine ansible.builtin.copy: src: "{{ stub_bin }}" diff --git a/ansible/roles/decdn_node/README.md b/ansible/roles/decdn_node/README.md index 831a185..a128ae0 100644 --- a/ansible/roles/decdn_node/README.md +++ b/ansible/roles/decdn_node/README.md @@ -71,7 +71,10 @@ Per the deCDN node-onboarding ADR (019), a node only serves paid traffic after copied **verbatim**. This is the escape hatch: the role trusts them and does *not* consult `decdn_node_target` (so a test-harness stub that is legitimately not an ELF for the node's arch still works). You own building for the host's - architecture (default target `x86_64-unknown-linux-gnu`). + architecture (default target `x86_64-unknown-linux-gnu`). Set **both together + or neither** (a partial pair is rejected); when both are set they **take + precedence** over `decdn_release_target_dir`, so a host can override a + fleet-wide target dir without having to blank it. Build both `decdn-node` and `decdn` from the upstream `decdn` repo. `decdn_node_version` is **not** required in this mode — but if it is set (e.g. diff --git a/ansible/roles/decdn_node/tasks/main.yml b/ansible/roles/decdn_node/tasks/main.yml index 79ed759..04c72c1 100644 --- a/ansible/roles/decdn_node/tasks/main.yml +++ b/ansible/roles/decdn_node/tasks/main.yml @@ -41,36 +41,57 @@ when: decdn_node_install_method == "release" # Resolve + validate the manual binary sources on the CONTROL MACHINE before they -# are copied to the node. Two ways to point at them: decdn_release_target_dir (a -# target/release dir the role searches — plus the cross/`--target` output dir -# target//release as a fallback — for a dir holding both binaries), or the -# two explicit *_manual_bin_src paths (which win outright). Whatever is resolved is -# then checked to be ELF for decdn_node_target: a host-native `cargo build --release` -# on a non-Linux control machine (macOS) yields a Mach-O the Linux node cannot exec, -# and copying it would only fail opaquely at first start. +# are copied to the node. Two ways to point at them, in precedence order: +# 1. both explicit *_manual_bin_src paths — the escape hatch, copied verbatim and +# NOT format-checked (a test-harness stub is legitimately not an ELF for the +# node's arch). Both must be set together; they override a fleet-wide +# decdn_release_target_dir, so a per-host override works without blanking it. +# 2. decdn_release_target_dir — a target/release dir the role searches (plus the +# cross/`--target` output dir target//release as a fallback) for a dir +# holding BOTH binaries, then checks they are ELF for decdn_node_target. A +# host-native `cargo build --release` on a non-Linux control machine (macOS) +# yields a Mach-O the Linux node cannot exec; this catches it at deploy time +# rather than as an opaque first-start crash. - name: Resolve and validate the manual binary sources when: decdn_node_install_method == "manual" block: - - name: Require a manual binary source (target dir or explicit paths) + - name: Classify the manual binary source (explicit paths vs target dir) + ansible.builtin.set_fact: + _decdn_both_explicit: "{{ decdn_node_manual_bin_src | length > 0 and decdn_cli_manual_bin_src | length > 0 }}" + _decdn_any_explicit: "{{ decdn_node_manual_bin_src | length > 0 or decdn_cli_manual_bin_src | length > 0 }}" + + # Dir-mode (derive + fallback + ELF validation) applies only when a target dir is + # set AND the explicit pair has not overridden it. + - name: Decide whether target-dir resolution applies + ansible.builtin.set_fact: + _decdn_use_dir: "{{ decdn_release_target_dir | length > 0 and not (_decdn_both_explicit | bool) }}" + + - name: Require a manual binary source (target dir or BOTH explicit paths) ansible.builtin.assert: that: - - >- - (decdn_release_target_dir | length > 0) - or (decdn_node_manual_bin_src | length > 0 - and decdn_cli_manual_bin_src | length > 0) + - decdn_release_target_dir | length > 0 or _decdn_both_explicit | bool fail_msg: >- decdn_node_install_method "manual" needs EITHER decdn_release_target_dir (the control-machine target/release dir — the role derives + validates both binaries from it) OR both decdn_node_manual_bin_src and decdn_cli_manual_bin_src (explicit control-machine paths). Set one in host_vars. + - name: Reject a partial explicit binary override + ansible.builtin.assert: + that: + - not (_decdn_any_explicit | bool and not _decdn_both_explicit | bool) + fail_msg: >- + Set BOTH decdn_node_manual_bin_src and decdn_cli_manual_bin_src, or neither — + exactly one was given, an incomplete override that would silently leave the + other binary to decdn_release_target_dir. + - name: Build the candidate source directories ansible.builtin.set_fact: _decdn_bin_candidate_dirs: >- {{ [decdn_release_target_dir, (decdn_release_target_dir | dirname) ~ '/' ~ decdn_node_target ~ '/release'] - if decdn_release_target_dir | length > 0 else [] + if _decdn_use_dir | bool else [] }} - name: Stat the daemon binary in each candidate directory @@ -78,50 +99,69 @@ path: "{{ item }}/decdn-node" register: _decdn_node_bin_stat loop: "{{ _decdn_bin_candidate_dirs }}" + loop_control: + label: "{{ item }}" + delegate_to: localhost + become: false + when: _decdn_use_dir | bool + + - name: Stat the CLI binary in each candidate directory + ansible.builtin.stat: + path: "{{ item }}/decdn" + register: _decdn_cli_bin_stat + loop: "{{ _decdn_bin_candidate_dirs }}" + loop_control: + label: "{{ item }}" delegate_to: localhost become: false - when: decdn_release_target_dir | length > 0 + when: _decdn_use_dir | bool - - name: Select the first candidate directory that holds the daemon binary + # Pick the first candidate holding BOTH binaries (order-preserving), so a plain + # target/release with only decdn-node correctly yields to a cross dir that has + # the pair, and a dir with the daemon but no CLI is not chosen. + - name: Select the first candidate directory that holds both binaries ansible.builtin.set_fact: _decdn_resolved_dir: >- - {{ (_decdn_node_bin_stat.results - | selectattr('stat.exists') - | map(attribute='item') + {{ (_decdn_bin_candidate_dirs + | select('in', _decdn_node_bin_stat.results | selectattr('stat.exists') | map(attribute='item') | list) + | select('in', _decdn_cli_bin_stat.results | selectattr('stat.exists') | map(attribute='item') | list) | first) | default('', true) }} - when: decdn_release_target_dir | length > 0 + when: _decdn_use_dir | bool - - name: Fail if no candidate directory held the daemon binary + - name: Fail if no candidate directory held both binaries ansible.builtin.assert: that: - _decdn_resolved_dir | length > 0 fail_msg: >- - No decdn-node binary found under decdn_release_target_dir + No decdn-node + decdn pair found under decdn_release_target_dir ({{ decdn_release_target_dir }}) or its cross output dir ({{ (decdn_release_target_dir | dirname) ~ '/' ~ decdn_node_target ~ '/release' }}). - Build it first — `cargo build --release` (native Linux control machine) or, + Build both — `cargo build --release` (native Linux control machine) or, cross-compiling for the node, `cross build --release --target {{ decdn_node_target }}`. - when: decdn_release_target_dir | length > 0 + when: _decdn_use_dir | bool - # Derive the effective source paths: from the resolved dir when a target dir was - # given, else the operator's explicit paths (which the copy step already reads). + # Derive the effective source paths from the resolved dir in dir-mode; otherwise + # keep the operator's explicit paths (which the copy step reads unchanged). - name: Derive the effective binary source paths ansible.builtin.set_fact: decdn_node_manual_bin_src: >- {{ (_decdn_resolved_dir ~ '/decdn-node') - if decdn_release_target_dir | length > 0 else decdn_node_manual_bin_src }} + if _decdn_use_dir | bool else decdn_node_manual_bin_src }} decdn_cli_manual_bin_src: >- {{ (_decdn_resolved_dir ~ '/decdn') - if decdn_release_target_dir | length > 0 else decdn_cli_manual_bin_src }} + if _decdn_use_dir | bool else decdn_cli_manual_bin_src }} - # Format validation (and the cross-dir fallback above) apply ONLY to the - # decdn_release_target_dir workflow — the footgun is "I ran `cargo build + # Format validation applies ONLY to dir-mode — the footgun is "I ran `cargo build # --release` into target/release and pointed the dir here" on a non-Linux box. - # Explicit *_manual_bin_src paths are the deliberate escape hatch: the role - # trusts them and does not consult decdn_node_target (a stub binary in a test - # harness, for instance, is legitimately not an ELF for the node's arch). + # Explicit paths are trusted verbatim (see the precedence note above). argv + `--` + # keeps the check robust for paths with spaces or a leading dash. - name: Identify the on-disk format of both resolved binaries - ansible.builtin.command: "file -b {{ item }}" + ansible.builtin.command: + argv: + - file + - -b + - -- + - "{{ item }}" register: _decdn_bin_file changed_when: false loop: @@ -131,7 +171,7 @@ label: "{{ item }}" delegate_to: localhost become: false - when: decdn_release_target_dir | length > 0 + when: _decdn_use_dir | bool - name: Require both binaries to be ELF for the node's target architecture ansible.builtin.assert: @@ -154,7 +194,7 @@ x86_64-unknown-linux-gnu: x86-64 aarch64-unknown-linux-gnu: aarch64 _decdn_expect_arch: "{{ _decdn_arch_file_tokens[decdn_node_target] | default('x86-64') }}" - when: decdn_release_target_dir | length > 0 + when: _decdn_use_dir | bool - name: Validate decdn_node_generate_keystore is a real boolean ansible.builtin.assert: