fix: resolve symlinks for agent self-update to avoid cross-device rename

When the agent binary is symlinked (e.g., from /opt/pulse/bin to
/usr/local/bin), the self-update would fail with "invalid cross-device
link" because os.Rename() doesn't work across filesystems.

Now resolves symlinks before update and creates the temp file in the
same directory as the real binary.

Related to #737
This commit is contained in:
rcourtman 2025-11-26 20:16:09 +00:00
parent c021dc6ca5
commit d2a303ed0c
2 changed files with 67 additions and 7 deletions

View file

@ -15,6 +15,7 @@ import (
"net/http"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"syscall"
@ -327,8 +328,17 @@ func (u *Updater) performUpdate(ctx context.Context) error {
// Verify checksum if provided
checksumHeader := strings.TrimSpace(resp.Header.Get("X-Checksum-Sha256"))
// Create temporary file
tmpFile, err := os.CreateTemp("", u.cfg.AgentName+"-*.tmp")
// Resolve symlinks to get the real path for atomic rename
realExecPath, err := filepath.EvalSymlinks(execPath)
if err != nil {
// Fall back to original path if symlink resolution fails
realExecPath = execPath
}
// 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, u.cfg.AgentName+"-*.tmp")
if err != nil {
return fmt.Errorf("failed to create temp file: %w", err)
}
@ -374,15 +384,15 @@ func (u *Updater) performUpdate(ctx context.Context) error {
return fmt.Errorf("failed to chmod: %w", err)
}
// Atomic replacement with backup
backupPath := execPath + ".backup"
if err := os.Rename(execPath, backupPath); err != nil {
// Atomic replacement with backup (use realExecPath for rename operations)
backupPath := realExecPath + ".backup"
if err := os.Rename(realExecPath, backupPath); err != nil {
return fmt.Errorf("failed to backup current binary: %w", err)
}
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)
}

View file

@ -340,3 +340,53 @@ func TestConstants(t *testing.T) {
t.Error("downloadTimeout should be positive")
}
}
func TestSymlinkResolution(t *testing.T) {
// Test that filepath.EvalSymlinks works as expected for our use case
tmpDir := t.TempDir()
// Create a real file
realFile := filepath.Join(tmpDir, "real", "binary")
if err := os.MkdirAll(filepath.Dir(realFile), 0755); err != nil {
t.Fatalf("failed to create dir: %v", err)
}
if err := os.WriteFile(realFile, []byte("test"), 0755); err != nil {
t.Fatalf("failed to create file: %v", err)
}
// Create a symlink to it
linkFile := filepath.Join(tmpDir, "link", "binary")
if err := os.MkdirAll(filepath.Dir(linkFile), 0755); err != nil {
t.Fatalf("failed to create link dir: %v", err)
}
if err := os.Symlink(realFile, linkFile); err != nil {
t.Fatalf("failed to create symlink: %v", err)
}
// EvalSymlinks should resolve to the real path
resolved, err := filepath.EvalSymlinks(linkFile)
if err != nil {
t.Fatalf("EvalSymlinks failed: %v", err)
}
if resolved != realFile {
t.Errorf("EvalSymlinks(%q) = %q, want %q", linkFile, resolved, realFile)
}
// Verify temp file in same dir as resolved path allows rename
targetDir := filepath.Dir(resolved)
tmpFile, err := os.CreateTemp(targetDir, "test-*.tmp")
if err != nil {
t.Fatalf("failed to create temp file: %v", err)
}
tmpPath := tmpFile.Name()
tmpFile.Close()
defer os.Remove(tmpPath)
// Rename should work since both files are on the same filesystem
newPath := filepath.Join(targetDir, "renamed")
if err := os.Rename(tmpPath, newPath); err != nil {
t.Errorf("Rename failed (same filesystem): %v", err)
}
os.Remove(newPath)
}