mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-10 10:35:51 +00:00
Addresses #1555
This commit is contained in:
+15
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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...)
|
||||
}
|
||||
|
||||
@@ -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{
|
||||
|
||||
Reference in New Issue
Block a user