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
13 changes: 8 additions & 5 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,11 @@
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.
source. Disabling is resolution-gated — a gateable resolver is skipped when
every top-level root it can emit is disabled; when any sibling root remains
enabled, the resolver runs and only disabled output is pruned. 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 Expand Up @@ -53,6 +53,9 @@

### Fixed

- Fully disabled multi-output and shared-output core resolvers now skip their
probe work, including demand-driven DMI/GCE sharing; enabled sibling roots,
output pruning, queries, cache behavior, and disable diagnostics are unchanged.
- The DragonFly `disks`/`partitions` probe no longer reports empty memory-disk
(`md`) phantoms and no longer fans out `disklabel` across non-existent slice
targets. It skips `md`/`cd` pseudo-devices and enumerates only the slice nodes
Expand Down
30 changes: 15 additions & 15 deletions cache_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,16 +11,15 @@ import (
"github.com/ncode/facts/internal/engine"
)

// redirectCacheDir points the engine's persistent-cache location at a temp dir
// for the duration of the test, so WithCache exercises a real round trip
// without reading or writing the host's actual fact cache.
func redirectCacheDir(t *testing.T) string {
// cacheDefaults points one Engine at a temp persistent-cache location so
// WithCache exercises a real round trip without process-global mutation.
func cacheDefaults(t *testing.T) (string, Option) {
t.Helper()
dir := t.TempDir()
original := engine.DefaultCachePath
engine.DefaultCachePath = func() string { return dir }
t.Cleanup(func() { engine.DefaultCachePath = original })
return dir
return dir, func(cfg *engine.EngineConfig) error {
cfg.Defaults = &engine.DiscoveryDefaults{CachePath: dir}
return nil
}
}

// writeTTLConfig writes a config file giving group a 30-day TTL, which is what
Expand Down Expand Up @@ -68,10 +67,10 @@ func cachingResolver(value string) func(context.Context) (any, error) {
// contract: opting in with WithCache and a TTL'd group causes Discover to
// persist the freshly resolved fact to the cache directory.
func TestWithCache_persistsResolvedFactToDisk(t *testing.T) {
dir := redirectCacheDir(t)
dir, defaults := cacheDefaults(t)
conf := writeTTLConfig(t, "demo")

eng, err := New(WithCache(), WithConfigFile(conf), WithFact("demo", cachingResolver("fresh")))
eng, err := New(WithCache(), WithConfigFile(conf), WithFact("demo", cachingResolver("fresh")), defaults)
if err != nil {
t.Fatal(err)
}
Expand All @@ -90,11 +89,11 @@ func TestWithCache_persistsResolvedFactToDisk(t *testing.T) {
// freshly resolved one. A different resolver value makes the substitution
// observable.
func TestWithCache_servesFreshCachedValueOverResolver(t *testing.T) {
dir := redirectCacheDir(t)
dir, defaults := cacheDefaults(t)
conf := writeTTLConfig(t, "demo")
seedCacheFile(t, filepath.Join(dir, "demo"), map[string]any{"demo": "from-cache"})

eng, err := New(WithCache(), WithConfigFile(conf), WithFact("demo", cachingResolver("fresh")))
eng, err := New(WithCache(), WithConfigFile(conf), WithFact("demo", cachingResolver("fresh")), defaults)
if err != nil {
t.Fatal(err)
}
Expand All @@ -113,7 +112,7 @@ func TestWithCache_servesFreshCachedValueOverResolver(t *testing.T) {
}

func TestWithCache_selectsQueriedFactsThroughEngineCachePath(t *testing.T) {
dir := redirectCacheDir(t)
dir, defaults := cacheDefaults(t)
conf := writeTTLConfig(t, "demo")
seedCacheFile(t, filepath.Join(dir, "demo"), map[string]any{"demo": map[string]any{"child": "from-cache"}})

Expand All @@ -124,6 +123,7 @@ func TestWithCache_selectsQueriedFactsThroughEngineCachePath(t *testing.T) {
return map[string]any{"child": "fresh"}, nil
}),
WithFact("other", cachingResolver("fresh")),
defaults,
)
if err != nil {
t.Fatal(err)
Expand All @@ -144,11 +144,11 @@ func TestWithCache_selectsQueriedFactsThroughEngineCachePath(t *testing.T) {
// cache that WithCache would serve is ignored when WithCache is absent, proving
// the option — not some always-on path — is what enables caching.
func TestWithoutCache_ignoresExistingCache(t *testing.T) {
dir := redirectCacheDir(t)
dir, defaults := cacheDefaults(t)
conf := writeTTLConfig(t, "demo")
seedCacheFile(t, filepath.Join(dir, "demo"), map[string]any{"demo": "from-cache"})

eng, err := New(WithConfigFile(conf), WithFact("demo", cachingResolver("fresh")))
eng, err := New(WithConfigFile(conf), WithFact("demo", cachingResolver("fresh")), defaults)
if err != nil {
t.Fatal(err)
}
Expand Down
3 changes: 2 additions & 1 deletion engine_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,7 @@ func TestWithConfigFile_loadsConfiguredDirs(t *testing.T) {
}

func TestWithConfigFile_recomputesDiscoveryPolicyEachDiscover(t *testing.T) {
cacheDir := redirectCacheDir(t)
cacheDir, defaults := cacheDefaults(t)
firstDir := t.TempDir()
writeTestFile(t, firstDir, "site.txt", "site_location=first\n")
writeTestFile(t, firstDir, "blocked.txt", "blocked_probe=blocked\n")
Expand All @@ -162,6 +162,7 @@ facts : {
WithCache(),
WithConfigFile(configPath),
WithFact("cache_probe", func(context.Context) (any, error) { return "cached", nil }),
defaults,
)
if err != nil {
t.Fatal(err)
Expand Down
92 changes: 92 additions & 0 deletions gating_contract_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
package facts

import (
"context"
"errors"
"log/slog"
"os"
"path/filepath"
"testing"
)

func TestDiscover_gateableCoreFactsPreservePublicPruningAndQueries(t *testing.T) {
tests := []struct {
name string
disabled string
keptQuery string
pruned string
wantWarn string
}{
{
name: "multi-output sibling remains resolved",
disabled: `"filesystems", "os", "system_profiler"`,
keptQuery: "kernel",
pruned: "os",
wantWarn: `fact "os" is disabled by the configuration file`,
},
{
name: "disabled subfact is pruned after resolution",
disabled: `"os.release"`,
keptQuery: "os.name",
pruned: "os.release",
wantWarn: `fact "os.release" is disabled by the configuration file`,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
config := writeTestFile(t, t.TempDir(), "facter.conf", "facts : {\n blocklist : [ "+tt.disabled+" ],\n}\n")
handler := &recordingHandler{}
eng, err := New(WithConfigFile(config), WithLogger(slog.New(handler)))
if err != nil {
t.Fatal(err)
}

snap, err := eng.Discover(context.Background(), tt.keptQuery, tt.pruned)
if err != nil {
t.Fatalf("Discover() err = %v", err)
}
if _, err := snap.Value(tt.keptQuery); err != nil {
t.Fatalf("Value(%q) err = %v, want kept sibling resolved", tt.keptQuery, err)
}
if _, err := snap.Value(tt.pruned); !errors.Is(err, ErrFactNotFound) {
t.Fatalf("Value(%q) err = %v, want ErrFactNotFound", tt.pruned, err)
}
if !handler.hasWarn(tt.wantWarn) {
t.Fatalf("diagnostics = %#v, want WARN %q", handler.records, tt.wantWarn)
}
})
}
}

func TestDiscover_fullyDisabledCoreResolverSkipsCacheConsultation(t *testing.T) {
dir, defaults := cacheDefaults(t)
cachePath := filepath.Join(dir, "operating system")
if err := os.WriteFile(cachePath, []byte("{"), 0o600); err != nil {
t.Fatal(err)
}
config := writeTestFile(t, t.TempDir(), "facter.conf", `facts : {
blocklist : [ "filesystems", "kernel", "os", "system_profiler" ],
ttls : [ { "operating system" : "30 days" } ],
}
`)
handler := &recordingHandler{}
eng, err := New(WithCache(), WithConfigFile(config), WithLogger(slog.New(handler)), defaults)
if err != nil {
t.Fatal(err)
}

snap, err := eng.Discover(context.Background(), "kernel")
if err != nil {
t.Fatalf("Discover() err = %v", err)
}
if _, err := snap.Value("kernel"); !errors.Is(err, ErrFactNotFound) {
t.Fatalf("Value(kernel) err = %v, want ErrFactNotFound", err)
}
if _, err := os.Stat(cachePath); err != nil {
t.Fatalf("malformed cache was consulted and removed: %v", err)
}
if want := `fact "kernel" is disabled by the configuration file`; !handler.hasWarn(want) {
t.Fatalf("diagnostics = %#v, want WARN %q", handler.records, want)
}
}
51 changes: 26 additions & 25 deletions internal/app/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,12 @@ import (
"github.com/ncode/facts/internal/engine"
)

var defaultExternalFactDirs = engine.CurrentDefaultExternalFactDirs

// Run executes the facts command with the provided arguments.
func Run(stdout, stderr io.Writer, args []string) error {
return runWithDefaults(stdout, stderr, args, engine.CurrentDiscoveryDefaults())
}

func runWithDefaults(stdout, stderr io.Writer, args []string, defaults engine.DiscoveryDefaults) error {
args = cli.PrepareArguments(args)
if err := cli.ValidateOptions(args); err != nil {
return optionError(stdout, err)
Expand All @@ -37,30 +39,30 @@ func Run(stdout, stderr io.Writer, args []string) error {
_, err := fmt.Fprintln(stdout, engine.Version)
return err
case "list_block_groups", "--list-block-groups":
groups, err := factGroups(args[1:])
groups, err := factGroups(args[1:], defaults)
if err != nil {
return err
}
_, err = fmt.Fprintln(stdout, engine.FormatFactGroups(groups))
return err
case "list_cache_groups", "--list-cache-groups":
groups, err := factGroups(args[1:])
groups, err := factGroups(args[1:], defaults)
if err != nil {
return err
}
_, err = fmt.Fprintln(stdout, engine.FormatFactGroups(groups))
return err
case "query":
return runQuery(stdout, stderr, args[1:])
return runQuery(stdout, stderr, args[1:], defaults)
default:
return fmt.Errorf("unknown command %q", args[0])
}
}

func factGroups(args []string) ([]engine.FactGroup, error) {
func factGroups(args []string, defaults engine.DiscoveryDefaults) ([]engine.FactGroup, error) {
options := parseListOptions(args)
groups := engine.BuiltinFactGroups()
config, err := engine.ParseConfig(options.ConfigPath, slog.New(slog.DiscardHandler))
config, err := engine.ParseConfig(options.ConfigPath, slog.New(slog.DiscardHandler), defaults)
if err != nil {
return nil, err
}
Expand All @@ -69,7 +71,7 @@ func factGroups(args []string) ([]engine.FactGroup, error) {
configuredExternalDirs := engine.DiscoveryExternalDirs(config, options.ExternalDirs, false, false, nil)
var defaultExternalDirs []string
if len(configuredExternalDirs) == 0 {
defaultExternalDirs = defaultExternalFactDirs()
defaultExternalDirs = defaults.ExternalFactDirs
}
externalDirs := engine.DiscoveryExternalDirs(config, options.ExternalDirs, false, true, defaultExternalDirs)
external, err := engine.ExternalFactGroups(externalDirs)
Expand Down Expand Up @@ -149,7 +151,7 @@ Format facts as JSON:
return b.String()
}

func runQuery(stdout, stderr io.Writer, args []string) error {
func runQuery(stdout, stderr io.Writer, args []string, defaults engine.DiscoveryDefaults) error {
flags, options, err := parseOptions("query", stderr, args)
if err != nil {
return err
Expand All @@ -163,7 +165,7 @@ func runQuery(stdout, stderr io.Writer, args []string) error {
logHandler := &stderrLogHandler{stderr: stderr, color: colorDiagnostics}
logger := slog.New(logHandler)
configFile := options.ConfigPath
configOptions, configErr := engine.ParseConfig(configFile, logger)
configOptions, configErr := engine.ParseConfig(configFile, logger, defaults)
if configErr != nil {
return configErr
}
Expand All @@ -185,7 +187,7 @@ func runQuery(stdout, stderr io.Writer, args []string) error {
}
var defaultExternalDirs []string
if !options.NoExternalFacts && len(conflictExternalDirs) == 0 {
defaultExternalDirs = defaultExternalFactDirs()
defaultExternalDirs = defaults.ExternalFactDirs
}
discoveryExternalDirs := engine.DiscoveryExternalDirs(configOptions, options.ExternalDirs, options.NoExternalFacts, true, defaultExternalDirs)
disabledFactsForFastPath := map[string]bool{}
Expand Down Expand Up @@ -227,20 +229,19 @@ func runQuery(stdout, stderr io.Writer, args []string) error {
resolutionStart := time.Now()

eng, err := engine.NewEngine(engine.EngineConfig{
CLICompat: true,
SystemDefaults: true,
ConfigFile: configFile,
ConfigLoaded: true,
Config: configOptions,
ExternalDirs: cliExternalDirs,
UseCache: !options.NoCache,
NoExternalFacts: options.NoExternalFacts,
DisabledFacts: disabledFacts,
ExtraDisabled: options.DisableEntries,
DefaultExternalDirsSet: true,
DefaultExternalDirs: defaultExternalDirs,
IncludeTypedDotted: mergeDottedFacts,
Logger: logger,
CLICompat: true,
SystemDefaults: true,
ConfigFile: configFile,
ConfigLoaded: true,
Config: configOptions,
ExternalDirs: cliExternalDirs,
UseCache: !options.NoCache,
NoExternalFacts: options.NoExternalFacts,
DisabledFacts: disabledFacts,
ExtraDisabled: options.DisableEntries,
Defaults: &defaults,
IncludeTypedDotted: mergeDottedFacts,
Logger: logger,
})
if err != nil {
return err
Expand Down
Loading
Loading