Re-authors the fix stranded on origin/fix/node-dns-resilient-upstream (7a34590). That
commit was NOT landed: it wrote a netplan drop-in and ran `netplan apply`, but netplan
does not exist on these nodes any more (Debian 13 trixie generic cloud image; not in
node_baseline_packages). Landing it would have failed the handler with command-not-found
and BROKEN node-baseline.yml on every node — worse than not landing it. Its dev-LAN
literals also sat in role defaults, which prod inherits, pointing prod nodes at a PiHole
on a network they cannot reach.
The underlying problem is real and verified: a SINGLE upstream resolver is a hard SPOF.
PiHole answers jackson.lan instantly but its external recursion flaps — 0/25 for public
names (registry-1.docker.io, quay.io, ghcr.io) while gitea.jackson.lan resolved 5/5 —
breaking containerd image pulls and CoreDNS `forward . /etc/resolv.conf`.
MECHANISM DETECTION (new `dns` tag in node_baseline). Five read-only probes feed one
set_fact yielding three outcomes, because the two environments no longer share a resolver
stack (dev-1 is behind resolved's 127.0.0.53 stub; prod-1 has a direct resolv.conf and no
cloud-init at all):
* systemd-resolved — resolved running AND owning /etc/resolv.conf (proven by the
symlink pointing into /run/systemd/resolve/, or by every nameserver being a resolved
loopback). Writes resolved.conf.d/99-k3s-dns.conf with an ordered DNS=, Domains=, and
FallbackDNS= EXPLICITLY EMPTIED so systemd's compiled-in public resolvers can never
silently bypass the local zone and ad-blocking.
* resolv-conf-file — plain directly-managed file (prod's shape). Ordered nameserver
lines + options timeout:2 attempts:2, backup: true. Deliberately NO `rotate`, which
would destroy PiHole-first ordering.
* contended — resolvconf/openresolv present or NetworkManager active. Writes NOTHING
and fails loudly: a change that silently gets reverted is worse than no change.
Resolved-running-but-not-owning-resolv.conf correctly falls through to the file branch.
Ordering is the policy that keeps the local zone working — the second server is only
consulted after the first fails, so a gateway NXDOMAIN can never beat PiHole to a
jackson.lan answer.
SEAMS. Dev values in role defaults ([192.168.1.18, 192.168.1.1], search jackson.lan);
prod values in inventory/group_vars/k3s_prod/vars.yml. NOT cluster-vars — that ConfigMap
is consumed by Flux postBuild for platform/ and apps/, and no Ansible code reads it.
PROD'S SECOND UPSTREAM IS AN OPEN OPERATOR DECISION and is left as a loud
BLOCKED-pending-second-upstream placeholder, the same precedent as the node_baseline disk
devices, with a dns-tagged assert that stops the play before any file is written. A
public resolver was NOT invented as a default: it would bypass the local zone, which is a
decision, not a default. PROD-PROVISIONING.md §4 was rewritten (the old bullet pointed at
the now-dropped branch) to state the choice, including that prod routing to the dev
PiHole is unverified across the pfSense boundary.
tofu: servers = [var.dns_server, var.network_gateway] — the one hunk of the old branch
worth taking as-is. Dev-only by nature.
ALSO FIXES A FALSE WARN THIS WOULD OTHERWISE CREATE: node_health_collect.py parsed
/etc/resolv.conf only, so on dev it read the 127.0.0.53 stub and would report a permanent
single-upstream WARN even after a correct fix. It now asks resolvectl for the real
upstreams when it sees the stub — and if that fails, nameservers is NULL with the reason
recorded, never the stub and never a guess. assess.yml turns null into SKIP, not WARN and
not PASS. The honesty discipline is preserved exactly.
Verified with NO play run against any node: syntax-check + yamllint + tofu fmt clean;
--tags packages still selects ONLY the apt task (the prod package-floor escape hatch
bypasses DNS entirely); --list-hosts unchanged; -c local simulation driving the REAL
detection expression and REAL file bodies extracted from the role by script (so the test
cannot drift) passes all 5 scenarios — dev stub -> systemd-resolved, prod shape ->
resolv-conf-file, NetworkManager and resolvconf -> contended; dev defaults pass the
assert byte-identically while prod overrides fail it by name; collector unit-tested
against 8 resolver shapes.
Serves: R-DNS-01, R-DNS-03, R-ENV-02, R-ENV-03.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
node_health — read-only node hardware health
Sibling of roles/cluster_verify. cluster_verify answers "is the cluster healthy";
node_health answers "is the hardware under it healthy". Same posture: it inspects and
reports, it never changes the node.
Serves R-ENV-01 (you cannot certify a node you have not measured), R-ENV-03 (bare-metal parity with dev) and R-OBS-04 (node-level observability).
# prod (all nodes in the k3s_prod group)
./infra/devcontainer/dev.sh run bash -c \
'cd /work/infra/ansible && ansible-playbook playbooks/node-health.yml -e node_health_hosts=k3s_prod'
# dev (default target), one node
./infra/devcontainer/dev.sh run bash -c \
'cd /work/infra/ansible && ansible-playbook playbooks/node-health.yml --limit k3s-dev-1'
Contract
- Read-only and side-effect-free. It reads
/sys,/proc,/etc, runslsblk/lscpu, and — only when it can escalate —smartctl. It writes nothing to the node and installs nothing.smartmontoolsis declared innode_baseline_packages; if it is absent the SMART checks SKIP with that as the stated reason (operator ruling 2026-08-31: package installation isnode_baseline's job, never a health check's, never a manualapt-get). - A skip is honest; a fake green is a defect. Every check is
PASS/WARN/FAIL/SKIP/INFO, and aSKIPalways carries why it could not be measured. Nothing that was not read is reported as passing. The summary ends with a dedicated "NOT READ (and why) — these are UNKNOWNS, not passes" section. - Works on bare metal and VMs. Everything absent in a guest (SMART on virtio, DMI,
EDAC,
coretemp, CPU throttle counters) skips cleanly instead of erroring. Verified against bothk3s-prod-1(HP EliteDesk, bare metal) andk3s-dev-1(Proxmox VM). - Never
becomes unless it must. Only SMART needs root.
What it checks
| id | what |
|---|---|
access.privilege |
whether become works at all (gates the root-only checks) |
storage.smart.<disk> |
overall-health, power-on hours, lifetime remaining, reallocated + pending sectors, CRC errors, drive temperature — root only |
storage.io_errors |
kernel SCSI/ATA ioerr_cnt / iotmo_cnt per disk (no root needed) |
storage.capacity, storage.inodes |
worst mounted filesystem, space and inodes |
storage.layout |
physical disks, and whether any device is genuinely free for node_baseline_longhorn_device / node_baseline_etcd_device |
memory.available, memory.swap, memory.ecc |
headroom, kubelet's swap-off prerequisite, EDAC correctable/uncorrectable counters |
cpu.throttle, cpu.inventory, cpu.virtualisation |
thermal throttle events since boot, topology + frequency scaling, VT-x/AMD-V + /dev/kvm (KubeVirt, R-PLAT-03) |
thermal.max |
hottest sensor across thermal zones and hwmon chips |
pressure.cpu / .memory / .io |
PSI some avg60 — dev learned to care about CPU PSI specifically |
network.link, network.errors, network.resolvers |
link state/speed, NIC error counters, DNS upstream redundancy per address family |
os.reboot_required, os.inventory, firmware.inventory |
pending reboot, OS/kernel/uptime, chassis + BIOS |
Thresholds live in defaults/main.yml (node_health_thresholds); no verdict is hardcoded
in task logic.
Output
- A human summary printed in the play output.
- The same summary and a structured JSON record under
infra/ansible/_artifacts/node-health/<host>.{txt,json}.
_artifacts/ is in infra/ansible/.gitignore — the same convention as
conformance/_artifacts/. Generated output is never committed (it also carries drive
serial numbers).
The become subtlety (read before editing)
ansible_become set as an inventory/group var — which is exactly what
inventory/group_vars/k3s_prod/vars.yml does — is a connection variable, and
connection variables outrank the become: play/task keyword. Verified on
ansible-core 2.20: a task saying become: false still tries to sudo and, on prod, dies
with Missing sudo password before the module runs. So this role overrides the
variable (vars: ansible_become: false) on the unprivileged work, and probes
escalation exactly once before attempting SMART.
Equally verified: failed_when: false does not catch a missing-sudo-password error
(it is raised by the become plugin before the failure expression is evaluated);
ignore_errors: true does, and still populates the registered variable. That is why the
escalation probe uses ignore_errors — anything else turns "no vault yet" into a hard,
misleading play failure.
Known limits
- Without root: no SMART (wear, power-on hours, reallocated/pending sectors, drive
temperature) and no DMI serial numbers. On prod that means creating
inventory/group_vars/k3s_prod/vault.yml(see that directory's README) and runningnode-baselinesosmartmontoolsexists — in that order. - EDAC reports nothing on platforms with no ECC memory controller bound. That is "ECC status unknown", not "no errors", and the role says so.
ioerr_cntcounts commands the kernel saw fail, which includes benign rejected or unsupported commands during discovery. Attribution needsdmesg, which needs root.