# 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_BIN := $(VENV)/bin/ansible-playbook GALAXY := $(VENV)/bin/ansible-galaxy LINT := $(VENV)/bin/ansible-lint MOLECULE := $(VENV)/bin/molecule # Vault password is resolved via ansible.cfg (vault_password_file); no flag needed. VAULT_ARGS := # Default vault file for edit-vault / check-vault (override with VAULT=). VAULT ?= inventories/production/group_vars/all/vault.yml INVENTORY := -i inventories/production/ TF := terraform TF_ENV ?= staging MOLECULE_IMAGE := forgejo.nyumbani.baobab.band/sjat/molecule-debian13:latest MOLECULE_DOCKERFILE := .docker/molecule-debian13/Dockerfile # Custom Caddy + Gandi DNS-01 plugin (ADR-024). Build on ubongo, NOT askari/Hetzner # (the Go module proxy 403s Hetzner IPs); push the pinned tag to the Forgejo registry. CADDY_IMAGE := forgejo.nyumbani.baobab.band/sjat/caddy-gandi:2.11.4 CADDY_DOCKERFILE := .docker/caddy-gandi/Dockerfile # Forgejo container registry (same host/user as the image tags above). `make registry-login` # logs the Docker daemon in using vault.forgejo.registry_token (2026-06-17 kaizen) so image # pushes are agent-completable non-interactively. REGISTRY_HOST := forgejo.nyumbani.baobab.band REGISTRY_USER := sjat # For TF_ENV=offsite, source the Hetzner token from the vault into the environment # (rbw must be unlocked). Read in-memory; never written to a tfvars file (CLAUDE.md). ifeq ($(TF_ENV),offsite) TF_TOKEN_ENV := TF_VAR_hcloud_token="$$($(ANSIBLE)-vault view inventories/production/group_vars/all/vault.yml | $(PYTHON) -c 'import sys, yaml; print(yaml.safe_load(sys.stdin)["vault"]["hetzner"]["token"])')" else TF_TOKEN_ENV := endif .DEFAULT_GOAL := help .PHONY: help setup collections lint test test-all test-integration test-integration-clean \ check deploy encrypt decrypt \ edit-vault check-vault new-role \ tf-init tf-plan tf-apply tf-output tf-inventory tf-inventory-offsite \ molecule-image molecule-image-push caddy-image caddy-image-push registry-login 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 test-integration HOST= [CERTS=internal|le-staging] [KEEP=1] Run ADR-025 integration cycle against a VM" @echo " make test-integration-clean Prune stale integration-test VM snapshots" @echo " make check PLAYBOOK= [LIMIT=] [TAGS=] [EXTRA=] Dry-run a playbook (check mode)" @echo " make deploy PLAYBOOK= [LIMIT=] [TAGS=] [EXTRA=] Run a playbook against production" @echo " make edit-vault [VAULT=] Edit the vault in nvim (auto re-encrypts + checks)" @echo " make check-vault [VAULT=] Validate vault structure (values masked)" @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 " make tf-inventory-offsite Generate offsite_hosts inventory (askari) into inventories/production/" @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 " make caddy-image Build the custom Caddy + Gandi DNS-01 image (run on ubongo)" @echo " make caddy-image-push Push the Caddy image to the Forgejo registry" @echo " make registry-login Log Docker into the Forgejo registry (vaulted token)" @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) $(PYTHON) scripts/check-tags.py # ── Testing ─────────────────────────────────────────────────────────────────── test: ifndef ROLE $(error ROLE is required: make test ROLE=) endif cd roles/$(ROLE) && PATH="$(CURDIR)/$(VENV)/bin:$$PATH" molecule test test-all: @for role in roles/*/; do \ echo "── Testing $$role ──"; \ cd $$role && PATH="$(CURDIR)/$(VENV)/bin:$$PATH" molecule test; cd ../..; \ done test-integration: ifndef HOST $(error HOST is required: make test-integration HOST= [CERTS=internal|le-staging] [KEEP=1]) endif PATH="$(CURDIR)/$(VENV)/bin:$$PATH" $(PYTHON) scripts/integration-vm.py cycle \ --host $(HOST) $(if $(CERTS),--certs $(CERTS)) $(if $(KEEP),--keep) test-integration-clean: PATH="$(CURDIR)/$(VENV)/bin:$$PATH" $(PYTHON) scripts/integration-vm.py prune # ── Playbook execution ──────────────────────────────────────────────────────── check: ifndef PLAYBOOK $(error PLAYBOOK is required: make check PLAYBOOK=) endif $(PLAYBOOK_BIN) $(INVENTORY) $(VAULT_ARGS) $(if $(LIMIT),--limit $(LIMIT)) $(if $(TAGS),--tags $(TAGS)) $(EXTRA) --check --diff playbooks/$(PLAYBOOK).yml deploy: ifndef PLAYBOOK $(error PLAYBOOK is required: make deploy PLAYBOOK=) endif $(PLAYBOOK_BIN) $(INVENTORY) $(VAULT_ARGS) $(if $(LIMIT),--limit $(LIMIT)) $(if $(TAGS),--tags $(TAGS)) $(EXTRA) playbooks/$(PLAYBOOK).yml # ── Vault ───────────────────────────────────────────────────────────────────── # Streamlined edit: ansible-vault edit decrypts to a temp file, opens nvim, and # re-encrypts on :wq (abort with :cq) — no plaintext ever lands in the work tree. # Then validate structure. Override the file with VAULT=. edit-vault: EDITOR=nvim $(ANSIBLE)-vault edit $(VAULT) @$(PYTHON) scripts/check-vault.py $(VAULT) check-vault: @$(PYTHON) scripts/check-vault.py $(VAULT) encrypt: ifndef FILE $(error FILE is required: make encrypt FILE=) endif $(ANSIBLE)-vault encrypt $(FILE) decrypt: ifndef FILE $(error FILE is required: make decrypt FILE=) endif $(ANSIBLE)-vault decrypt $(FILE) # ── Molecule test image ─────────────────────────────────────────────────────── molecule-image: docker build -t $(MOLECULE_IMAGE) -f $(MOLECULE_DOCKERFILE) . molecule-image-push: molecule-image docker push $(MOLECULE_IMAGE) # ── Custom Caddy image (Gandi DNS-01 plugin, ADR-024) ───────────────────────── # DNS-01 (wildcard / mesh-LAN-only certs) needs the caddy-dns/gandi plugin compiled # in via xcaddy. Build on ubongo — Google's Go module proxy 403s Hetzner IPs. caddy-image: docker build -t $(CADDY_IMAGE) -f $(CADDY_DOCKERFILE) .docker/caddy-gandi caddy-image-push: caddy-image docker push $(CADDY_IMAGE) # Log the local Docker daemon into the Forgejo registry using the vaulted token, so the # *-image-push targets above are agent-completable non-interactively (rbw must be unlocked). registry-login: @ANSIBLE_VAULT="$(ANSIBLE)-vault" PYTHON="$(PYTHON)" VAULT="$(VAULT)" \ REGISTRY_HOST="$(REGISTRY_HOST)" REGISTRY_USER="$(REGISTRY_USER)" \ bash scripts/registry-login.sh # ── Terraform ───────────────────────────────────────────────────────────────── tf-init: $(TF_TOKEN_ENV) $(TF) -chdir=terraform/environments/$(TF_ENV) init tf-plan: $(TF_TOKEN_ENV) $(TF) -chdir=terraform/environments/$(TF_ENV) plan tf-apply: $(TF_TOKEN_ENV) $(TF) -chdir=terraform/environments/$(TF_ENV) apply tf-output: $(TF_TOKEN_ENV) $(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" tf-inventory-offsite: $(TF_TOKEN_ENV) $(TF) -chdir=terraform/environments/offsite output -json \ | $(PYTHON) scripts/tf_to_inventory.py > inventories/production/offsite.yml @echo "Offsite inventory written to inventories/production/offsite.yml" # ── Role scaffolding ────────────────────────────────────────────────────────── new-role: ifndef NAME $(error NAME is required: make new-role NAME=) endif mkdir -p roles/$(NAME)/tasks roles/$(NAME)/handlers roles/$(NAME)/defaults \ roles/$(NAME)/templates roles/$(NAME)/files roles/$(NAME)/meta \ roles/$(NAME)/molecule/default echo "---" > roles/$(NAME)/tasks/main.yml echo "---" > roles/$(NAME)/handlers/main.yml printf '%s\n' '---' \ '# Role defaults use the __var double-underscore namespace.' \ '#' \ '# Service roles (ADR-004) also declare access__*/backup__* data here. Those are' \ '# cross-role conventions (not rolename-prefixed), so EACH such line needs a trailing' \ '# noqa: var-naming[no-role-prefix] (ansible-lint 24.x has no per-prefix allowlist).' \ '# Reference: roles/reverse_proxy/defaults/main.yml' \ > roles/$(NAME)/defaults/main.yml echo "---" > roles/$(NAME)/meta/main.yml printf '# %s\n\nRole description here.\n' "$(NAME)" > roles/$(NAME)/README.md cp .scaffold/molecule.yml roles/$(NAME)/molecule/default/molecule.yml sed 's/ROLE_NAME_PLACEHOLDER/$(NAME)/g' .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"