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
Real Ansible's own SMBIOS chassis-type table (module_utils/facts/hardware/linux.py) 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.
Which subset tokens map to which krikri gatherer family - real
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 real 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 real
Ansible always gathers first anyway.
The families "min" covers in this engine. Real 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.
Every subset name real 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 real
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 - real 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.
Detect whether we're running inside a container/VM, following the same
heuristics real Ansible's fact gathering uses. Returns the virtualization
type name (e.g. "docker", "lxc", "kvm", "xen"), or "None" (the exact
string real Ansible uses) when running on bare metal / a plain host.
ansible_pkg_mgr / ansible_facts.pkg_mgr - which package manager real
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). Real 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 real Ansible uses so a modern RHEL system
(where /usr/bin/yum is often just a symlink to dnf) reports "dnf".
Python fnmatch.translate's glob dialect: *, ?, [seq], [!seq] - an unterminated [ is a literal. Case-sensitive (posix fnmatch).
socket.getfqdn()'s final step over one getent hosts <ip> line
("<ip> <canonical> [alias ...]"): the canonical name is checked
first (Python's aliases.insert(0, hostname)), then the aliases in
order, and the first entry containing a dot wins - "" when none
does, which the caller maps to the plain-hostname fallback.
/proc/cmdline facts - real Ansible's CmdLineFactCollector (module_utils/facts/system/cmdline.py), 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 (real's collector returns {} and skips both facts).
Block-device facts - the ansible_devices dict (keyed by device name)
real 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 real 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 - real Ansible's DnsFactCollector (module_utils/facts/system/dns.py) 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
real 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 real 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 - real 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 - real Ansible reads /etc/nvme/hostnqn ("" when missing). Missing entirely before - podman-diff setup case (real W5).
ansible_is_chroot - real 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 - real 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 - real Ansible's LoadAvgFactCollector over /proc/loadavg's first three fields. Missing entirely before - podman-diff setup case (real W5, all).
fact_path - real Ansible's local facts mechanism
(module_utils/facts/system/local.py): 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 real 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 real
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.
Real 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. Uses stat -f (present on
every target this repo benchmarks) rather than a raw statvfs(2) FFI
binding - matches the same fields real Ansible's own os.statvfs()
reads, just fetched via a subprocess instead of a syscall:
%S block_size (statvfs.f_frsize) %b block_total (f_blocks)
%f block_free, all users (f_bfree) %a block_available, non-root (f_bavail)
%c inode_total (f_files) %d inode_free, non-root (f_favail)
Returned as strings (matching this plugin's existing Hash(String,String)
mount-entry shape) - real Ansible's own | int filter chain in the
role already coerces the field before comparing, so a numeric-looking
string round-trips identically to a real int for that purpose.
ansible_system_capabilities / _enforced - real Ansible's
SystemCapabilitiesFactCollector (module_utils/facts/system/caps.py)
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 real'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: real 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 real
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) - real 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 (real 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).
The RAW text of whichever os-release file #parse_os_release used - real
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 real Ansible's own
container=lxc/container=podman/generic-container=. priority
order (module_utils/facts/virtual/linux.py). 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 real 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).
Real 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 (real 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.
Real Ansible's get_collector_names (module_utils/facts/collector.py), 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 real 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 real Ansible's argument-spec defaults (gather_subset=all,
gather_timeout=10, no filter, fact_path=/etc/ansible/facts.d).