diff --git a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md index 4891c82049..b4db8bdd90 100644 --- a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md +++ b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md @@ -3011,7 +3011,10 @@ agent inventory, registration state, or command-channel readiness. agent `Host.LastSeen`-backed rows on that ledger, but must not reinterpret derived `state` (active/paused/unauthorized/unreachable/ stale/pending) as install authority or treat the probe response as - enrollment state. Metadata, link-local, multicast, and unspecified + enrollment state. A cluster member's derived state on that ledger is node + liveness only: physical disks an agent reports on the node never compose + into the member, so a disk health warning cannot read as the agent having + stopped reporting. Metadata, link-local, multicast, and unspecified probe destinations must fail closed before any outbound dial, and lifecycle surfaces must surface that canonical rejection instead of retrying through lane-local probe helpers. Ledger writes still flow diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md index 12452c53b1..7c750feef2 100644 --- a/docs/release-control/v6/internal/subsystems/api-contracts.md +++ b/docs/release-control/v6/internal/subsystems/api-contracts.md @@ -3732,7 +3732,12 @@ a new API state machine, queue contract, or verification-accounting field. payload must also carry the backend-authored cluster member collection with node identity, endpoint, node-local status, and any linked agent connection id so the frontend can render child node composition without - reverse-engineering it from standalone agent rows. Those member records, + reverse-engineering it from standalone agent rows. Only node records + compose that member collection: a physical disk an agent reports on a + member carries the node's Proxmox identity so it stays discoverable in the + Proxmox workspace, but it never becomes a member and never lends its + health, timestamp, or model name to the node's state or aliases, so a disk + warning cannot render an actively reporting node as Stale. Those member records, plus any primary or attached connection row that represents the same host, must also carry canonical host aliases when the backend knows them, so discovery and settings surfaces can reconcile hostname-only and IP-only diff --git a/docs/release-control/v6/internal/subsystems/storage-recovery.md b/docs/release-control/v6/internal/subsystems/storage-recovery.md index 035d5ed4ab..0e712c5237 100644 --- a/docs/release-control/v6/internal/subsystems/storage-recovery.md +++ b/docs/release-control/v6/internal/subsystems/storage-recovery.md @@ -6609,6 +6609,14 @@ The change is navigation presentation only. It adds no storage, backup, snapshot or recovery surface, and it changes no route, authority or recovery state. The Recovery settings item and its capability gate are untouched. +### Connection member composition ignores physical disks + +`internal/api/connections_grouping.go` now composes Proxmox cluster members on +the `/api/connections` systems payload from node records only, so an +agent-reported physical disk that carries its node's identity no longer turns +the node row Stale. The disk's own risk, storage evidence and recovery state +are unchanged, and this adds no backup, restore, retention or recovery surface. + ### Pulse Mobile pairing copy only `internal/api/onboarding_handlers.go` changed only the human-readable pairing diff --git a/internal/api/connections_grouping.go b/internal/api/connections_grouping.go index 065d1a4cf8..1597325242 100644 --- a/internal/api/connections_grouping.go +++ b/internal/api/connections_grouping.go @@ -254,6 +254,12 @@ func connectionSystemMemberFromResource( if resource.Proxmox == nil || !resource.Proxmox.IsClusterMember { return ConnectionSystemMember{}, "", false } + // Disks an agent reports on a cluster node carry the node's Proxmox + // identity so they stay discoverable in the Proxmox workspace. Only the + // node itself is a member; a disk's health is not the node's liveness. + if unified.CanonicalResourceType(resource.Type) != unified.ResourceTypeAgent { + return ConnectionSystemMember{}, "", false + } nodeName := strings.TrimSpace(resource.Proxmox.NodeName) if nodeName == "" { return ConnectionSystemMember{}, "", false diff --git a/internal/api/contract_test.go b/internal/api/contract_test.go index 560758dae7..30981bec30 100644 --- a/internal/api/contract_test.go +++ b/internal/api/contract_test.go @@ -16608,6 +16608,82 @@ func TestContract_PBSHostAgentComposesIntoOwningSystem(t *testing.T) { } } +// An agent on a Proxmox cluster node reports SMART data for the node's disks, +// and the registry stamps each disk with the owning node's identity so it +// stays discoverable in the Proxmox workspace. A disk is not a cluster member: +// projecting it as one folded its health into the node row, so a warm NVMe +// drive rendered an actively reporting node as Stale whenever the agent's +// report was newer than the last Proxmox poll. +func TestContract_ConnectionSystemMembersIgnoreOwnedPhysicalDisks(t *testing.T) { + cfg := &config.Config{DataPath: t.TempDir()} + monitor, err := monitoring.New(cfg) + if err != nil { + t.Fatalf("monitoring.New: %v", err) + } + t.Cleanup(func() { monitor.Stop() }) + + polledAt := time.Now().UTC().Add(-10 * time.Second) + reportedAt := polledAt.Add(6 * time.Second) + adapter := unifiedresources.NewMonitorAdapter(nil) + adapter.PopulateFromSnapshot(models.StateSnapshot{ + Nodes: []models.Node{{ + ID: "nexus-aurora", + NodeIdentity: "nexus-aurora", + Name: "aurora", + Instance: "nexus", + ClusterName: "Nexus", + IsClusterMember: true, + Host: "https://192.0.2.5:8006", + Type: "node", + Status: "online", + ConnectionHealth: "healthy", + LastSeen: polledAt, + LinkedAgentID: "host-aurora", + }}, + Hosts: []models.Host{{ + ID: "host-aurora", + Hostname: "aurora", + Status: "online", + LastSeen: reportedAt, + LinkedNodeID: "nexus-aurora", + Sensors: models.HostSensorSummary{ + SMART: []models.HostDiskSMART{{ + Device: "/dev/nvme0n1", + Model: "WD_BLACK SN7100 4TB", + Serial: "SN7100-AURORA", + Type: "nvme", + Temperature: 63, + Health: "PASSED", + }}, + }, + }}, + }) + setTestUnexportedField(t, monitor, "resourceStore", monitoring.ResourceStoreInterface(adapter)) + + disks := adapter.GetByType(unifiedresources.ResourceTypePhysicalDisk) + if len(disks) != 1 || disks[0].Status != unifiedresources.StatusWarning || + disks[0].Proxmox == nil || !disks[0].Proxmox.IsClusterMember { + t.Fatalf("precondition: want one warning disk owned by a cluster node, got %+v", disks) + } + + systems := buildConnectionSystems([]Connection{ + {ID: "pve:nexus", Type: ConnectionTypePVE, Name: "nexus", Address: "https://192.0.2.5:8006", State: ConnectionStateActive, Enabled: true}, + {ID: "agent:host-aurora", Type: ConnectionTypeAgent, Name: "aurora", Address: "aurora", State: ConnectionStateActive, Enabled: true, LastSeen: &reportedAt}, + }, monitor) + if len(systems) != 1 || len(systems[0].Members) != 1 { + t.Fatalf("want one cluster system with one member, got %+v", systems) + } + member := systems[0].Members[0] + if member.State != ConnectionStateActive { + t.Fatalf("member state = %q, want %q: disk health leaked into node liveness (%+v)", member.State, ConnectionStateActive, member) + } + for _, alias := range member.HostAliases { + if alias == "wd_black sn7100 4tb" { + t.Fatalf("member host aliases include the disk model: %+v", member.HostAliases) + } + } +} + func TestContract_AgentConnectionPayloadIncludesVersionFields(t *testing.T) { conn := Connection{ ID: "agent:host-1",