diff --git a/docs/schema/facts.yaml b/docs/schema/facts.yaml index e56ed0e8..76362b39 100644 --- a/docs/schema/facts.yaml +++ b/docs/schema/facts.yaml @@ -5,15 +5,16 @@ # # type: string | integer | double | boolean | map | array # description: what the fact means, one line -# platforms: the platforms that can emit it (linux, darwin, windows, -# freebsd, openbsd, netbsd, dragonfly, illumos, plan9) +# platforms: schema-visible platform target IDs from internal/platform # conditional: true when presence depends on host state (cloud instances, # swap, DMI visibility, installed tools); such entries may be # absent from a discovery without it being a bug +# open_subtree: true when provider-shaped descendants are intentionally +# accepted without documenting every leaf # -# A `map`- or `array`-typed entry covers every deeper leaf under it, so -# provider-shaped subtrees (ec2_metadata, gce, az_metadata, system_profiler) -# are documented at the subtree root. +# A `*` path segment matches exactly one segment. A `map`-typed entry only +# covers the entry path itself unless `open_subtree: true` is set. Array +# entries cover the synthetic `path.*` leaf used by the conformance test. # # TestFactsSchemaConformance (schema_test.go) enforces this file on every # platform CI gate: an undocumented fact fails the gate, and a non-conditional @@ -32,6 +33,7 @@ augeas.version: az_metadata: type: map + open_subtree: true description: The Azure Instance Metadata Service tree, on Azure virtual machines. platforms: [linux, windows] conditional: true @@ -156,6 +158,7 @@ dmi.product.version: ec2_metadata: type: map + open_subtree: true description: The EC2 instance metadata tree, on AWS instances. platforms: [linux, windows, freebsd] conditional: true @@ -182,6 +185,7 @@ fips_enabled: gce: type: map + open_subtree: true description: The Google Compute Engine metadata tree, on GCE instances. platforms: [linux, windows] conditional: true @@ -584,7 +588,7 @@ networking.interfaces.*.scope6: networking.interfaces.*.speed: type: integer description: The negotiated speed of the interface, in Mbit/s. - platforms: [linux] + platforms: [linux, freebsd] conditional: true networking.ip: type: string @@ -917,6 +921,7 @@ ssh.*.type: system_profiler: type: map + open_subtree: true description: macOS system_profiler hardware, software, and Ethernet details (provider-shaped keys, such as model_name and serial_number). platforms: [darwin] diff --git a/docs/supported-facts/README.md b/docs/supported-facts/README.md index 150439ba..201f855e 100644 --- a/docs/supported-facts/README.md +++ b/docs/supported-facts/README.md @@ -9,7 +9,7 @@ These pages are generated from [`docs/schema/facts.yaml`](../schema/facts.yaml). | [Linux](linux.md) | 175 | | [macOS / Darwin](darwin.md) | 107 | | [Windows](windows.md) | 101 | -| [FreeBSD](freebsd.md) | 130 | +| [FreeBSD](freebsd.md) | 131 | | [OpenBSD](openbsd.md) | 113 | | [NetBSD](netbsd.md) | 117 | | [DragonFly BSD](dragonfly.md) | 115 | diff --git a/docs/supported-facts/freebsd.md b/docs/supported-facts/freebsd.md index f051ad6e..3e9f6942 100644 --- a/docs/supported-facts/freebsd.md +++ b/docs/supported-facts/freebsd.md @@ -64,7 +64,7 @@ $ facts --json ## Fact Contract -130 schema entries include `freebsd`. +131 schema entries include `freebsd`. | Fact | Type | Conditional | Description | | --- | --- | --- | --- | @@ -146,6 +146,7 @@ $ facts --json | `networking.interfaces.*.network6` | `string` | yes | The IPv6 network of the interface's first binding. | | `networking.interfaces.*.operational_state` | `string` | yes | The operational state of the interface, such as up or down. | | `networking.interfaces.*.scope6` | `string` | yes | The IPv6 scope of the interface's first binding, such as global or link. | +| `networking.interfaces.*.speed` | `integer` | yes | The negotiated speed of the interface, in Mbit/s. | | `networking.ip` | `string` | yes | The IPv4 address of the primary interface. | | `networking.ip6` | `string` | yes | The IPv6 address of the primary interface. | | `networking.mac` | `string` | yes | The MAC address of the primary interface. | diff --git a/internal/app/app.go b/internal/app/app.go index 1c212ab4..540610a8 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -86,20 +86,27 @@ func effectiveExternalDirs(explicit []string) []string { } func configPathFromArgs(args []string) string { - for i := range args { + for i := 0; i < len(args); i++ { arg := args[i] - if value, ok := strings.CutPrefix(arg, "--config="); ok { - return value + option, ok := cli.LookupOption(arg) + if !ok { + continue } - if value, ok := strings.CutPrefix(arg, "-c="); ok { - return value + if value, hasInlineValue := inlineOptionValue(arg); hasInlineValue { + if option.Canonical == "--config" { + return value + } + continue } - if arg == "--config" || arg == "-c" { + if option.Canonical == "--config" { if i+1 < len(args) { return args[i+1] } return "" } + if option.Arity == cli.RequiredValue && i+1 < len(args) { + i++ + } } return "" } @@ -108,68 +115,55 @@ func externalDirsFromArgs(args []string) []string { dirs := []string{} for i := 0; i < len(args); i++ { arg := args[i] - if value, ok := strings.CutPrefix(arg, "--external-dir="); ok { - dirs = append(dirs, value) + option, ok := cli.LookupOption(arg) + if !ok { continue } - if arg == "--external-dir" { + if value, hasInlineValue := inlineOptionValue(arg); hasInlineValue { + if option.Canonical == "--external-dir" { + dirs = append(dirs, value) + } + continue + } + if option.Canonical == "--external-dir" { if i+1 < len(args) { dirs = append(dirs, args[i+1]) i++ } continue } - if optionTakesValueForGroupListing(arg) && i+1 < len(args) { + if option.Arity == cli.RequiredValue && i+1 < len(args) { i++ } } return dirs } -func optionTakesValueForGroupListing(arg string) bool { - switch arg { - case "--config", "-c", "--log-level", "-l": - return true - default: - return false - } +func inlineOptionValue(arg string) (string, bool) { + _, value, ok := strings.Cut(arg, "=") + return value, ok } func helpText() string { - return `Usage + var b strings.Builder + b.WriteString(`Usage ===== facts [options] [query] [query] [...] Options ======= - [--color] Force color output (default: enabled when writing to a terminal). In the default output format, fact keys are colored by nesting depth. - [--no-color] Disable color output. - -c [--config] The location of the config file. - -d [--debug] Enable debug output. - [--external-dir] A directory to use for external facts. - [--hocon] Output in Hocon format. - -j [--json] Output in JSON format. - -l [--log-level] Set logging level. - [--no-block] Disable fact blocking. - [--no-cache] Disable loading and refreshing facts from the cache. - [--no-external-facts] Disable external facts. - [--verbose] Enable verbose output. - -y [--yaml] Output in YAML format. - [--strict] Enable more aggressive error reporting. - -t [--timing] Show how much time it took to resolve each fact. - [--sequential] Resolve facts sequentially. - [--http-debug] Write HTTP request and responses to stderr. - -h [--help] Help for all arguments - --version, -v Print the version - --man Display manual. - --list-block-groups List block groups - --list-cache-groups List cache groups -` +`) + for _, option := range cli.DocumentedOptions() { + b.WriteString(option.Documentation.Help) + b.WriteByte('\n') + } + return b.String() } func manText() string { - return `facts - collect and display facts about the current system + var b strings.Builder + b.WriteString(`facts - collect and display facts about the current system ========================================================== SYNOPSIS @@ -186,27 +180,12 @@ Many command line options can also be set via the HOCON config file. This file c OPTIONS ------- - * --color: Force color output (default: enabled when writing to a terminal). In the default output format, fact keys are colored by nesting depth. - * --no-color: Disable color output. - * -c, --config: The location of the config file. - * -d, --debug: Enable debug output. - * --external-dir: A directory to use for external facts. - * --hocon: Output in Hocon format. - * -j, --json: Output in JSON format. - * -l, --log-level: Set logging level. - * --no-block: Disable fact blocking. - * --no-cache: Disable loading and refreshing facts from the cache. - * --no-external-facts: Disable external facts. - * --verbose: Enable verbose output. - * -y, --yaml: Output in YAML format. - * --strict: Enable more aggressive error reporting. - * -t, --timing: Show how much time it took to resolve each fact. - * --sequential: Resolve facts sequentially. - * --http-debug: Write HTTP request and responses to stderr. - * --version, -v: Print the version. - * --man: Display manual. - * --list-block-groups: List block groups. - * --list-cache-groups: List cache groups. +`) + for _, option := range cli.DocumentedOptions() { + b.WriteString(option.Documentation.Man) + b.WriteByte('\n') + } + b.WriteString(` FILES ----- @@ -231,7 +210,8 @@ Display a single structured fact: Format facts as JSON: facts --json os.name os.release.major processors.isa -` +`) + return b.String() } func runQuery(stdout, stderr io.Writer, args []string) error { diff --git a/internal/app/cli_option_contract_test.go b/internal/app/cli_option_contract_test.go new file mode 100644 index 00000000..e6f43914 --- /dev/null +++ b/internal/app/cli_option_contract_test.go @@ -0,0 +1,51 @@ +package app + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/ncode/facts/internal/cli" +) + +func TestCLIOptionDocumentationIncludesAcceptedNonHiddenOptions(t *testing.T) { + installedManPage, err := os.ReadFile(filepath.Join("..", "..", "man", "man8", "facts.8")) + if err != nil { + t.Fatal(err) + } + + surfaces := []struct { + name string + text string + }{ + {name: "help", text: helpText()}, + {name: "man", text: manText()}, + {name: "installed man page", text: normalizeManPage(string(installedManPage))}, + } + + for _, option := range cli.Options() { + if option.Hidden { + continue + } + names := append([]string{option.Canonical}, option.Aliases...) + for _, surface := range surfaces { + for _, name := range names { + if !strings.Contains(surface.text, name) { + t.Fatalf("%s output missing documented option %q:\n%s", surface.name, name, surface.text) + } + } + } + } +} + +func normalizeManPage(text string) string { + replacer := strings.NewReplacer( + `\fB`, "", + `\fR`, "", + `\-`, "-", + `\.`, ".", + `\&`, "", + ) + return replacer.Replace(text) +} diff --git a/internal/cli/arguments.go b/internal/cli/arguments.go index ff54b028..5906ddb7 100644 --- a/internal/cli/arguments.go +++ b/internal/cli/arguments.go @@ -14,12 +14,12 @@ func PrepareArguments(args []string) []string { normal := make([]string, 0, len(prepared)) for i := 0; i < len(prepared); i++ { arg := prepared[i] - if mappedFlags[arg] || tasks[arg] { + if IsTaskFlag(arg) || IsTask(arg) { priority = append(priority, arg) continue } normal = append(normal, arg) - if optionTakesSeparateValue(arg) && i+1 < len(prepared) { + if OptionTakesSeparateValue(arg) && i+1 < len(prepared) { i++ normal = append(normal, prepared[i]) } @@ -34,7 +34,7 @@ func expandShortOptions(args []string) []string { expanded = append(expanded, arg) continue } - if shortOptionTakesAttachedValue(arg[1]) { + if ShortOptionTakesAttachedValue(arg[1]) { expanded = append(expanded, arg[:2], arg[2:]) continue } @@ -45,41 +45,13 @@ func expandShortOptions(args []string) []string { return expanded } -func shortOptionTakesAttachedValue(flag byte) bool { - switch flag { - case 'c', 'l': - return true - default: - return false - } -} - -var tasks = map[string]bool{ - "help": true, - "query": true, - "version": true, - "man": true, - "list_block_groups": true, - "list_cache_groups": true, -} - -var mappedFlags = map[string]bool{ - "-h": true, - "--help": true, - "--man": true, - "-v": true, - "--version": true, - "--list-block-groups": true, - "--list-cache-groups": true, -} - func containsKnownTaskOrMappedFlag(args []string) bool { for i := 0; i < len(args); i++ { arg := args[i] - if tasks[arg] || mappedFlags[arg] { + if IsTask(arg) || IsTaskFlag(arg) { return true } - if optionTakesSeparateValue(arg) && i+1 < len(args) { + if OptionTakesSeparateValue(arg) && i+1 < len(args) { i++ } } diff --git a/internal/cli/arguments_test.go b/internal/cli/arguments_test.go index d59949c2..0317c72b 100644 --- a/internal/cli/arguments_test.go +++ b/internal/cli/arguments_test.go @@ -30,6 +30,14 @@ func TestPrepareArguments_reordersShortVersionFlag(t *testing.T) { } } +func TestPrepareArguments_doesNotPromoteTaskFlagWithInlineValue(t *testing.T) { + got := PrepareArguments([]string{"--help=topic"}) + want := []string{"query", "--help=topic"} + if !slices.Equal(got, want) { + t.Fatalf("PrepareArguments() = %v, want %v", got, want) + } +} + func TestPrepareArguments_preservesShortOptionsWithEquals(t *testing.T) { got := PrepareArguments([]string{"-l=debug", "os.name"}) want := []string{"query", "-l=debug", "os.name"} diff --git a/internal/cli/options.go b/internal/cli/options.go new file mode 100644 index 00000000..85c82286 --- /dev/null +++ b/internal/cli/options.go @@ -0,0 +1,376 @@ +package cli + +import "strings" + +// ValueArity describes whether a CLI option consumes a value. +type ValueArity int + +const ( + NoValue ValueArity = iota + RequiredValue +) + +// OptionDocumentation holds the help and manual rows for a visible option. +type OptionDocumentation struct { + Help string + Man string +} + +// Option describes one accepted command-line option and its aliases. +type Option struct { + Canonical string + Aliases []string + Arity ValueArity + Repeatable bool + TaskFlag bool + Hidden bool + Conflicts []string + Documentation OptionDocumentation +} + +var optionDefinitions = []Option{ + { + Canonical: "--color", + Conflicts: []string{ + "--no-color", + }, + Documentation: OptionDocumentation{ + Help: "\t [--color] Force color output (default: enabled when writing to a terminal). In the default output format, fact keys are colored by nesting depth.", + Man: " * --color: Force color output (default: enabled when writing to a terminal). In the default output format, fact keys are colored by nesting depth.", + }, + }, + { + Canonical: "--no-color", + Documentation: OptionDocumentation{ + Help: "\t [--no-color] Disable color output.", + Man: " * --no-color: Disable color output.", + }, + }, + { + Canonical: "--config", + Aliases: []string{"-c"}, + Arity: RequiredValue, + Documentation: OptionDocumentation{ + Help: "\t -c [--config] The location of the config file.", + Man: " * -c, --config: The location of the config file.", + }, + }, + { + Canonical: "--debug", + Aliases: []string{"-d"}, + Documentation: OptionDocumentation{ + Help: "\t -d [--debug] Enable debug output.", + Man: " * -d, --debug: Enable debug output.", + }, + }, + { + Canonical: "--external-dir", + Arity: RequiredValue, + Repeatable: true, + Conflicts: []string{ + "--no-external-facts", + }, + Documentation: OptionDocumentation{ + Help: "\t [--external-dir] A directory to use for external facts.", + Man: " * --external-dir: A directory to use for external facts.", + }, + }, + { + Canonical: "--force-dot-resolution", + Documentation: OptionDocumentation{ + Help: "\t [--force-dot-resolution] Merge dotted facts into structured facts.", + Man: " * --force-dot-resolution: Merge dotted facts into structured facts.", + }, + }, + { + Canonical: "--hocon", + Conflicts: []string{ + "--no-hocon", + }, + Documentation: OptionDocumentation{ + Help: "\t [--hocon] Output in Hocon format.", + Man: " * --hocon: Output in Hocon format.", + }, + }, + { + Canonical: "--json", + Aliases: []string{"-j"}, + Conflicts: []string{ + "--no-json", + "-y", + "--yaml", + "--hocon", + }, + Documentation: OptionDocumentation{ + Help: "\t -j [--json] Output in JSON format.", + Man: " * -j, --json: Output in JSON format.", + }, + }, + { + Canonical: "--log-level", + Aliases: []string{"-l"}, + Arity: RequiredValue, + Documentation: OptionDocumentation{ + Help: "\t -l [--log-level] Set logging level.", + Man: " * -l, --log-level: Set logging level.", + }, + }, + { + Canonical: "--no-block", + Documentation: OptionDocumentation{ + Help: "\t [--no-block] Disable fact blocking.", + Man: " * --no-block: Disable fact blocking.", + }, + }, + { + Canonical: "--no-cache", + Documentation: OptionDocumentation{ + Help: "\t [--no-cache] Disable loading and refreshing facts from the cache.", + Man: " * --no-cache: Disable loading and refreshing facts from the cache.", + }, + }, + { + Canonical: "--no-external-facts", + Conflicts: []string{ + "--external-dir", + }, + Documentation: OptionDocumentation{ + Help: "\t [--no-external-facts] Disable external facts.", + Man: " * --no-external-facts: Disable external facts.", + }, + }, + {Canonical: "--no-hocon", Hidden: true}, + {Canonical: "--no-json", Hidden: true}, + {Canonical: "--no-yaml", Hidden: true}, + { + Canonical: "--verbose", + Documentation: OptionDocumentation{ + Help: "\t [--verbose] Enable verbose output.", + Man: " * --verbose: Enable verbose output.", + }, + }, + { + Canonical: "--yaml", + Aliases: []string{"-y"}, + Conflicts: []string{ + "--no-yaml", + "-j", + "--hocon", + }, + Documentation: OptionDocumentation{ + Help: "\t -y [--yaml] Output in YAML format.", + Man: " * -y, --yaml: Output in YAML format.", + }, + }, + { + Canonical: "--strict", + Documentation: OptionDocumentation{ + Help: "\t [--strict] Enable more aggressive error reporting.", + Man: " * --strict: Enable more aggressive error reporting.", + }, + }, + { + Canonical: "--timing", + Aliases: []string{"-t"}, + Documentation: OptionDocumentation{ + Help: "\t -t [--timing] Show how much time it took to resolve each fact.", + Man: " * -t, --timing: Show how much time it took to resolve each fact.", + }, + }, + { + Canonical: "--sequential", + Documentation: OptionDocumentation{ + Help: "\t [--sequential] Resolve facts sequentially.", + Man: " * --sequential: Resolve facts sequentially.", + }, + }, + { + Canonical: "--http-debug", + Documentation: OptionDocumentation{ + Help: "\t [--http-debug] Write HTTP request and responses to stderr.", + Man: " * --http-debug: Write HTTP request and responses to stderr.", + }, + }, + { + Canonical: "--help", + Aliases: []string{"-h"}, + TaskFlag: true, + Documentation: OptionDocumentation{ + Help: "\t -h [--help] Help for all arguments", + Man: " * --help, -h: Help for all arguments.", + }, + }, + { + Canonical: "--version", + Aliases: []string{"-v"}, + TaskFlag: true, + Documentation: OptionDocumentation{ + Help: "\t --version, -v Print the version", + Man: " * --version, -v: Print the version.", + }, + }, + { + Canonical: "--man", + TaskFlag: true, + Documentation: OptionDocumentation{ + Help: "\t --man Display manual.", + Man: " * --man: Display manual.", + }, + }, + { + Canonical: "--list-block-groups", + TaskFlag: true, + Documentation: OptionDocumentation{ + Help: "\t --list-block-groups List block groups", + Man: " * --list-block-groups: List block groups.", + }, + }, + { + Canonical: "--list-cache-groups", + TaskFlag: true, + Documentation: OptionDocumentation{ + Help: "\t --list-cache-groups List cache groups", + Man: " * --list-cache-groups: List cache groups.", + }, + }, +} + +var taskNames = map[string]bool{ + "help": true, + "query": true, + "version": true, + "man": true, + "list_block_groups": true, + "list_cache_groups": true, +} + +var optionByName = buildOptionIndex() + +func buildOptionIndex() map[string]Option { + options := make(map[string]Option) + for _, option := range optionDefinitions { + options[option.Canonical] = option + for _, alias := range option.Aliases { + options[alias] = option + } + } + return options +} + +// Options returns all accepted CLI options in documentation order. +func Options() []Option { + options := make([]Option, 0, len(optionDefinitions)) + for _, option := range optionDefinitions { + options = append(options, copyOption(option)) + } + return options +} + +// DocumentedOptions returns all accepted CLI options that should appear in docs. +func DocumentedOptions() []Option { + options := []Option{} + for _, option := range optionDefinitions { + if option.Hidden { + continue + } + options = append(options, copyOption(option)) + } + return options +} + +// LookupOption returns metadata for arg, accepting aliases and inline values. +func LookupOption(arg string) (Option, bool) { + name, _, hasInlineValue := strings.Cut(arg, "=") + if !hasInlineValue { + name = arg + } + option, ok := optionByName[name] + if !ok || (hasInlineValue && option.Arity == NoValue) { + return Option{}, false + } + return copyOption(option), true +} + +// CanonicalOption returns the canonical option name for arg if it is known. +func CanonicalOption(arg string) string { + option, ok := LookupOption(arg) + if !ok { + return arg + } + return option.Canonical +} + +// KnownOption reports whether arg names an accepted CLI option. +func KnownOption(arg string) bool { + _, ok := LookupOption(arg) + return ok +} + +// OptionTakesSeparateValue reports whether arg consumes the next argument. +func OptionTakesSeparateValue(arg string) bool { + if _, _, ok := strings.Cut(arg, "="); ok { + return false + } + option, ok := LookupOption(arg) + return ok && option.Arity == RequiredValue +} + +// ShortOptionTakesAttachedValue reports whether a short option supports -xVALUE. +func ShortOptionTakesAttachedValue(flag byte) bool { + option, ok := LookupOption("-" + string(flag)) + return ok && option.Arity == RequiredValue +} + +// IsTask reports whether arg is a bare app task name. +func IsTask(arg string) bool { + return taskNames[arg] +} + +// IsTaskFlag reports whether arg is an option that maps to an app task. +func IsTaskFlag(arg string) bool { + option, ok := LookupOption(arg) + return ok && option.TaskFlag +} + +func rawOptionName(arg string) string { + name, _, ok := strings.Cut(arg, "=") + if ok { + return name + } + return arg +} + +func copyOption(option Option) Option { + option.Aliases = append([]string(nil), option.Aliases...) + option.Conflicts = append([]string(nil), option.Conflicts...) + return option +} + +type optionConflict struct { + option string + conflicts []string +} + +func optionConflictRules() []optionConflict { + return append(optionConflictRulesFor("--color", "--json", "--yaml", "--hocon"), + optionConflict{option: "-j", conflicts: []string{"--no-json", "--hocon"}}, + optionConflict{option: "-y", conflicts: []string{"--no-yaml", "-j", "--hocon"}}, + optionConflictRuleFor("--no-external-facts"), + ) +} + +func optionConflictRulesFor(names ...string) []optionConflict { + rules := make([]optionConflict, 0, len(names)) + for _, name := range names { + rules = append(rules, optionConflictRuleFor(name)) + } + return rules +} + +func optionConflictRuleFor(name string) optionConflict { + option, _ := LookupOption(name) + return optionConflict{ + option: name, + conflicts: option.Conflicts, + } +} diff --git a/internal/cli/options_test.go b/internal/cli/options_test.go new file mode 100644 index 00000000..879eda3f --- /dev/null +++ b/internal/cli/options_test.go @@ -0,0 +1,134 @@ +package cli + +import "testing" + +func TestOptions_describeAcceptedOptionMetadata(t *testing.T) { + tests := []struct { + name string + arg string + canonical string + arity ValueArity + repeatable bool + taskFlag bool + hidden bool + conflicts []string + }{ + { + name: "external dir is repeatable valued option", + arg: "--external-dir", + canonical: "--external-dir", + arity: RequiredValue, + repeatable: true, + conflicts: []string{"--no-external-facts"}, + }, + { + name: "short config alias canonicalizes to config", + arg: "-c", + canonical: "--config", + arity: RequiredValue, + }, + { + name: "short log-level alias canonicalizes to log-level", + arg: "-l=debug", + canonical: "--log-level", + arity: RequiredValue, + }, + { + name: "list block groups is a task flag", + arg: "--list-block-groups", + canonical: "--list-block-groups", + taskFlag: true, + }, + { + name: "force dot resolution is documented", + arg: "--force-dot-resolution", + canonical: "--force-dot-resolution", + }, + { + name: "inverse json compatibility option is hidden", + arg: "--no-json", + canonical: "--no-json", + hidden: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + option, ok := LookupOption(tt.arg) + if !ok { + t.Fatalf("LookupOption(%q) ok = false, want true", tt.arg) + } + if option.Canonical != tt.canonical { + t.Fatalf("LookupOption(%q).Canonical = %q, want %q", tt.arg, option.Canonical, tt.canonical) + } + if option.Arity != tt.arity { + t.Fatalf("LookupOption(%q).Arity = %v, want %v", tt.arg, option.Arity, tt.arity) + } + if option.Repeatable != tt.repeatable { + t.Fatalf("LookupOption(%q).Repeatable = %v, want %v", tt.arg, option.Repeatable, tt.repeatable) + } + if option.TaskFlag != tt.taskFlag { + t.Fatalf("LookupOption(%q).TaskFlag = %v, want %v", tt.arg, option.TaskFlag, tt.taskFlag) + } + if option.Hidden != tt.hidden { + t.Fatalf("LookupOption(%q).Hidden = %v, want %v", tt.arg, option.Hidden, tt.hidden) + } + for _, conflict := range tt.conflicts { + if !hasOption(option.Conflicts, conflict) { + t.Fatalf("LookupOption(%q).Conflicts = %v, want %q", tt.arg, option.Conflicts, conflict) + } + } + if !option.Hidden && option.Documentation.Help == "" { + t.Fatalf("LookupOption(%q).Documentation.Help is empty for non-hidden option", tt.arg) + } + if !option.Hidden && option.Documentation.Man == "" { + t.Fatalf("LookupOption(%q).Documentation.Man is empty for non-hidden option", tt.arg) + } + }) + } +} + +func TestOptionValueHelpersUseSharedMetadata(t *testing.T) { + separateValue := []string{"--external-dir", "--config", "-c", "--log-level", "-l"} + for _, arg := range separateValue { + t.Run(arg, func(t *testing.T) { + if !OptionTakesSeparateValue(arg) { + t.Fatalf("OptionTakesSeparateValue(%q) = false, want true", arg) + } + }) + } + + inlineValue := []string{"--external-dir=/facts", "--config=/facts.conf", "-c=/facts.conf", "-l=debug"} + for _, arg := range inlineValue { + t.Run(arg, func(t *testing.T) { + if OptionTakesSeparateValue(arg) { + t.Fatalf("OptionTakesSeparateValue(%q) = true, want false", arg) + } + }) + } + + if !ShortOptionTakesAttachedValue('c') { + t.Fatal("ShortOptionTakesAttachedValue('c') = false, want true") + } + if !ShortOptionTakesAttachedValue('l') { + t.Fatal("ShortOptionTakesAttachedValue('l') = false, want true") + } + if ShortOptionTakesAttachedValue('j') { + t.Fatal("ShortOptionTakesAttachedValue('j') = true, want false") + } +} + +func TestLookupOptionRejectsInlineValueForNoValueOption(t *testing.T) { + if _, ok := LookupOption("--json=false"); ok { + t.Fatal("LookupOption(\"--json=false\") ok = true, want false") + } +} + +func hasOption(options []string, want string) bool { + for _, option := range options { + if option == want { + return true + } + } + return false +} diff --git a/internal/cli/validation.go b/internal/cli/validation.go index 130bc5dd..fb84fadd 100644 --- a/internal/cli/validation.go +++ b/internal/cli/validation.go @@ -49,13 +49,13 @@ func validateOptions(args []string) error { arg := args[i] if strings.HasPrefix(arg, "-") { seenRaw[rawOption(arg)] = true - option := canonicalOption(arg) - if !knownOption(option) { + option, ok := LookupOption(arg) + if !ok { return fmt.Errorf("unrecognised option '%s'", arg) } - seen[option] = true - counts[option]++ - if option == "--log-level" { + seen[option.Canonical] = true + counts[option.Canonical]++ + if option.Canonical == "--log-level" { if _, value, ok := strings.Cut(arg, "="); ok { logLevel = value } else if i+1 < len(args) { @@ -63,30 +63,22 @@ func validateOptions(args []string) error { } } } - if optionTakesSeparateValue(arg) { + if OptionTakesSeparateValue(arg) { if i+1 >= len(args) || strings.HasPrefix(args[i+1], "-") { - return fmt.Errorf("%s requires a value", canonicalOption(arg)) + return fmt.Errorf("%s requires a value", CanonicalOption(arg)) } i++ } } for option, count := range counts { - if count > 1 && !repeatableOption(option) { + metadata, _ := LookupOption(option) + if count > 1 && !metadata.Repeatable { return fmt.Errorf("option %s cannot be specified more than once.", option) } } - invalid := []optionConflict{ - {option: "--color", conflicts: []string{"--no-color"}}, - {option: "--json", conflicts: []string{"--no-json", "-y", "--yaml", "--hocon"}}, - {option: "--yaml", conflicts: []string{"--no-yaml", "-j", "--hocon"}}, - {option: "--hocon", conflicts: []string{"--no-hocon"}}, - {option: "-j", conflicts: []string{"--no-json", "--hocon"}}, - {option: "-y", conflicts: []string{"--no-yaml", "-j", "--hocon"}}, - {option: "--no-external-facts", conflicts: []string{"--external-dir"}}, - } - for _, invalid := range invalid { + for _, invalid := range optionConflictRules() { if !seenRaw[invalid.option] { continue } @@ -105,11 +97,6 @@ func validateOptions(args []string) error { return nil } -type optionConflict struct { - option string - conflicts []string -} - func rawOption(arg string) string { name, _, ok := strings.Cut(arg, "=") if ok { @@ -118,83 +105,6 @@ func rawOption(arg string) string { return arg } -func canonicalOption(arg string) string { - if !strings.HasPrefix(arg, "--") { - return shortOptionAlias(arg) - } - name, _, ok := strings.Cut(arg, "=") - if !ok { - return arg - } - return name -} - -func shortOptionAlias(arg string) string { - name, _, _ := strings.Cut(arg, "=") - switch arg { - case "-j": - return "--json" - case "-y": - return "--yaml" - case "-h": - return "--help" - case "-v": - return "--version" - case "-d": - return "--debug" - case "-t": - return "--timing" - case "-c": - return "--config" - case "-l": - return "--log-level" - default: - switch name { - case "-c": - return "--config" - case "-l": - return "--log-level" - } - return arg - } -} - -func knownOption(arg string) bool { - switch arg { - case "--color", "--config", "--debug", "--external-dir", - "--force-dot-resolution", "--help", "--hocon", "--http-debug", "--json", "--list-block-groups", - "--list-cache-groups", "--log-level", "--no-block", "--no-cache", - "--no-color", "--no-external-facts", "--no-hocon", - "--no-json", "--no-yaml", "--man", - "--sequential", "--strict", "--timing", - "--verbose", "--version", "--yaml": - return true - default: - return false - } -} - -func repeatableOption(arg string) bool { - switch arg { - case "--external-dir": - return true - default: - return false - } -} - -func optionTakesSeparateValue(arg string) bool { - if _, _, ok := strings.Cut(arg, "="); ok { - return false - } - switch arg { - case "--external-dir", "--config", "-c", "--log-level", "-l": - return true - default: - return false - } -} - func logOptionsConflict(seen map[string]bool, logLevel string) bool { debug := seen["--debug"] verbose := seen["--verbose"] diff --git a/internal/cli/validation_test.go b/internal/cli/validation_test.go index a3ea595d..b6aaa0fd 100644 --- a/internal/cli/validation_test.go +++ b/internal/cli/validation_test.go @@ -96,6 +96,16 @@ func TestValidateOptions_rejectsUnknownConcatenatedShortOption(t *testing.T) { } } +func TestValidateOptions_rejectsInlineValueForNoValueOption(t *testing.T) { + err := ValidateOptions([]string{"--json=false", "os.name"}) + if err == nil { + t.Fatal("ValidateOptions() err = nil, want unknown option error") + } + if got, want := err.Error(), "unrecognised option '--json=false'"; got != want { + t.Fatalf("ValidateOptions() err = %q, want %q", got, want) + } +} + func TestValidateOptions_validatesLogLevelCombinations(t *testing.T) { tests := []struct { name string diff --git a/internal/engine/core_benchmark_test.go b/internal/engine/core_benchmark_test.go index 6b3f393e..2900ac9f 100644 --- a/internal/engine/core_benchmark_test.go +++ b/internal/engine/core_benchmark_test.go @@ -152,6 +152,6 @@ func BenchmarkDisksFact(b *testing.B) { b.ReportAllocs() b.ResetTimer() for b.Loop() { - _ = disksFact(dir) + _ = disksFact(dir, osHost{}) } } diff --git a/internal/engine/disks.go b/internal/engine/disks.go index e18ff153..6640f36c 100644 --- a/internal/engine/disks.go +++ b/internal/engine/disks.go @@ -2,11 +2,11 @@ package engine import ( "encoding/xml" - "os" "path/filepath" - "runtime" "strconv" "strings" + + targets "github.com/ncode/facts/internal/platform" ) type freeBSDGeomMesh struct { @@ -38,12 +38,11 @@ type freeBSDGeomConfig struct { Type string `xml:"type"` } -func disksFact(root string, hosts ...hostOS) map[string]any { - var host hostOS = osHost{} - if len(hosts) > 0 { - host = hosts[0] +func disksFact(root string, host hostOS) map[string]any { + if host == nil { + return nil } - entries, err := os.ReadDir(root) + entries, err := host.readDir(root) if err != nil { return nil } @@ -96,11 +95,7 @@ func disksFacts(disks map[string]any) []ResolvedFact { return []ResolvedFact{{Name: "disks", Value: disks}} } -func currentDisks(goos string, run commandRunner, hosts ...hostOS) map[string]any { - var host hostOS = osHost{} - if len(hosts) > 0 { - host = hosts[0] - } +func currentDisks(goos string, run commandRunner, host hostOS) map[string]any { switch goos { case "freebsd": return parseFreeBSDGeomDisks(run("sysctl", "-n", "kern.geom.confxml")) @@ -115,11 +110,7 @@ func currentDisks(goos string, run commandRunner, hosts ...hostOS) map[string]an } } -func currentLinuxDisks(root string, run commandRunner, hosts ...hostOS) map[string]any { - var host hostOS = osHost{} - if len(hosts) > 0 { - host = hosts[0] - } +func currentLinuxDisks(root string, run commandRunner, host hostOS) map[string]any { disks := disksFact(root, host) if len(disks) == 0 || run == nil { return disks @@ -177,7 +168,7 @@ func parseFreeBSDGeomDisks(input string) map[string]any { } func currentPartitions(s *Session) map[string]any { - switch runtime.GOOS { + switch s.goos() { case "freebsd": return parseFreeBSDGeomPartitions(s.commandOutput("sysctl", "-n", "kern.geom.confxml")) case "dragonfly": @@ -187,7 +178,7 @@ func currentPartitions(s *Session) map[string]any { case "netbsd": return currentNetBSDPartitions(s.commandOutput) case "illumos": - return currentIllumosPartitions(s.commandOutput, filepath.Glob) + return currentIllumosPartitions(s.commandOutput, s.glob) case "linux": return currentLinuxPartitions("/sys/class/block", s.commandOutput, s.host) default: @@ -195,11 +186,7 @@ func currentPartitions(s *Session) map[string]any { } } -func currentLinuxPartitions(root string, run commandRunner, hosts ...hostOS) map[string]any { - host := hostOS(osHost{}) - if len(hosts) > 0 && hosts[0] != nil { - host = hosts[0] - } +func currentLinuxPartitions(root string, run commandRunner, host hostOS) map[string]any { partitions := discoverPartitions(root, host) if len(partitions) == 0 || run == nil { return partitions @@ -225,12 +212,11 @@ func currentLinuxPartitions(root string, run commandRunner, hosts ...hostOS) map return partitions } -func discoverPartitions(root string, hosts ...hostOS) map[string]any { - host := hostOS(osHost{}) - if len(hosts) > 0 && hosts[0] != nil { - host = hosts[0] +func discoverPartitions(root string, host hostOS) map[string]any { + if host == nil { + return nil } - entries, err := os.ReadDir(root) + entries, err := host.readDir(root) if err != nil { return nil } @@ -271,11 +257,7 @@ func discoverPartitions(root string, hosts ...hostOS) map[string]any { return partitions } -func addLinuxPartitionSize(partition map[string]any, root, name string, readFiles ...fileReader) { - readFile := osHost{}.readFile - if len(readFiles) > 0 && readFiles[0] != nil { - readFile = readFiles[0] - } +func addLinuxPartitionSize(partition map[string]any, root, name string, readFile fileReader) { sectors, err := strconv.Atoi(readSysfsString(root, name, "size", readFile)) if err != nil || sectors < 0 { sectors = 0 @@ -1012,16 +994,17 @@ type mountStat struct { } func rootMountpoint(s *Session) map[string]any { - if runtime.GOOS == "openbsd" { + goos := s.goos() + if goos == "openbsd" { return currentOpenBSDMountpoints(s) } - if runtime.GOOS == "netbsd" { + if goos == "netbsd" { return currentNetBSDMountpoints(s) } - if runtime.GOOS == "dragonfly" { + if goos == "dragonfly" { return currentDragonFlyMountpoints(s) } - if runtime.GOOS == "illumos" { + if goos == "illumos" { return currentIllumosMountpoints(s) } @@ -1029,16 +1012,16 @@ func rootMountpoint(s *Session) map[string]any { if len(entries) == 0 { entries = []mountEntry{{Path: "/"}} } - if runtime.GOOS == "darwin" { - return darwinMountpointsFact(entries, statMountpoint) + if goos == "darwin" { + return darwinMountpointsFact(entries, s.statMountpoint) } - return mountpointsFact(entries, statMountpoint) + return mountpointsFact(entries, s.statMountpoint) } func currentOpenBSDMountpoints(s *Session) map[string]any { mountOutput := s.commandOutput("mount") if mountOutput == "" { - return mountpointsFact([]mountEntry{{Path: "/"}}, statMountpoint) + return mountpointsFact([]mountEntry{{Path: "/"}}, s.statMountpoint) } dfOutput := s.commandOutput("df", "-P") return openBSDMountpointsFact(mountOutput, dfOutput) @@ -1047,7 +1030,7 @@ func currentOpenBSDMountpoints(s *Session) map[string]any { func currentNetBSDMountpoints(s *Session) map[string]any { mountOutput := s.commandOutput("mount") if mountOutput == "" { - return mountpointsFact([]mountEntry{{Path: "/"}}, statMountpoint) + return mountpointsFact([]mountEntry{{Path: "/"}}, s.statMountpoint) } dfOutput := s.commandOutput("df", "-P") return netBSDMountpointsFact(mountOutput, dfOutput) @@ -1056,7 +1039,7 @@ func currentNetBSDMountpoints(s *Session) map[string]any { func currentDragonFlyMountpoints(s *Session) map[string]any { mountOutput := s.commandOutput("mount") if mountOutput == "" { - return mountpointsFact([]mountEntry{{Path: "/"}}, statMountpoint) + return mountpointsFact([]mountEntry{{Path: "/"}}, s.statMountpoint) } return dragonFlyMountpointsFact(mountOutput, s.commandOutput("df", "-P")) } @@ -1064,13 +1047,13 @@ func currentDragonFlyMountpoints(s *Session) map[string]any { func currentIllumosMountpoints(s *Session) map[string]any { mountOutput := s.commandOutput("mount", "-v") if mountOutput == "" { - return mountpointsFact([]mountEntry{{Path: "/"}}, statMountpoint) + return mountpointsFact([]mountEntry{{Path: "/"}}, s.statMountpoint) } return illumosMountpointsFact(mountOutput, s.commandOutput("df", "-P")) } func currentMountEntries(s *Session) []mountEntry { - switch runtime.GOOS { + switch s.goos() { case "darwin": out := s.commandOutput("mount") if out == "" { @@ -1514,7 +1497,11 @@ func skipMountEntry(entry mountEntry) bool { } func currentZFSFacts(goos string, run commandRunner) []ResolvedFact { - if run == nil || goos != "freebsd" && goos != "netbsd" && goos != "illumos" { + if run == nil { + return nil + } + profile, ok := targets.Lookup(goos) + if !ok || !profile.Capabilities.ZFS { return nil } facts := zfsFactsFromUpgradeOutput(run("zfs", "upgrade", "-v")) @@ -1609,15 +1596,16 @@ func isDecimalString(value string) bool { // disksCoreFacts assembles the disks category facts (block devices, partitions, // and mountpoints) for the current host. func disksCoreFacts(s *Session) []ResolvedFact { - disks := currentDisks(runtime.GOOS, s.commandOutput, s.host) + goos := s.goos() + disks := currentDisks(goos, s.commandOutput, s.host) var mountEntries []mountEntry - if runtime.GOOS == "linux" { + if goos == "linux" { mountEntries = currentMountEntries(s) } mountpoints := rootMountpoint(s) var facts []ResolvedFact facts = append(facts, mountpointsFacts(mountpoints)...) - facts = append(facts, currentZFSFacts(runtime.GOOS, s.commandOutput)...) + facts = append(facts, currentZFSFacts(goos, s.commandOutput)...) facts = append(facts, disksFacts(disks)...) facts = append(facts, partitionsFacts(partitionsFactWithMountEntries(currentPartitions(s), mountEntries, mountpoints))...) return facts diff --git a/internal/engine/disks_test.go b/internal/engine/disks_test.go index 5c698af9..3eaedb55 100644 --- a/internal/engine/disks_test.go +++ b/internal/engine/disks_test.go @@ -127,7 +127,7 @@ func TestDiscoverPartitionsReadsSysfsPartitionEntries(t *testing.T) { t.Fatal(err) } - got := discoverPartitions(root) + got := discoverPartitions(root, osHost{}) want := map[string]any{ "/dev/sda1": map[string]any{"size": "2.00 MiB", "size_bytes": 2097152}, } @@ -160,7 +160,7 @@ func TestCurrentLinuxPartitionsAddsLSBLKParttypeLikeRubyResolver(t *testing.T) { } } - got := currentLinuxPartitions(root, run) + got := currentLinuxPartitions(root, run, osHost{}) want := map[string]any{ "/dev/sda1": map[string]any{ "filesystem": "ext3", @@ -192,7 +192,7 @@ func TestDiscoverPartitionsHandlesDMAndLoopDevicesLikeRubyResolver(t *testing.T) writeFile(t, filepath.Join(loopDir, "loop", "backing_file"), "some_path\n") writeFile(t, filepath.Join(loopDir, "size"), "234\n") - got := discoverPartitions(root) + got := discoverPartitions(root, osHost{}) want := map[string]any{ "/dev/mapper/VolGroup00-LogVol00": map[string]any{"size": "98.25 MiB", "size_bytes": 103021056}, "/dev/loop0": map[string]any{"backing_file": "some_path", "size": "117.00 KiB", "size_bytes": 119808}, @@ -528,7 +528,7 @@ func TestCurrentDisksUsesBSDDisklabel(t *testing.T) { t.Fatalf("unexpected command %q %#v", name, args) return "" } - }) + }, osHost{}) want := map[string]any{ tt.wantDiskKey: map[string]any{"size": "10.00 GiB", "size_bytes": 10_737_418_240}, } @@ -552,7 +552,7 @@ func TestCurrentDisksUsesDragonFlyDiskinfo(t *testing.T) { t.Fatalf("unexpected command %q %#v", name, args) return "" } - }) + }, osHost{}) want := map[string]any{ "da0": map[string]any{"size": "128.00 GiB", "size_bytes": 137_438_953_472}, } @@ -830,7 +830,7 @@ func TestDisksFact_readsLinuxSysfsBlockDevices(t *testing.T) { } } - got := disksFact(dir) + got := disksFact(dir, osHost{}) want := map[string]any{ "sda": map[string]any{ "model": "FastDisk", @@ -887,7 +887,7 @@ func TestCurrentLinuxDisksAddsSerialNumberAndWWN(t *testing.T) { } } - got := currentLinuxDisks(dir, run) + got := currentLinuxDisks(dir, run, osHost{}) want := map[string]any{ "sda": map[string]any{ "model": "model2", @@ -911,6 +911,133 @@ func TestCurrentLinuxDisksAddsSerialNumberAndWWN(t *testing.T) { } } +func TestDisksCoreFactsUsesSessionHostForLinuxDiskPartitionAndMountpointFacts(t *testing.T) { + host := &fakeHostOS{ + platform: "linux", + dirs: map[string][]os.DirEntry{ + "/sys/block": fakeDirEntries("sdz"), + "/sys/class/block": fakeDirEntries("sdz1"), + }, + files: map[string][]byte{ + "/sys/block/sdz/device/model": []byte("FastDisk\n"), + "/sys/block/sdz/device/vendor": []byte("Acme\n"), + "/sys/block/sdz/queue/rotational": []byte("0\n"), + "/sys/block/sdz/size": []byte("2048\n"), + "/sys/class/block/sdz1/size": []byte("4096\n"), + "/proc/self/mounts": []byte("/dev/sdz1 / ext4 rw,noatime 0 0\n"), + "/proc/cmdline": []byte("root=/dev/sdz1\n"), + }, + stats: map[string]os.FileInfo{ + "/sys/block/sdz/device": fakeFileInfo{name: "device", mode: os.ModeDir, isDir: true}, + "/sys/class/block/sdz1/partition": fakeFileInfo{name: "partition"}, + }, + mountStats: map[string]mountStat{ + "/": {SizeBytes: 4096, AvailableBytes: 1024, UsedBytes: 3072}, + }, + runOutputs: map[string]string{ + fakeRunKey("lsblk", "-dn", "-o", "serial", "/dev/sdz"): "SERIAL42\n", + fakeRunKey("lsblk", "-dn", "-o", "wwn", "/dev/sdz"): "wwn-42\n", + fakeRunKey("lsblk", "--version"): "lsblk from util-linux 2.25\n", + fakeRunKey("lsblk", "-p", "-P", "-o", "NAME,FSTYPE,UUID,LABEL,PARTUUID,PARTLABEL,PARTTYPE"): `NAME="/dev/sdz1" FSTYPE="ext4" UUID="uuid-root" LABEL="rootfs" PARTUUID="partuuid-root" PARTLABEL="root" PARTTYPE="linux"` + "\n", + fakeRunKey("zfs", "upgrade", "-v"): "", + fakeRunKey("zpool", "upgrade", "-v"): "", + }, + } + s := NewSessionContext(t.Context()) + s.host = host + + got := factsByName(disksCoreFacts(s)) + wantDisks := map[string]any{ + "sdz": map[string]any{ + "model": "FastDisk", + "serial_number": "SERIAL42", + "size": "1.00 MiB", + "size_bytes": 1_048_576, + "type": "ssd", + "vendor": "Acme", + "wwn": "wwn-42", + }, + } + if !reflect.DeepEqual(got["disks"], wantDisks) { + t.Fatalf("disks = %#v, want %#v", got["disks"], wantDisks) + } + wantMountpoints := map[string]any{ + "/": map[string]any{ + "available": "1.00 KiB", + "available_bytes": 1024, + "capacity": "75.00%", + "device": "/dev/sdz1", + "filesystem": "ext4", + "options": []string{"rw", "noatime"}, + "size": "4.00 KiB", + "size_bytes": 4096, + "used": "3.00 KiB", + "used_bytes": 3072, + }, + } + if !reflect.DeepEqual(got["mountpoints"], wantMountpoints) { + t.Fatalf("mountpoints = %#v, want %#v", got["mountpoints"], wantMountpoints) + } + wantPartitions := map[string]any{ + "/dev/sdz1": map[string]any{ + "filesystem": "ext4", + "label": "rootfs", + "mount": "/", + "partlabel": "root", + "parttype": "linux", + "partuuid": "partuuid-root", + "size": "2.00 MiB", + "size_bytes": 2_097_152, + "uuid": "uuid-root", + }, + } + if !reflect.DeepEqual(got["partitions"], wantPartitions) { + t.Fatalf("partitions = %#v, want %#v", got["partitions"], wantPartitions) + } + if want := []string{"/sys/block", "/sys/class/block"}; !reflect.DeepEqual(host.readDirCalls, want) { + t.Fatalf("readDir calls = %#v, want %#v", host.readDirCalls, want) + } + if want := []string{"/"}; !reflect.DeepEqual(host.statMountpointCalls, want) { + t.Fatalf("statMountpoint calls = %#v, want %#v", host.statMountpointCalls, want) + } +} + +func TestCurrentPartitionsUsesSessionHostGlobForIllumos(t *testing.T) { + const vtoc = `* Dimensions: +* 512 bytes/sector +* + 0 12 00 256 2048 2303 + 2 5 01 0 8192 8191 +` + host := &fakeHostOS{ + platform: "illumos", + globs: map[string][]string{ + "/dev/rdsk/*s2": {"/dev/rdsk/c0t0d0s2"}, + }, + runOutputs: map[string]string{ + fakeRunKey("prtvtoc", "/dev/rdsk/c0t0d0s2"): vtoc, + fakeRunKey("fstyp", "/dev/rdsk/c0t0d0s0"): "ufs\n", + }, + } + s := NewSessionContext(t.Context()) + s.host = host + + got := currentPartitions(s) + want := map[string]any{ + "/dev/dsk/c0t0d0s0": map[string]any{ + "filesystem": "ufs", + "size": "1.00 MiB", + "size_bytes": 1_048_576, + }, + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("currentPartitions() = %#v, want %#v", got, want) + } + if want := []string{"/dev/rdsk/*s2"}; !reflect.DeepEqual(host.globCalls, want) { + t.Fatalf("glob calls = %#v, want %#v", host.globCalls, want) + } +} + func TestParseLinuxFilesystems_sortsAndSkipsPseudoEntries(t *testing.T) { input := "nodev\tsysfs\nnodev\tproc\next4\nfuseblk\nxfs\n" @@ -932,6 +1059,40 @@ func TestCurrentLinuxFilesystemsUnreadableProcMatchesRubyResolver(t *testing.T) } } +func TestCurrentFilesystemsHonorsTargetCapabilityPolicy(t *testing.T) { + called := false + readFile := func(path string) ([]byte, error) { + called = true + return []byte("ext4\n"), nil + } + run := func(name string, args ...string) string { + called = true + return "/dev/disk on / (apfs, local)\n" + } + + if got := currentFilesystems("freebsd", readFile, run); got != nil { + t.Fatalf("currentFilesystems(freebsd) = %#v, want nil", got) + } + if called { + t.Fatal("currentFilesystems(freebsd) touched probes despite target policy") + } +} + +func TestCurrentZFSFactsHonorsTargetCapabilityPolicy(t *testing.T) { + called := false + run := func(name string, args ...string) string { + called = true + return " 1 initial version\n" + } + + if got := currentZFSFacts("openbsd", run); got != nil { + t.Fatalf("currentZFSFacts(openbsd) = %#v, want nil", got) + } + if called { + t.Fatal("currentZFSFacts(openbsd) touched probes despite target policy") + } +} + func TestParseDarwinFilesystems_sortsUniqueFilesystemTypes(t *testing.T) { input := "/dev/disk3s1 on / (apfs, local, read-only)\nmap auto_home on /System/Volumes/Data/home (autofs, automounted)\n/dev/disk3s2 on /System/Volumes/Preboot (apfs, local)\n" diff --git a/internal/engine/os.go b/internal/engine/os.go index b26945ba..62980ae3 100644 --- a/internal/engine/os.go +++ b/internal/engine/os.go @@ -9,6 +9,8 @@ import ( "sort" "strconv" "strings" + + targets "github.com/ncode/facts/internal/platform" ) type freeBSDVersions struct { @@ -209,6 +211,10 @@ func probeWindowsOSVersionInput(s *Session) string { } func currentOSRelease(s *Session, goos string, readFile fileReader, run commandRunner) any { + profile, ok := targets.Lookup(goos) + if !ok || !profile.Capabilities.OSRelease { + return nil + } switch goos { case "linux": data, err := readFile("/etc/os-release") @@ -1667,6 +1673,9 @@ func filesystemsFacts(value []string) []ResolvedFact { } func currentFilesystems(goos string, readFile fileReader, run commandRunner) []string { + if profile, ok := targets.Lookup(goos); ok && !profile.Capabilities.Filesystems { + return nil + } switch goos { case "darwin": if run == nil { @@ -1726,40 +1735,20 @@ func parseDarwinFilesystems(input string) []string { } func osFamily(goos string, distro linuxDistro) string { - switch goos { - case "darwin": - return "Darwin" - case "windows": - return "windows" - case "linux": + if goos == "linux" { if distro.ID != "" { return discoverFamily(distro.ID) } return "Linux" - case "freebsd": - return "FreeBSD" - case "netbsd": - return "NetBSD" - case "openbsd": - return "OpenBSD" - case "dragonfly": - return "DragonFly" - case "illumos": - return "illumos" - case "plan9": - return "Plan 9" - default: - return goos } + if profile, ok := targets.Lookup(goos); ok && profile.Identity.OSFamily != "" { + return profile.Identity.OSFamily + } + return goos } func osName(goos string, distro linuxDistro) string { - switch goos { - case "darwin": - return "Darwin" - case "windows": - return "windows" - case "linux": + if goos == "linux" { if distro.Name != "" { return distro.Name } @@ -1770,49 +1759,23 @@ func osName(goos string, distro linuxDistro) string { return distro.ID } return "Linux" - case "freebsd": - return "FreeBSD" - case "netbsd": - return "NetBSD" - case "openbsd": - return "OpenBSD" - case "dragonfly": - return "DragonFly" - case "illumos": + } + if goos == "illumos" { if distro.Name != "" { return distro.Name } - return "illumos" - case "plan9": - return "Plan 9" - default: - return goos } + if profile, ok := targets.Lookup(goos); ok && profile.Identity.OSName != "" { + return profile.Identity.OSName + } + return goos } func kernelName(goos string) string { - switch goos { - case "darwin": - return "Darwin" - case "windows": - return "windows" - case "linux": - return "Linux" - case "freebsd": - return "FreeBSD" - case "netbsd": - return "NetBSD" - case "openbsd": - return "OpenBSD" - case "dragonfly": - return "DragonFly" - case "illumos": - return "SunOS" - case "plan9": - return "Plan 9" - default: - return goos + if profile, ok := targets.Lookup(goos); ok && profile.Identity.KernelName != "" { + return profile.Identity.KernelName } + return goos } // kernelFacts assembles the structured kernel subtree from the kernel name, diff --git a/internal/engine/os_test.go b/internal/engine/os_test.go index bbb91904..33914469 100644 --- a/internal/engine/os_test.go +++ b/internal/engine/os_test.go @@ -8,6 +8,8 @@ import ( "runtime" "strings" "testing" + + targets "github.com/ncode/facts/internal/platform" ) func TestNetworkingInterfacesWindowsKeepsAddresslessInterfaceLikeRubyResolver(t *testing.T) { @@ -1164,6 +1166,29 @@ func TestKernelName_mapsBSDLikeRubyFact(t *testing.T) { } } +func TestOSIdentityHelpersUseTargetProfileDefaults(t *testing.T) { + t.Parallel() + + for _, profile := range targets.Profiles() { + if profile.ID == "linux" { + continue + } + t.Run(profile.ID, func(t *testing.T) { + t.Parallel() + + if got := osFamily(profile.ID, linuxDistro{}); got != profile.Identity.OSFamily { + t.Fatalf("osFamily(%q) = %q, want profile OS family %q", profile.ID, got, profile.Identity.OSFamily) + } + if got := osName(profile.ID, linuxDistro{}); got != profile.Identity.OSName { + t.Fatalf("osName(%q) = %q, want profile OS name %q", profile.ID, got, profile.Identity.OSName) + } + if got := kernelName(profile.ID); got != profile.Identity.KernelName { + t.Fatalf("kernelName(%q) = %q, want profile kernel name %q", profile.ID, got, profile.Identity.KernelName) + } + }) + } +} + func TestParseLinuxDistroOSRelease_mapsMissingSLESCodenameToNA(t *testing.T) { t.Parallel() diff --git a/internal/engine/plan9_existing_test.go b/internal/engine/plan9_existing_test.go index 7da88f4e..202d6b5f 100644 --- a/internal/engine/plan9_existing_test.go +++ b/internal/engine/plan9_existing_test.go @@ -37,6 +37,22 @@ func TestCurrentOSReleasePlan9OmitsOSVersionProtocol(t *testing.T) { } } +func TestCurrentOSReleaseUnsupportedTargetOmitsOSRelease(t *testing.T) { + t.Parallel() + + for _, goos := range []string{"solaris", "aix"} { + t.Run(goos, func(t *testing.T) { + t.Parallel() + + s := NewSession() + s.host = &fakeHostOS{runOutput: "5.11\n"} + if got := currentOSRelease(s, goos, nil, func(string, ...string) string { return "5.11\n" }); got != nil { + t.Fatalf("currentOSRelease(%s) = %#v, want nil", goos, got) + } + }) + } +} + func TestCurrentLoadAveragesPlan9OmitsLoadAverages(t *testing.T) { t.Parallel() diff --git a/internal/engine/session.go b/internal/engine/session.go index 30d78342..0a1479ac 100644 --- a/internal/engine/session.go +++ b/internal/engine/session.go @@ -20,14 +20,22 @@ import ( var coreCommandTimeout = 30 * time.Second type hostOS interface { + goos() string run(context.Context, string, ...string) string readFile(string) ([]byte, error) + readDir(string) ([]os.DirEntry, error) stat(string) (os.FileInfo, error) lstat(string) (os.FileInfo, error) + glob(string) ([]string, error) + statMountpoint(string) (mountStat, bool) } type osHost struct{} +func (osHost) goos() string { + return runtime.GOOS +} + func (osHost) run(ctx context.Context, name string, args ...string) string { cmdName, ok := coreCommandExecutable(name, runtime.GOOS) if !ok { @@ -143,6 +151,10 @@ func (osHost) readFile(path string) ([]byte, error) { return os.ReadFile(path) } +func (osHost) readDir(path string) ([]os.DirEntry, error) { + return os.ReadDir(path) +} + func (osHost) stat(path string) (os.FileInfo, error) { return os.Stat(path) } @@ -151,6 +163,14 @@ func (osHost) lstat(path string) (os.FileInfo, error) { return os.Lstat(path) } +func (osHost) glob(pattern string) ([]string, error) { + return filepath.Glob(pattern) +} + +func (osHost) statMountpoint(path string) (mountStat, bool) { + return statMountpoint(path) +} + // Session carries the state of one resolution run: memoized host probes and // resolution-scoped caches. Resolvers share a Session so facts derived from // the same probe agree within a run; a fresh Session re-reads the host, which @@ -221,10 +241,18 @@ func (s *Session) Context() context.Context { return s.ctx } +func (s *Session) goos() string { + return s.host.goos() +} + func (s *Session) readFile(path string) ([]byte, error) { return s.host.readFile(path) } +func (s *Session) readDir(path string) ([]os.DirEntry, error) { + return s.host.readDir(path) +} + func (s *Session) stat(path string) (os.FileInfo, error) { return s.host.stat(path) } @@ -233,6 +261,14 @@ func (s *Session) lstat(path string) (os.FileInfo, error) { return s.host.lstat(path) } +func (s *Session) glob(pattern string) ([]string, error) { + return s.host.glob(pattern) +} + +func (s *Session) statMountpoint(path string) (mountStat, bool) { + return s.host.statMountpoint(path) +} + // logr returns the session logger, defaulting to a discard logger so a Session // built as a bare literal never panics. Sessions from NewSessionContext always // carry a non-nil logger (a DiscardHandler unless an Engine supplies one). diff --git a/internal/engine/session_test.go b/internal/engine/session_test.go index 17a15805..e5673024 100644 --- a/internal/engine/session_test.go +++ b/internal/engine/session_test.go @@ -18,11 +18,19 @@ import ( var testSession = NewSession() type fakeHostOS struct { - runCalls []fakeHostRunCall - runOutput string - files map[string][]byte - stats map[string]os.FileInfo - lstats map[string]os.FileInfo + platform string + runCalls []fakeHostRunCall + runOutput string + runOutputs map[string]string + files map[string][]byte + dirs map[string][]os.DirEntry + stats map[string]os.FileInfo + lstats map[string]os.FileInfo + globs map[string][]string + mountStats map[string]mountStat + readDirCalls []string + globCalls []string + statMountpointCalls []string } type fakeHostRunCall struct { @@ -32,22 +40,47 @@ type fakeHostRunCall struct { func (h *fakeHostOS) run(_ context.Context, name string, args ...string) string { h.runCalls = append(h.runCalls, fakeHostRunCall{name: name, args: append([]string(nil), args...)}) + if h.runOutputs != nil { + if output, ok := h.runOutputs[fakeRunKey(name, args...)]; ok { + return output + } + } if h.runOutput != "" { return h.runOutput } return "host-output\n" } +func fakeRunKey(name string, args ...string) string { + return strings.Join(append([]string{name}, args...), "\x00") +} + +func (h *fakeHostOS) goos() string { + if h.platform != "" { + return h.platform + } + return runtime.GOOS +} + func (h *fakeHostOS) readFile(path string) ([]byte, error) { - data, ok := h.files[path] + data, ok := h.files[fakeHostPath(path)] if !ok { return nil, os.ErrNotExist } return data, nil } +func (h *fakeHostOS) readDir(path string) ([]os.DirEntry, error) { + h.readDirCalls = append(h.readDirCalls, path) + entries, ok := h.dirs[fakeHostPath(path)] + if !ok { + return nil, os.ErrNotExist + } + return entries, nil +} + func (h *fakeHostOS) stat(path string) (os.FileInfo, error) { - info, ok := h.stats[path] + info, ok := h.stats[fakeHostPath(path)] if !ok { return nil, os.ErrNotExist } @@ -55,13 +88,32 @@ func (h *fakeHostOS) stat(path string) (os.FileInfo, error) { } func (h *fakeHostOS) lstat(path string) (os.FileInfo, error) { - info, ok := h.lstats[path] + info, ok := h.lstats[fakeHostPath(path)] if !ok { return nil, os.ErrNotExist } return info, nil } +func (h *fakeHostOS) glob(pattern string) ([]string, error) { + h.globCalls = append(h.globCalls, pattern) + matches, ok := h.globs[fakeHostPath(pattern)] + if !ok { + return nil, nil + } + return append([]string(nil), matches...), nil +} + +func (h *fakeHostOS) statMountpoint(path string) (mountStat, bool) { + h.statMountpointCalls = append(h.statMountpointCalls, path) + stat, ok := h.mountStats[fakeHostPath(path)] + return stat, ok +} + +func fakeHostPath(path string) string { + return filepath.ToSlash(path) +} + type fakeFileInfo struct { name string mode os.FileMode @@ -75,6 +127,27 @@ func (fi fakeFileInfo) ModTime() time.Time { return time.Time{} } func (fi fakeFileInfo) IsDir() bool { return fi.isDir } func (fi fakeFileInfo) Sys() any { return nil } +type fakeDirEntry struct { + name string + mode os.FileMode + isDir bool +} + +func (de fakeDirEntry) Name() string { return de.name } +func (de fakeDirEntry) IsDir() bool { return de.isDir } +func (de fakeDirEntry) Type() os.FileMode { return de.mode.Type() } +func (de fakeDirEntry) Info() (os.FileInfo, error) { + return fakeFileInfo{name: de.name, mode: de.mode, isDir: de.isDir}, nil +} + +func fakeDirEntries(names ...string) []os.DirEntry { + entries := make([]os.DirEntry, 0, len(names)) + for _, name := range names { + entries = append(entries, fakeDirEntry{name: name, mode: os.ModeDir, isDir: true}) + } + return entries +} + func TestSessionRoutesHostIOThroughHost(t *testing.T) { host := &fakeHostOS{ files: map[string][]byte{"/proc/data": []byte("file-data")}, diff --git a/internal/platform/profile.go b/internal/platform/profile.go new file mode 100644 index 00000000..e4bc6f9a --- /dev/null +++ b/internal/platform/profile.go @@ -0,0 +1,397 @@ +package platform + +// SupportTier classifies the policy status for a platform target. +type SupportTier string + +const ( + SupportTierRelease SupportTier = "release" + SupportTierLabValidated SupportTier = "lab_validated" +) + +// Target is one GOOS/GOARCH build or distribution tuple. +type Target struct { + GOOS string + GOARCH string +} + +// IdentityPolicy captures the default OS and kernel identity for a target. +type IdentityPolicy struct { + OSFamily string + OSName string + KernelName string +} + +// CapabilityPolicy captures coarse target-level fact applicability. +type CapabilityPolicy struct { + Filesystems bool + ZFS bool + OSRelease bool +} + +// GateMetadata names the native gate associated with a target without storing +// lab-specific hosts, credentials, or other local environment details. +type GateMetadata struct { + GOOS string + Name string + CIJob string + LocalTarget string + Script string + LabGuest string +} + +// Profile is the internal policy profile for one GOOS target. +type Profile struct { + ID string + Label string + SupportTier SupportTier + SchemaVisible bool + CompileTargets []Target + DistributionTargets []Target + Gate GateMetadata + Identity IdentityPolicy + Capabilities CapabilityPolicy +} + +var profileOrder = []string{ + "linux", + "darwin", + "windows", + "freebsd", + "openbsd", + "netbsd", + "dragonfly", + "illumos", + "plan9", +} + +var profiles = map[string]Profile{ + "linux": { + ID: "linux", + Label: "Linux", + SupportTier: SupportTierRelease, + SchemaVisible: true, + CompileTargets: []Target{ + {GOOS: "linux", GOARCH: "amd64"}, + {GOOS: "linux", GOARCH: "arm64"}, + }, + DistributionTargets: []Target{ + {GOOS: "linux", GOARCH: "amd64"}, + {GOOS: "linux", GOARCH: "arm64"}, + }, + Gate: GateMetadata{ + GOOS: "linux", + Name: "Linux GitHub-hosted runners and container workloads", + CIJob: "go_tests, linux_container_tests, linux_distro_fact_tests", + }, + Identity: IdentityPolicy{ + OSFamily: "Linux", + OSName: "Linux", + KernelName: "Linux", + }, + Capabilities: CapabilityPolicy{ + Filesystems: true, + OSRelease: true, + }, + }, + "darwin": { + ID: "darwin", + Label: "macOS / Darwin", + SupportTier: SupportTierRelease, + SchemaVisible: true, + CompileTargets: []Target{ + {GOOS: "darwin", GOARCH: "amd64"}, + {GOOS: "darwin", GOARCH: "arm64"}, + }, + DistributionTargets: []Target{ + {GOOS: "darwin", GOARCH: "amd64"}, + {GOOS: "darwin", GOARCH: "arm64"}, + }, + Gate: GateMetadata{ + GOOS: "darwin", + Name: "macOS GitHub-hosted runners", + CIJob: "go_tests", + }, + Identity: IdentityPolicy{ + OSFamily: "Darwin", + OSName: "Darwin", + KernelName: "Darwin", + }, + Capabilities: CapabilityPolicy{ + Filesystems: true, + OSRelease: true, + }, + }, + "windows": { + ID: "windows", + Label: "Windows", + SupportTier: SupportTierRelease, + SchemaVisible: true, + CompileTargets: []Target{ + {GOOS: "windows", GOARCH: "amd64"}, + {GOOS: "windows", GOARCH: "arm64"}, + }, + DistributionTargets: []Target{ + {GOOS: "windows", GOARCH: "amd64"}, + {GOOS: "windows", GOARCH: "arm64"}, + }, + Gate: GateMetadata{ + GOOS: "windows", + Name: "Windows release gate", + CIJob: "go_tests", + Script: "tools/windows-release-gate.ps1", + LocalTarget: "go test ./... on Windows runner", + }, + Identity: IdentityPolicy{ + OSFamily: "windows", + OSName: "windows", + KernelName: "windows", + }, + Capabilities: CapabilityPolicy{ + OSRelease: true, + }, + }, + "freebsd": { + ID: "freebsd", + Label: "FreeBSD", + SupportTier: SupportTierRelease, + SchemaVisible: true, + CompileTargets: []Target{ + {GOOS: "freebsd", GOARCH: "amd64"}, + {GOOS: "freebsd", GOARCH: "arm"}, + {GOOS: "freebsd", GOARCH: "arm64"}, + }, + DistributionTargets: []Target{ + {GOOS: "freebsd", GOARCH: "amd64"}, + {GOOS: "freebsd", GOARCH: "arm"}, + {GOOS: "freebsd", GOARCH: "arm64"}, + }, + Gate: GateMetadata{ + GOOS: "freebsd", + Name: "FreeBSD release gate", + CIJob: "freebsd_tests", + Script: "tools/freebsd-release-gate.sh", + LocalTarget: "lima-freebsd-smoke, local-freebsd-amd64-smoke", + LabGuest: "freebsd", + }, + Identity: IdentityPolicy{ + OSFamily: "FreeBSD", + OSName: "FreeBSD", + KernelName: "FreeBSD", + }, + Capabilities: CapabilityPolicy{ + ZFS: true, + OSRelease: true, + }, + }, + "openbsd": { + ID: "openbsd", + Label: "OpenBSD", + SupportTier: SupportTierRelease, + SchemaVisible: true, + CompileTargets: []Target{ + {GOOS: "openbsd", GOARCH: "amd64"}, + {GOOS: "openbsd", GOARCH: "arm"}, + {GOOS: "openbsd", GOARCH: "arm64"}, + }, + DistributionTargets: []Target{ + {GOOS: "openbsd", GOARCH: "amd64"}, + {GOOS: "openbsd", GOARCH: "arm"}, + {GOOS: "openbsd", GOARCH: "arm64"}, + }, + Gate: GateMetadata{ + GOOS: "openbsd", + Name: "OpenBSD release gate", + CIJob: "openbsd_tests", + Script: "tools/openbsd-release-gate.sh", + LocalTarget: "local-openbsd-smoke, local-openbsd-amd64-smoke", + LabGuest: "openbsd", + }, + Identity: IdentityPolicy{ + OSFamily: "OpenBSD", + OSName: "OpenBSD", + KernelName: "OpenBSD", + }, + Capabilities: CapabilityPolicy{ + OSRelease: true, + }, + }, + "netbsd": { + ID: "netbsd", + Label: "NetBSD", + SupportTier: SupportTierRelease, + SchemaVisible: true, + CompileTargets: []Target{ + {GOOS: "netbsd", GOARCH: "amd64"}, + {GOOS: "netbsd", GOARCH: "arm"}, + {GOOS: "netbsd", GOARCH: "arm64"}, + }, + DistributionTargets: []Target{ + {GOOS: "netbsd", GOARCH: "amd64"}, + {GOOS: "netbsd", GOARCH: "arm"}, + {GOOS: "netbsd", GOARCH: "arm64"}, + }, + Gate: GateMetadata{ + GOOS: "netbsd", + Name: "NetBSD release gate", + CIJob: "netbsd_tests", + Script: "tools/netbsd-release-gate.sh", + LocalTarget: "local-netbsd-smoke, local-netbsd-amd64-smoke", + LabGuest: "netbsd", + }, + Identity: IdentityPolicy{ + OSFamily: "NetBSD", + OSName: "NetBSD", + KernelName: "NetBSD", + }, + Capabilities: CapabilityPolicy{ + ZFS: true, + OSRelease: true, + }, + }, + "dragonfly": { + ID: "dragonfly", + Label: "DragonFly BSD", + SupportTier: SupportTierRelease, + SchemaVisible: true, + CompileTargets: []Target{ + {GOOS: "dragonfly", GOARCH: "amd64"}, + }, + DistributionTargets: []Target{ + {GOOS: "dragonfly", GOARCH: "amd64"}, + }, + Gate: GateMetadata{ + GOOS: "dragonfly", + Name: "DragonFly BSD release gate", + CIJob: "dragonfly_tests", + Script: "tools/dragonfly-release-gate.sh", + LocalTarget: "local-dragonfly-amd64-smoke", + LabGuest: "dragonfly", + }, + Identity: IdentityPolicy{ + OSFamily: "DragonFly", + OSName: "DragonFly", + KernelName: "DragonFly", + }, + Capabilities: CapabilityPolicy{ + OSRelease: true, + }, + }, + "illumos": { + ID: "illumos", + Label: "illumos", + SupportTier: SupportTierRelease, + SchemaVisible: true, + CompileTargets: []Target{ + {GOOS: "illumos", GOARCH: "amd64"}, + }, + DistributionTargets: []Target{ + {GOOS: "illumos", GOARCH: "amd64"}, + }, + Gate: GateMetadata{ + GOOS: "illumos", + Name: "illumos release gate through OmniOS", + CIJob: "omnios_tests", + Script: "tools/illumos-release-gate.sh", + LocalTarget: "local-illumos-amd64-smoke", + LabGuest: "omnios", + }, + Identity: IdentityPolicy{ + OSFamily: "illumos", + OSName: "illumos", + KernelName: "SunOS", + }, + Capabilities: CapabilityPolicy{ + ZFS: true, + OSRelease: true, + }, + }, + "plan9": { + ID: "plan9", + Label: "Plan 9", + SupportTier: SupportTierLabValidated, + SchemaVisible: true, + CompileTargets: []Target{ + {GOOS: "plan9", GOARCH: "amd64"}, + }, + Gate: GateMetadata{ + GOOS: "plan9", + Name: "Plan 9 lab release gate", + Script: "tools/plan9-release-gate.rc", + LabGuest: "plan9", + }, + Identity: IdentityPolicy{ + OSFamily: "Plan 9", + OSName: "Plan 9", + KernelName: "Plan 9", + }, + }, +} + +// Lookup returns the target profile for goos. +func Lookup(goos string) (Profile, bool) { + profile, ok := profiles[goos] + if !ok { + return Profile{}, false + } + return copyProfile(profile), true +} + +// Profiles returns all target profiles in stable policy order. +func Profiles() []Profile { + out := make([]Profile, 0, len(profileOrder)) + for _, id := range profileOrder { + profile, ok := profiles[id] + if !ok { + panic("internal/platform: profileOrder references unknown profile " + id) + } + out = append(out, copyProfile(profile)) + } + return out +} + +// SchemaVisibleProfiles returns profiles accepted by the facts schema. +func SchemaVisibleProfiles() []Profile { + profiles := Profiles() + out := make([]Profile, 0, len(profiles)) + for _, profile := range profiles { + if profile.SchemaVisible { + out = append(out, profile) + } + } + return out +} + +// CompileTargets returns the cross-compile target set in stable order. +func CompileTargets() []Target { + var out []Target + for _, profile := range Profiles() { + out = append(out, profile.CompileTargets...) + } + return out +} + +// DistributionTargets returns the published artifact target set in stable order. +func DistributionTargets() []Target { + var out []Target + for _, profile := range Profiles() { + out = append(out, profile.DistributionTargets...) + } + return out +} + +// NativeGates returns target gate metadata in stable target order. +func NativeGates() []GateMetadata { + var out []GateMetadata + for _, profile := range Profiles() { + if profile.Gate.Name != "" { + out = append(out, profile.Gate) + } + } + return out +} + +func copyProfile(profile Profile) Profile { + profile.CompileTargets = append([]Target(nil), profile.CompileTargets...) + profile.DistributionTargets = append([]Target(nil), profile.DistributionTargets...) + return profile +} diff --git a/internal/platform/profile_test.go b/internal/platform/profile_test.go new file mode 100644 index 00000000..2473a957 --- /dev/null +++ b/internal/platform/profile_test.go @@ -0,0 +1,135 @@ +package platform + +import ( + "reflect" + "testing" +) + +func TestProfilesExposeExpectedTargetIDs(t *testing.T) { + got := profileIDs(Profiles()) + want := []string{"linux", "darwin", "windows", "freebsd", "openbsd", "netbsd", "dragonfly", "illumos", "plan9"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("Profiles() IDs = %#v, want %#v", got, want) + } +} + +func TestProfileOrderMatchesProfileTable(t *testing.T) { + if len(profileOrder) != len(profiles) { + t.Fatalf("profileOrder has %d entries, profiles has %d", len(profileOrder), len(profiles)) + } + for _, id := range profileOrder { + if _, ok := profiles[id]; !ok { + t.Fatalf("profileOrder references unknown profile %q", id) + } + } +} + +func TestTargetSetsRemainDistinct(t *testing.T) { + compileTargets := CompileTargets() + distributionTargets := DistributionTargets() + + if !containsTarget(compileTargets, Target{GOOS: "plan9", GOARCH: "amd64"}) { + t.Fatalf("CompileTargets() = %#v, want plan9/amd64", compileTargets) + } + if containsTarget(distributionTargets, Target{GOOS: "plan9", GOARCH: "amd64"}) { + t.Fatalf("DistributionTargets() = %#v, want plan9/amd64 excluded until artifact promotion", distributionTargets) + } + if !containsTarget(distributionTargets, Target{GOOS: "illumos", GOARCH: "amd64"}) { + t.Fatalf("DistributionTargets() = %#v, want illumos/amd64", distributionTargets) + } + + schemaIDs := profileIDs(SchemaVisibleProfiles()) + if !reflect.DeepEqual(schemaIDs, []string{"linux", "darwin", "windows", "freebsd", "openbsd", "netbsd", "dragonfly", "illumos", "plan9"}) { + t.Fatalf("SchemaVisibleProfiles() IDs = %#v, want all schema-visible targets", schemaIDs) + } + if reflect.DeepEqual(compileTargets, distributionTargets) { + t.Fatalf("compile and distribution target sets unexpectedly match: %#v", compileTargets) + } +} + +func TestUnsupportedNamesRemainExcluded(t *testing.T) { + for _, id := range []string{"solaris", "aix"} { + if _, ok := Lookup(id); ok { + t.Fatalf("Lookup(%q) found unsupported target", id) + } + if containsGOOS(CompileTargets(), id) { + t.Fatalf("CompileTargets() includes unsupported GOOS %q", id) + } + if containsGOOS(DistributionTargets(), id) { + t.Fatalf("DistributionTargets() includes unsupported GOOS %q", id) + } + for _, profile := range SchemaVisibleProfiles() { + if profile.ID == id { + t.Fatalf("SchemaVisibleProfiles() includes unsupported GOOS %q", id) + } + } + } +} + +func TestCapabilityPolicyCapturesLowRiskGates(t *testing.T) { + tests := []struct { + goos string + filesystems bool + zfs bool + operatingRelease bool + }{ + {goos: "linux", filesystems: true, operatingRelease: true}, + {goos: "darwin", filesystems: true, operatingRelease: true}, + {goos: "freebsd", zfs: true, operatingRelease: true}, + {goos: "netbsd", zfs: true, operatingRelease: true}, + {goos: "illumos", zfs: true, operatingRelease: true}, + {goos: "plan9", operatingRelease: false}, + } + + for _, tt := range tests { + t.Run(tt.goos, func(t *testing.T) { + profile, ok := Lookup(tt.goos) + if !ok { + t.Fatalf("Lookup(%q) did not find profile", tt.goos) + } + if profile.Capabilities.Filesystems != tt.filesystems { + t.Fatalf("%s filesystems capability = %v, want %v", tt.goos, profile.Capabilities.Filesystems, tt.filesystems) + } + if profile.Capabilities.ZFS != tt.zfs { + t.Fatalf("%s ZFS capability = %v, want %v", tt.goos, profile.Capabilities.ZFS, tt.zfs) + } + if profile.Capabilities.OSRelease != tt.operatingRelease { + t.Fatalf("%s OSRelease capability = %v, want %v", tt.goos, profile.Capabilities.OSRelease, tt.operatingRelease) + } + }) + } +} + +func TestNativeGateMetadataUsesTargetIDs(t *testing.T) { + for _, gate := range NativeGates() { + if _, ok := Lookup(gate.GOOS); !ok { + t.Fatalf("NativeGates() includes unknown GOOS %q", gate.GOOS) + } + } +} + +func profileIDs(profiles []Profile) []string { + ids := make([]string, 0, len(profiles)) + for _, profile := range profiles { + ids = append(ids, profile.ID) + } + return ids +} + +func containsTarget(targets []Target, want Target) bool { + for _, target := range targets { + if target == want { + return true + } + } + return false +} + +func containsGOOS(targets []Target, goos string) bool { + for _, target := range targets { + if target.GOOS == goos { + return true + } + } + return false +} diff --git a/internal/schema/schema.go b/internal/schema/schema.go new file mode 100644 index 00000000..bad41fce --- /dev/null +++ b/internal/schema/schema.go @@ -0,0 +1,349 @@ +package schema + +import ( + "bytes" + "errors" + "fmt" + "io" + "os" + "reflect" + "sort" + "strings" + + targets "github.com/ncode/facts/internal/platform" + "gopkg.in/yaml.v3" +) + +const DefaultPath = "docs/schema/facts.yaml" + +// Entry is one documented fact: a dotted path or `*` pattern mapped to its +// type, description, platform list, and schema matching metadata. +type Entry struct { + Type string `yaml:"type"` + Description string `yaml:"description"` + Platforms []string `yaml:"platforms"` + Conditional bool `yaml:"conditional"` + OpenSubtree bool `yaml:"open_subtree"` +} + +// Schema is the parsed facts schema keyed by dotted fact path or pattern. +type Schema map[string]Entry + +// Platform is one schema-visible platform target. +type Platform struct { + ID string + Label string +} + +// Item is one schema entry paired with its path. +type Item struct { + Path string + Entry Entry +} + +var schemaTypes = map[string]bool{ + "string": true, + "integer": true, + "double": true, + "boolean": true, + "map": true, + "array": true, +} + +// Platforms returns the schema-visible platform vocabulary. +func Platforms() []Platform { + profiles := targets.SchemaVisibleProfiles() + out := make([]Platform, 0, len(profiles)) + for _, profile := range profiles { + out = append(out, Platform{ID: profile.ID, Label: profile.Label}) + } + return out +} + +// LoadFile reads and validates a facts schema YAML file. +func LoadFile(path string) (Schema, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("read schema: %w", err) + } + schema, err := Parse(data) + if err != nil { + return nil, fmt.Errorf("parse schema: %w", err) + } + return schema, nil +} + +// Parse decodes and validates facts schema YAML data. +func Parse(data []byte) (Schema, error) { + var schema Schema + decoder := yaml.NewDecoder(bytes.NewReader(data)) + decoder.KnownFields(true) + if err := decoder.Decode(&schema); err != nil { + return nil, err + } + var extra any + if err := decoder.Decode(&extra); err != io.EOF { + if err == nil { + return nil, errors.New("schema contains multiple YAML documents") + } + return nil, err + } + if err := schema.Validate(); err != nil { + return nil, err + } + return schema, nil +} + +// Validate checks the schema file's own shape. +func (s Schema) Validate() error { + if len(s) == 0 { + return errors.New("schema has no entries") + } + + var errs []error + platformIDs := schemaPlatformIDs() + for _, pattern := range s.Patterns() { + entry := s[pattern] + if strings.TrimSpace(pattern) == "" { + errs = append(errs, errors.New("entry has empty path")) + } + if !schemaTypes[entry.Type] { + errs = append(errs, fmt.Errorf("entry %q has invalid type %q", pattern, entry.Type)) + } + if strings.TrimSpace(entry.Description) == "" { + errs = append(errs, fmt.Errorf("entry %q has no description", pattern)) + } + if len(entry.Platforms) == 0 { + errs = append(errs, fmt.Errorf("entry %q lists no platforms", pattern)) + } + seen := map[string]bool{} + for _, platform := range entry.Platforms { + if !platformIDs[platform] { + errs = append(errs, fmt.Errorf("entry %q lists invalid platform %q", pattern, platform)) + } + if seen[platform] { + errs = append(errs, fmt.Errorf("entry %q lists platform %q twice", pattern, platform)) + } + seen[platform] = true + } + if entry.OpenSubtree && entry.Type != "map" && entry.Type != "array" { + errs = append(errs, fmt.Errorf("entry %q open_subtree requires type map or array", pattern)) + } + } + return errors.Join(errs...) +} + +func schemaPlatformIDs() map[string]bool { + ids := make(map[string]bool) + for _, platform := range Platforms() { + ids[platform.ID] = true + } + return ids +} + +// Patterns returns the schema paths in stable order. +func (s Schema) Patterns() []string { + patterns := make([]string, 0, len(s)) + for pattern := range s { + patterns = append(patterns, pattern) + } + sort.Strings(patterns) + return patterns +} + +// EntriesForPlatform returns schema entries for platform in stable path order. +func (s Schema) EntriesForPlatform(platform string) []Item { + items := make([]Item, 0, len(s)) + for _, path := range s.Patterns() { + entry := s[path] + if PlatformsInclude(entry.Platforms, platform) { + items = append(items, Item{Path: path, Entry: entry}) + } + } + return items +} + +// UndocumentedPaths returns the emitted leaf paths no platform-applicable +// schema entry covers. +func (s Schema) UndocumentedPaths(paths []string, platform string) []string { + var unmatched []string + for _, path := range paths { + documented := false + for pattern, entry := range s { + if !PlatformsInclude(entry.Platforms, platform) { + continue + } + if MatchesPath(pattern, entry, path) { + documented = true + break + } + } + if !documented { + unmatched = append(unmatched, path) + } + } + return unmatched +} + +// MissingEntries returns the non-conditional schema entries for platform that +// no emitted path satisfies. +func (s Schema) MissingEntries(paths []string, platform string) []string { + var missing []string + for _, pattern := range s.Patterns() { + entry := s[pattern] + if entry.Conditional || !PlatformsInclude(entry.Platforms, platform) { + continue + } + if wildcardPrefixAbsent(pattern, paths) { + continue + } + present := false + for _, path := range paths { + if MatchesPath(pattern, entry, path) { + present = true + break + } + } + if !present { + missing = append(missing, pattern) + } + } + return missing +} + +// FlattenTree reduces the canonical tree to sorted leaf paths: maps recurse +// with one segment per key, empty maps are leaves, arrays contribute a single +// path.* marker, and scalars are leaves. +func FlattenTree(tree map[string]any) []string { + leaves := make([]string, 0, 256) + var walk func(prefix string, value any) + walk = func(prefix string, value any) { + switch v := value.(type) { + case map[string]any: + if len(v) == 0 { + leaves = append(leaves, prefix) + return + } + for key, item := range v { + walk(joinPath(prefix, key), item) + } + default: + if value != nil { + kind := reflect.TypeOf(value).Kind() + if kind == reflect.Slice || kind == reflect.Array { + leaves = append(leaves, prefix+".*") + return + } + } + leaves = append(leaves, prefix) + } + } + for key, value := range tree { + walk(escapeSegment(key), value) + } + sort.Strings(leaves) + return leaves +} + +// MatchesPath reports whether a schema entry covers a flattened leaf path. +// `*` matches exactly one path segment. Map entries are not open subtrees +// unless OpenSubtree is set; array entries cover their flattened path.* item. +func MatchesPath(pattern string, entry Entry, path string) bool { + patternSegments := splitPath(pattern) + pathSegments := splitPath(path) + if matchSegments(patternSegments, pathSegments) { + return true + } + + if entry.Type == "array" && len(pathSegments) == len(patternSegments)+1 && pathSegments[len(pathSegments)-1] == "*" { + return matchSegments(patternSegments, pathSegments[:len(patternSegments)]) + } + + if !entry.OpenSubtree || (entry.Type != "map" && entry.Type != "array") || len(patternSegments) >= len(pathSegments) { + return false + } + return matchSegments(patternSegments, pathSegments[:len(patternSegments)]) +} + +// PlatformsInclude reports whether platforms contains platform. +func PlatformsInclude(platforms []string, platform string) bool { + for _, candidate := range platforms { + if candidate == platform { + return true + } + } + return false +} + +func wildcardPrefixAbsent(pattern string, paths []string) bool { + patternSegments := splitPath(pattern) + wildcard := -1 + for i, segment := range patternSegments { + if segment == "*" { + wildcard = i + } + } + if wildcard == -1 { + return false + } + if wildcard == len(patternSegments)-1 { + return false + } + + for _, path := range paths { + pathSegments := splitPath(path) + if len(pathSegments) > wildcard && matchSegments(patternSegments[:wildcard], pathSegments[:wildcard]) { + return false + } + } + return true +} + +func matchSegments(pattern []string, segments []string) bool { + if len(pattern) != len(segments) { + return false + } + for i, part := range pattern { + if part != "*" && part != segments[i] { + return false + } + } + return true +} + +func joinPath(prefix, key string) string { + key = escapeSegment(key) + if prefix == "" { + return key + } + return prefix + "." + key +} + +func escapeSegment(segment string) string { + segment = strings.ReplaceAll(segment, `\`, `\\`) + return strings.ReplaceAll(segment, `.`, `\.`) +} + +func splitPath(path string) []string { + var segments []string + var segment strings.Builder + escaped := false + for _, r := range path { + switch { + case escaped: + segment.WriteRune(r) + escaped = false + case r == '\\': + escaped = true + case r == '.': + segments = append(segments, segment.String()) + segment.Reset() + default: + segment.WriteRune(r) + } + } + if escaped { + segment.WriteByte('\\') + } + return append(segments, segment.String()) +} diff --git a/internal/schema/schema_test.go b/internal/schema/schema_test.go new file mode 100644 index 00000000..10782968 --- /dev/null +++ b/internal/schema/schema_test.go @@ -0,0 +1,306 @@ +package schema + +import ( + "reflect" + "strings" + "testing" + + targets "github.com/ncode/facts/internal/platform" +) + +func TestMatchesPath(t *testing.T) { + tests := []struct { + name string + pattern string + entry Entry + path string + want bool + }{ + { + name: "exact path", + pattern: "kernel.name", + entry: Entry{Type: "string"}, + path: "kernel.name", + want: true, + }, + { + name: "wildcard matches one segment", + pattern: "networking.interfaces.*.mtu", + entry: Entry{Type: "integer"}, + path: "networking.interfaces.en0.mtu", + want: true, + }, + { + name: "wildcard does not match multiple segments", + pattern: "networking.interfaces.*.mtu", + entry: Entry{Type: "integer"}, + path: "networking.interfaces.en0.alias.mtu", + want: false, + }, + { + name: "documented dynamic child", + pattern: "disks.*.serial_number", + entry: Entry{Type: "string"}, + path: "disks.sda.serial_number", + want: true, + }, + { + name: "dynamic map is not open", + pattern: "disks.*", + entry: Entry{Type: "map"}, + path: "disks.sda.serial", + want: false, + }, + { + name: "array entry covers flattened array items", + pattern: "path", + entry: Entry{Type: "array"}, + path: "path.*", + want: true, + }, + { + name: "explicit open subtree", + pattern: "system_profiler", + entry: Entry{Type: "map", OpenSubtree: true}, + path: "system_profiler.hardware.model_name", + want: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := MatchesPath(tt.pattern, tt.entry, tt.path); got != tt.want { + t.Fatalf("MatchesPath(%q, %#v, %q) = %v, want %v", tt.pattern, tt.entry, tt.path, got, tt.want) + } + }) + } +} + +func TestSchemaUndocumentedPathsRejectsUnknownDynamicChildren(t *testing.T) { + s := Schema{ + "disks.*": { + Type: "map", + Description: "disk", + Platforms: []string{"linux"}, + }, + "disks.*.serial_number": { + Type: "string", + Description: "serial number", + Platforms: []string{"linux"}, + }, + } + + got := s.UndocumentedPaths([]string{ + "disks.sda.serial", + "disks.sda.serial_number", + }, "linux") + want := []string{"disks.sda.serial"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("UndocumentedPaths() = %#v, want %#v", got, want) + } +} + +func TestFlattenTree(t *testing.T) { + tree := map[string]any{ + "filesystems": []string{"apfs", "devfs"}, + "kernel": map[string]any{ + "name": "Darwin", + "release": map[string]any{}, + }, + } + + got := FlattenTree(tree) + want := []string{"filesystems.*", "kernel.name", "kernel.release"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("FlattenTree() = %#v, want %#v", got, want) + } +} + +func TestFlattenTreeEscapesDottedKeys(t *testing.T) { + tree := map[string]any{ + "networking": map[string]any{ + "interfaces": map[string]any{ + "eth0.100": map[string]any{ + "mtu": 1500, + }, + }, + }, + } + + paths := FlattenTree(tree) + want := []string{`networking.interfaces.eth0\.100.mtu`} + if !reflect.DeepEqual(paths, want) { + t.Fatalf("FlattenTree() = %#v, want %#v", paths, want) + } + + s := Schema{ + "networking.interfaces.*.mtu": { + Type: "integer", + Description: "interface MTU", + Platforms: []string{"linux"}, + }, + } + if got := s.UndocumentedPaths(paths, "linux"); len(got) != 0 { + t.Fatalf("UndocumentedPaths() = %#v, want none", got) + } +} + +func TestSchemaUndocumentedPathsAcceptsOpenSubtree(t *testing.T) { + s := Schema{ + "system_profiler": { + Type: "map", + Description: "system profiler", + Platforms: []string{"darwin"}, + OpenSubtree: true, + }, + } + + got := s.UndocumentedPaths([]string{"system_profiler.hardware.model_name"}, "darwin") + if len(got) != 0 { + t.Fatalf("UndocumentedPaths() = %#v, want none", got) + } +} + +func TestSchemaMissingEntriesSkipsAbsentWildcardCollection(t *testing.T) { + s := Schema{ + "mountpoints.*.size": { + Type: "string", + Description: "mount size", + Platforms: []string{"linux"}, + }, + } + + got := s.MissingEntries([]string{"kernel.name"}, "linux") + if len(got) != 0 { + t.Fatalf("MissingEntries() = %#v, want none", got) + } +} + +func TestSchemaMissingEntriesRequiresWildcardCollectionRoot(t *testing.T) { + s := Schema{ + "mountpoints.*": { + Type: "map", + Description: "mountpoint", + Platforms: []string{"linux"}, + }, + } + + got := s.MissingEntries([]string{"kernel.name"}, "linux") + want := []string{"mountpoints.*"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("MissingEntries() = %#v, want %#v", got, want) + } +} + +func TestSchemaMissingEntriesRequiresWildcardChildWhenCollectionExists(t *testing.T) { + s := Schema{ + "mountpoints.*.size": { + Type: "string", + Description: "mount size", + Platforms: []string{"linux"}, + }, + } + + got := s.MissingEntries([]string{"mountpoints.root.device"}, "linux") + want := []string{"mountpoints.*.size"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("MissingEntries() = %#v, want %#v", got, want) + } +} + +func TestSchemaMissingEntriesSkipsAbsentNestedWildcardCollection(t *testing.T) { + s := Schema{ + "a.*.b.*.c": { + Type: "string", + Description: "nested child", + Platforms: []string{"linux"}, + }, + } + + got := s.MissingEntries([]string{"a.one.name"}, "linux") + if len(got) != 0 { + t.Fatalf("MissingEntries() = %#v, want none", got) + } +} + +func TestSchemaMissingEntriesRequiresNestedWildcardChildWhenCollectionExists(t *testing.T) { + s := Schema{ + "a.*.b.*.c": { + Type: "string", + Description: "nested child", + Platforms: []string{"linux"}, + }, + } + + got := s.MissingEntries([]string{"a.one.b.two.name"}, "linux") + want := []string{"a.*.b.*.c"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("MissingEntries() = %#v, want %#v", got, want) + } +} + +func TestValidateRejectsUnknownPlatform(t *testing.T) { + s := Schema{ + "kernel.name": { + Type: "string", + Description: "kernel name", + Platforms: []string{"linux", "solaris"}, + }, + } + + err := s.Validate() + if err == nil || !strings.Contains(err.Error(), `invalid platform "solaris"`) { + t.Fatalf("Validate() error = %v, want invalid platform", err) + } +} + +func TestValidateRejectsScalarOpenSubtree(t *testing.T) { + s := Schema{ + "kernel.name": { + Type: "string", + Description: "kernel name", + Platforms: []string{"linux"}, + OpenSubtree: true, + }, + } + + err := s.Validate() + if err == nil || !strings.Contains(err.Error(), "open_subtree requires type map or array") { + t.Fatalf("Validate() error = %v, want open_subtree type error", err) + } +} + +func TestParseRejectsMultipleYAMLDocuments(t *testing.T) { + data := []byte(` +kernel.name: + type: string + description: Kernel name. + platforms: [linux] +--- +kernel.release: + type: string + description: Kernel release. + platforms: [linux] +`) + + _, err := Parse(data) + if err == nil || !strings.Contains(err.Error(), "multiple YAML documents") { + t.Fatalf("Parse() error = %v, want multiple YAML documents", err) + } +} + +func TestPlatformsUseTargetProfileVocabulary(t *testing.T) { + got := make([]string, 0, len(Platforms())) + for _, platform := range Platforms() { + got = append(got, platform.ID) + } + + want := make([]string, 0, len(targets.SchemaVisibleProfiles())) + for _, profile := range targets.SchemaVisibleProfiles() { + want = append(want, profile.ID) + } + + if !reflect.DeepEqual(got, want) { + t.Fatalf("Platforms() IDs = %#v, want target profile schema IDs %#v", got, want) + } +} diff --git a/man/man8/facts.8 b/man/man8/facts.8 index 9e82aaad..2ed8674e 100644 --- a/man/man8/facts.8 +++ b/man/man8/facts.8 @@ -54,6 +54,12 @@ Enable debug output\. A directory to use for external facts\. . .TP +\fB\-\-force\-dot\-resolution\fR: +. +.IP +Merge dotted facts into structured facts\. +. +.TP \fB\-\-hocon\fR: . .IP diff --git a/openspec/changes/archive/2026-06-21-deepen-facts-internal-contracts/.openspec.yaml b/openspec/changes/archive/2026-06-21-deepen-facts-internal-contracts/.openspec.yaml new file mode 100644 index 00000000..6c351aca --- /dev/null +++ b/openspec/changes/archive/2026-06-21-deepen-facts-internal-contracts/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-06-21 diff --git a/openspec/changes/archive/2026-06-21-deepen-facts-internal-contracts/design.md b/openspec/changes/archive/2026-06-21-deepen-facts-internal-contracts/design.md new file mode 100644 index 00000000..a17ed671 --- /dev/null +++ b/openspec/changes/archive/2026-06-21-deepen-facts-internal-contracts/design.md @@ -0,0 +1,78 @@ +## Context + +Facts already has the right high-level boundaries: the public library API stays idiomatic Go, the `facts` CLI is the compatibility surface, and the canonical structured tree is the only fact surface. The current friction is inside those boundaries. + +Four internal contracts are doing more work than their code shape admits: + +- Schema semantics live in both `schema_test.go` and `tools/supportedfacts`. +- CLI option metadata lives across validation, app runtime, help/man text, and installed docs. +- Platform target vocabulary lives across schema, docs generation, Makefile targets, CI, and category policy. +- Session owns run-scoped discovery state, but category modules still call host/runtime APIs directly in places. + +## Goals / Non-Goals + +**Goals:** + +- Give schema loading, validation, path matching, and platform inclusion one internal implementation. +- Give `facts` CLI option metadata one small internal source used by validation and documentation checks. +- Give platform target identity and coarse capability policy one internal profile table. +- Keep `Session` as the run-scoped discovery module while extending the host probe seam only where it improves testability. +- Preserve current fact output except for stricter schema failures and corrected CLI documentation. + +**Non-Goals:** + +- No public Facts API changes. +- No new CLI framework. +- No resolver registry. +- No GOOS-suffixed resolver split; ADR-0010 category organization remains. +- No attempt to fake native platform gates with unit tests. + +## Decisions + +### Use one change with four small workstreams + +The four candidates share the same problem: shallow internal contracts. Keeping them in one proposal lets the design preserve consistent boundaries while implementation can still land as small independent commits. + +Alternative considered: four separate changes. That adds process overhead and repeats the same context in each proposal. + +### Add an internal schema contract first + +Create an internal schema package that owns entry loading, platform vocabulary, validation, tree flattening, and schema path matching. `schema_test.go` and `tools/supportedfacts` should become adapters over it. + +The matching rule should distinguish dynamic keyed maps from explicitly open subtrees. Entries such as `disks.*` and `mountpoints.*` are not blanket approval for arbitrary descendants; open provider-shaped metadata subtrees can remain open when explicitly marked. + +Alternative considered: move only the duplicated `schemaEntry` struct. That is cleanup, not depth. + +### Keep CLI option work as metadata, not a framework + +Centralize option names, aliases, arity, repeatability, task flags, conflicts, and documentation rows in `internal/cli`. Keep `flag.FlagSet` and app execution logic in place. + +Alternative considered: generate the entire CLI parser/help/man output. That is unnecessary for current drift. + +### Make platform profile policy-only + +Add a target profile keyed by GOOS for identity, labels, support tier, schema visibility, compile/dist target membership, and coarse capability policy. Category modules still own resolver implementation and parsing. + +Alternative considered: use the profile as a resolver registry. That would fight ADR-0010 and make category code harder to reason about. + +### Extend Session's host seam only where needed + +Do not split Session's memoized discovery state. Add host seam operations only when a category currently reaches around Session for host/runtime data and tests need injection. The first useful slice is disks because it uses commands, files, stats, directory reads, globbing, and platform selection. + +Alternative considered: a new probe module for every category. That risks shallow wrappers around `os` and `runtime`. + +## Risks / Trade-offs + +- Stricter schema matching may expose undocumented emitted leaves -> treat failures as contract signal and update schema or resolver output deliberately. +- Platform profile can become a dumping ground -> keep it to target policy and coarse capabilities, not parser bodies. +- CLI option metadata can mirror `flag` poorly -> centralize only vocabulary and docs metadata, not parsing mechanics. +- Host seam expansion can become a wrapper for every standard library call -> add methods only when they remove direct host access from fact resolution paths and improve fixture tests. + +## Migration Plan + +1. Land schema contract extraction and stricter matching with tests. +2. Land CLI option metadata and documentation drift checks. +3. Land platform profile for vocabulary and low-risk identity/capability policy. +4. Land the narrow Session host seam slice, starting with disk/mount/partition probes. +5. Run local `go test ./...`, `go vet ./...`, and docs drift checks after each slice. +6. Use facts-lab native gates only for slices that change platform probe behavior or target policy. diff --git a/openspec/changes/archive/2026-06-21-deepen-facts-internal-contracts/proposal.md b/openspec/changes/archive/2026-06-21-deepen-facts-internal-contracts/proposal.md new file mode 100644 index 00000000..460baef3 --- /dev/null +++ b/openspec/changes/archive/2026-06-21-deepen-facts-internal-contracts/proposal.md @@ -0,0 +1,34 @@ +## Why + +Facts has several shallow internal contracts that now cost us real time: schema interpretation is duplicated, platform vocabulary is repeated across tests/docs/build tooling, CLI option metadata has already drifted, and host probe behavior still leaks through category modules. + +This change deepens those contracts without changing the public Facts library API or the canonical structured fact tree. + +## What Changes + +- Add one internal schema contract used by schema conformance and supported-facts generation. +- Tighten schema matching so dynamic keyed maps do not hide undocumented emitted leaves unless the schema explicitly marks an open subtree. +- Add one internal platform target profile for target identity, support tier, platform vocabulary, and coarse capability policy. +- Keep category-oriented resolver modules intact; do not introduce GOOS-suffixed resolver splits. +- Keep `Session` as the discovery-run module, but extend the host probe seam where category modules currently call host/runtime APIs directly. +- Add one small CLI option contract so validation, help/man text, and runtime option handling share the same supported option vocabulary. +- Fix known CLI option documentation drift, including `--force-dot-resolution`. + +## Capabilities + +### New Capabilities + +- `facts-cli-option-contract`: Covers consistency between accepted `facts` CLI options, validation, help/man surfaces, and option metadata used by the app runner. + +### Modified Capabilities + +- `facts-schema`: Schema conformance and supported-facts docs must use the same schema semantics, and dynamic keyed maps must not mask undocumented fact leaves. +- `go-port-supported-platform-facts`: Host I/O and platform capability policy must remain testable through the run-scoped Session/profile seams while preserving category-oriented fact assembly. +- `go-port-ci-platform-gates`: Platform target vocabulary used by build, distribution, schema, and native gates must stay aligned while preserving the distinction between compile targets, distribution targets, and validated release targets. + +## Impact + +- Affected code: `schema_test.go`, `tools/supportedfacts`, `internal/cli`, `internal/app`, `internal/engine/session.go`, selected `internal/engine` category modules, platform/release gate helpers, and generated supported-facts docs. +- No new runtime dependency is planned. +- No public Facts API change is planned. +- User-visible output should remain stable except for corrected help/man documentation and stricter failures for undocumented or overclaimed schema entries. diff --git a/openspec/changes/archive/2026-06-21-deepen-facts-internal-contracts/specs/facts-cli-option-contract/spec.md b/openspec/changes/archive/2026-06-21-deepen-facts-internal-contracts/specs/facts-cli-option-contract/spec.md new file mode 100644 index 00000000..5e220312 --- /dev/null +++ b/openspec/changes/archive/2026-06-21-deepen-facts-internal-contracts/specs/facts-cli-option-contract/spec.md @@ -0,0 +1,35 @@ +## ADDED Requirements + +### Requirement: CLI option vocabulary is shared + +The `facts` CLI SHALL use one supported option vocabulary for validation, option metadata, help output, man output, and installed man page content. + +#### Scenario: Accepted options are documented + +- **WHEN** an option is accepted by `facts` validation and runtime handling +- **THEN** the option MUST be listed in generated help/man documentation unless it is explicitly marked hidden +- **AND** `--force-dot-resolution` MUST be documented while it remains accepted + +#### Scenario: Unsupported options remain rejected + +- **WHEN** validation reads an option outside the shared supported option vocabulary +- **THEN** validation MUST reject it before runtime execution + +### Requirement: CLI option metadata preserves parser behavior + +The shared CLI option metadata SHALL describe canonical names, aliases, value arity, repeatability, task flags, and conflicts without replacing the existing parser. + +#### Scenario: Short aliases canonicalize consistently + +- **WHEN** validation processes grouped short options such as `-jdtz` +- **THEN** each short alias MUST map to the same canonical option used by runtime handling + +#### Scenario: Repeated and valued options are recognized consistently + +- **WHEN** validation, group-listing logic, config path discovery, or external-dir discovery needs to know whether an option takes a value or can repeat +- **THEN** each caller MUST receive the same answer from the shared option metadata + +#### Scenario: Documentation drift fails tests + +- **WHEN** help text, man text, or the installed man page omits a non-hidden supported option +- **THEN** the CLI option contract tests MUST fail diff --git a/openspec/changes/archive/2026-06-21-deepen-facts-internal-contracts/specs/facts-schema/spec.md b/openspec/changes/archive/2026-06-21-deepen-facts-internal-contracts/specs/facts-schema/spec.md new file mode 100644 index 00000000..ad50ffec --- /dev/null +++ b/openspec/changes/archive/2026-06-21-deepen-facts-internal-contracts/specs/facts-schema/spec.md @@ -0,0 +1,35 @@ +## ADDED Requirements + +### Requirement: Schema semantics are shared + +Facts SHALL use one internal schema contract for loading, validating, matching, and reporting schema entries used by conformance tests and supported-facts documentation. + +#### Scenario: Conformance and docs use the same schema entries + +- **WHEN** schema conformance and supported-facts generation read `docs/schema/facts.yaml` +- **THEN** both MUST use the same parsed entry model, platform vocabulary, conditional handling, and path matching semantics + +#### Scenario: Unknown platforms fail consistently + +- **WHEN** a schema entry lists an unknown platform +- **THEN** schema validation MUST fail for both conformance and documentation generation + +### Requirement: Dynamic keyed schema paths are not open subtrees + +Facts SHALL require emitted fact leaves under dynamic keyed maps to match documented child paths unless the schema explicitly marks the entry as an open subtree. + +#### Scenario: Undocumented dynamic child fails + +- **WHEN** discovery emits a leaf such as `disks.sda.serial` +- **AND** the schema only documents `disks.*` and `disks.*.serial_number` +- **THEN** schema conformance MUST report `disks.sda.serial` as undocumented + +#### Scenario: Documented dynamic child passes + +- **WHEN** discovery emits a leaf matching a documented dynamic child path such as `disks.sda.serial_number` +- **THEN** schema conformance MUST treat it as documented by `disks.*.serial_number` + +#### Scenario: Explicit open subtree remains open + +- **WHEN** discovery emits provider-shaped metadata under a schema entry explicitly marked as an open subtree +- **THEN** schema conformance MAY accept arbitrary descendants under that subtree diff --git a/openspec/changes/archive/2026-06-21-deepen-facts-internal-contracts/specs/go-port-ci-platform-gates/spec.md b/openspec/changes/archive/2026-06-21-deepen-facts-internal-contracts/specs/go-port-ci-platform-gates/spec.md new file mode 100644 index 00000000..a242d007 --- /dev/null +++ b/openspec/changes/archive/2026-06-21-deepen-facts-internal-contracts/specs/go-port-ci-platform-gates/spec.md @@ -0,0 +1,34 @@ +## ADDED Requirements + +### Requirement: Platform target vocabulary is shared + +Facts SHALL use one internal platform target vocabulary for schema-visible platform names, supported-facts generation, build target metadata, distribution target metadata, and native gate metadata. + +#### Scenario: Target sets remain distinct + +- **WHEN** maintainers inspect platform target metadata +- **THEN** compile targets, distribution targets, schema-visible platforms, and native validation gates MUST be represented as distinct target sets + +#### Scenario: Unsupported platform names remain excluded + +- **WHEN** platform target metadata is validated +- **THEN** unsupported names such as `solaris` and `aix` MUST remain excluded unless a later OpenSpec change promotes them + +#### Scenario: Schema and docs use the same platform vocabulary + +- **WHEN** supported-facts documentation is generated from the schema +- **THEN** the platform names accepted by schema validation MUST match the platform names used by supported-facts generation + +### Requirement: Native gates align with target policy + +Facts SHALL keep lab-backed and CI-backed native gates aligned with platform target policy without storing lab-specific secrets or host details in tracked files. + +#### Scenario: Gate fact sets follow target policy + +- **WHEN** a native gate validates a target with intentionally absent fact groups +- **THEN** the gate MUST validate the target's supported fact set and MUST NOT require facts marked inapplicable by target policy + +#### Scenario: Local and CI gates use supported target names + +- **WHEN** local or CI gate scripts select a platform target +- **THEN** they MUST use a target name present in the shared platform target vocabulary diff --git a/openspec/changes/archive/2026-06-21-deepen-facts-internal-contracts/specs/go-port-supported-platform-facts/spec.md b/openspec/changes/archive/2026-06-21-deepen-facts-internal-contracts/specs/go-port-supported-platform-facts/spec.md new file mode 100644 index 00000000..f372c46c --- /dev/null +++ b/openspec/changes/archive/2026-06-21-deepen-facts-internal-contracts/specs/go-port-supported-platform-facts/spec.md @@ -0,0 +1,29 @@ +## ADDED Requirements + +### Requirement: Host probes remain Session-injectable + +Facts SHALL keep host I/O used for platform fact discovery reachable through the run-scoped Session seam so category behavior can be tested with injected native source data. + +#### Scenario: Disk probes are injectable + +- **WHEN** disk, partition, or mountpoint facts need command output, file reads, stat data, directory reads, glob matches, or platform identity +- **THEN** tests MUST be able to provide those inputs without reading the developer host directly + +#### Scenario: Session command behavior is preserved + +- **WHEN** a fact resolver executes a platform command through the Session host seam +- **THEN** command timeout, context cancellation, logging, and sanitized environment behavior MUST remain consistent with current Session command execution + +### Requirement: Platform capability policy is explicit + +Facts SHALL keep coarse platform capability policy explicit while preserving category-oriented resolver modules. + +#### Scenario: Not-applicable fact groups are omitted by policy + +- **WHEN** a target profile marks a fact group as inapplicable for the current platform +- **THEN** the relevant category module MUST omit that fact group rather than emitting empty placeholder values + +#### Scenario: Category modules own resolver implementation + +- **WHEN** platform capability policy is added or changed +- **THEN** parser and resolver bodies MUST remain in the relevant category modules rather than moving into a platform registry diff --git a/openspec/changes/archive/2026-06-21-deepen-facts-internal-contracts/tasks.md b/openspec/changes/archive/2026-06-21-deepen-facts-internal-contracts/tasks.md new file mode 100644 index 00000000..90d472fe --- /dev/null +++ b/openspec/changes/archive/2026-06-21-deepen-facts-internal-contracts/tasks.md @@ -0,0 +1,40 @@ +## 1. Schema Contract + +- [x] 1.1 Add an internal schema contract package for loading `docs/schema/facts.yaml`, validating entries, flattening fact trees, and matching schema paths. +- [x] 1.2 Move schema platform vocabulary and conditional/open-subtree validation into the shared schema contract. +- [x] 1.3 Update `schema_test.go` to use the shared schema contract instead of local schema parsing and matching logic. +- [x] 1.4 Update `tools/supportedfacts` to use the same parsed schema entries and platform vocabulary. +- [x] 1.5 Add tests for exact paths, one-segment `*` matches, documented dynamic children, unknown dynamic children, and explicit open subtrees. +- [x] 1.6 Regenerate supported-facts docs and verify generated docs are current. + +## 2. CLI Option Contract + +- [x] 2.1 Add shared CLI option metadata for canonical names, aliases, arity, repeatability, task flags, conflicts, and documentation rows. +- [x] 2.2 Update CLI validation and argument helpers to read option metadata from the shared contract. +- [x] 2.3 Update app helper paths that inspect raw args, including config path discovery, external dir discovery, and group-listing value handling. +- [x] 2.4 Add `--force-dot-resolution` to help/man surfaces while it remains accepted. +- [x] 2.5 Add drift tests proving accepted non-hidden options appear in help/man output and the installed man page. + +## 3. Platform Target Profile + +- [x] 3.1 Add an internal platform target profile table keyed by GOOS with ID, label, support tier, schema visibility, compile targets, distribution targets, gate metadata, and coarse capability policy. +- [x] 3.2 Replace duplicated schema/docs platform vocabulary with the target profile source. +- [x] 3.3 Replace low-risk OS identity helpers with target profile data while preserving current `os`, `kernel`, and release facts. +- [x] 3.4 Wire low-risk capability gates for filesystems, ZFS, and Plan 9 intentionally absent release facts. +- [x] 3.5 Add tests for target IDs, target set separation, excluded `solaris`/`aix`, OS identity, and schema/docs vocabulary alignment. + +## 4. Session Host Probe Seam + +- [x] 4.1 Extend the Session host seam only for direct host/runtime calls needed by disk, partition, and mountpoint resolution. +- [x] 4.2 Route disk, partition, and mountpoint resolution through injectable host seam operations for platform identity, directory reads, globbing, command output, file reads, and stat data. +- [x] 4.3 Preserve existing command timeout, context cancellation, logging, and sanitized environment behavior. +- [x] 4.4 Add fake-host tests proving disk, partition, and mountpoint facts do not read the developer host directly. +- [x] 4.5 Add regression tests proving canonical disk, partition, and mountpoint output is unchanged. + +## 5. Verification + +- [x] 5.1 Run `gofmt -w` on edited Go files. +- [x] 5.2 Run `go test ./...`. +- [x] 5.3 Run `go vet ./...`. +- [x] 5.4 Run native facts-lab gates only for slices that change platform probe behavior or target policy. +- [x] 5.5 Confirm the OpenSpec status reports this change apply-ready. diff --git a/openspec/specs/facts-cli-option-contract/spec.md b/openspec/specs/facts-cli-option-contract/spec.md new file mode 100644 index 00000000..4b19dc56 --- /dev/null +++ b/openspec/specs/facts-cli-option-contract/spec.md @@ -0,0 +1,38 @@ +# facts-cli-option-contract Specification + +## Purpose +Define the internal contract that keeps accepted `facts` CLI options, parser metadata, help output, man output, and installed documentation in sync. +## Requirements +### Requirement: CLI option vocabulary is shared + +The `facts` CLI SHALL use one supported option vocabulary for validation, option metadata, help output, man output, and installed man page content. + +#### Scenario: Accepted options are documented + +- **WHEN** an option is accepted by `facts` validation and runtime handling +- **THEN** the option MUST be listed in generated help/man documentation unless it is explicitly marked hidden +- **AND** `--force-dot-resolution` MUST be documented while it remains accepted + +#### Scenario: Unsupported options remain rejected + +- **WHEN** validation reads an option outside the shared supported option vocabulary +- **THEN** validation MUST reject it before runtime execution + +### Requirement: CLI option metadata preserves parser behavior + +The shared CLI option metadata SHALL describe canonical names, aliases, value arity, repeatability, task flags, and conflicts without replacing the existing parser. + +#### Scenario: Short aliases canonicalize consistently + +- **WHEN** validation processes grouped short options such as `-jdtz` +- **THEN** each short alias MUST map to the same canonical option used by runtime handling + +#### Scenario: Repeated and valued options are recognized consistently + +- **WHEN** validation, group-listing logic, config path discovery, or external-dir discovery needs to know whether an option takes a value or can repeat +- **THEN** each caller MUST receive the same answer from the shared option metadata + +#### Scenario: Documentation drift fails tests + +- **WHEN** help text, man text, or the installed man page omits a non-hidden supported option +- **THEN** the CLI option contract tests MUST fail diff --git a/openspec/specs/facts-schema/spec.md b/openspec/specs/facts-schema/spec.md index bc7abc17..daccf2d4 100644 --- a/openspec/specs/facts-schema/spec.md +++ b/openspec/specs/facts-schema/spec.md @@ -206,3 +206,37 @@ Facts SHALL document collection-valued facts as arrays, not as delimiter-separat - **AND** the pages MUST show `filesystems` and `path` as arrays - **AND** the pages MUST NOT list the removed flat facts +### Requirement: Schema semantics are shared + +Facts SHALL use one internal schema contract for loading, validating, matching, and reporting schema entries used by conformance tests and supported-facts documentation. + +#### Scenario: Conformance and docs use the same schema entries + +- **WHEN** schema conformance and supported-facts generation read `docs/schema/facts.yaml` +- **THEN** both MUST use the same parsed entry model, platform vocabulary, conditional handling, and path matching semantics + +#### Scenario: Unknown platforms fail consistently + +- **WHEN** a schema entry lists an unknown platform +- **THEN** schema validation MUST fail for both conformance and documentation generation + +### Requirement: Dynamic keyed schema paths are not open subtrees + +Facts SHALL require emitted fact leaves under dynamic keyed maps to match documented child paths unless the schema explicitly marks the entry as an open subtree. + +#### Scenario: Undocumented dynamic child fails + +- **WHEN** discovery emits a leaf such as `disks.sda.serial` +- **AND** the schema only documents `disks.*` and `disks.*.serial_number` +- **THEN** schema conformance MUST report `disks.sda.serial` as undocumented + +#### Scenario: Documented dynamic child passes + +- **WHEN** discovery emits a leaf matching a documented dynamic child path such as `disks.sda.serial_number` +- **THEN** schema conformance MUST treat it as documented by `disks.*.serial_number` + +#### Scenario: Explicit open subtree remains open + +- **WHEN** discovery emits provider-shaped metadata under a schema entry explicitly marked as an open subtree +- **THEN** schema conformance MAY accept arbitrary descendants under that subtree + diff --git a/openspec/specs/go-port-ci-platform-gates/spec.md b/openspec/specs/go-port-ci-platform-gates/spec.md index 8096ff2a..3a39ee2d 100644 --- a/openspec/specs/go-port-ci-platform-gates/spec.md +++ b/openspec/specs/go-port-ci-platform-gates/spec.md @@ -133,3 +133,36 @@ The Go port SHALL run Go vulnerability analysis as a blocking CI check. - **WHEN** the Go checks workflow runs - **THEN** it MUST run the repository-pinned `govulncheck` tool against `./...`, and any reported vulnerability or scanner failure MUST fail the workflow +### Requirement: Platform target vocabulary is shared + +Facts SHALL use one internal platform target vocabulary for schema-visible platform names, supported-facts generation, build target metadata, distribution target metadata, and native gate metadata. + +#### Scenario: Target sets remain distinct + +- **WHEN** maintainers inspect platform target metadata +- **THEN** compile targets, distribution targets, schema-visible platforms, and native validation gates MUST be represented as distinct target sets + +#### Scenario: Unsupported platform names remain excluded + +- **WHEN** platform target metadata is validated +- **THEN** unsupported names such as `solaris` and `aix` MUST remain excluded unless a later OpenSpec change promotes them + +#### Scenario: Schema and docs use the same platform vocabulary + +- **WHEN** supported-facts documentation is generated from the schema +- **THEN** the platform names accepted by schema validation MUST match the platform names used by supported-facts generation + +### Requirement: Native gates align with target policy + +Facts SHALL keep lab-backed and CI-backed native gates aligned with platform target policy without storing lab-specific secrets or host details in tracked files. + +#### Scenario: Gate fact sets follow target policy + +- **WHEN** a native gate validates a target with intentionally absent fact groups +- **THEN** the gate MUST validate the target's supported fact set and MUST NOT require facts marked inapplicable by target policy + +#### Scenario: Local and CI gates use supported target names + +- **WHEN** local or CI gate scripts select a platform target +- **THEN** they MUST use a target name present in the shared platform target vocabulary + diff --git a/openspec/specs/go-port-supported-platform-facts/spec.md b/openspec/specs/go-port-supported-platform-facts/spec.md index a26a19ae..7c4be7ae 100644 --- a/openspec/specs/go-port-supported-platform-facts/spec.md +++ b/openspec/specs/go-port-supported-platform-facts/spec.md @@ -335,3 +335,31 @@ Collection facts SHALL be emitted as arrays rather than delimiter-separated stri - **AND** `zpool.version` MUST be the latest supported pool version string, or `5000` when feature flags are present - **AND** `zpool_featurenumbers`, `zpool_featureflags`, and `zpool_version` MUST be absent +### Requirement: Host probes remain Session-injectable + +Facts SHALL keep host I/O used for platform fact discovery reachable through the run-scoped Session seam so category behavior can be tested with injected native source data. + +#### Scenario: Disk probes are injectable + +- **WHEN** disk, partition, or mountpoint facts need command output, file reads, stat data, directory reads, glob matches, or platform identity +- **THEN** tests MUST be able to provide those inputs without reading the developer host directly + +#### Scenario: Session command behavior is preserved + +- **WHEN** a fact resolver executes a platform command through the Session host seam +- **THEN** command timeout, context cancellation, logging, and sanitized environment behavior MUST remain consistent with current Session command execution + +### Requirement: Platform capability policy is explicit + +Facts SHALL keep coarse platform capability policy explicit while preserving category-oriented resolver modules. + +#### Scenario: Not-applicable fact groups are omitted by policy + +- **WHEN** a target profile marks a fact group as inapplicable for the current platform +- **THEN** the relevant category module MUST omit that fact group rather than emitting empty placeholder values + +#### Scenario: Category modules own resolver implementation + +- **WHEN** platform capability policy is added or changed +- **THEN** parser and resolver bodies MUST remain in the relevant category modules rather than moving into a platform registry + diff --git a/schema_test.go b/schema_test.go index c8e059fb..ace7f2ec 100644 --- a/schema_test.go +++ b/schema_test.go @@ -16,223 +16,29 @@ package facts import ( "flag" "fmt" - "os" "path/filepath" - "reflect" "runtime" "sort" "strings" "testing" - "gopkg.in/yaml.v3" + factschema "github.com/ncode/facts/internal/schema" ) var schemaReport = flag.Bool("schema-report", false, "print undocumented fact paths grouped by top-level fact instead of failing") -const schemaPath = "docs/schema/facts.yaml" +const schemaPath = factschema.DefaultPath -// schemaEntry is one documented fact: a dotted path or `*` pattern mapped to -// its type, description, platform list, and conditional marker. -type schemaEntry struct { - Type string `yaml:"type"` - Description string `yaml:"description"` - Platforms []string `yaml:"platforms"` - Conditional bool `yaml:"conditional"` -} - -var schemaTypes = map[string]bool{ - "string": true, - "integer": true, - "double": true, - "boolean": true, - "map": true, - "array": true, -} - -var schemaPlatforms = map[string]bool{ - "linux": true, - "darwin": true, - "windows": true, - "freebsd": true, - "openbsd": true, - "netbsd": true, - "dragonfly": true, - "illumos": true, - "plan9": true, -} - -func loadSchema(t *testing.T) map[string]schemaEntry { +func loadSchema(t *testing.T) factschema.Schema { t.Helper() - data, err := os.ReadFile(filepath.FromSlash(schemaPath)) + schema, err := factschema.LoadFile(filepath.FromSlash(schemaPath)) if err != nil { - t.Fatalf("read schema: %v", err) - } - schema := map[string]schemaEntry{} - if err := yaml.Unmarshal(data, &schema); err != nil { - t.Fatalf("parse %s: %v", schemaPath, err) - } - if len(schema) == 0 { - t.Fatalf("%s has no entries", schemaPath) + t.Fatalf("load schema: %v", err) } return schema } -// validateSchema pins the schema file's own shape: every entry carries a -// known type, a description, and at least one valid platform. -func validateSchema(t *testing.T, schema map[string]schemaEntry) { - t.Helper() - for _, pattern := range sortedPatterns(schema) { - entry := schema[pattern] - if !schemaTypes[entry.Type] { - t.Errorf("%s: entry %q has invalid type %q", schemaPath, pattern, entry.Type) - } - if strings.TrimSpace(entry.Description) == "" { - t.Errorf("%s: entry %q has no description", schemaPath, pattern) - } - if len(entry.Platforms) == 0 { - t.Errorf("%s: entry %q lists no platforms", schemaPath, pattern) - } - seen := map[string]bool{} - for _, platform := range entry.Platforms { - if !schemaPlatforms[platform] { - t.Errorf("%s: entry %q lists invalid platform %q", schemaPath, pattern, platform) - } - if seen[platform] { - t.Errorf("%s: entry %q lists platform %q twice", schemaPath, pattern, platform) - } - seen[platform] = true - } - } -} - -// flattenTree reduces the canonical tree to sorted leaf paths: maps recurse -// with one segment per key (an empty map is itself a leaf), arrays contribute -// a single `path.*` without enumerating indices, and scalars (nil included) -// are leaves. -func flattenTree(tree map[string]any) []string { - leaves := make([]string, 0, 256) - var walk func(prefix string, value any) - walk = func(prefix string, value any) { - switch v := value.(type) { - case map[string]any: - if len(v) == 0 { - leaves = append(leaves, prefix) - return - } - for key, item := range v { - walk(prefix+"."+key, item) - } - default: - if value != nil && reflect.TypeOf(value).Kind() == reflect.Slice { - leaves = append(leaves, prefix+".*") - return - } - leaves = append(leaves, prefix) - } - } - for key, value := range tree { - walk(key, value) - } - sort.Strings(leaves) - return leaves -} - -// patternMatchesSegments reports whether the pattern matches exactly the -// given path segments, with `*` matching exactly one segment. -func patternMatchesSegments(pattern []string, segments []string) bool { - if len(pattern) != len(segments) { - return false - } - for i, part := range pattern { - if part != "*" && part != segments[i] { - return false - } - } - return true -} - -// entryMatchesPath reports whether a schema entry covers a leaf path: the -// pattern matches the whole path, or the entry is a map/array and its pattern -// matches a strict ancestor of the path (subtree coverage). -func entryMatchesPath(pattern string, entry schemaEntry, path string) bool { - patternSegments := strings.Split(pattern, ".") - pathSegments := strings.Split(path, ".") - if patternMatchesSegments(patternSegments, pathSegments) { - return true - } - if entry.Type != "map" && entry.Type != "array" { - return false - } - if len(patternSegments) >= len(pathSegments) { - return false - } - return patternMatchesSegments(patternSegments, pathSegments[:len(patternSegments)]) -} - -func platformsInclude(platforms []string, goos string) bool { - for _, platform := range platforms { - if platform == goos { - return true - } - } - return false -} - -func sortedPatterns(schema map[string]schemaEntry) []string { - patterns := make([]string, 0, len(schema)) - for pattern := range schema { - patterns = append(patterns, pattern) - } - sort.Strings(patterns) - return patterns -} - -// undocumentedPaths returns the emitted leaf paths no platform-applicable -// schema entry covers. -func undocumentedPaths(paths []string, schema map[string]schemaEntry, goos string) []string { - var unmatched []string - for _, path := range paths { - documented := false - for pattern, entry := range schema { - if !platformsInclude(entry.Platforms, goos) { - continue - } - if entryMatchesPath(pattern, entry, path) { - documented = true - break - } - } - if !documented { - unmatched = append(unmatched, path) - } - } - return unmatched -} - -// missingEntries returns the non-conditional schema entries for goos that no -// emitted path satisfies. -func missingEntries(paths []string, schema map[string]schemaEntry, goos string) []string { - var missing []string - for _, pattern := range sortedPatterns(schema) { - entry := schema[pattern] - if entry.Conditional || !platformsInclude(entry.Platforms, goos) { - continue - } - present := false - for _, path := range paths { - if entryMatchesPath(pattern, entry, path) { - present = true - break - } - } - if !present { - missing = append(missing, pattern) - } - } - return missing -} - func printSchemaReport(paths []string, undocumented []string) { if len(undocumented) == 0 { fmt.Printf("schema-report: all %d emitted leaf paths are documented in %s\n", len(paths), schemaPath) @@ -259,14 +65,13 @@ func printSchemaReport(paths []string, undocumented []string) { func TestFactsSchemaConformance(t *testing.T) { schema := loadSchema(t) - validateSchema(t, schema) - paths := flattenTree(hermeticSnapshot().Tree()) + paths := factschema.FlattenTree(hermeticSnapshot().Tree()) if len(paths) == 0 { t.Fatal("hermetic discovery emitted no facts") } - undocumented := undocumentedPaths(paths, schema, runtime.GOOS) + undocumented := schema.UndocumentedPaths(paths, runtime.GOOS) if *schemaReport { printSchemaReport(paths, undocumented) return @@ -280,7 +85,7 @@ func TestFactsSchemaConformance(t *testing.T) { // (b) No overclaimed facts: every non-conditional entry for this platform // is present in the discovery. - for _, pattern := range missingEntries(paths, schema, runtime.GOOS) { + for _, pattern := range schema.MissingEntries(paths, runtime.GOOS) { t.Errorf("schema entry %q lists platform %s but no discovered fact matches it: mark it `conditional: true` or fix its platforms", pattern, runtime.GOOS) } } diff --git a/tools/supportedfacts/main.go b/tools/supportedfacts/main.go index 65c61f77..82b757aa 100644 --- a/tools/supportedfacts/main.go +++ b/tools/supportedfacts/main.go @@ -6,37 +6,12 @@ import ( "fmt" "os" "path/filepath" - "sort" "strings" - "gopkg.in/yaml.v3" + factschema "github.com/ncode/facts/internal/schema" ) -const schemaPath = "docs/schema/facts.yaml" - -type schemaEntry struct { - Type string `yaml:"type"` - Description string `yaml:"description"` - Platforms []string `yaml:"platforms"` - Conditional bool `yaml:"conditional"` -} - -type platform struct { - ID string - Label string -} - -var platforms = []platform{ - {ID: "linux", Label: "Linux"}, - {ID: "darwin", Label: "macOS / Darwin"}, - {ID: "windows", Label: "Windows"}, - {ID: "freebsd", Label: "FreeBSD"}, - {ID: "openbsd", Label: "OpenBSD"}, - {ID: "netbsd", Label: "NetBSD"}, - {ID: "dragonfly", Label: "DragonFly BSD"}, - {ID: "illumos", Label: "illumos"}, - {ID: "plan9", Label: "Plan 9"}, -} +const schemaPath = factschema.DefaultPath func main() { docs, err := renderDocs(schemaPath) @@ -57,45 +32,33 @@ func main() { } func renderDocs(schemaFile string) (map[string]string, error) { - schema, err := loadSchema(schemaFile) + schema, err := factschema.LoadFile(schemaFile) if err != nil { return nil, err } docs := map[string]string{ "docs/supported-facts/README.md": renderIndex(schema), } - for _, p := range platforms { + for _, p := range factschema.Platforms() { docs["docs/supported-facts/"+p.ID+".md"] = renderPlatform(schema, p) } return docs, nil } -func loadSchema(path string) (map[string]schemaEntry, error) { - data, err := os.ReadFile(path) - if err != nil { - return nil, fmt.Errorf("read schema: %w", err) - } - schema := map[string]schemaEntry{} - if err := yaml.Unmarshal(data, &schema); err != nil { - return nil, fmt.Errorf("parse schema: %w", err) - } - return schema, nil -} - -func renderIndex(schema map[string]schemaEntry) string { +func renderIndex(schema factschema.Schema) string { var b strings.Builder writeHeader(&b, "Supported Facts") b.WriteString("These pages are generated from [`docs/schema/facts.yaml`](../schema/facts.yaml). ") b.WriteString("The examples are synthetic, platform-shaped output for documentation; the tables are the supported fact contract.\n\n") b.WriteString("| Platform | Supported facts |\n| --- | ---: |\n") - for _, p := range platforms { - fmt.Fprintf(&b, "| [%s](%s.md) | %d |\n", p.Label, p.ID, len(entriesFor(schema, p.ID))) + for _, p := range factschema.Platforms() { + fmt.Fprintf(&b, "| [%s](%s.md) | %d |\n", p.Label, p.ID, len(schema.EntriesForPlatform(p.ID))) } return b.String() } -func renderPlatform(schema map[string]schemaEntry, p platform) string { - entries := entriesFor(schema, p.ID) +func renderPlatform(schema factschema.Schema, p factschema.Platform) string { + entries := schema.EntriesForPlatform(p.ID) var b strings.Builder writeHeader(&b, p.Label+" Supported Facts") fmt.Fprintf(&b, "Generated from [`docs/schema/facts.yaml`](../schema/facts.yaml). `%s` entries may be absent on a host when their preconditions do not hold.\n\n", "conditional") @@ -104,14 +67,14 @@ func renderPlatform(schema map[string]schemaEntry, p platform) string { b.WriteString("| Fact | Type | Conditional | Description |\n| --- | --- | --- | --- |\n") for _, item := range entries { conditional := "no" - if item.entry.Conditional { + if item.Entry.Conditional { conditional = "yes" } fmt.Fprintf(&b, "| `%s` | `%s` | %s | %s |\n", - item.path, - item.entry.Type, + item.Path, + item.Entry.Type, conditional, - escapeMarkdown(item.entry.Description), + escapeMarkdown(item.Entry.Description), ) } return b.String() @@ -121,31 +84,6 @@ func writeHeader(b *strings.Builder, title string) { fmt.Fprintf(b, "\n\n# %s\n\n", title) } -type schemaItem struct { - path string - entry schemaEntry -} - -func entriesFor(schema map[string]schemaEntry, platform string) []schemaItem { - items := make([]schemaItem, 0, len(schema)) - for path, entry := range schema { - if includes(entry.Platforms, platform) { - items = append(items, schemaItem{path: path, entry: entry}) - } - } - sort.Slice(items, func(i, j int) bool { return items[i].path < items[j].path }) - return items -} - -func includes(values []string, want string) bool { - for _, value := range values { - if value == want { - return true - } - } - return false -} - func escapeMarkdown(s string) string { s = strings.ReplaceAll(s, `\`, `\\`) return strings.ReplaceAll(s, "|", `\|`) diff --git a/tools/supportedfacts/main_test.go b/tools/supportedfacts/main_test.go index 8e7506e3..1756c124 100644 --- a/tools/supportedfacts/main_test.go +++ b/tools/supportedfacts/main_test.go @@ -3,7 +3,10 @@ package main import ( "os" "path/filepath" + "reflect" "testing" + + factschema "github.com/ncode/facts/internal/schema" ) func TestGeneratedDocsAreCurrent(t *testing.T) { @@ -23,6 +26,28 @@ func TestGeneratedDocsAreCurrent(t *testing.T) { } } +func TestRenderedDocsUseSchemaPlatformVocabulary(t *testing.T) { + root := repoRoot(t) + docs, err := renderDocs(filepath.Join(root, "docs", "schema", "facts.yaml")) + if err != nil { + t.Fatal(err) + } + + want := map[string]bool{"docs/supported-facts/README.md": true} + for _, platform := range factschema.Platforms() { + want["docs/supported-facts/"+platform.ID+".md"] = true + } + + got := make(map[string]bool, len(docs)) + for path := range docs { + got[path] = true + } + + if !reflect.DeepEqual(got, want) { + t.Fatalf("renderDocs() paths = %#v, want schema platform docs %#v", got, want) + } +} + func repoRoot(t *testing.T) string { t.Helper() dir, err := os.Getwd()