Related to #712: auto-restore host agent binaries for download

This commit is contained in:
rcourtman 2025-11-20 15:45:21 +00:00
parent e467b3d6c0
commit 823508dc48
5 changed files with 251 additions and 130 deletions

View file

@ -12,6 +12,7 @@ import (
"syscall"
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/agentbinaries"
"github.com/rcourtman/pulse-go-rewrite/internal/alerts"
"github.com/rcourtman/pulse-go-rewrite/internal/api"
"github.com/rcourtman/pulse-go-rewrite/internal/config"
@ -104,7 +105,7 @@ func runServer() {
log.Info().Msg("Starting Pulse monitoring server")
// Validate agent binaries are available for download
validateAgentBinaries()
agentbinaries.EnsureHostAgentBinaries(Version)
// Create context that cancels on interrupt
ctx, cancel := context.WithCancel(context.Background())
@ -170,7 +171,7 @@ func runServer() {
}
return nil
}
router = api.NewRouter(cfg, reloadableMonitor.GetMonitor(), wsHub, reloadFunc)
router = api.NewRouter(cfg, reloadableMonitor.GetMonitor(), wsHub, reloadFunc, Version)
// Create HTTP server with unified configuration
// In production, serve everything (frontend + API) on the frontend port

View file

@ -1,4 +1,4 @@
package main
package agentbinaries
import (
"archive/tar"
@ -12,84 +12,47 @@ import (
"path/filepath"
"sort"
"strings"
"sync"
"time"
"github.com/rs/zerolog/log"
)
type hostAgentBinary struct {
platform string
arch string
filenames []string
type HostAgentBinary struct {
Platform string
Arch string
Filenames []string
}
var requiredHostAgentBinaries = []hostAgentBinary{
{platform: "linux", arch: "amd64", filenames: []string{"pulse-host-agent-linux-amd64"}},
{platform: "linux", arch: "arm64", filenames: []string{"pulse-host-agent-linux-arm64"}},
{platform: "linux", arch: "armv7", filenames: []string{"pulse-host-agent-linux-armv7"}},
{platform: "darwin", arch: "amd64", filenames: []string{"pulse-host-agent-darwin-amd64"}},
{platform: "darwin", arch: "arm64", filenames: []string{"pulse-host-agent-darwin-arm64"}},
var requiredHostAgentBinaries = []HostAgentBinary{
{Platform: "linux", Arch: "amd64", Filenames: []string{"pulse-host-agent-linux-amd64"}},
{Platform: "linux", Arch: "arm64", Filenames: []string{"pulse-host-agent-linux-arm64"}},
{Platform: "linux", Arch: "armv7", Filenames: []string{"pulse-host-agent-linux-armv7"}},
{Platform: "linux", Arch: "armv6", Filenames: []string{"pulse-host-agent-linux-armv6"}},
{Platform: "linux", Arch: "386", Filenames: []string{"pulse-host-agent-linux-386"}},
{Platform: "darwin", Arch: "amd64", Filenames: []string{"pulse-host-agent-darwin-amd64"}},
{Platform: "darwin", Arch: "arm64", Filenames: []string{"pulse-host-agent-darwin-arm64"}},
{
platform: "windows",
arch: "amd64",
filenames: []string{"pulse-host-agent-windows-amd64", "pulse-host-agent-windows-amd64.exe"},
Platform: "windows",
Arch: "amd64",
Filenames: []string{"pulse-host-agent-windows-amd64", "pulse-host-agent-windows-amd64.exe"},
},
{
platform: "windows",
arch: "arm64",
filenames: []string{"pulse-host-agent-windows-arm64", "pulse-host-agent-windows-arm64.exe"},
Platform: "windows",
Arch: "arm64",
Filenames: []string{"pulse-host-agent-windows-arm64", "pulse-host-agent-windows-arm64.exe"},
},
{
platform: "windows",
arch: "386",
filenames: []string{"pulse-host-agent-windows-386", "pulse-host-agent-windows-386.exe"},
Platform: "windows",
Arch: "386",
Filenames: []string{"pulse-host-agent-windows-386", "pulse-host-agent-windows-386.exe"},
},
}
func validateAgentBinaries() {
binDirs := hostAgentSearchPaths()
missing := findMissingHostAgentBinaries(binDirs)
if len(missing) == 0 {
log.Info().Msg("All host agent binaries available for download")
return
}
var downloadMu sync.Mutex
missingPlatforms := make([]string, 0, len(missing))
for key := range missing {
missingPlatforms = append(missingPlatforms, key)
}
sort.Strings(missingPlatforms)
log.Warn().
Strs("missing_platforms", missingPlatforms).
Msg("Host agent binaries missing - attempting to download bundle from GitHub release")
if err := downloadAndInstallHostAgentBinaries(binDirs[0]); err != nil {
log.Error().
Err(err).
Str("target_dir", binDirs[0]).
Strs("missing_platforms", missingPlatforms).
Msg("Failed to automatically install host agent binaries; install script downloads will fail")
return
}
remaining := findMissingHostAgentBinaries(binDirs)
if len(remaining) == 0 {
log.Info().Msg("Host agent binaries restored from GitHub release bundle")
return
}
stillMissing := make([]string, 0, len(remaining))
for key := range remaining {
stillMissing = append(stillMissing, key)
}
sort.Strings(stillMissing)
log.Warn().
Strs("missing_platforms", stillMissing).
Msg("Host agent binaries still missing after automatic restoration attempt")
}
func hostAgentSearchPaths() []string {
// HostAgentSearchPaths returns the directories to search for host agent binaries.
func HostAgentSearchPaths() []string {
primary := strings.TrimSpace(os.Getenv("PULSE_BIN_DIR"))
if primary == "" {
primary = "/opt/pulse/bin"
@ -109,32 +72,64 @@ func hostAgentSearchPaths() []string {
return result
}
func findMissingHostAgentBinaries(binDirs []string) map[string]hostAgentBinary {
missing := make(map[string]hostAgentBinary)
for _, binary := range requiredHostAgentBinaries {
if !hostAgentBinaryExists(binDirs, binary.filenames) {
key := fmt.Sprintf("%s-%s", binary.platform, binary.arch)
missing[key] = binary
}
// EnsureHostAgentBinaries verifies that all host agent binaries are present locally.
// If any are missing, it attempts to restore them from the matching GitHub release.
// The returned map contains any binaries that remain missing after the attempt.
func EnsureHostAgentBinaries(version string) map[string]HostAgentBinary {
binDirs := HostAgentSearchPaths()
missing := findMissingHostAgentBinaries(binDirs)
if len(missing) == 0 {
return nil
}
return missing
downloadMu.Lock()
defer downloadMu.Unlock()
// Re-check after acquiring the lock in case another goroutine restored them.
missing = findMissingHostAgentBinaries(binDirs)
if len(missing) == 0 {
return nil
}
missingPlatforms := make([]string, 0, len(missing))
for key := range missing {
missingPlatforms = append(missingPlatforms, key)
}
sort.Strings(missingPlatforms)
log.Warn().
Strs("missing_platforms", missingPlatforms).
Msg("Host agent binaries missing - attempting to download bundle from GitHub release")
if err := DownloadAndInstallHostAgentBinaries(version, binDirs[0]); err != nil {
log.Error().
Err(err).
Str("target_dir", binDirs[0]).
Strs("missing_platforms", missingPlatforms).
Msg("Failed to automatically install host agent binaries; download endpoints will return 404s")
return missing
}
if remaining := findMissingHostAgentBinaries(binDirs); len(remaining) > 0 {
stillMissing := make([]string, 0, len(remaining))
for key := range remaining {
stillMissing = append(stillMissing, key)
}
sort.Strings(stillMissing)
log.Warn().
Strs("missing_platforms", stillMissing).
Msg("Host agent binaries still missing after automatic restoration attempt")
return remaining
}
log.Info().Msg("Host agent binaries restored from GitHub release bundle")
return nil
}
func hostAgentBinaryExists(binDirs, filenames []string) bool {
for _, dir := range binDirs {
for _, name := range filenames {
path := filepath.Join(dir, name)
if info, err := os.Stat(path); err == nil && !info.IsDir() {
return true
}
}
}
return false
}
func downloadAndInstallHostAgentBinaries(targetDir string) error {
version := strings.TrimSpace(Version)
if version == "" || strings.EqualFold(version, "dev") {
// DownloadAndInstallHostAgentBinaries fetches the universal host agent bundle for the given version and installs it.
func DownloadAndInstallHostAgentBinaries(version string, targetDir string) error {
normalizedVersion := normalizeVersionTag(version)
if normalizedVersion == "" || strings.EqualFold(normalizedVersion, "vdev") {
return fmt.Errorf("cannot download host agent bundle for non-release version %q", version)
}
@ -142,7 +137,7 @@ func downloadAndInstallHostAgentBinaries(targetDir string) error {
return fmt.Errorf("failed to ensure bin directory %s: %w", targetDir, err)
}
url := fmt.Sprintf("https://github.com/rcourtman/Pulse/releases/download/%[1]s/pulse-%[1]s.tar.gz", version)
url := fmt.Sprintf("https://github.com/rcourtman/Pulse/releases/download/%[1]s/pulse-%[1]s.tar.gz", normalizedVersion)
tempFile, err := os.CreateTemp("", "pulse-host-agent-*.tar.gz")
if err != nil {
return fmt.Errorf("failed to create temporary archive file: %w", err)
@ -176,6 +171,38 @@ func downloadAndInstallHostAgentBinaries(targetDir string) error {
return nil
}
func findMissingHostAgentBinaries(binDirs []string) map[string]HostAgentBinary {
missing := make(map[string]HostAgentBinary)
for _, binary := range requiredHostAgentBinaries {
if !hostAgentBinaryExists(binDirs, binary.Filenames) {
key := fmt.Sprintf("%s-%s", binary.Platform, binary.Arch)
missing[key] = binary
}
}
return missing
}
func hostAgentBinaryExists(binDirs, filenames []string) bool {
for _, dir := range binDirs {
for _, name := range filenames {
path := filepath.Join(dir, name)
if info, err := os.Stat(path); err == nil && !info.IsDir() {
return true
}
}
}
return false
}
func normalizeVersionTag(version string) string {
v := strings.TrimSpace(version)
if v == "" {
return ""
}
v = strings.TrimPrefix(v, "v")
return "v" + v
}
func extractHostAgentBinaries(archivePath, targetDir string) error {
file, err := os.Open(archivePath)
if err != nil {

View file

@ -18,11 +18,13 @@ import (
"path/filepath"
"regexp"
"runtime"
"sort"
"strconv"
"strings"
"sync"
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/agentbinaries"
"github.com/rcourtman/pulse-go-rewrite/internal/auth"
"github.com/rcourtman/pulse-go-rewrite/internal/config"
"github.com/rcourtman/pulse-go-rewrite/internal/dockeragent"
@ -58,6 +60,7 @@ type Router struct {
oidcMu sync.Mutex
oidcService *OIDCService
wrapped http.Handler
serverVersion string
projectRoot string
// Cached system settings to avoid loading from disk on every request
settingsMu sync.RWMutex
@ -97,7 +100,7 @@ func isDirectLoopbackRequest(req *http.Request) bool {
}
// NewRouter creates a new router instance
func NewRouter(cfg *config.Config, monitor *monitoring.Monitor, wsHub *websocket.Hub, reloadFunc func() error) *Router {
func NewRouter(cfg *config.Config, monitor *monitoring.Monitor, wsHub *websocket.Hub, reloadFunc func() error, serverVersion string) *Router {
// Initialize persistent session and CSRF stores
InitSessionStore(cfg.DataPath)
InitCSRFStore(cfg.DataPath)
@ -125,6 +128,7 @@ func NewRouter(cfg *config.Config, monitor *monitoring.Monitor, wsHub *websocket
updateHistory: updateHistory,
exportLimiter: NewRateLimiter(5, 1*time.Minute), // 5 attempts per minute
persistence: config.NewConfigPersistence(cfg.DataPath),
serverVersion: strings.TrimSpace(serverVersion),
projectRoot: projectRoot,
}
@ -3394,36 +3398,53 @@ func (r *Router) handleDownloadHostAgent(w http.ResponseWriter, req *http.Reques
return
}
searchPaths := make([]string, 0, 12)
strictMode := platformParam != "" && archParam != ""
// Try platform-specific binary first
if strictMode {
searchPaths = append(searchPaths,
filepath.Join(pulseBinDir(), fmt.Sprintf("pulse-host-agent-%s-%s", platformParam, archParam)),
filepath.Join("/opt/pulse", fmt.Sprintf("pulse-host-agent-%s-%s", platformParam, archParam)),
filepath.Join("/app", fmt.Sprintf("pulse-host-agent-%s-%s", platformParam, archParam)),
)
checkedPaths, served := r.tryServeHostAgentBinary(w, req, platformParam, archParam)
if served {
return
}
if platformParam != "" && !strictMode {
searchPaths = append(searchPaths,
filepath.Join(pulseBinDir(), "pulse-host-agent-"+platformParam),
filepath.Join("/opt/pulse", "pulse-host-agent-"+platformParam),
filepath.Join("/app", "pulse-host-agent-"+platformParam),
)
remainingMissing := agentbinaries.EnsureHostAgentBinaries(r.serverVersion)
afterRestorePaths, served := r.tryServeHostAgentBinary(w, req, platformParam, archParam)
checkedPaths = append(checkedPaths, afterRestorePaths...)
if served {
return
}
// Default locations (host architecture) - only when no platform/arch specified
if !strictMode && platformParam == "" {
searchPaths = append(searchPaths,
filepath.Join(pulseBinDir(), "pulse-host-agent"),
"/opt/pulse/pulse-host-agent",
filepath.Join("/app", "pulse-host-agent"),
)
// Build detailed error message with troubleshooting guidance
var errorMsg strings.Builder
errorMsg.WriteString(fmt.Sprintf("Host agent binary not found for %s/%s\n\n", platformParam, archParam))
errorMsg.WriteString("Troubleshooting:\n")
errorMsg.WriteString("1. If running in Docker: Rebuild the Docker image to include all platform binaries\n")
errorMsg.WriteString("2. If running bare metal: Run 'scripts/build-release.sh' to build all platform binaries\n")
errorMsg.WriteString("3. Build from source:\n")
errorMsg.WriteString(fmt.Sprintf(" GOOS=%s GOARCH=%s go build -o pulse-host-agent-%s-%s ./cmd/pulse-host-agent\n", platformParam, archParam, platformParam, archParam))
errorMsg.WriteString(fmt.Sprintf(" sudo mv pulse-host-agent-%s-%s /opt/pulse/bin/\n\n", platformParam, archParam))
if len(remainingMissing) > 0 {
errorMsg.WriteString("Automatic repair attempted but the following binaries are still missing:\n")
for _, key := range sortedHostAgentKeys(remainingMissing) {
errorMsg.WriteString(fmt.Sprintf(" - %s\n", key))
}
if r.serverVersion != "" {
errorMsg.WriteString(fmt.Sprintf("Release bundle used: %s\n\n", strings.TrimSpace(r.serverVersion)))
} else {
errorMsg.WriteString("\n")
}
}
errorMsg.WriteString("Searched locations:\n")
for _, path := range dedupeStrings(checkedPaths) {
errorMsg.WriteString(fmt.Sprintf(" - %s\n", path))
}
http.Error(w, errorMsg.String(), http.StatusNotFound)
}
func (r *Router) tryServeHostAgentBinary(w http.ResponseWriter, req *http.Request, platformParam, archParam string) ([]string, bool) {
searchPaths := hostAgentSearchCandidates(platformParam, archParam)
checkedPaths := make([]string, 0, len(searchPaths)*2)
shouldCheckWindowsExe := func(path string) bool {
base := strings.ToLower(filepath.Base(path))
return strings.Contains(base, "windows") && !strings.HasSuffix(base, ".exe")
@ -3441,32 +3462,76 @@ func (r *Router) handleDownloadHostAgent(w http.ResponseWriter, req *http.Reques
for _, path := range pathsToCheck {
checkedPaths = append(checkedPaths, path)
if info, err := os.Stat(path); err == nil && !info.IsDir() {
// Check if this is a checksum request
if strings.HasSuffix(req.URL.Path, ".sha256") {
r.serveChecksum(w, req, path)
return
return checkedPaths, true
}
http.ServeFile(w, req, path)
return
return checkedPaths, true
}
}
}
// Build detailed error message with troubleshooting guidance
var errorMsg strings.Builder
errorMsg.WriteString(fmt.Sprintf("Host agent binary not found for %s/%s\n\n", platformParam, archParam))
errorMsg.WriteString("Troubleshooting:\n")
errorMsg.WriteString("1. If running in Docker: Rebuild the Docker image to include all platform binaries\n")
errorMsg.WriteString("2. If running bare metal: Run 'scripts/build-release.sh' to build all platform binaries\n")
errorMsg.WriteString("3. Build from source:\n")
errorMsg.WriteString(fmt.Sprintf(" GOOS=%s GOARCH=%s go build -o pulse-host-agent-%s-%s ./cmd/pulse-host-agent\n", platformParam, archParam, platformParam, archParam))
errorMsg.WriteString(fmt.Sprintf(" sudo mv pulse-host-agent-%s-%s /opt/pulse/bin/\n\n", platformParam, archParam))
errorMsg.WriteString("Searched locations:\n")
for _, path := range checkedPaths {
errorMsg.WriteString(fmt.Sprintf(" - %s\n", path))
return checkedPaths, false
}
func hostAgentSearchCandidates(platformParam, archParam string) []string {
searchPaths := make([]string, 0, 12)
strictMode := platformParam != "" && archParam != ""
if strictMode {
searchPaths = append(searchPaths,
filepath.Join(pulseBinDir(), fmt.Sprintf("pulse-host-agent-%s-%s", platformParam, archParam)),
filepath.Join("/opt/pulse", fmt.Sprintf("pulse-host-agent-%s-%s", platformParam, archParam)),
filepath.Join("/app", fmt.Sprintf("pulse-host-agent-%s-%s", platformParam, archParam)),
)
}
http.Error(w, errorMsg.String(), http.StatusNotFound)
if platformParam != "" && !strictMode {
searchPaths = append(searchPaths,
filepath.Join(pulseBinDir(), "pulse-host-agent-"+platformParam),
filepath.Join("/opt/pulse", "pulse-host-agent-"+platformParam),
filepath.Join("/app", "pulse-host-agent-"+platformParam),
)
}
if !strictMode && platformParam == "" {
searchPaths = append(searchPaths,
filepath.Join(pulseBinDir(), "pulse-host-agent"),
"/opt/pulse/pulse-host-agent",
filepath.Join("/app", "pulse-host-agent"),
)
}
return searchPaths
}
func dedupeStrings(values []string) []string {
seen := make(map[string]struct{}, len(values))
result := make([]string, 0, len(values))
for _, value := range values {
if value == "" {
continue
}
if _, ok := seen[value]; ok {
continue
}
seen[value] = struct{}{}
result = append(result, value)
}
return result
}
func sortedHostAgentKeys(missing map[string]agentbinaries.HostAgentBinary) []string {
if len(missing) == 0 {
return nil
}
keys := make([]string, 0, len(missing))
for key := range missing {
keys = append(keys, key)
}
sort.Strings(keys)
return keys
}
// serveChecksum computes and serves the SHA256 checksum of a file

View file

@ -40,6 +40,29 @@ func TestHandleDownloadHostAgentServesWindowsExe(t *testing.T) {
}
}
func TestHandleDownloadHostAgentServesLinuxArm64(t *testing.T) {
binDir := setupTempPulseBin(t)
filePath := filepath.Join(binDir, "pulse-host-agent-linux-arm64")
payload := []byte("arm64-binary")
if err := os.WriteFile(filePath, payload, 0o755); err != nil {
t.Fatalf("failed to write test binary: %v", err)
}
req := httptest.NewRequest(http.MethodGet, "/download/pulse-host-agent?platform=linux&arch=arm64", nil)
rr := httptest.NewRecorder()
router := &Router{}
router.handleDownloadHostAgent(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("expected 200 OK, got %d", rr.Code)
}
if got := rr.Body.String(); got != string(payload) {
t.Fatalf("unexpected response body: %q", got)
}
}
func TestHandleDownloadHostAgentServesChecksumForWindowsExe(t *testing.T) {
const (
arch = "unit-sha"

View file

@ -79,10 +79,15 @@ func newIntegrationServerWithConfig(t *testing.T, customize func(*config.Config)
return monitor.GetState().ToFrontend()
})
version := readRuntimeVersion(t)
if version == "" {
version = "dev"
}
router := api.NewRouter(cfg, monitor, hub, func() error {
monitor.SyncAlertState()
return nil
})
}, version)
srv := httptest.NewServer(router.Handler())
t.Cleanup(func() {