Integrate typed helper container summaries

This commit is contained in:
pulse-triage[bot]
2026-08-30 21:29:11 +01:00
parent d9e9bf3679
commit e094a55b45
53 changed files with 1029 additions and 78 deletions
+3 -1
View File
@@ -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)
+22 -2
View File
@@ -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 == "" {
+26
View File
@@ -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.
+11 -6
View File
@@ -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 |
@@ -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
@@ -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.
@@ -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,
@@ -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.
@@ -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.
@@ -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.
@@ -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/<collector-uid>`, 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.
@@ -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.
@@ -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.
+20 -16
View File
@@ -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"
]
}
+11 -6
View File
@@ -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 |
@@ -160,6 +160,7 @@ export type DockerPlatformData = {
runtime?: string;
runtimeVersion?: string;
dockerVersion?: string;
collectionMode?: string;
os?: string;
kernelVersion?: string;
architecture?: string;
@@ -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(() => <DockerHostDrawer host={summaryHost} />);
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();
});
});
@@ -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 (
<Show when={hostSourceId()}>
<Show when={hostSourceId() && !summaryOnly()}>
<InfoCardFrame data-testid="docker-host-management-actions" class="max-w-md">
<h3 class="mb-2 text-[11px] font-medium uppercase tracking-wide text-base-content">
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 (
<div class="space-y-3">
<DrawerAttentionSection
items={(props.host.alerts ?? []).map((alert) => ({
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,
})),
]}
/>
<Show when={props.host.availability || props.host.availabilityChecks?.length}>
<div class="max-w-sm">
@@ -145,6 +145,7 @@ type APIResource = {
runtime?: string;
runtimeVersion?: string;
dockerVersion?: string;
collectionMode?: string;
hostSourceId?: string;
updateStatus?: WorkloadGuest['updateStatus'];
};
@@ -44,6 +44,19 @@ function createResource(overrides: Partial<Resource> = {}): 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',
+1
View File
@@ -282,6 +282,7 @@ export interface DockerRuntime {
runtime?: string;
runtimeVersion?: string;
dockerVersion?: string;
collectionMode?: string;
cpus: number;
totalMemoryBytes: number;
uptimeSeconds: number;
+1
View File
@@ -792,6 +792,7 @@ export interface ResourceDockerMeta {
runtime?: string;
runtimeVersion?: string;
dockerVersion?: string;
collectionMode?: string;
memory?: Partial<Memory>;
os?: string;
kernelVersion?: string;
@@ -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
+139 -13
View File
@@ -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 {
@@ -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) {
+1
View File
@@ -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 {
+196
View File
@@ -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
}
@@ -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)
}
}
@@ -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)
}
@@ -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")
}
}
@@ -0,0 +1,5 @@
//go:build !linux
package dockeragent
func collectorOwnsRootlessEndpoint(string) bool { return false }
@@ -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")
+15 -1
View File
@@ -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 {
+1
View File
@@ -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,
+4
View File
@@ -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)
}
+21
View File
@@ -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 {
+1
View File
@@ -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"`
+1
View File
@@ -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"`
+1
View File
@@ -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(),
+1
View File
@@ -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,
@@ -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) {
+2
View File
@@ -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() {
@@ -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)
}
@@ -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",
+1
View File
@@ -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"`
+7
View File
@@ -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 ""
+4
View File
@@ -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())
}
+3
View File
@@ -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"`
+5 -1
View File
@@ -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() {
@@ -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)
}
})
}
}
@@ -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 {
@@ -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"})
@@ -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"
],