From 8df57ba50d259901155e77484d4d7cf341d7da07 Mon Sep 17 00:00:00 2001 From: rcourtman Date: Fri, 8 May 2026 11:19:56 +0100 Subject: [PATCH] Detect Proxmox VE host agent OS identity --- .../v6/internal/subsystems/agent-lifecycle.md | 5 + internal/hostagent/agent_new_test.go | 122 ++++++++++++++++++ internal/hostagent/os_identity.go | 72 +++++++++-- 3 files changed, 187 insertions(+), 12 deletions(-) diff --git a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md index 18eea0e4d..cd5d7617b 100644 --- a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md +++ b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md @@ -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 diff --git a/internal/hostagent/agent_new_test.go b/internal/hostagent/agent_new_test.go index 7961b0b9a..f79ce1d06 100644 --- a/internal/hostagent/agent_new_test.go +++ b/internal/hostagent/agent_new_test.go @@ -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) { diff --git a/internal/hostagent/os_identity.go b/internal/hostagent/os_identity.go index 4b5521546..12f86cf71 100644 --- a/internal/hostagent/os_identity.go +++ b/internal/hostagent/os_identity.go @@ -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 {