From 6ff5ae4200c9fad25b7aca4956c1fb869d54d5cb Mon Sep 17 00:00:00 2001 From: rcourtman Date: Thu, 27 Nov 2025 15:49:03 +0000 Subject: [PATCH] fix: multiple agent installation and update issues - Default enableDocker to false in UI to prevent unintended Docker agent activation on host-only installs (Related to #766) - Deploy agent scripts and binaries during web UI upgrades, not just the main binary (Related to #760) - Apply symlink resolution fix to standalone docker agent self-update to prevent cross-device rename failures (Related to #737) --- .../src/components/Settings/UnifiedAgents.tsx | 2 +- internal/dockeragent/agent.go | 38 ++++++-- internal/updates/manager.go | 87 +++++++++++++++++++ 3 files changed, 118 insertions(+), 9 deletions(-) diff --git a/frontend-modern/src/components/Settings/UnifiedAgents.tsx b/frontend-modern/src/components/Settings/UnifiedAgents.tsx index 4ad6cb4..ed95053 100644 --- a/frontend-modern/src/components/Settings/UnifiedAgents.tsx +++ b/frontend-modern/src/components/Settings/UnifiedAgents.tsx @@ -107,7 +107,7 @@ export const UnifiedAgents: Component = () => { const [lookupResult, setLookupResult] = createSignal(null); const [lookupError, setLookupError] = createSignal(null); const [lookupLoading, setLookupLoading] = createSignal(false); - const [enableDocker, setEnableDocker] = createSignal(true); // Default to true for unified experience + const [enableDocker, setEnableDocker] = createSignal(false); // Default to false - user must opt-in for Docker monitoring createEffect(() => { if (requiresToken()) { diff --git a/internal/dockeragent/agent.go b/internal/dockeragent/agent.go index 2bf18be..7c2f3a9 100644 --- a/internal/dockeragent/agent.go +++ b/internal/dockeragent/agent.go @@ -16,6 +16,7 @@ import ( "net/http" "os" "os/exec" + "path/filepath" "runtime" "strconv" "strings" @@ -1640,6 +1641,12 @@ func isUnraid() bool { return err == nil } +// resolveSymlink resolves symlinks to get the real path of a file. +// This is needed for self-update because os.Rename() fails across filesystems. +func resolveSymlink(path string) (string, error) { + return filepath.EvalSymlinks(path) +} + // verifyELFMagic checks that the file is a valid ELF binary func verifyELFMagic(path string) error { f, err := os.Open(path) @@ -1701,6 +1708,19 @@ func (a *Agent) selfUpdate(ctx context.Context) error { return fmt.Errorf("failed to get executable path: %w", err) } + // Resolve symlinks to get the real path for atomic rename + // os.Rename() fails across filesystems, so we need the actual target path + realExecPath, err := resolveSymlink(execPath) + if err != nil { + a.logger.Debug().Err(err).Str("path", execPath).Msg("Failed to resolve symlinks, using original path") + realExecPath = execPath + } else if realExecPath != execPath { + a.logger.Debug(). + Str("symlink", execPath). + Str("target", realExecPath). + Msg("Resolved symlink for self-update") + } + downloadBase := strings.TrimRight(target.URL, "/") + "/download/pulse-docker-agent" archParam := determineSelfUpdateArch() @@ -1767,10 +1787,12 @@ func (a *Agent) selfUpdate(ctx context.Context) error { checksumHeader := strings.TrimSpace(resp.Header.Get("X-Checksum-Sha256")) - // Create temporary file - tmpFile, err := os.CreateTemp("", "pulse-docker-agent-*.tmp") + // Create temporary file in the same directory as the target binary + // to ensure atomic rename works (os.Rename fails across filesystems) + targetDir := filepath.Dir(realExecPath) + tmpFile, err := os.CreateTemp(targetDir, "pulse-docker-agent-*.tmp") if err != nil { - return fmt.Errorf("failed to create temp file: %w", err) + return fmt.Errorf("failed to create temp file in %s: %w", targetDir, err) } tmpPath := tmpFile.Name() defer os.Remove(tmpPath) // Clean up if something goes wrong @@ -1815,16 +1837,16 @@ func (a *Agent) selfUpdate(ctx context.Context) error { return fmt.Errorf("failed to make temp file executable: %w", err) } - // Create backup of current binary - backupPath := execPath + ".backup" - if err := os.Rename(execPath, backupPath); err != nil { + // Create backup of current binary (use realExecPath for atomic operations) + backupPath := realExecPath + ".backup" + if err := os.Rename(realExecPath, backupPath); err != nil { return fmt.Errorf("failed to backup current binary: %w", err) } // Move new binary to current location - if err := os.Rename(tmpPath, execPath); err != nil { + if err := os.Rename(tmpPath, realExecPath); err != nil { // Restore backup on failure - os.Rename(backupPath, execPath) + os.Rename(backupPath, realExecPath) return fmt.Errorf("failed to replace binary: %w", err) } diff --git a/internal/updates/manager.go b/internal/updates/manager.go index b135574..48e629d 100644 --- a/internal/updates/manager.go +++ b/internal/updates/manager.go @@ -1229,6 +1229,93 @@ func (m *Manager) applyUpdateFiles(extractDir string) error { } } + // Deploy agent installation scripts from tarball + scriptsDir := filepath.Join(extractDir, "scripts") + if _, err := os.Stat(scriptsDir); err == nil { + destScriptsDir := "/opt/pulse/scripts" + if err := os.MkdirAll(destScriptsDir, 0755); err != nil { + log.Warn().Err(err).Msg("Failed to create scripts directory") + } else { + // List of agent scripts to deploy + agentScripts := []string{ + "install-docker-agent.sh", + "install-container-agent.sh", + "install-host-agent.ps1", + "uninstall-host-agent.sh", + "uninstall-host-agent.ps1", + "install-sensor-proxy.sh", + "install-docker.sh", + "install.sh", + "install.ps1", + } + + deployed := 0 + for _, script := range agentScripts { + srcPath := filepath.Join(scriptsDir, script) + if _, err := os.Stat(srcPath); err == nil { + destPath := filepath.Join(destScriptsDir, script) + cmd = exec.Command("cp", srcPath, destPath) + if err := cmd.Run(); err != nil { + log.Warn().Err(err).Str("script", script).Msg("Failed to copy agent script") + continue + } + if err := os.Chmod(destPath, 0755); err != nil { + log.Warn().Err(err).Str("script", script).Msg("Failed to set script permissions") + } + deployed++ + } + } + if deployed > 0 { + log.Info().Int("count", deployed).Msg("Deployed agent installation scripts") + } + } + } + + // Deploy agent binaries from tarball (for serving to remote hosts) + binDir := filepath.Join(extractDir, "bin") + if _, err := os.Stat(binDir); err == nil { + destBinDir := "/opt/pulse/bin" + if err := os.MkdirAll(destBinDir, 0755); err != nil { + log.Warn().Err(err).Msg("Failed to create bin directory") + } else { + // Copy agent binaries (pulse-agent-*, pulse-docker-agent-*, pulse-host-agent-*) + entries, err := os.ReadDir(binDir) + if err == nil { + agentBinariesDeployed := 0 + for _, entry := range entries { + name := entry.Name() + // Skip the main pulse binary (already handled above) and directories + if entry.IsDir() || name == "pulse" { + continue + } + // Copy agent binaries + if strings.HasPrefix(name, "pulse-agent-") || + strings.HasPrefix(name, "pulse-docker-agent") || + strings.HasPrefix(name, "pulse-host-agent") || + name == "pulse-sensor-proxy" { + srcPath := filepath.Join(binDir, name) + destPath := filepath.Join(destBinDir, name) + cmd = exec.Command("cp", "-a", srcPath, destPath) + if err := cmd.Run(); err != nil { + log.Warn().Err(err).Str("binary", name).Msg("Failed to copy agent binary") + continue + } + // Set executable permission (skip for symlinks) + if info, err := os.Lstat(destPath); err == nil && info.Mode()&os.ModeSymlink == 0 { + if err := os.Chmod(destPath, 0755); err != nil { + log.Warn().Err(err).Str("binary", name).Msg("Failed to set binary permissions") + } + } + agentBinariesDeployed++ + } + } + if agentBinariesDeployed > 0 { + log.Info().Int("count", agentBinariesDeployed).Msg("Deployed agent binaries") + } + } + } + } + // Set ownership if /opt/pulse exists if _, err := os.Stat("/opt/pulse"); err == nil { cmd = exec.Command("chown", "-R", "pulse:pulse", "/opt/pulse")