Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .github/workflows/molecule.yml
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,8 @@ jobs:
# for 14 scenarios, CPU-bound (per-task overhead roughly doubled under contention),
# with the slowest scenarios queued last. Now the wall clock is the slowest single
# scenario plus setup: ~10m for 18 scenarios (first run, PR #96), set by
# cloud-init-publisher (~9m45s), then cloud-init (~8m) and sponsord-onramp (~7m30s);
# cloud-init-sponsord (~9m45s; now cloud-init-publisher, which also boots an
# origin node), then cloud-init (~8m) and sponsord-onramp (~7m30s);
# most others take 2-5m. By October 2026 iroh-relay and source-build had reached
# ~11m on their side effects, which are now split over sibling scenarios (#106),
# so no scenario should go much past ~6.5m. Re-measure against the timeout when a
Expand Down
18 changes: 18 additions & 0 deletions .github/workflows/release-collection.yml
Original file line number Diff line number Diff line change
Expand Up @@ -126,8 +126,26 @@ jobs:
fi
echo "latest=$latest" >> "$GITHUB_OUTPUT"
echo "Latest: $latest (newest node collection release: ${newest:-n/a})"
# The runner's system Python is externally managed (PEP 668): pip into it fails.
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: '3.12'
- name: Install ansible-core
run: python3 -m pip install --upgrade ansible-core
# decdn.publisher installs only if Galaxy can resolve its decdn.node dependency.
# scripts/release.sh checks origin's node-collection tags, but a tag is not an
# upload (one pushed while PUBLISH_ENABLED was off, or whose publish failed), so
# ask Galaxy itself, before anything is published: re-run this job once decdn.node
# is up.
- name: Require decdn.publisher's decdn.node dependency on Galaxy
run: |
[[ "$TAG" == publisher-collection-v* ]] || exit 0
tarball="dist/decdn-${TAG%-collection-v*}-${TAG##*-collection-v}.tar.gz"
need="$(tar -xzOf "$tarball" MANIFEST.json \
| python3 -c 'import json, sys; print(json.load(sys.stdin)["collection_info"]["dependencies"]["decdn.node"])')"
echo "decdn.publisher requires decdn.node $need"
ansible-galaxy collection download "decdn.node:$need" --no-deps -p "$(mktemp -d)" \
|| { echo "::error::could not resolve decdn.node $need on Galaxy (see the error above): if it is not published, publish a node-collection release first; then re-run this job"; exit 1; }
# Not idempotent: Galaxy refuses a version that already exists, so a re-run
# after this step succeeded fails here. RELEASING.md covers recovery.
- name: Publish the collection to Galaxy
Expand Down
17 changes: 10 additions & 7 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@ split along that line:
ADR 002/011), and optionally `sponsord` + its onramp, iroh relays and an iroh DNS
server: `publisher.yml` / `make deploy-publisher` (`origin.yml` + the component
playbooks), `cloud-init/user-data-publisher.yaml`, Compose's `origin`/`onramp`
profiles, the `decdn.publisher` collection. Front door: `docs/publishers.md`.
profiles, the chart (an origin node only), the `decdn.publisher` collection. Front
door: `docs/publishers.md`.

There are four deploy paths:
**Ansible** (`ansible/`, VMs/bare metal, the primary path, also the `decdn.node` and
Expand Down Expand Up @@ -104,7 +105,7 @@ monitoring/ # Grafana dashboards + Prometheus alert rules (maintaine
sponsord/ # sponsord's (+ promtool unit tests), Ansible/Compose only
iroh-relay/ # the relay's (+ promtool unit tests, exported-metrics.txt), Ansible only
docs/ # cross-path operator docs: node-operators.md + publishers.md (the front doors), requirements.md, lifecycle.md
scripts/ # upstream-mirror generators, the release script (release.sh, git-cliff: ../cliff.toml) and its gate (+ its Artifact Hub changes generator), the molecule driver (molecule.sh)
scripts/ # upstream-mirror generators, the release script (release.sh, git-cliff: ../cliff.toml) and its gate (+ its Artifact Hub changes generator), the molecule driver (molecule.sh), ansible/Makefile's LIMIT preflight (limit-guard.sh)
```

**Generated mirrors of upstream — regenerate, never hand-edit:**
Expand Down Expand Up @@ -465,7 +466,8 @@ the pinned `iroh-relay` (re-capture it when bumping `iroh_relay_version`).
`node` and `origin` (both start the one `decdn-node`: a node operator's cache node,
or a publisher's origin, whose `node.toml` carries `[cache.origin]`), `sponsord`,
`onramp` (also starts `sponsord`) and `caddy`. An fs origin's content mount goes in
the operator's own `compose.override.yaml`, so the committed mounts stay exact.
the operator's own `compose.override.yaml` (passed as a second `-f` on every
command: Compose skips it when `-f` is given), so the committed mounts stay exact.
- **sponsord / onramp:** the roles' `/etc/sponsord/` layout, except that the
credential files belong to a host `sponsord` account (bind mounts keep owner and
mode, and upstream rejects a group-readable keystore). They are mounted
Expand Down Expand Up @@ -581,11 +583,12 @@ which depends on `decdn.node`). Each overlay lives in `ansible/galaxy/<collectio
`make test-scripts` checks every role ships in exactly one), and is staged into a clean
collection tree by `galaxy/build.sh <collection>` — there is **no** `galaxy.yml` at the
`ansible/` root (that would make ansible-lint treat the deploy project as a collection).
A new role goes in one `roles.txt`. Build/validate with `make build` / `make galaxy-check`
(both; `-node`/`-publisher` suffixes for one). **Publishing** is per artifact, each on
A new role goes in one `roles.txt`. Build/validate from `ansible/` with `make build` /
`make galaxy-check` (both; `-node`/`-publisher` suffixes for one; the root's
`galaxy-build`/`galaxy-check` run both). **Publishing** is per artifact, each on
its own version: `release-collection.yml` on a `node-collection-vX.Y.Z` or
`publisher-collection-vX.Y.Z` tag (the repo's Latest follows `decdn.node`; the publisher
collection's first release waits for node's), `release-chart.yml` on a
`publisher-collection-vX.Y.Z` tag (the repo's Latest follows `decdn.node`; release.sh
refuses a publisher release until a node tag satisfies its `decdn.node` constraint), `release-chart.yml` on a
`decdn-node-X.Y.Z` tag (that file name and tag prefix are the chart's cosign identity:
never rename them), and only while the `PUBLISH_ENABLED` repository variable is `true`
(RELEASING.md). The chart's publish also pushes its
Expand Down
4 changes: 2 additions & 2 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,8 @@ targets, so a local pass means a CI pass. Deploy targets live in
| `make lint-helm` | chart: `helm lint --strict`, positive/negative render tests, kubeconform and `promtool check rules` on the alert rules (digest-pinned images), the shared schema-key check (needs `helm`, `yq`, `python3` ≥ 3.11, Docker). Set `DECDN_CLI=<path to decdn>` to also run the real `decdn config validate` (CI can't). |
| `make lint-alloy` | renders `roles/grafana_alloy`'s templates and validates them with the **real** digest-pinned Alloy binary. The molecule stub exits 0 for everything, so this is the only gate that proves the config loads. `ALLOY_BIN=<path>` skips the download. |
| `make lint-compose` | renders `compose/compose.yaml` with every profile on (with its example env, without env files, with an empty `.env`) and asserts `compose/tests/invariants.jq`, `inline-env.jq` and `fail-closed.jq` |
| `make lint-cloud-init` | `cloud-init schema` on `cloud-init/user-data-node.yaml` and `cloud-init/user-data-publisher.yaml`, then `cloud-init/tests/lint.py` on each: only the templates' top-level modules and no YAML anchors, no secrets (only the bootstrap's own files, once each, plain `content` only, no secret-looking keys or assignments), no hardening skip, `release` installs verified against the vendored keys with a host-generated node wallet (trust knobs only in their own group's `vars`, no moved keyring or secret path), only localhost, in `decdn_nodes` and/or `sponsord_hosts` (an origin only beside `decdn_nodes` and with a backend, the onramp only beside sponsord), a keyed admin account, `runcmd` exactly stage 1, shellcheck-clean scripts, and a collection lock that covers `ansible/requirements.yml` (needs `cloud-init`, `shellcheck`, `yq`). `CLOUD_INIT_FILE=<path>` checks your own filled-in copy. |
| `make test-scripts` | `tests/scripts-test.sh`: the `ansible/Makefile` scoping guards (dry runs), the molecule driver's selection guards and locks (no containers), the split scenarios' shared inventories, the release gate, `scripts/release.sh` on a fixture repo and `pr-title.yml`'s check (the former skipped without `git-cliff` or `ssh-keygen` on PATH, failed in CI), the negative cases of `lint-compose` and `lint-cloud-init` (the latter skipped without `cloud-init` on PATH), and the cloud-init bootstrap's contracts: `baseline-plays.sh` on fixtures and on the real playbooks (every node.yml, origin.yml and sponsord.yml play runs `baseline` tagged `baseline`), the two templates' shared stage 1, login hint and `final_message`, `bootstrap.sh`'s groups against lint.py's, and lint.py's `FORBIDDEN_VARS` against the roles' keyrings and secret paths. `UPSTREAM=<decdn checkout>` adds the sync generators' exit codes. |
| `make lint-cloud-init` | `cloud-init schema` on `cloud-init/user-data-node.yaml` and `cloud-init/user-data-publisher.yaml`, then `cloud-init/tests/lint.py` on each: only the templates' top-level modules and no YAML anchors, no secrets (only the bootstrap's own files, once each, plain `content` only, no secret-looking keys or assignments), no hardening skip, `release` installs verified against the vendored keys with a host-generated node wallet (trust knobs only in their own group's `vars`, no moved keyring or secret path), only localhost, in `decdn_nodes` and/or `sponsord_hosts` (an origin only beside `decdn_nodes` and with a backend, every backend key only in `decdn_origin_nodes.vars`, the onramp only beside sponsord), a keyed admin account, `runcmd` exactly stage 1, shellcheck-clean scripts, and a collection lock that covers `ansible/requirements.yml` (needs `cloud-init`, `shellcheck`, `yq`). `CLOUD_INIT_FILE=<path>` checks your own filled-in copy. |
| `make test-scripts` | `tests/scripts-test.sh`: the `ansible/Makefile` scoping guards (dry runs, and `limit-guard.sh` on a fixture inventory), the molecule driver's selection guards and locks (no containers), the split scenarios' shared inventories, the release gate, `scripts/release.sh` on a fixture repo and `pr-title.yml`'s check (the former skipped without `git-cliff` or `ssh-keygen` on PATH, failed in CI), the negative cases of `lint-compose` and `lint-cloud-init` (the latter skipped without `cloud-init` on PATH), and the cloud-init bootstrap's contracts: `baseline-plays.sh` on fixtures and on the real playbooks (every node.yml, origin.yml and sponsord.yml play runs `baseline` tagged `baseline`), the two templates' shared stage 1, login hint and `final_message`, `bootstrap.sh`'s groups against lint.py's, and lint.py's `FORBIDDEN_VARS` against the roles' keyrings and secret paths. `UPSTREAM=<decdn checkout>` adds the sync generators' exit codes. |
| `make security` | KICS IaC scan of `ansible/`, the rendered chart and `compose/` (digest-pinned engine, fail on HIGH) |
| `make galaxy-check` | build the `decdn.node` and `decdn.publisher` collections and run galaxy-importer's checks on each (`make -C ansible galaxy-check-node` / `galaxy-check-publisher` for one) |

Expand Down
7 changes: 5 additions & 2 deletions RELEASING.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,11 @@ The repo publishes three artifacts, each on its own version and released by its
Release an artifact when it has changes; the others are not touched. Their versions are
independent. The one dependency is Galaxy's: `decdn.publisher` requires `decdn.node`
(its playbooks run `decdn.node`'s baseline, Alloy and origin nodes), so the publisher
collection's first release comes after the node collection's, and `scripts/release.sh`
refuses it before. What the artifacts must agree on is checked by CI, not by a shared
collection's first release comes after the node collection's. `scripts/release.sh`
refuses any publisher release whose `decdn.node: ">=X.Y.Z"` no `node-collection-v*`
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
`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
Expand Down
31 changes: 28 additions & 3 deletions ansible/Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -50,11 +50,20 @@ INVENTORY ?= inventory/hosts.yml
# with no inventory present.
strict_inventory = ANSIBLE_INVENTORY_UNPARSED_FAILED=True

# $(call limit_guard,<playbook>): refuse a run in which the playbook's plays select no
# host, or skip a host LIMIT names; ansible-playbook reports both as success (e.g.
# `deploy-node LIMIT=<an origin>`: node.yml leaves decdn_origin_nodes to origin.yml).
# See scripts/limit-guard.sh.
define limit_guard
@$(strict_inventory) ../scripts/limit-guard.sh '$(INVENTORY)' $(1) '$(LIMIT)' $(ANSIBLE_ARGS)
endef

# Fail loud on the two ways a run can look scoped but silently be fleet-wide. Scoped to
# the playbook targets (check/deploy[-node|-origin|-publisher|-sponsord|-relay|-dns]/backup/decommission) via MAKECMDGOALS so an
# unrelated `make lint` is never blocked. NOTE: this
# does not catch an argument that is well-formed but selects nothing — `--tags` with a
# typo (decdn-node vs decdn_node) runs zero tasks and still exits 0. See ansible/README.md.
# unrelated `make lint` is never blocked. A run that selects no host, or skips one LIMIT
# names, is refused by limit_guard, above. NOTE: neither catches a --tags that is well-formed but
# selects nothing — a typo (decdn-node vs decdn_node) runs zero tasks and still exits 0.
# See ansible/README.md.
ifneq ($(filter check deploy check-node deploy-node check-origin deploy-origin check-publisher deploy-publisher check-sponsord deploy-sponsord check-relay deploy-relay check-dns deploy-dns backup decommission,$(MAKECMDGOALS)),)
# Echo back the goal the operator actually typed, so the hint never nudges a
# `make check` dry-run toward the mutating `make deploy`.
Expand Down Expand Up @@ -89,70 +98,86 @@ endif
# Dry run of everything in $(INVENTORY) (site.yml: node.yml, then publisher.yml) —
# shows diffs, mutates nothing.
check:
$(call limit_guard,playbooks/site.yml)
$(strict_inventory) ansible-playbook -i '$(INVENTORY)' playbooks/site.yml --check --diff $(limit_arg) $(ANSIBLE_ARGS)

# Provision every host in $(INVENTORY) (default inventory/hosts.yml).
deploy:
$(call limit_guard,playbooks/site.yml)
$(strict_inventory) ansible-playbook -i '$(INVENTORY)' playbooks/site.yml $(limit_arg) $(ANSIBLE_ARGS)

# Node operators: the cache nodes alone (playbooks/node.yml, decdn_nodes that are not
# in decdn_origin_nodes).
check-node:
$(call limit_guard,playbooks/node.yml)
$(strict_inventory) ansible-playbook -i '$(INVENTORY)' playbooks/node.yml --check --diff $(limit_arg) $(ANSIBLE_ARGS)

deploy-node:
$(call limit_guard,playbooks/node.yml)
$(strict_inventory) ansible-playbook -i '$(INVENTORY)' playbooks/node.yml $(limit_arg) $(ANSIBLE_ARGS)

# Publishers: everything a publisher runs (playbooks/publisher.yml: origins, sponsord
# and its onramp, iroh relays, iroh DNS servers), leaving the cache nodes untouched.
check-publisher:
$(call limit_guard,playbooks/publisher.yml)
$(strict_inventory) ansible-playbook -i '$(INVENTORY)' playbooks/publisher.yml --check --diff $(limit_arg) $(ANSIBLE_ARGS)

deploy-publisher:
$(call limit_guard,playbooks/publisher.yml)
$(strict_inventory) ansible-playbook -i '$(INVENTORY)' playbooks/publisher.yml $(limit_arg) $(ANSIBLE_ARGS)

# A publisher's origin nodes alone (playbooks/origin.yml, hosts in decdn_origin_nodes).
check-origin:
$(call limit_guard,playbooks/origin.yml)
$(strict_inventory) ansible-playbook -i '$(INVENTORY)' playbooks/origin.yml --check --diff $(limit_arg) $(ANSIBLE_ARGS)

deploy-origin:
$(call limit_guard,playbooks/origin.yml)
$(strict_inventory) ansible-playbook -i '$(INVENTORY)' playbooks/origin.yml $(limit_arg) $(ANSIBLE_ARGS)

# sponsord alone (playbooks/sponsord.yml, hosts in sponsord_hosts). check/deploy and
# check/deploy-publisher above already include it; these leave every decdn-node untouched.
check-sponsord:
$(call limit_guard,playbooks/sponsord.yml)
$(strict_inventory) ansible-playbook -i '$(INVENTORY)' playbooks/sponsord.yml --check --diff $(limit_arg) $(ANSIBLE_ARGS)

deploy-sponsord:
$(call limit_guard,playbooks/sponsord.yml)
$(strict_inventory) ansible-playbook -i '$(INVENTORY)' playbooks/sponsord.yml $(limit_arg) $(ANSIBLE_ARGS)

# iroh relays alone (playbooks/iroh_relay.yml, hosts in iroh_relay_hosts). check/deploy
# and check/deploy-publisher above already include them; these leave every other host
# untouched.
check-relay:
$(call limit_guard,playbooks/iroh_relay.yml)
$(strict_inventory) ansible-playbook -i '$(INVENTORY)' playbooks/iroh_relay.yml --check --diff $(limit_arg) $(ANSIBLE_ARGS)

deploy-relay:
$(call limit_guard,playbooks/iroh_relay.yml)
$(strict_inventory) ansible-playbook -i '$(INVENTORY)' playbooks/iroh_relay.yml $(limit_arg) $(ANSIBLE_ARGS)

# iroh DNS servers alone (playbooks/iroh_dns_server.yml, hosts in iroh_dns_server_hosts).
# check/deploy and check/deploy-publisher above already include them; these leave every
# other host untouched.
check-dns:
$(call limit_guard,playbooks/iroh_dns_server.yml)
$(strict_inventory) ansible-playbook -i '$(INVENTORY)' playbooks/iroh_dns_server.yml --check --diff $(limit_arg) $(ANSIBLE_ARGS)

deploy-dns:
$(call limit_guard,playbooks/iroh_dns_server.yml)
$(strict_inventory) ansible-playbook -i '$(INVENTORY)' playbooks/iroh_dns_server.yml $(limit_arg) $(ANSIBLE_ARGS)

# Encrypted backup of each node (identity by default; -e decdn_backup_scope=full for
# the whole data dir, which stops the node for the copy). One host at a time.
# Needs decdn_backup_age_recipients. See docs/lifecycle.md.
backup:
$(call limit_guard,playbooks/backup.yml)
$(strict_inventory) ansible-playbook -i '$(INVENTORY)' playbooks/backup.yml $(limit_arg) $(ANSIBLE_ARGS)

# Stop the node and remove its unit on LIMIT (required). Keeps the identity; prints
# the on-chain exit steps. Asks for typed confirmation. See docs/lifecycle.md.
decommission:
$(call limit_guard,playbooks/decommission.yml)
$(strict_inventory) ansible-playbook -i '$(INVENTORY)' playbooks/decommission.yml $(limit_arg) $(ANSIBLE_ARGS)

# --- Tests -------------------------------------------------------------------
Expand Down
Loading
Loading