diff --git a/cmd/pulse-agent-helper/main.go b/cmd/pulse-agent-helper/main.go index aba6c4a1d..fd3d1bb99 100644 --- a/cmd/pulse-agent-helper/main.go +++ b/cmd/pulse-agent-helper/main.go @@ -75,7 +75,9 @@ func run(args []string) error { containers, err := agenthelper.NewLocalContainerProvider([]agenthelper.ContainerEndpoint{ {Runtime: "docker", SocketPath: "/var/run/docker.sock", APIPath: "/v1.41/containers/json?all=1"}, - {Runtime: "podman", SocketPath: "/run/podman/podman.sock", APIPath: "/v4.0.0/libpod/containers/json?all=true"}, + // Podman's documented Docker-compatibility API preserves the bounded + // Docker list-summary schema decoded by LocalContainerProvider. + {Runtime: "podman", SocketPath: "/run/podman/podman.sock", APIPath: "/v1.40/containers/json?all=1"}, }) if err != nil { return fmt.Errorf("configure container inventory: %w", err) diff --git a/cmd/pulse-agent/main.go b/cmd/pulse-agent/main.go index 922c2c104..b3b64f55b 100644 --- a/cmd/pulse-agent/main.go +++ b/cmd/pulse-agent/main.go @@ -465,11 +465,16 @@ func run(ctx context.Context, args []string, getenv func(string) string) error { // 7. Start Auto-Updater var privilegedUpdate agentupdate.PrivilegedUpdate helperSocket := strings.TrimSpace(os.Getenv("PULSE_AGENT_HELPER_SOCKET")) + var helperContainerInventory dockeragent.ContainerInventory if helperSocket != "" { privilegedUpdate, err = newPrivilegeHelperUpdate(helperSocket) if err != nil { return fmt.Errorf("configure typed privilege-helper updates: %w", err) } + helperContainerInventory, err = dockeragent.NewPrivilegeHelperContainerInventory(helperSocket) + if err != nil { + return fmt.Errorf("configure typed privilege-helper container inventory: %w", err) + } } updater := newUpdater(agentupdate.Config{ PulseURL: cfg.PulseURL, @@ -607,11 +612,12 @@ func run(ctx context.Context, args []string, getenv func(string) string) error { DiskExclude: cfg.DiskExclude, DiskInclude: cfg.DiskInclude, Targets: dockerReportTargets(cfg), + HelperInventory: helperContainerInventory, } dockerAgent, err = newDockerAgent(dockerCfg) if err == nil { - dockerUpdaterBridge.set(dockerAgent) + bindDockerActionBridge(dockerUpdaterBridge, dockerAgent) } if err != nil { runtimeStatus.setState("docker", moduleStateRetrying, err) @@ -626,7 +632,7 @@ func run(ctx context.Context, args []string, getenv func(string) string) error { agent := initDockerWithRetry(ctx, dockerCfg, &logger) if agent != nil { dockerAgent = agent - dockerUpdaterBridge.set(agent) + bindDockerActionBridge(dockerUpdaterBridge, agent) runtimeStatus.setState("docker", moduleStateRunning, nil) logger.Info().Msg("Docker / Podman module started (after retry)") return agent.Run(ctx) @@ -731,6 +737,20 @@ func run(ctx context.Context, args []string, getenv func(string) string) error { return nil } +type containerActionCapability interface { + ContainerActionsAvailable() bool +} + +func bindDockerActionBridge(bridge *lateBoundDockerUpdater, agent RunnableCloser) { + if bridge == nil || agent == nil { + return + } + if capability, ok := agent.(containerActionCapability); ok && !capability.ContainerActionsAvailable() { + return + } + bridge.set(agent) +} + func configureAgentLogger(cfg Config) (zerolog.Logger, func(), error) { zerolog.SetGlobalLevel(cfg.LogLevel) if cfg.LogFile == "" { diff --git a/cmd/pulse-agent/main_test.go b/cmd/pulse-agent/main_test.go index 886751e37..a202e501d 100644 --- a/cmd/pulse-agent/main_test.go +++ b/cmd/pulse-agent/main_test.go @@ -2639,6 +2639,17 @@ type stubTypedContainerUpdater struct { lifecycleMutations int } +type capabilityDockerAgent struct { + stubTypedContainerUpdater + actions bool +} + +func (*capabilityDockerAgent) Run(context.Context) error { return nil } +func (*capabilityDockerAgent) Close() error { return nil } +func (a *capabilityDockerAgent) ContainerActionsAvailable() bool { + return a.actions +} + func (s *stubTypedContainerUpdater) TypedContainerUpdatePreflight(context.Context, string, string, string) error { s.preflightCalls++ return nil @@ -2706,6 +2717,21 @@ func TestLateBoundDockerUpdaterBridgesModuleWhenItComesUp(t *testing.T) { } } +func TestBindDockerActionBridgeRejectsSummaryOnlyModule(t *testing.T) { + bridge := &lateBoundDockerUpdater{} + summaryOnly := &capabilityDockerAgent{actions: false} + bindDockerActionBridge(bridge, summaryOnly) + if _, err := bridge.TypedContainerUpdate(context.Background(), "docker", strings.Repeat("a", 12), "sha256:"+strings.Repeat("1", 64), nil); err == nil { + t.Fatal("summary-only module was granted container update authority") + } + + direct := &capabilityDockerAgent{actions: true} + bindDockerActionBridge(bridge, direct) + if _, err := bridge.TypedContainerUpdate(context.Background(), "docker", strings.Repeat("a", 12), "sha256:"+strings.Repeat("1", 64), nil); err != nil { + t.Fatalf("direct runtime module was not bridged: %v", err) + } +} + func TestDockerAgentImplementsTypedContainerUpdater(t *testing.T) { // The bridge installs by structural assertion; if the Docker module's // method signature drifts, updates silently refuse at runtime. Pin it. diff --git a/docs/AGENT_SECURITY.md b/docs/AGENT_SECURITY.md index bb61037f4..d3e8aa818 100644 --- a/docs/AGENT_SECURITY.md +++ b/docs/AGENT_SECURITY.md @@ -165,8 +165,9 @@ collector-writable direct-replacement fallback. Exceptional telemetry crosses `/run/pulse-agent/helper.sock` to a separate root process. The socket admits only the `pulse-agent` UID, and the helper has no Pulse URL, API token, or network namespace. Its protocol exposes bounded, -versioned SMART and Proxmox LXC filesystem snapshots, not a shell, executable -path, device path, VMID, environment, or caller-selected arguments. The helper +versioned SMART, Proxmox LXC filesystem, and fixed-endpoint container summary +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`. `PrivateDevices` is intentionally not enabled because SMART needs the host @@ -175,9 +176,13 @@ only the affected telemetry disappears; the collector does not fall back to sudo, root, or a broader local command path. The typed-helper profile cannot be combined with `--grant-smart` or -`--grant-pct`. Rootful Docker-socket monitoring is also unavailable because -membership in the Docker group is root-equivalent; API monitoring or a -separately scoped rootless runtime socket is required instead. The profile is +`--grant-pct`. The collector never joins the rootful Docker group. When no +collector-owned rootless socket is available, the helper preserves only +container ID/name/image/state/status/creation summaries; reports identify this +as `collectionMode: typed-helper-summary`. Stats, images, volumes, networks, +storage, Swarm, registry update checks, and lifecycle actions remain unavailable. +A separately scoped rootless runtime socket is required for full collection. +The profile is currently explicit rather than the installer default. Its inspect, apply, and rollback transaction is implemented for Linux systemd, but representative live-host migration and helper update staging/activation/rollback exercises, @@ -199,7 +204,7 @@ or generic command path. | 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; helper-backed signed update activation is implemented but its live activation/recovery transaction is not qualified | **Qualified on disposable Ubuntu 24.04.4 arm64 at committed main `defc24af837b91428fbee939d09cd31e9559fb4f`** for install, migration, explicit/automatic profile rollback, helper health, reporting continuity, and process/credential separation. The schema-v4 receipt's ordinary update ran under the downgraded root monitoring profile and does not prove `agent_update.activate.v1`, executable-digest commit, watchdog rollback, interrupted recovery, or last-known-good restoration. Remains opt-in pending those live scenarios, exact-RC reproduction, and external review | `deployment-installability` and `security-privacy`: qualify helper activation/failure/recovery from the designated release candidate 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 | -| Rootful Docker or Podman inventory | No direct collector access to a root-equivalent daemon socket | **Unavailable in the safe profile.** Migration disables the provider visibly. A closed helper `container.inventory` operation exists, but collector integration and live parity are not qualified | Rootful container parity is an explicit default blocker; the legacy/root profile is not safe-profile evidence | `agent-lifecycle`: either integrate and qualify bounded helper inventory or retain the explicit degradation permanently | +| 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 | Unit and installer regressions cover the boundary, but no representative live Docker/Podman qualification exists. Full rootful parity remains an explicit default blocker; the legacy/root profile is not safe-profile evidence | `agent-lifecycle`: record fresh install, migration, restart, helper loss/recovery, bounds, and summary parity on representative rootful Docker and Podman; 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 | Implemented, unqualified live. Ambiguous, root-owned, unreadable, unwritable, or unavailable sockets disable container monitoring | Does not yet justify container-runtime parity or a default change | `deployment-installability`: record fresh install, migration, restart, socket-loss, ambiguity, and telemetry parity on both rootless Docker and rootless Podman | | 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-v4 committed-main systemd receipt records a real verified apt-cache mutation, stale-fingerprint refusal, replay, nonce-bound readiness, exact credential rotation, and self-revocation. 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. Neither proof covers local runner activation failure after credential preparation, every runner operation, or an exact release candidate | `agent-lifecycle` and `api-contracts`: reproduce the combined systemd and production Router path from the RC, including failed local activation/rollback and representative package-update success/failure/cancellation evidence | | Separate runner Proxmox guest and container lifecycle/update actions | Root-owned runner with closed typed protocols; never the monitoring collector | Implemented, unqualified on representative PVE and container-runtime targets | No live-provider action-parity claim and no default change | `agent-lifecycle`: record target-bound success, stale-state refusal, cancellation, reconnect/replay, and independent postconditions on disposable real targets | diff --git a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md index 00ba98a82..b73d9fca2 100644 --- a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md +++ b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md @@ -6876,7 +6876,13 @@ The first collection operations are `smart.snapshot.v1`, `proxmox.lxc_filesystems.v1`, and the bounded fixed-endpoint `container.inventory.v1`; callers cannot supply a binary path, arbitrary filesystem path, daemon endpoint, environment, or command arguments. The -`agent_update.activate.v1` and `agent_update.rollback.v1` families accept only +collector uses this operation only as a summary-only reporting fallback when +direct runtime admission fails; it does not expose update or lifecycle methods, +and reports preserve that reduction as `collectionMode: typed-helper-summary`. +Under the helper profile, direct runtime candidates are admitted by endpoint +type, collector UID ownership, and rootless runtime path before the first daemon +API request; rejected candidates are closed without probing daemon information. +The `agent_update.activate.v1` and `agent_update.rollback.v1` families accept only fixed-root, regular, owned, digest-bound update artifacts and produce durable activation identity around an atomic swap. Their collector staging, restart/health, and live rollback integration remains qualification work, so diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md index 831c03585..7dd5e7b12 100644 --- a/docs/release-control/v6/internal/subsystems/api-contracts.md +++ b/docs/release-control/v6/internal/subsystems/api-contracts.md @@ -10369,3 +10369,13 @@ that predate explicit status. Model, resource, and presentation contracts are pinned by `internal/monitoring/node_pending_updates_evidence_test.go`, `internal/unifiedresources/proxmox_update_evidence_test.go`, and `frontend-modern/src/utils/__tests__/proxmoxUpdateEvidence.test.ts`. + +### Docker collection mode is additive capability evidence + +Docker host reports and their canonical resource projections may add the +optional string `collectionMode`. The current bounded value +`typed-helper-summary` means the collector received only helper-provided +container ID, name, image, state, status, and creation summaries. Absence +retains the existing direct-runtime contract. Clients must treat unknown future +values defensively and must not infer stats, secondary inventories, update +checks, or lifecycle authority from a summary-mode report. diff --git a/docs/release-control/v6/internal/subsystems/deployment-installability.md b/docs/release-control/v6/internal/subsystems/deployment-installability.md index 497deb44a..a04d3e39c 100644 --- a/docs/release-control/v6/internal/subsystems/deployment-installability.md +++ b/docs/release-control/v6/internal/subsystems/deployment-installability.md @@ -193,11 +193,12 @@ host-bound authority-reduction API and fails closed unless `agent:exec` and installer-owned state-root metadata plus the Proxmox registration markers the apply path can mutate; it never replays collector-controlled descendant paths, and every rollback strips the legacy command flag rather than reversing the -server-side reduction. Rootful Docker is an explicit migration degradation: it -is disabled unless a -collector-owned, readable and writable rootless runtime socket is available; -the safe profile never restores Docker by adding the collector to a -root-equivalent group. +server-side reduction. Rootful Docker remains an explicit migration +degradation: without a collector-owned, readable and writable rootless runtime +socket, the typed helper supplies summary-only container inventory and the +report carries `collectionMode: typed-helper-summary`. Stats, secondary +inventory, update checks, and actions remain unavailable; the safe profile +never restores Docker by adding the collector to a root-equivalent group. Release builds and archives carry both helper and runner binaries for the five Linux targets (`amd64`, `arm64`, `armv7`, `armv6`, and `386`) with checksum, diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index 65449af69..97fa8d5dd 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -6932,3 +6932,13 @@ current positive observation. Provider errors are never rendered. Component and browser evidence covers both 1440px and 390px layouts in `NodeDrawerOverview.updateEvidence.test.tsx`, `ProxmoxNodesTable.test.tsx`, and `frontend-modern/browser-verification.json`. + +### Docker drawers expose reduced helper coverage without actions + +The Docker host drawer consumes the canonical optional `collectionMode` field. +For `typed-helper-summary` it adds one bounded warning to the shared attention +section and omits the container update management card; it does not fabricate +zero update state or offer an action that the reporting collector cannot +execute. Unknown or absent values preserve the direct-runtime presentation. +Component and browser proofs cover the warning, action omission, mode +transition, and desktop/narrow containment. diff --git a/docs/release-control/v6/internal/subsystems/monitoring.md b/docs/release-control/v6/internal/subsystems/monitoring.md index 9101b24f2..ba0616d60 100644 --- a/docs/release-control/v6/internal/subsystems/monitoring.md +++ b/docs/release-control/v6/internal/subsystems/monitoring.md @@ -3559,3 +3559,13 @@ until their normal refresh boundary, and a failed refresh is retried without discarding the cache. `node_pending_updates_evidence_test.go` and the cluster client pending-update tests pin zero, permission, reachability, stale-cache, and offline behavior. + +### Summary-only container collection remains explicit through ingestion + +Monitoring copies the Docker report's optional `collectionMode` into the +stored host model and frontend projection without upgrading its authority. +`typed-helper-summary` reports may carry the bounded container summaries and +ordinary host metrics, but missing stats, storage, images, networks, volumes, +Swarm, and update evidence remain absent rather than being reconstructed from +older or adjacent observations. Model, monitor, and unified-resource tests pin +the additive field through the ingestion path. diff --git a/docs/release-control/v6/internal/subsystems/performance-and-scalability.md b/docs/release-control/v6/internal/subsystems/performance-and-scalability.md index 3dc2bb01f..af6e5a8ea 100644 --- a/docs/release-control/v6/internal/subsystems/performance-and-scalability.md +++ b/docs/release-control/v6/internal/subsystems/performance-and-scalability.md @@ -3000,3 +3000,12 @@ poll loop, websocket channel, history series, or per-render scan: collection retains the existing 30-minute cache and frontend projection/presentation is constant work per rendered node. Failed refreshes reuse the single cached successful observation rather than accumulating attempts or provider errors. + +### Container collection mode adds no Workloads hot-path work + +The optional Docker `collectionMode` value is copied through the existing host +resource and Workloads projections in constant work per resource. It adds no +poll, query, websocket subscription, history series, cache, per-row scan, or +responsive signal. The Docker drawer evaluates the one bounded string only for +the selected host, and summary-only helper collection omits the existing +stats-dependent and secondary-inventory work at the agent. diff --git a/docs/release-control/v6/internal/subsystems/security-privacy.md b/docs/release-control/v6/internal/subsystems/security-privacy.md index 154b15503..4ad8d7943 100644 --- a/docs/release-control/v6/internal/subsystems/security-privacy.md +++ b/docs/release-control/v6/internal/subsystems/security-privacy.md @@ -2630,3 +2630,18 @@ grouping, and they never infer identity from hostnames, message text, timestamps, or resource-path truncation. `internal/alerts/correlation_test.go` and the alerts overview state tests pin these disclosure and fail-open boundaries. + +### Typed-helper container inventory never delegates daemon authority + +Under the typed-helper profile, the collector admits a direct container +runtime only when the endpoint is a real Unix socket below +`/run/user/`, owned by that UID, and not a symlink. Admission +occurs before the first daemon API request and is repeated on reconnect. +Rootful, remote, missing, malformed, and replaced candidates are closed and the +collector falls back to the helper's fixed-endpoint summary operation. + +The helper request contains no daemon URL, HTTP method, query, container +selector, or mutation argument. Summary mode does not bind lifecycle or update +bridges, and its report labels the reduced authority as +`typed-helper-summary`; helper loss cannot trigger sudo, root execution, or a +broader direct socket fallback. diff --git a/docs/release-control/v6/internal/subsystems/storage-recovery.md b/docs/release-control/v6/internal/subsystems/storage-recovery.md index aec8da534..7b7c30fa6 100644 --- a/docs/release-control/v6/internal/subsystems/storage-recovery.md +++ b/docs/release-control/v6/internal/subsystems/storage-recovery.md @@ -5853,3 +5853,13 @@ a recovery point, retention evidence, restore authority, or permission to install packages. Canonical merging may replace an older count with a checked zero or clear inherited check metadata for unavailable evidence, but it must not mutate storage or initiate remediation. + +### Container summary mode is not storage or recovery evidence + +The Docker host `collectionMode: typed-helper-summary` marker describes the +authority and completeness of the current inventory observation only. Its +absence of image, volume, network, layer, mount, and storage detail must not be +interpreted as authoritative emptiness, deletion, backup completion, recovery +state, or permission to clean up storage. Propagating the marker through the +shared resource projection creates no snapshot, restore, retention, or +container-action authority. diff --git a/docs/release-control/v6/internal/subsystems/unified-resources.md b/docs/release-control/v6/internal/subsystems/unified-resources.md index efcbaa56b..71d6de57f 100644 --- a/docs/release-control/v6/internal/subsystems/unified-resources.md +++ b/docs/release-control/v6/internal/subsystems/unified-resources.md @@ -4974,3 +4974,14 @@ uses the explicit state for details, while the compact node-row badge requires current evidence. `proxmox_update_evidence_test.go`, `views_test.go`, and the frontend evidence presentation tests pin transport, merge, and consumer behavior. + +### Docker collection completeness survives canonical projection + +The Docker host facet carries the optional `collectionMode` value through +adapters, cloning, typed views, API/frontend resource types, detail mappers, +and Workloads projection. `typed-helper-summary` remains attached to the host +whose bounded container summaries it qualifies; consumers must not infer full +runtime inventory or action capability from the presence of container rows. +The Docker drawer uses this canonical marker to warn and suppress update +controls, while absent or unknown values retain compatibility without changing +resource identity. diff --git a/frontend-modern/browser-verification.json b/frontend-modern/browser-verification.json index afead0ca6..6167c0471 100644 --- a/frontend-modern/browser-verification.json +++ b/frontend-modern/browser-verification.json @@ -1,16 +1,24 @@ { "version": 1, - "base_sha": "b0de28c7c406468310dfbef95b00bd71400d8abb", - "verified_at": "2026-08-30T19:10:01Z", + "base_sha": "d9e9bf3679ab579be9f8c7350932008c287ad5ad", + "verified_at": "2026-08-30T20:24:19Z", "result": "passed", "changed_paths": [ - "frontend-modern/src/components/Discovery/AvailabilityProposalCard.tsx" + "frontend-modern/src/components/Infrastructure/resourceDetailMappers.ts", + "frontend-modern/src/features/docker/DockerHostDrawerOverview.tsx", + "frontend-modern/src/hooks/useWorkloads.ts", + "frontend-modern/src/types/api.ts", + "frontend-modern/src/types/resource.ts" ], "content_sha256": { - "frontend-modern/src/components/Discovery/AvailabilityProposalCard.tsx": "0e3c4c5c2bb63ac5c8cd2549cb1fccea3b17d86a8bffa4dd7d0bf0aca7f45000" + "frontend-modern/src/components/Infrastructure/resourceDetailMappers.ts": "8b1913ff24e6f5dbf074091f2bbaff1a8c3298126515ea288b055a02c0153997", + "frontend-modern/src/features/docker/DockerHostDrawerOverview.tsx": "621af65f92c9d58312f019790ecf2eb6986fb260724e9ef53213972fd9b55579", + "frontend-modern/src/hooks/useWorkloads.ts": "85719e8c93f503843cd09c1be41c66f9995288d1280ca2eedf7d47c3443bd9a1", + "frontend-modern/src/types/api.ts": "be1b1889588085791880dbd6716955710cd66e3dc69339998f8b9ad30c0ac378", + "frontend-modern/src/types/resource.ts": "74b26ae53a507f5608a3c191f7c386cffab636e0a95aeffa02028bd8199297b0" }, "routes": [ - "/browser-assurance-harness.html (temporary current-build Vite harness for the /docker/overview discovery proposal card)" + "/browser-helper-summary-harness.html (temporary current-build Vite harness for the Docker host drawer)" ], "viewports": [ { @@ -23,18 +31,14 @@ } ], "states": [ - "existing-check inventory loading, failure, retry recovery, empty inventory, and post-create duplicate detection", - "proposal test transport success, application failure, and request failure with scoped status or alert feedback", - "created active-check confirmation alongside duplicate protection", - "machine-review loading, populated, and load-failure states without false empty-state overlap", - "settled desktop and narrow card/dialog pixels from the final current-source Vite render; managed full-app startup was blocked by unrelated pulse-pro go.mod drift" + "typed-helper summary mode with reduced-coverage warning and no container update management card", + "direct-runtime mode without the reduced-coverage warning and with container update management controls", + "return to typed-helper summary mode after direct-runtime mode" ], "interactions": [ - "confirmed Create active check stays disabled until existing-check inventory succeeds and retry restores it", - "ran successful and failed Test proposal actions and confirmed focus returns after the native loading-button blur", - "created an active check and confirmed status plus duplicate feedback", - "opened machine review, checked loading and populated states, closed with Escape, and confirmed focus return", - "reopened machine review on failure and confirmed proposal errors stay outside the dialog and no false empty state appears", - "inspected desktop and 390x844 settled pixels, topmost dialog hit testing, bounds, clipping, scrolling, and horizontal overflow" + "loaded summary mode and confirmed warning visibility, update-control omission, and no desktop horizontal overflow", + "switched to direct-runtime mode and confirmed the warning disappeared and update controls appeared", + "switched back to summary mode and confirmed the warning returned and update controls were removed", + "inspected the final summary state at 390x844 and confirmed no horizontal overflow or clipped warning copy" ] } diff --git a/frontend-modern/public/docs/AGENT_SECURITY.md b/frontend-modern/public/docs/AGENT_SECURITY.md index bb61037f4..d3e8aa818 100644 --- a/frontend-modern/public/docs/AGENT_SECURITY.md +++ b/frontend-modern/public/docs/AGENT_SECURITY.md @@ -165,8 +165,9 @@ collector-writable direct-replacement fallback. Exceptional telemetry crosses `/run/pulse-agent/helper.sock` to a separate root process. The socket admits only the `pulse-agent` UID, and the helper has no Pulse URL, API token, or network namespace. Its protocol exposes bounded, -versioned SMART and Proxmox LXC filesystem snapshots, not a shell, executable -path, device path, VMID, environment, or caller-selected arguments. The helper +versioned SMART, Proxmox LXC filesystem, and fixed-endpoint container summary +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`. `PrivateDevices` is intentionally not enabled because SMART needs the host @@ -175,9 +176,13 @@ only the affected telemetry disappears; the collector does not fall back to sudo, root, or a broader local command path. The typed-helper profile cannot be combined with `--grant-smart` or -`--grant-pct`. Rootful Docker-socket monitoring is also unavailable because -membership in the Docker group is root-equivalent; API monitoring or a -separately scoped rootless runtime socket is required instead. The profile is +`--grant-pct`. The collector never joins the rootful Docker group. When no +collector-owned rootless socket is available, the helper preserves only +container ID/name/image/state/status/creation summaries; reports identify this +as `collectionMode: typed-helper-summary`. Stats, images, volumes, networks, +storage, Swarm, registry update checks, and lifecycle actions remain unavailable. +A separately scoped rootless runtime socket is required for full collection. +The profile is currently explicit rather than the installer default. Its inspect, apply, and rollback transaction is implemented for Linux systemd, but representative live-host migration and helper update staging/activation/rollback exercises, @@ -199,7 +204,7 @@ or generic command path. | 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; helper-backed signed update activation is implemented but its live activation/recovery transaction is not qualified | **Qualified on disposable Ubuntu 24.04.4 arm64 at committed main `defc24af837b91428fbee939d09cd31e9559fb4f`** for install, migration, explicit/automatic profile rollback, helper health, reporting continuity, and process/credential separation. The schema-v4 receipt's ordinary update ran under the downgraded root monitoring profile and does not prove `agent_update.activate.v1`, executable-digest commit, watchdog rollback, interrupted recovery, or last-known-good restoration. Remains opt-in pending those live scenarios, exact-RC reproduction, and external review | `deployment-installability` and `security-privacy`: qualify helper activation/failure/recovery from the designated release candidate 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 | -| Rootful Docker or Podman inventory | No direct collector access to a root-equivalent daemon socket | **Unavailable in the safe profile.** Migration disables the provider visibly. A closed helper `container.inventory` operation exists, but collector integration and live parity are not qualified | Rootful container parity is an explicit default blocker; the legacy/root profile is not safe-profile evidence | `agent-lifecycle`: either integrate and qualify bounded helper inventory or retain the explicit degradation permanently | +| 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 | Unit and installer regressions cover the boundary, but no representative live Docker/Podman qualification exists. Full rootful parity remains an explicit default blocker; the legacy/root profile is not safe-profile evidence | `agent-lifecycle`: record fresh install, migration, restart, helper loss/recovery, bounds, and summary parity on representative rootful Docker and Podman; 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 | Implemented, unqualified live. Ambiguous, root-owned, unreadable, unwritable, or unavailable sockets disable container monitoring | Does not yet justify container-runtime parity or a default change | `deployment-installability`: record fresh install, migration, restart, socket-loss, ambiguity, and telemetry parity on both rootless Docker and rootless Podman | | 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-v4 committed-main systemd receipt records a real verified apt-cache mutation, stale-fingerprint refusal, replay, nonce-bound readiness, exact credential rotation, and self-revocation. 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. Neither proof covers local runner activation failure after credential preparation, every runner operation, or an exact release candidate | `agent-lifecycle` and `api-contracts`: reproduce the combined systemd and production Router path from the RC, including failed local activation/rollback and representative package-update success/failure/cancellation evidence | | Separate runner Proxmox guest and container lifecycle/update actions | Root-owned runner with closed typed protocols; never the monitoring collector | Implemented, unqualified on representative PVE and container-runtime targets | No live-provider action-parity claim and no default change | `agent-lifecycle`: record target-bound success, stale-state refusal, cancellation, reconnect/replay, and independent postconditions on disposable real targets | diff --git a/frontend-modern/src/components/Infrastructure/resourceDetailMappers.ts b/frontend-modern/src/components/Infrastructure/resourceDetailMappers.ts index a61d934c6..3a77a7ade 100644 --- a/frontend-modern/src/components/Infrastructure/resourceDetailMappers.ts +++ b/frontend-modern/src/components/Infrastructure/resourceDetailMappers.ts @@ -160,6 +160,7 @@ export type DockerPlatformData = { runtime?: string; runtimeVersion?: string; dockerVersion?: string; + collectionMode?: string; os?: string; kernelVersion?: string; architecture?: string; diff --git a/frontend-modern/src/features/docker/DockerHostDrawer.test.tsx b/frontend-modern/src/features/docker/DockerHostDrawer.test.tsx index dbe559a7b..829a75609 100644 --- a/frontend-modern/src/features/docker/DockerHostDrawer.test.tsx +++ b/frontend-modern/src/features/docker/DockerHostDrawer.test.tsx @@ -78,3 +78,20 @@ describe('DockerHostDrawer Discovery availability', () => { expect(screen.getByTestId('docker-host-discovery')).toBeInTheDocument(); }); }); + +describe('DockerHostDrawer typed-helper summary mode', () => { + it('warns about reduced coverage and hides container update controls', async () => { + const summaryHost = host(); + if (summaryHost.docker) { + summaryHost.docker.collectionMode = 'typed-helper-summary'; + } + + render(() => ); + + expect(screen.getByText('Reduced container coverage')).toBeInTheDocument(); + expect(screen.getByText(/typed helper reports container summaries only/i)).toBeInTheDocument(); + + await fireEvent.click(screen.getByRole('tab', { name: 'Manage' })); + expect(screen.queryByTestId('docker-host-management-actions')).toBeNull(); + }); +}); diff --git a/frontend-modern/src/features/docker/DockerHostDrawerOverview.tsx b/frontend-modern/src/features/docker/DockerHostDrawerOverview.tsx index 44ec32a69..3d77cdedb 100644 --- a/frontend-modern/src/features/docker/DockerHostDrawerOverview.tsx +++ b/frontend-modern/src/features/docker/DockerHostDrawerOverview.tsx @@ -73,6 +73,9 @@ const toDetailRows = (rows: DockerOverviewRow[]): DetailRow[] => export function DockerHostDrawerManagement(props: DockerHostDrawerOverviewProps) { const docker = () => props.host.docker; const hostSourceId = createMemo(() => cleanText(docker()?.hostSourceId) || null); + const summaryOnly = createMemo( + () => cleanText(docker()?.collectionMode) === 'typed-helper-summary', + ); const updatesAvailable = createMemo(() => docker()?.updatesAvailableCount ?? 0); const hostCommand = createMemo(() => docker()?.command as DockerHostCommandMeta | undefined); const hostCommandActive = createMemo(() => @@ -93,7 +96,7 @@ export function DockerHostDrawerManagement(props: DockerHostDrawerOverviewProps) }); return ( - +

Container updates @@ -164,6 +167,7 @@ export function DockerHostDrawerOverview(props: DockerHostDrawerOverviewProps) { const docker = () => props.host.docker; const agent = () => props.host.agent; const linkedAgentId = () => cleanText(agent()?.agentId); + const summaryOnly = () => cleanText(docker()?.collectionMode) === 'typed-helper-summary'; const temperatureThresholds = createMemo(() => alertsActivation.getMetricThresholds( 'node', @@ -341,11 +345,24 @@ export function DockerHostDrawerOverview(props: DockerHostDrawerOverviewProps) { return (
({ - id: alert.id, - message: alert.message, - severity: alert.level, - }))} + items={[ + ...(summaryOnly() + ? [ + { + id: `${props.host.id}:typed-helper-summary`, + subject: 'Reduced container coverage', + message: + 'The typed helper reports container summaries only. Stats, secondary inventory, update checks, and lifecycle actions are unavailable.', + severity: 'warning', + }, + ] + : []), + ...(props.host.alerts ?? []).map((alert) => ({ + id: alert.id, + message: alert.message, + severity: alert.level, + })), + ]} />
diff --git a/frontend-modern/src/hooks/useWorkloads.ts b/frontend-modern/src/hooks/useWorkloads.ts index 84f89aa63..9d6947499 100644 --- a/frontend-modern/src/hooks/useWorkloads.ts +++ b/frontend-modern/src/hooks/useWorkloads.ts @@ -145,6 +145,7 @@ type APIResource = { runtime?: string; runtimeVersion?: string; dockerVersion?: string; + collectionMode?: string; hostSourceId?: string; updateStatus?: WorkloadGuest['updateStatus']; }; diff --git a/frontend-modern/src/types/__tests__/resource.test.ts b/frontend-modern/src/types/__tests__/resource.test.ts index 07e75366c..b24a7ab2a 100644 --- a/frontend-modern/src/types/__tests__/resource.test.ts +++ b/frontend-modern/src/types/__tests__/resource.test.ts @@ -44,6 +44,19 @@ function createResource(overrides: Partial = {}): Resource { } describe('Resource Type Guards', () => { + it('retains Docker helper collection completeness on the host facet', () => { + const resource = createResource({ + type: 'docker-host', + docker: { + hostSourceId: 'docker-host-1', + collectionMode: 'typed-helper-summary', + }, + }); + + expect(resource.docker?.collectionMode).toBe('typed-helper-summary'); + expect(resource.docker?.hostSourceId).toBe('docker-host-1'); + }); + it('preserves storage alias IDs as compatibility metadata', () => { const resource = createResource({ type: 'storage', diff --git a/frontend-modern/src/types/api.ts b/frontend-modern/src/types/api.ts index cd986bd5a..dc0462dfb 100644 --- a/frontend-modern/src/types/api.ts +++ b/frontend-modern/src/types/api.ts @@ -282,6 +282,7 @@ export interface DockerRuntime { runtime?: string; runtimeVersion?: string; dockerVersion?: string; + collectionMode?: string; cpus: number; totalMemoryBytes: number; uptimeSeconds: number; diff --git a/frontend-modern/src/types/resource.ts b/frontend-modern/src/types/resource.ts index 1a04f7231..243a92ed7 100644 --- a/frontend-modern/src/types/resource.ts +++ b/frontend-modern/src/types/resource.ts @@ -792,6 +792,7 @@ export interface ResourceDockerMeta { runtime?: string; runtimeVersion?: string; dockerVersion?: string; + collectionMode?: string; memory?: Partial; os?: string; kernelVersion?: string; diff --git a/internal/agenthelper/container_inventory_test.go b/internal/agenthelper/container_inventory_test.go index d63cca040..df54f878e 100644 --- a/internal/agenthelper/container_inventory_test.go +++ b/internal/agenthelper/container_inventory_test.go @@ -48,7 +48,8 @@ func TestContainerInventoryUsesOnlyFixedBoundedGET(t *testing.T) { } func TestContainerInventoryBoundsDaemonOutput(t *testing.T) { - provider, err := NewLocalContainerProvider([]ContainerEndpoint{{Runtime: "podman", SocketPath: "/fixed/podman.sock", APIPath: "/v4/libpod/containers/json?all=true"}}) + const podmanDockerCompatPath = "/v1.40/containers/json?all=1" + provider, err := NewLocalContainerProvider([]ContainerEndpoint{{Runtime: "podman", SocketPath: "/fixed/podman.sock", APIPath: podmanDockerCompatPath}}) if err != nil { t.Fatal(err) } @@ -56,7 +57,13 @@ func TestContainerInventoryBoundsDaemonOutput(t *testing.T) { server, client := net.Pipe() go func() { defer server.Close() - _, _ = http.ReadRequest(bufio.NewReader(server)) + request, readErr := http.ReadRequest(bufio.NewReader(server)) + if readErr != nil { + return + } + if request.Method != http.MethodGet || request.URL.RequestURI() != podmanDockerCompatPath { + t.Errorf("Podman compatibility request = %s %s", request.Method, request.URL.RequestURI()) + } _, _ = fmt.Fprint(server, "HTTP/1.1 200 OK\r\nConnection: close\r\n\r\n[\""+strings.Repeat("x", maxContainerDaemonBytes)+"\"]") }() return client, nil diff --git a/internal/dockeragent/agent.go b/internal/dockeragent/agent.go index 940c8ace9..b4b7cc98f 100644 --- a/internal/dockeragent/agent.go +++ b/internal/dockeragent/agent.go @@ -65,6 +65,9 @@ type Config struct { DiskInclude []string // Devices or mount points to opt into monitoring despite automatic filtering LogLevel zerolog.Level Logger *zerolog.Logger + // HelperInventory is the optional closed, summary-only fallback used when + // this process cannot access a container runtime socket directly. + HelperInventory ContainerInventory } var allowedContainerStates = map[string]string{ @@ -114,6 +117,7 @@ func setAgentHeaders(req *http.Request, token string) { type Agent struct { cfg Config docker dockerClient + helperInventory ContainerInventory daemonHost string daemonID string // Cached at init; Podman can return unstable IDs across calls runtime RuntimeKind @@ -272,9 +276,47 @@ func New(cfg Config) (*Agent, error) { return nil, fmt.Errorf("dockeragent.New: normalize runtime: %w", err) } - dockerClient, info, runtimeKind, err := connectRuntimeFn(runtimePref, logger) - if err != nil { - return nil, fmt.Errorf("dockeragent.New: connect runtime: %w", err) + connect := connectRuntimeFn + if cfg.HelperInventory != nil { + // The helper profile must reject rootful, remote, and otherwise + // untrusted endpoints before even the read-only daemon probe runs. + connect = connectCollectorRuntimeFn + } + runtimeDockerClient, info, runtimeKind, connectErr := connect(runtimePref, logger) + if connectErr == nil && cfg.HelperInventory != nil && !collectorOwnsRootlessEndpoint(runtimeDockerClient.DaemonHost()) { + endpoint := runtimeDockerClient.DaemonHost() + closeErr := runtimeDockerClient.Close() + runtimeDockerClient = nil + connectErr = fmt.Errorf("direct runtime endpoint %q is not a collector-owned rootless Unix socket", endpoint) + if closeErr != nil { + connectErr = errors.Join(connectErr, fmt.Errorf("close rejected direct runtime client: %w", closeErr)) + } + } + if connectErr != nil && cfg.HelperInventory == nil { + return nil, fmt.Errorf("dockeragent.New: connect runtime: %w", connectErr) + } + if connectErr != nil { + probeCtx, cancelProbe := context.WithTimeout(context.Background(), helperInventoryOperationDeadline) + result, helperErr := cfg.HelperInventory.Inventory(probeCtx) + cancelProbe() + if helperErr != nil { + return nil, errors.Join( + fmt.Errorf("dockeragent.New: connect runtime: %w", connectErr), + fmt.Errorf("dockeragent.New: connect typed helper inventory: %w", helperErr), + ) + } + snapshot, helperErr := selectHelperRuntime(result, runtimePref) + if helperErr != nil { + return nil, errors.Join(fmt.Errorf("dockeragent.New: connect runtime: %w", connectErr), helperErr) + } + runtimeKind = RuntimeKind(snapshot.Runtime) + cfg.DisableUpdateChecks = true + cfg.IncludeServices = false + cfg.IncludeTasks = false + logger.Info(). + Str("runtime", string(runtimeKind)). + Str("collection_mode", agentsdocker.CollectionModeTypedHelperSummary). + Msg("Connected to container runtime through typed summary-only helper") } cfg.Runtime = string(runtimeKind) @@ -289,11 +331,13 @@ func New(cfg Config) (*Agent, error) { cfg.IncludeTasks = false } - logger.Info(). - Str("runtime", string(runtimeKind)). - Str("daemon_host", dockerClient.DaemonHost()). - Str("version", info.ServerVersion). - Msg("Connected to container runtime") + if runtimeDockerClient != nil { + logger.Info(). + Str("runtime", string(runtimeKind)). + Str("daemon_host", runtimeDockerClient.DaemonHost()). + Str("version", info.ServerVersion). + Msg("Connected to container runtime") + } hasSecure := false hasInsecure := false @@ -363,10 +407,20 @@ func New(cfg Config) (*Agent, error) { } } + var runtimeClient dockerClient + var helperInventory ContainerInventory + daemonHost := "" + if runtimeDockerClient != nil { + runtimeClient = newSwappableDockerClient(runtimeDockerClient) + daemonHost = runtimeDockerClient.DaemonHost() + } else { + helperInventory = cfg.HelperInventory + } agent := &Agent{ cfg: cfg, - docker: newSwappableDockerClient(dockerClient), - daemonHost: dockerClient.DaemonHost(), + docker: runtimeClient, + helperInventory: helperInventory, + daemonHost: daemonHost, daemonID: info.ID, // Cache at init for stable agent ID runtime: runtimeKind, runtimePref: runtimePref, @@ -550,6 +604,20 @@ type runtimeCandidate struct { } func connectRuntime(preference RuntimeKind, logger *zerolog.Logger) (dockerClient, systemtypes.Info, RuntimeKind, error) { + return connectRuntimeWithProbe(preference, logger, tryRuntimeCandidateFn) +} + +func connectCollectorOwnedRootlessRuntime(preference RuntimeKind, logger *zerolog.Logger) (dockerClient, systemtypes.Info, RuntimeKind, error) { + return connectRuntimeWithProbe(preference, logger, func(opts []client.Opt) (dockerClient, systemtypes.Info, error) { + return tryRuntimeCandidateWithEndpointAdmission(opts, collectorOwnsRootlessEndpoint) + }) +} + +func connectRuntimeWithProbe( + preference RuntimeKind, + logger *zerolog.Logger, + probe func([]client.Opt) (dockerClient, systemtypes.Info, error), +) (dockerClient, systemtypes.Info, RuntimeKind, error) { candidates := buildRuntimeCandidatesFn(preference) var attempts []string @@ -562,7 +630,7 @@ func connectRuntime(preference RuntimeKind, logger *zerolog.Logger) (dockerClien opts = append(opts, client.WithHost(candidate.host)) } - cli, info, err := tryRuntimeCandidateFn(opts) + cli, info, err := probe(opts) if err != nil { attempts = append(attempts, fmt.Sprintf("%s: %v", candidate.label, err)) continue @@ -608,6 +676,29 @@ func tryRuntimeCandidate(opts []client.Opt) (dockerClient, systemtypes.Info, err if err != nil { return nil, systemtypes.Info{}, err } + return probeRuntimeClient(cli) +} + +func tryRuntimeCandidateWithEndpointAdmission( + opts []client.Opt, + admit func(string) bool, +) (dockerClient, systemtypes.Info, error) { + cli, err := newDockerClientFn(opts...) + if err != nil { + return nil, systemtypes.Info{}, err + } + endpoint := cli.DaemonHost() + if admit == nil || !admit(endpoint) { + err := fmt.Errorf("runtime endpoint %q is outside the collector-owned rootless boundary", endpoint) + if closeErr := cli.Close(); closeErr != nil { + return nil, systemtypes.Info{}, errors.Join(err, fmt.Errorf("close rejected runtime client: %w", closeErr)) + } + return nil, systemtypes.Info{}, err + } + return probeRuntimeClient(cli) +} + +func probeRuntimeClient(cli dockerClient) (dockerClient, systemtypes.Info, error) { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() @@ -781,6 +872,9 @@ func (a *Agent) Run(ctx context.Context) error { ticker := newTickerFn(interval) defer ticker.Stop() + if a.helperInventory != nil { + return a.runHelperInventoryLoop(ctx, ticker) + } const ( updateInterval = 24 * time.Hour @@ -841,6 +935,25 @@ func (a *Agent) Run(ctx context.Context) error { } } +func (a *Agent) runHelperInventoryLoop(ctx context.Context, ticker *time.Ticker) error { + collect := func(phase string) { + if err := a.collectOnce(ctx); err != nil && !errors.Is(err, context.Canceled) { + a.logger.Error().Err(err).Str("phase", phase). + Str("collection_mode", agentsdocker.CollectionModeTypedHelperSummary). + Msg("Failed to send typed helper container summary") + } + } + collect("startup") + for { + select { + case <-ctx.Done(): + return ctx.Err() + case <-ticker.C: + collect("periodic") + } + } +} + func stopTimer(timer *time.Timer) { if !timer.Stop() { select { @@ -906,8 +1019,14 @@ func (a *Agent) collectOnceWithReport(ctx context.Context) (agentsdocker.Report, a.collectMu.Lock() defer a.collectMu.Unlock() - report, err := a.buildReport(ctx) - if err != nil && a.maybeReconnectRuntime(err) { + var report agentsdocker.Report + var err error + if a.helperInventory != nil { + report, err = a.buildHelperInventoryReport(ctx) + } else { + report, err = a.buildReport(ctx) + } + if err != nil && a.helperInventory == nil && a.maybeReconnectRuntime(err) { report, err = a.buildReport(ctx) } if err != nil { @@ -921,6 +1040,13 @@ func (a *Agent) collectOnceWithReport(ctx context.Context) (agentsdocker.Report, return report, nil } +// ContainerActionsAvailable reports whether this module owns a direct runtime +// client. Summary-only helper inventory never grants lifecycle or update +// authority to the collector. +func (a *Agent) ContainerActionsAvailable() bool { + return a != nil && a.helperInventory == nil && a.docker != nil +} + func (a *Agent) flushBuffer(ctx context.Context) { a.ensureReportBuffers() for _, target := range a.targets { diff --git a/internal/dockeragent/agent_internal_test.go b/internal/dockeragent/agent_internal_test.go index 62483754f..9ef6d478f 100644 --- a/internal/dockeragent/agent_internal_test.go +++ b/internal/dockeragent/agent_internal_test.go @@ -1679,6 +1679,33 @@ func TestIssue1647ReconnectAfterPersistentDaemonGone(t *testing.T) { } } +func TestTypedHelperProfileRejectsRootfulReconnect(t *testing.T) { + closed := false + rootful := &fakeDockerClient{ + daemonHost: "unix:///var/run/docker.sock", + closeFn: func() error { closed = true; return nil }, + } + swap(t, &connectCollectorRuntimeFn, func(RuntimeKind, *zerolog.Logger) (dockerClient, systemtypes.Info, RuntimeKind, error) { + return rootful, systemtypes.Info{}, RuntimeDocker, nil + }) + + agent := &Agent{ + cfg: Config{HelperInventory: &helperInventoryStub{}}, + logger: zerolog.Nop(), + runtimePref: RuntimeAuto, + runtimeGoneStreak: runtimeReconnectFailureThreshold - 1, + } + if agent.maybeReconnectRuntime(errors.New("cannot connect to the Docker daemon")) { + t.Fatal("typed-helper profile adopted a rootful reconnect endpoint") + } + if !closed { + t.Fatal("rejected rootful reconnect client was not closed") + } + if agent.docker != nil { + t.Fatal("rejected rootful reconnect was installed") + } +} + func TestBuildReportForwardsExplicitDiskIncludesAndExcludes(t *testing.T) { var gotExclude, gotInclude []string swap(t, &hostmetricsCollectWithDiskFilters, func(_ context.Context, exclude, include []string) (hostmetrics.Snapshot, error) { diff --git a/internal/dockeragent/deps.go b/internal/dockeragent/deps.go index c7d11be22..4405da276 100644 --- a/internal/dockeragent/deps.go +++ b/internal/dockeragent/deps.go @@ -16,6 +16,7 @@ import ( var ( connectRuntimeFn = connectRuntime + connectCollectorRuntimeFn = connectCollectorOwnedRootlessRuntime hostmetricsCollect = hostmetrics.Collect hostmetricsCollectWithDiskFilters = func(ctx context.Context, exclude, include []string) (hostmetrics.Snapshot, error) { if len(include) == 0 { diff --git a/internal/dockeragent/helper_inventory.go b/internal/dockeragent/helper_inventory.go new file mode 100644 index 000000000..f39bf950c --- /dev/null +++ b/internal/dockeragent/helper_inventory.go @@ -0,0 +1,196 @@ +package dockeragent + +import ( + "context" + "errors" + "fmt" + "runtime" + "strings" + "time" + + "github.com/rcourtman/pulse-go-rewrite/internal/agenthelper" + agentsdocker "github.com/rcourtman/pulse-go-rewrite/pkg/agents/docker" +) + +const helperInventoryOperationDeadline = 30 * time.Second + +// ContainerInventory is the collector-side view of the helper's closed, +// read-only container inventory operation. It intentionally exposes no daemon +// socket, URL, HTTP method, query, container selector, or mutation primitive. +type ContainerInventory interface { + Inventory(context.Context) (agenthelper.ContainerInventoryResult, error) +} + +type privilegeHelperContainerInventory struct { + client *agenthelper.Client +} + +// NewPrivilegeHelperContainerInventory creates a local-only client for the +// fixed helper socket selected by the installer. An empty path disables the +// helper inventory bridge. +func NewPrivilegeHelperContainerInventory(socketPath string) (ContainerInventory, error) { + if strings.TrimSpace(socketPath) == "" { + return nil, nil + } + client, err := agenthelper.NewClient(agenthelper.ClientConfig{ + SocketPath: socketPath, + MaxDeadline: helperInventoryOperationDeadline, + }) + if err != nil { + return nil, err + } + return &privilegeHelperContainerInventory{client: client}, nil +} + +func (c *privilegeHelperContainerInventory) Inventory(ctx context.Context) (agenthelper.ContainerInventoryResult, error) { + var response agenthelper.ContainerInventoryResult + _, err := c.client.Call( + ctx, + agenthelper.OperationContainerInventory, + agenthelper.OperationVersion1, + helperInventoryOperationDeadline, + struct{}{}, + &response, + ) + if err != nil { + return agenthelper.ContainerInventoryResult{}, err + } + return response, nil +} + +func selectHelperRuntime(result agenthelper.ContainerInventoryResult, preference RuntimeKind) (agenthelper.ContainerRuntimeSnapshot, error) { + available := make(map[RuntimeKind]agenthelper.ContainerRuntimeSnapshot, len(result.Runtimes)) + for _, snapshot := range result.Runtimes { + runtime := RuntimeKind(strings.ToLower(strings.TrimSpace(snapshot.Runtime))) + if runtime != RuntimeDocker && runtime != RuntimePodman { + continue + } + if snapshot.Available { + available[runtime] = snapshot + } + } + + if preference == RuntimeDocker || preference == RuntimePodman { + if snapshot, ok := available[preference]; ok { + return snapshot, nil + } + return agenthelper.ContainerRuntimeSnapshot{}, fmt.Errorf("typed helper reports %s runtime unavailable", preference) + } + if snapshot, ok := available[RuntimeDocker]; ok { + return snapshot, nil + } + if snapshot, ok := available[RuntimePodman]; ok { + return snapshot, nil + } + return agenthelper.ContainerRuntimeSnapshot{}, errors.New("typed helper reports no available container runtime") +} + +func (a *Agent) buildHelperInventoryReport(ctx context.Context) (agentsdocker.Report, error) { + ctx, cancel := context.WithTimeout(ctx, helperInventoryOperationDeadline) + defer cancel() + + result, err := a.helperInventory.Inventory(ctx) + if err != nil { + return agentsdocker.Report{}, fmt.Errorf("collect typed helper container inventory: %w", err) + } + snapshot, err := selectHelperRuntime(result, a.runtimePref) + if err != nil { + return agentsdocker.Report{}, err + } + runtimeKind := RuntimeKind(strings.ToLower(strings.TrimSpace(snapshot.Runtime))) + if runtimeKind != a.runtime { + a.logger.Info(). + Str("runtime_previous", string(a.runtime)). + Str("runtime_current", string(runtimeKind)). + Msg("Typed helper container inventory runtime changed") + a.runtime = runtimeKind + } + + metricsCtx, metricsCancel := context.WithTimeout(ctx, 10*time.Second) + metrics, err := hostmetricsCollectWithDiskFilters(metricsCtx, a.cfg.DiskExclude, a.cfg.DiskInclude) + metricsCancel() + if err != nil { + return agentsdocker.Report{}, fmt.Errorf("collect host metrics: %w", err) + } + + agentID := a.cfg.AgentID + if agentID == "" { + agentID = a.machineID + if agentID == "" { + agentID = a.hostName + } + } + containers := make([]agentsdocker.Container, 0, len(snapshot.Containers)) + for _, summary := range snapshot.Containers { + state := strings.ToLower(strings.TrimSpace(summary.State)) + if len(a.allowedStates) > 0 { + if _, ok := a.allowedStates[state]; !ok { + continue + } + } + if isBackupContainer(summary.Names) { + continue + } + name := "" + for _, candidate := range summary.Names { + candidate = strings.TrimPrefix(strings.TrimSpace(candidate), "/") + if candidate != "" { + name = candidate + break + } + } + if name == "" { + name = shortContainerID(summary.ID) + } + container := agentsdocker.Container{ + ID: strings.TrimSpace(summary.ID), + Name: name, + Image: strings.TrimSpace(summary.Image), + State: state, + Status: strings.TrimSpace(summary.Status), + } + if summary.Created > 0 { + container.CreatedAt = time.Unix(summary.Created, 0).UTC() + } + containers = append(containers, container) + } + + intervalSeconds := int(a.cfg.Interval / time.Second) + if intervalSeconds <= 0 { + intervalSeconds = 30 + } + report := agentsdocker.Report{ + Agent: agentsdocker.AgentInfo{ + ID: agentID, Version: a.agentVersion, Type: a.cfg.AgentType, + IntervalSeconds: intervalSeconds, + }, + Host: agentsdocker.HostInfo{ + Hostname: a.hostName, + Name: a.hostName, + MachineID: a.machineID, + Runtime: string(runtimeKind), + CollectionMode: agentsdocker.CollectionModeTypedHelperSummary, + OS: runtime.GOOS, + Architecture: runtime.GOARCH, + TotalCPU: metrics.CPUCount, + UptimeSeconds: readSystemUptime(), + CPUUsagePercent: safeFloat(metrics.CPUUsagePercent), + LoadAverage: append([]float64(nil), metrics.LoadAverage...), + Memory: metrics.Memory, + TotalMemoryBytes: metrics.Memory.TotalBytes, + Disks: append([]agentsdocker.Disk(nil), metrics.Disks...), + Network: append([]agentsdocker.NetworkInterface(nil), metrics.Network...), + }, + Containers: containers, + Timestamp: time.Now().UTC(), + } + return report, nil +} + +func shortContainerID(id string) string { + id = strings.TrimSpace(id) + if len(id) > 12 { + return id[:12] + } + return id +} diff --git a/internal/dockeragent/helper_inventory_test.go b/internal/dockeragent/helper_inventory_test.go new file mode 100644 index 000000000..d8cf55081 --- /dev/null +++ b/internal/dockeragent/helper_inventory_test.go @@ -0,0 +1,157 @@ +package dockeragent + +import ( + "context" + "errors" + "testing" + "time" + + systemtypes "github.com/moby/moby/api/types/system" + "github.com/rcourtman/pulse-go-rewrite/internal/agenthelper" + "github.com/rcourtman/pulse-go-rewrite/internal/hostmetrics" + agentshost "github.com/rcourtman/pulse-go-rewrite/pkg/agents/host" + "github.com/rs/zerolog" +) + +type helperInventoryStub struct { + result agenthelper.ContainerInventoryResult + err error + calls int +} + +func (s *helperInventoryStub) Inventory(context.Context) (agenthelper.ContainerInventoryResult, error) { + s.calls++ + return s.result, s.err +} + +func TestSelectHelperRuntimeHonorsPreferenceAndAvailability(t *testing.T) { + result := agenthelper.ContainerInventoryResult{Runtimes: []agenthelper.ContainerRuntimeSnapshot{ + {Runtime: "docker", Available: true}, + {Runtime: "podman", Available: true}, + }} + selected, err := selectHelperRuntime(result, RuntimeAuto) + if err != nil || selected.Runtime != "docker" { + t.Fatalf("auto selection = %+v, %v; want docker", selected, err) + } + selected, err = selectHelperRuntime(result, RuntimePodman) + if err != nil || selected.Runtime != "podman" { + t.Fatalf("podman selection = %+v, %v", selected, err) + } + if _, err := selectHelperRuntime(agenthelper.ContainerInventoryResult{}, RuntimeDocker); err == nil { + t.Fatal("unavailable requested runtime was accepted") + } +} + +func TestNewFallsBackToTypedHelperWithoutActionAuthority(t *testing.T) { + originalConnect := connectCollectorRuntimeFn + t.Cleanup(func() { connectCollectorRuntimeFn = originalConnect }) + connectCollectorRuntimeFn = func(RuntimeKind, *zerolog.Logger) (dockerClient, systemtypes.Info, RuntimeKind, error) { + return nil, systemtypes.Info{}, RuntimeAuto, errors.New("permission denied opening daemon socket") + } + helper := &helperInventoryStub{result: agenthelper.ContainerInventoryResult{ + Runtimes: []agenthelper.ContainerRuntimeSnapshot{{Runtime: "docker", Available: true}}, + }} + logger := zerolog.Nop() + agent, err := New(Config{ + PulseURL: "http://127.0.0.1:7655", APIToken: "token", Runtime: "auto", + HelperInventory: helper, Logger: &logger, + }) + if err != nil { + t.Fatalf("New helper fallback: %v", err) + } + defer agent.Close() + if agent.helperInventory != helper || agent.docker != nil { + t.Fatalf("helper fallback wiring = helper:%T docker:%T", agent.helperInventory, agent.docker) + } + if agent.ContainerActionsAvailable() { + t.Fatal("summary-only helper fallback exposed container action authority") + } + if helper.calls != 1 { + t.Fatalf("helper probe calls = %d, want 1", helper.calls) + } +} + +func TestNewRejectsDirectRootfulSocketWhenTypedHelperIsConfigured(t *testing.T) { + originalConnect := connectCollectorRuntimeFn + t.Cleanup(func() { connectCollectorRuntimeFn = originalConnect }) + closed := false + connectCollectorRuntimeFn = func(RuntimeKind, *zerolog.Logger) (dockerClient, systemtypes.Info, RuntimeKind, error) { + return &fakeDockerClient{ + daemonHost: "unix:///var/run/docker.sock", + closeFn: func() error { closed = true; return nil }, + }, systemtypes.Info{ID: "rootful-daemon"}, RuntimeDocker, nil + } + helper := &helperInventoryStub{result: agenthelper.ContainerInventoryResult{ + Runtimes: []agenthelper.ContainerRuntimeSnapshot{{Runtime: "docker", Available: true}}, + }} + logger := zerolog.Nop() + agent, err := New(Config{ + PulseURL: "http://127.0.0.1:7655", APIToken: "token", Runtime: "auto", + HelperInventory: helper, Logger: &logger, + }) + if err != nil { + t.Fatalf("New helper fallback: %v", err) + } + defer agent.Close() + if !closed { + t.Fatal("rejected rootful direct client was not closed") + } + if agent.helperInventory != helper || agent.ContainerActionsAvailable() { + t.Fatalf("rootful endpoint bypassed helper boundary: helper=%T actions=%t", agent.helperInventory, agent.ContainerActionsAvailable()) + } +} + +func TestBuildHelperInventoryReportIsExplicitlySummaryOnly(t *testing.T) { + originalMetrics := hostmetricsCollectWithDiskFilters + t.Cleanup(func() { hostmetricsCollectWithDiskFilters = originalMetrics }) + hostmetricsCollectWithDiskFilters = func(context.Context, []string, []string) (hostmetrics.Snapshot, error) { + return hostmetrics.Snapshot{ + CPUUsagePercent: 12.5, + CPUCount: 4, + Memory: agentshost.MemoryMetric{TotalBytes: 4096, UsedBytes: 1024}, + }, nil + } + helper := &helperInventoryStub{result: agenthelper.ContainerInventoryResult{ + Runtimes: []agenthelper.ContainerRuntimeSnapshot{{ + Runtime: "docker", Available: true, + Containers: []agenthelper.ContainerSummary{ + {ID: "1234567890abcdef", Names: []string{"/app"}, Image: "repo/app:1", State: "running", Status: "Up", Created: 1_700_000_000}, + {ID: "backup", Names: []string{"app_pulse_backup_20260830_120000"}, State: "exited"}, + {ID: "stopped", Names: []string{"stopped"}, State: "exited"}, + }, + }}, + }} + agent := &Agent{ + cfg: Config{ + AgentID: "agent-1", AgentType: "unified", Interval: 15 * time.Second, + }, + helperInventory: helper, + runtimePref: RuntimeAuto, + runtime: RuntimeDocker, + agentVersion: "6.0.0", + hostName: "node-1", + machineID: "machine-1", + allowedStates: map[string]struct{}{"running": {}}, + logger: zerolog.Nop(), + } + report, err := agent.buildHelperInventoryReport(context.Background()) + if err != nil { + t.Fatalf("buildHelperInventoryReport: %v", err) + } + if report.Host.CollectionMode != "typed-helper-summary" { + t.Fatalf("collection mode = %q", report.Host.CollectionMode) + } + if len(report.Containers) != 1 { + t.Fatalf("containers = %+v, want only running non-backup summary", report.Containers) + } + container := report.Containers[0] + if container.ID != "1234567890abcdef" || container.Name != "app" || container.Image != "repo/app:1" { + t.Fatalf("container summary = %+v", container) + } + if container.CPUPercent != 0 || container.MemoryUsageBytes != 0 || len(container.Env) != 0 || len(container.Mounts) != 0 { + t.Fatalf("summary-only report fabricated privileged detail: %+v", container) + } + if report.Host.TotalMemoryBytes != 4096 || report.Host.TotalCPU != 4 || report.Agent.IntervalSeconds != 15 { + t.Fatalf("host/agent summary = %+v / %+v", report.Host, report.Agent) + } +} diff --git a/internal/dockeragent/rootless_endpoint_linux.go b/internal/dockeragent/rootless_endpoint_linux.go new file mode 100644 index 000000000..97938e8ab --- /dev/null +++ b/internal/dockeragent/rootless_endpoint_linux.go @@ -0,0 +1,38 @@ +//go:build linux + +package dockeragent + +import ( + "os" + "path/filepath" + "strconv" + "strings" + "syscall" +) + +var ( + rootlessRuntimeRoot = "/run/user" + effectiveUID = os.Geteuid +) + +func collectorOwnsRootlessEndpoint(endpoint string) bool { + const unixPrefix = "unix://" + if !strings.HasPrefix(endpoint, unixPrefix) { + return false + } + path := strings.TrimPrefix(endpoint, unixPrefix) + if !filepath.IsAbs(path) || filepath.Clean(path) != path { + return false + } + uid := effectiveUID() + root := filepath.Join(rootlessRuntimeRoot, strconv.Itoa(uid)) + if path != root && !strings.HasPrefix(path, root+string(filepath.Separator)) { + return false + } + info, err := os.Lstat(path) + if err != nil || info.Mode()&os.ModeSocket == 0 || info.Mode()&os.ModeSymlink != 0 { + return false + } + stat, ok := info.Sys().(*syscall.Stat_t) + return ok && stat.Uid == uint32(uid) +} diff --git a/internal/dockeragent/rootless_endpoint_linux_test.go b/internal/dockeragent/rootless_endpoint_linux_test.go new file mode 100644 index 000000000..4f8b408c6 --- /dev/null +++ b/internal/dockeragent/rootless_endpoint_linux_test.go @@ -0,0 +1,51 @@ +//go:build linux + +package dockeragent + +import ( + "net" + "os" + "path/filepath" + "strconv" + "testing" +) + +func TestCollectorOwnsRootlessEndpointRequiresOwnedRuntimeSocket(t *testing.T) { + originalRoot := rootlessRuntimeRoot + originalUID := effectiveUID + t.Cleanup(func() { + rootlessRuntimeRoot = originalRoot + effectiveUID = originalUID + }) + + rootlessRuntimeRoot = t.TempDir() + effectiveUID = os.Geteuid + runtimeDir := filepath.Join(rootlessRuntimeRoot, strconv.Itoa(os.Geteuid())) + if err := os.MkdirAll(runtimeDir, 0o700); err != nil { + t.Fatal(err) + } + socketPath := filepath.Join(runtimeDir, "docker.sock") + listener, err := net.Listen("unix", socketPath) + if err != nil { + t.Fatal(err) + } + defer listener.Close() + + if !collectorOwnsRootlessEndpoint("unix://" + socketPath) { + t.Fatal("collector-owned rootless Unix socket was rejected") + } + if collectorOwnsRootlessEndpoint("unix:///var/run/docker.sock") { + t.Fatal("rootful system socket was accepted") + } + if collectorOwnsRootlessEndpoint("tcp://127.0.0.1:2375") { + t.Fatal("non-Unix runtime endpoint was accepted") + } + + symlink := filepath.Join(runtimeDir, "linked.sock") + if err := os.Symlink(socketPath, symlink); err != nil { + t.Fatal(err) + } + if collectorOwnsRootlessEndpoint("unix://" + symlink) { + t.Fatal("symlinked runtime endpoint was accepted") + } +} diff --git a/internal/dockeragent/rootless_endpoint_other.go b/internal/dockeragent/rootless_endpoint_other.go new file mode 100644 index 000000000..837e5be61 --- /dev/null +++ b/internal/dockeragent/rootless_endpoint_other.go @@ -0,0 +1,5 @@ +//go:build !linux + +package dockeragent + +func collectorOwnsRootlessEndpoint(string) bool { return false } diff --git a/internal/dockeragent/runtime_coverage_test.go b/internal/dockeragent/runtime_coverage_test.go index 5af493f71..d1bd8e07b 100644 --- a/internal/dockeragent/runtime_coverage_test.go +++ b/internal/dockeragent/runtime_coverage_test.go @@ -12,6 +12,35 @@ import ( ) func TestTryRuntimeCandidate(t *testing.T) { + t.Run("endpoint admission precedes daemon info", func(t *testing.T) { + closed := false + infoCalls := 0 + fake := &fakeDockerClient{ + daemonHost: "unix:///var/run/docker.sock", + infoFunc: func(_ context.Context) (systemtypes.Info, error) { + infoCalls++ + return systemtypes.Info{}, nil + }, + closeFn: func() error { + closed = true + return nil + }, + } + swap(t, &newDockerClientFn, func(_ ...client.Opt) (dockerClient, error) { + return fake, nil + }) + + if _, _, err := tryRuntimeCandidateWithEndpointAdmission(nil, func(string) bool { return false }); err == nil { + t.Fatal("expected rejected endpoint error") + } + if infoCalls != 0 { + t.Fatalf("daemon Info called %d times before endpoint admission", infoCalls) + } + if !closed { + t.Fatal("rejected client was not closed") + } + }) + t.Run("new client error", func(t *testing.T) { swap(t, &newDockerClientFn, func(_ ...client.Opt) (dockerClient, error) { return nil, errors.New("dial failed") diff --git a/internal/dockeragent/runtime_reconnect.go b/internal/dockeragent/runtime_reconnect.go index 87e1399c9..eb5d33045 100644 --- a/internal/dockeragent/runtime_reconnect.go +++ b/internal/dockeragent/runtime_reconnect.go @@ -162,7 +162,11 @@ func (a *Agent) maybeReconnectRuntime(err error) bool { return false } - cli, info, runtimeKind, connErr := connectRuntimeFn(a.runtimePref, &a.logger) + connect := connectRuntimeFn + if a.cfg.HelperInventory != nil { + connect = connectCollectorRuntimeFn + } + cli, info, runtimeKind, connErr := connect(a.runtimePref, &a.logger) if connErr != nil { a.logger.Warn(). Err(connErr). @@ -170,6 +174,16 @@ func (a *Agent) maybeReconnectRuntime(err error) bool { Msg("Container runtime endpoint unavailable; reconnect attempt failed") return false } + if a.cfg.HelperInventory != nil && !collectorOwnsRootlessEndpoint(cli.DaemonHost()) { + endpoint := cli.DaemonHost() + if closeErr := cli.Close(); closeErr != nil { + a.logger.Debug().Err(closeErr).Msg("Failed to close rejected runtime reconnect client") + } + a.logger.Warn(). + Str("daemon_host", endpoint). + Msg("Rejected runtime reconnect outside the collector-owned rootless boundary") + return false + } previous := a.adoptRuntimeConnection(cli, info, runtimeKind) if previous != nil { diff --git a/internal/models/converters.go b/internal/models/converters.go index ea6c97a84..e24316bfe 100644 --- a/internal/models/converters.go +++ b/internal/models/converters.go @@ -229,6 +229,7 @@ func (d DockerHost) ToFrontend() DockerHostFrontend { Runtime: d.Runtime, RuntimeVersion: d.RuntimeVersion, DockerVersion: d.DockerVersion, + CollectionMode: d.CollectionMode, CPUs: d.CPUs, TotalMemoryBytes: d.TotalMemoryBytes, UptimeSeconds: d.UptimeSeconds, diff --git a/internal/models/converters_test.go b/internal/models/converters_test.go index 336a45062..2fab91577 100644 --- a/internal/models/converters_test.go +++ b/internal/models/converters_test.go @@ -1006,6 +1006,7 @@ func TestDockerHostToFrontend(t *testing.T) { Runtime: "docker", RuntimeVersion: "24.0.0", DockerVersion: "24.0.0", + CollectionMode: "typed-helper-summary", CPUs: 8, TotalMemoryBytes: 16000000000, UptimeSeconds: 86400, @@ -1044,6 +1045,9 @@ func TestDockerHostToFrontend(t *testing.T) { if frontend.DisplayName != host.DisplayName { t.Errorf("DisplayName = %q, want %q", frontend.DisplayName, host.DisplayName) } + if frontend.CollectionMode != host.CollectionMode { + t.Errorf("CollectionMode = %q, want %q", frontend.CollectionMode, host.CollectionMode) + } if frontend.CPUUsagePercent != host.CPUUsage { t.Errorf("CPUUsagePercent = %f, want %f", frontend.CPUUsagePercent, host.CPUUsage) } diff --git a/internal/models/metrics_types_test.go b/internal/models/metrics_types_test.go index a57670dc9..6f7686228 100644 --- a/internal/models/metrics_types_test.go +++ b/internal/models/metrics_types_test.go @@ -38,6 +38,27 @@ func TestAlertLastSeenWireContract(t *testing.T) { } } +func TestDockerHostCollectionModeWireContract(t *testing.T) { + payload, err := json.Marshal(DockerHost{ + ID: "docker-host-1", + CollectionMode: "typed-helper-summary", + }) + if err != nil { + t.Fatalf("marshal Docker host collection mode: %v", err) + } + if !strings.Contains(string(payload), `"collectionMode":"typed-helper-summary"`) { + t.Fatalf("payload = %s, want explicit summary collection mode", payload) + } + + withoutMode, err := json.Marshal(DockerHost{ID: "docker-host-1"}) + if err != nil { + t.Fatalf("marshal Docker host without collection mode: %v", err) + } + if strings.Contains(string(withoutMode), "collectionMode") { + t.Fatalf("payload = %s, absent collection mode must be omitted", withoutMode) + } +} + func TestNodeNetworkInterfacesNormalizeCollections(t *testing.T) { node := Node{NetworkInterfaces: []HostNetworkInterface{{Name: "vmbr0"}}}.NormalizeCollections() if node.NetworkInterfaces == nil || node.NetworkInterfaces[0].Addresses == nil { diff --git a/internal/models/models.go b/internal/models/models.go index 6b06ed083..6ae5b7dfd 100644 --- a/internal/models/models.go +++ b/internal/models/models.go @@ -952,6 +952,7 @@ type DockerHost struct { Runtime string `json:"runtime,omitempty"` RuntimeVersion string `json:"runtimeVersion,omitempty"` DockerVersion string `json:"dockerVersion,omitempty"` + CollectionMode string `json:"collectionMode,omitempty"` CPUs int `json:"cpus"` TotalMemoryBytes int64 `json:"totalMemoryBytes"` UptimeSeconds int64 `json:"uptimeSeconds"` diff --git a/internal/models/models_frontend.go b/internal/models/models_frontend.go index 74392ba71..5a80483c2 100644 --- a/internal/models/models_frontend.go +++ b/internal/models/models_frontend.go @@ -160,6 +160,7 @@ type DockerHostFrontend struct { Runtime string `json:"runtime"` RuntimeVersion string `json:"runtimeVersion,omitempty"` DockerVersion string `json:"dockerVersion,omitempty"` + CollectionMode string `json:"collectionMode,omitempty"` CPUs int `json:"cpus"` TotalMemoryBytes int64 `json:"totalMemoryBytes"` UptimeSeconds int64 `json:"uptimeSeconds"` diff --git a/internal/monitoring/monitor.go b/internal/monitoring/monitor.go index 57c961c32..41a691ab7 100644 --- a/internal/monitoring/monitor.go +++ b/internal/monitoring/monitor.go @@ -4042,6 +4042,7 @@ func dockerHostFromReadStateView(view *unifiedresources.DockerHostView) models.D Runtime: view.Runtime(), RuntimeVersion: view.RuntimeVersion(), DockerVersion: view.DockerVersion(), + CollectionMode: view.CollectionMode(), CPUs: view.CPUs(), TotalMemoryBytes: totalMemory, UptimeSeconds: view.UptimeSeconds(), diff --git a/internal/monitoring/monitor_agents.go b/internal/monitoring/monitor_agents.go index 27a990525..635b61df2 100644 --- a/internal/monitoring/monitor_agents.go +++ b/internal/monitoring/monitor_agents.go @@ -2149,6 +2149,7 @@ func (m *Monitor) ApplyDockerReport(report agentsdocker.Report, tokenRecord *con Runtime: runtime, RuntimeVersion: runtimeVersion, DockerVersion: dockerVersion, + CollectionMode: strings.TrimSpace(report.Host.CollectionMode), CPUs: report.Host.TotalCPU, TotalMemoryBytes: report.Host.TotalMemoryBytes, UptimeSeconds: report.Host.UptimeSeconds, diff --git a/internal/monitoring/monitor_docker_test.go b/internal/monitoring/monitor_docker_test.go index c8c73e3fc..3264deff3 100644 --- a/internal/monitoring/monitor_docker_test.go +++ b/internal/monitoring/monitor_docker_test.go @@ -1272,6 +1272,7 @@ func TestApplyDockerReportPodmanRuntimeMetadata(t *testing.T) { Runtime: "podman", RuntimeVersion: "4.9.3", DockerVersion: "", + CollectionMode: agentsdocker.CollectionModeTypedHelperSummary, }, Timestamp: time.Now().UTC(), } @@ -1290,6 +1291,9 @@ func TestApplyDockerReportPodmanRuntimeMetadata(t *testing.T) { if host.DockerVersion != "4.9.3" { t.Fatalf("expected docker version fallback to runtime version, got %q", host.DockerVersion) } + if host.CollectionMode != agentsdocker.CollectionModeTypedHelperSummary { + t.Fatalf("expected collection mode to survive report ingestion, got %q", host.CollectionMode) + } } func TestApplyDockerReportDerivesDockerSecurityPosture(t *testing.T) { diff --git a/internal/unifiedresources/adapters.go b/internal/unifiedresources/adapters.go index 4f8319512..716b199a1 100644 --- a/internal/unifiedresources/adapters.go +++ b/internal/unifiedresources/adapters.go @@ -1389,6 +1389,7 @@ func resourceFromDockerHost(host models.DockerHost) (Resource, ResourceIdentity) Runtime: host.Runtime, RuntimeVersion: host.RuntimeVersion, DockerVersion: host.DockerVersion, + CollectionMode: host.CollectionMode, OS: host.OS, KernelVersion: host.KernelVersion, Architecture: host.Architecture, @@ -2369,6 +2370,7 @@ func resourceFromDockerContainer(ct models.DockerContainer, host models.DockerHo Runtime: runtime, RuntimeVersion: host.RuntimeVersion, DockerVersion: host.DockerVersion, + CollectionMode: host.CollectionMode, Security: cloneDockerHostSecurity(host.Security), } if !ct.CreatedAt.IsZero() { diff --git a/internal/unifiedresources/adapters_test.go b/internal/unifiedresources/adapters_test.go index bcb4b61f1..701529417 100644 --- a/internal/unifiedresources/adapters_test.go +++ b/internal/unifiedresources/adapters_test.go @@ -703,6 +703,7 @@ func TestResourceFromDockerContainerIncludesContainerID(t *testing.T) { Hostname: "docker-1", Runtime: "podman", RuntimeVersion: "5.2.0", + CollectionMode: "typed-helper-summary", } resource, _ := resourceFromDockerContainer(container, host) if resource.Docker == nil { @@ -720,6 +721,9 @@ func TestResourceFromDockerContainerIncludesContainerID(t *testing.T) { if got, want := resource.Docker.RuntimeVersion, host.RuntimeVersion; got != want { t.Fatalf("runtimeVersion = %q, want %q", got, want) } + if got, want := resource.Docker.CollectionMode, host.CollectionMode; got != want { + t.Fatalf("collectionMode = %q, want %q", got, want) + } if got, want := resource.Docker.ImageID, container.ImageDigest; got != want { t.Fatalf("imageId = %q, want %q", got, want) } diff --git a/internal/unifiedresources/canonical_identity_test.go b/internal/unifiedresources/canonical_identity_test.go index 072b62ec6..519de9d99 100644 --- a/internal/unifiedresources/canonical_identity_test.go +++ b/internal/unifiedresources/canonical_identity_test.go @@ -77,6 +77,34 @@ func TestRefreshCanonicalIdentityPrefersTargetsAndCanonicalHostData(t *testing.T } } +func TestDockerCollectionModeDoesNotChangeCanonicalIdentity(t *testing.T) { + resource := Resource{ + ID: "docker-host-1", + Type: ResourceTypeAgent, + Name: "docker-one", + Docker: &DockerData{ + HostSourceID: "docker-source-1", + Hostname: "docker-one.local", + CollectionMode: "typed-helper-summary", + }, + } + + RefreshCanonicalIdentity(&resource) + if resource.Canonical == nil { + t.Fatal("canonical identity is nil") + } + want := *resource.Canonical + + resource.Docker.CollectionMode = "" + RefreshCanonicalIdentity(&resource) + if resource.Canonical == nil || + resource.Canonical.PrimaryID != want.PrimaryID || + resource.Canonical.Hostname != want.Hostname || + !reflect.DeepEqual(resource.Canonical.Aliases, want.Aliases) { + t.Fatalf("collection completeness changed canonical identity: got %+v, want %+v", resource.Canonical, want) + } +} + func TestRefreshCanonicalIdentityKeepsProxmoxPresentationSeparateFromNativeAliases(t *testing.T) { resource := Resource{ ID: "production-pve1", diff --git a/internal/unifiedresources/types.go b/internal/unifiedresources/types.go index 0f2f6e6de..1a8f6980e 100644 --- a/internal/unifiedresources/types.go +++ b/internal/unifiedresources/types.go @@ -1087,6 +1087,7 @@ type DockerData struct { Runtime string `json:"runtime,omitempty"` RuntimeVersion string `json:"runtimeVersion,omitempty"` DockerVersion string `json:"dockerVersion,omitempty"` + CollectionMode string `json:"collectionMode,omitempty"` OS string `json:"os,omitempty"` KernelVersion string `json:"kernelVersion,omitempty"` Architecture string `json:"architecture,omitempty"` diff --git a/internal/unifiedresources/views.go b/internal/unifiedresources/views.go index b1385c9be..7ae22239b 100644 --- a/internal/unifiedresources/views.go +++ b/internal/unifiedresources/views.go @@ -1570,6 +1570,13 @@ func (v DockerHostView) DockerVersion() string { return v.r.Docker.DockerVersion } +func (v DockerHostView) CollectionMode() string { + if v.r == nil || v.r.Docker == nil { + return "" + } + return v.r.Docker.CollectionMode +} + func (v DockerHostView) Runtime() string { if v.r == nil || v.r.Docker == nil { return "" diff --git a/internal/unifiedresources/views_test.go b/internal/unifiedresources/views_test.go index 54b8eb8b0..d43cef49c 100644 --- a/internal/unifiedresources/views_test.go +++ b/internal/unifiedresources/views_test.go @@ -1026,6 +1026,7 @@ func TestView_DockerHostViewAccessors(t *testing.T) { DockerVersion: "25.0.0", Runtime: "docker", RuntimeVersion: "1.7.0", + CollectionMode: "typed-helper-summary", OS: "Ubuntu", KernelVersion: "6.8.0", Architecture: "amd64", @@ -1070,6 +1071,9 @@ func TestView_DockerHostViewAccessors(t *testing.T) { if v.Hostname() != "docker-host-1" || v.DockerVersion() != "25.0.0" || v.RuntimeVersion() != "1.7.0" || v.OS() != "Ubuntu" { t.Fatalf("expected docker accessors to match, got hostname=%q docker=%q runtime=%q os=%q", v.Hostname(), v.DockerVersion(), v.RuntimeVersion(), v.OS()) } + if v.CollectionMode() != "typed-helper-summary" { + t.Fatalf("expected collection mode accessor to match, got %q", v.CollectionMode()) + } if v.HostSourceID() != "docker-source-1" || v.DisplayName() != "Docker Host One" || v.CustomDisplayName() != "Custom Docker Host" || v.MachineID() != "machine-docker-1" || v.Runtime() != "docker" { t.Fatalf("expected docker identity/runtime accessors to match, got source=%q display=%q custom=%q machine=%q runtime=%q", v.HostSourceID(), v.DisplayName(), v.CustomDisplayName(), v.MachineID(), v.Runtime()) } diff --git a/pkg/agents/docker/report.go b/pkg/agents/docker/report.go index 6c4ff6003..94542e270 100644 --- a/pkg/agents/docker/report.go +++ b/pkg/agents/docker/report.go @@ -43,6 +43,7 @@ type HostInfo struct { OS string `json:"os,omitempty"` Runtime string `json:"runtime,omitempty"` RuntimeVersion string `json:"runtimeVersion,omitempty"` + CollectionMode string `json:"collectionMode,omitempty"` KernelVersion string `json:"kernelVersion,omitempty"` Architecture string `json:"architecture,omitempty"` DockerVersion string `json:"dockerVersion,omitempty"` @@ -58,6 +59,8 @@ type HostInfo struct { Security *HostSecurityInfo `json:"security,omitempty"` } +const CollectionModeTypedHelperSummary = "typed-helper-summary" + // HostSecurityInfo captures container-runtime security posture data reported by the agent. type HostSecurityInfo struct { AuthorizationPlugins []string `json:"authorizationPlugins,omitempty"` diff --git a/scripts/install.sh b/scripts/install.sh index 21cedbd75..22fa6d1f2 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -2958,9 +2958,13 @@ safe_profile_apply_docker_degradation() { log_info "Safe-profile migration preserved container monitoring through the collector-owned ${ROOTLESS_RUNTIME_KIND} socket: ${ROOTLESS_RUNTIME_SOCKET_PATH}" return 0 fi + if [[ "$PRIVILEGED_HELPER_ENABLED" == "true" ]]; then + log_warn "Safe-profile migration preserved rootful container inventory through the typed helper in summary-only mode. Container stats, images, storage, Swarm, update checks, and lifecycle actions remain unavailable without a collector-owned rootless socket." + return 0 + fi ENABLE_DOCKER="false" DOCKER_EXPLICIT="true" - log_warn "Safe-profile migration disabled rootful Docker monitoring: the collector has no usable collector-owned rootless runtime. Container monitoring is an explicit migration degradation, not helper parity." + log_warn "Safe-profile migration disabled rootful Docker monitoring: neither a usable collector-owned rootless runtime nor typed helper inventory is available." } detect_kubernetes() { diff --git a/scripts/installtests/safe_profile_migration_test.go b/scripts/installtests/safe_profile_migration_test.go index a0f72bbe3..1fe6dcfb9 100644 --- a/scripts/installtests/safe_profile_migration_test.go +++ b/scripts/installtests/safe_profile_migration_test.go @@ -401,32 +401,40 @@ exit 23 func TestSafeProfileDockerDegradationRequiresCollectorOwnedRootlessRuntime(t *testing.T) { function := extractInstallShellFunction(t, "safe_profile_apply_docker_degradation") - for _, usable := range []bool{true, false} { - script := ` + for _, tc := range []struct { + name string + usable bool + helperEnabled bool + want string + }{ + {name: "rootless", usable: true, helperEnabled: true, want: "enabled=true explicit=false"}, + {name: "typed helper summary", helperEnabled: true, want: "enabled=true explicit=false"}, + {name: "no safe source", want: "enabled=false explicit=true"}, + } { + t.Run(tc.name, func(t *testing.T) { + script := ` set -euo pipefail SAFE_PROFILE_ACTION=apply ENABLE_DOCKER=true DOCKER_EXPLICIT=false +PRIVILEGED_HELPER_ENABLED=` + map[bool]string{true: "true", false: "false"}[tc.helperEnabled] + ` ROOTLESS_RUNTIME_KIND=docker ROOTLESS_RUNTIME_SOCKET_PATH=/run/user/991/docker.sock log_info() { :; } log_warn() { printf '%s\n' "$*"; } -safe_profile_selected_rootless_runtime_usable() { return ` + map[bool]string{true: "0", false: "1"}[usable] + `; } +safe_profile_selected_rootless_runtime_usable() { return ` + map[bool]string{true: "0", false: "1"}[tc.usable] + `; } ` + function + ` safe_profile_apply_docker_degradation printf 'enabled=%s explicit=%s\n' "$ENABLE_DOCKER" "$DOCKER_EXPLICIT" ` - out, err := exec.Command("bash", "-c", script).CombinedOutput() - if err != nil { - t.Fatalf("docker degradation: %v\n%s", err, out) - } - want := "enabled=false explicit=true" - if usable { - want = "enabled=true explicit=false" - } - if !strings.Contains(string(out), want) { - t.Fatalf("usable=%v output missing %q:\n%s", usable, want, out) - } + out, err := exec.Command("bash", "-c", script).CombinedOutput() + if err != nil { + t.Fatalf("docker degradation: %v\n%s", err, out) + } + if !strings.Contains(string(out), tc.want) { + t.Fatalf("output missing %q:\n%s", tc.want, out) + } + }) } } diff --git a/scripts/installtests/secure_runtime_platform_matrix_test.go b/scripts/installtests/secure_runtime_platform_matrix_test.go index b4a0ee272..f921be2e7 100644 --- a/scripts/installtests/secure_runtime_platform_matrix_test.go +++ b/scripts/installtests/secure_runtime_platform_matrix_test.go @@ -32,8 +32,8 @@ func TestSecureRuntimePlatformMatrixRemainsExplicitAndShipped(t *testing.T) { "Separate runner Proxmox guest and container lifecycle/update actions", "Appliance, non-systemd, Windows, and macOS host-agent profiles", "Implemented, unqualified", - "**Unavailable in the safe profile.**", - "The safe profile therefore remains opt-in.", + "collectionMode: typed-helper-summary", + "currently explicit rather than the installer default.", "Residual owner and removal condition", } for _, marker := range required { diff --git a/scripts/installtests/secure_runtime_systemd_lab_test.go b/scripts/installtests/secure_runtime_systemd_lab_test.go index 8fe6a771b..6f7f91972 100644 --- a/scripts/installtests/secure_runtime_systemd_lab_test.go +++ b/scripts/installtests/secure_runtime_systemd_lab_test.go @@ -1049,8 +1049,8 @@ func TestSecureRuntimeSystemdLab(t *testing.T) { } dockerDegraded := dockerInitiallyEnabled && !secureRuntimeCollectorHasArgument("--enable-docker") if dockerInitiallyEnabled && !secureRuntimeCollectorOwnedRootlessAvailable(t) { - if !dockerDegraded || !strings.Contains(applyOutput, "disabled rootful Docker monitoring") { - t.Fatalf("safe migration did not make rootful Docker degradation explicit:\n%s", applyOutput) + if dockerDegraded || !strings.Contains(applyOutput, "typed helper in summary-only mode") { + t.Fatalf("safe migration did not preserve explicitly reduced helper inventory:\n%s", applyOutput) } } pass("safe_profile_apply", "fresh server lastSeen, least-privilege identity, typed helper health", map[string]any{"collector_service_user": "pulse-agent", "collector_authority": "monitoring-only", "helper_status": "ok"}) diff --git a/scripts/release_control/secure_runtime_source_manifest_v4.json b/scripts/release_control/secure_runtime_source_manifest_v4.json index dc7d9b13f..0e0919489 100644 --- a/scripts/release_control/secure_runtime_source_manifest_v4.json +++ b/scripts/release_control/secure_runtime_source_manifest_v4.json @@ -4,6 +4,15 @@ "target_os": "linux", "description": "Production source boundary for the secure collector, typed helper, action runner, update path, control-plane admission, and systemd qualification harness.", "exact_paths": [ + "internal/models/converters.go", + "internal/models/models.go", + "internal/models/models_frontend.go", + "internal/monitoring/monitor.go", + "internal/monitoring/monitor_agents.go", + "internal/unifiedresources/adapters.go", + "internal/unifiedresources/types.go", + "internal/unifiedresources/views.go", + "pkg/agents/docker/report.go", "scripts/install.sh", "scripts/installtests/secure_runtime_systemd_lab_test.go", "scripts/release_control/secure_runtime_attestation.py", @@ -19,6 +28,7 @@ "internal/agentupdate", "internal/api", "internal/config", + "internal/dockeragent", "internal/hostagent", "internal/operationreceipt" ],