diff --git a/ansible/molecule/default/converge.yml b/ansible/molecule/default/converge.yml index b0e18b5..5d1ee62 100644 --- a/ansible/molecule/default/converge.yml +++ b/ansible/molecule/default/converge.yml @@ -27,6 +27,26 @@ # it (verify.yml asserts owner/group/mode). Keep in sync with verify.yml. decdn_cache_origin_kind: "fs" decdn_cache_origin_path: "/var/lib/decdn/origin" + # Operator-facing tuning knobs (#29 + siblings): exercise the new emission + + # validation. Chosen to cover the forms a naive template would get wrong — an + # explicit 0 (must EMIT, not be dropped), bare-integer Bytes/Percent (never + # "2MiB"/"4.0"), a bool (rendered lowercase via `| lower`), and a string list. + # verify.yml asserts each renders with the exact value AND type. Keep in sync. + decdn_node_to_node_pull_through_enabled: true + decdn_gc_interval_sec: 0 + decdn_pull_ahead_bytes: 2097152 + decdn_pull_share_ratio_percent: 400 + decdn_enable_0rtt: false + decdn_delivery_floor: 0 + decdn_settlement_auto_threshold_micro_usdc: 50000000 + # Two entries each so the {% for %} comma-join branch (loop.last) is exercised — + # a single-element list never renders the separator. verify.yml asserts order. + decdn_relay_urls: + - "https://relay1.example.invalid:443" + - "https://relay2.example.invalid:443" + decdn_pinned_hashes: + - "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + - "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" # Exercise baseline's named-sudo-users feature (see pre_tasks below). Throwaway # keys, generated for this test only — public keys, not credentials. Two users # on purpose: `alice.smith` has a '.' (so the sudoers.d filename-sanitize maps it diff --git a/ansible/molecule/default/verify.yml b/ansible/molecule/default/verify.yml index 9a84d34..025c56d 100644 --- a/ansible/molecule/default/verify.yml +++ b/ansible/molecule/default/verify.yml @@ -96,6 +96,39 @@ if origin.get("kind") != "fs" or origin.get("path") != "/var/lib/decdn/origin": print("node.toml [cache.origin] mismatch (got):", origin, file=sys.stderr) sys.exit(1) + # Operator-facing tuning knobs (#29 + siblings), set in converge.yml. Assert + # exact value AND type: a dropped `0`, a stringified Bytes, or a bool rendered + # as an int/string are real regressions a loose `==` would miss (in Python + # `0 == False` and `1 == True`, so isinstance guards are load-bearing here). + def _is_int(x): + return isinstance(x, int) and not isinstance(x, bool) + cache, net, pay, bc = (d.get(s, {}) for s in ("cache", "network", "payment", "blockchain")) + knob_checks = [ + ("cache.node_to_node_pull_through_enabled", + cache.get("node_to_node_pull_through_enabled") is True), + ("cache.gc_interval_sec (explicit 0 must emit)", + _is_int(cache.get("gc_interval_sec")) and cache.get("gc_interval_sec") == 0), + ("cache.pull_ahead_bytes (bare-int Bytes)", + _is_int(cache.get("pull_ahead_bytes")) and cache.get("pull_ahead_bytes") == 2097152), + ("cache.pull_share_ratio_percent (bare-int Percent)", + _is_int(cache.get("pull_share_ratio_percent")) and cache.get("pull_share_ratio_percent") == 400), + # Two-element lists in exact order — exercises the comma-join branch. + ("cache.pinned_hashes", cache.get("pinned_hashes") == ["a" * 64, "b" * 64]), + ("network.relay_urls", + net.get("relay_urls") == ["https://relay1.example.invalid:443", + "https://relay2.example.invalid:443"]), + ("network.enable_0rtt", net.get("enable_0rtt") is False), + ("payment.delivery_floor (explicit 0 must emit)", + _is_int(pay.get("delivery_floor")) and pay.get("delivery_floor") == 0), + ("blockchain.settlement_auto_threshold_micro_usdc", + _is_int(bc.get("settlement_auto_threshold_micro_usdc")) + and bc.get("settlement_auto_threshold_micro_usdc") == 50000000), + ] + knob_bad = [name for name, ok in knob_checks if not ok] + if knob_bad: + print("node.toml tuning-knob mismatch:", knob_bad, file=sys.stderr) + print(" cache=", cache, "\n network=", net, "\n payment=", pay, file=sys.stderr) + sys.exit(1) - name: Assert node.toml is valid TOML and renders the expected content ansible.builtin.command: diff --git a/ansible/molecule/validation/converge.yml b/ansible/molecule/validation/converge.yml new file mode 100644 index 0000000..515ce82 --- /dev/null +++ b/ansible/molecule/validation/converge.yml @@ -0,0 +1,130 @@ +--- +# NEGATIVE-path scenario: proves the role's fail-loud tuning-knob validation actually +# REJECTS bad values (AGENTS.md hard-rule 4), so a regression that weakens an assert +# fails CI. Each case runs decdn_node with a VALID required config plus ONE bad +# optional knob, wrapped in block/rescue. The role aborts at its validation asserts +# (which run before any host mutation), so cases don't interfere and no service starts. +# +# The rescue records a case as "rejected" ONLY when the failing task is one of the +# role's own "Validate optional ..." asserts (matched on ansible_failed_task.name). +# That is what makes this a real regression guard: if a bad value slipped PAST +# validation, the role would run on and fail later at a differently-named task (e.g. +# the keystore gate) — which is NOT counted, so the final assert catches the miss. +- name: Converge (expect validation failures) + hosts: all + become: true + vars: + stub_bin: "{{ lookup('ansible.builtin.env', 'MOLECULE_PROJECT_DIRECTORY') }}/molecule/default/files/decdn-node-stub" + # Valid baseline so execution reaches the OPTIONAL-knob asserts (required-var + # asserts all pass); each case below overrides exactly one optional knob. + decdn_node_install_method: manual + decdn_node_manual_bin_src: "{{ stub_bin }}" + decdn_cli_manual_bin_src: "{{ stub_bin }}" + decdn_rpc_url: "https://rpc.example.invalid/" + decdn_region: "US" + decdn_payment_channel_address: "0x1111111111111111111111111111111111111111" + decdn_capacity_bond_address: "0x2222222222222222222222222222222222222222" + decdn_slash_judge_address: "0x3333333333333333333333333333333333333333" + tasks: + - name: Initialize the rejected-by-validation tracker + ansible.builtin.set_fact: + decdn_rejected: [] + + # --- Case: cross-field (the regression guard for the effective-value fix) ------ + # A raised pull_ahead with the leech cap left UNSET (resolves to 256 MiB): the + # daemon would reject floor>cap, so the role must too. + - name: "Case cross-field — pull_ahead above the default (unset) leech cap" + block: + - name: Run decdn_node with pull_ahead > default leech cap + ansible.builtin.include_role: + name: decdn_node + vars: + decdn_pull_ahead_bytes: 536870912 + rescue: + - name: Record cross-field rejection (only if a validation assert failed) + ansible.builtin.set_fact: + decdn_rejected: "{{ decdn_rejected + ['cross-field'] }}" + when: ansible_failed_task.name is match('^Validate optional') + + # --- Case: range (numeric but below the daemon's minimum) --------------------- + - name: "Case range — event_poll_interval_ms below 250" + block: + - name: Run decdn_node with event_poll below the daemon minimum + ansible.builtin.include_role: + name: decdn_node + vars: + decdn_event_poll_interval_ms: 100 + rescue: + - name: Record range rejection (only if a validation assert failed) + ansible.builtin.set_fact: + decdn_rejected: "{{ decdn_rejected + ['range'] }}" + when: ansible_failed_task.name is match('^Validate optional') + + # --- Case: shape (non-numeric string for an integer knob) --------------------- + - name: "Case shape — non-numeric integer knob" + block: + - name: Run decdn_node with a non-numeric integer knob + ansible.builtin.include_role: + name: decdn_node + vars: + decdn_gc_interval_sec: "12x" + rescue: + - name: Record shape rejection (only if a validation assert failed) + ansible.builtin.set_fact: + decdn_rejected: "{{ decdn_rejected + ['shape'] }}" + when: ansible_failed_task.name is match('^Validate optional') + + # --- Case: bool (quoted non-boolean) ------------------------------------------ + - name: "Case bool — quoted non-boolean" + block: + - name: Run decdn_node with a quoted non-boolean + ansible.builtin.include_role: + name: decdn_node + vars: + decdn_enable_0rtt: "yes" + rescue: + - name: Record bool rejection (only if a validation assert failed) + ansible.builtin.set_fact: + decdn_rejected: "{{ decdn_rejected + ['bool'] }}" + when: ansible_failed_task.name is match('^Validate optional') + + # --- Case: list (uppercase pinned hash — upstream rejects uppercase) ---------- + - name: "Case list — uppercase pinned hash" + block: + - name: Run decdn_node with an uppercase pinned hash + ansible.builtin.include_role: + name: decdn_node + vars: + decdn_pinned_hashes: + - "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" + rescue: + - name: Record list rejection (only if a validation assert failed) + ansible.builtin.set_fact: + decdn_rejected: "{{ decdn_rejected + ['list'] }}" + when: ansible_failed_task.name is match('^Validate optional') + + # --- Case: string (double-quote breaks the rendered TOML string) -------------- + - name: "Case string — user_agent containing a double-quote" + block: + - name: Run decdn_node with a quote in user_agent + ansible.builtin.include_role: + name: decdn_node + vars: + decdn_cache_user_agent: 'bad"agent' + rescue: + - name: Record string rejection (only if a validation assert failed) + ansible.builtin.set_fact: + decdn_rejected: "{{ decdn_rejected + ['string'] }}" + when: ansible_failed_task.name is match('^Validate optional') + + - name: Confirm every bad value was rejected by the role's own validation + ansible.builtin.assert: + that: + - decdn_rejected | sort == _decdn_expected | sort + fail_msg: >- + decdn_node did NOT reject every bad value at its validation asserts. + Expected {{ _decdn_expected | sort }}, got {{ decdn_rejected | sort }} — + a missing tag means that bad value slipped past validation (it would reach + the daemon and crash-loop it at config load). + vars: + _decdn_expected: ["cross-field", "range", "shape", "bool", "list", "string"] diff --git a/ansible/molecule/validation/molecule.yml b/ansible/molecule/validation/molecule.yml new file mode 100644 index 0000000..05aeb0b --- /dev/null +++ b/ansible/molecule/validation/molecule.yml @@ -0,0 +1,37 @@ +--- +# NEGATIVE-path scenario: proves the role's fail-loud tuning-knob validation +# actually REJECTS bad values (AGENTS.md hard-rule 4). converge.yml runs the role +# with an otherwise-valid config plus one bad knob per case and asserts each is +# rejected — so a regression that weakens an assert fails this scenario. The role +# aborts at its validation asserts (before any host mutation), so no service starts +# here; there is no verify/idempotence step — the converge IS the assertion. Reuses +# the default scenario's stub binary. `baseline` is not exercised (host-only — see +# molecule/default/molecule.yml). +dependency: + name: galaxy + options: + requirements-file: ../../requirements.yml +driver: + name: docker +platforms: + - name: decdn-node-validation + # Same digest-pinned image as the default scenario (repo convention). Re-resolve + # both together to bump. + image: geerlingguy/docker-debian12-ansible@sha256:4553092be2c00b1ffe580927b9ff03f3c3a0df32b7dd693a3eb02efb6c2b77b7 + pre_build_image: true + command: /usr/lib/systemd/systemd + privileged: true + cgroupns_mode: host + volumes: + - /sys/fs/cgroup:/sys/fs/cgroup:rw +provisioner: + name: ansible + env: + ANSIBLE_ROLES_PATH: "${MOLECULE_PROJECT_DIRECTORY}/roles" + ANSIBLE_COLLECTIONS_PATH: "${MOLECULE_PROJECT_DIRECTORY}/collections" +scenario: + test_sequence: + - dependency + - create + - converge + - destroy diff --git a/ansible/roles/decdn_node/README.md b/ansible/roles/decdn_node/README.md index 4e6f82a..434c556 100644 --- a/ansible/roles/decdn_node/README.md +++ b/ansible/roles/decdn_node/README.md @@ -118,6 +118,38 @@ Optional (omitted from `node.toml` unless set): defaults). A **timeout** only warns; it never fails the deploy — but a non-200 *answer* does. See [Readiness](#readiness). +### Optional tuning knobs + +These expose daemon config fields that most operators never touch. Each defaults to +`""` (or `[]`), meaning **omit the key and use the daemon's own default** — set one only +to override. Unlike the `*_from_block` knobs above, an explicit `0`/`false` **is** emitted +(`0` is meaningful — e.g. `decdn_gc_interval_sec: 0` disables the GC sweep). A malformed +or out-of-range value fails loud at deploy time (the daemon would otherwise reject it at +load and crash-loop). See `defaults/main.yml` for every knob's upstream default and unit. + +- **Node-to-node pull-through (#29)** — `decdn_node_to_node_pull_through_enabled` (bool) + gates all serve-time origin pull from upstream nodes; the tuning knobs + (`decdn_node_pull_probe_fanout`, `decdn_node_pull_timeout_sec`, `decdn_pull_ahead_bytes`, + `decdn_max_unrecouped_leech_bytes`, `decdn_pull_share_ratio_percent`, + `decdn_pull_through_require_authorized_origin`) only take effect when it is `true`. + `*_bytes` / `*_percent` are **bare integers** (bytes; percent at scale 100, `400` = 4.0×). +- **Settlement / channels** — `decdn_redeem_threshold_micro_usdc`, + `decdn_buyer_deposit_micro_usdc`, `decdn_buyer_max_approve` (bool), and the opt-in + auto-close pair `decdn_settlement_auto_threshold_micro_usdc` / + `decdn_settlement_auto_by_voucher_nonce_span` (`""` disables — a configured `0` is + rejected upstream). All µUSDC. +- **Delivery-rate clamps** — `decdn_delivery_floor` / `decdn_delivery_ceiling` (µUSDC; + floor ≤ ceiling) and `decdn_voucher_interval_mb` (`1..=1024`). +- **Cache tuning** — `decdn_max_probe_holds`, `decdn_stake_lane_reserved_holds`, + `decdn_gc_interval_sec`, `decdn_pinned_hashes` (list of 64-char **lowercase-hex** BLAKE3 + hashes), `decdn_cache_user_agent`. +- **Blockchain watchers** — `decdn_rpc_watchdog_interval_sec` (`0` or `>= 10`), + `decdn_event_poll_interval_ms` (`>= 250`), `decdn_content_blacklist_poll_interval_sec` (`>= 1`). +- **Network** — `decdn_relay_urls` (list; when non-empty the role emits it and omits the + singular `decdn_relay_url`), `decdn_enable_0rtt` (bool). +- **Observability** — `decdn_otlp_endpoint` (OTLP span export; needs the node built + `--features otlp`), `decdn_region_accounting_interval_sec`. + Source contract addresses / chain-id from the deCDN contract deployment for your target chain, or the relevant ADR — never guess. See `roles/decdn_node/defaults/main.yml` for the full knob list and defaults. diff --git a/ansible/roles/decdn_node/defaults/main.yml b/ansible/roles/decdn_node/defaults/main.yml index 50ba679..2dbe92a 100644 --- a/ansible/roles/decdn_node/defaults/main.yml +++ b/ansible/roles/decdn_node/defaults/main.yml @@ -55,14 +55,56 @@ decdn_chain_id: 421614 # Arbitrum Sepolia (matches decdn-node's --chain # --- Network ------------------------------------------------------------------ decdn_bind_port: 4433 # public QUIC (udp); opened in the baseline firewall -decdn_relay_url: "" # optional iroh relay for NAT traversal +decdn_relay_url: "" # optional iroh relay for NAT traversal (deprecated single alias) +# iroh relay URLs; [] => n0 default relays. When this list is non-empty the template +# emits it and omits decdn_relay_url (this list supersedes the singular alias). +# e.g. ["https://relay1.example:443", "https://relay2.example:443"] +decdn_relay_urls: [] +# The optional knobs below default to "" (or []) => "omit the key, use the daemon's +# own default". Set a value only to override; an explicit 0/false IS emitted. +decdn_enable_0rtt: "" # bool; daemon dflt true — QUIC 0-RTT for cdn/probe/v1 (ADR 015) # --- Economics ---------------------------------------------------------------- decdn_rate_per_mb: 10 # USDC base units (6 decimals) +# Delivery-rate clamps + voucher cadence (µUSDC; "" => daemon default). +decdn_delivery_floor: "" # daemon dflt 0 — rate clamp floor before signing ProbeResponse +decdn_delivery_ceiling: "" # daemon dflt 1_000_000_000_000 (protocol MAX) — rate clamp ceiling +decdn_voucher_interval_mb: "" # daemon dflt 1; range 1..=1024 — voucher cadence (ADR 003) + +# --- Settlement / channels (all µUSDC; "" => daemon default) ------------------ +decdn_redeem_threshold_micro_usdc: "" # daemon dflt 1_000_000 (1 USDC) — accrued µUSDC to trigger withdraw +decdn_buyer_deposit_micro_usdc: "" # daemon dflt 10_000_000 (10 USDC) — escrow on a buyer-channel open +decdn_buyer_max_approve: "" # bool; daemon dflt true — one-time max USDC approval to PaymentChannel +# Auto-closeChannel triggers (OR'd). "" => off (disabled upstream); a configured 0 is rejected. +decdn_settlement_auto_threshold_micro_usdc: "" # un-redeemed µUSDC threshold +decdn_settlement_auto_by_voucher_nonce_span: "" # nonce-span companion trigger + +# --- Blockchain watcher tuning ("" => daemon default) ------------------------- +decdn_rpc_watchdog_interval_sec: "" # daemon dflt 30; 0 disables, else >= 10 — RPC watchdog +decdn_event_poll_interval_ms: "" # daemon dflt 7000; >= 250 — eth_getFilterChanges poll +decdn_content_blacklist_poll_interval_sec: "" # daemon dflt 600; >= 1 — blacklist re-scope cadence # --- Cache -------------------------------------------------------------------- decdn_cache_size_mb: 10240 # 10 GB decdn_max_blob_size_mb: 1024 # 1 GB +# Cache tuning ("" / [] => omit + use the daemon default; an explicit 0 IS emitted — +# 0 is meaningful here, e.g. gc_interval_sec = 0 disables the sweep and +# max_unrecouped_leech_bytes = 0 turns the cap off). +decdn_max_probe_holds: "" # daemon dflt 256 — eviction-exempt holds (ADR 005); 0 = has_blob answers false +decdn_stake_lane_reserved_holds: "" # daemon dflt 0 — holds reserved for node-to-node probes (#757) +decdn_gc_interval_sec: "" # daemon dflt 300 — iroh-blobs GC sweep; 0 = off +decdn_pinned_hashes: [] # eviction-exempt pins; each a 64-char lowercase-hex BLAKE3 hash +decdn_cache_user_agent: "" # daemon dflt decdn-node/ — HTTP origin User-Agent +# Node-to-node pull-through (#29). The toggle gates all serve-time origin pull from +# upstream nodes; the knobs below only take effect when it is true. *_bytes / *_percent +# are BARE INTEGERS on the wire (bytes; percent at scale 100, so 400 = 4.0x). +decdn_node_to_node_pull_through_enabled: "" # bool; daemon dflt false — enable pull from upstream nodes (#29) +decdn_node_pull_probe_fanout: "" # daemon dflt 5 — providers probed per miss; 0 disables pull +decdn_node_pull_timeout_sec: "" # daemon dflt 20 — per-upstream pull timeout +decdn_pull_ahead_bytes: "" # daemon dflt 1048576 (1 MiB) — speculative pull window (ADR 037) +decdn_max_unrecouped_leech_bytes: "" # daemon dflt 268435456 (256 MiB) — speculative-spend cap; 0 = off +decdn_pull_share_ratio_percent: "" # daemon dflt 400 (=4.0x) — per-peer speculative ceiling +decdn_pull_through_require_authorized_origin: "" # bool; daemon dflt false — gate pulls on the ADR 022 directory # Pull-through origin (#437): what the node fetches on a cache miss. Empty kind => # the [cache.origin] table is omitted and misses fail NoOrigin — a serving node # needs an origin. Pick ONE kind and set that kind's fields; the others are ignored. @@ -82,6 +124,9 @@ decdn_log_format: json decdn_metrics_port: 9090 decdn_metrics_bind: "127.0.0.1" # keep loopback — Prometheus scrape is a follow-up decdn_admin_port: 9191 +# Optional ("" => omit + use the daemon default). +decdn_otlp_endpoint: "" # OTLP span export; http(s)://; needs the node built --features otlp +decdn_region_accounting_interval_sec: "" # daemon dflt 3600 — per-region bandwidth log; 0 = off # --- Readiness probe (advisory) ----------------------------------------------- # After start, the role probes http://127.0.0.1:/metrics as a diff --git a/ansible/roles/decdn_node/tasks/main.yml b/ansible/roles/decdn_node/tasks/main.yml index 69f7f9e..892a32a 100644 --- a/ansible/roles/decdn_node/tasks/main.yml +++ b/ansible/roles/decdn_node/tasks/main.yml @@ -176,6 +176,161 @@ kind empty to omit [cache.origin] (cache misses then fail NoOrigin). when: decdn_cache_origin_kind | length > 0 +# --- Optional tuning knobs ("" => omit + use the daemon default) -------------- +# These template with a `!= ""` gate (NOT `| int > 0`) because 0/false are +# meaningful values here. A bad value would render TOML the daemon rejects at +# load — an opaque crash-loop — so validate shape + upstream range/constraints at +# deploy time instead (AGENTS.md hard-rule 4). Every check is guarded `== "" or +# (...)` so the unset default always passes. + +- name: Validate optional boolean tuning knobs + ansible.builtin.assert: + that: + - decdn_enable_0rtt in ["", none] or decdn_enable_0rtt is boolean + - decdn_buyer_max_approve in ["", none] or decdn_buyer_max_approve is boolean + - decdn_node_to_node_pull_through_enabled in ["", none] or decdn_node_to_node_pull_through_enabled is boolean + - >- + decdn_pull_through_require_authorized_origin in ["", none] + or decdn_pull_through_require_authorized_origin is boolean + fail_msg: >- + decdn_enable_0rtt, decdn_buyer_max_approve, + decdn_node_to_node_pull_through_enabled and + decdn_pull_through_require_authorized_origin must each be a real boolean + (true/false), not a quoted string — they template through `| lower` into a + bare TOML bool. Leave a knob "" (or null) to omit it (daemon default applies). + +# Integer shape first (a separate task): if a value is non-numeric this fails loud +# BEFORE the range task below runs its `| int` comparisons, so `| int` there never +# silently coerces a typo to 0. +- name: Validate optional integer tuning knobs are non-negative integers + ansible.builtin.assert: + that: + # Shape AND magnitude: TOML integers are i64-domain, so a value above + # i64::MAX (9223372036854775807) renders but fails the daemon's TOML parse + # (a crash-loop) even though it is a "valid u64". Cap it here. "" and null + # (None) both mean "unset" and pass. + - item in ["", none] or (item | string is match('^[0-9]+$') and item | int <= 9223372036854775807) + quiet: true + fail_msg: >- + "{{ item }}" is not a non-negative integer <= i64::MAX (9223372036854775807). + Every optional numeric knob (delivery/settlement/watcher/cache-tuning/ + pull-through) must be a bare u64 in that range, or "" / null to omit it. See + roles/decdn_node/defaults/main.yml for the full list. + # A list EXPRESSION (not a list of "{{ }}" strings) so each item keeps its type — + # a null stays None (caught by the `in [..., none]` guard) instead of becoming the + # string "None", which would fail the shape regex. + loop: >- + {{ [decdn_delivery_floor, decdn_delivery_ceiling, decdn_voucher_interval_mb, + decdn_redeem_threshold_micro_usdc, decdn_buyer_deposit_micro_usdc, + decdn_settlement_auto_threshold_micro_usdc, decdn_settlement_auto_by_voucher_nonce_span, + decdn_rpc_watchdog_interval_sec, decdn_event_poll_interval_ms, + decdn_content_blacklist_poll_interval_sec, decdn_max_probe_holds, + decdn_stake_lane_reserved_holds, decdn_gc_interval_sec, decdn_node_pull_probe_fanout, + decdn_node_pull_timeout_sec, decdn_pull_ahead_bytes, decdn_max_unrecouped_leech_bytes, + decdn_pull_share_ratio_percent, decdn_region_accounting_interval_sec] }} + +# Range / nonzero constraints the daemon enforces at load (out-of-range => the node +# refuses to start). Values are integer-shaped by the task above, so `| int` is safe. +- name: Validate optional knob ranges the daemon rejects out-of-range + ansible.builtin.assert: + that: + # rpc_watchdog: 0 disables, otherwise must be >= 10 (1..9 rejected upstream). + # ("" and null both mean "unset" and skip the check — the shape task above + # already rejected any non-numeric, non-null value.) + - >- + decdn_rpc_watchdog_interval_sec in ["", none] + or (decdn_rpc_watchdog_interval_sec | int == 0 or decdn_rpc_watchdog_interval_sec | int >= 10) + - decdn_event_poll_interval_ms in ["", none] or (decdn_event_poll_interval_ms | int >= 250) + - >- + decdn_content_blacklist_poll_interval_sec in ["", none] + or (decdn_content_blacklist_poll_interval_sec | int >= 1) + - decdn_redeem_threshold_micro_usdc in ["", none] or (decdn_redeem_threshold_micro_usdc | int >= 1) + - decdn_buyer_deposit_micro_usdc in ["", none] or (decdn_buyer_deposit_micro_usdc | int >= 1) + # settlement auto-close: 0 is rejected (omit to disable), so require >= 1. + - >- + decdn_settlement_auto_threshold_micro_usdc in ["", none] + or (decdn_settlement_auto_threshold_micro_usdc | int >= 1) + - >- + decdn_settlement_auto_by_voucher_nonce_span in ["", none] + or (decdn_settlement_auto_by_voucher_nonce_span | int >= 1) + - >- + decdn_delivery_ceiling in ["", none] + or (decdn_delivery_ceiling | int >= 1 and decdn_delivery_ceiling | int <= 1000000000000) + - >- + decdn_voucher_interval_mb in ["", none] + or (decdn_voucher_interval_mb | int >= 1 and decdn_voucher_interval_mb | int <= 1024) + fail_msg: >- + An optional knob is out of the range the daemon accepts: + decdn_rpc_watchdog_interval_sec (0 or >= 10), + decdn_event_poll_interval_ms (>= 250), + decdn_content_blacklist_poll_interval_sec (>= 1), + decdn_redeem_threshold_micro_usdc / decdn_buyer_deposit_micro_usdc (>= 1), + decdn_settlement_auto_threshold_micro_usdc / + decdn_settlement_auto_by_voucher_nonce_span (>= 1 — leave "" to disable, 0 is + rejected), decdn_delivery_ceiling (1..=1_000_000_000_000), + decdn_voucher_interval_mb (1..=1024). + +- name: Validate optional cross-field knob constraints + ansible.builtin.assert: + that: + # The daemon fills each UNSET side with its own default and THEN enforces the + # constraint, so compare effective (default-substituted) values — NOT "skip if + # either side is unset". Skipping would let a raised pull_ahead with an unset + # leech cap (which resolves to 256 MiB), or a floor above the default ceiling, + # slip through here and crash-loop the daemon at config load. + - _eff_dfloor | int <= _eff_dceil | int + # leech cap: a RESOLVED 0 disables it; otherwise pull_ahead <= leech. + - _eff_leech | int == 0 or (_eff_pull | int <= _eff_leech | int) + fail_msg: >- + Cross-field constraint violated (unset sides compared against the daemon + default): decdn_delivery_floor must be <= decdn_delivery_ceiling (default + 1_000_000_000_000), and decdn_pull_ahead_bytes must be <= + decdn_max_unrecouped_leech_bytes (default 268435456) unless that cap is 0 (off). + vars: + # "" and null both mean "unset" => substitute the daemon default. + _eff_dfloor: "{{ decdn_delivery_floor if decdn_delivery_floor not in ['', none] else 0 }}" + _eff_dceil: "{{ decdn_delivery_ceiling if decdn_delivery_ceiling not in ['', none] else 1000000000000 }}" + _eff_pull: "{{ decdn_pull_ahead_bytes if decdn_pull_ahead_bytes not in ['', none] else 1048576 }}" + _eff_leech: >- + {{ decdn_max_unrecouped_leech_bytes if decdn_max_unrecouped_leech_bytes not in ['', none] + else 268435456 }} + +- name: Validate optional list + singular-relay knobs + ansible.builtin.assert: + that: + # Fully anchored (^...$) with [^"\s\\]: a start-anchored '^https?://' prefix + # match would accept a value like 'https://x" injected = 1' whose unescaped + # quote breaks the rendered TOML array (a crash-loop). Also reject backslash — + # in a TOML basic string it starts an escape, and a trailing '\' escapes the + # closing quote. A null list is treated as unset (is none => pass). + - decdn_relay_urls is none or decdn_relay_urls | reject('match', '^https?://[^\"\\s\\\\]+$') | list | length == 0 + # The singular relay_url is templated too (when relay_urls is empty), so guard it + # with the same rule — a bad value there breaks node.toml just the same. + - decdn_relay_url in ["", none] or (decdn_relay_url is match('^https?://[^\"\\s\\\\]+$')) + - decdn_pinned_hashes is none or decdn_pinned_hashes | reject('match', '^[0-9a-f]{64}$') | list | length == 0 + fail_msg: >- + decdn_relay_urls / decdn_relay_url entries must each be an http(s):// URL with + no quote, whitespace or backslash, and decdn_pinned_hashes entries must each be + a 64-char lowercase-hex BLAKE3 hash (uppercase is rejected upstream). Leave a + list empty ([]) or a scalar "" to omit it. + +# user_agent + otlp_endpoint interpolate into TOML basic strings ("..."). An +# unescaped '"', backslash or newline breaks the rendered node.toml (a daemon +# crash-loop — a trailing '\' escapes the closing quote), and the daemon additionally +# requires otlp_endpoint to be an http(s):// URL. Neither is emitted through | int, so +# validate their shape here (guarded so "" / null pass). +- name: Validate optional string knobs (user_agent, otlp_endpoint) + ansible.builtin.assert: + that: + # No '"', CR, LF or backslash (would break the TOML string / are rejected upstream). + - decdn_cache_user_agent in ["", none] or (decdn_cache_user_agent is match('^[^\"\\r\\n\\\\]+$')) + # http(s):// URL, and no quote/whitespace/backslash (same TOML-injection guard). + - decdn_otlp_endpoint in ["", none] or (decdn_otlp_endpoint is match('^https?://[^\"\\s\\\\]+$')) + fail_msg: >- + decdn_cache_user_agent must contain no double-quote, backslash or newline, and + decdn_otlp_endpoint must be an http(s):// URL with no quote, whitespace or + backslash (both are interpolated verbatim into node.toml). Leave either "" to omit. + # --- User & directories ------------------------------------------------------- - name: Create decdn system group ansible.builtin.group: diff --git a/ansible/roles/decdn_node/templates/node.toml.j2 b/ansible/roles/decdn_node/templates/node.toml.j2 index df20361..1486b3c 100644 --- a/ansible/roles/decdn_node/templates/node.toml.j2 +++ b/ansible/roles/decdn_node/templates/node.toml.j2 @@ -10,9 +10,14 @@ region = "{{ decdn_region }}" [network] bind_port = {{ decdn_bind_port }} -{% if decdn_relay_url | length > 0 %} +{% if decdn_relay_urls is not none and decdn_relay_urls | length > 0 %} +relay_urls = [{% for u in decdn_relay_urls %}"{{ u }}"{% if not loop.last %}, {% endif %}{% endfor %}] +{% elif decdn_relay_url not in ["", none] %} relay_url = "{{ decdn_relay_url }}" {% endif %} +{% if decdn_enable_0rtt not in ["", none] %} +enable_0rtt = {{ decdn_enable_0rtt | lower }} +{% endif %} [blockchain] chain_id = {{ decdn_chain_id }} @@ -39,14 +44,83 @@ content_blacklist_address = "{{ decdn_content_blacklist_address }}" content_blacklist_from_block = {{ decdn_content_blacklist_from_block | int }} {% endif %} {% endif %} +{% if decdn_rpc_watchdog_interval_sec not in ["", none] %} +rpc_watchdog_interval_sec = {{ decdn_rpc_watchdog_interval_sec | int }} +{% endif %} +{% if decdn_event_poll_interval_ms not in ["", none] %} +event_poll_interval_ms = {{ decdn_event_poll_interval_ms | int }} +{% endif %} +{% if decdn_content_blacklist_poll_interval_sec not in ["", none] %} +content_blacklist_poll_interval_sec = {{ decdn_content_blacklist_poll_interval_sec | int }} +{% endif %} +{% if decdn_redeem_threshold_micro_usdc not in ["", none] %} +redeem_threshold_micro_usdc = {{ decdn_redeem_threshold_micro_usdc | int }} +{% endif %} +{% if decdn_buyer_deposit_micro_usdc not in ["", none] %} +buyer_deposit_micro_usdc = {{ decdn_buyer_deposit_micro_usdc | int }} +{% endif %} +{% if decdn_buyer_max_approve not in ["", none] %} +buyer_max_approve = {{ decdn_buyer_max_approve | lower }} +{% endif %} +{% if decdn_settlement_auto_threshold_micro_usdc not in ["", none] %} +settlement_auto_threshold_micro_usdc = {{ decdn_settlement_auto_threshold_micro_usdc | int }} +{% endif %} +{% if decdn_settlement_auto_by_voucher_nonce_span not in ["", none] %} +settlement_auto_by_voucher_nonce_span = {{ decdn_settlement_auto_by_voucher_nonce_span | int }} +{% endif %} [payment] rate_per_mb = {{ decdn_rate_per_mb }} +{% if decdn_delivery_floor not in ["", none] %} +delivery_floor = {{ decdn_delivery_floor | int }} +{% endif %} +{% if decdn_delivery_ceiling not in ["", none] %} +delivery_ceiling = {{ decdn_delivery_ceiling | int }} +{% endif %} +{% if decdn_voucher_interval_mb not in ["", none] %} +voucher_interval_mb = {{ decdn_voucher_interval_mb | int }} +{% endif %} [cache] cache_dir = "{{ decdn_cache_dir }}" cache_size_mb = {{ decdn_cache_size_mb }} max_blob_size_mb = {{ decdn_max_blob_size_mb }} +{% if decdn_max_probe_holds not in ["", none] %} +max_probe_holds = {{ decdn_max_probe_holds | int }} +{% endif %} +{% if decdn_stake_lane_reserved_holds not in ["", none] %} +stake_lane_reserved_holds = {{ decdn_stake_lane_reserved_holds | int }} +{% endif %} +{% if decdn_gc_interval_sec not in ["", none] %} +gc_interval_sec = {{ decdn_gc_interval_sec | int }} +{% endif %} +{% if decdn_pinned_hashes is not none and decdn_pinned_hashes | length > 0 %} +pinned_hashes = [{% for h in decdn_pinned_hashes %}"{{ h }}"{% if not loop.last %}, {% endif %}{% endfor %}] +{% endif %} +{% if decdn_cache_user_agent not in ["", none] %} +user_agent = "{{ decdn_cache_user_agent }}" +{% endif %} +{% if decdn_node_to_node_pull_through_enabled not in ["", none] %} +node_to_node_pull_through_enabled = {{ decdn_node_to_node_pull_through_enabled | lower }} +{% endif %} +{% if decdn_node_pull_probe_fanout not in ["", none] %} +node_pull_probe_fanout = {{ decdn_node_pull_probe_fanout | int }} +{% endif %} +{% if decdn_node_pull_timeout_sec not in ["", none] %} +node_pull_timeout_sec = {{ decdn_node_pull_timeout_sec | int }} +{% endif %} +{% if decdn_pull_ahead_bytes not in ["", none] %} +pull_ahead_bytes = {{ decdn_pull_ahead_bytes | int }} +{% endif %} +{% if decdn_max_unrecouped_leech_bytes not in ["", none] %} +max_unrecouped_leech_bytes = {{ decdn_max_unrecouped_leech_bytes | int }} +{% endif %} +{% if decdn_pull_share_ratio_percent not in ["", none] %} +pull_share_ratio_percent = {{ decdn_pull_share_ratio_percent | int }} +{% endif %} +{% if decdn_pull_through_require_authorized_origin not in ["", none] %} +pull_through_require_authorized_origin = {{ decdn_pull_through_require_authorized_origin | lower }} +{% endif %} {% if decdn_cache_origin_kind | length > 0 %} [cache.origin] @@ -79,3 +153,9 @@ log_format = "{{ decdn_log_format }}" metrics_port = {{ decdn_metrics_port }} metrics_bind = "{{ decdn_metrics_bind }}" admin_port = {{ decdn_admin_port }} +{% if decdn_otlp_endpoint not in ["", none] %} +otlp_endpoint = "{{ decdn_otlp_endpoint }}" +{% endif %} +{% if decdn_region_accounting_interval_sec not in ["", none] %} +region_accounting_interval_sec = {{ decdn_region_accounting_interval_sec | int }} +{% endif %}