From 3f1d7eb128c5797bb33a0410d453a2d428425642 Mon Sep 17 00:00:00 2001 From: sjat Date: Sat, 30 May 2026 14:10:01 +0200 Subject: [PATCH] Add core Ansible scaffold, tooling, and pre-commit guards Co-Authored-By: Claude Opus 4.8 (1M context) --- .ansible-lint | 19 +++ .claude/commands/deploy.md | 12 ++ .claude/commands/lint.md | 6 + .claude/commands/new-role.md | 17 ++ .docker/molecule-debian13/Dockerfile | 25 +++ .gitignore | 33 ++++ .pre-commit-config.yaml | 40 +++++ .scaffold/converge.yml | 7 + .scaffold/molecule.yml | 31 ++++ .scaffold/verify.yml | 11 ++ .yamllint | 20 +++ Makefile | 158 ++++++++++++++++++ ansible.cfg | 13 ++ inventories/README.md | 11 ++ .../production/group_vars/all/vars.yml | 38 +++++ .../production/group_vars/all/vault.yml | 7 + inventories/production/hosts.yml | 28 ++++ inventories/staging/hosts.yml | 12 ++ playbooks/README.md | 12 ++ playbooks/bootstrap.yml | 36 ++++ playbooks/site.yml | 17 ++ requirements.txt | 11 ++ requirements.yml | 13 ++ roles/README.md | 12 ++ scripts/README.md | 7 + scripts/check-vault-encrypted.sh | 35 ++++ scripts/tf_to_inventory.py | 74 ++++++++ 27 files changed, 705 insertions(+) create mode 100644 .ansible-lint create mode 100644 .claude/commands/deploy.md create mode 100644 .claude/commands/lint.md create mode 100644 .claude/commands/new-role.md create mode 100644 .docker/molecule-debian13/Dockerfile create mode 100644 .gitignore create mode 100644 .pre-commit-config.yaml create mode 100644 .scaffold/converge.yml create mode 100644 .scaffold/molecule.yml create mode 100644 .scaffold/verify.yml create mode 100644 .yamllint create mode 100644 Makefile create mode 100644 ansible.cfg create mode 100644 inventories/README.md create mode 100644 inventories/production/group_vars/all/vars.yml create mode 100644 inventories/production/group_vars/all/vault.yml create mode 100644 inventories/production/hosts.yml create mode 100644 inventories/staging/hosts.yml create mode 100644 playbooks/README.md create mode 100644 playbooks/bootstrap.yml create mode 100644 playbooks/site.yml create mode 100644 requirements.txt create mode 100644 requirements.yml create mode 100644 roles/README.md create mode 100644 scripts/README.md create mode 100755 scripts/check-vault-encrypted.sh create mode 100644 scripts/tf_to_inventory.py diff --git a/.ansible-lint b/.ansible-lint new file mode 100644 index 0000000..1a298da --- /dev/null +++ b/.ansible-lint @@ -0,0 +1,19 @@ +--- +profile: production + +# Exclude paths Ansible-lint should not check +exclude_paths: + - .venv/ + - .collections/ + - .scaffold/ + +# Warn only (don't fail) on these rules during initial setup +# Remove entries as the codebase matures +warn_list: + - experimental + +# Skip rules that conflict with our conventions +skip_list: [] + +# Enforce FQCN for all builtin modules +use_default_rules: true diff --git a/.claude/commands/deploy.md b/.claude/commands/deploy.md new file mode 100644 index 0000000..48933eb --- /dev/null +++ b/.claude/commands/deploy.md @@ -0,0 +1,12 @@ +# Run a playbook against production + +Playbook: $ARGUMENTS + +## Steps — do not skip any + +1. Run `make lint` — stop and report if it fails +2. Run `make check PLAYBOOK=$ARGUMENTS` — show the full diff output +3. Summarise what will change (hosts affected, tasks that will run) +4. **Stop and ask for explicit confirmation before proceeding** +5. After confirmation: run `make deploy PLAYBOOK=$ARGUMENTS` +6. Report the result — highlight any failures or unreachable hosts diff --git a/.claude/commands/lint.md b/.claude/commands/lint.md new file mode 100644 index 0000000..2b83b70 --- /dev/null +++ b/.claude/commands/lint.md @@ -0,0 +1,6 @@ +# Run the full lint suite + +1. Run `make lint` +2. If it passes: confirm with "Lint passed — no issues found" +3. If it fails: show each failure with file, line number, rule ID, and a suggested fix +4. After showing all failures, ask whether to fix them automatically diff --git a/.claude/commands/new-role.md b/.claude/commands/new-role.md new file mode 100644 index 0000000..7c562fc --- /dev/null +++ b/.claude/commands/new-role.md @@ -0,0 +1,17 @@ +# Scaffold and implement a new Ansible role + +Role name: $ARGUMENTS + +## Steps + +1. Run `make new-role NAME=$ARGUMENTS` to create the directory structure +2. Update `roles/$ARGUMENTS/molecule/default/converge.yml` — replace `ROLE_NAME_PLACEHOLDER` with `$ARGUMENTS` +3. Fill in `roles/$ARGUMENTS/meta/main.yml` with role metadata (Debian 13 platform) +4. Add well-commented placeholder variables to `roles/$ARGUMENTS/defaults/main.yml` using `$ARGUMENTS__varname` namespace +5. Write initial tasks in `roles/$ARGUMENTS/tasks/main.yml`: + - Use FQCN for all modules + - Every task has a descriptive `name:` and at least one tag +6. Write `roles/$ARGUMENTS/README.md` with: purpose, variable reference table, example playbook +7. Run `make lint` — fix any issues +8. Run `make test ROLE=$ARGUMENTS` — fix any failures +9. Show a summary of what was created and any open TODOs diff --git a/.docker/molecule-debian13/Dockerfile b/.docker/molecule-debian13/Dockerfile new file mode 100644 index 0000000..55bc694 --- /dev/null +++ b/.docker/molecule-debian13/Dockerfile @@ -0,0 +1,25 @@ +FROM debian:trixie-slim + +# Required by Molecule's Docker driver to detect we're in a container +ENV container=docker +ENV DEBIAN_FRONTEND=noninteractive + +RUN apt-get update -qq \ + && apt-get install -y --no-install-recommends \ + python3 \ + sudo \ + systemd \ + systemd-sysv \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* \ + # Remove units that fail or are irrelevant inside a container + && rm -f \ + /lib/systemd/system/multi-user.target.wants/* \ + /lib/systemd/system/local-fs.target.wants/* \ + /lib/systemd/system/sockets.target.wants/*udev* \ + /lib/systemd/system/sockets.target.wants/*initctl* \ + /lib/systemd/system/sysinit.target.wants/systemd-tmpfiles-setup* \ + /etc/systemd/system/*.wants/* + +VOLUME ["/sys/fs/cgroup"] +CMD ["/lib/systemd/systemd"] diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..14bec1a --- /dev/null +++ b/.gitignore @@ -0,0 +1,33 @@ +# Secrets — never commit these +.vault_pass + +# Python environment +.venv/ +__pycache__/ +*.pyc +*.pyo + +# Ansible collections (installed, not versioned) +.collections/ + +# Molecule ephemeral state +.molecule/ +molecule/**/molecule.yml.bak + +# Editor artifacts +.idea/ +.vscode/ +*.swp +*.swo +*~ + +# OS artifacts +.DS_Store +Thumbs.db + +# Terraform +terraform/**/.terraform/ +terraform/**/*.tfstate +terraform/**/*.tfstate.backup +terraform/**/terraform.tfvars +# .terraform.lock.hcl is intentionally tracked (pins provider versions) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..f325864 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,40 @@ +--- +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.6.0 + hooks: + - id: trailing-whitespace + - id: end-of-file-fixer + - id: check-merge-conflict + - id: check-yaml + args: [--unsafe] # allow custom YAML tags used by Ansible + + - repo: https://github.com/adrienverge/yamllint + rev: v1.35.1 + hooks: + - id: yamllint + args: [-c, .yamllint] + + - repo: https://github.com/ansible/ansible-lint + rev: v24.9.2 + hooks: + - id: ansible-lint + additional_dependencies: + - ansible-core>=2.17 + + # Secret scanning — catches plaintext credentials before they are committed. + # Bump `rev` as new gitleaks releases land. + - repo: https://github.com/gitleaks/gitleaks + rev: v8.18.4 + hooks: + - id: gitleaks + + # Local guard: any file named vault.yml must be ansible-vault encrypted + # (or contain only comments — a documented placeholder). See scripts/. + - repo: local + hooks: + - id: vault-encrypted + name: vault.yml must be ansible-vault encrypted + entry: scripts/check-vault-encrypted.sh + language: script + files: (^|/)vault\.yml$ diff --git a/.scaffold/converge.yml b/.scaffold/converge.yml new file mode 100644 index 0000000..1657c25 --- /dev/null +++ b/.scaffold/converge.yml @@ -0,0 +1,7 @@ +--- +- name: Converge + hosts: all + gather_facts: true + + roles: + - role: ROLE_NAME_PLACEHOLDER diff --git a/.scaffold/molecule.yml b/.scaffold/molecule.yml new file mode 100644 index 0000000..51818d7 --- /dev/null +++ b/.scaffold/molecule.yml @@ -0,0 +1,31 @@ +--- +dependency: + name: galaxy + options: + requirements-file: ../../requirements.yml + +driver: + name: docker + +platforms: + - name: instance + # Project-owned image built from .docker/molecule-debian13/Dockerfile + # and hosted in the Forgejo container registry. + # Build/push with: make molecule-image / make molecule-image-push + image: git.baobab.band///molecule-debian13:latest + pre_build_image: true + privileged: true # required for systemd + cgroupns_mode: host + volumes: + - /sys/fs/cgroup:/sys/fs/cgroup:rw + command: /lib/systemd/systemd + +provisioner: + name: ansible + inventory: + host_vars: + instance: + ansible_user: root + +verifier: + name: ansible diff --git a/.scaffold/verify.yml b/.scaffold/verify.yml new file mode 100644 index 0000000..c87d14e --- /dev/null +++ b/.scaffold/verify.yml @@ -0,0 +1,11 @@ +--- +- name: Verify + hosts: all + gather_facts: true + + tasks: + - name: Add verification tasks here + ansible.builtin.assert: + that: true + msg: "Replace this with real assertions" + tags: [verify] diff --git a/.yamllint b/.yamllint new file mode 100644 index 0000000..aa9214a --- /dev/null +++ b/.yamllint @@ -0,0 +1,20 @@ +--- +extends: default + +rules: + line-length: + max: 120 + level: warning + truthy: + allowed-values: ['true', 'false'] + check-keys: true + comments: + min-spaces-from-content: 1 + braces: + min-spaces-inside: 0 + max-spaces-inside: 1 + +ignore: | + .venv/ + .collections/ + .scaffold/ diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..37ede35 --- /dev/null +++ b/Makefile @@ -0,0 +1,158 @@ +# Ansible + Terraform homelab monorepo — Makefile +# All operations go through these targets. Never run ansible-playbook or terraform directly. + +VENV := .venv +PYTHON := $(VENV)/bin/python +PIP := $(VENV)/bin/pip +ANSIBLE := $(VENV)/bin/ansible +PLAYBOOK := $(VENV)/bin/ansible-playbook +GALAXY := $(VENV)/bin/ansible-galaxy +LINT := $(VENV)/bin/ansible-lint +MOLECULE := $(VENV)/bin/molecule +VAULT_ARGS := --vault-password-file .vault_pass +INVENTORY := -i inventories/production/hosts.yml + +TF := terraform +TF_ENV ?= staging +MOLECULE_IMAGE := git.baobab.band///molecule-debian13:latest +MOLECULE_DOCKERFILE := .docker/molecule-debian13/Dockerfile + +.DEFAULT_GOAL := help + +.PHONY: help setup collections lint test test-all check deploy encrypt decrypt new-role \ + tf-init tf-plan tf-apply tf-output tf-inventory \ + molecule-image molecule-image-push + +help: + @echo "" + @echo "Ansible homelab — available targets:" + @echo "" + @echo " make setup Create venv and install Python deps" + @echo " make collections Install Ansible collections" + @echo " make lint Run yamllint + ansible-lint" + @echo " make test ROLE= Run Molecule tests for a role" + @echo " make test-all Run Molecule tests for all roles" + @echo " make check PLAYBOOK= Dry-run a playbook (check mode)" + @echo " make deploy PLAYBOOK= Run a playbook against production" + @echo " make encrypt FILE= Encrypt a vault file" + @echo " make decrypt FILE= Decrypt a vault file" + @echo " make new-role NAME= Scaffold a new role" + @echo "" + @echo " make tf-init [TF_ENV=staging] Initialise Terraform providers" + @echo " make tf-plan [TF_ENV=staging] Show Terraform plan" + @echo " make tf-apply [TF_ENV=staging] Apply Terraform changes" + @echo " make tf-output [TF_ENV=staging] Print Terraform outputs as JSON" + @echo " make tf-inventory [TF_ENV=staging] Regenerate Ansible inventory from Terraform outputs" + @echo "" + @echo " TF_ENV defaults to 'staging'. Use TF_ENV=production for production." + @echo "" + @echo " make molecule-image Build the Molecule test image locally" + @echo " make molecule-image-push Push the test image to the Forgejo registry" + @echo "" + +# ── Environment setup ───────────────────────────────────────────────────────── + +setup: + python3 -m venv $(VENV) + $(PIP) install --upgrade pip + $(PIP) install -r requirements.txt + @echo "Venv ready. Activate with: source $(VENV)/bin/activate" + +collections: + $(GALAXY) collection install -r requirements.yml --upgrade + +# ── Linting ─────────────────────────────────────────────────────────────────── + +lint: + $(VENV)/bin/yamllint . + $(LINT) + +# ── Testing ─────────────────────────────────────────────────────────────────── + +test: +ifndef ROLE + $(error ROLE is required: make test ROLE=) +endif + cd roles/$(ROLE) && ../../$(MOLECULE) test + +test-all: + @for role in roles/*/; do \ + echo "── Testing $$role ──"; \ + cd $$role && ../../$(MOLECULE) test; cd ../..; \ + done + +# ── Playbook execution ──────────────────────────────────────────────────────── + +check: +ifndef PLAYBOOK + $(error PLAYBOOK is required: make check PLAYBOOK=) +endif + $(PLAYBOOK) $(INVENTORY) $(VAULT_ARGS) --check --diff playbooks/$(PLAYBOOK).yml + +deploy: +ifndef PLAYBOOK + $(error PLAYBOOK is required: make deploy PLAYBOOK=) +endif + $(PLAYBOOK) $(INVENTORY) $(VAULT_ARGS) playbooks/$(PLAYBOOK).yml + +# ── Vault ───────────────────────────────────────────────────────────────────── + +encrypt: +ifndef FILE + $(error FILE is required: make encrypt FILE=) +endif + $(ANSIBLE)-vault encrypt --vault-password-file .vault_pass $(FILE) + +decrypt: +ifndef FILE + $(error FILE is required: make decrypt FILE=) +endif + $(ANSIBLE)-vault decrypt --vault-password-file .vault_pass $(FILE) + +# ── Molecule test image ─────────────────────────────────────────────────────── + +molecule-image: + docker build -t $(MOLECULE_IMAGE) -f $(MOLECULE_DOCKERFILE) . + +molecule-image-push: molecule-image + docker push $(MOLECULE_IMAGE) + +# ── Terraform ───────────────────────────────────────────────────────────────── + +tf-init: + $(TF) -chdir=terraform/environments/$(TF_ENV) init + +tf-plan: + $(TF) -chdir=terraform/environments/$(TF_ENV) plan + +tf-apply: + $(TF) -chdir=terraform/environments/$(TF_ENV) apply + +tf-output: + $(TF) -chdir=terraform/environments/$(TF_ENV) output -json + +tf-inventory: +ifndef TF_ENV + $(error TF_ENV is required: make tf-inventory TF_ENV=) +endif + $(TF) -chdir=terraform/environments/$(TF_ENV) output -json \ + | $(PYTHON) scripts/tf_to_inventory.py > inventories/$(TF_ENV)/hosts.yml + @echo "Inventory written to inventories/$(TF_ENV)/hosts.yml" + +# ── Role scaffolding ────────────────────────────────────────────────────────── + +new-role: +ifndef NAME + $(error NAME is required: make new-role NAME=) +endif + mkdir -p roles/$(NAME)/{tasks,handlers,defaults,templates,files,meta,molecule/default} + echo "---" > roles/$(NAME)/tasks/main.yml + echo "---" > roles/$(NAME)/handlers/main.yml + echo "---" > roles/$(NAME)/defaults/main.yml + echo "---" > roles/$(NAME)/meta/main.yml + echo "# $(NAME)\n\nRole description here." > roles/$(NAME)/README.md + cp .scaffold/molecule.yml roles/$(NAME)/molecule/default/molecule.yml + cp .scaffold/converge.yml roles/$(NAME)/molecule/default/converge.yml + cp .scaffold/verify.yml roles/$(NAME)/molecule/default/verify.yml + @echo "Role $(NAME) scaffolded at roles/$(NAME)/" + @echo "Next: fill in meta/main.yml, defaults/main.yml, tasks/main.yml, README.md" diff --git a/ansible.cfg b/ansible.cfg new file mode 100644 index 0000000..0e1cd75 --- /dev/null +++ b/ansible.cfg @@ -0,0 +1,13 @@ +[defaults] +inventory = inventories/production/hosts.yml +roles_path = roles +collections_path = .collections +vault_password_file = .vault_pass +interpreter_python = auto_silent +stdout_callback = yaml +callbacks_enabled = timer, profile_tasks + +# Avoid slow DNS lookups +[ssh_connection] +pipelining = True +ssh_args = -o ControlMaster=auto -o ControlPersist=60s -o StrictHostKeyChecking=accept-new diff --git a/inventories/README.md b/inventories/README.md new file mode 100644 index 0000000..8edcec5 --- /dev/null +++ b/inventories/README.md @@ -0,0 +1,11 @@ +# inventories/ + +Ansible inventories, one directory per environment (`staging/`, `production/`). +Defines which hosts exist and their group membership; `group_vars/` and `host_vars/` +hold per-group and per-host configuration. + +- `hosts.yml` is **generated** from Terraform outputs by `make tf-inventory` — do not + hand-edit. The control node is the one manual exception. +- Terraform→inventory data flow and the data contract: **ADR-009**. +- Addressing conventions (subnets, ranges): **ADR-007**. +- Layout and host groups: see CLAUDE.md ("Inventory structure"). diff --git a/inventories/production/group_vars/all/vars.yml b/inventories/production/group_vars/all/vars.yml new file mode 100644 index 0000000..b38232c --- /dev/null +++ b/inventories/production/group_vars/all/vars.yml @@ -0,0 +1,38 @@ +--- +# Variables applied to all managed hosts +# Secrets belong in vault.yml alongside this file — never here + +# Ansible connection +ansible_user: ansible +ansible_python_interpreter: /usr/bin/python3 + +# SSH authorised keys — add one entry per person +# Format: "ssh-ed25519 AAAA... user@host" +base__ssh_authorised_keys: [] + +# Timezone +base__timezone: Europe/Copenhagen + +# Domain +base__domain: baobab.band +base__internal_zone: boma.baobab.band + +# DNS — internal resolvers on srv VLAN +base__dns_servers: + - 10.20.0.10 + - 10.20.0.11 + +# NTP +base__ntp_servers: + - 0.pool.ntp.org + - 1.pool.ntp.org + +# Network — srv VLAN (where all managed VMs live) +network__srv_gateway: 10.20.0.1 +network__srv_subnet: 10.20.0.0/24 + +# Services base directory (for Docker Compose deployments) +services__base_dir: /opt/services + +# Unattended upgrades — security patches only +base__unattended_upgrades_enabled: true diff --git a/inventories/production/group_vars/all/vault.yml b/inventories/production/group_vars/all/vault.yml new file mode 100644 index 0000000..32d9d45 --- /dev/null +++ b/inventories/production/group_vars/all/vault.yml @@ -0,0 +1,7 @@ +--- +# This file must be encrypted with Ansible Vault before committing. +# Run: make encrypt FILE=inventories/production/group_vars/all/vault.yml +# +# Example secrets (plaintext shown here for structure reference only): +# +# vault__ansible_become_password: "changeme" diff --git a/inventories/production/hosts.yml b/inventories/production/hosts.yml new file mode 100644 index 0000000..3824946 --- /dev/null +++ b/inventories/production/hosts.yml @@ -0,0 +1,28 @@ +--- +# Production inventory +# Generated from Terraform outputs: make tf-inventory TF_ENV=production +# Do not edit by hand — add hosts to terraform/environments/production/main.tf. +# Exception: the control node is added here manually (see docs/runbooks/new-host.md). +# Addressing conventions: docs/decisions/007-network.md + +all: + children: + docker_hosts: + hosts: + # dns1: + # ansible_host: 10.20.0.10 + # dns2: + # ansible_host: 10.20.0.11 + # proxy: + # ansible_host: 10.20.0.12 + # homeassistant: + # ansible_host: 10.20.0.13 + + proxmox_hosts: + hosts: + # pve0: + # ansible_host: 10.10.0.200 + # pve1: + # ansible_host: 10.10.0.201 + # pve2: + # ansible_host: 10.10.0.202 diff --git a/inventories/staging/hosts.yml b/inventories/staging/hosts.yml new file mode 100644 index 0000000..a3e657e --- /dev/null +++ b/inventories/staging/hosts.yml @@ -0,0 +1,12 @@ +--- +# Staging inventory — safe to run freely, used for integration testing +# Generated from Terraform outputs: make tf-inventory TF_ENV=staging +# Do not edit by hand — add hosts to terraform/environments/staging/main.tf. +# Addressing conventions: docs/decisions/007-network.md + +all: + children: + docker_hosts: + hosts: + # staging01: + # ansible_host: 10.20.0.50 diff --git a/playbooks/README.md b/playbooks/README.md new file mode 100644 index 0000000..93465e9 --- /dev/null +++ b/playbooks/README.md @@ -0,0 +1,12 @@ +# playbooks/ + +Top-level orchestration playbooks. No inline vars — configuration comes from +`group_vars/` / `host_vars/` (see CLAUDE.md). + +- `site.yml` — full standard state: applies `base` to all hosts and `docker_host` + to docker hosts. **Note:** those roles are empty today, so this is currently a + no-op — see `STATUS.md`. +- `bootstrap.yml` — first-run setup for a host that may not have Python yet; + self-contained (does not depend on the roles). + +Run via `make check PLAYBOOK=` then `make deploy PLAYBOOK=`. diff --git a/playbooks/bootstrap.yml b/playbooks/bootstrap.yml new file mode 100644 index 0000000..af185f7 --- /dev/null +++ b/playbooks/bootstrap.yml @@ -0,0 +1,36 @@ +--- +# bootstrap.yml — first-run setup for a new host +# Handles hosts that may not yet have Python installed +# Run via: make deploy PLAYBOOK=bootstrap + +- name: Bootstrap new host + hosts: "{{ target | default('all') }}" + become: true + gather_facts: false + + tasks: + - name: Ensure Python 3 is installed + ansible.builtin.raw: | + apt-get update -qq && apt-get install -y python3 + changed_when: false + tags: [bootstrap] + + - name: Gather facts after Python install + ansible.builtin.setup: + tags: [bootstrap] + + - name: Ensure ansible user exists + ansible.builtin.user: + name: ansible + shell: /bin/bash + create_home: true + system: false + tags: [bootstrap] + + - name: Add ansible user to sudoers + ansible.builtin.copy: + dest: /etc/sudoers.d/ansible + content: "ansible ALL=(ALL) NOPASSWD:ALL\n" + mode: "0440" + validate: visudo -cf %s + tags: [bootstrap] diff --git a/playbooks/site.yml b/playbooks/site.yml new file mode 100644 index 0000000..8001f1e --- /dev/null +++ b/playbooks/site.yml @@ -0,0 +1,17 @@ +--- +# site.yml — apply full standard state to all hosts +# Run via: make deploy PLAYBOOK=site + +- name: Apply base configuration to all hosts + hosts: all + become: true + roles: + - role: base + tags: [base] + +- name: Configure Docker hosts + hosts: docker_hosts + become: true + roles: + - role: docker_host + tags: [docker] diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..675c598 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,11 @@ +# Python dependencies for Ansible control node +# Pin versions for reproducibility. Update deliberately. + +ansible-core==2.17.* +ansible-lint==24.* +molecule==24.* +molecule-plugins[docker]==23.* +yamllint==1.35.* +docker==7.* # Python SDK for Docker (used by Molecule) +pytest==8.* # Required by Molecule test runner +pytest-testinfra==10.* # Optional: infra assertions in verify.yml diff --git a/requirements.yml b/requirements.yml new file mode 100644 index 0000000..a76192b --- /dev/null +++ b/requirements.yml @@ -0,0 +1,13 @@ +--- +# Ansible collection dependencies +# Pin versions. Update with: make collections +# +# Policy: add a collection only when a task in a committed role actively uses +# a module from it. Never pre-emptively include collections. +# All roles are written locally — no Galaxy roles. + +collections: + # Ansible-team maintained. Fills genuine ansible.builtin gaps: + # authorized_key, sysctl, acl. + - name: ansible.posix + version: ">=1.5.0" diff --git a/roles/README.md b/roles/README.md new file mode 100644 index 0000000..e78f864 --- /dev/null +++ b/roles/README.md @@ -0,0 +1,12 @@ +# roles/ + +Local Ansible roles. **No Galaxy roles** — every role is written and maintained here +(ADR-003). Scaffold new ones with `make new-role NAME=`; never create the +directory structure by hand. + +Each role must have: a `molecule/default/` scenario (Debian 13), a populated +`README.md`, and a filled-in `meta/main.yml`. Conventions: CLAUDE.md and +`docs/runbooks/new-role.md`. + +Current state: `base/` and `docker_host/` are scaffolded directories but **empty / +not implemented** — see `STATUS.md`. diff --git a/scripts/README.md b/scripts/README.md new file mode 100644 index 0000000..8b1b484 --- /dev/null +++ b/scripts/README.md @@ -0,0 +1,7 @@ +# scripts/ + +Small helper scripts. **Python standard library only** — no third-party +dependencies (keeps them runnable anywhere without a venv). + +- `tf_to_inventory.py` — reads `terraform output -json` on stdin and writes an + Ansible `hosts.yml`. Invoked by `make tf-inventory`. Data contract: **ADR-009**. diff --git a/scripts/check-vault-encrypted.sh b/scripts/check-vault-encrypted.sh new file mode 100755 index 0000000..be7b495 --- /dev/null +++ b/scripts/check-vault-encrypted.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env bash +# +# Pre-commit guard: fail if a file named vault.yml holds plaintext secrets. +# +# A vault.yml is allowed only if it is either: +# - ansible-vault encrypted (first line starts with `$ANSIBLE_VAULT`), or +# - a placeholder with no real content (comments / blank lines / `---` only). +# +# It fails when an unencrypted vault.yml contains actual key: value content, which +# is almost always an accidental plaintext secret. Encrypt it with: +# make encrypt FILE= +# +set -euo pipefail + +status=0 +for f in "$@"; do + [ -f "$f" ] || continue + + # Encrypted — always fine. + if head -n1 "$f" | grep -q '^\$ANSIBLE_VAULT'; then + continue + fi + + # Unencrypted — allowed only if there is no real content. "Real content" is any + # line that is not blank, not a comment, and not the YAML document marker `---`. + content=$(grep -vE '^\s*(#|---\s*$|$)' "$f" || true) + if [ -n "$content" ]; then + echo "ERROR: $f is not ansible-vault encrypted but contains plaintext content:" >&2 + printf '%s\n' "$content" | sed 's/^/ /' >&2 + echo " Encrypt it with: make encrypt FILE=$f" >&2 + status=1 + fi +done + +exit $status diff --git a/scripts/tf_to_inventory.py b/scripts/tf_to_inventory.py new file mode 100644 index 0000000..b9a0959 --- /dev/null +++ b/scripts/tf_to_inventory.py @@ -0,0 +1,74 @@ +#!/usr/bin/env python3 +""" +Read `terraform output -json` from stdin, emit an Ansible hosts.yml to stdout. + +Usage: + terraform -chdir=terraform/environments/ output -json \\ + | python3 scripts/tf_to_inventory.py > inventories//hosts.yml + +Expected Terraform output shape: + { + "vms": { + "value": { + "hostname": { "ip": "192.168.1.10", "group": "docker_hosts" } + } + } + } + +Valid groups: control, docker_hosts, proxmox_hosts +""" + +import json +import sys + +VALID_GROUPS = {"control", "docker_hosts", "proxmox_hosts"} + + +def main() -> None: + try: + data = json.load(sys.stdin) + except json.JSONDecodeError as exc: + print(f"error: could not parse Terraform output JSON: {exc}", file=sys.stderr) + sys.exit(1) + + vms = data.get("vms", {}).get("value", {}) + if not vms: + print("warning: no VMs in Terraform output — writing empty inventory", file=sys.stderr) + + groups: dict[str, dict[str, str]] = {} + for hostname, info in vms.items(): + group = info.get("group", "") + if group not in VALID_GROUPS: + print( + f"error: unknown group '{group}' for host '{hostname}' " + f"(valid: {', '.join(sorted(VALID_GROUPS))})", + file=sys.stderr, + ) + sys.exit(1) + groups.setdefault(group, {})[hostname] = info["ip"] + + lines = [ + "---", + "# Generated by scripts/tf_to_inventory.py — do not edit manually.", + "# Regenerate with: make tf-inventory TF_ENV=", + "", + "all:", + " children:", + ] + + for group in sorted(VALID_GROUPS): + lines.append(f" {group}:") + hosts = groups.get(group, {}) + if hosts: + lines.append(" hosts:") + for hostname in sorted(hosts): + lines.append(f" {hostname}:") + lines.append(f" ansible_host: {hosts[hostname]}") + else: + lines.append(" hosts: {}") + + print("\n".join(lines)) + + +if __name__ == "__main__": + main()