This commit is contained in:
rcourtman
2026-07-11 18:25:17 +01:00
parent 175309b8f7
commit 1d3b8e1949
19 changed files with 682 additions and 86 deletions
@@ -5,6 +5,7 @@ import infrastructureInstallerSectionSource from '../InfrastructureInstallerSect
import infrastructureOperationsModelSource from '../infrastructureOperationsModel.tsx?raw';
import useInfrastructureConfiguredNodesStateSource from '../useInfrastructureConfiguredNodesState.ts?raw';
import useInfrastructureInstallStateSource from '../useInfrastructureInstallState.tsx?raw';
import { resolveAgentCommandPlatform } from '@/utils/agentInstallCommand';
import {
INSTALL_PROFILE_OPTIONS,
getCapabilityManagementPath,
@@ -420,6 +421,20 @@ describe('infrastructure operations model', () => {
expect(unixUpgradeSource).not.toContain('--hostname');
});
it('resolves connection upgrade platforms through the shared caption-tolerant resolver', async () => {
const operationsStateSource = await import('../useInfrastructureOperationsState?raw').then(
(mod) => (mod as { default: string }).default,
);
// Legacy agents report gopsutil OS captions ("microsoft windows 11 pro"),
// so the hook must route through resolveAgentCommandPlatform instead of
// exact-matching platform tokens locally (refs #1555).
expect(operationsStateSource).toContain(
'resolveAgentCommandPlatform(connection.agentIdentity?.platform)',
);
expect(resolveAgentCommandPlatform('microsoft windows 11 pro')).toBe('windows');
expect(resolveAgentCommandPlatform('linux')).toBe('linux');
});
it('keeps discovered-node filtering anchored to canonical represented-host dedupe', async () => {
const discoveryStateSource = await import('../useInfrastructureDiscoveryRuntimeState?raw').then(
(mod) => (mod as { default: string }).default,
@@ -4,6 +4,7 @@ import {
buildPowerShellInstallScriptBootstrap,
buildWindowsAgentInstallCommand,
powerShellQuote,
resolveAgentCommandPlatform,
} from '@/utils/agentInstallCommand';
import {
TOKEN_PLACEHOLDER,
@@ -83,20 +84,8 @@ export const useInfrastructureOperationsState = (
if (address && !address.includes('://')) return address;
return connection.name?.trim() || '';
};
const getConnectionUpgradePlatform = (connection: Connection): AgentPlatform => {
const platform = connection.agentIdentity?.platform?.trim().toLowerCase();
switch (platform) {
case 'windows':
return 'windows';
case 'darwin':
case 'macos':
return 'macos';
case 'freebsd':
return 'freebsd';
default:
return 'linux';
}
};
const getConnectionUpgradePlatform = (connection: Connection): AgentPlatform =>
resolveAgentCommandPlatform(connection.agentIdentity?.platform);
const getUninstallCommand = (row?: UnifiedAgentRow) => {
const url = installState.selectedAgentUrl();
@@ -3,6 +3,7 @@ import {
buildUnixAgentInstallCommand,
buildWindowsAgentInstallCommand,
normalizeInstallerBaseUrl,
resolveAgentCommandPlatform,
resolveInstallerBaseUrl,
} from '../agentInstallCommand';
@@ -230,3 +231,29 @@ describe('agentInstallCommand', () => {
expect(command).not.toContain('$env:PULSE_TOKEN=');
});
});
describe('resolveAgentCommandPlatform', () => {
it('maps legacy gopsutil Windows captions to windows (refs #1555)', () => {
expect(resolveAgentCommandPlatform('microsoft windows 11 pro')).toBe('windows');
expect(resolveAgentCommandPlatform('Microsoft Windows Server 2022 Standard')).toBe('windows');
expect(resolveAgentCommandPlatform('windows')).toBe('windows');
});
it('maps macOS variants to macos', () => {
expect(resolveAgentCommandPlatform('darwin')).toBe('macos');
expect(resolveAgentCommandPlatform('macos')).toBe('macos');
expect(resolveAgentCommandPlatform('Mac OS X')).toBe('macos');
});
it('maps FreeBSD variants to freebsd', () => {
expect(resolveAgentCommandPlatform('freebsd')).toBe('freebsd');
expect(resolveAgentCommandPlatform('FreeBSD 14.1-RELEASE')).toBe('freebsd');
});
it('defaults Linux distros and unknown values to linux', () => {
expect(resolveAgentCommandPlatform('ubuntu')).toBe('linux');
expect(resolveAgentCommandPlatform('')).toBe('linux');
expect(resolveAgentCommandPlatform(undefined)).toBe('linux');
expect(resolveAgentCommandPlatform(null)).toBe('linux');
});
});
@@ -4,6 +4,29 @@ export const powerShellQuote = (value: string) =>
export const normalizeInstallerBaseUrl = (baseUrl: string) => baseUrl.replace(/\/+$/, '');
export type AgentCommandPlatform = 'linux' | 'macos' | 'freebsd' | 'windows';
// Legacy agents report gopsutil's host.Info().Platform verbatim — a
// descriptive OS caption such as "microsoft windows 11 pro" — so matching
// must tolerate captions, not just exact tokens (refs #1555). Mirrors the
// backend's platformsupport.AgentCommandPlatform; unmatched values are Linux
// distro names, for which the shell installer is correct.
export const resolveAgentCommandPlatform = (platform?: string | null): AgentCommandPlatform => {
const normalized = platform?.trim().toLowerCase() ?? '';
if (normalized.includes('windows')) return 'windows';
if (
normalized === 'darwin' ||
normalized === 'mac' ||
normalized === 'macos' ||
normalized.includes('mac os') ||
normalized.includes('os x')
) {
return 'macos';
}
if (normalized.includes('freebsd')) return 'freebsd';
return 'linux';
};
export const resolveInstallerBaseUrl = (customBaseUrl: string, fallbackBaseUrl: string) => {
const normalizedCustomBaseUrl = normalizeInstallerBaseUrl(customBaseUrl.trim());
if (normalizedCustomBaseUrl) {
+4 -9
View File
@@ -1187,16 +1187,11 @@ func remoteDurationSetting(settings map[string]interface{}, key string) (time.Du
}
func normalisePlatform(platform string) string {
platform = strings.ToLower(strings.TrimSpace(platform))
switch platform {
case "darwin":
return "macos"
default:
if runtimePlatform := platformsupport.RuntimePlatformForHostIdentityToken(platform); runtimePlatform != "" {
return runtimePlatform
}
return platform
normalized := platformsupport.NormalizeAgentReportedPlatform(platform)
if runtimePlatform := platformsupport.RuntimePlatformForHostIdentityToken(normalized); runtimePlatform != "" {
return runtimePlatform
}
return normalized
}
func normalizePulseURL(rawURL string) (string, error) {
+23
View File
@@ -1206,3 +1206,26 @@ func TestExecuteCommandPayload_TrustedBypassesAgentApprovalGate(t *testing.T) {
t.Fatalf("toAgentExecPayload dropped Trusted field; round-trip must preserve it")
}
}
func TestNormalisePlatformCanonicalisesReportedCaptions(t *testing.T) {
cases := []struct {
name string
platform string
want string
}{
// gopsutil reports the descriptive OS caption on Windows, not a
// canonical token (refs #1555).
{"windows caption", "Microsoft Windows 11 Pro", "windows"},
{"darwin", "darwin", "macos"},
{"freebsd caption", "FreeBSD 14.1-RELEASE", "freebsd"},
{"linux distro preserved", "ubuntu", "ubuntu"},
{"unraid maps to linux runtime", "unraid", "linux"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := normalisePlatform(tc.platform); got != tc.want {
t.Fatalf("normalisePlatform(%q) = %q, want %q", tc.platform, got, tc.want)
}
})
}
}
@@ -5,6 +5,7 @@ import (
"strings"
"github.com/rcourtman/pulse-go-rewrite/internal/models"
"github.com/rcourtman/pulse-go-rewrite/internal/platformsupport"
unifiedresources "github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources"
)
@@ -535,17 +536,11 @@ func connectedInfrastructureUninstallAgentID(resource unifiedresources.Resource)
}
func connectedInfrastructureUpgradePlatform(resource unifiedresources.Resource) string {
platform := ""
if resource.Agent != nil {
switch strings.ToLower(strings.TrimSpace(resource.Agent.Platform)) {
case "windows":
return "windows"
case "darwin", "macos", "mac":
return "macos"
case "freebsd":
return "freebsd"
}
platform = resource.Agent.Platform
}
return "linux"
return platformsupport.AgentCommandPlatform(platform)
}
func connectedInfrastructureAgentSurface(
@@ -354,3 +354,49 @@ func TestBuildConnectedInfrastructure_IgnoresChildPlatformResources(t *testing.T
t.Fatalf("expected child platform resources to stay out of connected infrastructure, got %#v", items)
}
}
func TestBuildConnectedInfrastructure_ResolvesLegacyAgentUpgradePlatform(t *testing.T) {
now := time.Unix(1_700_000_000, 0)
cases := []struct {
name string
platform string
want string
}{
// Legacy v5 agents report gopsutil's host.Info().Platform verbatim
// instead of a canonical token (refs #1555).
{"legacy windows caption", "microsoft windows 11 pro", "windows"},
{"canonical windows", "windows", "windows"},
{"darwin", "darwin", "macos"},
{"freebsd caption", "FreeBSD 14.1-RELEASE", "freebsd"},
{"linux distro", "ubuntu", "linux"},
{"empty", "", "linux"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
items := buildConnectedInfrastructure([]unifiedresources.Resource{
{
ID: "host-resource",
Name: "Host",
LastSeen: now,
Status: unifiedresources.StatusOnline,
Agent: &unifiedresources.AgentData{
AgentID: "host-agent",
AgentVersion: "5.1.36",
Hostname: "host.local",
Platform: tc.platform,
},
Identity: unifiedresources.ResourceIdentity{
Hostnames: []string{"host.local"},
},
},
}, models.StateSnapshot{})
if len(items) != 1 {
t.Fatalf("expected 1 connected infrastructure item, got %d", len(items))
}
if items[0].UpgradePlatform != tc.want {
t.Fatalf("expected upgrade platform %q for reported platform %q, got %q", tc.want, tc.platform, items[0].UpgradePlatform)
}
})
}
}
+3 -2
View File
@@ -11,6 +11,7 @@ import (
"github.com/rcourtman/pulse-go-rewrite/internal/config"
"github.com/rcourtman/pulse-go-rewrite/internal/logging"
"github.com/rcourtman/pulse-go-rewrite/internal/models"
"github.com/rcourtman/pulse-go-rewrite/internal/platformsupport"
"github.com/rcourtman/pulse-go-rewrite/internal/remoteconfig"
"github.com/rcourtman/pulse-go-rewrite/internal/storagehealth"
"github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources"
@@ -912,7 +913,7 @@ func hostFromContinuityEntry(entry config.HostContinuityEntry) models.Host {
AgentVersion: strings.TrimSpace(entry.AgentVersion),
MachineID: strings.TrimSpace(entry.MachineID),
TokenID: strings.TrimSpace(entry.TokenID),
Platform: strings.TrimSpace(entry.Platform),
Platform: platformsupport.NormalizeAgentReportedPlatform(entry.Platform),
IsLegacy: entry.IsLegacy,
LinkedNodeID: strings.TrimSpace(entry.LinkedNodeID),
LinkedVMID: strings.TrimSpace(entry.LinkedVMID),
@@ -1959,7 +1960,7 @@ func (m *Monitor) ApplyHostReport(report agentshost.Report, tokenRecord *config.
ID: identifier,
Hostname: hostname,
DisplayName: displayName,
Platform: strings.TrimSpace(strings.ToLower(report.Host.Platform)),
Platform: platformsupport.NormalizeAgentReportedPlatform(report.Host.Platform),
OSName: strings.TrimSpace(report.Host.OSName),
OSVersion: strings.TrimSpace(report.Host.OSVersion),
KernelVersion: strings.TrimSpace(report.Host.KernelVersion),
@@ -1,6 +1,7 @@
package monitoring
import (
"fmt"
"net"
"strings"
"testing"
@@ -3444,3 +3445,52 @@ func TestApplyHostReportMapsReclaimableMemoryCache(t *testing.T) {
t.Fatalf("clamped memory cache = %d, want %d", got, want)
}
}
func TestApplyHostReportNormalizesLegacyAgentPlatformAcceptedIngestProof(t *testing.T) {
monitor := &Monitor{
state: models.NewState(),
alertManager: alerts.NewManager(),
hostTokenBindings: make(map[string]string),
config: &config.Config{},
rateTracker: NewRateTracker(),
}
t.Cleanup(func() { monitor.alertManager.Stop() })
now := time.Now().UTC()
cases := []struct {
name string
platform string
want string
}{
// Legacy v5 agents report gopsutil's host.Info().Platform verbatim,
// e.g. "microsoft windows 11 pro" on Windows (refs #1555).
{"legacy windows caption", "microsoft windows 11 pro", "windows"},
{"darwin", "darwin", "macos"},
{"linux distro preserved", "Ubuntu", "ubuntu"},
}
for index, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
report := agentshost.Report{
Agent: agentshost.AgentInfo{
ID: fmt.Sprintf("agent-platform-%d", index),
Version: "5.1.36",
IntervalSeconds: 30,
},
Host: agentshost.HostInfo{
ID: fmt.Sprintf("machine-platform-%d", index),
Hostname: fmt.Sprintf("host-platform-%d", index),
Platform: tc.platform,
},
Timestamp: now,
}
host, err := monitor.ApplyHostReport(report, &config.APITokenRecord{ID: fmt.Sprintf("token-platform-%d", index), Name: "Platform Token"})
if err != nil {
t.Fatalf("ApplyHostReport: %v", err)
}
if host.Platform != tc.want {
t.Fatalf("expected ingested platform %q for reported %q, got %q", tc.want, tc.platform, host.Platform)
}
})
}
}
@@ -0,0 +1,52 @@
package platformsupport
import "strings"
// Canonical agent runtime platforms used by agent install/upgrade command
// surfaces.
const (
RuntimePlatformLinux = "linux"
RuntimePlatformMacOS = "macos"
RuntimePlatformFreeBSD = "freebsd"
RuntimePlatformWindows = "windows"
)
// NormalizeAgentReportedPlatform maps a raw agent-reported platform string
// onto the canonical runtime platform vocabulary. Legacy agents report
// gopsutil's host.Info().Platform verbatim — a descriptive OS caption such as
// "microsoft windows 11 pro" on Windows or "darwin" on macOS — so matching
// must tolerate captions, not just exact tokens (refs #1555). Values without
// a canonical mapping (Linux distro names such as "ubuntu") are returned
// trimmed and lowercased so distro identity is preserved.
func NormalizeAgentReportedPlatform(platform string) string {
normalized := strings.ToLower(strings.TrimSpace(platform))
switch {
case normalized == "":
return ""
case strings.Contains(normalized, "windows"):
return RuntimePlatformWindows
case normalized == "darwin" || normalized == "mac" || normalized == "macos" ||
strings.Contains(normalized, "mac os") || strings.Contains(normalized, "os x"):
return RuntimePlatformMacOS
case strings.Contains(normalized, "freebsd"):
return RuntimePlatformFreeBSD
}
return normalized
}
// AgentCommandPlatform resolves the install/upgrade command platform for an
// agent-reported platform string. Anything without a canonical non-Linux
// mapping resolves to linux: the long tail of unmatched values is Linux
// distro names, for which the shell installer is correct.
func AgentCommandPlatform(platform string) string {
switch NormalizeAgentReportedPlatform(platform) {
case RuntimePlatformWindows:
return RuntimePlatformWindows
case RuntimePlatformMacOS:
return RuntimePlatformMacOS
case RuntimePlatformFreeBSD:
return RuntimePlatformFreeBSD
default:
return RuntimePlatformLinux
}
}
@@ -0,0 +1,56 @@
package platformsupport
import "testing"
func TestNormalizeAgentReportedPlatform(t *testing.T) {
cases := []struct {
name string
platform string
want string
}{
{"empty", "", ""},
{"whitespace", " ", ""},
{"legacy windows caption", "microsoft windows 11 pro", RuntimePlatformWindows},
{"legacy windows caption mixed case", "Microsoft Windows 11 Pro", RuntimePlatformWindows},
{"windows server caption", "Microsoft Windows Server 2022 Standard", RuntimePlatformWindows},
{"exact windows", "windows", RuntimePlatformWindows},
{"darwin", "darwin", RuntimePlatformMacOS},
{"macos", "macos", RuntimePlatformMacOS},
{"mac", "mac", RuntimePlatformMacOS},
{"mac os x caption", "Mac OS X", RuntimePlatformMacOS},
{"freebsd", "freebsd", RuntimePlatformFreeBSD},
{"freebsd with version", "FreeBSD 14.1-RELEASE", RuntimePlatformFreeBSD},
{"linux distro preserved", "ubuntu", "ubuntu"},
{"linux distro lowercased", "Debian GNU/Linux", "debian gnu/linux"},
{"unraid preserved", "unraid", "unraid"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := NormalizeAgentReportedPlatform(tc.platform); got != tc.want {
t.Fatalf("NormalizeAgentReportedPlatform(%q) = %q, want %q", tc.platform, got, tc.want)
}
})
}
}
func TestAgentCommandPlatform(t *testing.T) {
cases := []struct {
name string
platform string
want string
}{
{"legacy windows caption", "microsoft windows 11 pro", RuntimePlatformWindows},
{"darwin", "darwin", RuntimePlatformMacOS},
{"freebsd caption", "FreeBSD 14.1-RELEASE", RuntimePlatformFreeBSD},
{"linux distro defaults to linux", "ubuntu", RuntimePlatformLinux},
{"empty defaults to linux", "", RuntimePlatformLinux},
{"unknown defaults to linux", "beos", RuntimePlatformLinux},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := AgentCommandPlatform(tc.platform); got != tc.want {
t.Fatalf("AgentCommandPlatform(%q) = %q, want %q", tc.platform, got, tc.want)
}
})
}
}
+60 -20
View File
@@ -13,20 +13,30 @@ type identityPinIndex struct {
byCanonicalID map[string]ResourceIdentityPin
byMachineID map[string]ResourceIdentityPin
byDMIUUID map[string]ResourceIdentityPin
byClusterHost map[string]ResourceIdentityPin
// byHostname holds pins per normalized hostname. Hostnames are not
// unique across machines, so lookups through this map require the
// byClusterHost buckets pins per cluster + full hostname, and
// byClusterShortHost per cluster + short hostname; byHostname and
// byShortHostname are the cluster-less equivalents. Full-hostname hits
// are authoritative; short buckets only resolve when they are
// unambiguous AND the pinned hostname is short/FQDN-equivalent to the
// incoming one, so distinct dotted hostnames that share a short name
// (cloud.rnd-lax1 vs cloud.gce-or1) never cross-match. Hostnames are
// not unique across machines, so every bucket lookup requires the
// bucket to be unambiguous.
byHostname map[string][]ResourceIdentityPin
byClusterHost map[string][]ResourceIdentityPin
byClusterShortHost map[string][]ResourceIdentityPin
byHostname map[string][]ResourceIdentityPin
byShortHostname map[string][]ResourceIdentityPin
}
func newIdentityPinIndex(pins []ResourceIdentityPin) *identityPinIndex {
index := &identityPinIndex{
byCanonicalID: make(map[string]ResourceIdentityPin, len(pins)),
byMachineID: make(map[string]ResourceIdentityPin),
byDMIUUID: make(map[string]ResourceIdentityPin),
byClusterHost: make(map[string]ResourceIdentityPin),
byHostname: make(map[string][]ResourceIdentityPin),
byCanonicalID: make(map[string]ResourceIdentityPin, len(pins)),
byMachineID: make(map[string]ResourceIdentityPin),
byDMIUUID: make(map[string]ResourceIdentityPin),
byClusterHost: make(map[string][]ResourceIdentityPin),
byClusterShortHost: make(map[string][]ResourceIdentityPin),
byHostname: make(map[string][]ResourceIdentityPin),
byShortHostname: make(map[string][]ResourceIdentityPin),
}
for _, pin := range pins {
pin = pin.normalized()
@@ -40,18 +50,24 @@ func newIdentityPinIndex(pins []ResourceIdentityPin) *identityPinIndex {
if pin.DMIUUID != "" {
index.byDMIUUID[pin.DMIUUID] = pin
}
if pin.ClusterName != "" && pin.Hostname != "" {
index.byClusterHost[clusterHostPinKey(pin.ClusterName, pin.Hostname)] = pin
if pin.Hostname == "" {
continue
}
if pin.Hostname != "" {
index.byHostname[pin.Hostname] = append(index.byHostname[pin.Hostname], pin)
shortHostname := NormalizeHostname(pin.Hostname)
if pin.ClusterName != "" {
fullKey := clusterHostPinKey(pin.ClusterName, pin.Hostname)
index.byClusterHost[fullKey] = append(index.byClusterHost[fullKey], pin)
shortKey := clusterHostPinKey(pin.ClusterName, shortHostname)
index.byClusterShortHost[shortKey] = append(index.byClusterShortHost[shortKey], pin)
}
index.byHostname[pin.Hostname] = append(index.byHostname[pin.Hostname], pin)
index.byShortHostname[shortHostname] = append(index.byShortHostname[shortHostname], pin)
}
return index
}
func clusterHostPinKey(clusterName, hostname string) string {
return strings.ToLower(strings.TrimSpace(clusterName)) + "\x00" + NormalizeHostname(hostname)
return strings.ToLower(strings.TrimSpace(clusterName)) + "\x00" + NormalizeFullHostname(hostname)
}
// find resolves the pin for an incoming identity, strongest key first. A pin
@@ -76,23 +92,47 @@ func (index *identityPinIndex) find(identity ResourceIdentity) (ResourceIdentity
}
clusterName := strings.TrimSpace(identity.ClusterName)
for _, hostname := range identity.Hostnames {
normalized := NormalizeHostname(hostname)
if normalized == "" {
fullHostname := NormalizeFullHostname(hostname)
if fullHostname == "" {
continue
}
shortHostname := NormalizeHostname(fullHostname)
if clusterName != "" {
if pin, ok := index.byClusterHost[clusterHostPinKey(clusterName, normalized)]; ok && pinCompatible(pin, machineID, dmiUUID) {
if pin, ok := resolvePinBucket(index.byClusterHost[clusterHostPinKey(clusterName, fullHostname)], fullHostname, machineID, dmiUUID); ok {
return pin, true
}
if pin, ok := resolvePinBucket(index.byClusterShortHost[clusterHostPinKey(clusterName, shortHostname)], fullHostname, machineID, dmiUUID); ok {
return pin, true
}
}
bucket := index.byHostname[normalized]
if len(bucket) == 1 && pinCompatible(bucket[0], machineID, dmiUUID) {
return bucket[0], true
if pin, ok := resolvePinBucket(index.byHostname[fullHostname], fullHostname, machineID, dmiUUID); ok {
return pin, true
}
if pin, ok := resolvePinBucket(index.byShortHostname[shortHostname], fullHostname, machineID, dmiUUID); ok {
return pin, true
}
}
return ResourceIdentityPin{}, false
}
// resolvePinBucket resolves a hostname bucket lookup. The bucket must be
// unambiguous (exactly one pin), the pinned hostname must be the incoming one
// or its short/FQDN equivalent (two distinct FQDNs sharing a short name never
// match), and the incoming strong keys must not contradict the pin.
func resolvePinBucket(bucket []ResourceIdentityPin, hostname, machineID, dmiUUID string) (ResourceIdentityPin, bool) {
if len(bucket) != 1 {
return ResourceIdentityPin{}, false
}
pin := bucket[0]
if pin.Hostname != hostname && !HostnamesEquivalent(pin.Hostname, hostname) {
return ResourceIdentityPin{}, false
}
if !pinCompatible(pin, machineID, dmiUUID) {
return ResourceIdentityPin{}, false
}
return pin, true
}
// pinCompatible reports whether an incoming identity's strong keys are
// consistent with the pin. Empty incoming keys never contradict; the pin's
// own empty keys never contradict either.
@@ -0,0 +1,164 @@
package unifiedresources
import "testing"
// The #1559 shape: several standalone agents whose hostnames share a short
// name (cloud.rnd-lax1, cloud.gce-or1, cloud.dmi-lax1). The pin index must
// resolve each full hostname to its own machine and refuse the ambiguous
// short name instead of handing one machine's pin to another.
func TestIdentityPinIndexKeepsDottedHostnamesDistinct(t *testing.T) {
pins := []ResourceIdentityPin{
{CanonicalID: "agent-rnd", ResourceType: ResourceTypeAgent, MachineID: "machine-rnd", Hostname: "cloud.rnd-lax1"},
{CanonicalID: "agent-gce", ResourceType: ResourceTypeAgent, MachineID: "machine-gce", Hostname: "cloud.gce-or1"},
{CanonicalID: "agent-dmi", ResourceType: ResourceTypeAgent, MachineID: "machine-dmi", Hostname: "cloud.dmi-lax1"},
}
index := newIdentityPinIndex(pins)
for _, pin := range pins {
got, ok := index.find(ResourceIdentity{Hostnames: []string{pin.Hostname}})
if !ok {
t.Fatalf("expected a pin match for %q", pin.Hostname)
}
if got.CanonicalID != pin.CanonicalID {
t.Fatalf("hostname %q resolved pin %q, want %q", pin.Hostname, got.CanonicalID, pin.CanonicalID)
}
}
if pin, ok := index.find(ResourceIdentity{Hostnames: []string{"cloud"}}); ok {
t.Fatalf("ambiguous short hostname must not resolve a pin, got %q", pin.CanonicalID)
}
if pin, ok := index.find(ResourceIdentity{Hostnames: []string{"cloud.aws-fra1"}}); ok {
t.Fatalf("unknown dotted sibling must not borrow another machine's pin, got %q", pin.CanonicalID)
}
}
func TestIdentityPinIndexShortAndFQDNStayEquivalent(t *testing.T) {
index := newIdentityPinIndex([]ResourceIdentityPin{
{CanonicalID: "agent-web", ResourceType: ResourceTypeAgent, MachineID: "machine-web", Hostname: "web01.lan"},
})
if pin, ok := index.find(ResourceIdentity{Hostnames: []string{"web01"}}); !ok || pin.CanonicalID != "agent-web" {
t.Fatalf("short hostname should resolve its own FQDN pin, got ok=%v pin=%q", ok, pin.CanonicalID)
}
if pin, ok := index.find(ResourceIdentity{Hostnames: []string{"web01.example.com"}}); ok {
t.Fatalf("a different FQDN sharing the short name must not match, got %q", pin.CanonicalID)
}
if pin, ok := index.find(ResourceIdentity{Hostnames: []string{"web01"}, MachineID: "machine-other"}); ok {
t.Fatalf("a contradicting machine ID must refuse the pin, got %q", pin.CanonicalID)
}
}
func TestIdentityPinIndexClusterLookupsUseFullHostname(t *testing.T) {
index := newIdentityPinIndex([]ResourceIdentityPin{
{CanonicalID: "agent-delly", ResourceType: ResourceTypeAgent, MachineID: "machine-delly", ClusterName: "homelab", Hostname: "delly.lan"},
})
// A PVE boot-window node record only knows cluster + short node name.
if pin, ok := index.find(ResourceIdentity{ClusterName: "homelab", Hostnames: []string{"delly"}}); !ok || pin.CanonicalID != "agent-delly" {
t.Fatalf("cluster + short hostname should resolve the FQDN pin, got ok=%v pin=%q", ok, pin.CanonicalID)
}
if pin, ok := index.find(ResourceIdentity{ClusterName: "homelab", Hostnames: []string{"delly.other"}}); ok {
t.Fatalf("a different FQDN in the same cluster must not match, got %q", pin.CanonicalID)
}
}
// Rows persisted before the fix hold the collapsed short hostname. A single
// legacy pin must keep matching its own host's full hostname until the next
// persist heals the row.
func TestIdentityPinIndexLegacyCollapsedPinStillMatches(t *testing.T) {
index := newIdentityPinIndex([]ResourceIdentityPin{
{CanonicalID: "agent-rnd", ResourceType: ResourceTypeAgent, MachineID: "machine-rnd", Hostname: "cloud"},
})
if pin, ok := index.find(ResourceIdentity{Hostnames: []string{"cloud.rnd-lax1"}}); !ok || pin.CanonicalID != "agent-rnd" {
t.Fatalf("legacy collapsed pin should match its host's full hostname, got ok=%v pin=%q", ok, pin.CanonicalID)
}
}
// The end-to-end #1559 scenario: multiple standalone agents with dotted
// hostnames must mint distinct canonical resources, persist full dotted
// hostnames in their pins, and boot-window records that only know the
// hostname must resolve to the right machine instead of the first pin.
func TestStandaloneDottedHostnameAgentsStayDistinct(t *testing.T) {
store := NewMemoryStore()
hosts := []struct {
hostname string
machineID string
}{
{"cloud.rnd-lax1", "machine-rnd"},
{"cloud.gce-or1", "machine-gce"},
{"cloud.dmi-lax1", "machine-dmi"},
}
agentResource := func(hostname, machineID string) Resource {
return Resource{
Type: ResourceTypeAgent,
Name: hostname,
Status: StatusOnline,
Agent: &AgentData{AgentID: machineID, Hostname: hostname, MachineID: machineID},
}
}
steady := NewRegistry(store)
idByHostname := make(map[string]string, len(hosts))
for _, host := range hosts {
identity := ResourceIdentity{MachineID: host.machineID, Hostnames: []string{host.hostname}}
id := steady.ingest(SourceAgent, host.machineID, agentResource(host.hostname, host.machineID), identity)
if id == "" {
t.Fatalf("agent ingest for %q returned no ID", host.hostname)
}
for hostname, existingID := range idByHostname {
if existingID == id {
t.Fatalf("hosts %q and %q collapsed to canonical ID %q", hostname, host.hostname, id)
}
}
idByHostname[host.hostname] = id
}
if got := len(steady.List()); got != len(hosts) {
t.Fatalf("expected %d distinct resources, got %d", len(hosts), got)
}
steady.PersistIdentityPins()
pins, err := store.ListResourceIdentityPins()
if err != nil {
t.Fatalf("ListResourceIdentityPins: %v", err)
}
pinnedHostnames := make(map[string]struct{}, len(pins))
for _, pin := range pins {
pinnedHostnames[pin.Hostname] = struct{}{}
}
for _, host := range hosts {
if _, ok := pinnedHostnames[host.hostname]; !ok {
t.Fatalf("expected pinned primary hostname %q, got %v", host.hostname, pinnedHostnames)
}
}
// Boot window: runtime records that only know the hostname (no machine
// ID yet) arrive before the agents. Each must complete from its own pin
// and land on its own canonical ID.
boot := NewRegistry(store)
for _, host := range hosts {
record := Resource{
Type: ResourceTypeAgent,
Name: host.hostname,
Status: StatusOnline,
Docker: &DockerData{HostSourceID: "docker:" + host.hostname, Hostname: host.hostname},
}
identity := ResourceIdentity{Hostnames: []string{host.hostname}}
id := boot.ingest(SourceDocker, "docker:"+host.hostname, record, identity)
if want := idByHostname[host.hostname]; id != want {
t.Fatalf("boot-window record for %q resolved %q, want its own canonical ID %q", host.hostname, id, want)
}
}
for _, host := range hosts {
identity := ResourceIdentity{MachineID: host.machineID, Hostnames: []string{host.hostname}}
id := boot.ingest(SourceAgent, host.machineID, agentResource(host.hostname, host.machineID), identity)
if want := idByHostname[host.hostname]; id != want {
t.Fatalf("agent ingest for %q resolved %q, want %q", host.hostname, id, want)
}
}
if got := len(boot.List()); got != len(hosts) {
t.Fatalf("expected %d merged resources after boot-window ingest, got %d", len(hosts), got)
}
}
+16 -3
View File
@@ -6,10 +6,14 @@ import (
"strings"
)
// NormalizeHostname lowercases and strips domain suffixes.
// NormalizeHostname lowercases a hostname and strips domain suffixes,
// returning the short (first-label) name. Use it only for equivalence-style
// matching where "web01" and "web01.lan" should land in the same bucket;
// never for persisted or display identity keys, where distinct dotted
// hostnames (cloud.rnd-lax1 vs cloud.gce-or1) must stay distinct — use
// NormalizeFullHostname for those.
func NormalizeHostname(hostname string) string {
host := strings.TrimSpace(strings.ToLower(hostname))
host = strings.TrimSuffix(host, ".")
host := NormalizeFullHostname(hostname)
if host == "" {
return ""
}
@@ -19,6 +23,15 @@ func NormalizeHostname(hostname string) string {
return host
}
// NormalizeFullHostname lowercases and trims a hostname while preserving the
// full dotted name. This is the persisted-identity normalizer: only case,
// surrounding whitespace, and a trailing root dot are dropped, so distinct
// machines that share a short name keep distinct identity keys.
func NormalizeFullHostname(hostname string) string {
host := strings.TrimSpace(strings.ToLower(hostname))
return strings.TrimSuffix(host, ".")
}
// NormalizeMAC normalizes a MAC address to lower-case colon format.
func NormalizeMAC(mac string) string {
mac = strings.TrimSpace(mac)
+15 -6
View File
@@ -48,7 +48,11 @@ type ResourceIdentityPin struct {
MachineID string
DMIUUID string
ClusterName string
// Hostname is the normalized primary hostname (NormalizeHostname).
// Hostname is the normalized full hostname (NormalizeFullHostname).
// Dotted hostnames are preserved so distinct machines that share a short
// name (cloud.rnd-lax1 vs cloud.gce-or1) keep distinct pins. Rows written
// before the fix for #1559 hold the collapsed short name; they heal in
// place on the host's next pin persist.
Hostname string
}
@@ -58,7 +62,7 @@ func (p ResourceIdentityPin) normalized() ResourceIdentityPin {
p.MachineID = strings.TrimSpace(p.MachineID)
p.DMIUUID = strings.TrimSpace(p.DMIUUID)
p.ClusterName = strings.TrimSpace(p.ClusterName)
p.Hostname = NormalizeHostname(p.Hostname)
p.Hostname = NormalizeFullHostname(p.Hostname)
return p
}
@@ -73,7 +77,7 @@ func (p ResourceIdentityPin) hasStrongKey() bool {
// journal eras without rewriting history.
func (p ResourceIdentityPin) EraIDs() []string {
p = p.normalized()
ids := make([]string, 0, 5)
ids := make([]string, 0, 7)
if p.CanonicalID != "" {
ids = append(ids, p.CanonicalID)
}
@@ -84,10 +88,15 @@ func (p ResourceIdentityPin) EraIDs() []string {
ids = append(ids, buildHashID(p.ResourceType, "dmi:"+p.DMIUUID))
}
if p.Hostname != "" {
if p.ClusterName != "" {
ids = append(ids, buildHashID(p.ResourceType, fmt.Sprintf("cluster:%s:%s", p.ClusterName, p.Hostname)))
// The historical chooseNewID ladder hashed the short hostname; the
// pin now preserves the full dotted name, so derive eras for both so
// journal rows written under short-hostname IDs stay readable.
for _, hostname := range uniqueTrimmed(p.Hostname, NormalizeHostname(p.Hostname)) {
if p.ClusterName != "" {
ids = append(ids, buildHashID(p.ResourceType, fmt.Sprintf("cluster:%s:%s", p.ClusterName, hostname)))
}
ids = append(ids, buildHashID(p.ResourceType, "hostname:"+hostname))
}
ids = append(ids, buildHashID(p.ResourceType, "hostname:"+p.Hostname))
}
return uniqueTrimmed(ids...)
}
+4
View File
@@ -52,9 +52,13 @@ func TestResourceIdentityPinEraIDs(t *testing.T) {
}
got := pin.EraIDs()
// The pin preserves the full dotted hostname, and eras cover both the
// full name and the short name the historical derivation hashed.
want := []string{
buildHashID(ResourceTypeAgent, "machine:machine-1"),
buildHashID(ResourceTypeAgent, "dmi:dmi-1"),
buildHashID(ResourceTypeAgent, "cluster:homelab:delly.lan"),
buildHashID(ResourceTypeAgent, "hostname:delly.lan"),
buildHashID(ResourceTypeAgent, "cluster:homelab:delly"),
buildHashID(ResourceTypeAgent, "hostname:delly"),
}
@@ -41,7 +41,7 @@ func coalescePresentationHostResourcesOnce(
}
coalesced := make([]Resource, 0, len(resources))
indexByHostKey := make(map[string]int, len(resources))
indexesByHostKey := make(map[string][]int, len(resources))
parentRedirects := make(map[string]string)
for _, resource := range resources {
resource.Type = CanonicalResourceType(resource.Type)
@@ -51,26 +51,33 @@ func coalescePresentationHostResourcesOnce(
continue
}
existingIndex, ok := indexByHostKey[hostKey]
if !ok {
indexByHostKey[hostKey] = len(coalesced)
coalesced = append(coalesced, resource)
continue
// The host key is the short hostname, so distinct machines with
// dotted hostnames (cloud.rnd-lax1 vs cloud.gce-or1) share a bucket;
// the hostname-compatibility check keeps them from merging while a
// short name still pairs with its own FQDN (web01 vs web01.lan).
merged := false
for _, existingIndex := range indexesByHostKey[hostKey] {
existing := coalesced[existingIndex]
if excluded != nil && excluded(existing, resource) {
continue
}
if !presentationHostnamesCompatible(existing, resource) {
continue
}
if !shouldMergePresentationHostResources(existing, resource) {
continue
}
mergedResource := mergePresentationHostResources(existing, resource)
coalesced[existingIndex] = mergedResource
addPresentationParentRedirect(parentRedirects, existing.ID, mergedResource.ID)
addPresentationParentRedirect(parentRedirects, resource.ID, mergedResource.ID)
merged = true
break
}
existing := coalesced[existingIndex]
if excluded != nil && excluded(existing, resource) {
if !merged {
indexesByHostKey[hostKey] = append(indexesByHostKey[hostKey], len(coalesced))
coalesced = append(coalesced, resource)
continue
}
if !shouldMergePresentationHostResources(existing, resource) {
coalesced = append(coalesced, resource)
continue
}
merged := mergePresentationHostResources(existing, resource)
coalesced[existingIndex] = merged
addPresentationParentRedirect(parentRedirects, existing.ID, merged.ID)
addPresentationParentRedirect(parentRedirects, resource.ID, merged.ID)
}
applyPresentationParentRedirects(coalesced, parentRedirects)
@@ -134,6 +141,16 @@ func presentationHostMergeKey(resource Resource) string {
return ""
}
for _, candidate := range presentationHostnameCandidates(resource) {
normalized := NormalizeHostname(candidate)
if normalized != "" {
return "agent:" + normalized
}
}
return ""
}
func presentationHostnameCandidates(resource Resource) []string {
candidates := []string{}
if resource.Canonical != nil {
candidates = append(candidates, resource.Canonical.PlatformID, resource.Canonical.Hostname)
@@ -146,14 +163,30 @@ func presentationHostMergeKey(resource Resource) string {
candidates = append(candidates, resource.Proxmox.NodeName)
}
candidates = append(candidates, resource.Name)
return candidates
}
for _, candidate := range candidates {
normalized := NormalizeHostname(candidate)
if normalized != "" {
return "agent:" + normalized
// presentationHostnamesCompatible reports whether two host views may describe
// the same machine. Sharing a short hostname is not enough: distinct dotted
// hostnames (cloud.rnd-lax1 vs cloud.gce-or1) belong to distinct machines,
// while a short name still pairs with its own FQDN (web01 vs web01.lan).
func presentationHostnamesCompatible(left, right Resource) bool {
for _, leftName := range presentationHostnameCandidates(left) {
leftFull := NormalizeFullHostname(leftName)
if leftFull == "" {
continue
}
for _, rightName := range presentationHostnameCandidates(right) {
rightFull := NormalizeFullHostname(rightName)
if rightFull == "" {
continue
}
if leftFull == rightFull || HostnamesEquivalent(leftFull, rightFull) {
return true
}
}
}
return ""
return false
}
func shouldMergePresentationHostResources(left, right Resource) bool {
@@ -181,6 +181,67 @@ func TestCoalescePresentationHostResourcesRedirectsProxmoxChildrenToAgentBackedP
}
}
// The #1559 shape: distinct standalone machines whose dotted hostnames share
// a short name (cloud.rnd-lax1 vs cloud.gce-or1) must not coalesce, while a
// short name still pairs with its own FQDN.
func TestCoalescePresentationHostResourcesKeepsDottedHostnameSiblingsApart(t *testing.T) {
now := time.Date(2026, 7, 11, 10, 30, 0, 0, time.UTC)
agentHost := func(id, hostname string, lastSeen time.Time) Resource {
return Resource{
ID: id,
Type: ResourceTypeAgent,
Name: hostname,
Status: StatusOnline,
LastSeen: lastSeen,
Sources: []DataSource{SourceAgent},
Identity: ResourceIdentity{MachineID: "machine-" + id, Hostnames: []string{hostname}},
Agent: &AgentData{AgentID: "machine-" + id, Hostname: hostname},
}
}
dockerHost := func(id, hostname string, lastSeen time.Time) Resource {
return Resource{
ID: id,
Type: ResourceTypeAgent,
Name: hostname,
Status: StatusOnline,
LastSeen: lastSeen,
Sources: []DataSource{SourceDocker},
Identity: ResourceIdentity{Hostnames: []string{hostname}},
Docker: &DockerData{HostSourceID: id, Hostname: hostname},
}
}
resources := []Resource{
agentHost("agent-rnd", "cloud.rnd-lax1", now),
dockerHost("docker-gce", "cloud.gce-or1", now.Add(time.Second)),
dockerHost("docker-rnd", "cloud.rnd-lax1", now.Add(2*time.Second)),
}
coalesced := CoalescePresentationHostResources(resources)
if len(coalesced) != 2 {
t.Fatalf("expected the two cloud.rnd-lax1 views to merge and cloud.gce-or1 to stay apart, got %d: %#v", len(coalesced), coalesced)
}
for _, resource := range coalesced {
if resource.ID == "agent-rnd" {
if resource.Docker == nil || resource.Docker.HostSourceID != "docker-rnd" {
t.Fatalf("expected agent-rnd to absorb its own docker view, got %+v", resource.Docker)
}
}
if resource.ID == "docker-gce" && resource.Agent != nil {
t.Fatalf("cloud.gce-or1 must not absorb another machine's agent view, got %+v", resource.Agent)
}
}
// Short name and its own FQDN still coalesce.
fqdnPair := []Resource{
agentHost("agent-web", "web01.lan", now),
dockerHost("docker-web", "web01", now.Add(time.Second)),
}
if merged := CoalescePresentationHostResources(fqdnPair); len(merged) != 1 {
t.Fatalf("expected short/FQDN views of the same host to coalesce, got %d: %#v", len(merged), merged)
}
}
func TestCoalescePresentationHostResourcesDoesNotMergeRuntimeOnlyNameCollision(t *testing.T) {
now := time.Date(2026, 5, 22, 10, 30, 0, 0, time.UTC)
resources := []Resource{