Skip to content

feat(cloud-init): user-data bootstrap for a no-Ansible single-VM node - #73

Merged
thiras merged 2 commits into
mainfrom
feat/cloud-init-bootstrap
Sep 24, 2026
Merged

thiras merged 2 commits into
mainfrom
feat/cloud-init-bootstrap

Conversation

@thiras

@thiras thiras commented Sep 24, 2026 •

Copy link
Copy Markdown
Contributor

Closes #72.

What

This adds a fourth deploy path, cloud-init/. The operator pastes user-data.yaml into 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's site.yml against localhost. The baseline hardening and the decdn_node role are reused unchanged, so there is no second copy of either.

Decisions on the issue's open questions

  • Approach: the ansible-pull pattern. It pulls a pinned ref and runs site.yml locally, with hardening included. It doesn't call the ansible-pull binary itself: ansible-core is installed from a hash-locked file inside the checkout, so the clone has to happen before Ansible exists.
  • Ready signal:
    • /var/lib/decdn-bootstrap/state (running / awaiting-secret / complete / failed). Any non-zero exit records failed: a stage-1 refusal, a failed command, or a signal such as a dropped SSH session;
    • an /etc/profile.d login hint, written by the user-data so it exists even if stage 1 fails. It reports a running state whose process has gone as interrupted. It isn't a MOTD, because DevSec ssh_hardening turns the PAM motd off;
    • cloud-init's final_message;
    • cloud-init status (done, or error on failure).
  • Region: decdn_region is a user-data value. It isn't a secret.

How it works

  1. Stage 1 is decdn-bootstrap, embedded in the user-data.
    • It takes a lock, so only one run happens at a time.
    • It reads bootstrap.env as literal KEY=value lines. It never sources the file and rejects unknown keys.
    • It clones the repo at DEVOPS_REF (a full SHA is checked after checkout; a tag is also accepted) and execs cloud-init/bootstrap.sh.
  2. Stage 2 is cloud-init/bootstrap.sh, in the repo, so shellcheck covers it.
    • It creates a venv with ansible-core from 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.
    • It installs the exact collections from collections.lock.yml, transitive ones included.
    • It syntax-checks the playbook and asserts localhost is in decdn_nodes. Without that, the play matches nothing and exits 0, and the udp/4433 hole never loads.
    • It asserts that --tags baseline still selects baseline tasks, so a renamed tag can't make it report an unhardened host as hardened.
    • Without /etc/decdn/decdn.env it runs --tags baseline only and records awaiting-secret. After the operator writes the file over SSH, sudo decdn-bootstrap runs the full playbook: a release-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 job cloud-init) runs cloud-init schema, then cloud-init/tests/lint.py, which checks:
    • write_files writes only the bootstrap's own four files, as plain text;
    • no secret-looking keys (decdn_extra_env included), no NAME=value assignment of a secret-looking variable anywhere, no credentials in URLs, and only known bootstrap.env keys;
    • no mention of the test-only baseline switch;
    • release mode, decdn_node_generate_keystore: true and signature verification left on, all set only in decdn_nodes.vars, so a host var can't override them. decdn_release_keyring and decdn_env_file are refused;
    • localhost in decdn_nodes with a local connection, and a keyed admin account;
    • runcmd exactly stage 1, so || true can't mask a failure;
    • shellcheck-clean stage 1 and login hint;
    • a collection lock that covers ansible/requirements.yml.
  • tests/scripts-test.sh checks 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. The scripts CI job installs cloud-init, and the harness fails rather than skips if it is missing in CI.
  • The molecule cloud-init scenario boots the real user-data through cloud-init's four stages in Debian 12 and Ubuntu 26.04 systemd containers:
    • It seeds NoCloud with the committed file. Placeholders are filled, and the repo, mirror and key point at local copies.
    • It asserts the host stopped at awaiting-secret with nothing of the node installed.
    • It checks that stage 1 refuses a branch name, the placeholder ref, an unknown key and a loose file mode, each recording failed with the hint saying so and the checkout unmoved.
    • It then moves DEVOPS_REF to a tag on a newer commit (the upgrade path), writes decdn.env, re-runs decdn-bootstrap, and asserts that the checkout is at the tag, plus a release-mode install (the version stamp), a host-generated 0600 wallet, a running decdn-node, and exactly the pinned ansible-core and collections.
    • The release comes from a loopback mirror of the stub daemon, signed with a throwaway key. This is the suite's first release-mode coverage.
    • molecule.yml now also triggers on cloud-init/**.
  • pre-commit's yamllint hook now covers cloud-init/.

Local results: molecule test -s cloud-init passes on both platforms. make lint-cloud-init, make test-scripts, make lint (pre-commit), make lint-ansible (production profile) and actionlint pass. make security-ansible finds 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) ran baseline as 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

  • Blocked on an upstream release, as the issue says. Upstream decdn/decdn has no release tag yet, so a real VM needs a self-hosted mirror via decdn_node_release_base (documented). No change is needed once upstream tags a release.
  • baseline is 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's include_tasks / include_role on ansible-core 2.19 and 2.21. Still worth one boot on a real VPS before relying on it.
  • No Dependabot for the pip lock. An automated bump could move the 3.11 pin to a release that drops 3.11. The pins are listed as manual bumps in dependabot.yml and CONTRIBUTING.
  • KICS has no cloud-init platform, so lint-cloud-init is the gate for this path.
  • Terraform integration for DigitalOcean VPS and Block Storage #48 cross-reference: not posted yet. The Droplet user_data option is documented in cloud-init/README.md.

🤖 Generated with Claude Code

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>
Copilot AI lite review requested due to automatic review settings September 24, 2026 01:28

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 High severity

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.

Comment thread cloud-init/tests/lint.py Outdated
Comment thread cloud-init/user-data.yaml Outdated
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>
@thiras
thiras merged commit 5fad02f into main Sep 24, 2026
16 checks passed
@thiras
thiras deleted the feat/cloud-init-bootstrap branch September 24, 2026 03:23
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

cloud-init user-data for a no-Ansible single-VM node bootstrap

2 participants