module

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

ALL_FAMILIES = MIN_FAMILIES + FAMILY_SUBSETS.keys
CHASSIS_TYPES = {1 => "Other", 2 => "Unknown", 3 => "Desktop", 4 => "Low Profile Desktop", 5 => "Pizza Box", 6 => "Mini Tower", 7 => "Tower", 8 => "Portable", 9 => "Laptop", 10 => "Notebook", 11 => "Hand Held", 12 => "Docking Station", 13 => "All In One", 14 => "Sub Notebook", 15 => "Space-saving", 16 => "Lunch Box", 17 => "Main Server Chassis", 18 => "Expansion Chassis", 19 => "Sub Chassis", 20 => "Bus Expansion Chassis", 21 => "Peripheral Chassis", 22 => "RAID Chassis", 23 => "Rack Mount Chassis", 24 => "Sealed-case PC", 25 => "Multi-system", 26 => "CompactPCI", 27 => "AdvancedTCA", 28 => "Blade", 29 => "Blade Enclosure", 30 => "Tablet", 31 => "Convertible", 32 => "Detachable", 33 => "IoT Gateway", 34 => "Embedded PC", 35 => "Mini PC", 36 => "Stick PC"}

Real Ansible's own SMBIOS chassis-type table (module_utils/facts/hardware/linux.py) behind ansible_form_factor.

DEFAULT_FACT_PATH = "/etc/ansible/facts.d"
DEFAULT_GATHER_TIMEOUT = 10
FACT_BINARY_EXTRA_DIRS = ["/sbin", "/usr/sbin", "/bin", "/usr/bin"]

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.

FAMILY_SUBSETS = {"network" => ["network", "all_ipv4_addresses", "all_ipv6_addresses", "default_ipv4", "default_ipv6", "interfaces"] of ::String, "hardware" => ["hardware", "devices", "dmi", "processor", "processor_cores", "processor_count", "nvme"] of ::String, "mounts" => ["mounts"] of ::String, "virtual" => ["virtual", "virtualization_role", "virtualization_type", "virtualization_tech_guest", "virtualization_tech_host"] of ::String, "is_chroot" => ["is_chroot"] of ::String, "loadavg" => ["loadavg"] of ::String, "fibre_channel_wwn" => ["fibre_channel_wwn"] of ::String, "iscsi" => ["iscsi"] of ::String, "hostnqn" => ["hostnqn"] of ::String}

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.

MIN_FAMILIES = ["min", "local"] of ::String

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.

VALID_SUBSETS = ["all_ipv4_addresses", "all_ipv6_addresses", "apparmor", "architecture", "caps", "chroot", "cmdline", "date_time", "default_ipv4", "default_ipv6", "devices", "distribution", "distribution_major_version", "distribution_release", "distribution_version", "dns", "effective_group_ids", "effective_user_id", "env", "facter", "fibre_channel_wwn", "fips", "hardware", "interfaces", "is_chroot", "iscsi", "kernel", "kernel_version", "loadavg", "local", "lsb", "machine", "machine_id", "mounts", "hostnqn", "network", "nvme", "ohai", "os_family", "pkg_mgr", "platform", "processor", "processor_cores", "processor_count", "python", "python_version", "real_user_id", "selinux", "service_mgr", "ssh_host_key_dsa_public", "ssh_host_key_ecdsa_public", "ssh_host_key_ed25519_public", "ssh_host_key_rsa_public", "ssh_host_pub_keys", "ssh_pub_keys", "system", "system_capabilities", "system_capabilities_enforced", "systemd", "user", "user_dir", "user_gecos", "user_gid", "user_id", "user_shell", "user_uid", "virtual", "virtualization_role", "virtualization_tech_guest", "virtualization_tech_host", "virtualization_type"] of ::String

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

apply_fact_filter(facts : FactSet, patterns : Array(String)) : FactSet

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).

Source
capture(command : String, args : Array(String) = [] of String) : String

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.

Source
capture_merged(command : String, args : Array(String) = [] of String) : String

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.

Source
detect_pkg_mgr

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".

Source
detect_virtualization
Source
fnmatch_to_regex(pattern : String) : Regex

Python fnmatch.translate's glob dialect: *, ?, [seq], [!seq] - an unterminated [ is a literal. Case-sensitive (posix fnmatch).

Source
fqdn_from_getent_hosts(output : String) : String

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.

Source
gather_cmdline_facts(facts)

/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).

Source
gather_date_time_facts(facts)
Source
gather_device_facts(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.

Source
gather_dns_facts(facts)

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).

Source
gather_environment_facts(facts)
Source
gather_facts(subset : Array(String) = [] of String, remote_connection : Bool = false, gather_timeout : Int64 | Nil = nil, fact_path : String | Nil = DEFAULT_FACT_PATH) : FactSet

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.

Source
gather_fibre_channel_wwn_facts(facts)

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.

Source
gather_hardware_facts(facts)
Source
gather_hostname(facts)

Gather hostname facts

Source
gather_hostnqn_fact(facts)

ansible_hostnqn - real Ansible reads /etc/nvme/hostnqn ("" when missing). Missing entirely before - podman-diff setup case (real W5).

Source
gather_is_chroot_fact(facts)

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).

Source
gather_iscsi_fact(facts)

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).

Source
gather_loadavg_facts(facts)

ansible_loadavg - real Ansible's LoadAvgFactCollector over /proc/loadavg's first three fields. Missing entirely before - podman-diff setup case (real W5, all).

Source
gather_local_facts(fact_path : String | Nil) : Hash(String, JSON::Any)

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.

Source
gather_mount_facts(facts)

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.

Source
gather_mount_space_stats(mountpoint : String) : Hash(String, Int64 | String)

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.

Source
gather_network_facts(facts)
Source
gather_os_facts(facts)

Gather OS facts

Source
gather_python_facts(facts, remote_connection : Bool = false)
Source
gather_system_capabilities_facts(facts)

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).

Source
gather_user_facts(facts)
Source
gather_virtualization_facts(facts)

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).

Source
os_release_content

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.

Source
parse_cmdline(data : String, multi : Bool) : Hash(String, JSON::Any)
Source
parse_container_env(environ : String) : String | Nil

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).

Source
parse_dns_content(content : String) : Hash(String, JSON::Any)
Source
parse_os_release
Source
real_mount_device_kept?(device : String, fstype : String) : Bool

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.

Source
resolve_enabled_families(tokens : Array(String)) : Set(String)

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.

Source
run(config : JSON::Any | Nil) : String

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).

Source

Nested types