railiance-infra/Makefile
codex d0a5a27926
All checks were successful
CI Smoke / source-contract (push) Successful in 11s
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Prepare governed S1 offsite upload
Assistant: codex
Assistant-Model: gpt-5.6-sol
Assistant-Session: 01a02994-7685-7940-bf34-3555b8256018
2026-08-23 14:11:00 +02:00

385 lines
21 KiB
Makefile
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# -------- RailianceHosts Make Utilities --------
SHELL := /usr/bin/env bash
.DEFAULT_GOAL := help
# Set this to your Gitea host if you want 'remote-set' helper
GITEA ?= gitea.example.com
OWNER ?= coulomb
REPO ?= railiance-infra
SOURCE_REVISION ?= $(shell git rev-parse HEAD 2>/dev/null)
# New-host defaults (can be overridden: make new-host NAME=... TYPE=...)
TYPE ?= cpx11
REGION ?= nbg1
ROLE ?= core
IMG ?= ubuntu-24.04
USER ?= admin
# Decrypt Hetzner token at runtime (requires SOPS_AGE_KEY or keys.txt locally)
HCLOUD_TOKEN := $(shell sops -d --extract '["hetzner"]["token"]' secrets/hetzner-token.yaml 2>/dev/null)
# ---- Help ----
help: ## Show this help
@echo "RailianceHosts Commands"; \
grep -E '^[a-zA-Z0-9_-]+:.*?## ' $(MAKEFILE_LIST) | sort | sed 's/:.*##/: /'
# ---- Git hooks ----
hooks: ## Configure git to use repo-local hooks (.githooks) and ensure executables
@mkdir -p .githooks
git config core.hooksPath .githooks
@test -f .githooks/pre-commit || (echo "❌ Missing .githooks/pre-commit"; exit 1)
chmod +x .githooks/pre-commit
@echo "✔ hooks enabled and pre-commit is executable"
hooks-test: ## Test secrets hook blocks plaintext in secrets/
@mkdir -p secrets && echo 'PLAINTEXT_TEST=true' > secrets/_hook_test.yaml
@git add secrets/_hook_test.yaml || true
@if git commit -m "TEST: should be blocked" 2>/dev/null; then \
echo "❌ Hook did NOT block plaintext (check .githooks/pre-commit)"; \
git reset --soft HEAD~1; \
else \
echo "✔ Hook blocked plaintext as expected"; \
fi
@git restore --staged secrets/_hook_test.yaml || true
@rm -f secrets/_hook_test.yaml
# ---- SOPS / Age helpers ----
sops-setup: ## Copy age key to SOPS default path (~/.config/sops/age/keys.txt)
mkdir -p ~/.config/sops/age
cp -n ~/.config/age/key.txt ~/.config/sops/age/keys.txt || true
chmod 600 ~/.config/sops/age/keys.txt
@echo "✔ SOPS key path set (~/.config/sops/age/keys.txt). Alternatively export SOPS_AGE_KEY."
sops-edit: ## Edit the global secrets with SOPS
sops secrets/hetzner-token.yaml
sops-encrypt: ## Encrypt a file in place: make sops-encrypt FILE=secrets/foo.yaml
@[ -n "$(FILE)" ] || (echo "Usage: make sops-encrypt FILE=secrets/xxx.yaml" && exit 1)
sops --encrypt --in-place $(FILE)
@echo "✔ Encrypted $(FILE)"
sops-decrypt: ## Print decrypted file to stdout (for inspection) FILE=secrets/foo.sops.yaml
@[ -n "$(FILE)" ] || (echo "Usage: make sops-decrypt FILE=secrets/xxx.sops.yaml" && exit 1)
sops -d $(FILE)
sops-rotate: ## Check SOPS recipient drift; use the bounded tool for approved changes
python3 scripts/sops_rotation.py --check
check-secrets: ## Fail if any declared secret-bearing path is not encrypted
python3 scripts/check_secret_paths.py --tracked
# ---- Terraform (Hetzner) ----
validate-inventory: ## Validate adopted/provider-managed host declarations without provider access
python3 scripts/inventory_contract.py inventory/servers.yaml
validate-baseline: ## Validate the executable baseline and its Ansible/Goss consumers
python3 scripts/baseline_contract.py --check-repo
validate-handoff-readonly: ## Prove the live S1 handoff playbook has no remote mutation surface
python3 scripts/handoff_contract.py
validate-receipts: ## Validate committed metadata-only S1 receipt examples
python3 scripts/s1_receipt.py docs/evidence/s1-receipts/*.json
s1-handoff: ## Run the read-only live S1 verification gate and emit a receipt
python3 scripts/s1_handoff.py
s1-handoff-dry-run: ## Validate handoff inputs without host access; receipt is not-run
python3 scripts/s1_handoff.py --dry-run
tf-fmt: ## Terraform fmt
terraform -chdir=terraform/hetzner fmt -recursive
tf-init: ## Terraform init
terraform -chdir=terraform/hetzner init
tf-plan: tf-init ## Terraform plan (requires decrypted HCLOUD_TOKEN)
@echo "🔍 Running terraform plan..."
@[ -n "$(HCLOUD_TOKEN)" ] || (echo "HCLOUD_TOKEN empty; unlock secrets/hetzner-token.yaml with SOPS" && exit 1)
@export HCLOUD_TOKEN=$(HCLOUD_TOKEN); terraform -chdir=terraform/hetzner plan -var="hcloud_token=$(HCLOUD_TOKEN)"
tf-apply: ## Terraform apply (provision; exact approval required before init)
@test "$(APPROVE_TF_APPLY)" = "YES" || (echo "Refusing apply: review make tf-plan, then set APPROVE_TF_APPLY=YES" && exit 1)
@[ -n "$(HCLOUD_TOKEN)" ] || (echo "HCLOUD_TOKEN empty; unlock secrets/hetzner-token.yaml with SOPS" && exit 1)
@$(MAKE) tf-init
@export HCLOUD_TOKEN=$(HCLOUD_TOKEN); terraform -chdir=terraform/hetzner apply -auto-approve -var="hcloud_token=$(HCLOUD_TOKEN)"
tf-destroy: ## Terraform destroy (exact approval required before init)
@test "$(APPROVE_TF_DESTROY)" = "DESTROY-MANAGED-HETZNER" || (echo "Refusing destroy: set APPROVE_TF_DESTROY=DESTROY-MANAGED-HETZNER after exact plan review" && exit 1)
@[ -n "$(HCLOUD_TOKEN)" ] || (echo "HCLOUD_TOKEN empty; unlock secrets/hetzner-token.yaml with SOPS" && exit 1)
@$(MAKE) tf-init
@export HCLOUD_TOKEN=$(HCLOUD_TOKEN); terraform -chdir=terraform/hetzner destroy -auto-approve -var="hcloud_token=$(HCLOUD_TOKEN)"
# --- Terraform provider/lockfile helpers ---
TF_DIR := terraform/hetzner
TF_TOKEN := $(HCLOUD_TOKEN)
LOCKFILE := $(TF_DIR)/.terraform.lock.hcl
tf-lock-commit: ## Commit the current provider lockfile
@test -f $(LOCKFILE) || (echo "$(LOCKFILE) not found. Run 'make tf-init' first."; exit 1)
@git add $(LOCKFILE)
@git commit -m "chore(terraform): lock providers" || echo " No lockfile changes to commit."
tf-providers-check: ## Check if newer provider versions are available (non-destructive)
@echo "🔎 Checking for provider upgrades (lockfile readonly)…"
@if terraform -chdir=$(TF_DIR) init -upgrade -lockfile=readonly >/dev/null 2>&1; then \
echo "✔ Providers up to date (no upgrades available)."; \
else \
echo "↗ Provider upgrades likely available (readonly lockfile blocked changes)."; \
echo " Run: make tf-providers-upgrade"; \
fi
tf-providers-upgrade: ## Upgrade providers (updates .terraform.lock.hcl)
@echo "⬆️ Upgrading providers…"
@terraform -chdir=$(TF_DIR) init -upgrade
@echo "— Diff for $(LOCKFILE):"
@git --no-pager diff -- $(LOCKFILE) || true
@echo "💡 If changes look good: make tf-lock-commit"
tf-providers-upgrade-commit: tf-providers-upgrade tf-lock-commit ## Upgrade providers and commit the lockfile
tf-providers-plan: ## Plan after an upgrade (uses HCLOUD_TOKEN if set)
@echo "🧪 Planning with upgraded providers…"
@terraform -chdir=$(TF_DIR) plan $(if $(TF_TOKEN),-var="hcloud_token=$(TF_TOKEN)")
# ---- Backup (Q3 Operability & Resilience — D4) ----
backup: ## Backup S1 OS config to /opt/backup/railiance/infra/ (age-encrypted, root required)
sudo tools/cmd/railiance-backup-s1
validate-s1-backup: ## Validate S1 backup declaration and recovery implementation without host changes
python3 scripts/s1_backup.py check
python3 -m unittest tests.test_s1_backup_recovery -v
s1-backup-status: ## Check newest local S1 backup integrity, freshness, count, and disk budget
python3 scripts/s1_backup.py status
s1-backup-prune-plan: ## Print retained bundles and their exact one-use deletion approval
python3 scripts/s1_backup.py prune-plan
s1-backup-prune: ## Apply reviewed prune plan: APPROVE_S1_BACKUP_PRUNE=PRUNE-S1-BACKUPS-...
@test -n "$(APPROVE_S1_BACKUP_PRUNE)" || (echo "Run make s1-backup-prune-plan, then pass its exact approval"; exit 1)
python3 scripts/s1_backup.py prune --approval "$(APPROVE_S1_BACKUP_PRUNE)"
s1-restore-inspect: ## Verify encrypted bundle metadata without a private key: BUNDLE=/absolute/path
@test -n "$(BUNDLE)" || (echo "Usage: make s1-restore-inspect BUNDLE=/absolute/path/to/s1-backup-*"; exit 1)
python3 scripts/s1_restore.py "$(BUNDLE)"
s1-restore-isolated: ## Decrypt only into explicit empty staging: BUNDLE=... DEST=/tmp/... IDENTITY=...
@test -n "$(BUNDLE)" && test -n "$(DEST)" && test -n "$(IDENTITY)" || (echo "Usage: make s1-restore-isolated BUNDLE=... DEST=/tmp/... IDENTITY=/path/to/age-identity"; exit 1)
python3 scripts/s1_restore.py "$(BUNDLE)" --extract-to "$(DEST)" --identity "$(IDENTITY)"
s1-offsite-review: ## Render the exact railiance-platform upload-contract approval
python3 scripts/s1_offsite.py review
s1-offsite-plan: ## Build a credential-free exact upload plan: BUNDLE=/absolute/path
@test -n "$(BUNDLE)" || (echo "Usage: make s1-offsite-plan BUNDLE=/absolute/path/to/s1-backup-*"; exit 1)
python3 scripts/s1_offsite.py plan "$(BUNDLE)"
s1-offsite-upload: ## Contained owner-routed upload: BUNDLE=... APPROVE_S1_OFFSITE_UPLOAD=UPLOAD-S1-OFFSITE-...
@test -n "$(BUNDLE)" && test -n "$(APPROVE_S1_OFFSITE_UPLOAD)" || (echo "Run make s1-offsite-plan, then pass BUNDLE and its exact approval"; exit 1)
warden access railiance-backup-offsite-lane --field RAILIANCE_BACKUP_NC_TOKEN --exec -- \
python3 scripts/s1_offsite.py upload "$(BUNDLE)" --approval "$(APPROVE_S1_OFFSITE_UPLOAD)"
s1-backup-deploy: ## Deploy and enable timer: HOST=... APPROVE_S1_BACKUP_DEPLOY=DEPLOY-RAIL-HO-WP-0012-S1-BACKUP-TIMER
@test -n "$(HOST)" || (echo "Usage: make s1-backup-deploy HOST=Railiance01 APPROVE_S1_BACKUP_DEPLOY=DEPLOY-RAIL-HO-WP-0012-S1-BACKUP-TIMER"; exit 1)
@test "$(APPROVE_S1_BACKUP_DEPLOY)" = "DEPLOY-RAIL-HO-WP-0012-S1-BACKUP-TIMER" || (echo "Refusing deployment: exact approval is absent"; exit 1)
@git diff --quiet && git diff --cached --quiet || (echo "Refusing deployment: commit the exact source first"; exit 1)
cd ansible && ansible-playbook playbooks/s1-backup.yaml --limit "$(HOST)" \
-e railiance_backup_deploy_approval="$(APPROVE_S1_BACKUP_DEPLOY)" \
-e railiance_backup_source_revision="$(SOURCE_REVISION)"
# ---- Ansible ----
ansible-bootstrap: ## Run base bootstrap play (users, ssh, ufw, sops-agent, custodian-agent)
cd ansible && ansible-playbook playbooks/bootstrap.yaml $(ANSIBLE_USER_FLAG)
provision-custodian-agent: ## Deploy custodian agent SSH key to all managed hosts
@python3 -c "import yaml; d=yaml.safe_load(open('ansible/inventory/group_vars/all.yaml')); k=d.get('custodian_agent_pubkey',''); exit(0 if k else 1)" \
|| (echo "ERROR: custodian_agent_pubkey is empty. Run: cd ~/the-custodian && make custodian-keygen"; exit 1)
cd ansible && ansible-playbook playbooks/custodian-agent.yaml $(ANSIBLE_USER_FLAG)
provision-custodian-agent-host: ## Deploy custodian agent key to one host: make provision-custodian-agent-host HOST=Railiance01
@test -n "$(HOST)" || (echo "Usage: make provision-custodian-agent-host HOST=Railiance01"; exit 1)
cd ansible && ansible-playbook playbooks/custodian-agent.yaml $(ANSIBLE_USER_FLAG) \
--limit "$(HOST)"
bootstrap-ssh-ca: ## Deploy OpenBao SSH CA trust + auth_principals: make bootstrap-ssh-ca SSH_CA_PUBKEY=/path/to/ca_user.pub
@test -n "$(SSH_CA_PUBKEY)" || (echo "Usage: make bootstrap-ssh-ca SSH_CA_PUBKEY=/path/to/ca_user.pub [HOST=Railiance01]"; exit 1)
cd ansible && ansible-playbook playbooks/bootstrap-ssh-ca.yaml $(ANSIBLE_USER_FLAG) \
-e ssh_ca_pubkey_path="$(SSH_CA_PUBKEY)" \
$(if $(HOST),--limit "$(HOST)",)
bootstrap-ssh-ca-host: ## Deploy SSH CA trust to one host: make bootstrap-ssh-ca-host HOST=Railiance01 SSH_CA_PUBKEY=...
@test -n "$(HOST)" && test -n "$(SSH_CA_PUBKEY)" || \
(echo "Usage: make bootstrap-ssh-ca-host HOST=Railiance01 SSH_CA_PUBKEY=/path/to/ca_user.pub"; exit 1)
$(MAKE) bootstrap-ssh-ca SSH_CA_PUBKEY="$(SSH_CA_PUBKEY)" HOST="$(HOST)"
# ---- Orchestration ----
apply: tf-fmt tf-apply ansible-bootstrap ## Provision via Terraform then converge via Ansible
deploy-stack: ## Print the full S1→S5 ordered deploy sequence (operator follows each step)
@echo ""
@echo "╔══════════════════════════════════════════════════════════════╗"
@echo "║ Railiance Stack — Full Deploy Sequence ║"
@echo "║ See docs/deploy-stack.md for full runbook ║"
@echo "╚══════════════════════════════════════════════════════════════╝"
@echo ""
@echo "PRE-CONDITIONS"
@echo " [ ] SSH key: ~/.ssh/id_ops"
@echo " [ ] SOPS key: ~/.config/sops/age/keys.txt (or SOPS_AGE_KEY)"
@echo " [ ] ops-bridge: bridge up state-hub-railiance01 k3s-api-railiance01"
@echo ""
@echo "S1 — Infrastructure Substrate (this repo)"
@echo " make tf-plan && make tf-apply # provision server (skip if exists)"
@echo " ssh tegwick@92.205.62.239 'cd ~/railiance-infra/ansible && ansible-playbook playbooks/bootstrap.yaml -c local --become -l Railiance01'"
@echo " make verify"
@echo ""
@echo "S2 — Cluster Runtime (railiance-cluster)"
@echo " ssh tegwick@92.205.62.239 'cd ~/railiance-cluster && make converge && make smoke'"
@echo ""
@echo "S3 — Platform Services (railiance-platform)"
@echo " ssh tegwick@92.205.62.239 'cd ~/railiance-platform && make forgejo-db-status'"
@echo ""
@echo "S4 — Developer Enablement (railiance-enablement)"
@echo " make -C ~/railiance-enablement check test"
@echo ""
@echo "S5 — Workloads and forge consumer checks"
@echo " make -C ~/railiance-forge gitea-status"
@echo " deploy each application through its owning rapp/release runbook"
@echo ""
@echo " Full runbook: docs/deploy-stack.md"
# ---- Utilities ----
doctor: ## Check tools and basic repo setup
@bash -ceu ' \
ok(){ printf "✔ %s\n" "$$1"; }; fail(){ printf "❌ %s\n" "$$1"; exit 1; }; \
command -v git >/dev/null && ok "git: $$(git --version)" || fail "git missing"; \
command -v ansible >/dev/null && ok "ansible: $$(ansible --version | head -1)"; \
command -v sops >/dev/null && ok "sops: $$(sops --version --check-for-updates)"; \
command -v age >/dev/null && ok "age: $$(age --version)"; \
command -v terraform >/dev/null && ok "terraform: $$(terraform -version | head -1)"; \
test -f keys/admin_ssh.pub && ok "keys/admin_ssh.pub present" || echo " add your SSH pubkey to keys/admin_ssh.pub"; \
python3 scripts/check_secret_paths.py --tracked >/dev/null && ok "declared secret paths encrypted" || fail "secret path check failed"; \
grep -q "age1" .sops.yaml && ok ".sops.yaml has an age recipient" || echo " add your age public key to .sops.yaml"; \
git config --get core.hooksPath >/dev/null && ok "git hooksPath: $$(git config --get core.hooksPath)" || echo " run: make hooks"; \
'
# ---- Inventory convenience ----
new-host: ## Add a new host quickly: make new-host NAME=core1 TYPE=cpx11 REGION=nbg1 ROLE=core
@[ -n "$(NAME)" ] || (echo "Usage: make new-host NAME=... [TYPE=...] [REGION=...] [ROLE=...] [IMG=...] [USER=...]" && exit 1)
@python3 scripts/new_host.py --name "$(NAME)" --type "$(TYPE)" --region "$(REGION)" --role "$(ROLE)" --image "$(IMG)" --user "$(USER)"
@echo "✔ Added host $(NAME) to inventory/servers.yaml"
remote-set: ## Set origin to your Gitea repo (GITEA/OWNER/REPO vars)
git remote remove origin 2>/dev/null || true
git remote add origin https://$(GITEA)/$(OWNER)/$(REPO).git
git branch -M main
git push -u origin main
@echo "✔ Remote set to https://$(GITEA)/$(OWNER)/$(REPO).git"
# ==== Convergence (Ansible) ====
ANS_DIR := ansible
INV_SCRIPT := $(ANS_DIR)/inventory_from_yaml.py
PLAY := $(ANS_DIR)/playbooks/bootstrap.yaml
# Inventory servers.yaml sets ansible_user. Override only when needed:
# make converge SSH_USER=tegwick
SSH_USER ?=
ANSIBLE_USER_FLAG := $(if $(SSH_USER),-u $(SSH_USER),)
# Load your SOPS key for decryption when running playbooks (optional if you use keys.txt)
export SOPS_AGE_KEY := $(shell cat ~/.config/sops/age/keys.txt 2>/dev/null)
ansible-help: ## Show common Ansible commands
@echo "Convergence targets:"
@echo " make ansible-inventory # show resolved inventory"
@echo " make ansible-ping # ping all hosts"
@echo " make converge # run baseline convergence on all hosts"
@echo " make converge-host HOST=web-01# run on a single host"
@echo " make converge-tags TAGS=base # run only tagged tasks"
@echo " make converge-firewall HOST=Railiance01 # UFW only (RAIL-HO-WP-0009)"
@echo " make converge-check # dry-run (check mode)"
@echo " make converge-diff # show config diffs"
@echo " make verify-host HOST=Railiance01 # read-only host verification"
@echo " make verify-refresh-host HOST=Railiance01 APPROVE_VERIFY_REFRESH=REFRESH-GOSS-Railiance01"
@echo " make goss-status # last on-host timer result"
ansible-inventory: ## Print the dynamic inventory Ansible will use
cd $(ANS_DIR) && ansible-inventory --list | head -200
ansible-ping: ## Quick connectivity check (SSH + Python availability)
cd $(ANS_DIR) && ansible all $(ANSIBLE_USER_FLAG) -m ping
status: ## Show live security state of all hosts (UFW, fail2ban, SSH hardening)
@echo "=== Connectivity ==="
cd $(ANS_DIR) && ansible all $(ANSIBLE_USER_FLAG) -m ping
@echo "=== UFW ==="
cd $(ANS_DIR) && ansible all $(ANSIBLE_USER_FLAG) -m shell -a "ufw status" --become
@echo "=== fail2ban ==="
cd $(ANS_DIR) && ansible all $(ANSIBLE_USER_FLAG) -m shell -a "systemctl is-active fail2ban"
@echo "=== SSH hardening ==="
cd $(ANS_DIR) && ansible all $(ANSIBLE_USER_FLAG) -m shell -a "grep -iE '^(PermitRootLogin|PasswordAuthentication)' /etc/ssh/sshd_config" --become
@echo ""
@echo "--- Hint: run 'make verify' for a structured pass/fail report ---"
verify: validate-handoff-readonly ## Read-only Goss verification of all hosts; writes TAP only on controller
@echo "Running read-only Goss baseline assertions..."
@cd $(ANS_DIR) && ansible-playbook playbooks/verify.yaml $(ANSIBLE_USER_FLAG) || \
(echo "One or more assertions FAILED — see reports/ for TAP output." && exit 1)
@echo "All assertions passed."
observe-railiance01: ## Timestamped host capacity observation for resource-control
@mkdir -p docs/evidence/resource-hosteurope-railiance01/observations
@out=$$(mktemp); \
python3 scripts/observe-host-capacity.py Railiance01 > $$out; \
stamp=$$(python3 -c "import json; print(json.load(open('$$out'))['observed_at'].replace(':',''))"); \
dest=docs/evidence/resource-hosteurope-railiance01/observations/$$stamp.json; \
mv $$out $$dest; \
ln -sfn $$stamp.json docs/evidence/resource-hosteurope-railiance01/observations/latest.json; \
echo "wrote $$dest"
verify-host: validate-handoff-readonly ## Read-only Goss verification: make verify-host HOST=Railiance01
@test -n "$(HOST)" || (echo "Usage: make verify-host HOST=Railiance01"; exit 1)
@echo "Running read-only Goss baseline assertions on $(HOST)..."
@cd $(ANS_DIR) && ansible-playbook playbooks/verify.yaml $(ANSIBLE_USER_FLAG) -l $(HOST) || \
(echo "One or more assertions FAILED — see reports/ for TAP output." && exit 1)
verify-refresh: ## Refresh Goss on all hosts after review (exact approval required)
@test "$(APPROVE_VERIFY_REFRESH)" = "REFRESH-GOSS-ALL" || \
(echo "Refusing host mutation: set APPROVE_VERIFY_REFRESH=REFRESH-GOSS-ALL after review"; exit 1)
cd $(ANS_DIR) && ansible-playbook playbooks/verify-refresh.yaml $(ANSIBLE_USER_FLAG)
verify-refresh-host: ## Refresh one host: HOST=... APPROVE_VERIFY_REFRESH=REFRESH-GOSS-<HOST>
@test -n "$(HOST)" || (echo "Usage: make verify-refresh-host HOST=Railiance01 APPROVE_VERIFY_REFRESH=REFRESH-GOSS-Railiance01"; exit 1)
@test "$(APPROVE_VERIFY_REFRESH)" = "REFRESH-GOSS-$(HOST)" || \
(echo "Refusing host mutation: set APPROVE_VERIFY_REFRESH=REFRESH-GOSS-$(HOST) after review"; exit 1)
cd $(ANS_DIR) && ansible-playbook playbooks/verify-refresh.yaml $(ANSIBLE_USER_FLAG) -l $(HOST)
goss-status: ## Fetch last on-host Goss timer result (fails if FAILED flag present)
cd $(ANS_DIR) && ansible-playbook playbooks/goss-status.yaml $(ANSIBLE_USER_FLAG)
converge: ## Converge all hosts to the baseline (idempotent)
cd $(ANS_DIR) && ansible-playbook $(PLAY) $(ANSIBLE_USER_FLAG)
converge-host: ## Converge a single host: make converge-host HOST=core-01
@test -n "$(HOST)" || (echo "Usage: make converge-host HOST=<name>"; exit 1)
cd $(ANS_DIR) && ansible-playbook $(PLAY) $(ANSIBLE_USER_FLAG) -l $(HOST)
converge-tags: ## Run only certain tags: make converge-tags TAGS="base,ufw"
@test -n "$(TAGS)" || (echo "Usage: make converge-tags TAGS=tag1,tag2"; exit 1)
cd $(ANS_DIR) && ansible-playbook $(PLAY) $(ANSIBLE_USER_FLAG) --tags "$(TAGS)"
converge-firewall: ## Apply only UFW tasks: make converge-firewall HOST=Railiance01
@test -n "$(HOST)" || (echo "Usage: make converge-firewall HOST=Railiance01"; exit 1)
cd $(ANS_DIR) && ansible-playbook playbooks/firewall.yaml $(ANSIBLE_USER_FLAG) -l $(HOST) --tags firewall \
$(if $(REEF_DECLARATION),-e reef_declaration_path="$(abspath $(REEF_DECLARATION))",)
validate-reef-exposure: ## Validate PORTS against REEF_DECLARATION without changing a host
@test -n "$(REEF_DECLARATION)" || (echo "Usage: make validate-reef-exposure REEF_DECLARATION=... PORTS=80,443"; exit 1)
python3 scripts/validate-reef-exposure.py --reef-declaration "$(REEF_DECLARATION)" --ports "$(or $(PORTS),80,443)"
converge-check: ## Dry-run (no changes), great for previews
cd $(ANS_DIR) && ansible-playbook $(PLAY) $(ANSIBLE_USER_FLAG) --check
converge-diff: ## Show file/templating diffs while applying changes
cd $(ANS_DIR) && ansible-playbook $(PLAY) $(ANSIBLE_USER_FLAG) --diff