mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-10 02:25:56 +00:00
Harden unified agent update preflight
Record the single pulse-agent product invariant and clarify Docker / Podman module terminology.
This commit is contained in:
@@ -57,7 +57,7 @@ type RemoteConfigApplier interface {
|
||||
ApplyRemoteConfig(settings map[string]interface{}, commandsEnabled *bool)
|
||||
}
|
||||
|
||||
// Runnable closer for Docker agent which needs cleanup
|
||||
// Runnable closer for the Docker / Podman collection module which needs cleanup.
|
||||
type RunnableCloser interface {
|
||||
Runnable
|
||||
Close() error
|
||||
@@ -360,7 +360,7 @@ func run(ctx context.Context, args []string, getenv func(string) string) error {
|
||||
}
|
||||
}
|
||||
|
||||
// 9. Start Docker Agent (if enabled)
|
||||
// 9. Start Docker / Podman module (if enabled)
|
||||
var dockerAgent RunnableCloser
|
||||
if cfg.EnableDocker {
|
||||
dockerCfg := dockeragent.Config{
|
||||
@@ -398,7 +398,7 @@ func run(ctx context.Context, args []string, getenv func(string) string) error {
|
||||
agent := initDockerWithRetry(ctx, dockerCfg, &logger)
|
||||
if agent != nil {
|
||||
dockerAgent = agent
|
||||
logger.Info().Msg("Docker agent module started (after retry)")
|
||||
logger.Info().Msg("Docker / Podman module started (after retry)")
|
||||
return agent.Run(ctx)
|
||||
}
|
||||
// Docker never became available, continue without it
|
||||
@@ -406,7 +406,7 @@ func run(ctx context.Context, args []string, getenv func(string) string) error {
|
||||
})
|
||||
} else {
|
||||
g.Go(func() error {
|
||||
logger.Info().Msg("Docker agent module started")
|
||||
logger.Info().Msg("Docker / Podman module started")
|
||||
return dockerAgent.Run(ctx)
|
||||
})
|
||||
}
|
||||
@@ -582,7 +582,7 @@ func cleanupDockerAgent(agent RunnableCloser, logger *zerolog.Logger) {
|
||||
Err(err).
|
||||
Str("component", "docker_agent").
|
||||
Str("action", "shutdown_failed").
|
||||
Msg("Failed to close docker agent")
|
||||
Msg("Failed to close Docker / Podman module")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -806,7 +806,7 @@ func loadConfig(args []string, getenv func(string) string) (Config, error) {
|
||||
logLevelFlag := fs.String("log-level", defaultLogLevel(envLogLevel), "Log level")
|
||||
|
||||
enableHostFlag := fs.Bool("enable-host", defaultEnableHost, "Enable Host Agent module")
|
||||
enableDockerFlag := fs.Bool("enable-docker", defaultEnableDocker, "Enable Docker / Podman Agent module")
|
||||
enableDockerFlag := fs.Bool("enable-docker", defaultEnableDocker, "Enable Docker / Podman collection module")
|
||||
enableKubernetesFlag := fs.Bool("enable-kubernetes", defaultEnableKubernetes, "Enable Kubernetes Agent module")
|
||||
enableProxmoxFlag := fs.Bool("enable-proxmox", defaultEnableProxmox, "Enable Proxmox mode (creates API token, registers node)")
|
||||
proxmoxTypeFlag := fs.String("proxmox-type", envProxmoxType, "Proxmox type: pve or pbs (auto-detected if not specified)")
|
||||
@@ -1115,8 +1115,8 @@ func resolveTokenInternal(tokenFlag, tokenFileFlag, envToken string, readFile fu
|
||||
return ""
|
||||
}
|
||||
|
||||
// initDockerWithRetry attempts to initialize the Docker agent with exponential backoff.
|
||||
// It returns the agent when Docker becomes available, or nil if the context is cancelled.
|
||||
// initDockerWithRetry attempts to initialize the Docker / Podman collection module with exponential backoff.
|
||||
// It returns the module when Docker / Podman becomes available, or nil if the context is cancelled.
|
||||
// Retry intervals: 5s, 10s, 20s, 40s, 80s, 160s, then cap at 5 minutes.
|
||||
func initDockerWithRetry(ctx context.Context, cfg dockeragent.Config, logger *zerolog.Logger) RunnableCloser {
|
||||
const multiplier = 2.0
|
||||
|
||||
@@ -1335,7 +1335,7 @@ func TestDockerAutoDetectHonorsExplicitDisable(t *testing.T) {
|
||||
t.Fatalf("run returned unexpected error: %v", err)
|
||||
}
|
||||
if got := atomic.LoadInt32(&dockerAgentCalls); got != 0 {
|
||||
t.Fatalf("Docker / Podman agent initialized despite explicit disable, calls=%d", got)
|
||||
t.Fatalf("Docker / Podman module initialized despite explicit disable, calls=%d", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1521,7 +1521,7 @@ func TestRun_AgentFailure(t *testing.T) {
|
||||
newDockerAgent = origDocker
|
||||
}()
|
||||
|
||||
// Docker agent fails immediately after start
|
||||
// Docker / Podman module fails immediately after start
|
||||
newDockerAgent = func(cfg dockeragent.Config) (RunnableCloser, error) {
|
||||
return &mockRunnableCloser{mockRunnable: mockRunnable{
|
||||
started: make(chan struct{}),
|
||||
|
||||
@@ -109,7 +109,7 @@ func (ws *windowsService) Execute(args []string, r <-chan svc.ChangeRequest, cha
|
||||
})
|
||||
}
|
||||
|
||||
// Start Docker Agent (if enabled)
|
||||
// Start Docker / Podman module (if enabled)
|
||||
if ws.cfg.EnableDocker {
|
||||
dockerCfg := dockeragent.Config{
|
||||
PulseURL: ws.cfg.PulseURL,
|
||||
@@ -132,16 +132,16 @@ func (ws *windowsService) Execute(args []string, r <-chan svc.ChangeRequest, cha
|
||||
|
||||
agent, err := dockeragent.New(dockerCfg)
|
||||
if err != nil {
|
||||
ws.logger.Error().Err(err).Msg("Failed to create docker agent")
|
||||
ws.logger.Error().Err(err).Msg("Failed to create Docker / Podman module")
|
||||
if ws.eventLog != nil {
|
||||
ws.eventLog.Error(1, fmt.Sprintf("Failed to create docker agent: %v", err))
|
||||
ws.eventLog.Error(1, fmt.Sprintf("Failed to create Docker / Podman module: %v", err))
|
||||
}
|
||||
changes <- svc.Status{State: svc.Stopped}
|
||||
return true, 1
|
||||
}
|
||||
|
||||
g.Go(func() error {
|
||||
ws.logger.Info().Msg("Docker agent module started")
|
||||
ws.logger.Info().Msg("Docker / Podman module started")
|
||||
return agent.Run(ctx)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -143,7 +143,8 @@ Unified agent (`pulse-agent`):
|
||||
2. Verify checksum (required).
|
||||
3. Verify the Ed25519 release signature when trusted update keys are embedded.
|
||||
4. Validate binary magic (ELF/Mach-O/PE) and size limits (100MB max).
|
||||
5. Make executable and swap atomically.
|
||||
5. Run the downloaded binary with `--self-test`, passing any live token through a short-lived `0600` token file rather than argv.
|
||||
6. Make executable and swap atomically.
|
||||
|
||||
## API Security
|
||||
|
||||
|
||||
+3
-1
@@ -1195,7 +1195,9 @@ clients to recompute and compare.
|
||||
`PATCH /api/agents/agent/{agent_id}/config` (admin, `agent:manage`)
|
||||
Updates server-side config for an agent (e.g., `commandsEnabled`).
|
||||
|
||||
### Docker Agent Management (Admin)
|
||||
### Docker / Podman Module Management (Admin)
|
||||
These routes manage Docker / Podman telemetry and container actions reported by the Docker / Podman module inside the installed `pulse-agent` binary.
|
||||
|
||||
- `POST /api/agents/docker/commands/{commandId}/ack` (`docker:report`)
|
||||
- `DELETE /api/agents/docker/runtimes/{agentId}` (`docker:manage`, supports `?hide=true` or `?force=true`)
|
||||
- `POST /api/agents/docker/runtimes/{agentId}/allow-reenroll` (`docker:manage`)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Centralized Agent Management (Pro/legacy Pro+/Cloud)
|
||||
|
||||
Pro, legacy Pro+, and Cloud support centralized management of agent configurations, allowing administrators to define "Configuration Profiles" and assign them to specific agents. This enables bulk updates and consistent configuration across your fleet without manually editing configuration files on each host.
|
||||
Pro, legacy Pro+, and Cloud support centralized management of `pulse-agent` configurations, allowing administrators to define "Configuration Profiles" and assign them to specific installed agents. This enables bulk updates and consistent configuration across your fleet without manually editing configuration files on each host.
|
||||
|
||||
Profiles are managed in the UI: **Settings → Unified Agents → Agent Profiles**.
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Pulse Unified Agent
|
||||
|
||||
The unified agent (`pulse-agent`) combines host, Docker, and Kubernetes monitoring into a single binary. It replaces older split-agent installs with one deployment and one service for simpler operations.
|
||||
The unified agent (`pulse-agent`) is the single host-installed Pulse infrastructure agent binary. It combines host, Docker/Podman, Kubernetes, Proxmox-local, and other enabled node-local telemetry modules into one deployment and one service.
|
||||
Install it on standalone hosts and on machines where Pulse needs full node-local telemetry.
|
||||
For API-backed platforms, start with the platform connection first and add the agent only where local telemetry is needed.
|
||||
|
||||
@@ -243,8 +243,10 @@ The unified agent automatically checks for updates every hour. When a new versio
|
||||
|
||||
1. Agent downloads the new binary from the Pulse server
|
||||
2. Verifies the checksum
|
||||
3. Replaces itself atomically (with backup)
|
||||
4. Restarts with the same configuration
|
||||
3. Verifies the release signature when trusted update keys are embedded
|
||||
4. Runs the downloaded binary with `--self-test`
|
||||
5. Replaces itself atomically (with backup)
|
||||
6. Restarts with the same configuration
|
||||
|
||||
To disable auto-updates:
|
||||
```bash
|
||||
|
||||
@@ -723,7 +723,7 @@
|
||||
},
|
||||
{
|
||||
"id": "RA9",
|
||||
"summary": "A real v5-installed Pulse Unified Agent upgrades through v6 release assets into one canonical v6 agent identity, preserves one-shot update continuity metadata, keeps legacy persisted host-agent token scopes valid at the v6 canonical agent endpoints, and does not drift agent-count or fallback behavior during the crossover.",
|
||||
"summary": "A real v5-installed Pulse Unified Agent upgrades through v6 release assets into one canonical v6 pulse-agent identity, preserves one-shot update continuity metadata, keeps self-update preflight token handling out of argv, keeps legacy persisted host-agent token scopes valid at the v6 canonical agent endpoints, and does not drift agent-count or fallback behavior during the crossover.",
|
||||
"kind": "journey",
|
||||
"blocking_level": "rc-ready",
|
||||
"proof_type": "hybrid",
|
||||
@@ -768,7 +768,7 @@
|
||||
"test",
|
||||
"./internal/agentupdate",
|
||||
"-run",
|
||||
"TestCheckAndUpdateToFirstHostReportCarriesPreviousVersionOnce|TestUpdateToFirstHostReportCarriesPreviousVersionOnce|TestPerformUpdatePersistsPreviousVersionForNextStart",
|
||||
"TestCheckAndUpdateToFirstHostReportCarriesPreviousVersionOnce|TestUpdateToFirstHostReportCarriesPreviousVersionOnce|TestPerformUpdatePersistsPreviousVersionForNextStart|TestRunDownloadedBinarySelfTestUsesTokenFile|TestRunDownloadedBinarySelfTestPropagatesFailureWithoutTokenArg|TestPerformUpdateSelfTestFailurePreservesCurrentBinary",
|
||||
"-count=1"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -16,7 +16,10 @@
|
||||
## Purpose
|
||||
|
||||
Own unified agent installation, registration, update continuity, profile
|
||||
management, and fleet control surfaces.
|
||||
management, and fleet control surfaces. Pulse v6 has one host-installed
|
||||
infrastructure agent binary, `pulse-agent`; host, Docker / Podman,
|
||||
Kubernetes, Proxmox-local, and other node-local telemetry are modules inside
|
||||
that binary, not separate customer-facing agent products.
|
||||
|
||||
## Canonical Files
|
||||
|
||||
@@ -87,10 +90,11 @@ management, and fleet control surfaces.
|
||||
|
||||
## Shared Boundaries
|
||||
|
||||
`internal/dockeragent/` is lifecycle-adjacent for agent binary/update trust,
|
||||
but Docker runtime capability truth is monitoring-owned. Lifecycle consumers
|
||||
must not reinterpret standalone `Swarm.LocalNodeState=inactive` metadata as
|
||||
agent enrollment, install, command, or fleet-control authority.
|
||||
`internal/dockeragent/` is lifecycle-adjacent for agent binary/update trust
|
||||
and owns the Docker / Podman collection module used by `pulse-agent`, but
|
||||
Docker runtime capability truth is monitoring-owned. Lifecycle consumers must
|
||||
not reinterpret standalone `Swarm.LocalNodeState=inactive` metadata as agent
|
||||
enrollment, install, command, or fleet-control authority.
|
||||
Inside-guest Docker / Podman visibility is also a privacy boundary: full
|
||||
Docker / Podman inventory may come from a guest-local agent or another explicit
|
||||
guest reporting path. LXC Docker inventory may also come from the Proxmox host
|
||||
@@ -2352,17 +2356,18 @@ self-hosted upgrade-metric summaries or infrastructure-onboarding analytics to
|
||||
local commercial metrics reporting routes must stay absent from the normal
|
||||
product API and must not become lifecycle setup, install, or fleet-progress
|
||||
signals.
|
||||
Lifecycle-adjacent Docker and Podman agent diagnostics are part of that same
|
||||
shared backend dependency. When `internal/api/diagnostics.go` emits agent
|
||||
Lifecycle-adjacent Docker and Podman module diagnostics are part of that same
|
||||
shared backend dependency. When `internal/api/diagnostics.go` emits module
|
||||
health notes for Docker and Podman, the copy must keep Infrastructure as the
|
||||
operator recovery surface and must not send users back to retired agent-only
|
||||
management routes.
|
||||
operator recovery surface and must not send users back to retired
|
||||
agent-management routes.
|
||||
Lifecycle-adjacent Docker / Podman management responses are part of that same
|
||||
shared backend dependency. When `internal/api/docker_agents.go`,
|
||||
`internal/api/docker_metadata.go`, or `frontend-modern/src/api/monitoring.ts`
|
||||
surface host removal, hide/unhide, pending uninstall, display-name, or metadata
|
||||
errors, the operator-facing copy must describe Docker / Podman agents or hosts
|
||||
rather than reviving generic container-runtime labels.
|
||||
errors, the operator-facing copy must describe Docker / Podman modules or hosts
|
||||
rather than reviving generic container-runtime labels or a separate Docker
|
||||
product identity.
|
||||
That same shared `internal/api/` dependency now also assumes auth persistence
|
||||
compatibility is handled as an explicit migration/import boundary: legacy
|
||||
raw-token `sessions.json` and `csrf_tokens.json` files may load for upgrade
|
||||
@@ -2541,9 +2546,10 @@ the matching base64-encoded `X-Signature-SSHSIG`, and
|
||||
and agent binaries from local or proxied assets that carry the matching
|
||||
detached signature sidecars.
|
||||
That same self-update pre-flight must keep the live agent token out of process
|
||||
argv. `internal/dockeragent/self_update.go` may pass a short-lived `0600`
|
||||
token file into `cmd/pulse-agent/main.go --self-test --token-file`, but it
|
||||
must not revive `--token <secret>` argument passing that exposes the runtime
|
||||
argv. `internal/agentupdate/update.go` and legacy
|
||||
`internal/dockeragent/self_update.go` may pass a short-lived `0600` token file
|
||||
into `cmd/pulse-agent/main.go --self-test --token-file`, but they must not
|
||||
revive `--token <secret>` argument passing that exposes the runtime
|
||||
credential through `/proc/*/cmdline`.
|
||||
That same unified-agent runtime boundary also owns vendor-aware host identity.
|
||||
When gopsutil reports generic Linux platform fields on NAS appliances,
|
||||
|
||||
@@ -557,8 +557,8 @@ the canonical monitored-system blocked payload.
|
||||
`internal/api/diagnostics.go`,
|
||||
`internal/api/diagnostics_additional_test.go`, and
|
||||
`internal/api/diagnostics_memory_test.go` together. Docker and Podman
|
||||
agent health notes emitted by diagnostics must lead with Docker / Podman
|
||||
agent language and route operator recovery to the Infrastructure and
|
||||
health notes emitted by diagnostics must lead with Docker / Podman module
|
||||
language and route operator recovery to the Infrastructure and
|
||||
Security settings surfaces rather than generic runtime family wording or
|
||||
retired agent-management destinations.
|
||||
3b. Route Docker / Podman management API response copy through
|
||||
@@ -566,7 +566,7 @@ the canonical monitored-system blocked payload.
|
||||
`frontend-modern/src/api/monitoring.ts`, and their route/client tests
|
||||
together. Operator-facing responses for Docker / Podman host removal,
|
||||
hide/unhide, pending uninstall, display-name, and host metadata paths must
|
||||
use Docker / Podman agent or host wording instead of generic container
|
||||
use Docker / Podman module or host wording instead of generic container
|
||||
runtime labels.
|
||||
3c. Route Assistant finding handoff context changes through
|
||||
`internal/api/ai_handler.go`, `internal/api/ai_handler_test.go`, and
|
||||
@@ -3254,6 +3254,13 @@ shared backend install-command helper in `internal/api/agent_install_command_sha
|
||||
instead of a handler-local shell formatter, so token omission, plain-HTTP
|
||||
`--insecure`, and trailing-slash normalization stay under one canonical API
|
||||
contract surface.
|
||||
That same Docker / Podman diagnostics and admin-route API copy must preserve
|
||||
the single installed-agent product identity: Docker / Podman telemetry is a
|
||||
module reported by `pulse-agent`, not a separate customer-facing
|
||||
Docker-specific agent product. Existing `/api/agents/docker/*` route names and
|
||||
stable error codes may remain for compatibility, but response messages,
|
||||
diagnostics notes, docs, and proof labels must use Docker / Podman module
|
||||
language.
|
||||
That same diagnostics boundary must also consume the canonical monitoring
|
||||
memory-source catalog instead of maintaining a second local trust/fallback
|
||||
classifier. Node, VM, and LXC memory-source aliases must normalize to the same
|
||||
|
||||
@@ -100,7 +100,8 @@ truth for live infrastructure data.
|
||||
network summaries, Swarm services, Swarm tasks, Swarm nodes, Swarm secrets,
|
||||
Swarm configs, and daemon storage-usage buckets from the documented runtime
|
||||
API, then publish those
|
||||
records through the Docker agent report for unified-resource ingestion.
|
||||
records through the Docker / Podman module report for unified-resource
|
||||
ingestion.
|
||||
Swarm service records must preserve documented service update status
|
||||
(`UpdateStatus.State`, message, and completion time when reported) so the
|
||||
container runtime surface can distinguish stable services from active or
|
||||
@@ -143,10 +144,10 @@ truth for live infrastructure data.
|
||||
`internal/monitoring/docker_detection.go`,
|
||||
`internal/monitoring/monitor_pve_guest_poll.go`, and monitoring guardrails
|
||||
together. Socket detection may only annotate LXC guests after explicit
|
||||
server opt-in. LXC Docker inventory may only emit Docker-agent-compatible
|
||||
reports into `ApplyDockerReport`, must skip guests with a linked online
|
||||
guest-local host agent, and must keep the command set to minimal read-only
|
||||
Docker summary and aggregate stats collection.
|
||||
server opt-in. LXC Docker inventory may only emit Docker / Podman
|
||||
module-compatible reports into `ApplyDockerReport`, must skip guests with a
|
||||
linked online guest-local host agent, and must keep the command set to
|
||||
minimal read-only Docker summary and aggregate stats collection.
|
||||
15. Add or change mock-mode Discovery context through the canonical mock
|
||||
fixture graph. Mock Discovery records must be derived from the same authored
|
||||
state graph as mock nodes, guests, Docker hosts, containers, and Kubernetes
|
||||
@@ -303,6 +304,10 @@ hostname aliases through the shared unified-resource equivalence rule when it
|
||||
binds tokens, matches reports, and removes ignored agents, so reconnects and
|
||||
reloads keep the same canonical host without weakening token uniqueness across
|
||||
different machines.
|
||||
Docker / Podman token binding in `internal/monitoring/monitor_agents.go` follows
|
||||
the same single-agent product boundary: token uniqueness and conflict messages
|
||||
are about Docker / Podman module reports from `pulse-agent`, not enrollment of a
|
||||
separate Docker-specific agent product.
|
||||
That same monitoring boundary now owns agentless availability targets as a
|
||||
first-class provider, not as a settings-only helper. Saved availability targets
|
||||
load from the config persistence boundary, schedule through
|
||||
|
||||
@@ -153,7 +153,7 @@ controls as normal product settings.
|
||||
auth-env reloads, hosted entitlement refresh origins, and
|
||||
pinned-fingerprint TLS clients keep one fail-closed security floor.
|
||||
9. Change operator-facing Resource Privacy/Data Handling posture through `frontend-modern/src/components/Settings/DataHandlingPanel.tsx` and `frontend-modern/src/components/Settings/dataHandlingPanelModel.ts` together so resource classification, handling-boundary, redaction copy, and the route-backed/hidden-sidebar presentation stay governed as a trust surface.
|
||||
10. Change inside-guest runtime collection boundaries through `docs/AGENT_SECURITY.md`, `docs/UNIFIED_AGENT.md`, `cmd/pulse-agent/main.go`, `internal/api/router.go`, and `internal/config/config.go` together. Docker / Podman inventory inside a VM or LXC may come from a guest-agent or explicitly reported guest data; LXC Docker inventory may also be collected by a Proxmox host agent only through explicit server opt-in, with optional VMID allowlisting and a minimal summary command set that avoids `docker inspect`, environment, mount, file, command, and process collection. Local Unified Agent Docker / Podman disables must not be reversed by remote profile configuration.
|
||||
10. Change inside-guest runtime collection boundaries through `docs/AGENT_SECURITY.md`, `docs/UNIFIED_AGENT.md`, `cmd/pulse-agent/main.go`, `internal/api/router.go`, and `internal/config/config.go` together. Docker / Podman inventory inside a VM or LXC may come from a guest-local `pulse-agent` module or explicitly reported guest data; LXC Docker inventory may also be collected by a Proxmox host agent only through explicit server opt-in, with optional VMID allowlisting and a minimal summary command set that avoids `docker inspect`, environment, mount, file, command, and process collection. Local Unified Agent Docker / Podman disables must not be reversed by remote profile configuration, and self-test/update preflight that needs the live runtime token must pass it through a short-lived token file rather than argv.
|
||||
Global resource timeline reads through `/api/resources/timeline` are
|
||||
adjacent monitoring-read surfaces, not a privacy bypass. Provider activity
|
||||
filters may expose backend-authored task/event metadata, but the endpoint
|
||||
|
||||
@@ -302,7 +302,7 @@ recovery scope, or a storage/recovery-owned secret source.
|
||||
storage remediation permission.
|
||||
Adjacent Docker / Podman management routes may also share `internal/api/`
|
||||
transport with storage/recovery. Storage and recovery consumers must
|
||||
preserve the API-owned Docker / Podman agent or host wording for management
|
||||
preserve the API-owned Docker / Podman module or host wording for management
|
||||
responses and must not introduce recovery-local container-runtime labels.
|
||||
Proxmox-side LXC Docker inventory wiring may also pass through
|
||||
`internal/api/router.go`, but storage and recovery may consume the resulting
|
||||
@@ -420,6 +420,10 @@ recovery scope, or a storage/recovery-owned secret source.
|
||||
`/api/admin/users` and manual discovery at `/api/discover` must stay hidden
|
||||
for the same reason; recovery-adjacent pages must not treat those
|
||||
admin-oriented read routes as safe public-demo evidence.
|
||||
Storage/recovery-adjacent diagnostics copy that references Docker / Podman
|
||||
runtime coverage must also inherit the canonical installed-agent identity:
|
||||
the coverage comes from Docker / Podman modules inside `pulse-agent`, not
|
||||
from a separate Docker-specific agent product.
|
||||
Storage and recovery consumers must also inherit the hook's canonical
|
||||
`ResourceType` normalization for route/query filters, so storage subtypes
|
||||
such as `physical_disk` stay on the same cache-backed snapshot instead of
|
||||
|
||||
@@ -70,7 +70,102 @@ func newUpdaterForTest(serverURL string) *Updater {
|
||||
CurrentVersion: "1.0.0",
|
||||
CheckInterval: 10 * time.Millisecond,
|
||||
}
|
||||
return New(cfg)
|
||||
u := New(cfg)
|
||||
u.selfTestFn = func(context.Context, string) error { return nil }
|
||||
return u
|
||||
}
|
||||
|
||||
func selfTestHelperCommand(exitCode string) *exec.Cmd {
|
||||
cmd := exec.Command(os.Args[0], "-test.run=TestAgentUpdateSelfTestHelperProcess", "--")
|
||||
cmd.Env = append(os.Environ(),
|
||||
"PULSE_AGENTUPDATE_SELFTEST_HELPER=1",
|
||||
"PULSE_AGENTUPDATE_SELFTEST_EXIT="+exitCode,
|
||||
)
|
||||
return cmd
|
||||
}
|
||||
|
||||
func TestAgentUpdateSelfTestHelperProcess(t *testing.T) {
|
||||
if os.Getenv("PULSE_AGENTUPDATE_SELFTEST_HELPER") != "1" {
|
||||
return
|
||||
}
|
||||
if os.Getenv("PULSE_AGENTUPDATE_SELFTEST_EXIT") != "0" {
|
||||
os.Exit(3)
|
||||
}
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
func TestRunDownloadedBinarySelfTestUsesTokenFile(t *testing.T) {
|
||||
u := New(Config{APIToken: " token-with-whitespace \n"})
|
||||
|
||||
origExec := execCommandContextFn
|
||||
t.Cleanup(func() { execCommandContextFn = origExec })
|
||||
|
||||
var (
|
||||
sawBinaryPath string
|
||||
sawArgs []string
|
||||
tokenFilePath string
|
||||
tokenContent string
|
||||
tokenMode os.FileMode
|
||||
)
|
||||
execCommandContextFn = func(ctx context.Context, name string, args ...string) *exec.Cmd {
|
||||
sawBinaryPath = name
|
||||
sawArgs = append([]string(nil), args...)
|
||||
if len(args) == 3 {
|
||||
tokenFilePath = args[2]
|
||||
data, err := os.ReadFile(tokenFilePath)
|
||||
if err != nil {
|
||||
t.Fatalf("read self-test token file: %v", err)
|
||||
}
|
||||
tokenContent = string(data)
|
||||
info, err := os.Stat(tokenFilePath)
|
||||
if err != nil {
|
||||
t.Fatalf("stat self-test token file: %v", err)
|
||||
}
|
||||
tokenMode = info.Mode().Perm()
|
||||
}
|
||||
return selfTestHelperCommand("0")
|
||||
}
|
||||
|
||||
if err := u.runDownloadedBinarySelfTest(context.Background(), "/tmp/pulse-agent-new"); err != nil {
|
||||
t.Fatalf("runDownloadedBinarySelfTest: %v", err)
|
||||
}
|
||||
|
||||
if sawBinaryPath != "/tmp/pulse-agent-new" {
|
||||
t.Fatalf("self-test binary path = %q", sawBinaryPath)
|
||||
}
|
||||
if len(sawArgs) != 3 || sawArgs[0] != "--self-test" || sawArgs[1] != "--token-file" || sawArgs[2] == "" {
|
||||
t.Fatalf("unexpected self-test args: %#v", sawArgs)
|
||||
}
|
||||
if tokenContent != "token-with-whitespace" {
|
||||
t.Fatalf("self-test token content = %q", tokenContent)
|
||||
}
|
||||
if runtime.GOOS != "windows" && tokenMode != 0o600 {
|
||||
t.Fatalf("self-test token mode = %o, want 0600", tokenMode)
|
||||
}
|
||||
if _, err := os.Stat(tokenFilePath); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("expected self-test token file cleanup, got err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunDownloadedBinarySelfTestPropagatesFailureWithoutTokenArg(t *testing.T) {
|
||||
u := New(Config{})
|
||||
|
||||
origExec := execCommandContextFn
|
||||
t.Cleanup(func() { execCommandContextFn = origExec })
|
||||
|
||||
var sawArgs []string
|
||||
execCommandContextFn = func(ctx context.Context, name string, args ...string) *exec.Cmd {
|
||||
sawArgs = append([]string(nil), args...)
|
||||
return selfTestHelperCommand("3")
|
||||
}
|
||||
|
||||
err := u.runDownloadedBinarySelfTest(context.Background(), "/tmp/pulse-agent-new")
|
||||
if err == nil || !strings.Contains(err.Error(), "new pulse-agent binary failed self-test") {
|
||||
t.Fatalf("expected self-test failure, got %v", err)
|
||||
}
|
||||
if len(sawArgs) != 1 || sawArgs[0] != "--self-test" {
|
||||
t.Fatalf("unexpected self-test args without token: %#v", sawArgs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRestartProcess(t *testing.T) {
|
||||
@@ -947,6 +1042,45 @@ func TestPerformUpdateSymlinkFallback(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPerformUpdateSelfTestFailurePreservesCurrentBinary(t *testing.T) {
|
||||
u := newUpdaterForTest("https://example")
|
||||
_, execPath := writeTempExec(t)
|
||||
data := testBinary()
|
||||
|
||||
origRestart := restartProcessFn
|
||||
t.Cleanup(func() { restartProcessFn = origRestart })
|
||||
restartProcessFn = func(string) error {
|
||||
t.Fatalf("restart should not be reached after self-test failure")
|
||||
return nil
|
||||
}
|
||||
u.selfTestFn = func(context.Context, string) error { return errors.New("self-test fail") }
|
||||
|
||||
u.client = &http.Client{
|
||||
Transport: roundTripperFunc(func(r *http.Request) (*http.Response, error) {
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Status: "200 OK",
|
||||
Body: io.NopCloser(bytes.NewReader(data)),
|
||||
Header: http.Header{"X-Checksum-Sha256": []string{checksum(data)}},
|
||||
}, nil
|
||||
}),
|
||||
}
|
||||
|
||||
if err := u.performUpdateWithExecPath(context.Background(), execPath); err == nil || !strings.Contains(err.Error(), "self-test fail") {
|
||||
t.Fatalf("expected self-test error, got %v", err)
|
||||
}
|
||||
current, err := os.ReadFile(execPath)
|
||||
if err != nil {
|
||||
t.Fatalf("read current binary: %v", err)
|
||||
}
|
||||
if string(current) != "old-binary" {
|
||||
t.Fatalf("current binary changed after failed self-test: %q", string(current))
|
||||
}
|
||||
if _, err := os.Stat(execPath + ".backup"); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("backup should not be created before self-test passes, got err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPerformUpdateErrors(t *testing.T) {
|
||||
t.Run("CreateTempError", func(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
@@ -6,9 +6,12 @@ import "context"
|
||||
// the process restart side effect so integration tests can inspect the result.
|
||||
func PerformUpdateWithExecPathForTest(u *Updater, ctx context.Context, execPath string) error {
|
||||
origRestart := restartProcessFn
|
||||
origSelfTest := u.selfTestFn
|
||||
restartProcessFn = func(string) error { return nil }
|
||||
u.selfTestFn = func(context.Context, string) error { return nil }
|
||||
defer func() {
|
||||
restartProcessFn = origRestart
|
||||
u.selfTestFn = origSelfTest
|
||||
}()
|
||||
return u.performUpdateWithExecPath(ctx, execPath)
|
||||
}
|
||||
@@ -34,16 +37,19 @@ func UseExecPathForUpdateChecksForTest(u *Updater, execPath string) func() {
|
||||
origExec := osExecutableFn
|
||||
origEval := evalSymlinksFn
|
||||
origRestart := restartProcessFn
|
||||
origSelfTest := u.selfTestFn
|
||||
|
||||
osExecutableFn = func() (string, error) { return execPath, nil }
|
||||
evalSymlinksFn = func(string) (string, error) { return execPath, nil }
|
||||
restartProcessFn = func(string) error { return nil }
|
||||
u.selfTestFn = func(context.Context, string) error { return nil }
|
||||
u.performUpdateFn = func(ctx context.Context) error {
|
||||
return u.performUpdateWithExecPath(ctx, execPath)
|
||||
}
|
||||
|
||||
return func() {
|
||||
u.performUpdateFn = origPerform
|
||||
u.selfTestFn = origSelfTest
|
||||
osExecutableFn = origExec
|
||||
evalSymlinksFn = origEval
|
||||
restartProcessFn = origRestart
|
||||
|
||||
@@ -78,6 +78,7 @@ var (
|
||||
unraidPersistentPathFn = unraidPersistentPath
|
||||
qnapPersistentPathFn = qnapPersistentPath
|
||||
restartProcessFn = restartProcess
|
||||
execCommandContextFn = exec.CommandContext
|
||||
osExecutableFn = os.Executable
|
||||
evalSymlinksFn = filepath.EvalSymlinks
|
||||
createTempFn = os.CreateTemp
|
||||
@@ -136,6 +137,7 @@ type Updater struct {
|
||||
checkInProgress bool
|
||||
|
||||
performUpdateFn func(context.Context) error
|
||||
selfTestFn func(context.Context, string) error
|
||||
initialDelay time.Duration
|
||||
newTicker func(time.Duration) *time.Ticker
|
||||
}
|
||||
@@ -190,6 +192,7 @@ func New(cfg Config) *Updater {
|
||||
configErr: configErr,
|
||||
}
|
||||
u.performUpdateFn = u.performUpdate
|
||||
u.selfTestFn = u.runDownloadedBinarySelfTest
|
||||
u.initialDelay = 5 * time.Second
|
||||
u.newTicker = time.NewTicker
|
||||
return u
|
||||
@@ -763,6 +766,10 @@ func (u *Updater) performUpdateWithExecPath(ctx context.Context, execPath string
|
||||
return fmt.Errorf("failed to chmod: %w", err)
|
||||
}
|
||||
|
||||
if err := u.selfTestFn(ctx, tmpPath); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Atomic replacement with backup (use realExecPath for rename operations)
|
||||
backupPath := realExecPath + ".backup"
|
||||
if err := renameFn(realExecPath, backupPath); err != nil {
|
||||
@@ -864,6 +871,68 @@ func GetUpdatedFromVersion() string {
|
||||
return strings.TrimSpace(string(data))
|
||||
}
|
||||
|
||||
func writeSelfTestTokenFile(token string) (string, error) {
|
||||
trimmed := strings.TrimSpace(token)
|
||||
if trimmed == "" {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
file, err := createTempFn("", "pulse-agent-selftest-token-*")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("create self-test token file: %w", err)
|
||||
}
|
||||
path := file.Name()
|
||||
cleanupOnError := true
|
||||
defer func() {
|
||||
if cleanupOnError {
|
||||
_ = closeFileFn(file)
|
||||
_ = os.Remove(path)
|
||||
}
|
||||
}()
|
||||
|
||||
if _, err := file.WriteString(trimmed); err != nil {
|
||||
return "", fmt.Errorf("write self-test token file: %w", err)
|
||||
}
|
||||
if err := closeFileFn(file); err != nil {
|
||||
return "", fmt.Errorf("close self-test token file: %w", err)
|
||||
}
|
||||
if err := chmodFn(path, 0o600); err != nil {
|
||||
return "", fmt.Errorf("chmod self-test token file: %w", err)
|
||||
}
|
||||
|
||||
cleanupOnError = false
|
||||
return path, nil
|
||||
}
|
||||
|
||||
func (u *Updater) runDownloadedBinarySelfTest(ctx context.Context, binaryPath string) error {
|
||||
args := []string{"--self-test"}
|
||||
|
||||
tokenFilePath, err := writeSelfTestTokenFile(u.cfg.APIToken)
|
||||
if err != nil {
|
||||
return fmt.Errorf("prepare self-test token file: %w", err)
|
||||
}
|
||||
if tokenFilePath != "" {
|
||||
defer func() {
|
||||
if removeErr := os.Remove(tokenFilePath); removeErr != nil && !errors.Is(removeErr, os.ErrNotExist) {
|
||||
u.logger.Warn().Err(removeErr).Str("path", tokenFilePath).Msg("agentupdate.selfTest: failed to remove token file")
|
||||
}
|
||||
}()
|
||||
args = append(args, "--token-file", tokenFilePath)
|
||||
}
|
||||
|
||||
cmd := execCommandContextFn(ctx, binaryPath, args...)
|
||||
if output, err := cmd.CombinedOutput(); err != nil {
|
||||
u.logger.Error().
|
||||
Err(err).
|
||||
Int("outputBytes", len(output)).
|
||||
Msg("agentupdate.selfTest: downloaded binary failed self-test")
|
||||
return fmt.Errorf("new pulse-agent binary failed self-test: %w", err)
|
||||
}
|
||||
|
||||
u.logger.Debug().Msg("downloaded pulse-agent binary passed self-test")
|
||||
return nil
|
||||
}
|
||||
|
||||
// determineArch returns the architecture string for download URLs (e.g., "linux-amd64", "darwin-arm64").
|
||||
func determineArch() string {
|
||||
goos := runtimeGOOS
|
||||
|
||||
@@ -14977,3 +14977,45 @@ func TestContract_ProxmoxGuestDockerDetectionRequiresExplicitOptIn(t *testing.T)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestContract_DockerPodmanAdminCopyUsesPulseAgentModuleIdentity(t *testing.T) {
|
||||
files := []string{
|
||||
"diagnostics.go",
|
||||
"docker_agents.go",
|
||||
"types.go",
|
||||
"update_detection.go",
|
||||
}
|
||||
for _, file := range files {
|
||||
t.Run(file, func(t *testing.T) {
|
||||
src, err := os.ReadFile(file)
|
||||
if err != nil {
|
||||
t.Fatalf("read %s: %v", file, err)
|
||||
}
|
||||
text := string(src)
|
||||
for _, forbidden := range []string{
|
||||
"Docker" + " agent",
|
||||
"docker" + " agent",
|
||||
"Docker / Podman" + " agent",
|
||||
"Docker / Podman" + " agents",
|
||||
} {
|
||||
if strings.Contains(text, forbidden) {
|
||||
t.Fatalf("%s must describe Docker / Podman as a pulse-agent module, not %q", file, forbidden)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
diagnostics, err := os.ReadFile("diagnostics.go")
|
||||
if err != nil {
|
||||
t.Fatalf("read diagnostics.go: %v", err)
|
||||
}
|
||||
for _, required := range []string{
|
||||
"Docker / Podman module is still using the shared API token",
|
||||
"No Docker / Podman modules have reported in yet",
|
||||
"All Docker / Podman modules are reporting with dedicated tokens and the expected version.",
|
||||
} {
|
||||
if !strings.Contains(string(diagnostics), required) {
|
||||
t.Errorf("diagnostics.go must preserve module copy %q", required)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+11
-11
@@ -631,7 +631,7 @@ func (u APITokenUsage) NormalizeCollections() APITokenUsage {
|
||||
return u
|
||||
}
|
||||
|
||||
// DockerAgentDiagnostic summarizes adoption of the Docker agent command system.
|
||||
// DockerAgentDiagnostic summarizes adoption of the Docker / Podman module command system.
|
||||
type DockerAgentDiagnostic struct {
|
||||
AgentsTotal int `json:"agentsTotal"`
|
||||
AgentsOnline int `json:"agentsOnline"`
|
||||
@@ -1158,7 +1158,7 @@ func buildDockerAgentDiagnostic(m *monitoring.Monitor, serverVersion string) *Do
|
||||
}
|
||||
|
||||
if len(hosts) == 0 {
|
||||
appendNote("No Docker / Podman agents have reported in yet. Use Settings → Infrastructure to install the Docker / Podman agent and unlock remote commands.")
|
||||
appendNote("No Docker / Podman modules have reported in yet. Use Settings → Infrastructure to install pulse-agent with Docker / Podman monitoring and unlock remote commands.")
|
||||
return diag
|
||||
}
|
||||
|
||||
@@ -1197,7 +1197,7 @@ func buildDockerAgentDiagnostic(m *monitoring.Monitor, serverVersion string) *Do
|
||||
issues := make([]string, 0, 4)
|
||||
|
||||
if status != "online" && status != "" {
|
||||
issues = append(issues, fmt.Sprintf("Docker / Podman agent reports status %q.", status))
|
||||
issues = append(issues, fmt.Sprintf("Docker / Podman module reports status %q.", status))
|
||||
}
|
||||
|
||||
if versionStr == "" {
|
||||
@@ -1214,7 +1214,7 @@ func buildDockerAgentDiagnostic(m *monitoring.Monitor, serverVersion string) *Do
|
||||
}
|
||||
|
||||
if strings.TrimSpace(host.TokenID()) == "" {
|
||||
issues = append(issues, "Docker / Podman agent is still using the shared API token. Generate a dedicated token in Settings → Security and rerun the installer.")
|
||||
issues = append(issues, "Docker / Podman module is still using the shared API token. Generate a dedicated token in Settings → Security and rerun the installer.")
|
||||
}
|
||||
|
||||
if !host.LastSeen().IsZero() && now.Sub(host.LastSeen().UTC()) > 10*time.Minute {
|
||||
@@ -1236,7 +1236,7 @@ func buildDockerAgentDiagnostic(m *monitoring.Monitor, serverVersion string) *Do
|
||||
|
||||
if host.PendingUninstall() {
|
||||
diag.AgentsPendingUninstall++
|
||||
issues = append(issues, "Docker / Podman agent is pending uninstall; confirm the agent container stopped or clear the flag.")
|
||||
issues = append(issues, "Docker / Podman module is pending uninstall; confirm pulse-agent stopped reporting Docker / Podman telemetry or clear the flag.")
|
||||
}
|
||||
|
||||
if len(issues) == 0 {
|
||||
@@ -1262,7 +1262,7 @@ func buildDockerAgentDiagnostic(m *monitoring.Monitor, serverVersion string) *Do
|
||||
diag.AgentsNeedingAttention = len(diag.Attention)
|
||||
|
||||
if legacyTokenHosts > 0 {
|
||||
appendNote(fmt.Sprintf("%s still %s on the shared API token. Migrate each agent to a dedicated token via Settings → Security and rerun the installer.", dockerPodmanAgentCount(legacyTokenHosts), pluralVerb(legacyTokenHosts, "relies", "rely")))
|
||||
appendNote(fmt.Sprintf("%s still %s on the shared API token. Migrate each module to a dedicated token via Settings → Security and rerun the installer.", dockerPodmanAgentCount(legacyTokenHosts), pluralVerb(legacyTokenHosts, "relies", "rely")))
|
||||
}
|
||||
if diag.AgentsOutdatedVersion > 0 {
|
||||
appendNote(fmt.Sprintf("%s %s out of date. Re-run the installer from Settings → Infrastructure to upgrade.", dockerPodmanAgentCount(diag.AgentsOutdatedVersion), pluralVerb(diag.AgentsOutdatedVersion, "is", "are")))
|
||||
@@ -1277,7 +1277,7 @@ func buildDockerAgentDiagnostic(m *monitoring.Monitor, serverVersion string) *Do
|
||||
appendNote(fmt.Sprintf("%s %s pending uninstall. Confirm the uninstall or clear the flag from Settings → Infrastructure.", dockerPodmanAgentCount(diag.AgentsPendingUninstall), pluralVerb(diag.AgentsPendingUninstall, "is", "are")))
|
||||
}
|
||||
if diag.AgentsNeedingAttention == 0 {
|
||||
appendNote("All Docker / Podman agents are reporting with dedicated tokens and the expected version.")
|
||||
appendNote("All Docker / Podman modules are reporting with dedicated tokens and the expected version.")
|
||||
}
|
||||
|
||||
return diag
|
||||
@@ -1285,16 +1285,16 @@ func buildDockerAgentDiagnostic(m *monitoring.Monitor, serverVersion string) *Do
|
||||
|
||||
func dockerPodmanAgentCount(count int) string {
|
||||
if count == 1 {
|
||||
return "1 Docker / Podman agent"
|
||||
return "1 Docker / Podman module"
|
||||
}
|
||||
return fmt.Sprintf("%d Docker / Podman agents", count)
|
||||
return fmt.Sprintf("%d Docker / Podman modules", count)
|
||||
}
|
||||
|
||||
func dockerPodmanAgentCommandCount(count int) string {
|
||||
if count == 1 {
|
||||
return "1 Docker / Podman agent command"
|
||||
return "1 Docker / Podman module command"
|
||||
}
|
||||
return fmt.Sprintf("%d Docker / Podman agent commands", count)
|
||||
return fmt.Sprintf("%d Docker / Podman module commands", count)
|
||||
}
|
||||
|
||||
func pluralVerb(count int, singular, plural string) string {
|
||||
|
||||
@@ -418,8 +418,8 @@ func TestBuildDockerAgentDiagnostic(t *testing.T) {
|
||||
t.Fatalf("Docker diagnostics should not expose generic container-runtime copy, got %q", diagnosticText)
|
||||
}
|
||||
for _, want := range []string{
|
||||
"Docker / Podman agent is still using the shared API token",
|
||||
"1 Docker / Podman agent is out of date",
|
||||
"Docker / Podman module is still using the shared API token",
|
||||
"1 Docker / Podman module is out of date",
|
||||
"Settings → Infrastructure",
|
||||
} {
|
||||
if !strings.Contains(diagnosticText, want) {
|
||||
@@ -442,7 +442,7 @@ func TestBuildDockerAgentDiagnosticEmptyUsesDockerPodmanCopy(t *testing.T) {
|
||||
t.Fatalf("empty Docker diagnostics should not expose generic container-runtime copy, got %q", notes)
|
||||
}
|
||||
for _, want := range []string{
|
||||
"No Docker / Podman agents have reported in yet",
|
||||
"No Docker / Podman modules have reported in yet",
|
||||
"Settings → Infrastructure",
|
||||
} {
|
||||
if !strings.Contains(notes, want) {
|
||||
|
||||
@@ -15,7 +15,7 @@ import (
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
// DockerAgentHandlers manages ingest from the external Docker agent.
|
||||
// DockerAgentHandlers manages Docker / Podman module ingest from pulse-agent.
|
||||
type DockerAgentHandlers struct {
|
||||
baseAgentHandlers
|
||||
config *config.Config
|
||||
@@ -70,12 +70,12 @@ func dockerRuntimeAgentIDFromPath(path string, suffix string) string {
|
||||
return strings.TrimSpace(trimmed)
|
||||
}
|
||||
|
||||
// NewDockerAgentHandlers constructs a new Docker agent handler group.
|
||||
// NewDockerAgentHandlers constructs a new Docker / Podman module handler group.
|
||||
func NewDockerAgentHandlers(mtm *monitoring.MultiTenantMonitor, m *monitoring.Monitor, hub *websocket.Hub, cfg *config.Config) *DockerAgentHandlers {
|
||||
return &DockerAgentHandlers{baseAgentHandlers: newBaseAgentHandlers(mtm, m, hub), config: cfg}
|
||||
}
|
||||
|
||||
// HandleReport accepts heartbeat payloads from the Docker agent.
|
||||
// HandleReport accepts heartbeat payloads from the Docker / Podman module.
|
||||
func (h *DockerAgentHandlers) HandleReport(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
writeErrorResponse(w, http.StatusMethodNotAllowed, "method_not_allowed", "Only POST is allowed", nil)
|
||||
@@ -115,7 +115,7 @@ func (h *DockerAgentHandlers) HandleReport(w http.ResponseWriter, r *http.Reques
|
||||
log.Debug().
|
||||
Str("dockerHost", host.Hostname).
|
||||
Int("containers", len(host.Containers)).
|
||||
Msg("Docker agent report processed")
|
||||
Msg("Docker / Podman module report processed")
|
||||
|
||||
// Broadcast the updated state for near-real-time UI updates
|
||||
h.broadcastState(r.Context())
|
||||
@@ -139,7 +139,7 @@ func (h *DockerAgentHandlers) HandleReport(w http.ResponseWriter, r *http.Reques
|
||||
}
|
||||
|
||||
if err := utils.WriteJSONResponse(w, response); err != nil {
|
||||
log.Error().Err(err).Msg("Failed to serialize docker agent response")
|
||||
log.Error().Err(err).Msg("Failed to serialize Docker / Podman module response")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -190,7 +190,7 @@ func (h *DockerAgentHandlers) HandleDockerHostActions(w http.ResponseWriter, r *
|
||||
writeErrorResponse(w, http.StatusMethodNotAllowed, "method_not_allowed", "Method not allowed", nil)
|
||||
}
|
||||
|
||||
// HandleCommandAck processes acknowledgements from docker agents for issued commands.
|
||||
// HandleCommandAck processes acknowledgements from Docker / Podman modules for issued commands.
|
||||
func (h *DockerAgentHandlers) HandleCommandAck(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
writeErrorResponse(w, http.StatusMethodNotAllowed, "method_not_allowed", "Only POST is allowed", nil)
|
||||
@@ -304,7 +304,7 @@ func (h *DockerAgentHandlers) HandleDeleteHost(w http.ResponseWriter, r *http.Re
|
||||
|
||||
if shouldHide {
|
||||
if !hostExists {
|
||||
writeErrorResponse(w, http.StatusNotFound, "docker_agent_not_found", "Docker / Podman agent not found", nil)
|
||||
writeErrorResponse(w, http.StatusNotFound, "docker_agent_not_found", "Docker / Podman module not found", nil)
|
||||
return
|
||||
}
|
||||
host, err := h.getMonitor(r.Context()).HideDockerHost(agentID)
|
||||
@@ -318,7 +318,7 @@ func (h *DockerAgentHandlers) HandleDeleteHost(w http.ResponseWriter, r *http.Re
|
||||
if err := utils.WriteJSONResponse(w, map[string]any{
|
||||
"success": true,
|
||||
"agentId": host.ID,
|
||||
"message": "Docker / Podman agent hidden",
|
||||
"message": "Docker / Podman module hidden",
|
||||
}); err != nil {
|
||||
log.Error().Err(err).Msg("Failed to serialize docker host operation response")
|
||||
}
|
||||
@@ -330,14 +330,14 @@ func (h *DockerAgentHandlers) HandleDeleteHost(w http.ResponseWriter, r *http.Re
|
||||
if err := utils.WriteJSONResponse(w, map[string]any{
|
||||
"success": true,
|
||||
"agentId": agentID,
|
||||
"message": "Docker / Podman agent already removed",
|
||||
"message": "Docker / Podman module already removed",
|
||||
}); err != nil {
|
||||
log.Error().Err(err).Msg("Failed to serialize docker host operation response")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
writeErrorResponse(w, http.StatusNotFound, "docker_agent_not_found", "Docker / Podman agent not found", nil)
|
||||
writeErrorResponse(w, http.StatusNotFound, "docker_agent_not_found", "Docker / Podman module not found", nil)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -372,7 +372,7 @@ func (h *DockerAgentHandlers) HandleDeleteHost(w http.ResponseWriter, r *http.Re
|
||||
if err := utils.WriteJSONResponse(w, map[string]any{
|
||||
"success": true,
|
||||
"agentId": host.ID,
|
||||
"message": "Docker / Podman agent removed",
|
||||
"message": "Docker / Podman module removed",
|
||||
}); err != nil {
|
||||
log.Error().Err(err).Msg("Failed to serialize docker host operation response")
|
||||
}
|
||||
@@ -431,7 +431,7 @@ func (h *DockerAgentHandlers) HandleUnhideHost(w http.ResponseWriter, r *http.Re
|
||||
if err := utils.WriteJSONResponse(w, map[string]any{
|
||||
"success": true,
|
||||
"agentId": host.ID,
|
||||
"message": "Docker / Podman agent unhidden",
|
||||
"message": "Docker / Podman module unhidden",
|
||||
}); err != nil {
|
||||
log.Error().Err(err).Msg("Failed to serialize docker host unhide response")
|
||||
}
|
||||
@@ -461,7 +461,7 @@ func (h *DockerAgentHandlers) HandleMarkPendingUninstall(w http.ResponseWriter,
|
||||
if err := utils.WriteJSONResponse(w, map[string]any{
|
||||
"success": true,
|
||||
"agentId": host.ID,
|
||||
"message": "Docker / Podman agent marked as pending uninstall",
|
||||
"message": "Docker / Podman module marked as pending uninstall",
|
||||
}); err != nil {
|
||||
log.Error().Err(err).Msg("Failed to serialize docker host pending uninstall response")
|
||||
}
|
||||
@@ -505,13 +505,13 @@ func (h *DockerAgentHandlers) HandleSetCustomDisplayName(w http.ResponseWriter,
|
||||
if err := utils.WriteJSONResponse(w, map[string]any{
|
||||
"success": true,
|
||||
"agentId": host.ID,
|
||||
"message": "Docker / Podman agent custom display name updated",
|
||||
"message": "Docker / Podman module custom display name updated",
|
||||
}); err != nil {
|
||||
log.Error().Err(err).Msg("Failed to serialize docker host custom display name response")
|
||||
}
|
||||
}
|
||||
|
||||
// HandleContainerUpdate triggers a container update on a Docker agent.
|
||||
// HandleContainerUpdate triggers a container update through the Docker / Podman module.
|
||||
// POST /api/agents/docker/containers/{containerId}/update
|
||||
func (h *DockerAgentHandlers) HandleContainerUpdate(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
|
||||
@@ -219,7 +219,7 @@ func TestDockerAgentHandlers_HandleDeleteHost(t *testing.T) {
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if body := rec.Body.String(); !strings.Contains(body, "Docker / Podman agent removed") || strings.Contains(body, "Container runtime") {
|
||||
if body := rec.Body.String(); !strings.Contains(body, "Docker / Podman module removed") || strings.Contains(body, "Container runtime") {
|
||||
t.Fatalf("unexpected delete response body: %s", body)
|
||||
}
|
||||
}
|
||||
@@ -235,7 +235,7 @@ func TestDockerAgentHandlers_HandleUnhideHost(t *testing.T) {
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if body := rec.Body.String(); !strings.Contains(body, "Docker / Podman agent unhidden") || strings.Contains(body, "Container runtime") {
|
||||
if body := rec.Body.String(); !strings.Contains(body, "Docker / Podman module unhidden") || strings.Contains(body, "Container runtime") {
|
||||
t.Fatalf("unexpected unhide response body: %s", body)
|
||||
}
|
||||
}
|
||||
@@ -251,7 +251,7 @@ func TestDockerAgentHandlers_HandleMarkPendingUninstall(t *testing.T) {
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if body := rec.Body.String(); !strings.Contains(body, "Docker / Podman agent marked as pending uninstall") || strings.Contains(body, "Container runtime") {
|
||||
if body := rec.Body.String(); !strings.Contains(body, "Docker / Podman module marked as pending uninstall") || strings.Contains(body, "Container runtime") {
|
||||
t.Fatalf("unexpected pending uninstall response body: %s", body)
|
||||
}
|
||||
}
|
||||
@@ -268,7 +268,7 @@ func TestDockerAgentHandlers_HandleSetCustomDisplayName(t *testing.T) {
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if body := rec.Body.String(); !strings.Contains(body, "Docker / Podman agent custom display name updated") || strings.Contains(body, "Container runtime") {
|
||||
if body := rec.Body.String(); !strings.Contains(body, "Docker / Podman module custom display name updated") || strings.Contains(body, "Container runtime") {
|
||||
t.Fatalf("unexpected display-name response body: %s", body)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -113,7 +113,7 @@ func TestDockerAgentHandlers_HandleDeleteHost_Errors(t *testing.T) {
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("expected 404, got %d", rec.Code)
|
||||
}
|
||||
if body := rec.Body.String(); !strings.Contains(body, "Docker / Podman agent not found") || strings.Contains(body, "Container runtime") {
|
||||
if body := rec.Body.String(); !strings.Contains(body, "Docker / Podman module not found") || strings.Contains(body, "Container runtime") {
|
||||
t.Fatalf("unexpected missing hide response body: %s", body)
|
||||
}
|
||||
|
||||
@@ -123,7 +123,7 @@ func TestDockerAgentHandlers_HandleDeleteHost_Errors(t *testing.T) {
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", rec.Code)
|
||||
}
|
||||
if body := rec.Body.String(); !strings.Contains(body, "Docker / Podman agent already removed") || strings.Contains(body, "Container runtime") {
|
||||
if body := rec.Body.String(); !strings.Contains(body, "Docker / Podman module already removed") || strings.Contains(body, "Container runtime") {
|
||||
t.Fatalf("unexpected forced delete response body: %s", body)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -365,7 +365,7 @@ type MetricPoint struct {
|
||||
Value float64 `json:"value"`
|
||||
}
|
||||
|
||||
// AgentVersionResponse represents Docker agent version information
|
||||
// AgentVersionResponse represents Docker / Podman module version information.
|
||||
type AgentVersionResponse struct {
|
||||
Version string `json:"version"`
|
||||
}
|
||||
|
||||
@@ -128,7 +128,7 @@ func (h *UpdateDetectionHandlers) HandleGetInfraUpdates(w http.ResponseWriter, r
|
||||
agentIDFilter := query.Get("agentId")
|
||||
resourceTypeFilter := strings.ToLower(query.Get("resourceType"))
|
||||
|
||||
// Collect updates from Docker agents
|
||||
// Collect updates from Docker / Podman modules.
|
||||
updates := h.collectDockerUpdates(agentIDFilter)
|
||||
|
||||
// Filter by resource type if specified
|
||||
@@ -328,7 +328,7 @@ func (h *UpdateDetectionHandlers) HandleGetInfraUpdatesForAgent(w http.ResponseW
|
||||
}
|
||||
}
|
||||
|
||||
// collectDockerUpdates gathers update information from Docker agents via ReadState.
|
||||
// collectDockerUpdates gathers update information from Docker / Podman modules via ReadState.
|
||||
func (h *UpdateDetectionHandlers) collectDockerUpdates(agentIDFilter string) []ContainerUpdateInfo {
|
||||
if h.readState == nil {
|
||||
return nil
|
||||
|
||||
@@ -29,14 +29,14 @@ type TargetConfig struct {
|
||||
InsecureSkipVerify bool
|
||||
}
|
||||
|
||||
// Config describes runtime configuration for the Docker agent.
|
||||
// Config describes runtime configuration for the Docker / Podman collection module.
|
||||
type Config struct {
|
||||
PulseURL string
|
||||
APIToken string
|
||||
Interval time.Duration
|
||||
HostnameOverride string
|
||||
AgentID string
|
||||
AgentType string // "unified" when running as part of pulse-agent, empty for standalone
|
||||
AgentType string // "unified" when running as part of pulse-agent, empty for legacy standalone mode
|
||||
AgentVersion string // Version to report; if empty, uses dockeragent.Version
|
||||
InsecureSkipVerify bool
|
||||
DisableAutoUpdate bool
|
||||
@@ -89,7 +89,7 @@ func isBackupContainer(names []string) bool {
|
||||
}
|
||||
|
||||
// setAgentHeaders sets the standard authentication and metadata headers for
|
||||
// requests from the Docker agent to a Pulse backend.
|
||||
// requests from the Docker / Podman module to a Pulse backend.
|
||||
func setAgentHeaders(req *http.Request, token string) {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("X-API-Token", token)
|
||||
@@ -97,7 +97,7 @@ func setAgentHeaders(req *http.Request, token string) {
|
||||
req.Header.Set("User-Agent", "pulse-agent/"+Version)
|
||||
}
|
||||
|
||||
// Agent collects Docker metrics and posts them to Pulse.
|
||||
// Agent collects Docker / Podman metrics and posts them to Pulse.
|
||||
type Agent struct {
|
||||
cfg Config
|
||||
docker dockerClient
|
||||
@@ -142,7 +142,7 @@ type cpuSample struct {
|
||||
read time.Time
|
||||
}
|
||||
|
||||
// New creates a new Docker agent instance.
|
||||
// New creates a new Docker / Podman module instance.
|
||||
func New(cfg Config) (*Agent, error) {
|
||||
targets, err := normalizeTargetsFn(cfg.Targets)
|
||||
if err != nil {
|
||||
@@ -873,7 +873,7 @@ func (a *Agent) sendReportToTarget(ctx context.Context, target TargetConfig, pay
|
||||
if strings.Contains(errMsg, "already in use") {
|
||||
a.logger.Error().
|
||||
Str("pulseURL", target.URL).
|
||||
Msg("DOCKER REGISTRATION FAILED: This API token is already used by another Docker agent. " +
|
||||
Msg("DOCKER REGISTRATION FAILED: This API token is already used by another Docker / Podman module. " +
|
||||
"Each Docker host requires its own unique token. " +
|
||||
"Generate a new token in Pulse Settings > Agents and reinstall with the new token.")
|
||||
}
|
||||
@@ -1192,7 +1192,7 @@ func (a *Agent) Close() error {
|
||||
case <-done:
|
||||
stopTimer(waitTimer)
|
||||
case <-waitTimer.C:
|
||||
a.logger.Warn().Msg("Timed out waiting for docker agent background work to stop")
|
||||
a.logger.Warn().Msg("Timed out waiting for Docker / Podman module background work to stop")
|
||||
}
|
||||
|
||||
for _, client := range a.httpClients {
|
||||
|
||||
@@ -58,7 +58,7 @@ type ImageUpdateResult struct {
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// NewRegistryChecker creates a new registry checker for the Docker agent.
|
||||
// NewRegistryChecker creates a new registry checker for the Docker / Podman module.
|
||||
func NewRegistryChecker(logger zerolog.Logger) *RegistryChecker {
|
||||
return newRegistryCheckerWithConfig(logger, true)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package dockeragent
|
||||
|
||||
// Version is the semantic version of the Pulse Docker agent binary. It is
|
||||
// overridden at build time via -ldflags for release artifacts. When building
|
||||
// from source without ldflags, it defaults to "dev" to prevent auto-update
|
||||
// loops in development builds.
|
||||
// Version is the semantic version reported by the Docker / Podman module when
|
||||
// it runs outside the unified pulse-agent entrypoint. Unified Agent builds pass
|
||||
// the pulse-agent version explicitly.
|
||||
var Version = "dev"
|
||||
|
||||
@@ -620,7 +620,7 @@ func TestDockerTokenBindingUsesCanonicalHostIdentity(t *testing.T) {
|
||||
"monitor_agents.go": {
|
||||
"resolveDockerTokenBindingIdentity(identifier, report, previous, hasPrevious)",
|
||||
"dockerTokenBindingMatches(boundAgentID, tokenBindingAliases)",
|
||||
"Bound Docker agent token to host identity",
|
||||
"Bound Docker / Podman module token to host identity",
|
||||
},
|
||||
"monitor.go": {
|
||||
"Docker host identity bindings",
|
||||
|
||||
@@ -29,7 +29,7 @@ type DockerChecker interface {
|
||||
// DockerInventoryCollector provides explicitly opted-in Docker inventory from
|
||||
// an LXC guest through the Proxmox node that owns it.
|
||||
type DockerInventoryCollector interface {
|
||||
// CollectDockerInventory returns a Docker agent-compatible report for the
|
||||
// CollectDockerInventory returns a Docker / Podman module-compatible report for the
|
||||
// supplied Proxmox LXC container. The bool is false when collection was
|
||||
// intentionally skipped, for example because the container is outside the
|
||||
// configured VMID allowlist or no Docker runtime is present.
|
||||
|
||||
@@ -69,7 +69,7 @@ func (m *Monitor) RemoveDockerHost(hostID string) (models.DockerHost, error) {
|
||||
log.Debug().
|
||||
Str("tokenID", host.TokenID).
|
||||
Str("dockerHostID", hostID).
|
||||
Msg("Unbound Docker agent token from removed host")
|
||||
Msg("Unbound Docker / Podman module token from removed host")
|
||||
}
|
||||
if cmd, ok := m.dockerCommands[hostID]; ok {
|
||||
delete(m.dockerCommandIndex, cmd.status.ID)
|
||||
@@ -1027,7 +1027,7 @@ func (m *Monitor) AcknowledgeDockerHostCommand(commandID, hostID, status, messag
|
||||
return m.acknowledgeDockerCommand(commandID, hostID, status, message)
|
||||
}
|
||||
|
||||
// ApplyDockerReport ingests a docker agent report into the shared state.
|
||||
// ApplyDockerReport ingests a Docker / Podman module report into the shared state.
|
||||
func (m *Monitor) ApplyDockerReport(report agentsdocker.Report, tokenRecord *config.APITokenRecord) (models.DockerHost, error) {
|
||||
readState := m.snapshotBackedUnifiedReadState()
|
||||
var dockerHosts []*unifiedresources.DockerHostView
|
||||
@@ -1099,7 +1099,7 @@ func (m *Monitor) ApplyDockerReport(report agentsdocker.Report, tokenRecord *con
|
||||
Str("boundAgentID", boundAgentID).
|
||||
Str("conflictingHost", conflictingHostname).
|
||||
Msg("Rejecting Docker report: token already bound to different agent")
|
||||
return models.DockerHost{}, fmt.Errorf("API token%s is already in use by agent %q (host: %s). Each Docker agent must use a unique API token. Generate a new token for this agent", tokenHint, boundAgentID, conflictingHostname)
|
||||
return models.DockerHost{}, fmt.Errorf("API token%s is already in use by agent %q (host: %s). Each Docker / Podman module must use a unique API token. Generate a new token for this agent", tokenHint, boundAgentID, conflictingHostname)
|
||||
}
|
||||
if boundAgentID != agentID {
|
||||
m.dockerTokenBindings[tokenID] = agentID
|
||||
@@ -1111,7 +1111,7 @@ func (m *Monitor) ApplyDockerReport(report agentsdocker.Report, tokenRecord *con
|
||||
Str("tokenID", tokenID).
|
||||
Str("agentID", agentID).
|
||||
Str("hostname", report.Host.Hostname).
|
||||
Msg("Bound Docker agent token to host identity")
|
||||
Msg("Bound Docker / Podman module token to host identity")
|
||||
}
|
||||
m.mu.Unlock()
|
||||
}
|
||||
|
||||
@@ -168,6 +168,43 @@ func TestApplyDockerReport_RecreatedContainerAgentIDKeepsTokenBinding(t *testing
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyDockerReportTokenConflictUsesModuleCopy(t *testing.T) {
|
||||
monitor := newTestMonitor(t)
|
||||
token := &config.APITokenRecord{ID: "token-conflict", Name: "Docker Token"}
|
||||
|
||||
firstReport := agentsdocker.Report{
|
||||
Agent: agentsdocker.AgentInfo{ID: "module-a", Version: "1.0.0", IntervalSeconds: 30},
|
||||
Host: agentsdocker.HostInfo{
|
||||
Hostname: "docker-a",
|
||||
MachineID: "machine-a",
|
||||
},
|
||||
Timestamp: time.Now().UTC(),
|
||||
}
|
||||
if _, err := monitor.ApplyDockerReport(firstReport, token); err != nil {
|
||||
t.Fatalf("first ApplyDockerReport failed: %v", err)
|
||||
}
|
||||
|
||||
conflictingReport := agentsdocker.Report{
|
||||
Agent: agentsdocker.AgentInfo{ID: "module-b", Version: "1.0.0", IntervalSeconds: 30},
|
||||
Host: agentsdocker.HostInfo{
|
||||
Hostname: "docker-b",
|
||||
MachineID: "machine-b",
|
||||
},
|
||||
Timestamp: firstReport.Timestamp.Add(time.Minute),
|
||||
}
|
||||
_, err := monitor.ApplyDockerReport(conflictingReport, token)
|
||||
if err == nil {
|
||||
t.Fatal("expected token conflict")
|
||||
}
|
||||
message := err.Error()
|
||||
if !strings.Contains(message, "Each Docker / Podman module must use a unique API token") {
|
||||
t.Fatalf("token conflict must use module copy, got %q", message)
|
||||
}
|
||||
if strings.Contains(message, "Docker"+" agent") || strings.Contains(message, "Docker / Podman"+" agent") {
|
||||
t.Fatalf("token conflict must not describe Docker / Podman as a separate agent product: %q", message)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyDockerReportPreservesDockerSwarmNodes(t *testing.T) {
|
||||
monitor := newTestMonitor(t)
|
||||
now := time.Now().UTC()
|
||||
|
||||
Reference in New Issue
Block a user