26 KiB
Host nftables Firewall (base firewall concern) Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Build the firewall-tagged concern of the base role — default-deny nftables rendered from a shared group_vars service catalog, applied with an auto-rollback safety net.
Architecture: A pure Python filter plugin resolves the global firewall_catalog/firewall_zones into a flat per-host rule list; a Jinja template renders /etc/nftables.conf (validated at render time with nft -c); tasks apply it safely (snapshot → armed systemd-run revert → apply → confirm/disarm → persist). Molecule renders + syntax-checks only (never applies — it shares the host kernel); the resolver is unit-tested with pytest; real enforcement is a Level-2 staging concern.
Tech Stack: Ansible (ansible.builtin only — no new collections), nftables, Python 3 filter plugin + pytest, Molecule (Docker driver), systemd (systemd-run transient timer).
File structure
| File | Responsibility | Action |
|---|---|---|
roles/base/ (scaffold) |
the base role skeleton | Create via make new-role |
roles/base/meta/main.yml |
role metadata (galaxy_info) | Fill |
roles/base/defaults/main.yml |
base__firewall_* behaviour knobs |
Create |
inventories/{staging,production}/group_vars/all/firewall.yml |
shared firewall_zones + firewall_catalog |
Create |
roles/base/filter_plugins/firewall_rules.py |
pure catalog→rules resolver | Create |
tests/test_firewall_rules.py |
pytest units for the resolver | Create |
roles/base/templates/nftables.conf.j2 |
the ruleset | Create |
roles/base/tasks/main.yml |
include firewall.yml (tagged) |
Replace scaffold |
roles/base/tasks/firewall.yml |
install + render + safe-apply | Create |
roles/base/molecule/default/molecule.yml |
fixture ansible_host |
Adjust scaffold |
roles/base/molecule/default/converge.yml |
fixture catalog/zones + apply:false |
Replace scaffold |
roles/base/molecule/default/verify.yml |
assert rendered rules + nft -c |
Replace scaffold |
roles/base/README.md |
document the firewall concern | Fill |
STATUS.md, docs/CAPABILITIES.md |
reflect the build | Modify |
Notes for the implementer:
- Run Ansible/Python via the repo venv (
.venv/bin/...); the Makefile wires paths. Molecule:make test ROLE=base. - The Molecule platform pulls
forgejo.nyumbani.baobab.band/sjat/molecule-debian13:latest. If the registry/image is unreachable in your environment,make testcan't run — report DONE_WITH_CONCERNS for that step; the pytest units (Task 3) still fully validate the resolver logic, which is the only non-trivial code. - Before any
git commit, the pre-commit hook decryptsvault.yml, so the vault agent must be unlocked: runrbw unlocked(exit 0 = good); if locked, ask the user torbw unlock. None of these tasks touch vault files. make lintmust stay green (yamllint + ansible-lint over the new role +check-tags). Use FQCN, a tag on every task, stringmode:, andchanged_when:on everycommand/shell.
Task 1: Scaffold the base role
Files:
-
Create:
roles/base/(viamake new-role) -
Fill:
roles/base/meta/main.yml,roles/base/README.md -
Step 1: Scaffold
Run: make new-role NAME=base
Expected: prints "Role base scaffolded at roles/base/". Creates roles/base/{tasks,handlers,defaults,templates,files,meta,molecule/default} and a scaffold tasks/main.yml (---), molecule/default/{molecule.yml,converge.yml,verify.yml}, README.md.
- Step 2: Fill
roles/base/meta/main.yml
Replace the scaffold --- with:
---
galaxy_info:
author: sjat
description: Hardened baseline configuration for all boma hosts (Debian 13).
license: MIT
min_ansible_version: "2.17"
platforms:
- name: Debian
versions:
- trixie
dependencies: []
- Step 3: Write
roles/base/README.md
Replace the scaffold content with:
# base
Hardened baseline applied to every boma host. Built incrementally; the first concern
implemented is the **host firewall** (`firewall` tag).
## Firewall (nftables)
Default-deny inbound + east-west allowlisting + permissive egress, per ADR-020. Rules
are rendered from the shared `firewall_catalog` / `firewall_zones` (in `group_vars/all`)
by the `resolve_firewall_rules` filter, written to `/etc/nftables.conf`, syntax-checked
with `nft -c` at render time, and applied with an **auto-rollback safety net**
(`systemd-run` arms a revert that a follow-up task cancels once connectivity is
confirmed). The apply sequence lives in tasks rather than a handler so the confirm/cancel
step is controllable.
`/etc/nftables.d/*.nft` is `include`d by the ruleset — the extension hook the
`docker_host` role uses for container forward/NAT rules.
### Variables
See `defaults/main.yml` (`base__firewall_*`). SSH is accepted only on
`base__firewall_mgmt_interface` (default `wt0`, the NetBird overlay — ADR-016); set it to
a reachable interface/source until NetBird is built. Set `base__firewall_apply: false` to
render + validate without applying (used by Molecule).
### Testing
- `tests/test_firewall_rules.py` — pytest units for the resolver.
- `make test ROLE=base` — Molecule renders + `nft -c` syntax-checks (never applies; it
shares the host kernel). Enforcement + the apply/rollback path are verified at ADR-008
Level 2 on staging VMs.
- Step 4: Verify scaffold + lint
Run: test -d roles/base/molecule/default && .venv/bin/ansible-lint roles/base
Expected: directory exists; ansible-lint passes (the scaffold tasks/main.yml is empty ---, meta is now filled).
- Step 5: Commit
git add roles/base
git commit -m "feat(base): scaffold role + meta/README (firewall concern incoming)"
Task 2: Shared catalog/zones + role defaults
Files:
-
Create:
inventories/staging/group_vars/all/firewall.yml -
Create:
inventories/production/group_vars/all/firewall.yml -
Create:
roles/base/defaults/main.yml -
Step 1: Create the shared firewall data (both envs)
Write this identical content to both inventories/staging/group_vars/all/firewall.yml
and inventories/production/group_vars/all/firewall.yml:
---
# Shared firewall topology — single source of truth for the host nftables layer
# (base role) and OPNsense (future). See docs/decisions/020-firewall.md.
# Zone → subnet (from ADR-007).
firewall_zones:
mgmt: 10.10.0.0/24
srv: 10.20.0.0/24
lan: 10.30.0.0/24
iot: 10.40.0.0/24
guest: 10.50.0.0/24
# Service catalog: <name> → placement (host | group | hosts) + ingress[].
# Empty until services are built; hosts still get default-deny + the management plane.
firewall_catalog: {}
- Step 2: Create
roles/base/defaults/main.yml
Replace the scaffold --- with:
---
# Host firewall (nftables) behaviour knobs. Shared topology (firewall_catalog/
# firewall_zones) lives in group_vars/all, not here. See docs/decisions/020-firewall.md.
base__firewall_mgmt_interface: wt0 # SSH accepted only on this iface (NetBird, ADR-016)
base__firewall_ssh_port: 22
base__firewall_rollback_timeout: 45 # seconds before the auto-revert fires on a bad apply
base__firewall_dropin_dir: /etc/nftables.d
base__firewall_apply: true # set false to render+validate without applying (CI/Molecule)
- Step 3: Verify + lint
Run: .venv/bin/python -c "import yaml; [print(sorted(yaml.safe_load(open(p))['firewall_zones'])) for p in ['inventories/staging/group_vars/all/firewall.yml','inventories/production/group_vars/all/firewall.yml']]" && make lint
Expected: prints the sorted zone list twice (['guest', 'iot', 'lan', 'mgmt', 'srv']); make lint passes.
- Step 4: Commit
git add inventories/staging/group_vars/all/firewall.yml inventories/production/group_vars/all/firewall.yml roles/base/defaults/main.yml
git commit -m "feat(base): shared firewall catalog/zones + firewall defaults"
Task 3: The resolver filter plugin (TDD)
Files:
-
Create:
roles/base/filter_plugins/firewall_rules.py -
Test:
tests/test_firewall_rules.py -
Step 1: Write the failing tests
Create tests/test_firewall_rules.py:
import importlib.util
import pathlib
import pytest
_PATH = (
pathlib.Path(__file__).resolve().parent.parent
/ "roles" / "base" / "filter_plugins" / "firewall_rules.py"
)
_spec = importlib.util.spec_from_file_location("firewall_rules", _PATH)
fr = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(fr)
ZONES = {"lan": "10.30.0.0/24", "srv": "10.20.0.0/24"}
HOSTVARS = {
"docker01": {"ansible_host": "10.20.0.50"},
"docker02": {"ansible_host": "10.20.0.51"},
}
GROUPS = {"docker_hosts": ["docker01", "docker02"]}
def test_zone_source():
cat = {"reverse_proxy": {"host": "docker01",
"ingress": [{"from": "lan", "port": 443, "proto": "tcp"}]}}
out = fr.resolve_firewall_rules(cat, ZONES, "docker01", HOSTVARS, GROUPS)
assert out == [{"proto": "tcp", "port": 443, "sources": ["10.30.0.0/24"]}]
def test_service_source_resolves_to_host_ip():
cat = {
"reverse_proxy": {"host": "docker01", "ingress": []},
"photoprism": {"host": "docker01",
"ingress": [{"from": "reverse_proxy", "port": 2342, "proto": "tcp"}]},
}
out = fr.resolve_firewall_rules(cat, ZONES, "docker01", HOSTVARS, GROUPS)
assert out == [{"proto": "tcp", "port": 2342, "sources": ["10.20.0.50/32"]}]
def test_group_placement_and_source_multi_host():
cat = {"dns": {"group": "docker_hosts",
"ingress": [{"from": "docker_hosts", "port": 53, "proto": "udp"}]}}
out = fr.resolve_firewall_rules(cat, ZONES, "docker01", HOSTVARS, GROUPS)
assert out == [{"proto": "udp", "port": 53,
"sources": ["10.20.0.50/32", "10.20.0.51/32"]}]
def test_host_with_no_services_returns_empty():
cat = {"photoprism": {"host": "docker02",
"ingress": [{"from": "lan", "port": 2342, "proto": "tcp"}]}}
assert fr.resolve_firewall_rules(cat, ZONES, "docker01", HOSTVARS, GROUPS) == []
def test_unresolvable_from_raises():
cat = {"x": {"host": "docker01",
"ingress": [{"from": "nope", "port": 80, "proto": "tcp"}]}}
with pytest.raises(ValueError):
fr.resolve_firewall_rules(cat, ZONES, "docker01", HOSTVARS, GROUPS)
def test_duplicate_rules_deduped():
cat = {"app": {"host": "docker01", "ingress": [
{"from": "lan", "port": 8080, "proto": "tcp"},
{"from": "lan", "port": 8080, "proto": "tcp"},
]}}
out = fr.resolve_firewall_rules(cat, ZONES, "docker01", HOSTVARS, GROUPS)
assert out == [{"proto": "tcp", "port": 8080, "sources": ["10.30.0.0/24"]}]
def test_missing_ansible_host_raises():
cat = {"x": {"host": "docker01",
"ingress": [{"from": "docker02", "port": 80, "proto": "tcp"}]}}
with pytest.raises(ValueError):
fr.resolve_firewall_rules(cat, ZONES, "docker01", {"docker01": {}, "docker02": {}}, GROUPS)
- Step 2: Run tests to verify they fail
Run: .venv/bin/python -m pytest tests/test_firewall_rules.py -v
Expected: FAIL — FileNotFoundError / import error (the module doesn't exist yet).
- Step 3: Write the filter plugin
Create roles/base/filter_plugins/firewall_rules.py:
"""Resolve the shared firewall catalog into concrete nftables ingress rules for one host.
Used by the base role's nftables template (ADR-020 / host-nftables design). Pure
functions — unit-tested in tests/test_firewall_rules.py.
"""
def _placement_hosts(entry, groups):
"""Hostnames a catalog entry is placed on (exactly one of host/group/hosts)."""
if "host" in entry:
return [entry["host"]]
if "group" in entry:
return list(groups.get(entry["group"], []))
if "hosts" in entry:
return list(entry["hosts"])
raise ValueError(f"catalog entry has no placement (host/group/hosts): {entry!r}")
def _host_cidr(host, hostvars):
hv = hostvars.get(host) or {}
ip = hv.get("ansible_host")
if not ip:
raise ValueError(f"no ansible_host for '{host}' — cannot resolve firewall source")
return f"{ip}/32"
def _resolve_source(frm, catalog, zones, hostvars, groups):
"""Resolve a symbolic `from` to a sorted list of source CIDRs."""
if frm in zones:
return [zones[frm]]
if frm in catalog:
return sorted(_host_cidr(h, hostvars)
for h in _placement_hosts(catalog[frm], groups))
if frm in groups:
return sorted(_host_cidr(h, hostvars) for h in groups[frm])
if frm in hostvars:
return [_host_cidr(frm, hostvars)]
raise ValueError(f"unresolvable firewall source '{frm}'")
def resolve_firewall_rules(catalog, zones, inventory_hostname, hostvars, groups):
"""Return sorted, de-duped [{proto, port, sources:[cidr,...]}] for services on this host."""
catalog = catalog or {}
zones = zones or {}
groups = groups or {}
rules = []
for _name, entry in sorted(catalog.items()):
if inventory_hostname not in _placement_hosts(entry, groups):
continue
for ing in entry.get("ingress", []):
rules.append({
"proto": ing.get("proto", "tcp"),
"port": int(ing["port"]),
"sources": _resolve_source(ing["from"], catalog, zones, hostvars, groups),
})
seen = set()
out = []
for r in sorted(rules, key=lambda x: (x["port"], x["proto"], x["sources"])):
key = (r["proto"], r["port"], tuple(r["sources"]))
if key not in seen:
seen.add(key)
out.append(r)
return out
class FilterModule:
"""Ansible filter plugin entry point."""
def filters(self):
return {"resolve_firewall_rules": resolve_firewall_rules}
- Step 4: Run tests to verify they pass
Run: .venv/bin/python -m pytest tests/test_firewall_rules.py -v
Expected: PASS (all 7 tests).
- Step 5: Commit
git add roles/base/filter_plugins/firewall_rules.py tests/test_firewall_rules.py
git commit -m "feat(base): firewall catalog resolver filter plugin + tests"
Task 4: Template + render tasks + Molecule fixtures
Files:
-
Create:
roles/base/templates/nftables.conf.j2 -
Create:
roles/base/tasks/firewall.yml -
Replace:
roles/base/tasks/main.yml -
Adjust:
roles/base/molecule/default/molecule.yml -
Replace:
roles/base/molecule/default/converge.yml -
Step 1: Create the template
Create roles/base/templates/nftables.conf.j2:
#!/usr/sbin/nft -f
# Ansible managed — do not edit by hand. Source: roles/base (ADR-020).
flush ruleset
table inet filter {
chain input {
type filter hook input priority 0; policy drop;
iif "lo" accept
ct state established,related accept
ct state invalid drop
iif "{{ base__firewall_mgmt_interface }}" tcp dport {{ base__firewall_ssh_port }} accept
ip protocol icmp accept
ip6 nexthdr ipv6-icmp accept
{% for r in base__firewall_resolved %}
ip saddr { {{ r.sources | join(', ') }} } {{ r.proto }} dport {{ r.port }} accept
{% endfor %}
}
chain forward { type filter hook forward priority 0; policy drop; }
chain output { type filter hook output priority 0; policy accept; }
}
include "{{ base__firewall_dropin_dir }}/*.nft"
- Step 2: Create
roles/base/tasks/firewall.yml(render path only; apply added in Task 5)
---
- name: Install nftables
ansible.builtin.apt:
name: nftables
state: present
tags: [firewall]
- name: Ensure nftables drop-in dir exists
ansible.builtin.file:
path: "{{ base__firewall_dropin_dir }}"
state: directory
mode: "0755"
tags: [firewall]
- name: Resolve firewall ingress rules for this host
ansible.builtin.set_fact:
base__firewall_resolved: >-
{{ firewall_catalog | default({})
| resolve_firewall_rules(firewall_zones | default({}),
inventory_hostname, hostvars, groups) }}
tags: [firewall]
- name: Render nftables ruleset (syntax-checked before install)
ansible.builtin.template:
src: nftables.conf.j2
dest: /etc/nftables.conf
mode: "0644"
validate: "nft -c -f %s"
register: base__firewall_render
tags: [firewall]
- Step 3: Replace
roles/base/tasks/main.yml
---
- name: Configure host firewall (nftables)
ansible.builtin.include_tasks: firewall.yml
tags: [firewall]
- Step 4: Add a fixture IP in
roles/base/molecule/default/molecule.yml
In the provisioner.inventory.host_vars.instance map (which already sets
ansible_user: root), add ansible_host: 10.20.0.50 so the resolver can map the
instance to an IP. The block becomes:
provisioner:
name: ansible
inventory:
host_vars:
instance:
ansible_user: root
ansible_host: 10.20.0.50
(The Molecule Docker connection addresses the container by name, not ansible_host, so
this is data-only and won't affect connectivity.)
- Step 5: Replace
roles/base/molecule/default/converge.ymlwith a fixture catalog andapply: false
---
- name: Converge
hosts: all
become: true
gather_facts: true
vars:
base__firewall_apply: false
firewall_zones:
lan: 10.30.0.0/24
srv: 10.20.0.0/24
mgmt: 10.10.0.0/24
firewall_catalog:
reverse_proxy:
host: instance
ingress:
- { from: lan, port: 443, proto: tcp }
photoprism:
host: instance
ingress:
- { from: reverse_proxy, port: 2342, proto: tcp }
roles:
- role: base
- Step 6: Run Molecule (scaffold verify still trivially passes) + lint
Run: make lint && make test ROLE=base
Expected: make lint passes. Molecule creates the container, converges (installs nftables, renders /etc/nftables.conf, and the nft -c validate succeeds), passes the idempotence run (second converge reports no changes), runs the scaffold verify.yml (asserts true), and destroys. If the registry image is unreachable, report DONE_WITH_CONCERNS and confirm make lint + Task 3 pytest still pass.
- Step 7: Commit
git add roles/base/templates/nftables.conf.j2 roles/base/tasks/firewall.yml roles/base/tasks/main.yml roles/base/molecule/default/molecule.yml roles/base/molecule/default/converge.yml
git commit -m "feat(base): render nftables ruleset from catalog (+ molecule fixture)"
Task 5: Safe apply with auto-rollback
Files:
-
Modify:
roles/base/tasks/firewall.yml(append the apply block) -
Step 1: Append the safe-apply block to
roles/base/tasks/firewall.yml
Add at the end of the file:
- name: Apply firewall ruleset safely (with auto-rollback)
when:
- base__firewall_apply | bool
- base__firewall_render is changed
tags: [firewall]
block:
- name: Snapshot the current ruleset as the rollback point
ansible.builtin.shell: "nft list ruleset > /etc/nftables.rollback"
changed_when: false
- name: Clear any stale rollback unit
ansible.builtin.shell: >-
systemctl stop nft-rollback.timer nft-rollback.service 2>/dev/null;
systemctl reset-failed nft-rollback.timer nft-rollback.service 2>/dev/null;
true
changed_when: false
- name: Arm the auto-rollback timer
ansible.builtin.command:
cmd: >-
systemd-run --on-active={{ base__firewall_rollback_timeout }}
--unit=nft-rollback /usr/sbin/nft -f /etc/nftables.rollback
changed_when: true
- name: Apply the new ruleset
ansible.builtin.command: nft -f /etc/nftables.conf
changed_when: true
- name: Confirm connectivity survived, then disarm the rollback
ansible.builtin.shell: >-
systemctl stop nft-rollback.timer nft-rollback.service 2>/dev/null;
systemctl reset-failed nft-rollback.timer nft-rollback.service 2>/dev/null;
true
changed_when: false
- name: Enable nftables.service so the ruleset persists across reboot
ansible.builtin.systemd:
name: nftables
enabled: true
when: base__firewall_apply | bool
tags: [firewall]
(The "Confirm" step runs only if the play reached it — i.e. the apply did not sever the
connection. If the apply locked the host out, the play cannot continue, the armed timer
fires after base__firewall_rollback_timeout seconds, and the host self-heals to the
snapshot. Molecule sets base__firewall_apply: false, so this block is skipped there.)
- Step 2: Re-run Molecule + lint (apply still skipped, must stay idempotent)
Run: make lint && make test ROLE=base
Expected: make lint passes (no no-changed-when/FQCN findings — every command/shell has changed_when). Molecule still green and idempotent (the apply block is gated off by base__firewall_apply: false). DONE_WITH_CONCERNS if the image is unreachable.
- Step 3: Commit
git add roles/base/tasks/firewall.yml
git commit -m "feat(base): safe nftables apply with systemd-run auto-rollback"
Task 6: Molecule verify — assert rendered rules + syntax
Files:
-
Replace:
roles/base/molecule/default/verify.yml -
Step 1: Replace
roles/base/molecule/default/verify.yml
---
- name: Verify
hosts: all
become: true
gather_facts: false
tasks:
- name: Read the rendered ruleset
ansible.builtin.slurp:
src: /etc/nftables.conf
register: ruleset
- name: Decode it
ansible.builtin.set_fact:
nft: "{{ ruleset.content | b64decode }}"
- name: Assert default-deny input policy and management plane
ansible.builtin.assert:
that:
- "'type filter hook input priority 0; policy drop;' in nft"
- "'ct state established,related accept' in nft"
- "'iif \"wt0\" tcp dport 22 accept' in nft"
fail_msg: "input chain is missing default-deny or the management plane"
- name: Assert the lan->reverse_proxy:443 ingress rule
ansible.builtin.assert:
that:
- "'10.30.0.0/24' in nft"
- "'tcp dport 443 accept' in nft"
fail_msg: "missing lan->443 rule for reverse_proxy"
- name: Assert the reverse_proxy->photoprism:2342 ingress rule (resolved to host IP)
ansible.builtin.assert:
that:
- "'10.20.0.50/32' in nft"
- "'tcp dport 2342 accept' in nft"
fail_msg: "missing reverse_proxy->2342 rule for photoprism"
- name: Assert the docker_host extension hook is present
ansible.builtin.assert:
that:
- "'include \"/etc/nftables.d/*.nft\"' in nft"
fail_msg: "missing drop-in include hook"
- name: Syntax-check the rendered ruleset (no apply)
ansible.builtin.command: nft -c -f /etc/nftables.conf
changed_when: false
- Step 2: Run the full Molecule sequence + lint
Run: make lint && make test ROLE=base
Expected: make lint passes; Molecule converge renders, then verify.yml passes all
assertions and the nft -c check. DONE_WITH_CONCERNS if the image is unreachable (note
that the assertions could not be exercised).
- Step 3: Commit
git add roles/base/molecule/default/verify.yml
git commit -m "test(base): molecule verify asserts rendered firewall rules + nft -c"
Task 7: Reflect the build in STATUS + CAPABILITIES
Files:
-
Modify:
STATUS.md -
Modify:
docs/CAPABILITIES.md -
Step 1: Update the
roles/base/row in STATUS.md
In STATUS.md, under "## Scaffolded but empty — NOT implemented", find the row:
| `roles/base/` | Not in git — only an empty dir on disk (untracked). `site.yml` references it, so a clean clone errors on `make deploy PLAYBOOK=site` until it is built. |
Replace it with:
| `roles/base/` | **Partially built.** The `firewall` concern is implemented (nftables: catalog-driven default-deny + east-west allowlist + auto-rollback apply; ADR-020) with pytest + Molecule render/syntax tests. Other concerns (SSH hardening, fail2ban, auditd, packages, users) are **not** built yet, so `make deploy PLAYBOOK=site` is still incomplete. |
- Step 2: Update the firewall note in CAPABILITIES.md
In docs/CAPABILITIES.md (§1 Edge & networking), find the line added for ADR-020:
_Firewalling is two-layer (ADR-020): OPNsense at the perimeter + inter-VLAN, plus
per-host `nftables` (default-deny inbound + east-west allowlist) rendered by the `base`
role from a shared `group_vars` service catalog. Both layers are still to be built._
Replace the final sentence so it reads:
_Firewalling is two-layer (ADR-020): OPNsense at the perimeter + inter-VLAN, plus
per-host `nftables` (default-deny inbound + east-west allowlist) rendered by the `base`
role from a shared `group_vars` service catalog. The host `nftables` layer is built (the
`base` firewall concern); the OPNsense layer is still to be built._
- Step 3: Update the
_Last reviewed_date in STATUS.md
In STATUS.md, change the _Last reviewed: ..._ line to _Last reviewed: 2026-06-06._
(if it is not already that date).
- Step 4: Verify + lint
Run: grep -n "Partially built" STATUS.md && grep -n "host .nftables. layer is built" docs/CAPABILITIES.md && make lint
Expected: both greps match; make lint passes.
- Step 5: Commit
git add STATUS.md docs/CAPABILITIES.md
git commit -m "docs: record base firewall concern built (ADR-020 host layer)"
Final verification
make lintpasses end to end (yamllint + ansible-lint overroles/base+check-tags: OK)..venv/bin/python -m pytest tests/ -vpasses (thecheck-tagssuite + the 7 newfirewall_rulestests).make test ROLE=baseis green (or DONE_WITH_CONCERNS with a clear note if the Molecule image is unreachable in this environment).git log --oneline -7shows the seven task commits.- Sanity:
roles/base/tasks/firewall.ymlnever applies whenbase__firewall_applyis false, and everycommand/shelltask haschanged_when(ansible-lint clean).