Merge PBS API and agent rows via the PBS-reported node hostname

Connected systems showed a PBS machine twice (API row plus host-agent row)
whenever the PBS connection was configured with an address the agent never
reports, because PBS identity was limited to the configured name and host.
The PBS poll now captures the hostname the node reports about itself
(GET /nodes, the previously unused client GetNodeName) on
models.PBSInstance.NodeName, and /api/connections includes it in the PBS
connection's host aliases, so directPlatformHostAttachment merges the agent
running on that machine into the PBS row the same way PVE composes from
API-reported node identity. Node-name fetch failure stays partial data and
never fails the poll. Reported by Johannes Strasser (rc.5).
This commit is contained in:
rcourtman
2026-08-01 22:09:01 +01:00
parent 121b227bc6
commit 397cad802c
13 changed files with 220 additions and 8 deletions
@@ -2479,6 +2479,12 @@ freshness only. When normalized host identity uniquely proves that a PBS API
source and a Pulse Agent describe the same machine, the grouped systems
payload composes them into one source row with API + Agent coverage; provider
health and agent lifecycle facts remain independently authored inside that row.
That host identity includes the hostname the PBS node reports about itself
(`models.PBSInstance.NodeName`, captured by the PBS poll from `GET /nodes`),
so an IP-or-alias-configured PBS connection still reconciles with the agent's
reported hostname. The reported node name is identity evidence only: it never
enrolls the machine, extends heartbeats, or substitutes for agent-source
identity in lifecycle decisions.
### Docker and Podman report sizes share one exact-byte contract
@@ -2995,7 +2995,11 @@ a new API state machine, queue contract, or verification-accounting field.
Host matching uses normalized names, addresses, and aliases and fails
closed when more than one enabled platform source matches; VMware remains
excluded because a vCenter connection is not the ESXi host on which an
agent would run. Composition adds agent telemetry to the source row without
agent would run. PBS source aliases include the hostname the PBS node
reports about itself (state-side `PBSInstance.NodeName`), because reported
machine identity, not configured addressing, is what reconciles an
IP-or-alias-configured PBS connection with the host agent running on that
machine. Composition adds agent telemetry to the source row without
converting provider health, credentials, or polling freshness into agent
lifecycle evidence.
Agent-backed
@@ -965,7 +965,11 @@ timeout, cancellation, and panic outcomes all publish `offline`/`error`, while
a later success clears the current error and publishes `online`/`healthy`.
Optional node, datastore, namespace, or job collection failures remain partial
data evidence and do not turn a successful version/datastore connectivity
probe into a connection failure.
probe into a connection failure. Once connectivity is proven, the poll also
captures the hostname the PBS node reports about itself (`GET /nodes`) on
`models.PBSInstance.NodeName` as machine-identity evidence for connected-system
grouping; node-name fetch failure is partial data like the other optional
collections, never a poll failure.
### Host snapshots carry integration provenance; doctor copy is user-facing
@@ -997,9 +997,14 @@ recovery scope, or a storage/recovery-owned secret source.
When those aliases uniquely identify one enabled single-host API source
(including PBS) and one Pulse Agent, the API-owned grouped systems payload
must compose them into one source row rather than expose duplicate machine
rows. This is presentation and collection-method composition only: agent
liveness cannot become PBS reachability, backup freshness, protection, or
recovery evidence.
rows. PBS connection aliases include the hostname the PBS node reports
about itself (`models.PBSInstance.NodeName`, captured during the PBS poll),
so this composition holds even when the PBS connection is configured by an
IP or DNS alias the agent never reports. This is presentation and
collection-method composition only: agent liveness cannot become PBS
reachability, backup freshness, protection, or recovery evidence, and the
reported node name is grouping identity, not a storage or recovery health
input.
The settings-level manual discovery refresh at `/api/discovery/run` belongs
to that adjacent discovery/API boundary. Storage and recovery surfaces may
consume refreshed workload discovery records, but they must not reinterpret
+1
View File
@@ -900,6 +900,7 @@ export interface PBSInstance {
id: string;
name: string;
host: string;
nodeName?: string; // Hostname the PBS node reports about itself
guestURL?: string; // Optional guest-accessible URL (for navigation)
status: string;
version: string;
+28 -3
View File
@@ -123,6 +123,13 @@ type aggregatorInputs struct {
expectedAgentVersion string
now time.Time
// pbsReportedNodeNames maps a configured PBS instance name to the hostname
// that node reports about itself. Reported identity outranks configured
// addressing for grouping: it lets the host agent running on the PBS
// machine merge into the PBS connection row even when the connection was
// configured with an IP or DNS alias the agent never reports.
pbsReportedNodeNames map[string]string
// Configured poll cadences, used to scale the active→stale cutoff so slow
// cadences (e.g. 5 minutes) don't read as permanently stale. Zero means
// unknown and falls back to the connectionStaleThreshold floor. VMware and
@@ -154,7 +161,7 @@ func buildConnections(in aggregatorInputs) []Connection {
out = append(out, buildPVEConnection(pve, in.instanceHealth, now, in.pvePollingInterval))
}
for _, pbs := range in.pbsInstances {
out = append(out, buildPBSConnection(pbs, in.instanceHealth, now, in.pbsPollingInterval))
out = append(out, buildPBSConnection(pbs, in.instanceHealth, now, in.pbsPollingInterval, in.pbsReportedNodeNames[pbs.Name]))
}
for _, pmg := range in.pmgInstances {
out = append(out, buildPMGConnection(pmg, in.instanceHealth, now, in.pmgPollingInterval))
@@ -221,7 +228,7 @@ func buildPVEConnection(inst config.PVEInstance, health map[string]monitoring.In
return conn
}
func buildPBSConnection(inst config.PBSInstance, health map[string]monitoring.InstanceHealth, now time.Time, pollInterval time.Duration) Connection {
func buildPBSConnection(inst config.PBSInstance, health map[string]monitoring.InstanceHealth, now time.Time, pollInterval time.Duration, reportedNodeName string) Connection {
enabled := !inst.Disabled
surfaces := []string{"backups", "datastores", "syncJobs", "verifyJobs", "pruneJobs", "garbageJobs"}
scope := map[string]bool{
@@ -239,7 +246,7 @@ func buildPBSConnection(inst config.PBSInstance, health map[string]monitoring.In
Type: ConnectionTypePBS,
Name: inst.Name,
Address: inst.Host,
HostAliases: appendNormalizedHosts(nil, inst.Name, inst.Host),
HostAliases: appendNormalizedHosts(nil, inst.Name, inst.Host, reportedNodeName),
State: state,
StateReason: reason,
Enabled: enabled,
@@ -1298,6 +1305,24 @@ func connectionAgentHostProfileIDFromIdentity(values ...string) string {
return profile.ID
}
// pbsReportedNodeNamesByInstance projects the hostname each PBS node reports
// about itself out of monitoring state, keyed by configured instance name.
func pbsReportedNodeNamesByInstance(instances []models.PBSInstance) map[string]string {
if len(instances) == 0 {
return nil
}
out := make(map[string]string, len(instances))
for _, instance := range instances {
name := strings.TrimSpace(instance.Name)
nodeName := strings.TrimSpace(instance.NodeName)
if name == "" || nodeName == "" {
continue
}
out[name] = nodeName
}
return out
}
func connectionHostAliasesForAgent(host models.Host, name, address string) []string {
values := []string{name, address, host.Hostname, host.ReportIP}
for _, iface := range host.NetworkInterfaces {
@@ -326,6 +326,54 @@ func TestBuildConnections_AgentHostAliasesIncludeReportedIdentityHints(t *testin
}
}
// A PBS connection's identity must include the hostname the PBS node reports
// about itself, not just the configured address: reported identity is what
// lets the host agent running on the PBS machine attach to the PBS
// connection when the connection was configured by IP or DNS alias, so the
// ledger shows one system row instead of an API row plus an agent row.
func TestBuildConnections_PBSHostAliasesIncludeReportedNodeName(t *testing.T) {
now := time.Now()
in := aggregatorInputs{
pbsInstances: []config.PBSInstance{
{Name: "backup", Host: "https://192.0.2.40:8007"},
},
pbsReportedNodeNames: map[string]string{"backup": "pbs01"},
now: now,
}
got := buildConnections(in)
if len(got) != 1 {
t.Fatalf("expected 1 connection, got %d", len(got))
}
if !reflect.DeepEqual(got[0].HostAliases, []string{"backup", "192.0.2.40", "pbs01"}) {
t.Fatalf("pbs host aliases = %+v, want reported node name included", got[0].HostAliases)
}
agent := Connection{
ID: "agent:pbs01",
Type: ConnectionTypeAgent,
Name: "pbs01",
Address: "pbs01",
HostAliases: []string{"pbs01", "10.10.0.4"},
}
connections := map[string]Connection{got[0].ID: got[0], agent.ID: agent}
if attached := directPlatformHostAttachment(agent, connections); attached != "pbs:backup" {
t.Fatalf("directPlatformHostAttachment() = %q, want %q", attached, "pbs:backup")
}
}
func TestPBSReportedNodeNamesByInstanceSkipsBlankIdentity(t *testing.T) {
got := pbsReportedNodeNamesByInstance([]models.PBSInstance{
{Name: "backup", NodeName: " pbs01 "},
{Name: "unnamed", NodeName: " "},
{Name: "", NodeName: "orphan"},
})
want := map[string]string{"backup": "pbs01"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("pbsReportedNodeNamesByInstance() = %+v, want %+v", got, want)
}
}
func TestBuildConnections_ProjectsLegacyLinuxDistroToRuntimePlatform(t *testing.T) {
now := time.Now()
got := buildConnections(aggregatorInputs{
+1
View File
@@ -64,6 +64,7 @@ func buildAggregatorInputsWithRuntimeSources(
inputs.agentDesiredConfigs = connectionAgentDesiredConfigFingerprints(monitor, inputs.hosts, inputs.apiTokens)
inputs.instanceHealth = instanceHealthByKey(monitor.SchedulerHealth())
inputs.availabilityStatuses = monitor.AvailabilityStatusSnapshot()
inputs.pbsReportedNodeNames = pbsReportedNodeNamesByInstance(monitor.PBSInstancesSnapshot())
} else {
inputs.hosts = []models.Host{}
inputs.instanceHealth = map[string]monitoring.InstanceHealth{}
+23
View File
@@ -4,6 +4,29 @@ import (
"testing"
)
// clonePBSInstance must carry scalar identity fields, including the
// PBS-reported node hostname that connected-system grouping relies on, and
// its collection copies must stay independent of the source.
func TestClonePBSInstance_PreservesReportedNodeName(t *testing.T) {
src := PBSInstance{
ID: "pbs-backup",
Name: "backup",
Host: "https://192.0.2.40:8007",
NodeName: "pbs01",
Datastores: []PBSDatastore{{Name: "internal"}},
}
dest := clonePBSInstance(src)
if dest.NodeName != "pbs01" {
t.Fatalf("NodeName = %q, want %q", dest.NodeName, "pbs01")
}
dest.Datastores[0].Name = "mutated"
if src.Datastores[0].Name != "internal" {
t.Fatalf("clone datastores share backing array with source")
}
}
// --- Pointer clone helpers ---
func TestCloneBoolPtr_Nil(t *testing.T) {
+22
View File
@@ -215,6 +215,28 @@ func TestNativePoolHealthEvidenceNormalizesWithoutChangingIdentity(t *testing.T)
// are monitoring-internal attribution evidence on State. They must never
// leak into serialized state or snapshots, where they would read as an
// agent- or API-facing contract.
// PBSInstance.NodeName is the hostname the PBS node reports about itself and
// is machine-identity evidence for connected-system grouping. It serializes
// as camelCase `nodeName` and is omitted entirely until a poll captures it,
// so pre-upgrade payload shapes stay byte-identical.
func TestPBSInstanceNodeNameSerializesCamelCaseAndOmitsWhenEmpty(t *testing.T) {
withName, err := json.Marshal(PBSInstance{ID: "pbs-backup", Name: "backup", NodeName: "pbs01"})
if err != nil {
t.Fatalf("marshal PBSInstance: %v", err)
}
if !strings.Contains(string(withName), `"nodeName":"pbs01"`) {
t.Fatalf("payload = %s, want camelCase nodeName field", withName)
}
withoutName, err := json.Marshal(PBSInstance{ID: "pbs-backup", Name: "backup"})
if err != nil {
t.Fatalf("marshal PBSInstance: %v", err)
}
if strings.Contains(string(withoutName), "nodeName") {
t.Fatalf("payload = %s, empty NodeName must be omitted", withoutName)
}
}
func TestPBSGuestConfirmationEvidenceStaysOutOfSerializedState(t *testing.T) {
state := NewState()
state.UpdatePBSGuestConfirmationsForInstance("cluster-a", []PBSGuestConfirmation{
+1
View File
@@ -2660,6 +2660,7 @@ type PBSInstance struct {
ID string `json:"id"`
Name string `json:"name"`
Host string `json:"host"`
NodeName string `json:"nodeName,omitempty"` // Hostname the PBS node reports about itself (GET /nodes)
GuestURL string `json:"guestURL,omitempty"` // Optional guest-accessible URL (for navigation)
Status string `json:"status"`
Version string `json:"version"`
@@ -104,6 +104,68 @@ func TestPollPBSInstanceDoesNotQueryExcludedDatastoreDetails(t *testing.T) {
}
}
// The hostname a PBS node reports about itself (GET /nodes) is
// machine-identity evidence for connected-system grouping: it merges the
// host agent running on the PBS machine into the PBS connection even when
// the connection was configured by IP or DNS alias. The fetch is optional
// collection — capture it when available, never fail the poll on it.
func TestPollPBSInstanceCapturesReportedNodeName(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api2/json/version":
_ = json.NewEncoder(w).Encode(map[string]any{
"data": map[string]any{"version": "3.4"},
})
case "/api2/json/nodes":
_ = json.NewEncoder(w).Encode(map[string]any{
"data": []map[string]any{{"node": "pbs01"}},
})
case "/api2/json/nodes/localhost/status":
_ = json.NewEncoder(w).Encode(map[string]any{"data": map[string]any{}})
default:
http.Error(w, "unexpected request", http.StatusInternalServerError)
}
}))
defer server.Close()
client, err := pbs.NewClient(pbs.ClientConfig{
Host: server.URL,
TokenName: "root@pam!pulse-token",
TokenValue: "secret",
})
if err != nil {
t.Fatalf("NewClient failed: %v", err)
}
monitor := &Monitor{
config: &config.Config{
PBSInstances: []config.PBSInstance{{
Name: "pbs-nodename",
Host: server.URL,
}},
},
state: models.NewState(),
authFailures: make(map[string]int),
lastAuthAttempt: make(map[string]time.Time),
pollStatusMap: make(map[string]*pollStatus),
circuitBreakers: make(map[string]*circuitBreaker),
}
monitor.pollPBSInstance(context.Background(), "pbs-nodename", client)
snapshot := monitor.state.GetSnapshot()
if len(snapshot.PBSInstances) != 1 {
t.Fatalf("PBS instances = %+v, want one", snapshot.PBSInstances)
}
instance := snapshot.PBSInstances[0]
if instance.NodeName != "pbs01" {
t.Fatalf("NodeName = %q, want %q", instance.NodeName, "pbs01")
}
if instance.Status != "online" || instance.ConnectionHealth != "healthy" {
t.Fatalf("status = %q health = %q, want online/healthy", instance.Status, instance.ConnectionHealth)
}
}
func TestMonitor_PollPBSInstance_AuthFailure(t *testing.T) {
// Setup mock server that returns 401
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+10
View File
@@ -318,6 +318,16 @@ func (m *Monitor) pollPBSInstance(ctx context.Context, instanceName string, clie
}
}
// The PBS-reported node hostname is machine-identity evidence: connected
// systems grouping merges the host agent running on this machine into
// this PBS connection through it, even when the configured connection
// address is an IP or DNS alias the agent never reports.
if nodeName, nodeNameErr := client.GetNodeName(ctx); nodeNameErr == nil {
pbsInst.NodeName = strings.TrimSpace(nodeName)
} else if debugEnabled {
log.Debug().Err(nodeNameErr).Str("instance", instanceName).Msg("could not get PBS node name")
}
// Get node status (CPU, memory, etc.)
nodeStatus, err := client.GetNodeStatus(ctx)
if err != nil {