feat(machine): add OS and kernel version information to machine info, show in 'machine ls'

This commit is contained in:
Pasha Sviderski
2026-06-24 17:57:20 +10:00
parent 9ea30e6ee9
commit ad43569fe5
8 changed files with 574 additions and 230 deletions
+104
View File
@@ -0,0 +1,104 @@
// Package osinfo collects the host operating system information.
package osinfo
import (
"bufio"
"io"
"os"
"strings"
)
// Paths to the files describing the OS release. They are package variables so tests can override them.
var (
osReleasePaths = []string{"/etc/os-release", "/usr/lib/os-release"}
debianVersionPath = "/etc/debian_version"
)
// PrettyName returns a human-readable OS name and version derived from os-release, or an empty string
// if it cannot be determined. For example, "Ubuntu 24.04.4 LTS" or "Debian 13.5".
func PrettyName() string {
rel := readOSRelease()
if len(rel) == 0 {
return ""
}
// Debian's os-release only carries the major version (e.g. "13"), while /etc/debian_version
// holds the point release (e.g. "13.5"). Read it to report the precise version.
var debianVersion string
if rel["ID"] == "debian" {
if data, err := os.ReadFile(debianVersionPath); err == nil {
debianVersion = strings.TrimSpace(string(data))
}
}
return buildPrettyName(rel, debianVersion)
}
// readOSRelease reads the first available os-release file and parses it into a key-value map. It
// returns an empty map if no file is found.
func readOSRelease() map[string]string {
for _, path := range osReleasePaths {
f, err := os.Open(path)
if err != nil {
continue
}
rel := parseOSRelease(f)
f.Close()
return rel
}
return nil
}
// parseOSRelease parses an os-release file into a key-value map. Blank lines and comments are
// skipped, and surrounding quotes are stripped from values.
func parseOSRelease(r io.Reader) map[string]string {
rel := make(map[string]string)
scanner := bufio.NewScanner(r)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" || strings.HasPrefix(line, "#") {
continue
}
key, value, ok := strings.Cut(line, "=")
if !ok {
continue
}
key = strings.TrimSpace(key)
value = strings.Trim(strings.TrimSpace(value), `"'`)
rel[key] = value
}
return rel
}
// buildPrettyName composes a human-readable OS name and version from the parsed os-release map and the
// contents of /etc/debian_version (empty for non-Debian systems).
func buildPrettyName(rel map[string]string, debianVersion string) string {
// Debian's PRETTY_NAME is verbose ("Debian GNU/Linux 13 (trixie)") and lacks the point release,
// so prefer the precise version or codename (unstable releases) from /etc/debian_version.
if rel["ID"] == "debian" {
return strings.Join([]string{"Debian", debianVersion}, " ")
}
// PRETTY_NAME is the vendor's human-readable string and already includes the point release on
// most distros (e.g. "Ubuntu 24.04.4 LTS").
if pretty := rel["PRETTY_NAME"]; pretty != "" {
return pretty
}
// Fall back to composing the name from the individual fields.
name := rel["NAME"]
if name == "" {
name = rel["ID"]
}
parts := make([]string, 0, 3)
if name != "" {
parts = append(parts, name)
}
if version := rel["VERSION_ID"]; version != "" {
parts = append(parts, version)
}
if codename := rel["VERSION_CODENAME"]; codename != "" {
parts = append(parts, "("+codename+")")
}
return strings.Join(parts, " ")
}
+12
View File
@@ -0,0 +1,12 @@
package osinfo
import "golang.org/x/sys/unix"
// KernelVersion returns the running Linux kernel release, e.g. "6.8.0-31-generic".
func KernelVersion() string {
var un unix.Utsname
if err := unix.Uname(&un); err != nil {
return ""
}
return unix.ByteSliceToString(un.Release[:])
}
+8
View File
@@ -0,0 +1,8 @@
//go:build !linux
package osinfo
// KernelVersion returns an empty string on non-Linux hosts where the machine daemon does not run.
func KernelVersion() string {
return ""
}
+174
View File
@@ -0,0 +1,174 @@
package osinfo
import (
"os"
"path/filepath"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestParseOSRelease(t *testing.T) {
t.Parallel()
tests := []struct {
name string
content string
want map[string]string
}{
{
name: "ubuntu",
content: `PRETTY_NAME="Ubuntu 24.04.4 LTS"
NAME="Ubuntu"
VERSION_ID="24.04"
VERSION="24.04.4 LTS (Noble Numbat)"
VERSION_CODENAME=noble
ID=ubuntu`,
want: map[string]string{
"PRETTY_NAME": "Ubuntu 24.04.4 LTS",
"NAME": "Ubuntu",
"VERSION_ID": "24.04",
"VERSION": "24.04.4 LTS (Noble Numbat)",
"VERSION_CODENAME": "noble",
"ID": "ubuntu",
},
},
{
name: "debian",
content: `PRETTY_NAME="Debian GNU/Linux 13 (trixie)"
NAME="Debian GNU/Linux"
VERSION_ID="13"
VERSION="13 (trixie)"
VERSION_CODENAME=trixie
ID=debian`,
want: map[string]string{
"PRETTY_NAME": "Debian GNU/Linux 13 (trixie)",
"NAME": "Debian GNU/Linux",
"VERSION_ID": "13",
"VERSION": "13 (trixie)",
"VERSION_CODENAME": "trixie",
"ID": "debian",
},
},
{
name: "alpine",
content: `NAME="Alpine Linux"
ID=alpine
VERSION_ID=3.20.0
PRETTY_NAME="Alpine Linux v3.20"`,
want: map[string]string{
"NAME": "Alpine Linux",
"ID": "alpine",
"VERSION_ID": "3.20.0",
"PRETTY_NAME": "Alpine Linux v3.20",
},
},
{
name: "comments and blank lines are skipped",
content: `# This is a comment
ID=ubuntu
# Another comment
PRETTY_NAME='Ubuntu 24.04.4 LTS'`,
want: map[string]string{
"ID": "ubuntu",
"PRETTY_NAME": "Ubuntu 24.04.4 LTS",
},
},
{
name: "empty",
content: "",
want: map[string]string{},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
assert.Equal(t, tt.want, parseOSRelease(strings.NewReader(tt.content)))
})
}
}
func TestBuildPrettyName(t *testing.T) {
t.Parallel()
tests := []struct {
name string
rel map[string]string
debianVersion string
want string
}{
{
name: "ubuntu uses pretty name with point release",
rel: map[string]string{"ID": "ubuntu", "PRETTY_NAME": "Ubuntu 24.04.4 LTS"},
want: "Ubuntu 24.04.4 LTS",
},
{
name: "debian uses debian_version for the point release",
rel: map[string]string{"ID": "debian", "PRETTY_NAME": "Debian GNU/Linux 13 (trixie)"},
debianVersion: "13.5",
want: "Debian 13.5",
},
{
name: "debian unstable uses the codename from debian_version",
rel: map[string]string{"ID": "debian", "PRETTY_NAME": "Debian GNU/Linux 13 (trixie)"},
debianVersion: "trixie/sid",
want: "Debian trixie/sid",
},
{
name: "alpine uses pretty name",
rel: map[string]string{"ID": "alpine", "PRETTY_NAME": "Alpine Linux v3.20"},
want: "Alpine Linux v3.20",
},
{
name: "fallback composes name, version and codename without pretty name",
rel: map[string]string{"NAME": "Foo Linux", "VERSION_ID": "1.2", "VERSION_CODENAME": "bar"},
want: "Foo Linux 1.2 (bar)",
},
{
name: "empty map",
rel: map[string]string{},
want: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
assert.Equal(t, tt.want, buildPrettyName(tt.rel, tt.debianVersion))
})
}
}
func TestPrettyName_MissingFile(t *testing.T) {
// Not parallel: it mutates the package-level path variables.
original := osReleasePaths
defer func() { osReleasePaths = original }()
osReleasePaths = []string{filepath.Join(t.TempDir(), "nonexistent-os-release")}
assert.Equal(t, "", PrettyName())
}
func TestPrettyName_DebianPointRelease(t *testing.T) {
// Not parallel: it mutates the package-level path variables.
originalRelease, originalDebian := osReleasePaths, debianVersionPath
defer func() {
osReleasePaths = originalRelease
debianVersionPath = originalDebian
}()
dir := t.TempDir()
releasePath := filepath.Join(dir, "os-release")
require.NoError(t, os.WriteFile(releasePath, []byte(`ID=debian
PRETTY_NAME="Debian GNU/Linux 13 (trixie)"
VERSION_ID="13"`), 0o644))
debianPath := filepath.Join(dir, "debian_version")
require.NoError(t, os.WriteFile(debianPath, []byte("13.5\n"), 0o644))
osReleasePaths = []string{releasePath}
debianVersionPath = debianPath
assert.Equal(t, "Debian 13.5", PrettyName())
}