Keep socket-free LXC discovery truthful at its inventory limit

Inspect the remaining bounded config listing after finding 128 running guests. A 129th running guest or an unestablished later guest must fall back and remain degraded if pct cannot establish a complete list, rather than silently erase guests behind a complete status. Retain the existing PrivateNetwork and probe bounds, with 127/128/129 and stopped/unknown-tail provider regressions.

Change-source: pulse-maintainer
This commit is contained in:
pulse-triage[bot]
2026-10-06 13:15:44 +01:00
parent 123f66aee5
commit d0a1424c34
4 changed files with 71 additions and 4 deletions
@@ -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 <vmid> -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 <vmid> -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
@@ -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/<vmid>` cgroup and be its init (`NSpid` is
exactly `<pid> 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.
@@ -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)
}
})
}
}
+5 -2
View File
@@ -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