From a6f0645262d16ad326206620360dd1b72d6d88d9 Mon Sep 17 00:00:00 2001 From: Juliano Martinez Date: Wed, 24 Jun 2026 07:49:01 +0200 Subject: [PATCH] fix Linux OS release parsing --- CHANGELOG.md | 5 ++++ Makefile | 2 +- internal/engine/os.go | 17 +++++++++---- internal/engine/os_test.go | 49 +++++++++++++++++++++++++++++++++++++- 4 files changed, 67 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 02361f29..c189af7c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,11 @@ 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."/": {}`. +- Linux `os.release` is now omitted when `/etc/os-release` has no + `VERSION_ID`, including Arch Linux; `BUILD_ID=rolling` is not treated as an + operating system release. +- NixOS, Rocky Linux, and AlmaLinux `os.release` now split dotted + `VERSION_ID` values into `major` and `minor`, matching `os.distro.release`. ## v0.0.3 - 2026-06-18 diff --git a/Makefile b/Makefile index 6ab58b4f..91b27cca 100644 --- a/Makefile +++ b/Makefile @@ -248,7 +248,7 @@ lima-docker-distro-facts: lima-docker-build-amd64 *) echo "missing expected os.distro.id for $$image" >&2; exit 2 ;; \ esac; \ echo "==> distro fact smoke $$image"; \ - $(LIMACTL) shell '$(LIMA_DOCKER_INSTANCE)' -- sh -lc "set -eu; cd '$(CURDIR)'; out=\$$(docker run --rm --platform linux/amd64 -e CI=true -v \"\$$PWD/dist/facts-linux-amd64:/usr/local/bin/facts:ro\" $$image /usr/local/bin/facts --json os.name os.family os.distro.id os.distro.description os.release.major os.distro.release.major kernel.name virtual); printf '%s\n' \"\$$out\"; printf '%s\n' \"\$$out\" | grep -Eq '\"kernel.name\"[[:space:]]*:[[:space:]]*\"Linux\"'; printf '%s\n' \"\$$out\" | grep -Eq '\"os.distro.id\"[[:space:]]*:[[:space:]]*\"$$expected_id\"'" || exit $$?; \ + $(LIMACTL) shell '$(LIMA_DOCKER_INSTANCE)' -- sh -lc "set -eu; cd '$(CURDIR)'; out=\$$(docker run --rm --platform linux/amd64 -e CI=true -v \"\$$PWD/dist/facts-linux-amd64:/usr/local/bin/facts:ro\" $$image /usr/local/bin/facts --json os.name os.family os.distro.id os.distro.description kernel.name virtual); printf '%s\n' \"\$$out\"; printf '%s\n' \"\$$out\" | grep -Eq '\"kernel.name\"[[:space:]]*:[[:space:]]*\"Linux\"'; printf '%s\n' \"\$$out\" | grep -Eq '\"os.distro.id\"[[:space:]]*:[[:space:]]*\"$$expected_id\"'" || exit $$?; \ done lima-docker-workloads: lima-docker-go-containers lima-docker-distro-facts diff --git a/internal/engine/os.go b/internal/engine/os.go index 62980ae3..2a9bdeaa 100644 --- a/internal/engine/os.go +++ b/internal/engine/os.go @@ -198,7 +198,10 @@ func currentMacOSModel(goos string, run commandRunner) string { func probeOSRelease(s *Session) any { if runtime.GOOS == "windows" { - return currentWindowsOSRelease(s.cachedWindowsOSVersionInput()) + if release := currentWindowsOSRelease(s.cachedWindowsOSVersionInput()); len(release) > 0 { + return release + } + return nil } return currentOSRelease(s, runtime.GOOS, s.readFile, s.commandOutput) } @@ -225,7 +228,10 @@ func currentOSRelease(s *Session, goos string, readFile fileReader, run commandR if release := specificLinuxOSRelease(id, readFile, run); len(release) > 0 { return release } - return parseLinuxOSRelease(string(data)) + if release := parseLinuxOSRelease(string(data)); len(release) > 0 { + return release + } + return nil case "freebsd": versions := parseFreeBSDVersions(run("/bin/freebsd-version", "-k"), run("/bin/freebsd-version", "-ru")) if versions.InstalledUserland != "" { @@ -246,7 +252,10 @@ func currentOSRelease(s *Session, goos string, readFile fileReader, run commandR case "darwin": return parseDarwinOSRelease(run("uname", "-r")) case "windows": - return currentWindowsOSRelease(windowsWMIOutput(run, "os", "OtherTypeDescription,ProductType,Version")) + if release := currentWindowsOSRelease(windowsWMIOutput(run, "os", "OtherTypeDescription,ProductType,Version")); len(release) > 0 { + return release + } + return nil } return s.cachedKernelRelease() } @@ -711,7 +720,7 @@ func linuxOSReleaseMap(id, full string) map[string]any { return debianReleaseMap(full) } switch strings.ToLower(id) { - case "mariner", "azurelinux", "linuxmint", "gentoo", "mageia": + case "mariner", "azurelinux", "linuxmint", "gentoo", "mageia", "nixos", "rocky", "almalinux": return releaseHashFromString(full, false) } return map[string]any{"full": full, "major": full} diff --git a/internal/engine/os_test.go b/internal/engine/os_test.go index 33914469..847b830c 100644 --- a/internal/engine/os_test.go +++ b/internal/engine/os_test.go @@ -590,6 +590,34 @@ func TestParseLinuxOSRelease_padsDebianVersionIDLikeRubyResolver(t *testing.T) { } } +func TestParseLinuxOSRelease_splitsNixOSVersionID(t *testing.T) { + got := parseLinuxOSRelease("ID=nixos\nVERSION_ID=26.05\n") + + want := map[string]any{"full": "26.05", "major": "26", "minor": "05"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("parseLinuxOSRelease() = %#v, want %#v", got, want) + } +} + +func TestParseLinuxOSRelease_splitsRockyAndAlmaVersionID(t *testing.T) { + tests := []struct { + id string + }{ + {id: "rocky"}, + {id: "almalinux"}, + } + for _, tt := range tests { + t.Run(tt.id, func(t *testing.T) { + got := parseLinuxOSRelease("ID=" + tt.id + "\nVERSION_ID=9.8\n") + + want := map[string]any{"full": "9.8", "major": "9", "minor": "8"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("parseLinuxOSRelease() = %#v, want %#v", got, want) + } + }) + } +} + func TestParseLinuxDistroOSRelease_trimsDebianMinorLeadingZero(t *testing.T) { got := parseLinuxDistroOSRelease("ID=debian\nVERSION_ID=10.02\n") @@ -1251,15 +1279,34 @@ func TestParseLinuxDistroOSRelease_normalizesSLESNameAndSAPID(t *testing.T) { func TestParseLinuxDistroOSRelease_normalizesArchLinuxName(t *testing.T) { t.Parallel() - got := parseLinuxDistroOSRelease("NAME=\"Arch Linux\"\nID=arch\nPRETTY_NAME=\"Arch Linux\"\n") + got := parseLinuxDistroOSRelease("NAME=\"Arch Linux\"\nID=arch\nBUILD_ID=rolling\nPRETTY_NAME=\"Arch Linux\"\n") if got.Name != "Archlinux" { t.Fatalf("parseLinuxDistroOSRelease().Name = %q, want Archlinux", got.Name) } + if len(got.Release) != 0 || got.ReleaseKnown { + t.Fatalf("parseLinuxDistroOSRelease().Release = %#v, ReleaseKnown = %v, want no release from BUILD_ID", got.Release, got.ReleaseKnown) + } if name := osName("linux", got); name != "Archlinux" { t.Fatalf("osName(linux, arch) = %q, want Archlinux", name) } } +func TestCurrentOSRelease_omitsArchRollingBuildID(t *testing.T) { + t.Parallel() + + readFile := func(path string) ([]byte, error) { + if path != "/etc/os-release" { + return nil, os.ErrNotExist + } + return []byte("NAME=\"Arch Linux\"\nPRETTY_NAME=\"Arch Linux\"\nID=arch\nBUILD_ID=rolling\n"), nil + } + + got := currentOSRelease(testSession, "linux", readFile, func(string, ...string) string { return "" }) + if got != nil { + t.Fatalf("currentOSRelease(testSession, arch) = %#v, want nil because BUILD_ID is not a release", got) + } +} + func TestParseLinuxDistroOSRelease_normalizesManjaroLinuxName(t *testing.T) { t.Parallel()