feat(cloud-init): user-data bootstrap for a no-Ansible single-VM node - #73
Merged
Merged
Conversation
Add a fourth deploy path: paste cloud-init/user-data.yaml into a VPS provider's "create server" form, and a fresh Debian 12/13 or Ubuntu 24.04/26.04 host runs this repo's site.yml against localhost. It reuses the baseline and decdn_node roles unchanged. - Stage 1 (embedded in the user-data) clones the repo at a pinned ref, checks a full SHA after checkout, and execs cloud-init/bootstrap.sh. It reads bootstrap.env as literal KEY=value lines and never sources it. - Stage 2 installs ansible-core from a hash-locked requirements.txt (2.19 for Python 3.11, 2.21 for 3.12+) and the exact collection set in collections.lock.yml. Without /etc/decdn/decdn.env it runs baseline only and records "awaiting-secret"; once the operator writes the file over SSH, `sudo decdn-bootstrap` runs the full release-mode install with a host-generated wallet. - No secrets in user-data: `make lint-cloud-init` runs cloud-init schema plus invariants (no secret-looking keys or URL credentials, release mode, keystore generated on the host, localhost in decdn_nodes, a keyed admin account, shellcheck-clean stage 1, a lock that covers ansible/requirements.yml), with negative cases in tests/scripts-test.sh. - The molecule `cloud-init` scenario boots the real user-data through cloud-init on Debian 12 and Ubuntu 26.04 against a locally signed release mirror, from the secret gate to a running node. It is the suite's first release-mode coverage. Baseline is skipped in containers, as in the other scenarios. - Docs: cloud-init/README.md, the deploy-path chooser, README, AGENTS, CONTRIBUTING, SECURITY and the issue templates. Closes #72 Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Copilot review overview
🟡 Changes recommended
Validation gaps can allow secret-bearing overrides and bypass hardening; bootstrap and dependency handling also need fixes.
Get a fresh assessment by requesting another Copilot review.
Review effort: Lite
Findings: 2
Open (2)
What changed in this PR
Adds a cloud-init deployment path that bootstraps Ansible locally on a single VM with pinned dependencies, validation, and Molecule coverage.
Changes:
- Adds staged cloud-init bootstrap scripts and dependency locks.
- Adds linting, CI, negative tests, and Molecule coverage.
- Updates deployment documentation and repository metadata.
| File | Summary |
|---|---|
tests/scripts-test.sh |
Adds cloud-init negative tests. |
SECURITY.md |
Includes cloud-init in security scope. |
README.md |
Documents the new deployment path. |
Makefile |
Adds cloud-init linting. |
docs/requirements.md |
Adds deployment-path comparison details. |
CONTRIBUTING.md |
Documents checks and dependency pinning. |
compose/README.md |
Updates deployment references. |
cloud-init/user-data.yaml |
Provides staged bootstrap configuration. |
cloud-init/tests/lint.py |
Validates cloud-init invariants. |
cloud-init/requirements.txt |
Pins hashed Python dependencies. |
cloud-init/requirements.in |
Defines Ansible-core pins. |
cloud-init/README.md |
Documents setup and operation. |
cloud-init/collections.lock.yml |
Pins Galaxy collections. |
cloud-init/bootstrap.sh |
Installs tooling and runs Ansible. |
ansible/molecule/cloud-init/verify.yml |
Verifies completed bootstrap. |
ansible/molecule/cloud-init/side_effect.yml |
Simulates secret provisioning. |
ansible/molecule/cloud-init/prepare.yml |
Prepares the test environment. |
ansible/molecule/cloud-init/molecule.yml |
Defines the cloud-init scenario. |
ansible/molecule/cloud-init/converge.yml |
Boots the seeded user-data. |
AGENTS.md |
Documents cloud-init guidance. |
.pre-commit-config.yaml |
Extends YAML linting coverage. |
.github/workflows/molecule.yml |
Runs cloud-init Molecule coverage. |
.github/workflows/ci.yml |
Adds cloud-init CI validation. |
.github/ISSUE_TEMPLATE/feature_request.yml |
Adds cloud-init options. |
.github/ISSUE_TEMPLATE/bug_report.yml |
Adds cloud-init reporting options. |
.github/dependabot.yml |
Documents manual dependency updates. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Address the Copilot review and a four-agent review (code, tests, silent failures, docs) of #73. Lint (cloud-init/tests/lint.py): - write_files may write only the bootstrap's own four files, as plain text. Secrets in /etc/decdn/decdn.env, /etc/grafana-alloy.env or b64 content no longer pass. - Reject decdn_extra_env, and any NAME=value assignment of a secret-looking variable in file contents or commands. - The trust knobs (install method, keystore generation, signature verification) may be set only in decdn_nodes.vars, where they are checked. A host var no longer overrides them. decdn_release_keyring and decdn_env_file are refused, and the inventory may hold only the decdn_nodes group. - runcmd must be exactly stage 1, so `|| true` cannot mask a failure. - No mention of the test-only baseline switch anywhere. - Allowlist non-secret knobs with secret-looking names (baseline_sudo_passwordless, keystore/secret file paths). Bootstrap: - Drop DECDN_BOOTSTRAP_ANSIBLE_ARGS: user-data carried free-form ansible arguments (extra-vars, --skip-tags baseline). Molecule now uses a fixed test-only marker file that bootstrap.sh honours and the lint rejects. - Stage 1 takes a lock and records "running". An EXIT trap plus signal traps in both stages record "failed" on any non-zero exit, including stage-1 refusals and a dropped SSH session. - The login hint is now written by the user-data, so it exists even if stage 1 fails. It reports a "running" state whose process is gone as interrupted. - Refuse to run phase 1 unless `--tags baseline` still selects baseline tasks, so a renamed tag cannot report an unhardened host as hardened. Tests: - Every lint-cloud-init negative case now names the invariant it expects: 22 variants, plus a positive case for the allowlisted knobs. - The harness fails instead of skipping in CI when cloud-init is missing, and make lint-cloud-init reports a missing cloud-init as such. - Molecule checks that stage 1 refuses a branch name, the placeholder ref, an unknown key and a loose mode, each recording "failed" with the hint saying so and the checkout unmoved. It then upgrades to a tag on a newer commit before the secret run. Docs: fix the boot order, hint behaviour, admin login timing, the wallet address (via `decdn whoami`), tag mutability, the collection bump procedure and several stale comments. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.

Closes #72.
What
This adds a fourth deploy path,
cloud-init/. The operator pastesuser-data.yamlinto a VPS provider's "create server" form. A fresh Debian 12/13 or Ubuntu 24.04/26.04 host (x86_64 or aarch64) then runs this repo'ssite.ymlagainst localhost. Thebaselinehardening and thedecdn_noderole are reused unchanged, so there is no second copy of either.Decisions on the issue's open questions
ansible-pullpattern. It pulls a pinned ref and runssite.ymllocally, with hardening included. It doesn't call theansible-pullbinary itself: ansible-core is installed from a hash-locked file inside the checkout, so the clone has to happen before Ansible exists./var/lib/decdn-bootstrap/state(running/awaiting-secret/complete/failed). Any non-zero exit recordsfailed: a stage-1 refusal, a failed command, or a signal such as a dropped SSH session;/etc/profile.dlogin hint, written by the user-data so it exists even if stage 1 fails. It reports arunningstate whose process has gone as interrupted. It isn't a MOTD, because DevSecssh_hardeningturns the PAM motd off;final_message;cloud-init status(done, orerroron failure).decdn_regionis a user-data value. It isn't a secret.How it works
decdn-bootstrap, embedded in the user-data.bootstrap.envas literalKEY=valuelines. It never sources the file and rejects unknown keys.DEVOPS_REF(a full SHA is checked after checkout; a tag is also accepted) and execscloud-init/bootstrap.sh.cloud-init/bootstrap.sh, in the repo, so shellcheck covers it.requirements.txt, using pip--require-hashes --only-binary=:all:. There are two pins split by Python marker, because no single release covers 3.11 through 3.14: 2.19.13 for Debian 12's 3.11, and 2.21.4 for 3.12 and later.collections.lock.yml, transitive ones included.decdn_nodes. Without that, the play matches nothing and exits 0, and the udp/4433 hole never loads.--tags baselinestill selects baseline tasks, so a renamed tag can't make it report an unhardened host as hardened./etc/decdn/decdn.envit runs--tags baselineonly and recordsawaiting-secret. After the operator writes the file over SSH,sudo decdn-bootstrapruns the full playbook: arelease-mode install, a host-generated wallet and a started service.No secrets in user-data. The file holds only an SSH public key, a version, a region and a repo URL. There is no way to pass Ansible arguments from user-data. The CI-only baseline skip is a fixed marker file that molecule writes outside the user-data.
Checks
make lint-cloud-init(new CI jobcloud-init) runscloud-init schema, thencloud-init/tests/lint.py, which checks:write_fileswrites only the bootstrap's own four files, as plain text;decdn_extra_envincluded), noNAME=valueassignment of a secret-looking variable anywhere, no credentials in URLs, and only knownbootstrap.envkeys;releasemode,decdn_node_generate_keystore: trueand signature verification left on, all set only indecdn_nodes.vars, so a host var can't override them.decdn_release_keyringanddecdn_env_fileare refused;decdn_nodeswith a local connection, and a keyed admin account;runcmdexactly stage 1, so|| truecan't mask a failure;ansible/requirements.yml.tests/scripts-test.shchecks that it rejects 22 broken variants, each on the specific message it expects. A positive case covers the non-secret knobs whose names look secret. ThescriptsCI job installs cloud-init, and the harness fails rather than skips if it is missing in CI.cloud-initscenario boots the real user-data through cloud-init's four stages in Debian 12 and Ubuntu 26.04 systemd containers:awaiting-secretwith nothing of the node installed.failedwith the hint saying so and the checkout unmoved.DEVOPS_REFto a tag on a newer commit (the upgrade path), writesdecdn.env, re-runsdecdn-bootstrap, and asserts that the checkout is at the tag, plus a release-mode install (the version stamp), a host-generated0600wallet, a runningdecdn-node, and exactly the pinned ansible-core and collections.molecule.ymlnow also triggers oncloud-init/**.cloud-init/.Local results:
molecule test -s cloud-initpasses on both platforms.make lint-cloud-init,make test-scripts,make lint(pre-commit),make lint-ansible(production profile) and actionlint pass.make security-ansiblefinds 0 HIGH, with the same counts as before.Running molecule caught one real bug before commit: stage 1 originally sourced
bootstrap.env, so a value with a space (--skip-tags baseline) ranbaselineas a command. It now parses the file strictly.Review follow-up (7b666c1) covers the Copilot findings plus a code, test, silent-failure and doc review: lint bypasses, failure-state gaps, and doc accuracy. Everything above was re-run locally: molecule on both platforms, the script tests, pre-commit and
make lint-ansible.Known limits / follow-ups
decdn/decdnhas no release tag yet, so a real VM needs a self-hosted mirror viadecdn_node_release_base(documented). No change is needed once upstream tags a release.baselineis not exercised in the containers, the same boundary as every other scenario. On real hosts it runs through--tags baseline. Before relying on this I checked that role-level tags reach tasks inside baseline'sinclude_tasks/include_roleon ansible-core 2.19 and 2.21. Still worth one boot on a real VPS before relying on it.dependabot.ymland CONTRIBUTING.lint-cloud-initis the gate for this path.user_dataoption is documented incloud-init/README.md.🤖 Generated with Claude Code