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:
commit
3f1d7eb128
27 changed files with 705 additions and 0 deletions
19
.ansible-lint
Normal file
19
.ansible-lint
Normal 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
|
||||||
12
.claude/commands/deploy.md
Normal file
12
.claude/commands/deploy.md
Normal 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
6
.claude/commands/lint.md
Normal 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
|
||||||
17
.claude/commands/new-role.md
Normal file
17
.claude/commands/new-role.md
Normal 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
|
||||||
25
.docker/molecule-debian13/Dockerfile
Normal file
25
.docker/molecule-debian13/Dockerfile
Normal 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
33
.gitignore
vendored
Normal 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
40
.pre-commit-config.yaml
Normal 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
7
.scaffold/converge.yml
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
---
|
||||||
|
- name: Converge
|
||||||
|
hosts: all
|
||||||
|
gather_facts: true
|
||||||
|
|
||||||
|
roles:
|
||||||
|
- role: ROLE_NAME_PLACEHOLDER
|
||||||
31
.scaffold/molecule.yml
Normal file
31
.scaffold/molecule.yml
Normal 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
11
.scaffold/verify.yml
Normal 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
20
.yamllint
Normal 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
158
Makefile
Normal 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
13
ansible.cfg
Normal 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
11
inventories/README.md
Normal 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").
|
||||||
38
inventories/production/group_vars/all/vars.yml
Normal file
38
inventories/production/group_vars/all/vars.yml
Normal 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
|
||||||
7
inventories/production/group_vars/all/vault.yml
Normal file
7
inventories/production/group_vars/all/vault.yml
Normal 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"
|
||||||
28
inventories/production/hosts.yml
Normal file
28
inventories/production/hosts.yml
Normal 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
|
||||||
12
inventories/staging/hosts.yml
Normal file
12
inventories/staging/hosts.yml
Normal 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
12
playbooks/README.md
Normal 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
36
playbooks/bootstrap.yml
Normal 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
17
playbooks/site.yml
Normal 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
11
requirements.txt
Normal 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
13
requirements.yml
Normal 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
12
roles/README.md
Normal 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
7
scripts/README.md
Normal 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**.
|
||||||
35
scripts/check-vault-encrypted.sh
Executable file
35
scripts/check-vault-encrypted.sh
Executable 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
|
||||||
74
scripts/tf_to_inventory.py
Normal file
74
scripts/tf_to_inventory.py
Normal 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()
|
||||||
Loading…
Add table
Reference in a new issue