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
5 changes: 3 additions & 2 deletions .github/ISSUE_TEMPLATE/bug_report.yml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
---
name: Bug report
description: A deploy path (Ansible, Helm, Compose) does something wrong.
description: A deploy path (Ansible, cloud-init, Helm, Compose) does something wrong.
labels: [bug]
body:
- type: markdown
Expand All @@ -16,6 +16,7 @@ body:
options:
- Ansible (this repo's playbooks)
- Ansible (decdn.node Galaxy collection)
- cloud-init user-data
- Helm chart
- Docker Compose
validations:
Expand All @@ -24,7 +25,7 @@ body:
id: install
attributes:
label: Install method
description: Ansible only.
description: Ansible only (cloud-init is always release).
options:
- release (signed tarball)
- manual (locally built binaries)
Expand Down
1 change: 1 addition & 0 deletions .github/ISSUE_TEMPLATE/feature_request.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ body:
multiple: true
options:
- Ansible
- cloud-init
- Helm chart
- Docker Compose
- All / not specific
10 changes: 8 additions & 2 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,20 @@
# ${DECDN_IMAGE_DIGEST}, which the operator pins to a signed
# release digest; nothing to bump
# helm the chart has no dependencies, and its image is operator-set
# pip the repo has no pip manifest (CI installs are unpinned)
# pip CI installs are unpinned. The one pip manifest,
# cloud-init/requirements.txt, pins ansible-core twice with Python
# markers (2.19 for 3.11, 2.21 for 3.12+) and is compiled with uv;
# an automated bump could move the 3.11 pin to a release that
# drops 3.11
# Ansible Galaxy (ansible/requirements.yml) is not a Dependabot ecosystem.
#
# NOT covered, bump manually (CONTRIBUTING.md § Supply-chain / pinning rules):
# - the KICS and kubeconform image digests in the Makefile
# - the molecule image digests in ansible/molecule/*/molecule.yml
# - the four setup-helm `version:` inputs (two in ci.yml, two in release.yml)
# - the Galaxy collections in ansible/requirements.yml
# - the Galaxy collections in ansible/requirements.yml, and their exact pins in
# cloud-init/collections.lock.yml
# - ansible-core in cloud-init/requirements.in (then recompile requirements.txt)
# - the local yamllint hook's `additional_dependencies` pin
version: 2
updates:
Expand Down
41 changes: 36 additions & 5 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,8 @@ concurrency:
# malicious code — cf. the March 2026 KICS action compromise). The trailing
# comment records the human-readable version; .github/dependabot.yml bumps them.
jobs:
# Detect whether ansible/, the Helm chart or compose/ changed so heavy jobs skip
# unrelated PRs.
# Detect whether ansible/, the Helm chart, compose/ or cloud-init/ changed so heavy
# jobs skip unrelated PRs.
changes:
runs-on: ubuntu-latest
timeout-minutes: 5 # ~5s in practice
Expand All @@ -30,6 +30,7 @@ jobs:
ansible: ${{ steps.filter.outputs.ansible }}
helm: ${{ steps.filter.outputs.helm }}
compose: ${{ steps.filter.outputs.compose }}
cloud-init: ${{ steps.filter.outputs.cloud-init }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: dorny/paths-filter@ceb8a2b8f2d89434be7ff52d3de7ec3738c5cc9d # v4.0.3
Expand All @@ -52,6 +53,13 @@ jobs:
- 'compose/**'
- 'Makefile'
- '.github/workflows/ci.yml'
# lint-cloud-init lives in the root Makefile and reads
# ansible/requirements.yml (the collection lock must cover it).
cloud-init:
- 'cloud-init/**'
- 'ansible/requirements.yml'
- 'Makefile'
- '.github/workflows/ci.yml'

# Ansible style + best-practice + the production-profile SECURITY rules,
# plus a syntax-check of every playbook. Runs only when ansible/ changed.
Expand Down Expand Up @@ -200,6 +208,25 @@ jobs:
# DECDN_CLI=... before bumping the decdn version.
run: make lint-helm

# cloud-init path: `cloud-init schema` plus the invariants cloud-init/README.md
# promises (no secrets, release install, host-generated wallet, localhost in
# decdn_nodes, a keyed admin account, shellcheck-clean stage 1, a collection lock
# covering ansible/requirements.yml). The boot itself is the molecule `cloud-init`
# scenario (molecule.yml). Runs only when cloud-init/, ansible/requirements.yml, the
# root Makefile or ci.yml changed.
cloud-init:
needs: changes
if: needs.changes.outputs.cloud-init == 'true'
runs-on: ubuntu-latest
timeout-minutes: 10 # an apt install at most; seconds otherwise
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
# yq and shellcheck ship on ubuntu-latest; cloud-init may not.
- name: Install cloud-init
run: command -v cloud-init || { sudo apt-get update -qq && sudo apt-get install -y -qq cloud-init; }
- name: cloud-init invariants
run: make lint-cloud-init

# Docker Compose path: render compose/compose.yaml with its example env files and
# assert the invariants its README promises (host network, no published ports,
# digest-pinned image, read-only rootfs, no capabilities, graceful stop). Runs
Expand Down Expand Up @@ -343,13 +370,17 @@ jobs:
run: pre-commit run --all-files --show-diff-on-failure

# The repo's own guard rails: ansible/Makefile's scoping guards (dry runs), the
# release gate, and lint-compose's negative cases (tests/scripts-test.sh). Always
# runs: seconds, and it covers files every path filter above would miss.
# release gate, and the lint-compose and lint-cloud-init negative cases
# (tests/scripts-test.sh). Always runs: seconds, and it covers files every path
# filter above would miss.
scripts:
runs-on: ubuntu-latest
timeout-minutes: 5
timeout-minutes: 10 # an apt install at most; seconds otherwise
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
# Without cloud-init on PATH the harness SKIPS the lint-cloud-init cases.
- name: Install cloud-init
run: command -v cloud-init || { sudo apt-get update -qq && sudo apt-get install -y -qq cloud-init; }
- name: Script and guard tests
run: make test-scripts

Expand Down
8 changes: 5 additions & 3 deletions .github/workflows/molecule.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,15 @@ name: Molecule
# Heavy (privileged systemd Docker container) — scoped to ansible/ changes and
# blocking. Mark it a required status check in branch protection once proven.
# This file is in `paths` alongside ansible/ so a change to the job itself (its cache
# wiring, JOBS, the timeout) is exercised by the PR that makes it.
# wiring, JOBS, the timeout) is exercised by the PR that makes it. cloud-init/ is in
# `paths` because the `cloud-init` scenario boots cloud-init/user-data.yaml and runs
# cloud-init/bootstrap.sh.
on:
pull_request:
paths: ['ansible/**', '.github/workflows/molecule.yml']
paths: ['ansible/**', 'cloud-init/**', '.github/workflows/molecule.yml']
push:
branches: [main]
paths: ['ansible/**', '.github/workflows/molecule.yml']
paths: ['ansible/**', 'cloud-init/**', '.github/workflows/molecule.yml']

permissions:
contents: read
Expand Down
9 changes: 5 additions & 4 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -58,17 +58,18 @@ repos:
hooks:
- id: markdownlint-cli2

# ── YAML lint for the Ansible tree (reuses ansible/.yamllint) ──────────────
# Scoped to ansible/ so it stays in lockstep with `make -C ansible lint`.
# ── YAML lint for the Ansible tree and cloud-init/ (reuses ansible/.yamllint) ─
# Scoped so it stays in lockstep with `make -C ansible lint`. cloud-init/ is the
# Ansible project's user-data and collection lock, so it takes the same rules.
# *.yml.example templates don't match (they end in .example) and are skipped.
- repo: local
hooks:
- id: yamllint-ansible
name: yamllint (ansible/)
name: yamllint (ansible/, cloud-init/)
entry: yamllint -c ansible/.yamllint
language: python
additional_dependencies: ["yamllint==1.35.1"]
files: ^ansible/.*\.(ya?ml)$
files: ^(ansible|cloud-init)/.*\.(ya?ml)$
exclude: ^ansible/(collections|\.ansible)/

# Opt-in: full ansible-lint locally (needs `make -C ansible deps` first).
Expand Down
36 changes: 30 additions & 6 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,11 @@ DevOps repo.
## What this repo is

The official DevOps project for deploying a **deCDN node**: infrastructure, deployment,
and operational tooling, for node operators anywhere. There are three deploy paths:
and operational tooling, for node operators anywhere. There are four deploy paths:
**Ansible** (`ansible/`, VMs/bare metal, the primary path, also the `decdn.node` Galaxy
collection), **Docker Compose** (`compose/`, a single Docker host) and a **Helm chart**
(`charts/decdn-node/`, Kubernetes).
collection), **cloud-init** (`cloud-init/`, one VM that runs the Ansible playbook on
itself, no control machine), **Docker Compose** (`compose/`, a single Docker host) and a
**Helm chart** (`charts/decdn-node/`, Kubernetes).

This repo is **infrastructure only**. It is *not* a source of truth for protocol or
economic claims — those trace to the deCDN ADRs. If something here states a protocol fact
Expand Down Expand Up @@ -56,6 +57,7 @@ ansible/ # the deployment project (DevSec-hardened, lean roles)
playbooks/ # site.yml (decdn node), backup.yml, decommission.yml
roles/ # baseline, decdn_node, grafana_alloy
inventory/ galaxy/ molecule/ # see ansible/README.md
cloud-init/ # user-data.yaml + on-host bootstrap.sh; pinned ansible-core/collections (see its README.md)
compose/ # Docker Compose deploy path for a single host (see its README.md)
charts/
decdn-node/ # Helm chart for the node on Kubernetes (see its README.md)
Expand Down Expand Up @@ -123,6 +125,26 @@ addresses) the repo carries, and they carry their upstream commit.
everything, so only the real pinned binary proves the rendered config loads. See
`ansible/roles/grafana_alloy/README.md`.

- **`cloud-init/`** — the Ansible path with no control machine. `user-data.yaml` carries
only public material (the lint refuses secret-looking keys, credentials in URLs and
unknown `bootstrap.env` keys) and a stage-1 `decdn-bootstrap`. That script clones this
repo at a pinned ref (a full SHA is verified after checkout) and execs
`cloud-init/bootstrap.sh`, which:
- installs ansible-core from the hash-locked `requirements.txt` into a venv (two pins
split by Python marker: 2.19 for Debian 12's 3.11, 2.21 for 3.12 and later);
- installs the exact collections from `collections.lock.yml`;
- runs `site.yml` against localhost. The inventory must put localhost in
`decdn_nodes`, or the play matches nothing and the udp/4433 hole never loads.

With no `/etc/decdn/decdn.env`, it runs `--tags baseline` only and records
`awaiting-secret`. The operator writes the file over SSH and re-runs `decdn-bootstrap`
for the full playbook (`release` install, host-generated wallet). The roles are used
unchanged, so a role change reaches this path without edits here. When
`ansible/requirements.yml` changes, re-sync the lock: `make lint-cloud-init` checks
it covers the requirements. The molecule `cloud-init` scenario boots the real
user-data through cloud-init (skipping `baseline`) against a locally signed release
mirror, and is the suite's only coverage of the release download and verify path.

- **`compose/`** — the same node under Docker Compose on one host: the upstream image,
always by digest (`compose.yaml` builds `DECDN_IMAGE_REPO@DECDN_IMAGE_DIGEST`), the
role's host layout (`/etc/decdn` read-only, `/var/lib/decdn`), host
Expand Down Expand Up @@ -161,7 +183,8 @@ make molecule # every ansible/molecule/*/ scenario in parallel (Docker;
make lint-helm # chart: lint + render tests + kubeconform + schema keys
make lint-alloy # grafana_alloy config against the real pinned Alloy binary
make lint-compose # compose/ invariants
make test-scripts # Makefile guards, release gate, lint-compose negatives
make lint-cloud-init # cloud-init/user-data.yaml: schema + invariants (no secrets, release mode, lock)
make test-scripts # Makefile guards, release gate, lint-compose/lint-cloud-init negatives
make security # KICS over ansible/, the rendered chart and compose/

# Ansible — run from ansible/
Expand Down Expand Up @@ -189,6 +212,7 @@ repository variable is `true` (RELEASING.md). Log changes under `[Unreleased]` i
`ansible/galaxy/CHANGELOG.md` and `charts/decdn-node/CHANGELOG.md`.

**CI.** `ci.yml` is the blocking gate: `pre-commit`, `scripts` and `actionlint` on every PR, the
Ansible, chart and compose jobs path-filtered, KICS on any of them; `molecule.yml` runs the
molecule suite on `ansible/**`. `ansible-lint` is **not** a per-commit hook (it needs
Ansible, chart, compose and cloud-init jobs path-filtered, KICS on the first three (KICS has
no cloud-init platform); `molecule.yml` runs the molecule suite on `ansible/**` and
`cloud-init/**`. `ansible-lint` is **not** a per-commit hook (it needs
collections vendored): run `make lint-ansible`.
19 changes: 14 additions & 5 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,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 (digest-pinned image), 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 its example env and asserts its security invariants |
| `make test-scripts` | `tests/scripts-test.sh`: the `ansible/Makefile` scoping guards (dry runs), the release gate, and `lint-compose`'s negative cases. `UPSTREAM=<decdn checkout>` adds the sync generators' exit codes. |
| `make lint-cloud-init` | `cloud-init schema` on `cloud-init/user-data.yaml`, then `cloud-init/tests/lint.py`: no secrets (only the bootstrap's own files, no secret-looking keys or assignments), no hardening skip, a `release` install verified against the vendored key with a host-generated wallet (trust knobs only in `decdn_nodes.vars`), localhost in `decdn_nodes`, 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 release gate, and the negative cases of `lint-compose` and `lint-cloud-init` (the latter skipped without `cloud-init` on PATH). `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` collection and run galaxy-importer's checks |

Expand Down Expand Up @@ -66,8 +67,13 @@ reports the two differently.
- on `charts/**` (or the shared schema files, the root `Makefile`, `ci.yml`): `helm`
(`make lint-helm`);
- on `compose/**` (or the root `Makefile`, `ci.yml`): `compose` (`make lint-compose`);
- on any of those: `kics` (`make security`).
- **`molecule.yml`**: `make molecule JOBS=3` on `ansible/**` changes.
- on `cloud-init/**` (or `ansible/requirements.yml`, the root `Makefile`, `ci.yml`):
`cloud-init` (`make lint-cloud-init`);
- on the Ansible, chart or compose paths: `kics` (`make security`). KICS has no
cloud-init platform.
- **`molecule.yml`**: `make molecule JOBS=3` on `ansible/**` or `cloud-init/**` changes.
The `cloud-init` scenario boots the real user-data, so it needs network access to apt,
PyPI and Galaxy.
- **`release.yml`**: on `vX.Y.Z` tags; see [RELEASING.md](RELEASING.md).
- **`upstream-drift.yml`**: weekly, non-blocking; see "Upstream mirrors" above.
- **Every job is bounded** by `timeout-minutes`. The values are bounds sized off
Expand Down Expand Up @@ -103,8 +109,11 @@ reports the two differently.
`KUBECONFORM_IMAGE` digests in the `Makefile`; the molecule image digests in
`ansible/molecule/*/molecule.yml` (all together, `docker buildx imagetools inspect`);
the four `setup-helm` `version:` inputs (`ci.yml`'s `helm` and `kics` jobs, both
jobs in `release.yml`); the collection versions in `ansible/requirements.yml`; and the local
yamllint hook's `additional_dependencies` pin.
jobs in `release.yml`); the collection versions in `ansible/requirements.yml`, and
their exact pins in `cloud-init/collections.lock.yml` (the full transitive set, from
a fresh `make deps` resolve); ansible-core in `cloud-init/requirements.in`, followed by a
recompile of the hash-locked `requirements.txt` (command in `cloud-init/README.md`);
and the local yamllint hook's `additional_dependencies` pin.
- **Bump the `cache-epoch:` counter in `ansible/requirements.yml` to make CI
re-resolve the collections.** Those are `>=` ranges, so a warm cache pins the
resolved set — transitive collections like `community.crypto` included — until the
Expand Down
24 changes: 20 additions & 4 deletions Makefile
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Convenience targets for the deCDN DevOps monorepo.
# Run from the repo root. Ansible-specific work is delegated to ansible/Makefile.
.PHONY: help hooks lint lint-ansible lint-helm lint-alloy lint-compose test-scripts security security-ansible security-helm security-compose molecule molecule-serial galaxy-build galaxy-check
.PHONY: help hooks lint lint-ansible lint-helm lint-alloy lint-compose lint-cloud-init test-scripts security security-ansible security-helm security-compose molecule molecule-serial galaxy-build galaxy-check
SHELL := /bin/bash

# KICS runs straight from the engine image, pinned by digest. This target IS the
Expand Down Expand Up @@ -91,10 +91,26 @@ lint-compose: ## render compose/ with its examples and check its security
|| { echo "$(COMPOSE_FILE) violates an invariant (see the lint-compose comment in Makefile)" >&2; exit 1; }
@echo "compose invariants hold"

# The cloud-init user-data (cloud-init/README.md): `cloud-init schema` for its shape, then
# cloud-init/tests/lint.py for what a schema cannot see. That covers no secrets, no
# hardening skip, a signed release install with a host-generated wallet, localhost in
# decdn_nodes, a keyed admin account, runcmd exactly stage 1, shellcheck-clean scripts,
# and a collection lock that covers ansible/requirements.yml.
# CLOUD_INIT_FILE is overridable so operators can check their filled-in copy and
# tests/scripts-test.sh can feed it broken variants.
CLOUD_INIT_FILE ?= cloud-init/user-data.yaml
lint-cloud-init: ## schema-check cloud-init/user-data.yaml and its invariants (needs cloud-init, shellcheck, yq)
@command -v cloud-init >/dev/null || { echo "lint-cloud-init: needs cloud-init on PATH" >&2; exit 2; }
@cloud-init schema -c '$(CLOUD_INIT_FILE)' >/dev/null 2>&1 \
|| { cloud-init schema -c '$(CLOUD_INIT_FILE)' 2>&1 | grep -v WARNING >&2; \
echo "lint-cloud-init: $(CLOUD_INIT_FILE) is not a valid cloud-config (see above)" >&2; exit 2; }
@cloud-init/tests/lint.py '$(CLOUD_INIT_FILE)'
@echo "cloud-init invariants hold"

# The guard rails nothing else exercises: ansible/Makefile's scoping guards, the
# release gate, lint-compose's negative cases, and (with UPSTREAM=<decdn checkout>)
# the upstream-mirror generators' exit codes. CI job `scripts`.
test-scripts: ## test the Makefile guards, release gate and lint-compose negatives (needs docker, jq)
# release gate, the lint-compose and lint-cloud-init negative cases, and (with
# UPSTREAM=<decdn checkout>) the upstream-mirror generators' exit codes. CI job `scripts`.
test-scripts: ## test the Makefile guards, release gate, lint-compose and lint-cloud-init negatives (needs docker, jq, cloud-init, yq)
tests/scripts-test.sh

lint-helm: ## helm lint + render tests + kubeconform + shared schema-key check (needs helm, yq, python3>=3.11, docker)
Expand Down
Loading
Loading