mirror of
https://github.com/psviderski/uncloud.git
synced 2026-08-28 03:53:33 +00:00
feat(machine): add OS and kernel version information to machine info, show in 'machine ls'
This commit is contained in:
@@ -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, " ")
|
||||
}
|
||||
@@ -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[:])
|
||||
}
|
||||
@@ -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 ""
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
Reference in New Issue
Block a user