101 lines
2.9 KiB
Go
101 lines
2.9 KiB
Go
// Package updater carries the agent's self-update logic.
|
|
//
|
|
// The flow is operator-driven: the server dispatches a command.update
|
|
// WS envelope, the agent fetches a fresh binary from the server's
|
|
// /agent/binary endpoint, atomic-renames it over the running binary
|
|
// (Linux) or hands off to a detached helper script (Windows), and
|
|
// exits cleanly so the service manager restarts under the new
|
|
// binary. See docs/superpowers/specs/2026-05-06-p6-01-02-...
|
|
//
|
|
// Platform-specific code is build-tagged into updater_unix.go /
|
|
// updater_windows.go. This file holds the shared HTTP fetch + path
|
|
// helpers + the test seam.
|
|
package updater
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"runtime"
|
|
"time"
|
|
)
|
|
|
|
// fetch downloads the new binary into <binaryPath>.new, fsyncs, chmods.
|
|
// Returns the path of the staged file (always binaryPath + ".new").
|
|
func fetch(ctx context.Context, serverURL, binaryPath string) (string, error) {
|
|
url := fmt.Sprintf("%s/agent/binary?os=%s&arch=%s", serverURL, runtime.GOOS, runtime.GOARCH)
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
c := &http.Client{Timeout: 5 * time.Minute}
|
|
res, err := c.Do(req)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
defer func() { _ = res.Body.Close() }()
|
|
if res.StatusCode != http.StatusOK {
|
|
return "", fmt.Errorf("agent binary fetch: %s", res.Status)
|
|
}
|
|
|
|
stagePath := binaryPath + ".new"
|
|
f, err := os.OpenFile(stagePath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o755)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if _, copyErr := io.Copy(f, res.Body); copyErr != nil {
|
|
_ = f.Close()
|
|
_ = os.Remove(stagePath)
|
|
return "", copyErr
|
|
}
|
|
if syncErr := f.Sync(); syncErr != nil {
|
|
_ = f.Close()
|
|
_ = os.Remove(stagePath)
|
|
return "", syncErr
|
|
}
|
|
if closeErr := f.Close(); closeErr != nil {
|
|
_ = os.Remove(stagePath)
|
|
return "", closeErr
|
|
}
|
|
if err := os.Chmod(stagePath, 0o755); err != nil {
|
|
_ = os.Remove(stagePath)
|
|
return "", err
|
|
}
|
|
return stagePath, nil
|
|
}
|
|
|
|
// resolveOwnBinary returns the absolute path of the running binary.
|
|
// Refuses /proc/self/exe — that's what os.Executable returns on some
|
|
// systems but the path can't be renamed across.
|
|
func resolveOwnBinary() (string, error) {
|
|
p, err := os.Executable()
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
abs, err := filepath.Abs(p)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if abs == "/proc/self/exe" {
|
|
return "", fmt.Errorf("cannot resolve own binary path (/proc/self/exe)")
|
|
}
|
|
return abs, nil
|
|
}
|
|
|
|
// UpdateForTest is the platform-neutral test seam. In production the
|
|
// platform-specific Update fetches, swaps, then exits the process.
|
|
// UpdateForTest stops short of the exit so unit tests can assert on
|
|
// file state.
|
|
func UpdateForTest(serverURL, binaryPath string) error {
|
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
|
|
defer cancel()
|
|
stage, err := fetch(ctx, serverURL, binaryPath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return swap(stage, binaryPath)
|
|
}
|