mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-10 02:25:56 +00:00
Integrate typed helper container summaries
This commit is contained in:
@@ -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
@@ -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 == "" {
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -282,6 +282,7 @@ export interface DockerRuntime {
|
||||
runtime?: string;
|
||||
runtimeVersion?: string;
|
||||
dockerVersion?: string;
|
||||
collectionMode?: string;
|
||||
cpus: number;
|
||||
totalMemoryBytes: number;
|
||||
uptimeSeconds: number;
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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 ""
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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
@@ -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"
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user