Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions .github/workflows/unit_tests.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,7 @@ jobs:
export PATH=/usr/local/go/bin:$PATH
export GOFLAGS=-buildvcs=false
go version
go test . ./internal/engine ./internal/app ./tests/acceptance
go test -timeout 25m . ./internal/engine ./internal/app ./tests/acceptance
go build -o /tmp/facts ./cmd/facts
sh tools/freebsd-release-gate.sh /tmp/facts

Expand Down Expand Up @@ -140,7 +140,7 @@ jobs:
export PATH=/usr/local/go/bin:$PATH
export GOFLAGS=-buildvcs=false
go version
go test . ./internal/engine ./internal/app ./tests/acceptance
go test -timeout 25m . ./internal/engine ./internal/app ./tests/acceptance
go build -o /tmp/facts ./cmd/facts
sh tools/openbsd-release-gate.sh /tmp/facts

Expand Down Expand Up @@ -172,7 +172,7 @@ jobs:
export PATH=/usr/local/go/bin:/usr/bin:/bin:/usr/sbin:/sbin
export GOFLAGS=-buildvcs=false
go version
go test . ./internal/engine ./internal/app ./tests/acceptance
go test -timeout 25m . ./internal/engine ./internal/app ./tests/acceptance
go build -o /tmp/facts ./cmd/facts
sh tools/netbsd-release-gate.sh /tmp/facts

Expand Down Expand Up @@ -204,7 +204,7 @@ jobs:
export PATH=/usr/local/go/bin:$PATH
export GOFLAGS=-buildvcs=false
go version
go test . ./internal/engine ./internal/app ./tests/acceptance
go test -timeout 25m . ./internal/engine ./internal/app ./tests/acceptance
go build -o /tmp/facts ./cmd/facts
sh tools/dragonfly-release-gate.sh /tmp/facts

Expand Down Expand Up @@ -241,6 +241,6 @@ jobs:
export PATH=/usr/local/go/bin:$PATH
export GOFLAGS=-buildvcs=false
go version
go test . ./internal/engine ./internal/app ./tests/acceptance
go test -timeout 25m . ./internal/engine ./internal/app ./tests/acceptance
go build -o /tmp/facts ./cmd/facts
sh tools/illumos-release-gate.sh /tmp/facts
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,15 @@

### Added

- Fact disabling is now a first-class, facts-native control. Disable any fact
or fact group with `--disable a,b`, the `FACTS_DISABLE=a,b` environment
variable, or the `facts.conf` `disable` key; the Facter `blocklist` key keeps
working as its compatibility alias, and `--no-block` clears every disable
source. Disabling is resolution-gated — a disabled standalone-resolver fact
(`networking`, `processors`, `memory`, `ssh`, `timezone`, `fips`, `augeas`,
`xen`) is not resolved at all, not merely filtered from output; multi-output
categories resolve then prune. An explicitly queried fact disabled by the
environment or config emits a one-line stderr diagnostic.
- Release artifacts and cross-compile CI now include `arm` and `arm64` for
FreeBSD, OpenBSD, and NetBSD.
- Added native-gated Plan 9 (`plan9/amd64`) fact support for canonical
Expand Down
18 changes: 18 additions & 0 deletions CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,10 @@ A flat, pre-structured fact name (e.g. `operatingsystem`) from Ruby Facter's dep
A fact whose preconditions don't hold on this host (e.g. EC2 metadata off-cloud). It is simply absent from the canonical tree — never an error. Only facts that were supposed to resolve and didn't count as failures.
_Avoid_: failed fact, missing fact (that means "no such fact name")

**Disabled fact**:
A fact removed from discovery by the disabled set — the union of the CLI `--disable`, the `FACTS_DISABLE` environment variable, and the `facts.conf` `disable` key (the Facter `blocklist` key is accepted as its compatibility alias). Every fact is enabled by default; there is no opt-in. A disabled fact backed by its own resolver is not resolved at all (resolution-gating, e.g. `packages`); a fact that shares a multi-output resolver, or a disabled sub-fact, is pruned after resolution. `--no-block` re-enables everything for a run.
_Avoid_: blocked fact (use only for the Facter-compatible `blocklist` config spelling), hidden fact (disabling a standalone-resolver fact skips resolution, not just display)

**Supported fact**:
A fact documented in the schema as part of Facts' supported output contract for one or more supported release targets.
_Avoid_: available fact (too host-specific), implemented fact (too code-centric)
Expand Down Expand Up @@ -103,6 +107,20 @@ _Avoid_: fact hash, output map
A decode of part of the canonical tree into a caller-supplied Go type, failing loudly on shape mismatch. A view never resolves facts independently of the canonical tree.
_Avoid_: typed fact, fact struct

### Packages

**Package**:
A unit of installed software tracked by a package source (a `dpkg` entry, an `rpm` header, a macOS installer receipt, a `nix` profile entry). The `packages` fact reports packages, not arbitrary installable artifacts; an installed `.app` bundle is reported too, but as its own `apps` source, never folded into database packages.
_Avoid_: application, artifact, software (too broad — an unpackaged binary is not a package)

**Package source**:
An authoritative inventory of installed software on the host — usually an installation database (the rpm database, the dpkg database, the FreeBSD pkg database, macOS installer receipts, the nix store), or a structured filesystem inventory where the OS keeps no database (macOS `.app` bundles → `apps`). A host has several coexisting sources, so the `packages` fact namespaces by source — `packages.<source>` — and never merges records across sources. Frontends that write to the same database are the same source (apt/aptitude → `dpkg`; dnf/yum/zypper → `rpm`), so the source key is the database, never the frontend or the file format.
_Avoid_: package manager (ambiguous — frontend vs database), package format (`deb`/`rpm` name the artifact, not the source)

**Package record**:
One installed package's entry in a source's list — a map identified by its fields (`name` and `version` always; plus per-source identity fields such as `architecture`, `type`/`tap`, `store_path`, `bundle_id`/`path`, or the Windows uninstall subkey/`ProductCode`), never by position and never by a unique map key. `version` is the package manager's verbatim native string. The same software appearing in two sources (a macOS `.pkg` receipt and its `.app` bundle) is two records, not one.
_Avoid_: package entry, package map (records live in a list, not a name-keyed map)

## Example dialogue

> **Dev**: A consumer's monitoring agent wants `networking.ip` but with their own registered fact overriding it. Is there a global registry they add it to?
Expand Down
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,8 @@ Errors are honest: missing facts are `ErrFactNotFound`, a registered fact that l

The shipped binary is `facts`, and it keeps Ruby Facter's output contract — formatting, exit statuses, stderr diagnostics (with the program token rebranded to `Facts`), `facter.conf` semantics. Existing facter inputs keep working as the compat tier: `facter.conf` default paths, `FACTER_*` environment facts, and the puppetlabs fact directories are all still read, with the facts-native names winning when both are present.

Every fact is on by default. Disable any fact or fact group with `--disable`, the `FACTS_DISABLE` environment variable, or the `facts.conf` `disable` key (the Facter `blocklist` key stays as its compatibility alias). Disabling is resolution-gated — a disabled standalone resolver is skipped, not just hidden — and `--no-block` clears every disable.


```console
$ brew install ncode/tap/facts
Expand All @@ -107,6 +109,9 @@ $ facts --json os.family kernel.version.full

$ facts --external-dir ./facts.d site_role
web

$ facts --disable networking os.name # disable facts or groups; also FACTS_DISABLE, facts.conf 'disable'
Darwin
```

```sh
Expand Down
22 changes: 22 additions & 0 deletions docs/adr/0014-packages-fact-source-namespaced-record-lists.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# The packages fact namespaces installed software by source, each a list of records

Facts adds a `packages` fact reporting installed software — a Facts-native extension (Ruby Facter ships no installed-packages fact, so there is no upstream shape to match; ADR-0011 canonical spelling governs the names). It is namespaced by *source*, where a source is an authoritative installed-software inventory on the host — usually an installation database, or a structured filesystem inventory where the OS keeps no database (macOS `.app` bundles). The source key is the database or inventory, never an apt-style frontend and never an artifact format: apt/aptitude/dpkg → `dpkg` (not `apt`, not `deb`), dnf/yum/zypper → `rpm`. The v1 source set is `dpkg` (Debian/Ubuntu), `rpm` (RHEL family; SUSE), `pacman` (Arch), `apk` (Alpine), the cross-distro Linux sources `snap` (`/var/lib/snapd`) and `flatpak` (`/var/lib/flatpak`) wherever present, `pkg` (FreeBSD and DragonFly — the same pkgng SQLite database), `openbsd_pkg` (OpenBSD), `pkgsrc` (NetBSD; an illumos/DragonFly secondary), `ips` (illumos/Solaris — keyed `ips`, *not* `pkg`: IPS's `pkg(1)` CLI only coincidentally shares the FreeBSD tool name, and keying on the tool would clobber two different databases), `nix` (the installed profile set — `/nix/var/nix/profiles/default` and the NixOS system profile — *not* the whole `/nix/store`, which holds every build dependency and GC-rooted version and would massively over-report), macOS `receipts` (the installer(8)/PackageKit `.pkg` database — the dpkg/rpm analogue for MDM-managed servers), macOS `apps` (the `.app` bundle inventory), Windows `registry` (the Add/Remove-Programs uninstall hive), and Windows `appx` (Store/MSIX apps, which the uninstall hive omits). An optional `homebrew` source is auto-detected where a Cellar prefix exists (absent on clean servers); MacPorts and Chocolatey are deferred and would join the same auto-detected-secondary tier. Plan 9 emits nothing — it has no package database, so the source is absent, not empty.

Each `packages.<source>` is a list of package records, each a map of `{name, version, …}` in which name is a field, not a map key. The obvious shape — a name-keyed map, `packages.dpkg.bash = "5.1"` — is unsound, because a package name is not a unique identifier on most sources: dpkg multiarch (`libc6:amd64` and `libc6:i386` coexist), rpm multilib and install-only multiversion kernels (several `kernel-core` with identical name *and* arch), `openbsd_pkg` parallel installs (`autoconf-2.69p3` and `2.71p3`), homebrew formula-vs-cask (`docker` is both), the Windows uninstall hive (x86 and x64 builds share a DisplayName *and* DisplayVersion), and Nix (multiple versions of one name coexist by design). A name-keyed map silently drops the colliding sibling. Independently, package names contain dots (`python3.11`, `libdb5.3`, `libstdc++6`), which collide with the engine's dot-path addressing if a name is a path segment. A list keeps every record, is uniform across all sources, is dot-safe, and makes "is X installed?" a filter that can honestly return a set.

Identity inside the list is completed by per-source fields: `architecture` (dpkg, rpm, apk), `type` (formula|cask) and `tap` (homebrew, so two taps' same-name/version formulae stay distinct), `store_path` (nix), `branch` and `architecture` (flatpak, whose app-id alone collides across branches and installations — `snap` by contrast keeps one active revision per name and needs no extra key), `bundle_id` and `path` (the macOS `apps` inventory, since two `.app` of the same name and version can sit at different paths), and for Windows `registry` the uninstall subkey / MSI `ProductCode` plus an `architecture` distinguishing the native hive from `WOW6432Node` — without which a 32-bit and 64-bit build sharing DisplayName and DisplayVersion would be byte-identical records. `version` is the package manager's verbatim native string; where a manager renders no epoch by default the reader asks for it explicitly (the rpm reader queries `%{EPOCH}:%{VERSION}-%{RELEASE}`, because a bare `rpm -qa` omits the epoch that can separate otherwise-identical install-only kernels), and `version` is not decomposed into sub-fields in v1. A per-source install timestamp is omitted in v1; if added it is spelled `install_date` (the `_date` suffix, per the existing `dmi.bios.release_date`), and only where the source records a true install time.

The scope is system package databases only. Language and dev managers (pip, npm, gem, cargo) are out: they are per-user/per-project, not host facts, and double-list distro-shipped libraries; the source-namespaced shape lets them slot in later as `packages.pip` with no restructuring. System-scope `snap` and `flatpak` are root-owned host-global databases that meet the scope bar and are included in v1: omitting them would make `packages` wrong on the flagship Linux targets, where stock Ubuntu 22.04+ ships Firefox and Chromium only as snaps (no dpkg entry) and Fedora Workstation ships Flatpaks. Flatpak also keeps per-user installations, which fall under the execution-context boundary below. Records are never merged or deduped across sources: a macOS `.pkg` receipt and its `.app` bundle are two records under two keys, not one.

The fact reports system-global databases plus whatever the collector's own execution context can read; it does not drop privileges or walk every user's home to enumerate other users' per-user installs. That boundary leaves several sources context-bounded rather than truly host-complete — homebrew (the owning user's Cellar), nix per-user profiles, per-user flatpak installations, the Windows per-user `HKCU` uninstall entries, and Windows `appx` (per-user-registered: the reader takes the collector context plus the system-provisioned set via `Get-AppxProvisionedPackage`, so other users' private Store installs are under-reported). The under-report is documented, and a `user` field is the additive path to all-users enumeration later.

Collection honors one cheap read per source — parse the on-disk database directly where it is world-readable (`/var/lib/dpkg/status`, `/var/lib/pacman/local/*/desc`, the pkgng `local.sqlite`, `/var/db/receipts/*.plist`, the nix profile manifest), or one batch query (`rpm -qa --qf …`, `dpkg-query -W`, `pkg query`, `Get-AppxProvisionedPackage`) — never a process per package, never the network. The readers land as an ADR-0010 per-category module (`packages.go`) with non-GOOS-suffixed, cross-platform-testable parse functions (the on-disk-parse vs batch-query split is the impure-probe + pure-parse seam); the concrete file layout, the schema entries under ADR-0011 canonical spelling, and per-target validation land via a dedicated OpenSpec change that this ADR does not pre-empt — it fixes only the externally observable shape.

`packages` is on by default, like every other fact (ADR-0015): the fact model is all-on with subtractive disabling, so a host that does not want package collection on every discovery disables it — `--disable packages`, `FACTS_DISABLE=packages`, or the `facts.conf` `disable` key. `packages` registers as a fact group so a disable names it as one unit, and because the disabled set is resolution-gated, disabling skips package collection entirely rather than merely hiding output. Adding a Facts-native `packages` group diverges the Facter-mirrored `--list-block-groups`/`--list-cache-groups` output from Ruby Facter — a deliberate parity divergence, since `packages` is Facts-native.

## Considered Options

- **Name-keyed map (`packages.dpkg.bash = "5.1"`)** — rejected: lossy on six sources (drops multiarch siblings, install-only kernels, the cask, the colliding tap, all-but-one Nix version), and package names contain dots that break dot-path addressing.
- **Name-keyed map of lists (`packages.dpkg.libc6 = [ … ]`)** — rejected: keeps the dotted-name hazard and forces a list value on the ~99% single-instance case for the benefit of a key lookup that is unsound anyway.
- **Qualified-key map (`packages.rpm."<NEVRA>" = { … }`)** — rejected: heterogeneous key spelling per source, and NEVRA/store-path keys are themselves full of dots, so dot-path queries still mis-split.
- **List of records (chosen)** — uniform across every source, collision-proof once per-source identity fields are carried, and dot-safe; "is X installed?" becomes a filter, which is correct because the honest answer can be a set (a multilib pair, three kernels) rather than a single value.
Loading
Loading