Add core Ansible scaffold, tooling, and pre-commit guards

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
sjat 2026-05-30 14:10:01 +02:00
commit 3f1d7eb128
27 changed files with 705 additions and 0 deletions

19
.ansible-lint Normal file
View file

@ -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

View file

@ -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

6
.claude/commands/lint.md Normal file
View file

@ -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

View file

@ -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

View file

@ -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"]

33
.gitignore vendored Normal file
View file

@ -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)

40
.pre-commit-config.yaml Normal file
View file

@ -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$

7
.scaffold/converge.yml Normal file
View file

@ -0,0 +1,7 @@
---
- name: Converge
hosts: all
gather_facts: true
roles:
- role: ROLE_NAME_PLACEHOLDER

31
.scaffold/molecule.yml Normal file
View file

@ -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/<owner>/<repo>/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

11
.scaffold/verify.yml Normal file
View file

@ -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]

20
.yamllint Normal file
View file

@ -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/

158
Makefile Normal file
View file

@ -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/<owner>/<repo>/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=<name> Run Molecule tests for a role"
@echo " make test-all Run Molecule tests for all roles"
@echo " make check PLAYBOOK=<name> Dry-run a playbook (check mode)"
@echo " make deploy PLAYBOOK=<name> Run a playbook against production"
@echo " make encrypt FILE=<path> Encrypt a vault file"
@echo " make decrypt FILE=<path> Decrypt a vault file"
@echo " make new-role NAME=<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=<rolename>)
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=<name>)
endif
$(PLAYBOOK) $(INVENTORY) $(VAULT_ARGS) --check --diff playbooks/$(PLAYBOOK).yml
deploy:
ifndef PLAYBOOK
$(error PLAYBOOK is required: make deploy PLAYBOOK=<name>)
endif
$(PLAYBOOK) $(INVENTORY) $(VAULT_ARGS) playbooks/$(PLAYBOOK).yml
# ── Vault ─────────────────────────────────────────────────────────────────────
encrypt:
ifndef FILE
$(error FILE is required: make encrypt FILE=<path>)
endif
$(ANSIBLE)-vault encrypt --vault-password-file .vault_pass $(FILE)
decrypt:
ifndef FILE
$(error FILE is required: make decrypt FILE=<path>)
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=<staging|production>)
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=<rolename>)
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"

13
ansible.cfg Normal file
View file

@ -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

11
inventories/README.md Normal file
View file

@ -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").

View file

@ -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

View file

@ -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"

View file

@ -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

View file

@ -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

12
playbooks/README.md Normal file
View file

@ -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=<name>` then `make deploy PLAYBOOK=<name>`.

36
playbooks/bootstrap.yml Normal file
View file

@ -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]

17
playbooks/site.yml Normal file
View file

@ -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]

11
requirements.txt Normal file
View file

@ -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

13
requirements.yml Normal file
View file

@ -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"

12
roles/README.md Normal file
View file

@ -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=<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`.

7
scripts/README.md Normal file
View file

@ -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**.

View file

@ -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=<path>
#
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

View file

@ -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/<env> output -json \\
| python3 scripts/tf_to_inventory.py > inventories/<env>/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=<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()