diff --git a/Dockerfile b/Dockerfile index 67570873e..c2e54fb35 100644 --- a/Dockerfile +++ b/Dockerfile @@ -242,7 +242,7 @@ COPY --from=release-assets-builder /app/VERSION /VERSION ENV PULSE_NO_AUTO_UPDATE=true \ PULSE_DISABLE_AUTO_UPDATE=true \ - PULSE_ENABLE_HOST=false \ + PULSE_ENABLE_HOST=true \ PULSE_ENABLE_DOCKER=true \ PULSE_AGENT_ID_FILE=/var/lib/pulse-agent/agent-id \ PULSE_STATE_DIR=/var/lib/pulse-agent diff --git a/docs/API.md b/docs/API.md index 22c47b542..c0cb443e2 100644 --- a/docs/API.md +++ b/docs/API.md @@ -795,6 +795,12 @@ Returns scheduler health, DLQ, and breaker status. Requires `monitoring:read`. - `GET /api/diagnostics` (auth) - `POST /api/diagnostics/docker/prepare-token` (admin, `settings:write`) +The Docker migration token request accepts `agentId`, optional `tokenName`, and +optional `enableHost`. When `enableHost` is omitted, the generated token and +install command enable both host and Docker monitoring so the machine appears +in both Hosts and Docker. Set `enableHost` to `false` only for an intentional +workload-only agent; that mode receives Docker-report scope only. + ### Logs (Admin) - `GET /api/logs/stream` (server-sent stream) - `GET /api/logs/download` (bundled logs) diff --git a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md index f00aaa9eb..a32c367dd 100644 --- a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md +++ b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md @@ -3932,10 +3932,19 @@ the shared frontend Unix builder, instead of returning a raw `| bash -s --` pipeline that drifts from the lane's governed install shape. The same lifecycle shell-transport contract also applies to the diagnostics container-runtime migration install command in `internal/api/router.go`: that -response must emit the canonical `--enable-host=false` flag and the governed -root-or-sudo wrapper, rather than falling back to the stale `--disable-host` -alias or a raw `curl | sudo bash` pipe that drifts from the managed install -surface. +response defaults to the native Unified Agent's host plus Docker modules, +emits the canonical `--enable-host` flag, and uses a token scoped for host +report/configuration continuity as well as Docker reports. An explicit +`enableHost:false` request keeps the intentional workload-only path, the +canonical `--enable-host=false` flag, and Docker-report-only token scope. Both +modes must retain the governed root-or-sudo wrapper rather than falling back +to the stale `--disable-host` alias or a raw `curl | sudo bash` pipe that +drifts from the managed install surface. +The Settings Docker / Podman install profile and the legacy +`scripts/install-container-agent.sh` compatibility wrapper follow that same +default: selecting Docker monitoring enables the Docker module without +silently disabling the host module. Workload-only operation remains an +explicit `--enable-host=false` choice through the canonical installer. That same diagnostics migration command must stay on the shared backend install-command helper path in `internal/api/agent_install_command_shared.go`, rather than rebuilding a local shell formatter in `router.go`, so optional diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md index 9b391802b..0a3fda02f 100644 --- a/docs/release-control/v6/internal/subsystems/api-contracts.md +++ b/docs/release-control/v6/internal/subsystems/api-contracts.md @@ -5776,7 +5776,14 @@ That same governed container-runtime migration response must also preserve the canonical lifecycle shell payload shape: `installCommand` in the diagnostics docker prepare-token response may not emit the stale `--disable-host` alias or an ad hoc `curl | sudo bash` pipeline, and must instead match the canonical -root-or-sudo wrapped install transport with `--enable-host=false`. +root-or-sudo wrapped install transport. Omitted `enableHost` defaults the +native Unified Agent migration to host plus Docker monitoring and emits +`--enable-host`; explicit `enableHost:false` preserves the workload-only +variant and emits `--enable-host=false`. +The Settings infrastructure operations model must expose the same default in +generated install flags: its Docker / Podman profile enables Docker without +adding a host-disable flag, while the generic flag translator continues to +preserve an operator-supplied explicit host opt-out. That diagnostics install-command payload must also be assembled through the shared backend install-command helper in `internal/api/agent_install_command_shared.go` instead of a handler-local shell formatter, so token omission, plain-HTTP diff --git a/docs/release-control/v6/internal/subsystems/deployment-installability.md b/docs/release-control/v6/internal/subsystems/deployment-installability.md index 567fd96c6..c5c07028a 100644 --- a/docs/release-control/v6/internal/subsystems/deployment-installability.md +++ b/docs/release-control/v6/internal/subsystems/deployment-installability.md @@ -1328,6 +1328,17 @@ agent binaries, or installer signing material. The published self-hosted so official release images still carry signed installer and agent download assets, and any build that declares `PULSE_UPDATE_SIGNING_PUBLIC_KEY` must continue to fail closed unless the matching signing secret is mounted. +The `agent_runtime` image is a Unified Agent deployment and defaults to both +host and Docker modules (`PULSE_ENABLE_HOST=true`, +`PULSE_ENABLE_DOCKER=true`). Operators may still set +`PULSE_ENABLE_HOST=false` for an intentional workload-only deployment, but +the image must not silently force that narrower mode and remove a previously +reported machine from the Hosts surface. +The legacy `scripts/install-container-agent.sh` compatibility wrapper must +match that default by forwarding `--enable-docker --enable-host`; it may point +operators who need workload-only behavior to the canonical installer's +explicit `--enable-host=false` option, but must not choose that opt-out for +them. That same update-runtime boundary now also owns bounded rollback retention and disk-space fail-closed behavior for self-hosted app updates. `internal/updates/` must prune stale retained rollback snapshots, clear history references when an diff --git a/docs/release-control/v6/internal/subsystems/monitoring.md b/docs/release-control/v6/internal/subsystems/monitoring.md index a7a42b808..9d40299be 100644 --- a/docs/release-control/v6/internal/subsystems/monitoring.md +++ b/docs/release-control/v6/internal/subsystems/monitoring.md @@ -91,6 +91,12 @@ downstream surfaces can warn, and it must clear on its own once only one machine keeps reporting for the window. Monitoring must not auto-split the collapsed identity: the machine ID is the identity key, and the remedy (regenerating the clone's machine-id) belongs to the operator. +Unified Agent module projection is report-authored and additive. When one +agent sends host and Docker reports for the same machine identity, monitoring +must refresh one canonical machine carrying both facets so the Hosts and +Docker typed views expose the same canonical ID. A deliberately Docker-only +agent sends no host report and must remain absent from the Hosts view rather +than gaining a synthetic host facet from Docker telemetry. Proxmox read-state rehydration is the inverse boundary: canonical unified-resource CPU metrics are 0..100 percentages, while legacy `models.Node.CPU`, `models.VM.CPU`, and `models.Container.CPU` remain Proxmox diff --git a/docs/release-control/v6/internal/subsystems/performance-and-scalability.md b/docs/release-control/v6/internal/subsystems/performance-and-scalability.md index f3eddd978..0311f70d8 100644 --- a/docs/release-control/v6/internal/subsystems/performance-and-scalability.md +++ b/docs/release-control/v6/internal/subsystems/performance-and-scalability.md @@ -863,6 +863,10 @@ Default-org token scoping and notification-settings fan-out on shared `internal/api/router.go` are likewise adjacent only: the tenant-monitor settings inheritance hook runs once per tenant-monitor creation and changes no chart transport, downsampling, or polling behavior. +Container-runtime migration mode selection on that shared router path is also +request-local. Deriving the bounded host-plus-Docker or Docker-only token +scope and matching install flag must remain constant work with no resource +inventory scan, telemetry fan-out, or extra persistence pass. The bars / sparklines toggle and its sparkline-range picker on the WorkloadsSurface support page-level ownership through four optional diff --git a/docs/release-control/v6/internal/subsystems/security-privacy.md b/docs/release-control/v6/internal/subsystems/security-privacy.md index 2f5017c61..a5d2aeaed 100644 --- a/docs/release-control/v6/internal/subsystems/security-privacy.md +++ b/docs/release-control/v6/internal/subsystems/security-privacy.md @@ -773,6 +773,12 @@ the visible API-token manager: agent install command tokens, deploy bootstrap tokens, enrollment runtime tokens, container runtime migration tokens, and first-run/regenerated admin tokens must use the same shared server-side owner setter rather than carrying owner identity in caller-controlled metadata. +Container-runtime migration tokens must also follow the requested module +boundary. The default host-plus-Docker migration may grant only the bounded +Agent report, configuration-read, Agent-manage, and Docker-report scopes; an +explicit `enableHost:false` workload-only migration must retain only the +Docker-report scope. Neither path may gain command-execution or Kubernetes +report authority implicitly. That same command-token trust boundary also owns first-use binding for Proxmox install-command tokens. `internal/api/agent_exec_token_binding.go` may persist `bound_agent_id`, `bound_hostname`, and `bound_at` only for diff --git a/docs/release-control/v6/internal/subsystems/storage-recovery.md b/docs/release-control/v6/internal/subsystems/storage-recovery.md index a26e39c09..3d6342e0b 100644 --- a/docs/release-control/v6/internal/subsystems/storage-recovery.md +++ b/docs/release-control/v6/internal/subsystems/storage-recovery.md @@ -3636,6 +3636,12 @@ That same shared dependency now also assumes those diagnostics install payloads route through the canonical backend install-command helper, so recovery-adjacent transport surfaces do not inherit handler-local drift in token omission, plain-HTTP `--insecure`, or trailing-slash normalization. +The persisted migration token and returned install payload must describe the +same module boundary: omitted `enableHost` atomically persists the bounded +host-plus-Docker scopes before returning an enabled-host command, while an +explicit false value persists only Docker-report authority before returning +the workload-only command. A persistence failure must continue to fail the +request rather than returning credentials whose scope contract was not saved. That same shared `internal/api/` dependency also assumes diagnostics memory source breakdowns backfill canonical fallback reasons even when a raw legacy snapshot reaches `internal/api/diagnostics.go` without one, so diff --git a/docs/release-control/v6/internal/subsystems/unified-resources.md b/docs/release-control/v6/internal/subsystems/unified-resources.md index e6ddf6cd1..705e6529c 100644 --- a/docs/release-control/v6/internal/subsystems/unified-resources.md +++ b/docs/release-control/v6/internal/subsystems/unified-resources.md @@ -17,6 +17,11 @@ Own canonical resource identity, type normalization, typed views, and cross-source deduplication. +One machine may belong to more than one typed view without becoming more than +one canonical resource. A matching Pulse host report and Docker report merge +into one `agent` resource with Agent and Docker facets, and the Hosts and +Docker-host views must return that same canonical ID. A Docker-only resource +keeps its `docker-host` contract type and is not admitted to the Hosts view. For Docker and Podman container resources, the canonical CPU metric represents host-capacity-normalized utilization. Runtime-native per-core CPU percent is kept on Docker metadata as raw evidence and must not replace the canonical diff --git a/frontend-modern/src/components/Settings/__tests__/InfrastructureOperationsModel.test.tsx b/frontend-modern/src/components/Settings/__tests__/InfrastructureOperationsModel.test.tsx index 243bfbc4b..c2c82245e 100644 --- a/frontend-modern/src/components/Settings/__tests__/InfrastructureOperationsModel.test.tsx +++ b/frontend-modern/src/components/Settings/__tests__/InfrastructureOperationsModel.test.tsx @@ -77,7 +77,7 @@ describe('infrastructure operations model', () => { detail: 'Auto-detect', category: 'default', }, - installFlags: ['--enable-docker', '--disable-host', '--enable-proxmox', '--proxmox-type pbs'], + installFlags: ['--enable-docker', '--enable-proxmox', '--proxmox-type pbs'], searchText: 'node-a node-a.internal agent-1', surfaces: [ { @@ -212,8 +212,9 @@ describe('infrastructure operations model', () => { expect(dockerProfile).toBeDefined(); expect(dockerProfile?.label).toBe('Docker / Podman runtime'); expect(dockerProfile?.description).toBe( - 'Force Docker / Podman monitoring when automatic detection is restricted.', + 'Force Docker / Podman monitoring when automatic detection is restricted, while keeping host telemetry enabled.', ); + expect(dockerProfile?.flags).toEqual(['--enable-docker']); expect(dockerProfile?.description).not.toContain('container runtime'); }); diff --git a/frontend-modern/src/components/Settings/__tests__/infrastructureOperationsModel.branchcov0723pm.test.ts b/frontend-modern/src/components/Settings/__tests__/infrastructureOperationsModel.branchcov0723pm.test.ts index 744c4ea6c..fe7d0620b 100644 --- a/frontend-modern/src/components/Settings/__tests__/infrastructureOperationsModel.branchcov0723pm.test.ts +++ b/frontend-modern/src/components/Settings/__tests__/infrastructureOperationsModel.branchcov0723pm.test.ts @@ -324,12 +324,12 @@ describe('isPlatformConnectionsCapability', () => { describe('installFlagsForCapabilities (via rowFromConnectedInfrastructureItem)', () => { const scope = { label: 'Default', category: 'default' as const }; - it('emits the docker host-disable flag pair for a docker-only item', () => { + it('enables Docker without silently disabling the host for a docker-only item', () => { const row = rowFromConnectedInfrastructureItem( makeItem({ surfaces: [connectedSurface('docker')] }), scope, ); - expect(row.installFlags).toEqual(['--enable-docker', '--disable-host']); + expect(row.installFlags).toEqual(['--enable-docker']); }); it('emits only --enable-kubernetes for a kubernetes-only item', () => { diff --git a/frontend-modern/src/components/Settings/infrastructureOperationsModel.tsx b/frontend-modern/src/components/Settings/infrastructureOperationsModel.tsx index 1dc837337..7c5d87ff2 100644 --- a/frontend-modern/src/components/Settings/infrastructureOperationsModel.tsx +++ b/frontend-modern/src/components/Settings/infrastructureOperationsModel.tsx @@ -304,8 +304,8 @@ export const INSTALL_PROFILE_OPTIONS: InstallProfileOption[] = [ { value: 'docker', label: `${DOCKER_PODMAN_SOURCE_LABEL} runtime`, - description: `Force ${DOCKER_PODMAN_SOURCE_LABEL} monitoring when automatic detection is restricted.`, - flags: ['--enable-docker', '--disable-host'], + description: `Force ${DOCKER_PODMAN_SOURCE_LABEL} monitoring when automatic detection is restricted, while keeping host telemetry enabled.`, + flags: ['--enable-docker'], }, { value: 'kubernetes', @@ -440,7 +440,6 @@ const installFlagsForCapabilities = (capabilities: AgentCapability[]) => { const flags = new Set(); if (capabilities.includes('docker')) { flags.add('--enable-docker'); - flags.add('--disable-host'); } if (capabilities.includes('kubernetes')) { flags.add('--enable-kubernetes'); diff --git a/internal/api/agent_install_command_shared.go b/internal/api/agent_install_command_shared.go index dbab19b7f..b65026dc2 100644 --- a/internal/api/agent_install_command_shared.go +++ b/internal/api/agent_install_command_shared.go @@ -260,15 +260,34 @@ func buildProxmoxAgentInstallCommand(opts agentInstallCommandOptions) string { return withPrivilegeEscalation(command) + tokenCleanup } -func buildContainerRuntimeAgentInstallCommand(baseURL string, token string) string { +func containerRuntimeAgentScopes(enableHost bool) []string { + scopes := []string{config.ScopeDockerReport} + if enableHost { + scopes = append(scopes, + config.ScopeAgentReport, + config.ScopeAgentConfigRead, + config.ScopeAgentManage, + ) + } + return scopes +} + +func containerRuntimeAgentHostFlag(enableHost bool) string { + if enableHost { + return "--enable-host" + } + return "--enable-host=false" +} + +func buildContainerRuntimeAgentInstallCommand(baseURL string, token string, enableHost bool) string { normalizedBaseURL := normalizeAgentInstallBaseURL(baseURL) installScriptURL := normalizedBaseURL + "/install.sh" command := fmt.Sprintf(`curl -fsSL %s | bash -s -- \ --url %s \ --enable-docker \ - --enable-host=false \ + %s \ --interval 30s`, - posixShellQuote(installScriptURL), posixShellQuote(normalizedBaseURL)) + posixShellQuote(installScriptURL), posixShellQuote(normalizedBaseURL), containerRuntimeAgentHostFlag(enableHost)) if trimmedToken := strings.TrimSpace(token); trimmedToken != "" { command += fmt.Sprintf(` \ diff --git a/internal/api/agent_install_command_shared_test.go b/internal/api/agent_install_command_shared_test.go index 10fc18df0..248c30782 100644 --- a/internal/api/agent_install_command_shared_test.go +++ b/internal/api/agent_install_command_shared_test.go @@ -124,13 +124,14 @@ func TestBuildProxmoxAgentInstallCommand_IncludesCommandsWhenRequested(t *testin } func TestBuildContainerRuntimeAgentInstallCommand_UsesLifecycleTransport(t *testing.T) { - command := buildContainerRuntimeAgentInstallCommand("https://pulse.example.com/base", "token-123") + command := buildContainerRuntimeAgentInstallCommand("https://pulse.example.com/base", "token-123", true) require.Contains(t, command, posixShellQuote("https://pulse.example.com/base/install.sh")) require.Contains(t, command, "--url "+posixShellQuote("https://pulse.example.com/base")) require.Contains(t, command, "--token "+posixShellQuote("token-123")) require.Contains(t, command, "--enable-docker") - require.Contains(t, command, "--enable-host=false") + require.Contains(t, command, "--enable-host") + require.NotContains(t, command, "--enable-host=false") require.Contains(t, command, "--interval 30s") require.Contains(t, command, `| { if [ "$(id -u)" -eq 0 ]; then bash -s --`) require.Contains(t, command, `elif command -v sudo >/dev/null 2>&1; then sudo bash -s --`) @@ -138,7 +139,7 @@ func TestBuildContainerRuntimeAgentInstallCommand_UsesLifecycleTransport(t *test } func TestBuildContainerRuntimeAgentInstallCommand_OmitsTokenAndAddsInsecureForHTTP(t *testing.T) { - command := buildContainerRuntimeAgentInstallCommand("http://pulse.example.com:7655/", "") + command := buildContainerRuntimeAgentInstallCommand("http://pulse.example.com:7655/", "", true) require.Contains(t, command, posixShellQuote("http://pulse.example.com:7655/install.sh")) require.Contains(t, command, "--url "+posixShellQuote("http://pulse.example.com:7655")) @@ -146,6 +147,25 @@ func TestBuildContainerRuntimeAgentInstallCommand_OmitsTokenAndAddsInsecureForHT require.Contains(t, command, "--insecure") } +func TestBuildContainerRuntimeAgentInstallCommand_PreservesWorkloadOnlyMode(t *testing.T) { + command := buildContainerRuntimeAgentInstallCommand("https://pulse.example.com", "token-123", false) + + require.Contains(t, command, "--enable-docker") + require.Contains(t, command, "--enable-host=false") +} + +func TestContainerRuntimeAgentScopesFollowHostMode(t *testing.T) { + dualMode := containerRuntimeAgentScopes(true) + require.ElementsMatch(t, []string{ + config.ScopeDockerReport, + config.ScopeAgentReport, + config.ScopeAgentConfigRead, + config.ScopeAgentManage, + }, dualMode) + + require.Equal(t, []string{config.ScopeDockerReport}, containerRuntimeAgentScopes(false)) +} + func TestBuildSetupScriptCommand_UsesFailFastQuotedTransport(t *testing.T) { command := buildSetupScriptCommand("https://pulse.example.com/api/setup-script?type=pve&host=pve1.local", "token-123") diff --git a/internal/api/contract_test.go b/internal/api/contract_test.go index 9f7b2c35a..88d222883 100644 --- a/internal/api/contract_test.go +++ b/internal/api/contract_test.go @@ -6414,13 +6414,13 @@ func TestContract_HostsShareResolvedIdentityTreatsLoopbackAliasAsSameNode(t *tes func TestContract_DiagnosticsDockerPrepareTokenInstallCommandUsesLifecycleTransport(t *testing.T) { baseURL := "https://pulse.example.com/base" - got := buildContainerRuntimeAgentInstallCommand(baseURL, "token-123") + got := buildContainerRuntimeAgentInstallCommand(baseURL, "token-123", true) if !strings.Contains(got, posixShellQuote(baseURL+"/install.sh")) { t.Fatalf("install command missing normalized install script URL: %s", got) } - if !strings.Contains(got, "--enable-host=false") { - t.Fatalf("install command missing canonical host-disable flag: %s", got) + if !strings.Contains(got, "--enable-host") || strings.Contains(got, "--enable-host=false") { + t.Fatalf("install command must default the native migration to host and Docker monitoring: %s", got) } if strings.Contains(got, "--disable-host") { t.Fatalf("install command preserved stale disable-host flag: %s", got) @@ -6434,7 +6434,7 @@ func TestContract_DiagnosticsDockerPrepareTokenInstallCommandUsesLifecycleTransp } func TestContract_DiagnosticsDockerPrepareTokenOptionalAuthInstallCommandOmitsToken(t *testing.T) { - got := buildContainerRuntimeAgentInstallCommand("http://pulse.example.com:7655/", "") + got := buildContainerRuntimeAgentInstallCommand("http://pulse.example.com:7655/", "", true) if strings.Contains(got, "--token") { t.Fatalf("optional-auth install command preserved token flag: %s", got) @@ -6444,6 +6444,17 @@ func TestContract_DiagnosticsDockerPrepareTokenOptionalAuthInstallCommandOmitsTo } } +func TestContract_DiagnosticsDockerPrepareTokenPreservesExplicitWorkloadOnlyMode(t *testing.T) { + got := buildContainerRuntimeAgentInstallCommand("https://pulse.example.com", "token-123", false) + + if !strings.Contains(got, "--enable-host=false") { + t.Fatalf("explicit workload-only install command missing canonical host-disable flag: %s", got) + } + if strings.Contains(got, "--disable-host") { + t.Fatalf("explicit workload-only install command preserved stale disable-host alias: %s", got) + } +} + func TestContract_SetupScriptURLCommandUsesFailFastQuotedTransport(t *testing.T) { url := "https://pulse.example.com/api/setup-script?type=pve&host=pve1.local" got := buildSetupScriptCommand(url, "token-123") diff --git a/internal/api/router.go b/internal/api/router.go index 250722d62..1e1eff936 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -10426,8 +10426,9 @@ func (r *Router) handleDiagnosticsDockerPrepareToken(w http.ResponseWriter, req } var payload struct { - AgentID string `json:"agentId"` - TokenName string `json:"tokenName"` + AgentID string `json:"agentId"` + TokenName string `json:"tokenName"` + EnableHost *bool `json:"enableHost,omitempty"` } if err := json.NewDecoder(req.Body).Decode(&payload); err != nil { @@ -10492,6 +10493,10 @@ func (r *Router) handleDiagnosticsDockerPrepareToken(w http.ResponseWriter, req displayName := preferredDockerHostName(host) name = fmt.Sprintf("Container runtime: %s", displayName) } + enableHost := true + if payload.EnableHost != nil { + enableHost = *payload.EnableHost + } rawToken, err := auth.GenerateAPIToken() if err != nil { @@ -10500,7 +10505,7 @@ func (r *Router) handleDiagnosticsDockerPrepareToken(w http.ResponseWriter, req return } - record, err := config.NewAPITokenRecord(rawToken, name, []string{config.ScopeDockerReport}) + record, err := config.NewAPITokenRecord(rawToken, name, containerRuntimeAgentScopes(enableHost)) if err != nil { log.Error().Err(err).Msg("Failed to construct token record for container runtime migration") writeErrorResponse(w, http.StatusInternalServerError, "token_generation_failed", "Failed to generate API token", nil) @@ -10552,8 +10557,8 @@ func (r *Router) handleDiagnosticsDockerPrepareToken(w http.ResponseWriter, req config.Mu.Unlock() baseURL := normalizeAgentInstallBaseURL(r.resolvePublicURL(req)) - installCommand := buildContainerRuntimeAgentInstallCommand(baseURL, rawToken) - systemdSnippet := fmt.Sprintf("[Service]\nType=simple\nEnvironment=\"PULSE_URL=%s\"\nEnvironment=\"PULSE_TOKEN=%s\"\nExecStart=/usr/local/bin/pulse-agent --url %s --token %s --enable-docker --enable-host=false --interval 30s\nRestart=always\nRestartSec=5s\nUser=root", baseURL, rawToken, baseURL, rawToken) + installCommand := buildContainerRuntimeAgentInstallCommand(baseURL, rawToken, enableHost) + systemdSnippet := fmt.Sprintf("[Service]\nType=simple\nEnvironment=\"PULSE_URL=%s\"\nEnvironment=\"PULSE_TOKEN=%s\"\nExecStart=/usr/local/bin/pulse-agent --url %s --token %s --enable-docker %s --interval 30s\nRestart=always\nRestartSec=5s\nUser=root", baseURL, rawToken, baseURL, rawToken, containerRuntimeAgentHostFlag(enableHost)) response := map[string]any{ "success": true, @@ -10566,6 +10571,7 @@ func (r *Router) handleDiagnosticsDockerPrepareToken(w http.ResponseWriter, req "installCommand": installCommand, "systemdServiceSnippet": systemdSnippet, "pulseURL": baseURL, + "enableHost": enableHost, } if err := utils.WriteJSONResponse(w, response); err != nil { diff --git a/internal/api/router_low_coverage_additional_test.go b/internal/api/router_low_coverage_additional_test.go index b7d881837..5c704ea9c 100644 --- a/internal/api/router_low_coverage_additional_test.go +++ b/internal/api/router_low_coverage_additional_test.go @@ -170,8 +170,9 @@ func TestHandleDiagnosticsDockerPrepareToken_Success(t *testing.T) { if !strings.Contains(payload["installCommand"].(string), "https://pulse.example.com") { t.Fatalf("expected install command to include base URL") } - if !strings.Contains(payload["installCommand"].(string), "--enable-host=false") { - t.Fatalf("expected install command to disable host metrics with the canonical lifecycle flag: %q", payload["installCommand"]) + if !strings.Contains(payload["installCommand"].(string), "--enable-host") || + strings.Contains(payload["installCommand"].(string), "--enable-host=false") { + t.Fatalf("expected install command to enable host metrics by default: %q", payload["installCommand"]) } if !strings.Contains(payload["installCommand"].(string), "--enable-docker") { t.Fatalf("expected install command to enable docker metrics: %q", payload["installCommand"]) @@ -182,12 +183,51 @@ func TestHandleDiagnosticsDockerPrepareToken_Success(t *testing.T) { if strings.Contains(payload["installCommand"].(string), "| sudo bash -s -- --url") { t.Fatalf("expected install command to preserve the governed root-or-sudo wrapper instead of a raw sudo pipe: %q", payload["installCommand"]) } - if !strings.Contains(payload["systemdServiceSnippet"].(string), "--enable-host=false") { - t.Fatalf("expected systemd snippet to disable host metrics: %q", payload["systemdServiceSnippet"]) + if !strings.Contains(payload["systemdServiceSnippet"].(string), "--enable-host") || + strings.Contains(payload["systemdServiceSnippet"].(string), "--enable-host=false") { + t.Fatalf("expected systemd snippet to enable host metrics by default: %q", payload["systemdServiceSnippet"]) } if len(router.config.APITokens) == 0 { t.Fatalf("expected API token to be recorded") } + token := router.config.APITokens[0] + for _, scope := range []string{config.ScopeDockerReport, config.ScopeAgentReport, config.ScopeAgentConfigRead, config.ScopeAgentManage} { + if !token.HasScope(scope) { + t.Fatalf("expected default migration token to include %q, got %#v", scope, token.Scopes) + } + } +} + +func TestHandleDiagnosticsDockerPrepareToken_PreservesExplicitWorkloadOnlyMode(t *testing.T) { + monitor, state, _ := newTestMonitor(t) + state.DockerHosts = []models.DockerHost{{ID: "host-1", DisplayName: "Docker Host"}} + + router := &Router{monitor: monitor, config: &config.Config{PublicURL: "https://pulse.example.com"}} + req := httptest.NewRequest(http.MethodPost, "/api/diagnostics/docker/prepare-token", strings.NewReader(`{"agentId":"host-1","enableHost":false}`)) + rec := httptest.NewRecorder() + + router.handleDiagnosticsDockerPrepareToken(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK) + } + var payload map[string]interface{} + if err := json.NewDecoder(rec.Body).Decode(&payload); err != nil { + t.Fatalf("decode response: %v", err) + } + if enabled, ok := payload["enableHost"].(bool); !ok || enabled { + t.Fatalf("expected enableHost=false, got %#v", payload["enableHost"]) + } + if !strings.Contains(payload["installCommand"].(string), "--enable-host=false") { + t.Fatalf("expected workload-only install command, got %q", payload["installCommand"]) + } + if len(router.config.APITokens) != 1 { + t.Fatalf("expected one migration token, got %d", len(router.config.APITokens)) + } + token := router.config.APITokens[0] + if !token.HasScope(config.ScopeDockerReport) || token.HasScope(config.ScopeAgentReport) { + t.Fatalf("expected Docker-only token scopes, got %#v", token.Scopes) + } } func TestHandleDiagnosticsDockerPrepareToken_NormalizesTrailingSlashPublicURL(t *testing.T) { diff --git a/internal/monitoring/monitor_host_agents_test.go b/internal/monitoring/monitor_host_agents_test.go index 9cd33609b..a2a22ef2b 100644 --- a/internal/monitoring/monitor_host_agents_test.go +++ b/internal/monitoring/monitor_host_agents_test.go @@ -458,6 +458,149 @@ func TestApplyHostReportRefreshesUnifiedReadStateWithoutBroadcast(t *testing.T) t.Fatal("accepted host report did not refresh the canonical headless read state") } +func TestUnifiedAgentHostAndDockerReportsShareOneCanonicalMachine(t *testing.T) { + now := time.Now().UTC() + hostReport := agentshost.Report{ + Agent: agentshost.AgentInfo{ + ID: "dual-mode-agent", + Version: "6.1.1", + IntervalSeconds: 30, + }, + Host: agentshost.HostInfo{ + ID: "dual-mode-machine", + MachineID: "dual-mode-machine", + Hostname: "docker-lxc.local", + Platform: "linux", + OSName: "debian", + }, + Timestamp: now, + } + dockerReport := agentsdocker.Report{ + Agent: agentsdocker.AgentInfo{ + ID: "dual-mode-agent", + Version: "6.1.1", + IntervalSeconds: 30, + }, + Host: agentsdocker.HostInfo{ + Hostname: "docker-lxc.local", + MachineID: "dual-mode-machine", + DockerVersion: "27.0.0", + TotalCPU: 4, + TotalMemoryBytes: 8 << 30, + }, + Containers: []agentsdocker.Container{{ + ID: "workload-1", + Name: "app", + State: "running", + }}, + Timestamp: now.Add(time.Second), + } + + for _, tc := range []struct { + name string + dockerFirst bool + }{ + {name: "host report first"}, + {name: "Docker report first", dockerFirst: true}, + } { + t.Run(tc.name, func(t *testing.T) { + monitor := newTestMonitor(t) + adapter := unifiedresources.NewMonitorAdapter(unifiedresources.NewRegistry(nil)) + monitor.SetResourceStore(adapter) + token := &config.APITokenRecord{ID: "dual-mode-token", Name: "Dual-mode Agent"} + + applyHost := func() { + t.Helper() + if _, err := monitor.ApplyHostReport(hostReport, token); err != nil { + t.Fatalf("ApplyHostReport: %v", err) + } + } + applyDocker := func() { + t.Helper() + if _, err := monitor.ApplyDockerReport(dockerReport, token); err != nil { + t.Fatalf("ApplyDockerReport: %v", err) + } + } + if tc.dockerFirst { + applyDocker() + applyHost() + } else { + applyHost() + applyDocker() + } + + resources := adapter.GetAll() + if len(resources) != 2 { + t.Fatalf("canonical resource count = %d, want one machine and one workload: %#v", len(resources), resources) + } + var machine *unifiedresources.Resource + for i := range resources { + if resources[i].Type == unifiedresources.ResourceTypeAgent { + machine = &resources[i] + break + } + } + if machine == nil { + t.Fatalf("canonical resources did not retain the machine: %#v", resources) + } + if machine.Agent == nil || machine.Docker == nil { + t.Fatalf("machine facets = agent:%v docker:%v, want both", machine.Agent != nil, machine.Docker != nil) + } + if got := unifiedresources.ContractResourceType(*machine); got != unifiedresources.ResourceTypeAgent { + t.Fatalf("machine contract type = %q, want agent", got) + } + + hostViews := adapter.Hosts() + dockerViews := adapter.DockerHosts() + if len(hostViews) != 1 || len(dockerViews) != 1 { + t.Fatalf("typed views = hosts:%d docker:%d, want one in each", len(hostViews), len(dockerViews)) + } + if hostViews[0].ID() != dockerViews[0].ID() || hostViews[0].ID() != machine.ID { + t.Fatalf("typed view identities diverged: host=%q docker=%q machine=%q", hostViews[0].ID(), dockerViews[0].ID(), machine.ID) + } + }) + } +} + +func TestDockerOnlyAgentRemainsWorkloadOnly(t *testing.T) { + monitor := newTestMonitor(t) + adapter := unifiedresources.NewMonitorAdapter(unifiedresources.NewRegistry(nil)) + monitor.SetResourceStore(adapter) + + _, err := monitor.ApplyDockerReport(agentsdocker.Report{ + Agent: agentsdocker.AgentInfo{ + ID: "workload-only-agent", + Version: "6.1.1", + IntervalSeconds: 30, + }, + Host: agentsdocker.HostInfo{ + Hostname: "runtime-only.local", + MachineID: "runtime-only-machine", + DockerVersion: "27.0.0", + TotalCPU: 2, + TotalMemoryBytes: 4 << 30, + }, + Timestamp: time.Now().UTC(), + }, &config.APITokenRecord{ID: "workload-only-token"}) + if err != nil { + t.Fatalf("ApplyDockerReport: %v", err) + } + + resources := adapter.GetAll() + if len(resources) != 1 { + t.Fatalf("canonical resource count = %d, want one Docker runtime: %#v", len(resources), resources) + } + if resources[0].Agent != nil || resources[0].Docker == nil { + t.Fatalf("workload-only facets = agent:%v docker:%v, want Docker only", resources[0].Agent != nil, resources[0].Docker != nil) + } + if got := unifiedresources.ContractResourceType(resources[0]); got != unifiedresources.ResourceType("docker-host") { + t.Fatalf("workload-only contract type = %q, want docker-host", got) + } + if len(adapter.Hosts()) != 0 || len(adapter.DockerHosts()) != 1 { + t.Fatalf("typed views = hosts:%d docker:%d, want workload-only runtime excluded from Hosts", len(adapter.Hosts()), len(adapter.DockerHosts())) + } +} + func TestApplyDockerReportMigratesAppContainerURLToStableNameAcceptedIngestProof(t *testing.T) { monitor := newTestMonitor(t) report := agentsdocker.Report{ diff --git a/scripts/install-container-agent.sh b/scripts/install-container-agent.sh index d91f14103..9d377c6f2 100755 --- a/scripts/install-container-agent.sh +++ b/scripts/install-container-agent.sh @@ -24,7 +24,10 @@ Supported options: Notes: This wrapper forwards to install.sh with: - --enable-docker --disable-host + --enable-docker --enable-host + + Use install.sh directly with --enable-host=false for an intentional + workload-only deployment. USAGE } @@ -91,7 +94,7 @@ fi if [[ "$UNINSTALL" == "true" ]]; then forward_args+=(--uninstall) else - forward_args+=(--enable-docker --disable-host) + forward_args+=(--enable-docker --enable-host) fi SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" diff --git a/scripts/installtests/build_release_assets_test.go b/scripts/installtests/build_release_assets_test.go index 8e4039853..25f784fed 100644 --- a/scripts/installtests/build_release_assets_test.go +++ b/scripts/installtests/build_release_assets_test.go @@ -777,7 +777,7 @@ func TestDockerAndDemoBuildsUseCanonicalReleaseLdflags(t *testing.T) { } } -func TestAgentRuntimeImagePersistsAgentIdentityByDefault(t *testing.T) { +func TestAgentRuntimeImageDefaultsToUnifiedHostAndDockerMonitoring(t *testing.T) { dockerfileBytes, err := os.ReadFile(repoFile("Dockerfile")) if err != nil { t.Fatalf("read Dockerfile: %v", err) @@ -787,7 +787,7 @@ func TestAgentRuntimeImagePersistsAgentIdentityByDefault(t *testing.T) { required := []string{ `mkdir -p /var/lib/pulse-agent`, `PULSE_DISABLE_AUTO_UPDATE=true`, - `PULSE_ENABLE_HOST=false`, + `PULSE_ENABLE_HOST=true`, `PULSE_ENABLE_DOCKER=true`, `PULSE_AGENT_ID_FILE=/var/lib/pulse-agent/agent-id`, `PULSE_STATE_DIR=/var/lib/pulse-agent`, @@ -796,9 +796,12 @@ func TestAgentRuntimeImagePersistsAgentIdentityByDefault(t *testing.T) { } for _, needle := range required { if !strings.Contains(dockerfile, needle) { - t.Fatalf("Dockerfile agent_runtime missing persistent identity contract: %s", needle) + t.Fatalf("Dockerfile agent_runtime missing unified host and Docker contract: %s", needle) } } + if strings.Contains(dockerfile, `PULSE_ENABLE_HOST=false`) { + t.Fatal("agent_runtime must not silently force every deployment into workload-only mode") + } if strings.Contains(dockerfile, `ENTRYPOINT ["/usr/local/bin/pulse-agent", "--enable-docker", "--enable-host=false"]`) { t.Fatal("agent_runtime must not hard-code module flags in ENTRYPOINT; env defaults keep user args overridable") } diff --git a/scripts/installtests/install_sh_test.go b/scripts/installtests/install_sh_test.go index 1e532db88..e5c2271e6 100644 --- a/scripts/installtests/install_sh_test.go +++ b/scripts/installtests/install_sh_test.go @@ -15,6 +15,24 @@ import ( "testing" ) +func TestContainerAgentCompatibilityWrapperDefaultsToUnifiedHostAndDockerMonitoring(t *testing.T) { + wrapperBytes, err := os.ReadFile(repoFile("scripts", "install-container-agent.sh")) + if err != nil { + t.Fatalf("read container-agent compatibility wrapper: %v", err) + } + wrapper := string(wrapperBytes) + + if !strings.Contains(wrapper, `forward_args+=(--enable-docker --enable-host)`) { + t.Fatal("container-agent compatibility wrapper must preserve host telemetry while enabling Docker") + } + if strings.Contains(wrapper, `forward_args+=(--enable-docker --disable-host)`) { + t.Fatal("container-agent compatibility wrapper must not silently force workload-only mode") + } + if !strings.Contains(wrapper, `--enable-host=false for an intentional`) { + t.Fatal("container-agent compatibility wrapper must document the explicit workload-only path") + } +} + func TestInstallSHAllowsMissingTokenForOptionalAuth(t *testing.T) { content, err := os.ReadFile(repoFile("scripts", "install.sh")) if err != nil {