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