Detect Proxmox VE host agent OS identity

This commit is contained in:
rcourtman
2026-05-08 11:19:56 +01:00
parent e38332de86
commit 8df57ba50d
3 changed files with 187 additions and 12 deletions
@@ -198,6 +198,11 @@ redeclared in host-agent or settings-table branches. Host-profile detection is
an identity fact and must not depend on optional storage probes succeeding; an
Unraid host still reports the governed `unraid` profile and `linux` runtime
platform when `mdcmd` or array-topology collection is unavailable.
First-class platform hosts that also run the Pulse Agent must keep the same
operator-facing system identity split: a Proxmox VE node may report a Debian
runtime platform underneath, but the host-agent OS identity and infrastructure
System badge must resolve and present `Proxmox VE` / `PVE` from PVE runtime
evidence instead of exposing the Debian base distro as the primary system label.
The lifecycle-owned infrastructure source manager also owns platform/system
grouping as source-management content, but not its table band presentation:
`frontend-modern/src/components/Settings/InfrastructureSourceManager.tsx` must
+122
View File
@@ -78,6 +78,128 @@ func TestNew_AllowsInsecureRemoteHTTPPulseURL(t *testing.T) {
}
}
func TestNew_ResolvesProxmoxVEHostIdentity(t *testing.T) {
mc := &mockCollector{
hostInfoFn: func(context.Context) (*gohost.InfoStat, error) {
return &gohost.InfoStat{
Hostname: "pve-host",
HostID: "hid",
Platform: "debian",
PlatformFamily: "debian",
PlatformVersion: "13.4",
KernelVersion: "7.0.0-3-pve",
KernelArch: runtime.GOARCH,
}, nil
},
statFn: func(name string) (os.FileInfo, error) {
if name == "/etc/pve" {
return nil, nil
}
return nil, os.ErrNotExist
},
lookPathFn: func(file string) (string, error) {
if file == "pveversion" {
return "/usr/bin/pveversion", nil
}
return "", os.ErrNotExist
},
commandCombinedOutputFn: func(ctx context.Context, name string, arg ...string) (string, error) {
if name != "/usr/bin/pveversion" {
t.Fatalf("command name = %q, want /usr/bin/pveversion", name)
}
return "pve-manager/9.1.9/ee7bad0a3d1546c9 (running kernel: 7.0.0-3-pve)", nil
},
}
agent, err := New(Config{
APIToken: "token",
LogLevel: zerolog.InfoLevel,
Collector: mc,
})
if err != nil {
t.Fatalf("New() error = %v", err)
}
if agent.platform != "debian" {
t.Fatalf("platform = %q, want Debian runtime platform", agent.platform)
}
if agent.osName != proxmoxPVEOSName {
t.Fatalf("osName = %q, want %q", agent.osName, proxmoxPVEOSName)
}
if agent.osVersion != "9.1.9" {
t.Fatalf("osVersion = %q, want 9.1.9", agent.osVersion)
}
}
func TestNew_DoesNotUseDebianVersionForProxmoxVE(t *testing.T) {
mc := &mockCollector{
hostInfoFn: func(context.Context) (*gohost.InfoStat, error) {
return &gohost.InfoStat{
Hostname: "pve-host",
HostID: "hid",
Platform: "debian",
PlatformFamily: "debian",
PlatformVersion: "13.4",
KernelVersion: "7.0.0-3-pve",
KernelArch: runtime.GOARCH,
}, nil
},
statFn: func(name string) (os.FileInfo, error) {
if name == "/etc/pve" {
return nil, nil
}
return nil, os.ErrNotExist
},
lookPathFn: func(file string) (string, error) {
return "", os.ErrNotExist
},
}
agent, err := New(Config{
APIToken: "token",
LogLevel: zerolog.InfoLevel,
Collector: mc,
})
if err != nil {
t.Fatalf("New() error = %v", err)
}
if agent.osName != proxmoxPVEOSName {
t.Fatalf("osName = %q, want %q", agent.osName, proxmoxPVEOSName)
}
if agent.osVersion != "" {
t.Fatalf("osVersion = %q, want empty Proxmox version when pveversion is unavailable", agent.osVersion)
}
}
func TestCleanProxmoxPVEVersion(t *testing.T) {
for _, tt := range []struct {
name string
raw string
want string
}{
{
name: "short output",
raw: "pve-manager/9.1.9/ee7bad0a3d1546c9 (running kernel: 7.0.0-3-pve)",
want: "9.1.9",
},
{
name: "verbose output",
raw: "proxmox-ve: 9.1-1\npve-manager/9.1.9/ee7bad0a3d1546c9\n",
want: "9.1.9",
},
{
name: "unrelated",
raw: "Debian GNU/Linux 13",
want: "",
},
} {
t.Run(tt.name, func(t *testing.T) {
if got := cleanProxmoxPVEVersion(tt.raw); got != tt.want {
t.Fatalf("cleanProxmoxPVEVersion() = %q, want %q", got, tt.want)
}
})
}
}
func TestNew_RequiresAPITokenWhenEnrollmentEnabled(t *testing.T) {
mc := &mockCollector{
hostInfoFn: func(context.Context) (*gohost.InfoStat, error) {
+60 -12
View File
@@ -1,11 +1,16 @@
package hostagent
import (
"context"
"regexp"
"strings"
"time"
)
var unraidVersionPattern = regexp.MustCompile(`\b\d+(?:\.\d+)+(?:[-+._][A-Za-z0-9]+)*\b|\b\d+\b`)
var proxmoxPVEVersionPattern = regexp.MustCompile(`(?i)\bpve-manager/([^/\s]+)`)
const proxmoxPVEOSName = "Proxmox VE"
func resolveHostOSIdentity(collector SystemCollector, osName, osVersion string) (string, string) {
currentName := strings.TrimSpace(osName)
@@ -16,29 +21,32 @@ func resolveHostOSIdentity(collector SystemCollector, osName, osVersion string)
}
if name, version, ok := detectSynologyOSIdentity(collector); ok {
if version == "" {
version = currentVersion
}
return name, strings.TrimSpace(version)
return resolvedDetectedHostOSIdentity(name, version, currentVersion, true)
}
if name, version, ok := detectQNAPOSIdentity(collector); ok {
if version == "" {
version = currentVersion
}
return name, strings.TrimSpace(version)
return resolvedDetectedHostOSIdentity(name, version, currentVersion, true)
}
if name, version, ok := detectUnraidOSIdentity(collector); ok {
if version == "" {
version = currentVersion
}
return name, strings.TrimSpace(version)
return resolvedDetectedHostOSIdentity(name, version, currentVersion, true)
}
if name, version, ok := detectProxmoxVEOSIdentity(collector); ok {
return resolvedDetectedHostOSIdentity(name, version, currentVersion, false)
}
return currentName, currentVersion
}
func resolvedDetectedHostOSIdentity(name, version, currentVersion string, allowVersionFallback bool) (string, string) {
version = strings.TrimSpace(version)
if version == "" && allowVersionFallback {
version = currentVersion
}
return strings.TrimSpace(name), strings.TrimSpace(version)
}
func detectSynologyOSIdentity(collector SystemCollector) (string, string, bool) {
hasSynologyDir := false
if _, err := collector.Stat("/usr/syno"); err == nil {
@@ -163,6 +171,46 @@ func detectUnraidOSIdentity(collector SystemCollector) (string, string, bool) {
return "Unraid", version, true
}
func detectProxmoxVEOSIdentity(collector SystemCollector) (string, string, bool) {
hasPVE := false
if _, err := collector.Stat("/etc/pve"); err == nil {
hasPVE = true
}
pveVersionPath, err := collector.LookPath("pveversion")
if err == nil {
hasPVE = true
}
if !hasPVE {
if _, err := collector.LookPath("pvesh"); err != nil {
return "", "", false
}
}
version := ""
if pveVersionPath != "" {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
if output, err := collector.CommandCombinedOutput(ctx, pveVersionPath); err == nil {
version = cleanProxmoxPVEVersion(output)
}
}
return proxmoxPVEOSName, version, true
}
func cleanProxmoxPVEVersion(raw string) string {
raw = strings.TrimSpace(raw)
if raw == "" {
return ""
}
if match := proxmoxPVEVersionPattern.FindStringSubmatch(raw); len(match) == 2 {
return strings.TrimSpace(match[1])
}
return ""
}
func detectUnraidOSReleaseIdentity(collector SystemCollector) (string, string, bool) {
data, err := collector.ReadFile("/etc/os-release")
if err != nil || len(data) == 0 {