diff --git a/CHANGELOG.md b/CHANGELOG.md index 71d7aaa4..e607a8bb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,17 @@ - Release artifacts and cross-compile CI now include `arm` and `arm64` for FreeBSD, OpenBSD, and NetBSD. +### Fixed + +- QEMU/KVM lab guests on OpenBSD, NetBSD, DragonFly BSD, illumos, and Windows + now report `virtual: "kvm"` and `is_virtual: true` when native DMI/SMBIOS, + PCI, or WMI indicators expose the VM. +- Full fact output now omits empty optional top-level networking strings, such + as illumos `networking.ip6`, `networking.netmask6`, `networking.network6`, + and `networking.scope6` when no primary IPv6 address is available. +- Mountpoint entries with no stat, device, filesystem, or option data are now + omitted instead of rendering empty maps such as `mountpoints."/": {}`. + ## v0.0.3 - 2026-06-18 ### Added diff --git a/docs/schema/facts.yaml b/docs/schema/facts.yaml index 49e6bc7c..a5e9b89d 100644 --- a/docs/schema/facts.yaml +++ b/docs/schema/facts.yaml @@ -435,6 +435,7 @@ mountpoints.*: type: map description: A mounted filesystem, keyed by mount path. platforms: [linux, darwin, windows, freebsd, openbsd, netbsd, dragonfly, illumos] + conditional: true mountpoints.*.available: type: string description: The display amount of free space on the mount, such as 1.00 GiB. @@ -590,6 +591,7 @@ networking.ip6: type: string description: The IPv6 address of the primary interface. platforms: [linux, darwin, windows, freebsd, openbsd, netbsd, dragonfly, illumos] + conditional: true networking.mac: type: string description: The MAC address of the primary interface. @@ -607,6 +609,7 @@ networking.netmask6: type: string description: The IPv6 netmask of the primary interface. platforms: [linux, darwin, windows, freebsd, openbsd, netbsd, dragonfly, illumos] + conditional: true networking.network: type: string description: The IPv4 network of the primary interface. @@ -615,6 +618,7 @@ networking.network6: type: string description: The IPv6 network of the primary interface. platforms: [linux, darwin, windows, freebsd, openbsd, netbsd, dragonfly, illumos] + conditional: true networking.primary: type: string description: The name of the primary interface. @@ -623,6 +627,7 @@ networking.scope6: type: string description: The IPv6 scope of the primary interface, such as global or link. platforms: [linux, darwin, windows, freebsd, openbsd, netbsd, dragonfly, illumos] + conditional: true os.architecture: type: string diff --git a/docs/supported-facts/darwin.md b/docs/supported-facts/darwin.md index 5bf06e75..db841e31 100644 --- a/docs/supported-facts/darwin.md +++ b/docs/supported-facts/darwin.md @@ -101,7 +101,7 @@ $ facts --json | `memory.system.total_bytes` | `integer` | no | The total physical memory, in bytes. | | `memory.system.used` | `string` | no | The display amount of physical memory in use, such as 1.00 GiB. | | `memory.system.used_bytes` | `integer` | no | The physical memory in use, in bytes. | -| `mountpoints.*` | `map` | no | A mounted filesystem, keyed by mount path. | +| `mountpoints.*` | `map` | yes | A mounted filesystem, keyed by mount path. | | `mountpoints.*.available` | `string` | no | The display amount of free space on the mount, such as 1.00 GiB. | | `mountpoints.*.available_bytes` | `integer` | no | The free space on the mount, in bytes. | | `mountpoints.*.capacity` | `string` | no | The percentage of the mount's space in use. | @@ -130,15 +130,15 @@ $ facts --json | `networking.interfaces.*.network6` | `string` | yes | The IPv6 network of the interface's first binding. | | `networking.interfaces.*.scope6` | `string` | yes | The IPv6 scope of the interface's first binding, such as global or link. | | `networking.ip` | `string` | no | The IPv4 address of the primary interface. | -| `networking.ip6` | `string` | no | The IPv6 address of the primary interface. | +| `networking.ip6` | `string` | yes | The IPv6 address of the primary interface. | | `networking.mac` | `string` | no | The MAC address of the primary interface. | | `networking.mtu` | `integer` | yes | The maximum transmission unit of the primary interface. | | `networking.netmask` | `string` | no | The IPv4 netmask of the primary interface. | -| `networking.netmask6` | `string` | no | The IPv6 netmask of the primary interface. | +| `networking.netmask6` | `string` | yes | The IPv6 netmask of the primary interface. | | `networking.network` | `string` | no | The IPv4 network of the primary interface. | -| `networking.network6` | `string` | no | The IPv6 network of the primary interface. | +| `networking.network6` | `string` | yes | The IPv6 network of the primary interface. | | `networking.primary` | `string` | no | The name of the primary interface. | -| `networking.scope6` | `string` | no | The IPv6 scope of the primary interface, such as global or link. | +| `networking.scope6` | `string` | yes | The IPv6 scope of the primary interface, such as global or link. | | `os.architecture` | `string` | no | The operating system's hardware architecture, such as x86_64 or arm64. | | `os.family` | `string` | no | The operating system family, such as Debian, RedHat, Darwin, or windows. | | `os.hardware` | `string` | no | The hardware model of the machine, such as x86_64. | diff --git a/docs/supported-facts/dragonfly.md b/docs/supported-facts/dragonfly.md index 2e827099..ba75593c 100644 --- a/docs/supported-facts/dragonfly.md +++ b/docs/supported-facts/dragonfly.md @@ -115,7 +115,7 @@ $ facts --json | `memory.system.total_bytes` | `integer` | no | The total physical memory, in bytes. | | `memory.system.used` | `string` | no | The display amount of physical memory in use, such as 1.00 GiB. | | `memory.system.used_bytes` | `integer` | no | The physical memory in use, in bytes. | -| `mountpoints.*` | `map` | no | A mounted filesystem, keyed by mount path. | +| `mountpoints.*` | `map` | yes | A mounted filesystem, keyed by mount path. | | `mountpoints.*.available` | `string` | no | The display amount of free space on the mount, such as 1.00 GiB. | | `mountpoints.*.available_bytes` | `integer` | no | The free space on the mount, in bytes. | | `mountpoints.*.capacity` | `string` | no | The percentage of the mount's space in use. | @@ -145,15 +145,15 @@ $ facts --json | `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.ip` | `string` | no | The IPv4 address of the primary interface. | -| `networking.ip6` | `string` | no | The IPv6 address of the primary interface. | +| `networking.ip6` | `string` | yes | The IPv6 address of the primary interface. | | `networking.mac` | `string` | no | The MAC address of the primary interface. | | `networking.mtu` | `integer` | yes | The maximum transmission unit of the primary interface. | | `networking.netmask` | `string` | no | The IPv4 netmask of the primary interface. | -| `networking.netmask6` | `string` | no | The IPv6 netmask of the primary interface. | +| `networking.netmask6` | `string` | yes | The IPv6 netmask of the primary interface. | | `networking.network` | `string` | no | The IPv4 network of the primary interface. | -| `networking.network6` | `string` | no | The IPv6 network of the primary interface. | +| `networking.network6` | `string` | yes | The IPv6 network of the primary interface. | | `networking.primary` | `string` | no | The name of the primary interface. | -| `networking.scope6` | `string` | no | The IPv6 scope of the primary interface, such as global or link. | +| `networking.scope6` | `string` | yes | The IPv6 scope of the primary interface, such as global or link. | | `os.architecture` | `string` | no | The operating system's hardware architecture, such as x86_64 or arm64. | | `os.family` | `string` | no | The operating system family, such as Debian, RedHat, Darwin, or windows. | | `os.hardware` | `string` | no | The hardware model of the machine, such as x86_64. | diff --git a/docs/supported-facts/freebsd.md b/docs/supported-facts/freebsd.md index c975e155..06c305ef 100644 --- a/docs/supported-facts/freebsd.md +++ b/docs/supported-facts/freebsd.md @@ -116,7 +116,7 @@ $ facts --json | `memory.system.total_bytes` | `integer` | no | The total physical memory, in bytes. | | `memory.system.used` | `string` | no | The display amount of physical memory in use, such as 1.00 GiB. | | `memory.system.used_bytes` | `integer` | no | The physical memory in use, in bytes. | -| `mountpoints.*` | `map` | no | A mounted filesystem, keyed by mount path. | +| `mountpoints.*` | `map` | yes | A mounted filesystem, keyed by mount path. | | `mountpoints.*.available` | `string` | no | The display amount of free space on the mount, such as 1.00 GiB. | | `mountpoints.*.available_bytes` | `integer` | no | The free space on the mount, in bytes. | | `mountpoints.*.capacity` | `string` | no | The percentage of the mount's space in use. | @@ -147,15 +147,15 @@ $ facts --json | `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.ip` | `string` | no | The IPv4 address of the primary interface. | -| `networking.ip6` | `string` | no | The IPv6 address of the primary interface. | +| `networking.ip6` | `string` | yes | The IPv6 address of the primary interface. | | `networking.mac` | `string` | no | The MAC address of the primary interface. | | `networking.mtu` | `integer` | yes | The maximum transmission unit of the primary interface. | | `networking.netmask` | `string` | no | The IPv4 netmask of the primary interface. | -| `networking.netmask6` | `string` | no | The IPv6 netmask of the primary interface. | +| `networking.netmask6` | `string` | yes | The IPv6 netmask of the primary interface. | | `networking.network` | `string` | no | The IPv4 network of the primary interface. | -| `networking.network6` | `string` | no | The IPv6 network of the primary interface. | +| `networking.network6` | `string` | yes | The IPv6 network of the primary interface. | | `networking.primary` | `string` | no | The name of the primary interface. | -| `networking.scope6` | `string` | no | The IPv6 scope of the primary interface, such as global or link. | +| `networking.scope6` | `string` | yes | The IPv6 scope of the primary interface, such as global or link. | | `os.architecture` | `string` | no | The operating system's hardware architecture, such as x86_64 or arm64. | | `os.family` | `string` | no | The operating system family, such as Debian, RedHat, Darwin, or windows. | | `os.hardware` | `string` | no | The hardware model of the machine, such as x86_64. | diff --git a/docs/supported-facts/illumos.md b/docs/supported-facts/illumos.md index 1cf3ee57..1ac0caf5 100644 --- a/docs/supported-facts/illumos.md +++ b/docs/supported-facts/illumos.md @@ -134,7 +134,7 @@ $ facts --json | `memory.system.total_bytes` | `integer` | no | The total physical memory, in bytes. | | `memory.system.used` | `string` | no | The display amount of physical memory in use, such as 1.00 GiB. | | `memory.system.used_bytes` | `integer` | no | The physical memory in use, in bytes. | -| `mountpoints.*` | `map` | no | A mounted filesystem, keyed by mount path. | +| `mountpoints.*` | `map` | yes | A mounted filesystem, keyed by mount path. | | `mountpoints.*.available` | `string` | no | The display amount of free space on the mount, such as 1.00 GiB. | | `mountpoints.*.available_bytes` | `integer` | no | The free space on the mount, in bytes. | | `mountpoints.*.capacity` | `string` | no | The percentage of the mount's space in use. | @@ -163,15 +163,15 @@ $ facts --json | `networking.interfaces.*.network6` | `string` | yes | The IPv6 network of the interface's first binding. | | `networking.interfaces.*.scope6` | `string` | yes | The IPv6 scope of the interface's first binding, such as global or link. | | `networking.ip` | `string` | no | The IPv4 address of the primary interface. | -| `networking.ip6` | `string` | no | The IPv6 address of the primary interface. | +| `networking.ip6` | `string` | yes | The IPv6 address of the primary interface. | | `networking.mac` | `string` | no | The MAC address of the primary interface. | | `networking.mtu` | `integer` | yes | The maximum transmission unit of the primary interface. | | `networking.netmask` | `string` | no | The IPv4 netmask of the primary interface. | -| `networking.netmask6` | `string` | no | The IPv6 netmask of the primary interface. | +| `networking.netmask6` | `string` | yes | The IPv6 netmask of the primary interface. | | `networking.network` | `string` | no | The IPv4 network of the primary interface. | -| `networking.network6` | `string` | no | The IPv6 network of the primary interface. | +| `networking.network6` | `string` | yes | The IPv6 network of the primary interface. | | `networking.primary` | `string` | no | The name of the primary interface. | -| `networking.scope6` | `string` | no | The IPv6 scope of the primary interface, such as global or link. | +| `networking.scope6` | `string` | yes | The IPv6 scope of the primary interface, such as global or link. | | `os.architecture` | `string` | no | The operating system's hardware architecture, such as x86_64 or arm64. | | `os.family` | `string` | no | The operating system family, such as Debian, RedHat, Darwin, or windows. | | `os.hardware` | `string` | no | The hardware model of the machine, such as x86_64. | diff --git a/docs/supported-facts/linux.md b/docs/supported-facts/linux.md index 877be293..77088ec2 100644 --- a/docs/supported-facts/linux.md +++ b/docs/supported-facts/linux.md @@ -155,7 +155,7 @@ $ facts --json | `memory.system.total_bytes` | `integer` | no | The total physical memory, in bytes. | | `memory.system.used` | `string` | no | The display amount of physical memory in use, such as 1.00 GiB. | | `memory.system.used_bytes` | `integer` | no | The physical memory in use, in bytes. | -| `mountpoints.*` | `map` | no | A mounted filesystem, keyed by mount path. | +| `mountpoints.*` | `map` | yes | A mounted filesystem, keyed by mount path. | | `mountpoints.*.available` | `string` | no | The display amount of free space on the mount, such as 1.00 GiB. | | `mountpoints.*.available_bytes` | `integer` | no | The free space on the mount, in bytes. | | `mountpoints.*.capacity` | `string` | no | The percentage of the mount's space in use. | @@ -188,15 +188,15 @@ $ facts --json | `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` | no | The IPv4 address of the primary interface. | -| `networking.ip6` | `string` | no | The IPv6 address of the primary interface. | +| `networking.ip6` | `string` | yes | The IPv6 address of the primary interface. | | `networking.mac` | `string` | no | The MAC address of the primary interface. | | `networking.mtu` | `integer` | yes | The maximum transmission unit of the primary interface. | | `networking.netmask` | `string` | no | The IPv4 netmask of the primary interface. | -| `networking.netmask6` | `string` | no | The IPv6 netmask of the primary interface. | +| `networking.netmask6` | `string` | yes | The IPv6 netmask of the primary interface. | | `networking.network` | `string` | no | The IPv4 network of the primary interface. | -| `networking.network6` | `string` | no | The IPv6 network of the primary interface. | +| `networking.network6` | `string` | yes | The IPv6 network of the primary interface. | | `networking.primary` | `string` | no | The name of the primary interface. | -| `networking.scope6` | `string` | no | The IPv6 scope of the primary interface, such as global or link. | +| `networking.scope6` | `string` | yes | The IPv6 scope of the primary interface, such as global or link. | | `os.architecture` | `string` | no | The operating system's hardware architecture, such as x86_64 or arm64. | | `os.distro.codename` | `string` | yes | The codename of the distribution release, such as noble. | | `os.distro.description` | `string` | no | The full description of the distribution release. | diff --git a/docs/supported-facts/netbsd.md b/docs/supported-facts/netbsd.md index ad39da10..d71f9029 100644 --- a/docs/supported-facts/netbsd.md +++ b/docs/supported-facts/netbsd.md @@ -131,7 +131,7 @@ $ facts --json | `memory.system.total_bytes` | `integer` | no | The total physical memory, in bytes. | | `memory.system.used` | `string` | no | The display amount of physical memory in use, such as 1.00 GiB. | | `memory.system.used_bytes` | `integer` | no | The physical memory in use, in bytes. | -| `mountpoints.*` | `map` | no | A mounted filesystem, keyed by mount path. | +| `mountpoints.*` | `map` | yes | A mounted filesystem, keyed by mount path. | | `mountpoints.*.available` | `string` | no | The display amount of free space on the mount, such as 1.00 GiB. | | `mountpoints.*.available_bytes` | `integer` | no | The free space on the mount, in bytes. | | `mountpoints.*.capacity` | `string` | no | The percentage of the mount's space in use. | @@ -159,15 +159,15 @@ $ facts --json | `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.ip` | `string` | no | The IPv4 address of the primary interface. | -| `networking.ip6` | `string` | no | The IPv6 address of the primary interface. | +| `networking.ip6` | `string` | yes | The IPv6 address of the primary interface. | | `networking.mac` | `string` | no | The MAC address of the primary interface. | | `networking.mtu` | `integer` | yes | The maximum transmission unit of the primary interface. | | `networking.netmask` | `string` | no | The IPv4 netmask of the primary interface. | -| `networking.netmask6` | `string` | no | The IPv6 netmask of the primary interface. | +| `networking.netmask6` | `string` | yes | The IPv6 netmask of the primary interface. | | `networking.network` | `string` | no | The IPv4 network of the primary interface. | -| `networking.network6` | `string` | no | The IPv6 network of the primary interface. | +| `networking.network6` | `string` | yes | The IPv6 network of the primary interface. | | `networking.primary` | `string` | no | The name of the primary interface. | -| `networking.scope6` | `string` | no | The IPv6 scope of the primary interface, such as global or link. | +| `networking.scope6` | `string` | yes | The IPv6 scope of the primary interface, such as global or link. | | `os.architecture` | `string` | no | The operating system's hardware architecture, such as x86_64 or arm64. | | `os.family` | `string` | no | The operating system family, such as Debian, RedHat, Darwin, or windows. | | `os.hardware` | `string` | no | The hardware model of the machine, such as x86_64. | diff --git a/docs/supported-facts/openbsd.md b/docs/supported-facts/openbsd.md index b51567b1..1033d64b 100644 --- a/docs/supported-facts/openbsd.md +++ b/docs/supported-facts/openbsd.md @@ -110,7 +110,7 @@ $ facts --json | `memory.system.total_bytes` | `integer` | no | The total physical memory, in bytes. | | `memory.system.used` | `string` | no | The display amount of physical memory in use, such as 1.00 GiB. | | `memory.system.used_bytes` | `integer` | no | The physical memory in use, in bytes. | -| `mountpoints.*` | `map` | no | A mounted filesystem, keyed by mount path. | +| `mountpoints.*` | `map` | yes | A mounted filesystem, keyed by mount path. | | `mountpoints.*.available` | `string` | no | The display amount of free space on the mount, such as 1.00 GiB. | | `mountpoints.*.available_bytes` | `integer` | no | The free space on the mount, in bytes. | | `mountpoints.*.capacity` | `string` | no | The percentage of the mount's space in use. | @@ -140,15 +140,15 @@ $ facts --json | `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.ip` | `string` | no | The IPv4 address of the primary interface. | -| `networking.ip6` | `string` | no | The IPv6 address of the primary interface. | +| `networking.ip6` | `string` | yes | The IPv6 address of the primary interface. | | `networking.mac` | `string` | no | The MAC address of the primary interface. | | `networking.mtu` | `integer` | yes | The maximum transmission unit of the primary interface. | | `networking.netmask` | `string` | no | The IPv4 netmask of the primary interface. | -| `networking.netmask6` | `string` | no | The IPv6 netmask of the primary interface. | +| `networking.netmask6` | `string` | yes | The IPv6 netmask of the primary interface. | | `networking.network` | `string` | no | The IPv4 network of the primary interface. | -| `networking.network6` | `string` | no | The IPv6 network of the primary interface. | +| `networking.network6` | `string` | yes | The IPv6 network of the primary interface. | | `networking.primary` | `string` | no | The name of the primary interface. | -| `networking.scope6` | `string` | no | The IPv6 scope of the primary interface, such as global or link. | +| `networking.scope6` | `string` | yes | The IPv6 scope of the primary interface, such as global or link. | | `os.architecture` | `string` | no | The operating system's hardware architecture, such as x86_64 or arm64. | | `os.family` | `string` | no | The operating system family, such as Debian, RedHat, Darwin, or windows. | | `os.hardware` | `string` | no | The hardware model of the machine, such as x86_64. | diff --git a/docs/supported-facts/windows.md b/docs/supported-facts/windows.md index 01a69ea3..e0b6a6fc 100644 --- a/docs/supported-facts/windows.md +++ b/docs/supported-facts/windows.md @@ -93,7 +93,7 @@ $ facts --json | `memory.system.total_bytes` | `integer` | no | The total physical memory, in bytes. | | `memory.system.used` | `string` | no | The display amount of physical memory in use, such as 1.00 GiB. | | `memory.system.used_bytes` | `integer` | no | The physical memory in use, in bytes. | -| `mountpoints.*` | `map` | no | A mounted filesystem, keyed by mount path. | +| `mountpoints.*` | `map` | yes | A mounted filesystem, keyed by mount path. | | `networking.dhcp` | `string` | no | The DHCP server of the primary interface, when known. | | `networking.domain` | `string` | yes | The DNS domain of the host, when one is configured. | | `networking.fqdn` | `string` | no | The fully qualified domain name of the host. | @@ -113,15 +113,15 @@ $ facts --json | `networking.interfaces.*.network6` | `string` | yes | The IPv6 network of the interface's first binding. | | `networking.interfaces.*.scope6` | `string` | yes | The IPv6 scope of the interface's first binding, such as global or link. | | `networking.ip` | `string` | no | The IPv4 address of the primary interface. | -| `networking.ip6` | `string` | no | The IPv6 address of the primary interface. | +| `networking.ip6` | `string` | yes | The IPv6 address of the primary interface. | | `networking.mac` | `string` | no | The MAC address of the primary interface. | | `networking.mtu` | `integer` | yes | The maximum transmission unit of the primary interface. | | `networking.netmask` | `string` | no | The IPv4 netmask of the primary interface. | -| `networking.netmask6` | `string` | no | The IPv6 netmask of the primary interface. | +| `networking.netmask6` | `string` | yes | The IPv6 netmask of the primary interface. | | `networking.network` | `string` | no | The IPv4 network of the primary interface. | -| `networking.network6` | `string` | no | The IPv6 network of the primary interface. | +| `networking.network6` | `string` | yes | The IPv6 network of the primary interface. | | `networking.primary` | `string` | no | The name of the primary interface. | -| `networking.scope6` | `string` | no | The IPv6 scope of the primary interface, such as global or link. | +| `networking.scope6` | `string` | yes | The IPv6 scope of the primary interface, such as global or link. | | `os.architecture` | `string` | no | The operating system's hardware architecture, such as x86_64 or arm64. | | `os.family` | `string` | no | The operating system family, such as Debian, RedHat, Darwin, or windows. | | `os.hardware` | `string` | no | The hardware model of the machine, such as x86_64. | diff --git a/internal/engine/disks.go b/internal/engine/disks.go index c1e19978..e18ff153 100644 --- a/internal/engine/disks.go +++ b/internal/engine/disks.go @@ -914,6 +914,13 @@ func partitionsFacts(partitions map[string]any) []ResolvedFact { return []ResolvedFact{{Name: "partitions", Value: partitions}} } +func mountpointsFacts(mountpoints map[string]any) []ResolvedFact { + if len(mountpoints) == 0 { + return nil + } + return []ResolvedFact{{Name: "mountpoints", Value: mountpoints}} +} + func partitionsFactWithMountEntries(partitions map[string]any, mountEntries []mountEntry, mountpoints map[string]any) map[string]any { if len(partitions) == 0 { return nil @@ -1491,6 +1498,9 @@ func mountpointsFactWithSkip(entries []mountEntry, stat func(string) (mountStat, if len(entry.Options) > 0 { mountpoint["options"] = append([]string(nil), entry.Options...) } + if len(mountpoint) == 0 { + continue + } mountpoints[entry.Path] = mountpoint } if len(mountpoints) == 0 { @@ -1605,9 +1615,8 @@ func disksCoreFacts(s *Session) []ResolvedFact { mountEntries = currentMountEntries(s) } mountpoints := rootMountpoint(s) - facts := []ResolvedFact{ - {Name: "mountpoints", Value: mountpoints}, - } + var facts []ResolvedFact + facts = append(facts, mountpointsFacts(mountpoints)...) facts = append(facts, currentZFSFacts(runtime.GOOS, s.commandOutput)...) facts = append(facts, disksFacts(disks)...) facts = append(facts, partitionsFacts(partitionsFactWithMountEntries(currentPartitions(s), mountEntries, mountpoints))...) diff --git a/internal/engine/disks_test.go b/internal/engine/disks_test.go index 086588f3..5c698af9 100644 --- a/internal/engine/disks_test.go +++ b/internal/engine/disks_test.go @@ -692,6 +692,23 @@ func TestPartitionsFacts_omittedWhenNoDevicesEnumerate(t *testing.T) { } } +func TestMountpointsFacts_omittedWhenUnresolved(t *testing.T) { + t.Parallel() + + if got := mountpointsFacts(nil); got != nil { + t.Fatalf("mountpointsFacts(nil) = %#v, want nil", got) + } + if got := mountpointsFacts(map[string]any{}); got != nil { + t.Fatalf("mountpointsFacts(empty) = %#v, want nil", got) + } + + mountpoints := map[string]any{"/": map[string]any{"size": "8.00 GiB"}} + want := []ResolvedFact{{Name: "mountpoints", Value: mountpoints}} + if got := mountpointsFacts(mountpoints); !reflect.DeepEqual(got, want) { + t.Fatalf("mountpointsFacts() = %#v, want %#v", got, want) + } +} + func TestParseZFSPoolFacts_matchesRubyFacterFixtures(t *testing.T) { zfsOutput, err := os.ReadFile(filepath.Join("testdata", "zfs")) if err != nil { @@ -1010,6 +1027,17 @@ func TestMountpointsFactIncludesDeviceFilesystemAndOptions(t *testing.T) { } } +func TestMountpointsFactOmitsEmptyEntries(t *testing.T) { + t.Parallel() + + got := mountpointsFact([]mountEntry{{Path: "/"}}, func(string) (mountStat, bool) { + return mountStat{}, false + }) + if got != nil { + t.Fatalf("mountpointsFact(empty entry) = %#v, want nil", got) + } +} + func TestMountpointsFactCapacityMatchesRubyFilesystemHelper(t *testing.T) { t.Parallel() diff --git a/internal/engine/networking.go b/internal/engine/networking.go index 4ee7e791..ba4e1008 100644 --- a/internal/engine/networking.go +++ b/internal/engine/networking.go @@ -134,6 +134,13 @@ func networkingDHCPValue(goos string, interfaces map[string]any, primaryIP strin return dhcp } +func optionalNetworkingString(value string) any { + if value == "" { + return nil + } + return value +} + type networkInterfaceSnapshot struct { Interface net.Interface Addrs []net.Addr @@ -1669,16 +1676,16 @@ func networkingCoreFacts(s *Session) []ResolvedFact { {Name: "networking.fqdn", Value: fqdnValue}, {Name: "networking.domain", Value: domainValue}, {Name: "networking.dhcp", Value: primaryDHCP}, - {Name: "networking.ip", Value: ipv4}, - {Name: "networking.ip6", Value: ipv6}, + {Name: "networking.ip", Value: optionalNetworkingString(ipv4)}, + {Name: "networking.ip6", Value: optionalNetworkingString(ipv6)}, {Name: "networking.interfaces", Value: interfaces}, - {Name: "networking.mac", Value: primaryMAC}, + {Name: "networking.mac", Value: optionalNetworkingString(primaryMAC)}, {Name: "networking.mtu", Value: primaryMTU}, - {Name: "networking.netmask", Value: primaryNetmask}, - {Name: "networking.netmask6", Value: primaryNetmask6}, - {Name: "networking.network", Value: primaryNetwork}, - {Name: "networking.network6", Value: primaryNetwork6}, - {Name: "networking.primary", Value: primaryInterfaceName}, - {Name: "networking.scope6", Value: primaryScope6}, + {Name: "networking.netmask", Value: optionalNetworkingString(primaryNetmask)}, + {Name: "networking.netmask6", Value: optionalNetworkingString(primaryNetmask6)}, + {Name: "networking.network", Value: optionalNetworkingString(primaryNetwork)}, + {Name: "networking.network6", Value: optionalNetworkingString(primaryNetwork6)}, + {Name: "networking.primary", Value: optionalNetworkingString(primaryInterfaceName)}, + {Name: "networking.scope6", Value: optionalNetworkingString(primaryScope6)}, } } diff --git a/internal/engine/networking_test.go b/internal/engine/networking_test.go index f3e559a4..abda78c4 100644 --- a/internal/engine/networking_test.go +++ b/internal/engine/networking_test.go @@ -29,6 +29,30 @@ func TestNetworkingDHCPFactUsesPrimaryInterfaceDHCPValue(t *testing.T) { } } +func TestOptionalNetworkingStringOmitsEmptyValues(t *testing.T) { + t.Parallel() + + collection := Collection([]ResolvedFact{ + {Name: "networking.ip", Value: optionalNetworkingString("192.0.2.10")}, + {Name: "networking.ip6", Value: optionalNetworkingString("")}, + {Name: "networking.netmask6", Value: optionalNetworkingString("")}, + {Name: "networking.network6", Value: optionalNetworkingString("")}, + {Name: "networking.scope6", Value: optionalNetworkingString("")}, + }) + networking, ok := collection["networking"].(map[string]any) + if !ok { + t.Fatalf("networking fact = %#v, want map", collection["networking"]) + } + if got := networking["ip"]; got != "192.0.2.10" { + t.Fatalf("networking.ip = %#v, want 192.0.2.10", got) + } + for _, key := range []string{"ip6", "netmask6", "network6", "scope6"} { + if _, ok := networking[key]; ok { + t.Fatalf("networking.%s present in %#v, want omitted", key, networking) + } + } +} + func TestCurrentNetworkingDataExpandsWindowsInterfaceBindingsAndPrimary(t *testing.T) { t.Parallel() @@ -1373,8 +1397,8 @@ func TestCoreFacts_networkingIncludesIP6(t *testing.T) { t.Fatalf("networking fact = %#v, want map", collection["networking"]) } - if _, ok := networking["ip6"]; !ok { - t.Fatalf("networking fact missing ip6 in %#v", networking) + if value, ok := networking["ip6"]; ok && value == "" { + t.Fatalf("networking.ip6 = empty string in %#v, want omitted or populated", networking) } } @@ -1399,12 +1423,14 @@ func TestCoreFacts_networkingIncludesPrimaryIPv6Binding(t *testing.T) { if !ok { t.Fatalf("networking fact = %#v, want map", collection["networking"]) } - if networking["ip6"] == "" { + ip6, _ := networking["ip6"].(string) + if ip6 == "" { t.Skip("host has no primary IPv6 address") } for _, key := range []string{"netmask6", "network6", "scope6"} { - if networking[key] == "" { + value, _ := networking[key].(string) + if value == "" { t.Fatalf("networking = %#v, want %s for primary IPv6", networking, key) } } diff --git a/internal/engine/virtual.go b/internal/engine/virtual.go index 425e5682..002733d4 100644 --- a/internal/engine/virtual.go +++ b/internal/engine/virtual.go @@ -97,6 +97,14 @@ type freeBSDVirtualizationInput struct { type openBSDVirtualizationInput struct { ProductName string + Vendor string +} + +type dmiVirtualizationInput struct { + Manufacturer string + ProductName string + BIOSVendor string + PCIOutput string } type windowsVirtualizationInput struct { @@ -118,6 +126,12 @@ func detectVirtualization(s *Session) virtualization { return detectFreeBSDVirtualization(currentFreeBSDVirtualizationInput(s.commandOutput)) case "openbsd": return detectOpenBSDVirtualization(currentOpenBSDVirtualizationInput(s.commandOutput)) + case "netbsd": + return detectDMIHostVirtualization(currentNetBSDVirtualizationInput(s.commandOutput)) + case "dragonfly": + return detectDMIHostVirtualization(currentDragonFlyVirtualizationInput(s.commandOutput)) + case "illumos": + return detectDMIHostVirtualization(currentIllumosVirtualizationInput(s.commandOutput)) case "windows": return detectWindowsVirtualization(currentWindowsVirtualizationInput(runtime.GOOS, s.commandOutput)) default: @@ -161,16 +175,84 @@ func detectFreeBSDVirtualization(input freeBSDVirtualizationInput) virtualizatio func currentOpenBSDVirtualizationInput(run func(string, ...string) string) openBSDVirtualizationInput { return openBSDVirtualizationInput{ ProductName: strings.TrimSpace(run("sysctl", "-n", "hw.product")), + Vendor: strings.TrimSpace(run("sysctl", "-n", "hw.vendor")), } } func detectOpenBSDVirtualization(input openBSDVirtualizationInput) virtualization { - if name, ok := openBSDProductHypervisors[input.ProductName]; ok && name != "" { - return virtualization{Name: name, IsVirtual: true} + if name, ok := openBSDProductHypervisors[input.ProductName]; ok { + if name != "" { + return virtualization{Name: name, IsVirtual: true} + } + return virtualization{Name: "physical"} + } + if virtual := detectDMIHostVirtualization(dmiVirtualizationInput{ + Manufacturer: input.Vendor, + ProductName: input.ProductName, + }); virtual.IsVirtual { + return virtual } return virtualization{Name: "physical"} } +func currentNetBSDVirtualizationInput(run func(string, ...string) string) dmiVirtualizationInput { + return dmiVirtualizationInput{ + Manufacturer: strings.TrimSpace(run("/sbin/sysctl", "-n", "machdep.dmi.system-vendor")), + ProductName: strings.TrimSpace(run("/sbin/sysctl", "-n", "machdep.dmi.system-product")), + } +} + +func currentDragonFlyVirtualizationInput(run func(string, ...string) string) dmiVirtualizationInput { + system := parseColonValues(run("/usr/local/sbin/dmidecode", "-t", "system")) + bios := parseColonValues(run("/usr/local/sbin/dmidecode", "-t", "bios")) + return dmiVirtualizationInput{ + Manufacturer: strings.TrimSpace(firstNonEmpty(run("kenv", "smbios.system.maker"), system["Manufacturer"])), + ProductName: strings.TrimSpace(firstNonEmpty(run("kenv", "smbios.system.product"), system["Product Name"])), + BIOSVendor: strings.TrimSpace(firstNonEmpty(run("kenv", "smbios.bios.vendor"), bios["Vendor"])), + PCIOutput: run("pciconf", "-lv"), + } +} + +func currentIllumosVirtualizationInput(run func(string, ...string) string) dmiVirtualizationInput { + system := parseIllumosSMBIOSValues(run("/usr/sbin/smbios", "-t", "SMB_TYPE_SYSTEM")) + bios := parseIllumosSMBIOSValues(run("/usr/sbin/smbios", "-t", "SMB_TYPE_BIOS")) + return dmiVirtualizationInput{ + Manufacturer: system["Manufacturer"], + ProductName: system["Product"], + BIOSVendor: bios["Vendor"], + PCIOutput: run("/usr/sbin/prtconf", "-pv"), + } +} + +func detectDMIHostVirtualization(input dmiVirtualizationInput) virtualization { + manufacturerLower := strings.ToLower(input.Manufacturer) + productNameLower := strings.ToLower(input.ProductName) + if strings.Contains(manufacturerLower, "qemu") || strings.Contains(productNameLower, "qemu") { + return virtualization{Name: "kvm", IsVirtual: true} + } + biosVendorLower := strings.ToLower(input.BIOSVendor) + biosIndicatesKVM := strings.Contains(biosVendorLower, "qemu") || strings.Contains(biosVendorLower, "seabios") + name := dmiProductHypervisor(input.ProductName) + if name == "hyperv" && biosIndicatesKVM { + return virtualization{Name: "kvm", IsVirtual: true} + } + if name != "" { + return virtualization{Name: name, IsVirtual: true} + } + if biosIndicatesKVM { + return virtualization{Name: "kvm", IsVirtual: true} + } + if name := lspciHypervisor(input.PCIOutput); name != "" { + return virtualization{Name: name, IsVirtual: true} + } + switch { + case strings.Contains(strings.ToLower(input.PCIOutput), "virtio"): + return virtualization{Name: "kvm", IsVirtual: true} + default: + return virtualization{Name: "physical"} + } +} + func currentWindowsVirtualizationInput(goos string, run commandRunner) windowsVirtualizationInput { if goos != "windows" { return windowsVirtualizationInput{} @@ -197,6 +279,9 @@ func detectWindowsVirtualization(input windowsVirtualizationInput) virtualizatio model := strings.TrimSpace(input.Model) manufacturer := strings.TrimSpace(input.Manufacturer) biosManufacturer := strings.TrimSpace(input.BIOSManufacturer) + modelLower := strings.ToLower(model) + manufacturerLower := strings.ToLower(manufacturer) + biosManufacturerLower := strings.ToLower(biosManufacturer) switch { case strings.Contains(model, "VirtualBox"): return virtualization{Name: "virtualbox", IsVirtual: true} @@ -214,6 +299,8 @@ func detectWindowsVirtualization(input windowsVirtualizationInput) virtualizatio return virtualization{Name: "xen", IsVirtual: true} case strings.Contains(manufacturer, "Amazon EC2"): return virtualization{Name: "kvm", IsVirtual: true} + case strings.Contains(manufacturerLower, "qemu"), strings.Contains(modelLower, "qemu"), strings.Contains(modelLower, "standard pc (i440fx"), strings.Contains(biosManufacturerLower, "seabios"): + return virtualization{Name: "kvm", IsVirtual: true} case input.NetKVM && strings.Contains(biosManufacturer, "Google"): return virtualization{Name: "gce", IsVirtual: true} case input.NetKVM: @@ -434,8 +521,9 @@ func lspciHypervisor(output string) string { } func dmiProductHypervisor(productName string) string { + productName = strings.ToLower(productName) for _, hypervisor := range dmiProductHypervisors { - if strings.Contains(productName, hypervisor.substring) { + if strings.Contains(productName, strings.ToLower(hypervisor.substring)) { return hypervisor.name } } diff --git a/internal/engine/virtual_test.go b/internal/engine/virtual_test.go index 03f6e652..ecabbad5 100644 --- a/internal/engine/virtual_test.go +++ b/internal/engine/virtual_test.go @@ -331,6 +331,26 @@ func TestDetectWindowsVirtualizationMatchesRubyResolver(t *testing.T) { input: windowsVirtualizationInput{NetKVM: true}, want: virtualization{Name: "kvm", IsVirtual: true}, }, + { + name: "QEMU manufacturer", + input: windowsVirtualizationInput{WMIResult: true, Manufacturer: "QEMU", Model: "Standard PC (i440FX + PIIX, 1996)"}, + want: virtualization{Name: "kvm", IsVirtual: true}, + }, + { + name: "SeaBIOS manufacturer", + input: windowsVirtualizationInput{WMIResult: true, BIOSManufacturer: "SeaBIOS"}, + want: virtualization{Name: "kvm", IsVirtual: true}, + }, + { + name: "QEMU casing variant", + input: windowsVirtualizationInput{WMIResult: true, Manufacturer: "Qemu", Model: "standard pc (i440fx + piix, 1996)"}, + want: virtualization{Name: "kvm", IsVirtual: true}, + }, + { + name: "SeaBIOS casing variant", + input: windowsVirtualizationInput{WMIResult: true, BIOSManufacturer: "seabios"}, + want: virtualization{Name: "kvm", IsVirtual: true}, + }, { name: "Google NetKVM service", input: windowsVirtualizationInput{BIOSManufacturer: "Google", NetKVM: true}, @@ -527,8 +547,13 @@ func TestDetectOpenBSDVirtualization(t *testing.T) { want: virtualization{Name: "vmm", IsVirtual: true}, }, { - name: "qemu product name is physical like Facter", - input: openBSDVirtualizationInput{ProductName: "QEMU Virtual Machine"}, + name: "qemu vendor", + input: openBSDVirtualizationInput{Vendor: "QEMU", ProductName: "Standard PC (i440FX + PIIX, 1996)"}, + want: virtualization{Name: "kvm", IsVirtual: true}, + }, + { + name: "known none product stays physical", + input: openBSDVirtualizationInput{Vendor: "QEMU", ProductName: "none"}, want: virtualization{Name: "physical"}, }, } @@ -543,6 +568,84 @@ func TestDetectOpenBSDVirtualization(t *testing.T) { } } +func TestDetectDMIHostVirtualization(t *testing.T) { + tests := []struct { + name string + input dmiVirtualizationInput + want virtualization + }{ + { + name: "qemu manufacturer", + input: dmiVirtualizationInput{Manufacturer: "QEMU", ProductName: "Standard PC (i440FX + PIIX, 1996)"}, + want: virtualization{Name: "kvm", IsVirtual: true}, + }, + { + name: "qemu product", + input: dmiVirtualizationInput{ProductName: "QEMU Virtual Machine"}, + want: virtualization{Name: "kvm", IsVirtual: true}, + }, + { + name: "qemu manufacturer beats generic virtual machine product", + input: dmiVirtualizationInput{Manufacturer: "QEMU", ProductName: "Virtual Machine"}, + want: virtualization{Name: "kvm", IsVirtual: true}, + }, + { + name: "seabios", + input: dmiVirtualizationInput{BIOSVendor: "SeaBIOS"}, + want: virtualization{Name: "kvm", IsVirtual: true}, + }, + { + name: "seabios beats generic virtual machine product", + input: dmiVirtualizationInput{ProductName: "Virtual Machine", BIOSVendor: "SeaBIOS"}, + want: virtualization{Name: "kvm", IsVirtual: true}, + }, + { + name: "qemu casing variant", + input: dmiVirtualizationInput{Manufacturer: "Qemu", ProductName: "standard pc (i440fx + piix, 1996)"}, + want: virtualization{Name: "kvm", IsVirtual: true}, + }, + { + name: "seabios casing variant", + input: dmiVirtualizationInput{BIOSVendor: "seabios"}, + want: virtualization{Name: "kvm", IsVirtual: true}, + }, + { + name: "virtualbox casing variant", + input: dmiVirtualizationInput{ProductName: "virtualbox"}, + want: virtualization{Name: "virtualbox", IsVirtual: true}, + }, + { + name: "specific product beats seabios fallback", + input: dmiVirtualizationInput{ProductName: "Bochs Machine", BIOSVendor: "SeaBIOS"}, + want: virtualization{Name: "bochs", IsVirtual: true}, + }, + { + name: "virtio pci", + input: dmiVirtualizationInput{PCIOutput: "00:03.0 Ethernet controller: Red Hat, Inc. Virtio network device\n"}, + want: virtualization{Name: "kvm", IsVirtual: true}, + }, + { + name: "vmware pci", + input: dmiVirtualizationInput{PCIOutput: "00:0f.0 VGA compatible controller: VMware SVGA II Adapter\n"}, + want: virtualization{Name: "vmware", IsVirtual: true}, + }, + { + name: "physical host", + input: dmiVirtualizationInput{Manufacturer: "Apple Inc.", ProductName: "Mac16,10"}, + want: virtualization{Name: "physical"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := detectDMIHostVirtualization(tt.input) + if got != tt.want { + t.Fatalf("detectDMIHostVirtualization() = %#v, want %#v", got, tt.want) + } + }) + } +} + func TestLinuxHypervisorFacts(t *testing.T) { tests := []struct { name string diff --git a/openspec/changes/fix-vm-virtualization-detection/proposal.md b/openspec/changes/fix-vm-virtualization-detection/proposal.md new file mode 100644 index 00000000..4549f4bb --- /dev/null +++ b/openspec/changes/fix-vm-virtualization-detection/proposal.md @@ -0,0 +1,25 @@ +## Why + +Native fleet content validation found several lab VMs emitting +`virtual: "physical"` and `is_virtual: false` even though their host metadata +exposes QEMU/KVM indicators. + +## What Changes + +- Detect QEMU/KVM from DMI/SMBIOS/PCI indicators on OpenBSD, NetBSD, + DragonFly BSD, and illumos. +- Detect Windows Server 2025 QEMU/KVM VMs through WMI manufacturer/model/BIOS + indicators. +- Omit empty optional top-level networking strings found by full-tree native + validation, instead of rendering placeholder values. +- Omit empty mountpoint entries that have no stat, device, filesystem, or + option data. + +## Impact + +- VM guests now report `virtual: "kvm"` and `is_virtual: true` instead of + physical when QEMU/KVM metadata is present. +- Full output no longer renders empty primary networking placeholders when a + supported platform has no primary value for that field. +- Full output no longer renders empty mountpoint maps when no mountpoint data + can be resolved. diff --git a/openspec/changes/fix-vm-virtualization-detection/specs/go-port-supported-platform-facts/spec.md b/openspec/changes/fix-vm-virtualization-detection/specs/go-port-supported-platform-facts/spec.md new file mode 100644 index 00000000..362c45ea --- /dev/null +++ b/openspec/changes/fix-vm-virtualization-detection/specs/go-port-supported-platform-facts/spec.md @@ -0,0 +1,28 @@ +## MODIFIED Requirements + +### Requirement: Virtualization and cloud parity +The Go port SHALL match Ruby-compatible virtualization, hypervisor, and cloud metadata behavior on supported platforms, and MAY expose accurate Facts-native virtualization detection when a supported platform has stable native indicators not covered by Ruby Facter. + +#### Scenario: Virtualization and hypervisor facts +- **WHEN** supported-platform virtualization facts are resolved from Linux `virt-what`, cgroups, DMI, VMware, Xen, OpenVZ, Windows OEM/netkvm/WMI indicators, macOS indicators, FreeBSD virtualization indicators, OpenBSD DMI product/vendor indicators, NetBSD DMI indicators, DragonFly DMI/PCI indicators, illumos SMBIOS/PCI indicators, or other indicators identified by the parity audit +- **THEN** the Go port MUST match Ruby `virtual`, `is_virtual`, `hypervisors.*`, Xen, container, and nil/unknown behavior for supported detection paths +- **AND** it MUST report QEMU/KVM guests as virtual when those supported native indicators expose QEMU, SeaBIOS, or Virtio metadata + +### Requirement: Supported platform networking facts +The Go port SHALL omit optional primary networking facts when the platform +does not expose a primary value, instead of rendering empty string +placeholders. + +#### Scenario: Optional primary networking values are absent when unresolved +- **WHEN** supported-platform networking facts are rendered for full structured output +- **THEN** optional top-level primary fields such as `networking.ip`, `networking.ip6`, `networking.mac`, `networking.netmask`, `networking.netmask6`, `networking.network`, `networking.network6`, `networking.primary`, and `networking.scope6` MUST be absent when unresolved +- **AND** populated primary networking values MUST still be emitted + +### Requirement: Supported platform mountpoint facts +The Go port SHALL omit mountpoint entries that have no resolved stat, device, +filesystem, or option data. + +#### Scenario: Empty mountpoint entries are absent +- **WHEN** supported-platform mountpoint facts are rendered for full structured output +- **THEN** entries with no mountpoint fields MUST be absent instead of rendering an empty map +- **AND** populated mountpoint entries MUST still be emitted diff --git a/openspec/changes/fix-vm-virtualization-detection/tasks.md b/openspec/changes/fix-vm-virtualization-detection/tasks.md new file mode 100644 index 00000000..fd485ac5 --- /dev/null +++ b/openspec/changes/fix-vm-virtualization-detection/tasks.md @@ -0,0 +1,17 @@ +## 1. Implementation + +- [x] 1.1 Add fixture-backed detector coverage for QEMU/KVM VM signals. +- [x] 1.2 Update supported-platform virtualization detection. +- [x] 1.3 Omit empty optional top-level networking strings found by full-tree + native validation. +- [x] 1.4 Omit empty mountpoint entries found by full-tree native validation. + +## 2. Documentation + +- [x] 2.1 Update CHANGELOG and supported-platform facts spec. + +## 3. Verification + +- [x] 3.1 Run focused virtualization tests. +- [x] 3.2 Re-run native full-tree content validation. +- [x] 3.3 Run `go test ./...` and `go vet ./...`. diff --git a/tools/windows-release-gate.ps1 b/tools/windows-release-gate.ps1 index dbe951b6..471b54d0 100644 --- a/tools/windows-release-gate.ps1 +++ b/tools/windows-release-gate.ps1 @@ -7,7 +7,12 @@ param( $ErrorActionPreference = "Stop" -if (-not $IsWindows) { +$isWindowsHost = $IsWindows +if ($null -eq $isWindowsHost) { + $isWindowsHost = $env:OS -eq "Windows_NT" +} + +if (-not $isWindowsHost) { Write-Error "windows-release-gate.ps1 must run on Windows" exit 1 } @@ -54,7 +59,11 @@ if ($LASTEXITCODE -ne 0) { $json = $output -join [Environment]::NewLine Write-Output $json -$facts = $json | ConvertFrom-Json -AsHashtable +$factsObject = $json | ConvertFrom-Json +$facts = New-Object 'System.Collections.Hashtable' -ArgumentList ([System.StringComparer]::Ordinal) +foreach ($property in $factsObject.PSObject.Properties) { + $facts[$property.Name] = $property.Value +} function Assert-Key($key) { if (-not $facts.ContainsKey($key)) { @@ -81,9 +90,13 @@ function Assert-NonEmpty($key) { function Assert-Map($key) { Assert-Key $key $got = $facts[$key] - if ($null -eq $got -or -not ($got -is [System.Collections.IDictionary]) -or $got.Count -eq 0) { - throw "$key = '$got', want non-empty map" + if ($got -is [System.Collections.IDictionary] -and $got.Count -gt 0) { + return + } + if ($got -is [pscustomobject] -and $got.PSObject.Properties.Count -gt 0) { + return } + throw "$key = '$got', want non-empty map" } foreach ($fact in $factSet) {