Files
home-network-k3s-impl/infra/ansible/roles/node_health
jakman85andClaude Opus 5 c1f24e4a6d fix(dns): resilient node resolvers — mechanism-detecting, per-environment (R-DNS-01/03)
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]>
2026-09-01 00:11:49 -05:00
..

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, runs lsblk/lscpu, and — only when it can escalate — smartctl. It writes nothing to the node and installs nothing. smartmontools is declared in node_baseline_packages; if it is absent the SMART checks SKIP with that as the stated reason (operator ruling 2026-08-31: package installation is node_baseline's job, never a health check's, never a manual apt-get).
  • A skip is honest; a fake green is a defect. Every check is PASS / WARN / FAIL / SKIP / INFO, and a SKIP always 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 both k3s-prod-1 (HP EliteDesk, bare metal) and k3s-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

  1. A human summary printed in the play output.
  2. 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 running node-baseline so smartmontools exists — 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_cnt counts commands the kernel saw fail, which includes benign rejected or unsupported commands during discovery. Attribution needs dmesg, which needs root.