diff --git a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md index 1ae0bccb09..ea001419d8 100644 --- a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md +++ b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md @@ -615,7 +615,14 @@ least-privilege installs using that helper reported `provider_unavailable` on every report (#2511). Discovery hands the whole node back to `pct list` whenever it cannot establish a guest's state (no cgroup v2, an unreadable listing, config, cgroup or process, a search limit, or cancellation); only a -guest without a cgroup counts as stopped. The fixed `lxc-info -n -p` +guest without a cgroup counts as stopped. Exactly 128 running guests remain +admissible, but discovery must inspect the rest of the bounded config listing: +a 129th running guest or an unestablished later guest makes discovery unavailable, +not a truncated complete inventory. The existing bounded `pct list` fallback +then either establishes the full node or remains degraded under PrivateNetwork. +`TestSocketFreeProxmoxLXCDiscoveryCannotTruncateCompleteInventory` covers 127/128/129 +running guests and stopped/unknown guests after the limit through discovery and +collection. The fixed `lxc-info -n -p` PID lookup remains for that path, and `pct df` remains the per-container fallback, including through the `--grant-pct` wrapper. `TestHelperProxmoxLXCFilesystemsNeedNoPctOrLXCSockets` drives the collector diff --git a/docs/release-control/v6/internal/subsystems/monitoring.md b/docs/release-control/v6/internal/subsystems/monitoring.md index 74895f7439..d9105c18bf 100644 --- a/docs/release-control/v6/internal/subsystems/monitoring.md +++ b/docs/release-control/v6/internal/subsystems/monitoring.md @@ -966,7 +966,14 @@ opt-in owned tmpfs collector fixture. Installed-agent and model qualification are tracked separately in PATROL_ASSISTANT_CUSTOMER_JOURNEY.md. The same observer reads Proxmox LXC guests for the host agent and its typed -helper (#2511). An `lxc` request names the decimal VMID; the pinned process +helper (#2511). Its socket-free producer must not label the first 128 guests as +a complete inventory without inspecting later configs. A 129th running guest or +an unknown guest after the limit leaves collection unavailable/degraded through +the existing bounded fallback; a stopped later guest does not invalidate 128 +established guests. This preserves the consumer's distinction between complete +inventory replacement and explicit partial omissions. The producer boundary is +covered by `TestSocketFreeProxmoxLXCDiscoveryCannotTruncateCompleteInventory`; +partial-wire, omitted-reading and complete-empty cache tests remain authoritative. An `lxc` request names the decimal VMID; the pinned process must sit in that guest's exact `lxc/` cgroup and be its init (`NSpid` is exactly ` 1`, so the PID 1 of a namespace nested inside the guest, such as Docker in LXC, cannot stand in for it), checked before and after the read. diff --git a/internal/hostagent/privilege_helper_client_test.go b/internal/hostagent/privilege_helper_client_test.go index 6be8ec36c9..5070959942 100644 --- a/internal/hostagent/privilege_helper_client_test.go +++ b/internal/hostagent/privilege_helper_client_test.go @@ -609,3 +609,53 @@ func TestHelperProxmoxLXCFilesystemsNeedNoPctOrLXCSockets(t *testing.T) { t.Fatalf("126 disks = %+v, want rootfs and the mp0 mount with its own usage", got[0].Disks) } } + +func TestSocketFreeProxmoxLXCDiscoveryCannotTruncateCompleteInventory(t *testing.T) { + for _, tc := range []struct { + name string + running int + tail string + complete bool + }{ + {"below limit", proxmoxLXCMaxContainers - 1, "", true}, + {"exact limit", proxmoxLXCMaxContainers, "", true}, + {"exact limit plus stopped", proxmoxLXCMaxContainers, "stopped", true}, + {"over limit", proxmoxLXCMaxContainers + 1, "", false}, + {"unknown after limit", proxmoxLXCMaxContainers, "unknown", false}, + } { + t.Run(tc.name, func(t *testing.T) { + h := newFakeProxmoxLXCHost() + for n := 0; n < tc.running; n++ { + vmid, pid := 100+n, 1000+n + h.config(vmid, fmt.Sprintf("hostname: ct%d\nrootfs: local:vm-%d-disk-0,size=1G\n", vmid, vmid)) + h.cgroup(strconv.Itoa(vmid)+"/ns", pid) + h.proc(pid, fmt.Sprintf("%d\t1", pid), fmt.Sprintf("/lxc/%d/ns", vmid)) + h.observations[pid] = map[string]lxcObservation{"/": lxcUsage(4096, 1024, 3072)} + } + if tc.tail != "" { + vmid := 100 + tc.running + h.config(vmid, "hostname: tail\nrootfs: local:tail,size=1G\n") + if tc.tail == "unknown" { + h.cgroup(strconv.Itoa(vmid) + "/ns") // exists, but has no established init + } + } + collector, commands := h.collector(t) + agent := &Agent{logger: zerolog.Nop(), collector: collector} + rows, ok := agent.discoverRunningProxmoxLXCContainers(t.Context()) + if ok != tc.complete || (ok && len(rows) != tc.running) || (!ok && rows != nil) || *commands != 0 { + t.Fatalf("socket-free discovery: complete=%v rows=%d commands=%d", ok, len(rows), *commands) + } + result := agent.collectProxmoxLXCFilesystemsResult(t.Context()) + if tc.complete { + if !result.Applicable || result.Degraded || result.Inventory == nil || result.Inventory.Status != "complete" || len(result.Inventory.Containers) != tc.running || *commands != 0 { + t.Fatalf("complete boundary inventory=%+v commands=%d", result, *commands) + } + if last := result.Inventory.Containers[tc.running-1]; last.VMID != 99+tc.running || len(last.Disks) != 1 || last.Disks[0].UsedBytes != 1024 { + t.Fatalf("boundary guest reading lost: %+v", last) + } + } else if !result.Applicable || !result.Degraded || result.Inventory != nil || *commands != 1 { + t.Fatalf("unestablished node appeared complete instead of one failed pct fallback: %+v commands=%d", result, *commands) + } + }) + } +} diff --git a/internal/hostagent/proxmox_lxc_running.go b/internal/hostagent/proxmox_lxc_running.go index 8a33988788..65888f61e1 100644 --- a/internal/hostagent/proxmox_lxc_running.go +++ b/internal/hostagent/proxmox_lxc_running.go @@ -130,8 +130,11 @@ func (a *Agent) discoverRunningProxmoxLXCContainers(ctx context.Context) ([]prox return nil, false } running = append(running, proxmoxLXCRunningContainer{VMID: vmid, Name: proxmoxLXCConfigName(string(raw), vmid), PID: pid}) - if len(running) == proxmoxLXCMaxContainers { - break + if len(running) > proxmoxLXCMaxContainers { + // A truncated list cannot authoritatively declare the node complete. + // Keep the same bound as pct parsing and let the caller report a + // degraded fallback, rather than erase uninspected running guests. + return nil, false } } return running, true