Krikri::FactsGatherer
Perf item 2 - the fact-gathering body,
lifted verbatim out of plugins/facts.cr so the fat plugin binary
can link it and serve facts over the persistent daemon like every
other module.
facts was the last plugin still excluded from both the fat binary
and the daemon, and the reason was purely SHAPE: it had no
*Plugin < BasePlugin class and no input = STDIN.gets_to_end
trailer, which is what build.sh's fat-binary generator keys on. It
gathers on every host in every play and is frequently the slowest
single step of a warm run, so it paid a fresh ssh fork + remote
process spawn for the one task nothing can skip.
Everything below is the ORIGINAL top-level code, unchanged except
for being wrapped in this module (extend self keeps every internal
call site - capture, gather_facts, the gather_*_facts family -
resolving exactly as it did at top level). Wrapping matters: merged
into one binary with 80+ other plugins, top-level def capture and
a top-level re-opened lib LibC would be sharing a namespace with
every one of them.
It is deliberately NOT reshaped into a BasePlugin subclass, which
would have needed no generator change at all: BasePlugin#run_and_ capture returns a PluginResult, whose to_json round-trips every
extra field through JSON.parse(value.to_json) - a serialize-then-
reparse of the whole fact dict, on the exact hot path this item
exists to make cheaper. It would also have added an always-empty
msg key to a payload that has never carried one.
Constants
Ansible's interpreter_discovery builds the help_text doclink from the RUNNING ansible-core's major.minor (get_versioned_doclink), so a 2.21.6 controller points at /ansible-core/2.21/. A hardcoded release here made the discovery warning diverge from real's byte for byte as soon as the emulated release moved on (issue #933's podman diff: real 2.21 vs krikri 2.19). Derived from the release krikri targets so it cannot drift again.
Ansible's LinuxNetwork.INTERFACE_TYPE map (network/linux.py): sysfs /sys/class/net/<iface>/type numbers to the fact string, unknown numbers reporting "unknown" - default_ipv4.type is sourced from the same file the same way (issue #133).
Ansible's own SMBIOS chassis-type table behind ansible_form_factor.
Directories searched beyond $PATH for service-manager binaries - /sbin and /usr/sbin are routinely absent from a non-login shell's PATH, and that is where systemctl/initctl live on some distros.
The same, for this engine's implicit Gathering Facts call: the executor hands the facts plugin the host's already-resolved ansible_python_interpreter, which real folds into the module's shebang rather than its argument spec.
Which subset tokens map to which krikri gatherer family -
Ansible's aliases_map (each collector's _fact_ids): asking for a
single fact id like all_ipv4_addresses turns on that collector's
whole family, exactly as Ansible's fact_id -> collector map
does. The min-bundle subset names (distribution, python, user, ...)
are absent: they resolve to the min bundle itself, which
Ansible always gathers first anyway.
Task args the gather_facts ACTION plugin consumes itself and never
forwards to the fact module it runs (gather_facts.py pops parallel
off the task args before building the module args). A direct setup:
task's own parallel: is NOT consumed - it reaches setup's argument
spec and fails there like any other unknown key.
Ansible's INTERPRETER_PYTHON_FALLBACK default (ansible-core
2.19.11, ansible/config/base.yml) - the ordered candidate list its
interpreter discovery probes. Discovery is deliberately dumb: one
command -v per candidate through the connection's /bin/sh, and
the first absolute path in the output wins, /usr/bin/python3 when
nothing does. That is why a Ubuntu 22.04 target discovers the
VERSIONED /usr/bin/python3.10 and not whatever its python3
resolves to - the value this engine's python facts used to stamp
into discovered_interpreter_python by probing under
find_executable("python3"), which real never reports (issue #374:
the divergence is observable from the controller, where a
connection: local task executes the discovered path).
The families "min" covers in this engine. Ansible's
minimal_gather_subset also includes 'local' (the ansible_local
fact_path collector), which this tracks as its own family entry so
!local can drop just the custom-facts scan without touching the
rest of min. "min" itself is the bookkeeping name for the six
always-on gatherers in gather_facts.
setup's own argument-spec options (ansible.builtin.setup's four documented params), in the order Ansible's UnsupportedError message sorts them into "Supported parameters include:".
SshPubKeyFactCollector's keydir search order.
Every subset name ansible-core 2.19.4 accepts - the exact list
its own Bad-subset failure message enumerates (captured live from
setup on this machine). krikri implements only the network/
hardware/mounts families plus the min bundle; names that
Ansible resolves to collectors krikri has no implementation for
(virtual, dns, selinux, ...) are ACCEPTED but gather nothing extra,
because failing them would break every role that uses a valid
subset name this engine simply has no facts for.
Instance methods
filter - Ansible's fnmatch (shell-style glob) filter over the TOP-LEVEL fact keys only, applied after gathering. Empty patterns mean no filter (live-verified: filter="" returns everything).
Runs command with args directly (no shell), capturing stdout only
(stderr discarded) - equivalent to `command 2>/dev/null` but without
forking an intermediate /bin/sh -c. Returns "" if the binary can't be
found or execution otherwise fails, matching the empty-output behavior a
missing command produces under the shell.
Same as capture, but merges stderr into stdout - equivalent to
`command 2>&1`. Used for python --version, which some Python
builds print to stderr instead of stdout.
dash words a missing command as "/bin/sh: 1: X: not found" while bash words it as "/bin/sh: line 1: X: No such file or directory" - ansible shows whichever of the two the target's /bin/sh really is, so the flavour has to be detected on the machine running this code.
Detect whether we're running inside a container/VM, following the same
heuristics Ansible's fact gathering uses. Returns the virtualization
type name (e.g. "docker", "lxc", "kvm", "xen"), or "None" (the exact
string Ansible uses) when running on bare metal / a plain host.
ansible_pkg_mgr / ansible_facts.pkg_mgr - which package manager
Ansible's own pkg_mgr.py fact module reports, entirely unset before this
(found via openstack.ansible-hardening's own include_tasks: "{{ ansible_facts['pkg_mgr'] }}.yml" - the role's main OS-dispatch point,
resolving to the literal "undefined.yml" and failing the include
outright, taking the rest of that STIG control file's tasks down with
it). Ansible's own detection checks a longer, more exhaustive list
of package-manager binary paths and has extra dnf-vs-yum-symlink
disambiguation; this covers the package managers real roles actually
gate on (apt/dnf/yum/zypper/pacman/apk/pkgng), checked in the same
dnf-before-yum priority Ansible uses so a modern RHEL system
(where /usr/bin/yum is often just a symlink to dnf) reports "dnf".
Runs ansible's discovery bootstrap on THIS machine (the plugin
executes on the target for a remote gather, so "this machine" is
the target there, exactly like real's _low_level_execute_command
through the connection). Returns {interpreter, found_any}:
found_any distinguishes ansible's two fallback shapes - "no
interpreters found" (warning, early return, no second warning)
from "unexpected bootstrap output" (which real treats as the
fallback with the ordinary discovered-interpreter warning).
Python fnmatch.translate's glob dialect: *, ?, [seq], [!seq] - an unterminated [ is a literal. Case-sensitive (posix fnmatch).
socket.getfqdn()'s final-step parse lives in PluginHelpers::Getfqdn now, shared with the hostname plugin, which must produce the same ansible_fqdn at its own returned-facts layer (issue #585: the two disagreed and the role's warm run diverged).
/proc/cmdline facts - Ansible's CmdLineFactCollector part of real min output (podman-diff setup case, real W2): ansible_cmdline collapses duplicate keys (later token wins), ansible_proc_cmdline turns them into lists, and a flag without "=" is True. Empty/missing /proc/cmdline sets NEITHER key (Ansible's collector returns {} and skips both facts).
Block-device facts - the ansible_devices dict (keyed by device name)
ansible-core's Linux hardware collector ALWAYS populates by
scanning /sys/block/*. Found via Tecnativa.hetzner_rescue_installimage's
templates/autosetup.j2 ({% for device in ansible_devices if device.startswith("sd") ... %}): with the fact never set, the loop died
with "can't iterate over undefined" and killed the role's "configure
installation" task, which Ansible completes (iterating a missing
fact is never the case there - its setup module defines the key even as
an empty dict). Deliberately NOT conditioned on non-empty, unlike
ansible_mounts above: templates need the key to exist and be iterable
even when the scan finds nothing (minimal containers) - making it
omit-when-empty would reintroduce exactly this bug.
ansible_dns - Ansible's DnsFactCollector over /etc/resolv.conf: nameserver lines append to "nameservers", domain/search/sortlist as scalar/list, options as key:value or bare-True flags. The KEY is always set - an empty/comment-only resolv.conf yields {}, never an absent ansible_dns (real min output carries it on every host, podman-diff setup case).
Gather all system facts
gather_subset: - which families of facts to collect. Tokens are
Ansible's: all, min, hardware, network, mounts, the per-fact
aliases under FAMILY_SUBSETS, plus a leading "!" to subtract.
Later tokens win, unknown positive tokens fail (BadSubsetError),
and "min" is the floor exactly as in Ansible - which means
!all still yields the min set, while !all,!min (or a bare
!min) yields nothing but the gather_subset/module_setup meta
keys, live-verified against 2.19.4.
Subsetting exists to skip the EXPENSIVE families: !hardware avoids
reading every block device, !mounts avoids statting every mount.
ansible_fibre_channel_wwn - Ansible scans /sys/class/fc_host/*/node_name + port_name ("0x..." strings); no FC adapters (containers, most VMs) yields []. The KEY is still set under the subset that selects it (real W5 carries it with [] on hosts without FC hardware) - podman-diff setup case.
ansible_hostnqn - Ansible reads /etc/nvme/hostnqn ("" when missing). Missing entirely before - podman-diff setup case (real W5).
ansible_is_chroot - Ansible's IsChrootFactCollector compares /proc/1/root's resolved inode/device against /: same (the usual case, PID 1 lives in this root) is False, different is True. Missing entirely before - podman-diff setup case (real W5, all).
ansible_iscsi_iqn - Ansible reads the InitiatorName= line out of /etc/iscsi/initiatorname.iscsi, defaulting to "" when the file (or the entry) is absent. Missing entirely before - podman-diff setup case (real W5).
ansible_loadavg - Ansible's LoadAvgFactCollector over /proc/loadavg's first three fields. Missing entirely before - podman-diff setup case (real W5, all).
fact_path - Ansible's local facts mechanism
Observed behavior: every *.fact file in
fact_path (real default /etc/ansible/facts.d) becomes a key under
ansible_local. Executable files are RUN and their stdout parsed;
the rest are read in place. Content must parse as JSON, else as
ini (section-REQUIRED - a bare key=value with no [section] header
is configparser's MissingSectionHeaderError, live-verified, and
yields the same "error loading facts as JSON or ini - please check
content:" error string Ansible stores as the fact's value);
unparseable content is stored as that error string, never fatal.
Mount facts - a list of dicts, one per mounted filesystem, matching
Ansible's ansible_mounts shape (mount/device/fstype/opts are the fields
roles like os_hardening read). Parsed from /proc/self/mountinfo rather
than forking mount, and bounded to real bind/devtmpfs noise that roles
filter on themselves.
Ansible's own ansible_facts['mounts'] entries always include
space/inode statistics (size_total/size_available/block_size/
block_total/block_available/block_used/inode_total/
inode_available/inode_used, from os.statvfs() on each mountpoint)
alongside mount/device/fstype/opts - this plugin only ever populated the
latter, so any role reading the former (robertdebock.diskspace's whole
purpose: item.size_available | int >= kilobytes_available | int) saw
an undefined field and either crashed or - worse - silently never
actually checked anything, since the role's own when: mount.name == item.mount guard still matched the real mountpoint correctly; the
comparison inside the assert is what broke. Reads statvfs(2) directly
through LibC (declared at the top of this file next to uname) instead
of forking a stat -f subprocess per mount - identical numbers, just
fetched via a syscall instead of a subprocess. Same fields
Ansible's own os.statvfs() reads:
block_size (f_frsize, what stat -f %S reports)
block_total (f_blocks) block_free, all users (f_bfree)
block_available, non-root (f_bavail)
inode_total (f_files) inode_free, non-root (f_favail)
Section-annotation wrapper: a crash inside one gatherer's parsing (e.g. the round-5210000 ktechmidas.openvpn warm run's bare "Index out of bounds" from somewhere in the network section after the role's own tun interface/service state changed the host) surfaces as "Facts gathering failed: <msg>" with no hint which section died. Annotate the message with the section name so the next occurrence pinpoints itself.
ansible_ssh_host_key_{algo}_public(+keytype) - Ansible's
SshPubKeyFactCollector (ssh_pub_keys). Never implemented before -
znerol.ssh_kba round 5466303: the role's server-host-key chain reads
hostvars[..]["ansible_ssh_host_key*"] behind | default(False)
guards, which all went false, collapsing
ssh_kba_server_host_names_and_keys to [] and skipping its
| length > 0 include gate where ansible-playbook runs all six
loop items. (Round 5460170's depth-cap fix was real but masked this:
the loop ran empty for a different reason on every real host.)
Mirrors the collector's exact walk: dirs in order (/etc/ssh, /etc/openssh, /etc), algos (dsa, rsa, ecdsa, ed25519), and - subtle - the early return fires PER FACT NAME, so an algo the first dir lacked keeps falling through to the later dirs while the algos it DID find stop the search immediately. A file that exists but yields fewer than two whitespace-separated tokens is skipped (real would raise mid-collection; no real .pub file does this).
ansible_system_capabilities / _enforced - Ansible's
SystemCapabilitiesFactCollector
via capsh --print: its "Current:" line decides both - the bare
"=ep" bounding set means unenforced, anything else means enforced
with that capability list; no capsh binary (or a failing run)
leaves both keys at Ansible's literal "N/A" defaults. Missing
entirely before - podman-diff setup case (real W2 min carries
both keys).
virtualization facts - moved out of the min gatherers into their
own family: Ansible's VirtualFactCollector runs under the
'virtual' subset, which !all (min only) never selects (real W2
min output has NO virtualization facts, W5 all does - podman-diff
setup case), so reporting them under min made this engine's !all
result diverge by two keys. The tech_guest/tech_host lists
Ansible also reports (as sets, serialized as lists) were missing
entirely.
virtualization_role ("guest"/"host"/"NA") - entirely missing
before this, found benchmarking Ansible-Security-Compliance's
rhel7-role-hipaa (round823): its own audit-rule tasks gate on
ansible_virtualization_role != "guest" or ansible_virtualization_ type != "docker" (skip certain host-only audit rules on a
container/VM guest) - Ansible resolves this fine on a real
cloud VM (role: "guest"), this engine raised "Error while
evaluating conditional: 'ansible_virtualization_role' is
undefined" and crashed the whole run outright instead of just
this one task's when:. This engine's own #detect_virtualization
never distinguishes hypervisor-host detection from guest
detection (Ansible's own host-side checks - a populated
/etc/xen/, a running libvirtd, etc - are rare in practice and not
implemented here), so "host" is never reported; every detected
type maps to "guest", matching the overwhelming common case (a
real role's target is virtualized, not the hypervisor itself).
Python repr() of the fallback list, for ansible's "No python interpreters found for host 'x' (tried [...])." warning text.
Whether path cannot be executed on this machine - the check Ansible's interpreter discovery effectively performs before failing with rc 127. A leading "~/" is the invoking user's home; anything containing "/" must exist as an executable file, while a bare name must resolve on PATH. "auto" and its variants are discovery requests, not paths - Ansible never reports them missing.
The exact block ansible-playbook prints when the implicit setup module dies with rc 127 on a missing interpreter (live-verified output): the failed-task banner, the cause chain, the sub-event box carrying the module's own message, and the fatal line with ansible's failed_modules payload. The fatal line is returned uncoloured as its last line - the executor applies the red tint there, like every other fatal line, without touching the rest.
module_stdout/module_stderr differ by transport AND by target shell: over a real SSH round trip the interpreter error is the remote shell's (dash says "/bin/sh: 1: ... not found", bash says "/bin/sh: line 1: ... No such file or directory") and the connection-closed notice lands in stderr, while on ansible_connection=local the controller's own /bin/sh raises "not found" on stderr and stdout is empty.
The JSON's key order follows ansible's observed failed_modules payload exactly, so it is written with JSON.build in that order rather than left to a hash's iteration - the escaped \r\n inside the shell strings is how ansible displays them inside the JSON.
The RAW text of whichever os-release file #parse_os_release used -
Ansible's distribution-file parsers match substrings against the whole
file, not against parsed key/value pairs ("Mint" in data), so a
faithful port needs the original text.
Parses PID 1's /proc/1/environ content (NUL-separated key=value
entries) for a container= marker, matching Ansible's own
container=lxc/container=podman/generic-container=. priority
order. Only lxc and
podman get their own specific virtualization_type - EVERY other
non-empty value (docker, oci, systemd-nspawn, ...) normalizes to
the literal string "container", never the raw env value itself
(if re.search('^container=.', line): virtual_facts ['virtualization_type'] = 'container' - it does not capture or
reuse the matched value). Previously this returned the raw value
verbatim, so a Kata VM whose guest happened to carry container= docker in PID 1's environ (a leftover from the base rootfs image
having been built via podman build/Containerfile, even though
Kata boots a real guest kernel with no actual container runtime
inside it) reported "docker" - matching a role's virtualization_ type == "docker" when: check that Ansible (which reports
the generic "container") correctly left false. Found benchmarking
juju4.auditd's own "Not in container" block guard. Pulled out of
#detect_virtualization as a pure function so it's testable without
real /proc access. Returns nil when no container= entry is
present at all (the plain-host case).
The FOUND/ENDFOUND parsing half of discover_interpreter: only
absolute paths count (ansible's if interp.startswith('/')), the
first one wins, and output that does not match the bootstrap
wrapper at all falls back to /usr/bin/python3 the way real's
caught-ValueError path does.
Parses raw ip -o -4 addr show text into the ansible_<iface> per-
address entry hashes (address/netmask/network, broadcast on "brd"
lines). Lives here as a pure text-in/text-out method so the line
shapes that killed the gatherer on the round-5300009 ktechmidas.openvpn
warm run are unit-pinnable without a live ip binary: back before
the annotation pinpointed "while gathering network", its only
observable symptom was the bare "Index out of bounds" above -
parse time was the first place a changed-upon-warm-network-state
host could feed unexpected text to crystal's strict indexing.
Every line is parsed OR skipped, never crashed: a line whose
address is not exactly 4 numeric octets (a no-address or
partially-configured tun/bridge/wg left behind by the role's own
changes), or that carries no /prefix at all, is dropped the way
Ansible's own per-line continue drops it.
Parse an /etc/lsb-release payload into the ansible_lsb dict as a pure function so it's testable without the real file (extracted from #gather_os_facts, same shape as #parse_container_env).
ansible_processor - ansible-core's Linux get_cpu_facts appends the
value of EVERY /proc/cpuinfo line whose key is exactly one of
'processor', 'vendor_id', 'model name', 'Processor', 'cpu' or
'Vendor' to ONE flat list, in file order: per x86 logical CPU that
is [index, vendor, model] - roles-ansible.amdgpu_firmware's
when: ansible_processor[1] == 'AuthenticAMD' reads the vendor
slot, and the old "first 'model name' string alone" shape made
every such list index die with "undefined". ARM and other layouts
contribute whatever of those keys exist. Lines without a colon
still contribute "" when their whole text is a key, matching
ansible's split()/IndexError handling.
The /proc and /sys file reads that used to go through
capture("cat", ...) now read directly - no fork for what is a
plain read(2). The contract is capture("cat", ...)'s exactly:
stripped content, or "" when the file is missing or unreadable.
Ansible's own keep-or-skip rule for a mount's device field (hardware/linux.py get_mount_facts, live-verified): local device paths ("/dev/...", "/...") and NFS-style exports ("host:/path") are kept; pseudo-filesystem devices (overlay, proc, tmpfs, udev, cgroup, ...) are dropped, as is any fstype of exactly "none". Deliberately NOT private: the podman-diff setup_edge_cases_v2 FS3 divergence (Ansible reports an empty ansible_mounts inside a container, krikri reported every /proc/mountinfo entry) is pinned per-rule in facts_mount_network_scoping_spec.cr against this.
Ansible's get_collector_names, narrowed to the families this engine implements: 'min' is prepended unconditionally, "min"/"all" (and their negations) are special, a positive unknown token FAILS (BadSubsetError, mirroring Ansible's TypeError), a negated unknown token is ignored, and an empty resolution widens to everything. Later tokens win. Returns the set of families to gather.
The former # Entry point block, with the two differences that
make it serve both callers: config arrives already parsed (the
daemon hands over a JSON::Any; the standalone driver parses
STDIN itself), and the JSON is RETURNED rather than printed, since
the daemon frames the response itself. nil means "no config at
all", which the standalone path can legitimately see and which
means Ansible's argument-spec defaults (gather_subset=all,
gather_timeout=10, no filter, fact_path=/etc/ansible/facts.d).