mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-26 13:39:47 +00:00
Preserve partial PVE filesystem readings without widening helper access
Replace the rejected global host-network proposal with the unchanged private namespace on every platform, including native linux-386. Carry successful rows and omitted VMIDs through operation v2 without clearing degraded health or renewing omitted measurements; retain strict complete-only v1 compatibility. Restart the installer-owned helper only after isolated-unit and socket checks, and document the unresolved pmxcfs IPC limitation rather than claiming PVE recovery. Change-source: pulse-maintainer
This commit is contained in:
@@ -340,3 +340,45 @@ func TestRunFailsClosedOutsideLinux(t *testing.T) {
|
||||
t.Fatalf("run error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalProxmoxProviderV2KeepsPartialRowsAndV1FailsClosed(t *testing.T) {
|
||||
inventory := &agentshost.ProxmoxLXCInventory{
|
||||
Status: agentshost.ProxmoxLXCCollectionPartial,
|
||||
Containers: []agentshost.ProxmoxLXCContainer{{VMID: 100, Name: "web", Disks: []agentshost.Disk{{Mountpoint: "/", TotalBytes: 4096}}}},
|
||||
OmittedVMIDs: []int{102},
|
||||
}
|
||||
provider := localProxmoxProvider{collect: func(context.Context) hostagent.ProxmoxLXCFilesystemCollectionResult {
|
||||
return hostagent.ProxmoxLXCFilesystemCollectionResult{Applicable: true, Inventory: inventory, Degraded: true, FailedContainers: 1}
|
||||
}}
|
||||
if data, err := provider.LXCFilesystems(t.Context()); err == nil || data != nil {
|
||||
t.Fatal("v1 returned incomplete inventory")
|
||||
}
|
||||
data, err := provider.LXCFilesystemsV2(t.Context())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var got struct {
|
||||
Inventory *agentshost.ProxmoxLXCInventory `json:"inventory"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &got); err != nil || got.Inventory.Status != agentshost.ProxmoxLXCCollectionPartial || len(got.Inventory.Containers) != 1 || got.Inventory.OmittedVMIDs[0] != 102 {
|
||||
t.Fatalf("v2=%s, error=%v", data, err)
|
||||
}
|
||||
inventory.Status = agentshost.ProxmoxLXCCollectionComplete
|
||||
if data, err := provider.LXCFilesystemsV2(t.Context()); err == nil || data != nil {
|
||||
t.Fatal("contradictory partial inventory accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalProxmoxProviderV1RetainsStrictLegacyShape(t *testing.T) {
|
||||
inventory := &agentshost.ProxmoxLXCInventory{Status: agentshost.ProxmoxLXCCollectionComplete}
|
||||
provider := localProxmoxProvider{collect: func(context.Context) hostagent.ProxmoxLXCFilesystemCollectionResult {
|
||||
return hostagent.ProxmoxLXCFilesystemCollectionResult{Applicable: true, Inventory: inventory}
|
||||
}}
|
||||
data, err := provider.LXCFilesystems(t.Context())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(string(data), `"status"`) || strings.Contains(string(data), `"omittedVmids"`) {
|
||||
t.Fatalf("v1 shape changed: %s", data)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/agenthelper"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/hostagent"
|
||||
@@ -38,18 +39,45 @@ type localProxmoxProvider struct {
|
||||
}
|
||||
|
||||
func (p localProxmoxProvider) LXCFilesystems(ctx context.Context) (json.RawMessage, error) {
|
||||
return p.lxcFilesystems(ctx, false)
|
||||
}
|
||||
|
||||
func (p localProxmoxProvider) LXCFilesystemsV2(ctx context.Context) (json.RawMessage, error) {
|
||||
return p.lxcFilesystems(ctx, true)
|
||||
}
|
||||
|
||||
func (p localProxmoxProvider) lxcFilesystems(ctx context.Context, allowPartial bool) (json.RawMessage, error) {
|
||||
collect := p.collect
|
||||
if collect == nil {
|
||||
collect = hostagent.CollectProxmoxLXCFilesystemsLocalResult
|
||||
}
|
||||
collection := collect(ctx)
|
||||
if collection.Degraded {
|
||||
if collection.Degraded && (!allowPartial || collection.Inventory == nil) {
|
||||
return nil, &agenthelper.ProviderError{
|
||||
Code: agenthelper.ErrorProviderUnavailable,
|
||||
Message: "Proxmox LXC filesystem inventory is incomplete",
|
||||
Retryable: true,
|
||||
}
|
||||
}
|
||||
if collection.Inventory != nil {
|
||||
if err := collection.Inventory.ValidateCollection(); err != nil ||
|
||||
(allowPartial && collection.Inventory.Status == "") ||
|
||||
(collection.Degraded != (collection.Inventory.Status == agentshost.ProxmoxLXCCollectionPartial)) ||
|
||||
collection.FailedContainers != len(collection.Inventory.OmittedVMIDs) {
|
||||
return nil, &agenthelper.ProviderError{Code: agenthelper.ErrorProviderUnavailable, Message: "Proxmox LXC filesystem inventory has invalid completeness", Retryable: true}
|
||||
}
|
||||
}
|
||||
// V1 decoders are strict: do not add even complete-status fields to their
|
||||
// response. A partial result never reaches an older collector as healthy.
|
||||
if !allowPartial && collection.Inventory != nil {
|
||||
legacy := struct {
|
||||
Containers []agentshost.ProxmoxLXCContainer `json:"containers"`
|
||||
CollectedAt time.Time `json:"collectedAt"`
|
||||
}{collection.Inventory.Containers, collection.Inventory.CollectedAt}
|
||||
return json.Marshal(struct {
|
||||
Inventory any `json:"inventory"`
|
||||
}{legacy})
|
||||
}
|
||||
result, err := json.Marshal(struct {
|
||||
Inventory *agentshost.ProxmoxLXCInventory `json:"inventory"`
|
||||
}{Inventory: collection.Inventory})
|
||||
|
||||
+22
-1
@@ -179,6 +179,11 @@ snapshots, not a shell, executable path, device path, VMID, daemon endpoint,
|
||||
environment, or caller-selected arguments. The helper
|
||||
service keeps `PrivateNetwork=true`, `RestrictAddressFamilies=AF_UNIX`,
|
||||
`NoNewPrivileges=true`, `ProtectSystem=strict`, and `ProtectHome=true`.
|
||||
The private network remains required on every architecture. In particular,
|
||||
systemd does not enforce `RestrictAddressFamilies` on native 32-bit x86;
|
||||
`SystemCallArchitectures=native` does not change that. The address-family
|
||||
property is not, by itself, proof that IP socket creation is denied. Do not
|
||||
remove `PrivateNetwork` to work around a telemetry failure.
|
||||
It also bounds the helper cgroup to `TasksMax=64`, `LimitNOFILE=256`, and
|
||||
`MemoryMax=256M`. These limits leave headroom for the typed helper and its
|
||||
short-lived platform tools while preventing an overridden or malformed request
|
||||
@@ -188,6 +193,22 @@ block devices. If the helper is missing, incompatible, or rejects a request,
|
||||
only the affected telemetry disappears; the collector does not fall back to
|
||||
sudo, root, or a broader local command path.
|
||||
|
||||
Proxmox's `pct list` uses pmxcfs abstract Unix IPC, which is not reachable from
|
||||
the helper's private network namespace. On affected PVE hosts this leaves
|
||||
helper-backed LXC filesystem telemetry unavailable, while independent SMART
|
||||
and API monitoring can continue. Prefer the existing Proxmox API connection
|
||||
for the readings it provides; do not add a unit override or a root/sudo
|
||||
fallback. A successful SMART reading does not establish LXC helper health.
|
||||
|
||||
When an inventory succeeds only partly, operation v2 preserves the collected
|
||||
rows and explicitly names omitted container IDs. The helper module stays
|
||||
degraded, and omitted containers cannot refresh old filesystem readings.
|
||||
Operation v1 remains complete-only; a newer collector uses it only when an
|
||||
older helper explicitly rejects v2. An unavailable inventory is not an empty
|
||||
healthy snapshot or zero disk usage. An installer-managed helper upgrade
|
||||
restarts the verified helper after checking the effective isolated unit and
|
||||
socket identity; a collector-only automatic update cannot replace its helper.
|
||||
|
||||
The installer treats the effective systemd configuration as part of the safe
|
||||
profile boundary. The collector, helper service, helper socket, and an
|
||||
installed action runner must load from the installer-owned `FragmentPath` with
|
||||
@@ -279,7 +300,7 @@ or generic command path.
|
||||
| Proxmox VE/PBS/PMG inventory, status, storage, and ordinary metrics | API-only connection with a narrowly scoped token; no host agent | No collector, helper, or runner authority is required for this data | Supported independently of the safe host-agent profile; it does not prove host-local SMART, LXC filesystem, or action parity | `agent-lifecycle`: keep API permissions and returned telemetry covered by provider tests |
|
||||
| Standard Linux systemd host telemetry and collector update | Unprivileged `pulse-agent` plus the root-owned typed helper | Core `/proc`, filesystem, network, RAID, and hwmon telemetry stays in the collector; signed updates cross the fixed helper protocol and commit only after an authoritative report | **Qualified on disposable Ubuntu 24.04.4 arm64 at committed main `22fd662fb794f63efb9d3ca2158de73c4e07e1b8`** across the schema-v6 twenty-scenario run. It covers install, migration, explicit/automatic rollback, helper protocol and resource boundaries, effective-unit override rejection, reporting continuity, authoritative executable-digest commit, watchdog rollback, helper-restart recovery, and last-known-good restoration. It remains opt-in pending exact-RC reproduction, representative provider/appliance parity, and external review | `deployment-installability` and `security-privacy`: reproduce the same helper transaction from trusted release artifacts on representative hosts and accept the external boundary review |
|
||||
| Linux SMART telemetry | `smart.snapshot` through the no-network helper; no caller-selected device or arguments | Implemented, unqualified on representative physical disks. Helper failure omits/degrades SMART only; the collector does not retry as root | Does not yet justify SMART parity or a default change | `agent-lifecycle`: record live SATA, SAS/controller, USB bridge, and NVMe evidence, including standby, permission failure, timeout, and partial-data cases |
|
||||
| Proxmox node-local LXC filesystem telemetry | `proxmox.lxc_filesystems` through the no-network helper using fixed bounded `pct` operations | Implemented, unqualified on a representative PVE node. Helper failure omits/degrades this snapshot only | Does not yet justify Proxmox host-agent parity or a default change | `agent-lifecycle`: record live running/stopped LXC, mount, timeout, output-bound, and helper-loss behavior on supported PVE versions |
|
||||
| Proxmox node-local LXC filesystem telemetry | `proxmox.lxc_filesystems` through the isolated helper using fixed bounded `pct` operations; API monitoring where sufficient | Unavailable on hosts where pmxcfs abstract IPC is hidden by the private namespace. V2 preserves explicitly partial results when collection can proceed; global failure omits/degrades this snapshot only | Implemented, unqualified. No PVE recovery, host-IPC exception, or default change is established | `agent-lifecycle`: repair the IPC-dependent path without weakening isolation, then record live running/stopped LXC, partial/recovery, mount, timeout, output-bound, and helper-loss behavior on supported PVE versions |
|
||||
| Rootful Docker or Podman inventory | No direct collector access to a root-equivalent daemon socket | Implemented as a typed-helper summary-only fallback. Migration preserves container ID/name/image/state/status/creation inventory and marks the report `typed-helper-summary`; stats, secondary inventories, update checks, and actions remain unavailable | The standalone rootless packet exercises live same-family rootful Docker and Podman helper fallback plus summary-inventory semantic parity during socket loss. It does not qualify the complete standalone rootful fresh-install, migration, restart, helper-loss/recovery, and bounds matrix. Full rootful parity remains an explicit default blocker; the legacy/root profile is not safe-profile evidence | `agent-lifecycle`: record the remaining standalone lifecycle matrix on representative rootful Docker and Podman and decide explicitly whether reduced telemetry is sufficient |
|
||||
| Collector-owned rootless Docker or Podman | Direct access only to one usable runtime socket owned by the `pulse-agent` UID whose daemon also attests rootless mode | Qualified locally for the exact source-bound monitoring path. Discovery is deferred until the collector account exists and accepts exactly one readable/writable, non-symlink socket owned by that UID across Docker and Podman. Cross-user, root-owned, remote, unreadable, non-rootless-daemon, or ambiguous endpoints are rejected. An installer-owned exact pin survives daemon loss and update; the running collector can move between direct rootless monitoring and rootful typed-helper summary without regaining action authority | **Qualified on distinct disposable Ubuntu 24.04/systemd 255 arm64 hosts at commit `60041ad9e60c282c892f944e04f777b874991a5d`.** All eleven canonical scenarios passed for Docker and all eleven for Podman. Receipt SHA-256: `7a116d63ab0cd1560482165055f0a8c9158ce0a8333a27bd9e8256582a52dfb5`; local attestation SHA-256: `566279ecd7d7dfa89c92f24243e9fcd5ae3e295d4284bd559d4be42f1e83b3b5`. This is local opt-in artifact-bound self-attestation, not published-RC provenance, action/update qualification, external review, or authorization for a default change | `deployment-installability`: reproduce the exact packet from published release artifacts; separately qualify any desired rootless image-update/action surface; retain the opt-in default until the remaining provider, appliance, and review conditions pass |
|
||||
| Separate runner package update and package-cache cleanup | Root-owned `pulse-agent-runner`, host-bound action credential, typed request, postcondition, and durable receipt | The schema-v6 committed-main systemd receipt records a real verified apt-cache mutation, stale-fingerprint refusal, replay, nonce-bound readiness, effective-unit override rejection, exact credential rotation, bodyless self-revocation, and collector/helper continuity. A separate production Router regression exercises HTTPS issuance, WSS admission, encrypted token persistence, failed-rotation rollback, exact socket invalidation, two server restarts, old-secret rejection, and durable self-revoke | Qualified for the exercised systemd fixture paths and focused production Router lifecycle. Focused installer tests also cover atomic pending cancellation and fail-closed credential retention. The evidence does not cover every runner operation, representative providers, or an exact release candidate | `agent-lifecycle` and `api-contracts`: reproduce the combined systemd and production Router path from trusted RC artifacts with representative package-update success/failure/cancellation evidence |
|
||||
|
||||
@@ -857,6 +857,8 @@ installer download and the agent's subsequent Pulse TLS connection.
|
||||
|
||||
## Shared Boundaries
|
||||
|
||||
Proxmox helper operation v2 carries the shared `ProxmoxLXCInventory` completeness and bounded omitted-VMID contract. V1 stays strict complete-only. The collector tries v1 only after an explicit unsupported-v2 response, under one unchanged operation deadline; transport, authorization and provider failures never select a broader fallback.
|
||||
|
||||
The shared monitor constructor also composes notification bootstrap: it loads
|
||||
saved alert policy and destinations, binds the alert owner's immutable
|
||||
quiet-hours policy provider, then activates persistent queue delivery.
|
||||
@@ -3163,6 +3165,8 @@ traverse an agent.
|
||||
|
||||
## Completion Obligations
|
||||
|
||||
`cmd/pulse-agent-helper/main_test.go`, `internal/agenthelper/server_test.go`, `internal/hostagent/privilege_helper_client_test.go` and `pkg/agents/host/proxmox_lxc_test.go` must cover version negotiation, successful partial rows, all-failed/global-unavailable distinction, contradictory/overbound identities, degraded health and independent operation recovery. `scripts/installtests/safe_profile_migration_test.go` covers installer-managed helper replacement after effective-unit/socket validation and fatal restart failure. These source controls do not prove an installed upgrade or PVE recovery.
|
||||
|
||||
### VM command results describe the guest, not transport success
|
||||
|
||||
A verified terminal `qm guest exec` dictionary must project its guest exit or
|
||||
@@ -3571,6 +3575,8 @@ Agent` secondary handoff against the live setup wizard instead of relying
|
||||
|
||||
## Current State
|
||||
|
||||
The partial-result path no longer discards every successful row when another guest fails. The helper remains in its private network on every platform; pmxcfs abstract IPC is still unavailable there on the reported PVE estate (#2511). That defect is not fixed by the partial protocol (#2513), and API monitoring remains the safe existing alternative. The earlier host-network proposal is not part of this implementation.
|
||||
|
||||
### VM guest execution admission (backup precaution)
|
||||
|
||||
`AgentRegisterPayload.guest_exec_guard_version` is optional; version 1 on a
|
||||
|
||||
@@ -980,6 +980,8 @@ release-latency optimization.
|
||||
|
||||
## Shared Boundaries
|
||||
|
||||
Installer-managed helper upgrades restart the verified executable after effective isolated-unit and fixed socket-identity validation. Daemon reload or enabling an already-active socket alone does not replace an old process. Failure to restart aborts the installer and uses the existing safe-profile recovery transaction; collector-only updates still cannot replace the helper or unit.
|
||||
|
||||
`frontend-modern/src/utils/localStorage.ts` is a shared browser-preference key
|
||||
registry, not deployment state. Workload presentation preferences added there,
|
||||
including the Proxmox guest-memory comparison basis and per-surface workload
|
||||
@@ -2591,6 +2593,8 @@ artifact-selection behaviour.
|
||||
|
||||
## Completion Obligations
|
||||
|
||||
`scripts/installtests/safe_profile_migration_test.go` covers activation ordering, old-helper replacement, effective-unit failure, restart failure and fixed-target refusal in a disposable shell harness, alongside isolation rendering/admission for 386 and unknown platforms. Actual active-helper forward/reverse migration, protected state/identity and prior-active restoration still require authorized native execution; shell stubs are not that proof.
|
||||
|
||||
1. Update this contract when canonical deployment or installer entry points move
|
||||
2. Keep deployment runtime and shared API proof routing aligned in `registry.json`
|
||||
3. Preserve explicit coverage for installer parity, update planning, and deployment bootstrap behavior when these surfaces change. Shell installer update recovery changes must keep `scripts/installtests/install_sh_test.go` covering both persisted `connection.env` recovery and legacy running-process/service recovery across Linux and FreeBSD/rc.d, including single-dash v5 agent flags, non-procfs process inspection, and the rule that upgraded service args use `--token-file` instead of raw `--token`.
|
||||
@@ -2716,6 +2720,8 @@ artifact-selection behaviour.
|
||||
|
||||
## Current State
|
||||
|
||||
The helper remains network-isolated on all targets, and restart activates the installer-owned replacement rather than retaining old code after reload. Published security guidance discloses the PVE IPC limitation and partial-result semantics. This is not a published release or installed PVE acceptance.
|
||||
|
||||
### Demo runtime footprint qualification
|
||||
|
||||
The demo transaction executes no general installer. It verifies the activated
|
||||
|
||||
@@ -1671,6 +1671,8 @@ cleanup so readers cannot retain orphaned runtime or alert projections.
|
||||
|
||||
## Shared Boundaries
|
||||
|
||||
Helper-backed Proxmox inventories carry explicit complete/partial status and omitted VMIDs. Before cache mutation the monitor validates this shared contract. A complete inventory replaces only the exact linked-node/agent cache; a partial inventory removes that agent's explicitly omitted guests without renewing their data. Legacy reports retain legacy row admission and expiry.
|
||||
|
||||
PBS polling owns the internal `PBSInstance.NodeMetricsUnavailable` discriminator:
|
||||
each poll starts unavailable and only a successful non-nil node-status result
|
||||
clears it. A denied or failed node-status endpoint does not invalidate successful
|
||||
@@ -2182,6 +2184,8 @@ service-history reads plus denial/recovery without fabricated samples.
|
||||
|
||||
## Completion Obligations
|
||||
|
||||
`internal/monitoring/monitor_host_agents_test.go` and `pkg/agents/host/proxmox_lxc_test.go` must prove JSON wire-report ingestion through `ApplyHostReport`, partial/complete-empty recovery, contradictory IDs, unchanged node/agent/name/TTL protection, and no invalid-list-to-healthy-empty conversion. Omitted telemetry is never zero. Source proofs are distinct from native PVE and released availability.
|
||||
|
||||
### Discovery accepts successful command evidence only
|
||||
|
||||
Only stdout from a successful result with zero exit status and no error enters
|
||||
@@ -2334,6 +2338,8 @@ truthfulness, not native thaw, containing-release or workload acceptance.
|
||||
|
||||
## Current State
|
||||
|
||||
V2 partial inventories preserve usable filesystem readings while leaving typed-helper health degraded. Global inventory failure still yields no inventory. No host-network exception, guest mutation, database/retention change or temperature substitution accompanies this repair.
|
||||
|
||||
### Discovery observes VM guest execution pauses
|
||||
|
||||
Verified VM results describe the guest's terminal status and decoded output,
|
||||
|
||||
@@ -294,6 +294,8 @@ invisible to operators reviewing exactly what Pulse sends.
|
||||
|
||||
## Shared Boundaries
|
||||
|
||||
The generated typed helper and effective-unit admission retain `PrivateNetwork=true` on every platform, including native linux-386 and unknown architectures. `RestrictAddressFamilies=AF_UNIX` is additional hardening, not a portable proof of IP socket denial: systemd ignores this filter on native 32-bit x86. `SystemCallArchitectures=native` does not repair that ABI limitation.
|
||||
|
||||
- Configured local-admin synchronisation is a runtime identity replacement, not an additive role grant: changing the configured identity removes the previous bypass, and clearing it during first-run reset clears that bypass. Configurable authorizers must synchronise identity updates with concurrent authorization reads; other users still require their own policy grants.
|
||||
|
||||
### Notification recovery reload ownership
|
||||
@@ -827,12 +829,16 @@ in `docs/PRIVACY.md` and `frontend-modern/public/docs/PRIVACY.md`, which
|
||||
|
||||
## Forbidden Paths
|
||||
|
||||
Removing the helper's private network globally or for an unproved platform in order to reach pmxcfs is forbidden. Partial telemetry does not justify root/sudo fallback, caller-selected commands or paths, token exposure, wider socket families, or a healthy empty inventory after a failed list.
|
||||
|
||||
1. Changing telemetry payload semantics without updating the canonical privacy disclosure.
|
||||
2. Letting security-facing settings copy or privacy guarantees drift between runtime behavior and the governed docs.
|
||||
3. Treating API token management, auth posture, or telemetry controls as generic settings-shell polish instead of explicit trust-surface behavior.
|
||||
|
||||
## Completion Obligations
|
||||
|
||||
`scripts/installtests/safe_profile_migration_test.go` renders every shipped helper architecture plus an unknown case for PVE/non-PVE and rejects host/unknown effective network values; `scripts/installtests/secure_runtime_platform_matrix_test.go` binds truthful shipped documentation. These are renderer/admission regressions, not kernel enforcement proof. Any future host-IPC exception requires source-bound actual generated-child IPC success and IPv4/IPv6 creation denials with unsandboxed controls, plus owned active migration/rollback and identity evidence before acceptance.
|
||||
|
||||
Split-port agent exposure is complete only when its exact allowlist covers
|
||||
report/config routes, command WebSocket admission, version/server bootstrap,
|
||||
and the supported installer/download endpoints while every UI and management
|
||||
@@ -960,6 +966,8 @@ tokens, and path-normalization variants.
|
||||
|
||||
## Current State
|
||||
|
||||
This repair preserves the existing isolation boundary and introduces no host-IPC grant. Successful partial filesystem rows retain explicit completeness and degraded health; sanitized status exposes no raw provider errors. Native PVE recovery remains unproved. No old v6/v7 receipt is reinterpreted and no v8 qualification layer is introduced.
|
||||
|
||||
### Container diagnostics keeps copied material credential-free (1 October 2026)
|
||||
|
||||
Container migration-token issuance returns its secret separately on the
|
||||
|
||||
@@ -179,6 +179,11 @@ snapshots, not a shell, executable path, device path, VMID, daemon endpoint,
|
||||
environment, or caller-selected arguments. The helper
|
||||
service keeps `PrivateNetwork=true`, `RestrictAddressFamilies=AF_UNIX`,
|
||||
`NoNewPrivileges=true`, `ProtectSystem=strict`, and `ProtectHome=true`.
|
||||
The private network remains required on every architecture. In particular,
|
||||
systemd does not enforce `RestrictAddressFamilies` on native 32-bit x86;
|
||||
`SystemCallArchitectures=native` does not change that. The address-family
|
||||
property is not, by itself, proof that IP socket creation is denied. Do not
|
||||
remove `PrivateNetwork` to work around a telemetry failure.
|
||||
It also bounds the helper cgroup to `TasksMax=64`, `LimitNOFILE=256`, and
|
||||
`MemoryMax=256M`. These limits leave headroom for the typed helper and its
|
||||
short-lived platform tools while preventing an overridden or malformed request
|
||||
@@ -188,6 +193,22 @@ block devices. If the helper is missing, incompatible, or rejects a request,
|
||||
only the affected telemetry disappears; the collector does not fall back to
|
||||
sudo, root, or a broader local command path.
|
||||
|
||||
Proxmox's `pct list` uses pmxcfs abstract Unix IPC, which is not reachable from
|
||||
the helper's private network namespace. On affected PVE hosts this leaves
|
||||
helper-backed LXC filesystem telemetry unavailable, while independent SMART
|
||||
and API monitoring can continue. Prefer the existing Proxmox API connection
|
||||
for the readings it provides; do not add a unit override or a root/sudo
|
||||
fallback. A successful SMART reading does not establish LXC helper health.
|
||||
|
||||
When an inventory succeeds only partly, operation v2 preserves the collected
|
||||
rows and explicitly names omitted container IDs. The helper module stays
|
||||
degraded, and omitted containers cannot refresh old filesystem readings.
|
||||
Operation v1 remains complete-only; a newer collector uses it only when an
|
||||
older helper explicitly rejects v2. An unavailable inventory is not an empty
|
||||
healthy snapshot or zero disk usage. An installer-managed helper upgrade
|
||||
restarts the verified helper after checking the effective isolated unit and
|
||||
socket identity; a collector-only automatic update cannot replace its helper.
|
||||
|
||||
The installer treats the effective systemd configuration as part of the safe
|
||||
profile boundary. The collector, helper service, helper socket, and an
|
||||
installed action runner must load from the installer-owned `FragmentPath` with
|
||||
@@ -279,7 +300,7 @@ or generic command path.
|
||||
| Proxmox VE/PBS/PMG inventory, status, storage, and ordinary metrics | API-only connection with a narrowly scoped token; no host agent | No collector, helper, or runner authority is required for this data | Supported independently of the safe host-agent profile; it does not prove host-local SMART, LXC filesystem, or action parity | `agent-lifecycle`: keep API permissions and returned telemetry covered by provider tests |
|
||||
| Standard Linux systemd host telemetry and collector update | Unprivileged `pulse-agent` plus the root-owned typed helper | Core `/proc`, filesystem, network, RAID, and hwmon telemetry stays in the collector; signed updates cross the fixed helper protocol and commit only after an authoritative report | **Qualified on disposable Ubuntu 24.04.4 arm64 at committed main `22fd662fb794f63efb9d3ca2158de73c4e07e1b8`** across the schema-v6 twenty-scenario run. It covers install, migration, explicit/automatic rollback, helper protocol and resource boundaries, effective-unit override rejection, reporting continuity, authoritative executable-digest commit, watchdog rollback, helper-restart recovery, and last-known-good restoration. It remains opt-in pending exact-RC reproduction, representative provider/appliance parity, and external review | `deployment-installability` and `security-privacy`: reproduce the same helper transaction from trusted release artifacts on representative hosts and accept the external boundary review |
|
||||
| Linux SMART telemetry | `smart.snapshot` through the no-network helper; no caller-selected device or arguments | Implemented, unqualified on representative physical disks. Helper failure omits/degrades SMART only; the collector does not retry as root | Does not yet justify SMART parity or a default change | `agent-lifecycle`: record live SATA, SAS/controller, USB bridge, and NVMe evidence, including standby, permission failure, timeout, and partial-data cases |
|
||||
| Proxmox node-local LXC filesystem telemetry | `proxmox.lxc_filesystems` through the no-network helper using fixed bounded `pct` operations | Implemented, unqualified on a representative PVE node. Helper failure omits/degrades this snapshot only | Does not yet justify Proxmox host-agent parity or a default change | `agent-lifecycle`: record live running/stopped LXC, mount, timeout, output-bound, and helper-loss behavior on supported PVE versions |
|
||||
| Proxmox node-local LXC filesystem telemetry | `proxmox.lxc_filesystems` through the isolated helper using fixed bounded `pct` operations; API monitoring where sufficient | Unavailable on hosts where pmxcfs abstract IPC is hidden by the private namespace. V2 preserves explicitly partial results when collection can proceed; global failure omits/degrades this snapshot only | Implemented, unqualified. No PVE recovery, host-IPC exception, or default change is established | `agent-lifecycle`: repair the IPC-dependent path without weakening isolation, then record live running/stopped LXC, partial/recovery, mount, timeout, output-bound, and helper-loss behavior on supported PVE versions |
|
||||
| Rootful Docker or Podman inventory | No direct collector access to a root-equivalent daemon socket | Implemented as a typed-helper summary-only fallback. Migration preserves container ID/name/image/state/status/creation inventory and marks the report `typed-helper-summary`; stats, secondary inventories, update checks, and actions remain unavailable | The standalone rootless packet exercises live same-family rootful Docker and Podman helper fallback plus summary-inventory semantic parity during socket loss. It does not qualify the complete standalone rootful fresh-install, migration, restart, helper-loss/recovery, and bounds matrix. Full rootful parity remains an explicit default blocker; the legacy/root profile is not safe-profile evidence | `agent-lifecycle`: record the remaining standalone lifecycle matrix on representative rootful Docker and Podman and decide explicitly whether reduced telemetry is sufficient |
|
||||
| Collector-owned rootless Docker or Podman | Direct access only to one usable runtime socket owned by the `pulse-agent` UID whose daemon also attests rootless mode | Qualified locally for the exact source-bound monitoring path. Discovery is deferred until the collector account exists and accepts exactly one readable/writable, non-symlink socket owned by that UID across Docker and Podman. Cross-user, root-owned, remote, unreadable, non-rootless-daemon, or ambiguous endpoints are rejected. An installer-owned exact pin survives daemon loss and update; the running collector can move between direct rootless monitoring and rootful typed-helper summary without regaining action authority | **Qualified on distinct disposable Ubuntu 24.04/systemd 255 arm64 hosts at commit `60041ad9e60c282c892f944e04f777b874991a5d`.** All eleven canonical scenarios passed for Docker and all eleven for Podman. Receipt SHA-256: `7a116d63ab0cd1560482165055f0a8c9158ce0a8333a27bd9e8256582a52dfb5`; local attestation SHA-256: `566279ecd7d7dfa89c92f24243e9fcd5ae3e295d4284bd559d4be42f1e83b3b5`. This is local opt-in artifact-bound self-attestation, not published-RC provenance, action/update qualification, external review, or authorization for a default change | `deployment-installability`: reproduce the exact packet from published release artifacts; separately qualify any desired rootless image-update/action surface; retain the opt-in default until the remaining provider, appliance, and review conditions pass |
|
||||
| Separate runner package update and package-cache cleanup | Root-owned `pulse-agent-runner`, host-bound action credential, typed request, postcondition, and durable receipt | The schema-v6 committed-main systemd receipt records a real verified apt-cache mutation, stale-fingerprint refusal, replay, nonce-bound readiness, effective-unit override rejection, exact credential rotation, bodyless self-revocation, and collector/helper continuity. A separate production Router regression exercises HTTPS issuance, WSS admission, encrypted token persistence, failed-rotation rollback, exact socket invalidation, two server restarts, old-secret rejection, and durable self-revoke | Qualified for the exercised systemd fixture paths and focused production Router lifecycle. Focused installer tests also cover atomic pending cancellation and fail-closed credential retention. The evidence does not cover every runner operation, representative providers, or an exact release candidate | `agent-lifecycle` and `api-contracts`: reproduce the combined systemd and production Router path from trusted RC artifacts with representative package-update success/failure/cancellation evidence |
|
||||
|
||||
@@ -17,6 +17,7 @@ const shippedDocs = [
|
||||
{ source: path.join(repoRoot, 'docs', 'PRIVACY.md'), target: 'PRIVACY.md' },
|
||||
{ source: path.join(repoRoot, 'docs', 'CONFIGURATION.md'), target: 'CONFIGURATION.md' },
|
||||
{ source: path.join(repoRoot, 'docs', 'UNIFIED_AGENT.md'), target: 'UNIFIED_AGENT.md' },
|
||||
{ source: path.join(repoRoot, 'docs', 'AGENT_SECURITY.md'), target: 'AGENT_SECURITY.md' },
|
||||
{ source: path.join(repoRoot, 'docs', 'PROXY_AUTH.md'), target: 'PROXY_AUTH.md' },
|
||||
{ source: path.join(repoRoot, 'docs', 'TRUENAS.md'), target: 'TRUENAS.md' },
|
||||
{ source: path.join(repoRoot, 'SECURITY.md'), target: 'SECURITY.md' },
|
||||
|
||||
@@ -43,6 +43,7 @@ const (
|
||||
OperationAgentUpdateCommit = "agent_update.commit"
|
||||
OperationAgentUpdateRollback = "agent_update.rollback"
|
||||
OperationVersion1 = 1
|
||||
OperationVersion2 = 2
|
||||
)
|
||||
|
||||
// Request is the common envelope for one local helper operation. Payload is
|
||||
|
||||
@@ -20,6 +20,12 @@ type ProxmoxProvider interface {
|
||||
LXCFilesystems(context.Context) (json.RawMessage, error)
|
||||
}
|
||||
|
||||
// ProxmoxProviderV2 explicitly distinguishes successful partial collection
|
||||
// from a complete inventory. V1 remains complete-only for older collectors.
|
||||
type ProxmoxProviderV2 interface {
|
||||
LXCFilesystemsV2(context.Context) (json.RawMessage, error)
|
||||
}
|
||||
|
||||
// ContainerProvider owns a complete, bounded inventory of the helper's fixed
|
||||
// local Docker and Podman endpoints. The protocol supplies no socket, URL,
|
||||
// daemon method, or query fields.
|
||||
|
||||
@@ -92,6 +92,17 @@ func NewRegistryWithProviders(smart SMARTProvider, proxmox ProxmoxProvider, prov
|
||||
result, err := proxmox.LXCFilesystems(ctx)
|
||||
return validateProviderResult(result, err)
|
||||
})
|
||||
proxmoxV2, supportsV2 := proxmox.(ProxmoxProviderV2)
|
||||
if supportsV2 {
|
||||
registry.add(OperationProxmoxLXCFilesystems, OperationVersion2, true, func(ctx context.Context, payload json.RawMessage) (json.RawMessage, *ResponseError) {
|
||||
var request emptyOperationRequest
|
||||
if err := decodePayload(payload, &request); err != nil {
|
||||
return nil, invalidPayloadError(err)
|
||||
}
|
||||
result, err := proxmoxV2.LXCFilesystemsV2(ctx)
|
||||
return validateProviderResult(result, err)
|
||||
})
|
||||
}
|
||||
registry.add(OperationContainerInventory, OperationVersion1, providers.Containers != nil, func(ctx context.Context, payload json.RawMessage) (json.RawMessage, *ResponseError) {
|
||||
var request emptyOperationRequest
|
||||
if err := decodePayload(payload, &request); err != nil {
|
||||
|
||||
@@ -602,3 +602,29 @@ func TestAuditHookContainsMetadataOnly(t *testing.T) {
|
||||
t.Fatal("result data reached audit metadata")
|
||||
}
|
||||
}
|
||||
|
||||
type fakeProxmoxV2Provider struct{ calls int }
|
||||
|
||||
func (p *fakeProxmoxV2Provider) LXCFilesystems(context.Context) (json.RawMessage, error) {
|
||||
return nil, &ProviderError{Code: ErrorProviderUnavailable, Message: "incomplete"}
|
||||
}
|
||||
func (p *fakeProxmoxV2Provider) LXCFilesystemsV2(context.Context) (json.RawMessage, error) {
|
||||
p.calls++
|
||||
return json.RawMessage(`{"inventory":{"status":"partial","containers":[{"vmid":100}],"omittedVmids":[102],"collectedAt":"2026-10-05T15:00:00Z"}}`), nil
|
||||
}
|
||||
|
||||
func TestProxmoxV2DispatchIsTypedAndV1RemainsCompleteOnly(t *testing.T) {
|
||||
provider := &fakeProxmoxV2Provider{}
|
||||
server := newTestServer(t, NewRegistry(nil, provider), authorizedResolver(1000), nil)
|
||||
request := validRequest(OperationProxmoxLXCFilesystems)
|
||||
requireErrorCode(t, exchange(t, server, request), ErrorProviderUnavailable)
|
||||
request.OperationVersion = OperationVersion2
|
||||
if got := exchange(t, server, request); !got.Success || provider.calls != 1 {
|
||||
t.Fatalf("v2 response=%+v, calls=%d", got, provider.calls)
|
||||
}
|
||||
request.Payload = json.RawMessage(`{"vmid":100}`)
|
||||
requireErrorCode(t, exchange(t, server, request), ErrorInvalidRequest)
|
||||
if provider.calls != 1 {
|
||||
t.Fatal("caller-selected VMID reached provider")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
const privilegeHelperOperationDeadline = 30 * time.Second
|
||||
|
||||
var errPrivilegeHelperProxmoxInventoryUnavailable = errors.New("helper returned no Proxmox LXC filesystem inventory")
|
||||
var errPrivilegeHelperProxmoxInventoryPartial = errors.New("helper Proxmox LXC filesystem inventory is incomplete")
|
||||
|
||||
// PrivilegedTelemetry is the collector-side view of the no-network helper.
|
||||
// It intentionally exposes complete typed snapshots rather than commands,
|
||||
@@ -91,20 +92,37 @@ func (c *privilegeHelperTelemetry) ProxmoxLXCFilesystems(ctx context.Context) (*
|
||||
var response struct {
|
||||
Inventory *agentshost.ProxmoxLXCInventory `json:"inventory"`
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(ctx, privilegeHelperOperationDeadline)
|
||||
defer cancel()
|
||||
_, err := c.client.Call(
|
||||
ctx,
|
||||
agenthelper.OperationProxmoxLXCFilesystems,
|
||||
agenthelper.OperationVersion1,
|
||||
agenthelper.OperationVersion2,
|
||||
privilegeHelperOperationDeadline,
|
||||
struct{}{},
|
||||
&response,
|
||||
)
|
||||
legacy := false
|
||||
var remote *agenthelper.RemoteError
|
||||
if errors.As(err, &remote) && remote.Code == agenthelper.ErrorUnsupportedOperation {
|
||||
// Narrow compatibility only: an older helper explicitly rejects v2.
|
||||
// Do not fall back on transport, provider, malformed-result or peer errors.
|
||||
legacy = true
|
||||
_, err = c.client.Call(ctx, agenthelper.OperationProxmoxLXCFilesystems,
|
||||
agenthelper.OperationVersion1, privilegeHelperOperationDeadline, struct{}{}, &response)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if response.Inventory == nil {
|
||||
return nil, errPrivilegeHelperProxmoxInventoryUnavailable
|
||||
}
|
||||
if err := response.Inventory.ValidateCollection(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !legacy && response.Inventory.Status == "" {
|
||||
return nil, errors.New("helper v2 inventory has no completeness status")
|
||||
}
|
||||
return response.Inventory, nil
|
||||
}
|
||||
|
||||
@@ -121,7 +139,16 @@ func (a *Agent) collectProxmoxLXCFilesystemsForReport(ctx context.Context) *agen
|
||||
a.recordPrivilegeHelperOperation(privilegeHelperOperationProxmoxFilesystems, nil)
|
||||
return nil
|
||||
}
|
||||
a.recordPrivilegeHelperOperation(privilegeHelperOperationProxmoxFilesystems, err)
|
||||
if err == nil && inventory != nil {
|
||||
if validationErr := inventory.ValidateCollection(); validationErr != nil {
|
||||
err = validationErr
|
||||
}
|
||||
}
|
||||
statusErr := err
|
||||
if statusErr == nil && inventory != nil && inventory.Status == agentshost.ProxmoxLXCCollectionPartial {
|
||||
statusErr = errPrivilegeHelperProxmoxInventoryPartial
|
||||
}
|
||||
a.recordPrivilegeHelperOperation(privilegeHelperOperationProxmoxFilesystems, statusErr)
|
||||
if err != nil {
|
||||
a.logger.Debug().Err(err).Msg("Typed helper could not collect Proxmox LXC filesystems")
|
||||
return nil
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"os/exec"
|
||||
@@ -412,3 +413,158 @@ func requirePrivilegeHelperModuleStatus(t *testing.T, statuses []agentshost.Modu
|
||||
t.Fatalf("typed privilege helper module missing from %+v", statuses)
|
||||
return agentshost.ModuleStatus{}
|
||||
}
|
||||
|
||||
func TestCollectProxmoxPartialInventoryKeepsRowsAndDegradedHealth(t *testing.T) {
|
||||
helper := &fakePrivilegedTelemetry{proxmox: &agentshost.ProxmoxLXCInventory{
|
||||
Status: agentshost.ProxmoxLXCCollectionPartial, Containers: []agentshost.ProxmoxLXCContainer{{VMID: 100, Name: "web"}}, OmittedVMIDs: []int{102},
|
||||
}}
|
||||
status := NewPrivilegeHelperStatus()
|
||||
status.Record(privilegeHelperOperationSMART, errors.New("unrelated failure"))
|
||||
agent := &Agent{privilegedTelemetry: helper, privilegeHelperHealth: status, logger: zerolog.Nop()}
|
||||
got := agent.collectProxmoxLXCFilesystemsForReport(t.Context())
|
||||
if got == nil || len(got.Containers) != 1 || len(got.OmittedVMIDs) != 1 {
|
||||
t.Fatal("partial inventory was discarded")
|
||||
}
|
||||
module := status.ModuleStatus()
|
||||
if module.State != "degraded" || !strings.Contains(module.LastError, "inventory is incomplete") {
|
||||
t.Fatalf("module = %+v", module)
|
||||
}
|
||||
helper.proxmox = &agentshost.ProxmoxLXCInventory{Status: agentshost.ProxmoxLXCCollectionComplete}
|
||||
if agent.collectProxmoxLXCFilesystemsForReport(t.Context()) == nil {
|
||||
t.Fatal("complete recovery omitted")
|
||||
}
|
||||
module = status.ModuleStatus()
|
||||
if module.State != "degraded" || strings.Contains(module.LastError, "proxmox.lxc_filesystems") || !strings.Contains(module.LastError, privilegeHelperOperationSMART) {
|
||||
t.Fatalf("recovery cleared wrong health: %+v", module)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProxmoxHelperVersionCompatibilityIsNarrow(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
code string
|
||||
status string
|
||||
omitted []int
|
||||
wantErr bool
|
||||
wantCalls int
|
||||
}{
|
||||
{"v2 partial", "", "partial", []int{102}, false, 1},
|
||||
{"older helper", agenthelper.ErrorUnsupportedOperation, "", nil, false, 2},
|
||||
{"provider failure", agenthelper.ErrorProviderUnavailable, "", nil, true, 1},
|
||||
{"peer denial", agenthelper.ErrorUnauthorizedPeer, "", nil, true, 1},
|
||||
{"missing v2 completeness", "", "", nil, true, 1},
|
||||
{"invalid v2 completeness", "", "complete", []int{102}, true, 1},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
calls := make(chan int, 3)
|
||||
client, err := agenthelper.NewClient(agenthelper.ClientConfig{
|
||||
SocketPath: filepath.Join(t.TempDir(), "helper.sock"), MaxDeadline: privilegeHelperOperationDeadline,
|
||||
DialContext: func(context.Context, string, string) (net.Conn, error) {
|
||||
clientConn, serverConn := net.Pipe()
|
||||
go func() {
|
||||
defer serverConn.Close()
|
||||
var header [4]byte
|
||||
if _, err := io.ReadFull(serverConn, header[:]); err != nil {
|
||||
return
|
||||
}
|
||||
b := make([]byte, binary.BigEndian.Uint32(header[:]))
|
||||
if _, err := io.ReadFull(serverConn, b); err != nil {
|
||||
return
|
||||
}
|
||||
var req agenthelper.Request
|
||||
if json.Unmarshal(b, &req) != nil {
|
||||
return
|
||||
}
|
||||
calls <- req.OperationVersion
|
||||
inventory := &agentshost.ProxmoxLXCInventory{Status: tt.status, Containers: []agentshost.ProxmoxLXCContainer{{VMID: 100, Name: "web"}}, OmittedVMIDs: tt.omitted}
|
||||
raw, _ := json.Marshal(struct {
|
||||
Inventory *agentshost.ProxmoxLXCInventory `json:"inventory"`
|
||||
}{inventory})
|
||||
response := agenthelper.Response{ProtocolVersion: 1, RequestID: req.RequestID, Operation: req.Operation, OperationVersion: req.OperationVersion, Success: true, Result: raw}
|
||||
if tt.code != "" && req.OperationVersion == 2 {
|
||||
response.Success = false
|
||||
response.Result = nil
|
||||
response.Error = &agenthelper.ResponseError{Code: tt.code}
|
||||
}
|
||||
b, _ = json.Marshal(response)
|
||||
frame := make([]byte, 4+len(b))
|
||||
binary.BigEndian.PutUint32(frame[:4], uint32(len(b)))
|
||||
copy(frame[4:], b)
|
||||
serverConn.Write(frame)
|
||||
}()
|
||||
return clientConn, nil
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
inventory, err := (&privilegeHelperTelemetry{client: client}).ProxmoxLXCFilesystems(t.Context())
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Fatalf("inventory=%+v, error=%v", inventory, err)
|
||||
}
|
||||
if len(calls) != tt.wantCalls {
|
||||
t.Fatalf("calls=%d, want %d", len(calls), tt.wantCalls)
|
||||
}
|
||||
for n := 0; n < tt.wantCalls; n++ {
|
||||
version := <-calls
|
||||
if version != 2-n {
|
||||
t.Fatalf("call %d version=%d", n, version)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectProxmoxInvalidListCannotBecomeCompleteEmpty(t *testing.T) {
|
||||
header := "VMID Status Lock Name\n"
|
||||
boundary := header
|
||||
for n := 0; n < proxmoxLXCMaxContainers; n++ {
|
||||
boundary += fmt.Sprintf("%d running - web\n", 100+n)
|
||||
}
|
||||
if rows, err := parseProxmoxLXCRunningContainers(boundary); err != nil || len(rows) != proxmoxLXCMaxContainers {
|
||||
t.Fatalf("exact running-container boundary rejected: rows=%d err=%v", len(rows), err)
|
||||
}
|
||||
tests := []struct {
|
||||
name, output string
|
||||
valid bool
|
||||
}{
|
||||
{"header only", header, true},
|
||||
{"all stopped", header + "100 stopped - web\n", true},
|
||||
{"empty output", "", false},
|
||||
{"unrecognised output", "pmxcfs unavailable\n", false},
|
||||
{"headerless rows", "100 running - web\n", false},
|
||||
{"malformed header", "VMID Status\n", false},
|
||||
{"invalid running identity", header + "99 running - web\n", false},
|
||||
{"invalid state", header + "100 unknown - web\n", false},
|
||||
{"duplicate identity", header + "100 running - web\n100 stopped - web\n", false},
|
||||
{"over running limit", boundary + "228 running - web\n", false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
queries := 0
|
||||
collector := &mockCollector{
|
||||
goos: "linux",
|
||||
lookPathFn: func(string) (string, error) { return "/usr/sbin/pct", nil },
|
||||
commandCombinedOutputLimitedFn: func(_ context.Context, _ int, _ string, args ...string) (string, error) {
|
||||
queries++
|
||||
if strings.Join(args, " ") != "list" {
|
||||
t.Fatal("invalid or empty list must not start per-container queries")
|
||||
}
|
||||
return tt.output, nil
|
||||
},
|
||||
}
|
||||
result := (&Agent{logger: zerolog.Nop(), collector: collector}).collectProxmoxLXCFilesystemsResult(t.Context())
|
||||
if queries != 1 || !result.Applicable {
|
||||
t.Fatalf("collection did not use the one bounded list operation: %+v queries=%d", result, queries)
|
||||
}
|
||||
if tt.valid {
|
||||
if result.Degraded || result.Inventory == nil || result.Inventory.Status != "complete" || len(result.Inventory.Containers) != 0 {
|
||||
t.Fatalf("valid empty list=%+v", result)
|
||||
}
|
||||
} else if !result.Degraded || result.Inventory != nil {
|
||||
t.Fatalf("invalid list appeared complete: %+v", result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -92,6 +92,9 @@ func classifiedPrivilegeHelperStatusError(err error) string {
|
||||
if errors.Is(err, errPrivilegeHelperProxmoxInventoryUnavailable) {
|
||||
return "helper returned no Proxmox LXC filesystem inventory"
|
||||
}
|
||||
if errors.Is(err, errPrivilegeHelperProxmoxInventoryPartial) {
|
||||
return "helper Proxmox LXC filesystem inventory is incomplete"
|
||||
}
|
||||
var remote *agenthelper.RemoteError
|
||||
if errors.As(err, &remote) {
|
||||
switch remote.Code {
|
||||
|
||||
@@ -57,10 +57,8 @@ type proxmoxLXCRunningContainer struct {
|
||||
}
|
||||
|
||||
// ProxmoxLXCFilesystemCollectionResult describes applicability separately from
|
||||
// collection health without changing the helper protocol-v1 response shape.
|
||||
// A degraded result may retain a partial inventory for local diagnostics, but
|
||||
// the privileged helper fails the typed operation so the collector cannot
|
||||
// mistake a partial snapshot for a complete one.
|
||||
// collection health. Helper operation v1 fails incomplete collections; v2
|
||||
// carries successful rows with explicit completeness and omitted identities.
|
||||
type ProxmoxLXCFilesystemCollectionResult struct {
|
||||
Applicable bool
|
||||
Inventory *agentshost.ProxmoxLXCInventory
|
||||
@@ -177,10 +175,12 @@ func (a *Agent) collectProxmoxLXCFilesystemsResult(ctx context.Context) ProxmoxL
|
||||
result.Inventory = &agentshost.ProxmoxLXCInventory{
|
||||
Containers: []agentshost.ProxmoxLXCContainer{},
|
||||
CollectedAt: a.collector.Now().UTC(),
|
||||
Status: agentshost.ProxmoxLXCCollectionComplete,
|
||||
}
|
||||
for _, container := range containers {
|
||||
if collectionCtx.Err() != nil {
|
||||
result.FailedContainers++
|
||||
result.Inventory.OmittedVMIDs = append(result.Inventory.OmittedVMIDs, container.VMID)
|
||||
continue
|
||||
}
|
||||
containerCtx, cancelContainer := context.WithTimeout(collectionCtx, proxmoxLXCContainerQueryTimeout)
|
||||
@@ -188,6 +188,7 @@ func (a *Agent) collectProxmoxLXCFilesystemsResult(ctx context.Context) ProxmoxL
|
||||
cancelContainer()
|
||||
if collectionErr != nil {
|
||||
result.FailedContainers++
|
||||
result.Inventory.OmittedVMIDs = append(result.Inventory.OmittedVMIDs, container.VMID)
|
||||
continue
|
||||
}
|
||||
result.Inventory.Containers = append(result.Inventory.Containers, agentshost.ProxmoxLXCContainer{
|
||||
@@ -198,6 +199,7 @@ func (a *Agent) collectProxmoxLXCFilesystemsResult(ctx context.Context) ProxmoxL
|
||||
}
|
||||
if result.FailedContainers > 0 {
|
||||
result.Degraded = true
|
||||
result.Inventory.Status = agentshost.ProxmoxLXCCollectionPartial
|
||||
a.logger.Warn().
|
||||
Int("collected", len(result.Inventory.Containers)).
|
||||
Int("failed", result.FailedContainers).
|
||||
@@ -448,23 +450,38 @@ func parseProxmoxLXCRunningContainers(output string) ([]proxmoxLXCRunningContain
|
||||
|
||||
result := make([]proxmoxLXCRunningContainer, 0)
|
||||
seen := make(map[int]struct{})
|
||||
sawHeader := false
|
||||
for _, rawLine := range strings.Split(output, "\n") {
|
||||
line := strings.TrimSuffix(rawLine, "\r")
|
||||
if strings.TrimSpace(line) == "" || strings.HasPrefix(strings.TrimSpace(line), "VMID") {
|
||||
if strings.TrimSpace(line) == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) < 2 || !strings.EqualFold(fields[1], "running") {
|
||||
if fields[0] == "VMID" {
|
||||
if sawHeader || len(fields) != 4 || fields[1] != "Status" || fields[2] != "Lock" || fields[3] != "Name" {
|
||||
return nil, errors.New("pct list has an invalid header")
|
||||
}
|
||||
sawHeader = true
|
||||
continue
|
||||
}
|
||||
if !sawHeader || len(fields) < 3 {
|
||||
return nil, errors.New("pct list has an invalid row")
|
||||
}
|
||||
vmid, err := strconv.Atoi(fields[0])
|
||||
if err != nil || vmid < 100 || vmid > 999999999 {
|
||||
continue
|
||||
return nil, errors.New("pct list has an invalid container identity")
|
||||
}
|
||||
if _, exists := seen[vmid]; exists {
|
||||
return nil, errors.New("pct list has a duplicate container identity")
|
||||
}
|
||||
seen[vmid] = struct{}{}
|
||||
if strings.EqualFold(fields[1], "stopped") {
|
||||
continue
|
||||
}
|
||||
if !strings.EqualFold(fields[1], "running") {
|
||||
return nil, errors.New("pct list has an unknown container state")
|
||||
}
|
||||
|
||||
name := ""
|
||||
if len(line) > 35 {
|
||||
@@ -473,14 +490,16 @@ func parseProxmoxLXCRunningContainers(output string) ([]proxmoxLXCRunningContain
|
||||
name = fields[len(fields)-1]
|
||||
}
|
||||
if !safeProxmoxLXCText(name, proxmoxLXCMaxNameBytes) {
|
||||
continue
|
||||
return nil, errors.New("pct list has an invalid container name")
|
||||
}
|
||||
|
||||
seen[vmid] = struct{}{}
|
||||
result = append(result, proxmoxLXCRunningContainer{VMID: vmid, Name: name})
|
||||
if len(result) == proxmoxLXCMaxContainers {
|
||||
break
|
||||
return nil, errors.New("pct list exceeds the running container limit")
|
||||
}
|
||||
result = append(result, proxmoxLXCRunningContainer{VMID: vmid, Name: name})
|
||||
}
|
||||
if !sawHeader {
|
||||
return nil, errors.New("pct list has no inventory header")
|
||||
}
|
||||
sort.Slice(result, func(i, j int) bool { return result[i].VMID < result[j].VMID })
|
||||
return result, nil
|
||||
|
||||
@@ -74,6 +74,9 @@ mp0 tank:subvol-100-disk-0 1.0T 512.0G 512.0G 50.0 /srv/data
|
||||
if !result.Applicable || !result.Degraded || result.FailedContainers != 1 {
|
||||
t.Fatalf("collection result = %+v, want applicable partial failure", result)
|
||||
}
|
||||
if got.Status != "partial" || len(got.OmittedVMIDs) != 1 || got.OmittedVMIDs[0] != 102 {
|
||||
t.Fatalf("partial wire completeness = %+v", got)
|
||||
}
|
||||
if len(commands) != 3 {
|
||||
t.Fatalf("commands = %v, want list plus two running df queries", commands)
|
||||
}
|
||||
@@ -126,6 +129,9 @@ func TestCollectProxmoxLXCFilesystemsReportsTotalContainerFailure(t *testing.T)
|
||||
if result.Inventory == nil || len(result.Inventory.Containers) != 0 {
|
||||
t.Fatalf("partial inventory = %+v, want empty retained inventory", result.Inventory)
|
||||
}
|
||||
if result.Inventory.Status != "partial" || len(result.Inventory.OmittedVMIDs) != 2 || result.Inventory.ValidateCollection() != nil {
|
||||
t.Fatalf("all-failed inventory was not explicitly incomplete: %+v", result.Inventory)
|
||||
}
|
||||
}
|
||||
|
||||
// Regression for the #1477 follow-up: pct df costs over a second per guest,
|
||||
@@ -437,11 +443,8 @@ func TestParseProxmoxLXCFilesystemsValidatesAndBoundsInput(t *testing.T) {
|
||||
100, "running", "", "valid",
|
||||
)
|
||||
containers, err := parseProxmoxLXCRunningContainers(list)
|
||||
if err != nil {
|
||||
t.Fatalf("parse list: %v", err)
|
||||
}
|
||||
if len(containers) != 1 || containers[0].VMID != 100 || containers[0].Name != "valid" {
|
||||
t.Fatalf("running containers = %+v", containers)
|
||||
if err == nil || containers != nil {
|
||||
t.Fatalf("invalid identity cannot be silently omitted from a complete list: containers=%+v err=%v", containers, err)
|
||||
}
|
||||
if _, err := parseProxmoxLXCRunningContainers(
|
||||
strings.Repeat("x", proxmoxLXCMaxListOutputBytes+1),
|
||||
|
||||
@@ -29,9 +29,12 @@ type agentLXCFilesystemCacheEntry struct {
|
||||
}
|
||||
|
||||
func agentLXCFilesystemCacheKey(instance, node string, vmid int) string {
|
||||
return agentLXCFilesystemNodePrefix(instance, node) + strconv.Itoa(vmid)
|
||||
}
|
||||
|
||||
func agentLXCFilesystemNodePrefix(instance, node string) string {
|
||||
return strings.ToLower(strings.TrimSpace(instance)) + "\x00" +
|
||||
strings.ToLower(strings.TrimSpace(node)) + "\x00" +
|
||||
strconv.Itoa(vmid)
|
||||
strings.ToLower(strings.TrimSpace(node)) + "\x00"
|
||||
}
|
||||
|
||||
func agentLXCFilesystemTTL(intervalSeconds int) time.Duration {
|
||||
@@ -60,6 +63,9 @@ func (m *Monitor) applyAgentLXCFilesystems(
|
||||
strings.TrimSpace(linkedNodeID) == "" || agentID == "" {
|
||||
return
|
||||
}
|
||||
if inventory.ValidateCollection() != nil {
|
||||
return
|
||||
}
|
||||
|
||||
var linkedNodes []models.Node
|
||||
for _, node := range m.state.GetSnapshot().Nodes {
|
||||
@@ -109,7 +115,15 @@ func (m *Monitor) applyAgentLXCFilesystems(
|
||||
m.proxmoxLXCFilesystemsCache = make(map[string]agentLXCFilesystemCacheEntry)
|
||||
}
|
||||
for key, entry := range m.proxmoxLXCFilesystemsCache {
|
||||
if !receivedAt.Before(entry.expiresAt) {
|
||||
if !receivedAt.Before(entry.expiresAt) ||
|
||||
(inventory.Status == agentshost.ProxmoxLXCCollectionComplete && entry.agentID == agentID &&
|
||||
strings.HasPrefix(key, agentLXCFilesystemNodePrefix(node.Instance, node.Name))) {
|
||||
delete(m.proxmoxLXCFilesystemsCache, key)
|
||||
}
|
||||
}
|
||||
for _, vmid := range inventory.OmittedVMIDs {
|
||||
key := agentLXCFilesystemCacheKey(node.Instance, node.Name, vmid)
|
||||
if entry, exists := m.proxmoxLXCFilesystemsCache[key]; exists && entry.agentID == agentID {
|
||||
delete(m.proxmoxLXCFilesystemsCache, key)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6783,3 +6783,115 @@ func TestBroadcastProjectionListsRegistryOnceAndKeepsLiveChanges(t *testing.T) {
|
||||
t.Fatal("removed host lifecycle surface disappeared")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentLXCPartialInventoryPreservesSuccessAndInvalidatesOmissions(t *testing.T) {
|
||||
state := models.NewState()
|
||||
state.UpdateNodes([]models.Node{{ID: "node-id", Name: "node-a", Instance: "pve-a", LinkedAgentID: "agent-a"}})
|
||||
m := &Monitor{state: state}
|
||||
now := time.Date(2026, 10, 5, 16, 0, 0, 0, time.UTC)
|
||||
row := func(vmid int) agentshost.ProxmoxLXCContainer {
|
||||
return agentshost.ProxmoxLXCContainer{VMID: vmid, Name: "web", Disks: []agentshost.Disk{{Type: "rootfs", Device: "local:disk", Mountpoint: "/", TotalBytes: 4096, UsedBytes: 1024, FreeBytes: 3072}}}
|
||||
}
|
||||
m.applyAgentLXCFilesystems("node-id", "agent-a", &agentshost.ProxmoxLXCInventory{Status: "complete", Containers: []agentshost.ProxmoxLXCContainer{row(100), row(102)}}, now, 30)
|
||||
foreignKey := agentLXCFilesystemCacheKey("pve-b", "node-a", 102)
|
||||
m.proxmoxLXCFilesystemsCache[foreignKey] = agentLXCFilesystemCacheEntry{agentID: "agent-b", name: "foreign", expiresAt: now.Add(time.Hour)}
|
||||
m.applyAgentLXCFilesystems("node-id", "agent-a", &agentshost.ProxmoxLXCInventory{Status: "partial", Containers: []agentshost.ProxmoxLXCContainer{row(100)}, OmittedVMIDs: []int{102}}, now.Add(time.Minute), 30)
|
||||
if _, ok := m.proxmoxLXCFilesystemsCache[agentLXCFilesystemCacheKey("pve-a", "node-a", 102)]; ok {
|
||||
t.Fatal("omitted guest kept a usable prior agent reading")
|
||||
}
|
||||
if _, ok := m.proxmoxLXCFilesystemsCache[foreignKey]; !ok {
|
||||
t.Fatal("foreign instance cache was invalidated")
|
||||
}
|
||||
guest := models.Container{VMID: 100, Name: "web", Status: "running"}
|
||||
m.enrichContainerWithAgentLXCFilesystems("pve-a", "node-a", &guest, now.Add(time.Minute))
|
||||
if len(guest.Disks) != 1 || guest.Disks[0].Used != 1024 {
|
||||
t.Fatalf("successful partial guest=%+v", guest)
|
||||
}
|
||||
m.applyAgentLXCFilesystems("node-id", "agent-a", &agentshost.ProxmoxLXCInventory{Status: "complete"}, now.Add(2*time.Minute), 30)
|
||||
if _, ok := m.proxmoxLXCFilesystemsCache[agentLXCFilesystemCacheKey("pve-a", "node-a", 100)]; ok {
|
||||
t.Fatal("complete empty collection retained a removed/stopped guest reading")
|
||||
}
|
||||
if _, ok := m.proxmoxLXCFilesystemsCache[foreignKey]; !ok {
|
||||
t.Fatal("complete empty collection cleared a foreign instance")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentLXCMalformedPartialInventoryCannotRenewOrInvalidate(t *testing.T) {
|
||||
state := models.NewState()
|
||||
state.UpdateNodes([]models.Node{{ID: "node-id", Name: "node-a", Instance: "pve-a", LinkedAgentID: "agent-a"}})
|
||||
m := &Monitor{state: state}
|
||||
now := time.Now()
|
||||
key := agentLXCFilesystemCacheKey("pve-a", "node-a", 100)
|
||||
original := agentLXCFilesystemCacheEntry{agentID: "agent-a", name: "web", expiresAt: now.Add(time.Minute)}
|
||||
m.proxmoxLXCFilesystemsCache = map[string]agentLXCFilesystemCacheEntry{key: original}
|
||||
m.applyAgentLXCFilesystems("node-id", "agent-a", &agentshost.ProxmoxLXCInventory{Status: "complete", OmittedVMIDs: []int{100}}, now, 30)
|
||||
got := m.proxmoxLXCFilesystemsCache[key]
|
||||
if !got.expiresAt.Equal(original.expiresAt) || got.name != "web" {
|
||||
t.Fatal("malformed completeness changed cache")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentLXCPartialWireReportReachesLinkedGuestWithoutStaleOmission(t *testing.T) {
|
||||
m := newTestMonitor(t)
|
||||
m.state.UpdateNodes([]models.Node{{ID: "node-id", Name: "node-a", Instance: "pve-a"}})
|
||||
row := func(vmid int) agentshost.ProxmoxLXCContainer {
|
||||
return agentshost.ProxmoxLXCContainer{VMID: vmid, Name: "web", Disks: []agentshost.Disk{{
|
||||
Type: "rootfs", Device: "local:disk", Mountpoint: "/", TotalBytes: 4096, UsedBytes: 1024, FreeBytes: 3072,
|
||||
}}}
|
||||
}
|
||||
report := agentshost.Report{
|
||||
Agent: agentshost.AgentInfo{ID: "agent-a", IntervalSeconds: 30},
|
||||
Host: agentshost.HostInfo{ID: "agent-a", MachineID: "agent-a", Hostname: "node-a", Platform: "linux"},
|
||||
ProxmoxLXC: &agentshost.ProxmoxLXCInventory{
|
||||
Status: agentshost.ProxmoxLXCCollectionComplete, Containers: []agentshost.ProxmoxLXCContainer{row(100), row(102)},
|
||||
},
|
||||
}
|
||||
applyWire := func(sequence uint64) models.Host {
|
||||
t.Helper()
|
||||
report.Timestamp = time.Now().UTC()
|
||||
report.SequenceID = agentshost.FormatReportSequenceID("partial-wire-test", sequence)
|
||||
encoded, err := json.Marshal(report)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var decoded agentshost.Report
|
||||
if err := json.Unmarshal(encoded, &decoded); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
host, err := m.ApplyHostReport(decoded, &config.APITokenRecord{ID: "partial-wire-token"})
|
||||
if err != nil || host.LinkedNodeID != "node-id" {
|
||||
t.Fatalf("wire report did not reach linked node: host=%+v err=%v", host, err)
|
||||
}
|
||||
return host
|
||||
}
|
||||
applyWire(1)
|
||||
report.ProxmoxLXC.Status = agentshost.ProxmoxLXCCollectionPartial
|
||||
report.ProxmoxLXC.Containers = []agentshost.ProxmoxLXCContainer{row(100)}
|
||||
report.ProxmoxLXC.OmittedVMIDs = []int{102}
|
||||
report.Agent.Modules = []agentshost.ModuleStatus{{Name: agentshost.ModuleNameTypedPrivilegeHelper, Enabled: true, State: "degraded", LastError: "proxmox.lxc_filesystems: helper Proxmox LXC filesystem inventory is incomplete"}}
|
||||
host := applyWire(2)
|
||||
if len(host.AgentModules) != 1 || host.AgentModules[0].State != "degraded" {
|
||||
t.Fatalf("wire report lost partial helper health: %+v", host.AgentModules)
|
||||
}
|
||||
for _, vmid := range []int{100, 102} {
|
||||
guest := models.Container{VMID: vmid, Name: "web", Status: "running"}
|
||||
m.enrichContainerWithAgentLXCFilesystems("pve-a", "node-a", &guest, time.Now())
|
||||
if vmid == 100 && (len(guest.Disks) != 1 || guest.Disks[0].Used != 1024) {
|
||||
t.Fatalf("successful wire reading was lost: %+v", guest.Disks)
|
||||
}
|
||||
if vmid == 102 && len(guest.Disks) != 0 {
|
||||
t.Fatalf("omitted wire guest kept a prior reading: %+v", guest.Disks)
|
||||
}
|
||||
}
|
||||
report.ProxmoxLXC = &agentshost.ProxmoxLXCInventory{Status: agentshost.ProxmoxLXCCollectionComplete}
|
||||
report.Agent.Modules[0].State, report.Agent.Modules[0].LastError = "running", ""
|
||||
host = applyWire(3)
|
||||
if len(host.AgentModules) != 1 || host.AgentModules[0].State != "running" || host.AgentModules[0].LastError != "" {
|
||||
t.Fatalf("wire recovery did not clear partial helper health: %+v", host.AgentModules)
|
||||
}
|
||||
guest := models.Container{VMID: 100, Name: "web", Status: "running"}
|
||||
m.enrichContainerWithAgentLXCFilesystems("pve-a", "node-a", &guest, time.Now())
|
||||
if len(guest.Disks) != 0 {
|
||||
t.Fatal("complete empty wire report retained a stopped guest reading")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
package host
|
||||
|
||||
import "errors"
|
||||
|
||||
const (
|
||||
ProxmoxLXCCollectionComplete = "complete"
|
||||
ProxmoxLXCCollectionPartial = "partial"
|
||||
ProxmoxLXCMaxContainers = 128
|
||||
)
|
||||
|
||||
// ValidateCollection checks the shared helper/report completeness boundary.
|
||||
// Legacy omission is complete-only. Global unavailability has no inventory;
|
||||
// partial (including all-failed) inventories identify each omitted container.
|
||||
// Mount/value and node/name admission remain with the monitoring consumer.
|
||||
func (i *ProxmoxLXCInventory) ValidateCollection() error {
|
||||
invalid := errors.New("invalid Proxmox LXC filesystem completeness")
|
||||
if i == nil {
|
||||
return invalid
|
||||
}
|
||||
if i.Status == "" {
|
||||
// Keep legacy row-by-row admission and truncation unchanged.
|
||||
if len(i.OmittedVMIDs) != 0 {
|
||||
return invalid
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if len(i.Containers)+len(i.OmittedVMIDs) > ProxmoxLXCMaxContainers {
|
||||
return invalid
|
||||
}
|
||||
switch i.Status {
|
||||
case ProxmoxLXCCollectionComplete:
|
||||
if len(i.OmittedVMIDs) != 0 {
|
||||
return invalid
|
||||
}
|
||||
case ProxmoxLXCCollectionPartial:
|
||||
if len(i.OmittedVMIDs) == 0 {
|
||||
return invalid
|
||||
}
|
||||
default:
|
||||
return invalid
|
||||
}
|
||||
seen := make(map[int]struct{}, len(i.Containers)+len(i.OmittedVMIDs))
|
||||
admit := func(vmid int) bool {
|
||||
if vmid < 100 || vmid > 999999999 {
|
||||
return false
|
||||
}
|
||||
if _, duplicate := seen[vmid]; duplicate {
|
||||
return false
|
||||
}
|
||||
seen[vmid] = struct{}{}
|
||||
return true
|
||||
}
|
||||
for _, container := range i.Containers {
|
||||
if !admit(container.VMID) {
|
||||
return invalid
|
||||
}
|
||||
}
|
||||
for _, vmid := range i.OmittedVMIDs {
|
||||
if !admit(vmid) {
|
||||
return invalid
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package host
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestProxmoxLXCCollectionCompletenessWire(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
inventory ProxmoxLXCInventory
|
||||
valid bool
|
||||
}{
|
||||
{"legacy", ProxmoxLXCInventory{}, true},
|
||||
{"complete empty", ProxmoxLXCInventory{Status: ProxmoxLXCCollectionComplete}, true},
|
||||
{"partial", ProxmoxLXCInventory{Status: ProxmoxLXCCollectionPartial, Containers: []ProxmoxLXCContainer{{VMID: 100}}, OmittedVMIDs: []int{102}}, true},
|
||||
{"all failed", ProxmoxLXCInventory{Status: ProxmoxLXCCollectionPartial, OmittedVMIDs: []int{100, 102}}, true},
|
||||
{"partial without omissions", ProxmoxLXCInventory{Status: ProxmoxLXCCollectionPartial}, false},
|
||||
{"complete with omission", ProxmoxLXCInventory{Status: ProxmoxLXCCollectionComplete, OmittedVMIDs: []int{100}}, false},
|
||||
{"legacy with omission", ProxmoxLXCInventory{OmittedVMIDs: []int{100}}, false},
|
||||
{"unknown status", ProxmoxLXCInventory{Status: "unavailable"}, false},
|
||||
{"contradictory VMID", ProxmoxLXCInventory{Status: ProxmoxLXCCollectionPartial, Containers: []ProxmoxLXCContainer{{VMID: 100}}, OmittedVMIDs: []int{100}}, false},
|
||||
{"duplicate omission", ProxmoxLXCInventory{Status: ProxmoxLXCCollectionPartial, OmittedVMIDs: []int{100, 100}}, false},
|
||||
{"invalid omission", ProxmoxLXCInventory{Status: ProxmoxLXCCollectionPartial, OmittedVMIDs: []int{99}}, false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
tt.inventory.CollectedAt = time.Unix(1700000000, 0).UTC()
|
||||
if got := tt.inventory.ValidateCollection() == nil; got != tt.valid {
|
||||
t.Fatalf("valid=%v, want %v", got, tt.valid)
|
||||
}
|
||||
b, err := json.Marshal(tt.inventory)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var roundTrip ProxmoxLXCInventory
|
||||
if err := json.Unmarshal(b, &roundTrip); err != nil || !reflect.DeepEqual(tt.inventory, roundTrip) {
|
||||
t.Fatalf("round-trip=%+v, err=%v", roundTrip, err)
|
||||
}
|
||||
if tt.inventory.Status == "" && len(tt.inventory.OmittedVMIDs) == 0 {
|
||||
var old struct {
|
||||
Containers []ProxmoxLXCContainer `json:"containers"`
|
||||
CollectedAt time.Time `json:"collectedAt"`
|
||||
}
|
||||
if err := json.Unmarshal(b, &old); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestProxmoxLXCCollectionBounds(t *testing.T) {
|
||||
i := ProxmoxLXCInventory{Status: ProxmoxLXCCollectionComplete}
|
||||
for n := 0; n < ProxmoxLXCMaxContainers; n++ {
|
||||
i.Containers = append(i.Containers, ProxmoxLXCContainer{VMID: 100 + n})
|
||||
}
|
||||
if err := i.ValidateCollection(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
i.Containers = append(i.Containers, ProxmoxLXCContainer{VMID: 100 + ProxmoxLXCMaxContainers})
|
||||
if i.ValidateCollection() == nil {
|
||||
t.Fatal("over-bound inventory accepted")
|
||||
}
|
||||
}
|
||||
@@ -120,6 +120,10 @@ type XCPNGVM struct {
|
||||
type ProxmoxLXCInventory struct {
|
||||
Containers []ProxmoxLXCContainer `json:"containers"`
|
||||
CollectedAt time.Time `json:"collectedAt"`
|
||||
// Empty status is the legacy complete-only helper response. Version 2
|
||||
// makes completeness explicit; omitted VMIDs never carry fresh readings.
|
||||
Status string `json:"status,omitempty"`
|
||||
OmittedVMIDs []int `json:"omittedVmids,omitempty"`
|
||||
}
|
||||
|
||||
// ProxmoxLXCContainer contains direct pct df readings for one local running
|
||||
|
||||
@@ -5954,6 +5954,12 @@ provision_typed_privileged_helper() {
|
||||
fail "Failed to enable the typed privileged helper socket" "$EXIT_GENERAL"
|
||||
fi
|
||||
verify_privileged_helper_socket
|
||||
# Reloading a unit does not replace an already running helper's code.
|
||||
# Activate the verified binary only after the isolated unit and socket
|
||||
# identity have passed the installer-owned boundary checks.
|
||||
if ! systemctl restart "${PRIVILEGED_HELPER_NAME}.service"; then
|
||||
fail "Failed to restart the typed privileged helper with its installer-owned configuration" "$EXIT_GENERAL"
|
||||
fi
|
||||
log_info "Typed privileged helper socket active at ${PRIVILEGED_HELPER_SOCKET_PATH} (root:${LEAST_PRIVILEGE_USER} 0660)"
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
@@ -307,6 +308,7 @@ func TestSafeProfileValidatesEveryEffectiveSystemdBoundary(t *testing.T) {
|
||||
{name: "helper service drop-in", unit: "pulse-agent-helper.service", property: "DropInPaths", value: "/etc/systemd/system/pulse-agent-helper.service.d/override.conf"},
|
||||
{name: "helper executable", unit: "pulse-agent-helper.service", property: "ExecStart", value: "{ path=/tmp/helper ; argv[]=/tmp/helper ; }"},
|
||||
{name: "helper private network", unit: "pulse-agent-helper.service", property: "PrivateNetwork", value: "no"},
|
||||
{name: "helper unknown network isolation", unit: "pulse-agent-helper.service", property: "PrivateNetwork", value: ""},
|
||||
{name: "helper common hardening", unit: "pulse-agent-helper.service", property: "ProtectKernelModules", value: "no"},
|
||||
{name: "helper task limit", unit: "pulse-agent-helper.service", property: "TasksMax", value: "infinity"},
|
||||
{name: "helper descriptor limit", unit: "pulse-agent-helper.service", property: "LimitNOFILE", value: "1048576"},
|
||||
@@ -369,6 +371,102 @@ func TestTypedHelperProvisionChecksEffectiveUnitsBeforeSocketActivation(t *testi
|
||||
}
|
||||
}
|
||||
|
||||
func TestTypedHelperProvisionReplacesRunningHelperAfterValidatedUnit(t *testing.T) {
|
||||
provision := extractInstallShellFunction(t, "provision_typed_privileged_helper")
|
||||
// The production activation target remains fixed. Translate just that
|
||||
// comparison into this disposable filesystem, not the installer itself;
|
||||
// the wrong-target control must still be refused before any unit operation.
|
||||
fixedTargetCheck := `if [[ "${INSTALL_DIR}/${BINARY_NAME}" != "/usr/local/bin/pulse-agent" ]]; then`
|
||||
if strings.Count(provision, fixedTargetCheck) != 1 {
|
||||
t.Fatal("typed helper no longer enforces exactly one fixed activation target")
|
||||
}
|
||||
for _, mode := range []string{"success", "effective-unit-failure", "restart-failure", "wrong-target"} {
|
||||
t.Run(mode, func(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
mustMkdirAll(t, filepath.Join(root, "bin"), filepath.Join(root, "systemd"))
|
||||
if err := os.WriteFile(filepath.Join(root, "bin", "pulse-agent"), []byte("fixture"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
script := `
|
||||
set -euo pipefail
|
||||
ROOT=` + strconv.Quote(root) + `
|
||||
MODE=` + strconv.Quote(mode) + `
|
||||
INSTALL_DIR="$ROOT/bin"
|
||||
if [[ "$MODE" == wrong-target ]]; then
|
||||
INSTALL_DIR="$ROOT/other-bin"
|
||||
mkdir -p "$INSTALL_DIR"
|
||||
cp "$ROOT/bin/pulse-agent" "$INSTALL_DIR/pulse-agent"
|
||||
fi
|
||||
BINARY_NAME=pulse-agent
|
||||
LEAST_PRIVILEGE_USER=pulse-agent
|
||||
PRIVILEGED_HELPER_NAME=pulse-agent-helper
|
||||
PRIVILEGED_HELPER_BINARY_PATH="$ROOT/bin/pulse-agent-helper"
|
||||
PRIVILEGED_HELPER_SERVICE_UNIT="$ROOT/systemd/pulse-agent-helper.service"
|
||||
PRIVILEGED_HELPER_SOCKET_UNIT="$ROOT/systemd/pulse-agent-helper.socket"
|
||||
PRIVILEGED_HELPER_SOCKET_PATH="$ROOT/helper.sock"
|
||||
PRIVILEGED_HELPER_UPDATE_QUARANTINE_DIR="$ROOT/quarantine"
|
||||
PRIVILEGED_HELPER_STATE_DIR="$ROOT/state"
|
||||
PRIVILEGED_HELPER_UPDATE_STAGING_DIR="$ROOT/staging"
|
||||
EXIT_GENERAL=1
|
||||
install() { :; }
|
||||
chown() { :; }
|
||||
chmod() { :; }
|
||||
append_service_env() { :; }
|
||||
log_info() { printf 'complete\n' >> "$ROOT/events"; }
|
||||
fail() { printf '%s\n' "$1" >&2; exit "$2"; }
|
||||
safe_profile_verify_helper_effective_target() {
|
||||
printf 'validate\n' >> "$ROOT/events"
|
||||
test "$MODE" != effective-unit-failure
|
||||
}
|
||||
verify_privileged_helper_socket() { printf 'socket-identity\n' >> "$ROOT/events"; }
|
||||
systemctl() {
|
||||
printf '%s\n' "$*" >> "$ROOT/events"
|
||||
case "$*" in
|
||||
daemon-reload|'enable --now pulse-agent-helper.socket') return 0 ;;
|
||||
'restart pulse-agent-helper.service')
|
||||
test "$MODE" != restart-failure || return 1
|
||||
printf 'new-helper\n' > "$ROOT/running-helper"
|
||||
return 0 ;;
|
||||
*) return 2 ;;
|
||||
esac
|
||||
}
|
||||
printf 'old-helper\n' > "$ROOT/running-helper"
|
||||
: > "$ROOT/events"
|
||||
` + extractInstallShellFunction(t, "render_privileged_helper_socket_unit") + "\n" +
|
||||
extractInstallShellFunction(t, "render_privileged_helper_service_unit") + "\n" +
|
||||
strings.Replace(provision, fixedTargetCheck, `if [[ "${INSTALL_DIR}/${BINARY_NAME}" != "$ROOT/bin/pulse-agent" ]]; then`, 1) + "\nprovision_typed_privileged_helper\n"
|
||||
out, err := exec.Command("bash", "-c", script).CombinedOutput()
|
||||
events, readErr := os.ReadFile(filepath.Join(root, "events"))
|
||||
if readErr != nil {
|
||||
t.Fatalf("installer fixture events: %v; execution=%v output=%s", readErr, err, out)
|
||||
}
|
||||
want := "daemon-reload\nvalidate\n"
|
||||
if mode == "wrong-target" {
|
||||
want = ""
|
||||
} else if mode != "effective-unit-failure" {
|
||||
want += "enable --now pulse-agent-helper.socket\nsocket-identity\nrestart pulse-agent-helper.service\n"
|
||||
}
|
||||
if mode == "success" {
|
||||
want += "complete\n"
|
||||
}
|
||||
if string(events) != want || (err == nil) != (mode == "success") {
|
||||
t.Fatalf("installer helper activation: events=%q want=%q err=%v output=%s", events, want, err, out)
|
||||
}
|
||||
if mode == "success" {
|
||||
running, _ := os.ReadFile(filepath.Join(root, "running-helper"))
|
||||
unit, _ := os.ReadFile(filepath.Join(root, "systemd", "pulse-agent-helper.service"))
|
||||
if string(running) != "new-helper\n" || !strings.Contains(string(unit), "PrivateNetwork=true\n") {
|
||||
t.Fatal("installer left the simulated predecessor active or did not preserve the isolated unit")
|
||||
}
|
||||
} else if mode == "restart-failure" && !strings.Contains(string(out), "Failed to restart the typed privileged helper") {
|
||||
t.Fatalf("restart failure was not surfaced: %s", out)
|
||||
} else if mode == "wrong-target" && !strings.Contains(string(out), "require the fixed /usr/local/bin/pulse-agent target") {
|
||||
t.Fatalf("wrong activation target was not refused: %s", out)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTypedHelperUnitRendersBoundedResources(t *testing.T) {
|
||||
unitPath := filepath.Join(t.TempDir(), "pulse-agent-helper.service")
|
||||
render := extractInstallShellFunction(t, "render_privileged_helper_service_unit")
|
||||
@@ -394,6 +492,55 @@ render_privileged_helper_service_unit "` + unitPath + `" /usr/local/lib/pulse-ag
|
||||
}
|
||||
}
|
||||
|
||||
// AF_UNIX-only socket filtering is not enforced on native 32-bit x86.
|
||||
// Rendering/admission controls must keep the independent private namespace
|
||||
// on every platform, not infer enforcement from an effective property string.
|
||||
// These are installer regressions, not execution of a native sandbox.
|
||||
func TestTypedHelperIsolationDoesNotDependOnArchitectureOrPVE(t *testing.T) {
|
||||
for _, arch := range []string{"amd64", "arm64", "armv7", "armv6", "386", "unknown"} {
|
||||
for _, pve := range []string{"false", "true"} {
|
||||
t.Run(arch+"/pve="+pve, func(t *testing.T) {
|
||||
unitPath := filepath.Join(t.TempDir(), "helper.service")
|
||||
script := `
|
||||
set -euo pipefail
|
||||
ARCH=` + strconv.Quote(arch) + `
|
||||
ENABLE_PROXMOX=` + strconv.Quote(pve) + `
|
||||
PRIVILEGED_HELPER_NAME=pulse-agent-helper
|
||||
PRIVILEGED_HELPER_UPDATE_QUARANTINE_DIR=/var/lib/pulse-agent/update-quarantine
|
||||
PRIVILEGED_HELPER_STATE_DIR=/var/lib/pulse-agent-helper
|
||||
` + extractInstallShellFunction(t, "render_privileged_helper_service_unit") + `
|
||||
render_privileged_helper_service_unit ` + strconv.Quote(unitPath) + ` /usr/local/lib/pulse-agent/pulse-agent-helper
|
||||
`
|
||||
if out, err := exec.Command("bash", "-c", script).CombinedOutput(); err != nil {
|
||||
t.Fatalf("render isolated helper: %v\n%s", err, out)
|
||||
}
|
||||
body, err := os.ReadFile(unitPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, directive := range []string{"PrivateNetwork=true", "RestrictAddressFamilies=AF_UNIX", "SystemCallArchitectures=native"} {
|
||||
if strings.Count(string(body), directive+"\n") != 1 {
|
||||
t.Fatalf("%s/%s did not preserve %s: %s", arch, pve, directive, body)
|
||||
}
|
||||
}
|
||||
for _, value := range []string{"no", ""} {
|
||||
gate := effectiveSystemdHarness(t, "pulse-agent-helper.service", "PrivateNetwork", value) + "\n" +
|
||||
"ARCH=" + strconv.Quote(arch) + "\nENABLE_PROXMOX=" + strconv.Quote(pve) + "\n" +
|
||||
safeProfileEffectiveSystemdFunctions(t) + `
|
||||
if safe_profile_verify_effective_target; then
|
||||
echo 'host or unknown helper network was admitted' >&2
|
||||
exit 1
|
||||
fi
|
||||
`
|
||||
if out, err := exec.Command("bash", "-c", gate).CombinedOutput(); err != nil {
|
||||
t.Fatalf("fail-closed helper admission: %v\n%s", err, out)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSafeProfileRegistrationMustAdvanceLastSeen(t *testing.T) {
|
||||
functions := extractInstallShellFunction(t, "verify_agent_server_registration")
|
||||
script := `
|
||||
|
||||
@@ -35,6 +35,11 @@ func TestSecureRuntimePlatformMatrixRemainsExplicitAndShipped(t *testing.T) {
|
||||
"collectionMode: typed-helper-summary",
|
||||
"currently explicit rather than the installer default.",
|
||||
"Residual owner and removal condition",
|
||||
"systemd does not enforce `RestrictAddressFamilies` on native 32-bit x86",
|
||||
"Do not\nremove `PrivateNetwork` to work around a telemetry failure.",
|
||||
"helper's private network namespace",
|
||||
"operation v2 preserves the collected",
|
||||
"Unavailable on hosts where pmxcfs abstract IPC is hidden",
|
||||
}
|
||||
for _, marker := range required {
|
||||
if !strings.Contains(content, marker) {
|
||||
|
||||
Reference in New Issue
Block a user