Ansible development
This document describes how the ferrarimarco_home_lab_node Ansible role is
structured, so that changes to it converge reliably. For how to run playbooks,
see the operational scripts guide.
Role architecture: data-driven stacks
The role configures hosts through per-workload "stacks" (monitoring, media-stack, monitoring-apt, ...). Each stack contributes its resources (directories, files, templates, services, Compose projects) as data, and a small set of generic tasks applies that data:
- Each stack defines its resources in a dedicated vars file
(
roles/ferrarimarco_home_lab_node/vars/<stack>-stack.yaml), with every resource'sstatederived from the stack's enablement flag viaternary(for example{{ configure_monitoring_apt | ternary('file', 'absent') }}). APT repositories (<stack>_os_package_repositories) are deb822 sources applied byansible.builtin.deb822_repository(name,uris,suites,components,signed_by,state); an entry lists underlegacy_file_pathsthe files that earlierapt_keyandapt_repositorytasks created, and the role removes them before applying the source. tasks/include-variables.yamlaggregates the per-stack lists into global custom facts. Each stack appears as one entry with anenable_custom_factswitch and afact_categoryname.- The
fact_categoryname doubles as the stack's tag: running the playbook with--tags '<fact_category>' --tags untaggedselects that stack plus the global untagged initialization tasks. - Generic, untagged tasks in
tasks/main.yaml(configure directories, render templates, download files, manage services) then apply the aggregated data.
Conventions the stacks rely on:
- Timer-driven units carry no
[Install]section and no restart entry. A oneshot service withWantedBy=multi-user.targetcan be enabled on its own and then runs at every boot (the Docker prune did on raspberrypi2 until 2026-10-12); restarting the service runs the job, and so does restarting the timer on systemd 252 (observed on hl01 the same day, not on raspberrypi2's 247). Leaveos_services_to_restartout of both entries: the daemon reload that follows rendering re-reads the units and recomputes the timer's next elapse (expected from systemd's reload semantics; a schedule change has not exercised it yet). A unit without[Install]reportsstatic, which the systemd module counts as enabled, so it is not declared inos_serviceseither: declare the timer. - Workload state lives in bind-mounted directories under the configuration
and runtime trees, which restic backs up, never in named or anonymous volumes.
Containers and images are disposable, so the weekly
docker system prune --all --force --volumescan remove them without losing anything; the backups are the recovery path, and a volume that ever held state would be unbacked. - Templates for files the workload rewrites are seeds. A file the
application edits at runtime (qBittorrent's settings, the Zigbee2MQTT secret,
the Wi-Fi credentials) is rendered with
force: false: it seeds a fresh host and is never touched again, so the settings beyond the seed are owned by the application's own interface, and a template change reaches only fresh hosts or a deliberate re-seed (the backup operations guide has the qBittorrent recipe). Render the seed where the image reads it: qBittorrent's was rendered one level above its directory and went unread for twenty months.
Because a disabled stack still contributes its resources with state: absent,
running a stack's tag with its enablement flag unset tears the stack down
rather than skipping it. This is intentional (declarative removal), but it means
an unexpected absent prediction in check mode must be treated as a signal that
an enablement flag resolved differently than intended.
Enablement flags
- Stacks are enabled per host through
configure_<stack>variables inhost_vars, with role defaults offalseindefaults/main.yaml. -
Debian-conditional stacks (such as
monitoring-apt) are instead enabled for every Debian host intasks/register-Debian-facts.yamlwith the pattern:- name: Set configure_monitoring_apt to allow for eventual overriding ansible.builtin.set_fact: configure_monitoring_apt: "{{ configure_monitoring_apt | default(true) }}"The
default(true)fallback only fires when the variable is otherwise undefined, which is what letshost_varsoverrides win. Do not add a role default for such a variable: adefaults/main.yamlentry makes the variable always defined, silently pins it, and disables the stack everywhere (this regression shipped between 2025-07 and 2026-09 formonitoring-apt).
Python exporter services
systemd services that run Python-based exporters build their virtual environment
through the shared /usr/local/bin/build-python-venv script (source:
files/scripts/build-python-venv.sh),
never through inline ExecStartPre venv and pip commands:
- The script rebuilds the venv only when the requirements file's hash or the system Python version changes, or when the venv is broken; otherwise a start costs one hash check.
- The venv lives in the unit's
StateDirectory=(/var/lib/<unit>), not in/run: systemd creates the state directory before assembling the mount namespace, so it composes withProtectSystem=strict, and it survives reboots. Mount-namespace directives such asReadWritePaths=are assembled before anyExecStartPreruns, so they must never reference a path a command in the unit is supposed to create. - Reference implementation: the
monitoring-aptunit template (templates/monitoring-apt/monitoring-apt.service.jinja).
Authorized keys on Proxmox nodes
The bootstrap role declares the complete set of root authorized keys with
exclusive: true. On Proxmox nodes two things complicate that:
/root/.ssh/authorized_keysis a symlink to the cluster-shared/etc/pve/priv/authorized_keys. Theansible.posix.authorized_keymodule writes a temporary file and renames it over the target, which replaces the symlink with a regular file and leaves the shared file untouched. At the nextpveproxystart (every boot runspvecm updatecerts --silent), Proxmox renames the regular file aside, recreates the symlink, and merges the file back into the shared one, so stale keys return and the repository key accumulates duplicates. The role therefore stats the user's file and, when it is a symlink, converges the link target (pathset to the target,manage_dir: false).- The same merge step always appends the node's own root key
(
/root/.ssh/id_rsa.pub), so an exclusive key set that omits it flaps on every boot.setup-Proxmox.yamlreads that key and registers it inbootstrap_additional_authorized_keys, which the key task appends to the repository key. The read is guarded by astatbecause only the Proxmox installer generates the key: the Molecule instance runs the Proxmox tasks without it.
Two Jinja details matter in that key task: the additional keys default to an
empty list because a task skipped by tags registers nothing, and the scalar is
double-quoted so that YAML turns the \n separator into a real newline. In a
folded scalar the escape reaches Jinja literally, and the module then writes all
the keys on one line.
A future cluster needs the peer nodes' keys in the set as well (read them with
delegate_to, tolerating a powered-off node); there is no cluster today.
The exclusive set applies to every host, not only Proxmox nodes: keys added by
hand on a host (hardware security keys, for example) disappear at the next
bootstrap run unless the repository declares them. The user and key tasks carry
the authorized-keys tag, so a host whose OS tasks cannot run yet (raspberrypi2
on Debian 11, whose release has no non-free-firmware component) still gets its
keys converged with --tags authorized-keys; on a Proxmox node pair it with
proxmox, which registers the node's own key.
Check mode conventions
- Always run
--check --diff(capturing the full output to a log file) and review the predicted changes before applying. Watch specifically for unexpectedstate: absentpredictions (see enablement flags above). ansible.builtin.get_urltasks always reportchangedin check mode: the module cannot verify remote content without downloading. Confirm against the real run before treating such a prediction as drift.- Design tasks so check mode runs cleanly and truthfully on first runs:
state-query tasks carry
changed_when: falseandcheck_mode: false, and later tasks tolerate resources that an earlier task would have created.
Coral driver delivery
Google retired the Coral APT repository (packages.cloud.google.com/apt answers
403 Forbidden) and archived the gasket driver source at version 1.0-18, and
Debian packages neither the driver nor the EdgeTPU runtime. The
ferrarimarco_home_lab_coral role therefore installs the gasket-dkms package
from a pinned release asset of the
gasket-builder project, the path
Frigate's documentation points to: it builds the archived source plus
kernel-compatibility patches. The pin (release tag and sha256) lives in the
role's vars/main.yaml; to bump it, download the new asset, verify it, and
update both values. The host needs only the kernel driver: Frigate's container
ships its own EdgeTPU runtime and the Coral exporter reads the device
temperature from sysfs, so the role removes the libedgetpu1-std runtime
package that the old repository installed.