diff --git a/.gitignore b/.gitignore index 4cec9cc..528ac36 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,8 @@ /pulse-test /pulse-host-agent /pulse-host-agent-* +/pulse-agent +/pulse-agent-* # Logs *.log diff --git a/Dockerfile b/Dockerfile index 9d984c7..86ecd7e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -136,6 +136,51 @@ RUN --mount=type=cache,id=pulse-go-mod,target=/go/pkg/mod \ -trimpath \ -o pulse-host-agent-windows-386.exe ./cmd/pulse-host-agent +# Build unified agent binaries for all platforms (for download endpoint) +RUN --mount=type=cache,id=pulse-go-mod,target=/go/pkg/mod \ + --mount=type=cache,id=pulse-go-build,target=/root/.cache/go-build \ + VERSION="v$(cat VERSION | tr -d '\n')" && \ + CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build \ + -ldflags="-s -w -X main.Version=${VERSION}" \ + -trimpath \ + -o pulse-agent-linux-amd64 ./cmd/pulse-agent && \ + CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build \ + -ldflags="-s -w -X main.Version=${VERSION}" \ + -trimpath \ + -o pulse-agent-linux-arm64 ./cmd/pulse-agent && \ + CGO_ENABLED=0 GOOS=linux GOARCH=arm GOARM=7 go build \ + -ldflags="-s -w -X main.Version=${VERSION}" \ + -trimpath \ + -o pulse-agent-linux-armv7 ./cmd/pulse-agent && \ + CGO_ENABLED=0 GOOS=linux GOARCH=arm GOARM=6 go build \ + -ldflags="-s -w -X main.Version=${VERSION}" \ + -trimpath \ + -o pulse-agent-linux-armv6 ./cmd/pulse-agent && \ + CGO_ENABLED=0 GOOS=linux GOARCH=386 go build \ + -ldflags="-s -w -X main.Version=${VERSION}" \ + -trimpath \ + -o pulse-agent-linux-386 ./cmd/pulse-agent && \ + CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 go build \ + -ldflags="-s -w -X main.Version=${VERSION}" \ + -trimpath \ + -o pulse-agent-darwin-amd64 ./cmd/pulse-agent && \ + CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 go build \ + -ldflags="-s -w -X main.Version=${VERSION}" \ + -trimpath \ + -o pulse-agent-darwin-arm64 ./cmd/pulse-agent && \ + CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build \ + -ldflags="-s -w -X main.Version=${VERSION}" \ + -trimpath \ + -o pulse-agent-windows-amd64.exe ./cmd/pulse-agent && \ + CGO_ENABLED=0 GOOS=windows GOARCH=arm64 go build \ + -ldflags="-s -w -X main.Version=${VERSION}" \ + -trimpath \ + -o pulse-agent-windows-arm64.exe ./cmd/pulse-agent && \ + CGO_ENABLED=0 GOOS=windows GOARCH=386 go build \ + -ldflags="-s -w -X main.Version=${VERSION}" \ + -trimpath \ + -o pulse-agent-windows-386.exe ./cmd/pulse-agent + # Build pulse-sensor-proxy for all Linux architectures (for download endpoint) RUN --mount=type=cache,id=pulse-go-mod,target=/go/pkg/mod \ --mount=type=cache,id=pulse-go-build,target=/root/.cache/go-build \ @@ -224,7 +269,9 @@ COPY scripts/uninstall-host-agent.sh /opt/pulse/scripts/uninstall-host-agent.sh COPY scripts/uninstall-host-agent.ps1 /opt/pulse/scripts/uninstall-host-agent.ps1 COPY scripts/install-sensor-proxy.sh /opt/pulse/scripts/install-sensor-proxy.sh COPY scripts/install-docker.sh /opt/pulse/scripts/install-docker.sh -RUN chmod 755 /opt/pulse/scripts/install-docker-agent.sh /opt/pulse/scripts/install-container-agent.sh /opt/pulse/scripts/install-host-agent.sh /opt/pulse/scripts/install-host-agent.ps1 /opt/pulse/scripts/uninstall-host-agent.sh /opt/pulse/scripts/uninstall-host-agent.ps1 /opt/pulse/scripts/install-sensor-proxy.sh /opt/pulse/scripts/install-docker.sh +COPY scripts/install.sh /opt/pulse/scripts/install.sh +COPY scripts/install.ps1 /opt/pulse/scripts/install.ps1 +RUN chmod 755 /opt/pulse/scripts/*.sh /opt/pulse/scripts/*.ps1 # Copy all binaries for download endpoint RUN mkdir -p /opt/pulse/bin @@ -256,6 +303,22 @@ RUN ln -s pulse-host-agent-windows-amd64.exe /opt/pulse/bin/pulse-host-agent-win ln -s pulse-host-agent-windows-arm64.exe /opt/pulse/bin/pulse-host-agent-windows-arm64 && \ ln -s pulse-host-agent-windows-386.exe /opt/pulse/bin/pulse-host-agent-windows-386 +# Unified agent binaries (all platforms and architectures) +COPY --from=backend-builder /app/pulse-agent-linux-amd64 /opt/pulse/bin/ +COPY --from=backend-builder /app/pulse-agent-linux-arm64 /opt/pulse/bin/ +COPY --from=backend-builder /app/pulse-agent-linux-armv7 /opt/pulse/bin/ +COPY --from=backend-builder /app/pulse-agent-linux-armv6 /opt/pulse/bin/ +COPY --from=backend-builder /app/pulse-agent-linux-386 /opt/pulse/bin/ +COPY --from=backend-builder /app/pulse-agent-darwin-amd64 /opt/pulse/bin/ +COPY --from=backend-builder /app/pulse-agent-darwin-arm64 /opt/pulse/bin/ +COPY --from=backend-builder /app/pulse-agent-windows-amd64.exe /opt/pulse/bin/ +COPY --from=backend-builder /app/pulse-agent-windows-arm64.exe /opt/pulse/bin/ +COPY --from=backend-builder /app/pulse-agent-windows-386.exe /opt/pulse/bin/ +# Create symlinks for Windows without .exe extension +RUN ln -s pulse-agent-windows-amd64.exe /opt/pulse/bin/pulse-agent-windows-amd64 && \ + ln -s pulse-agent-windows-arm64.exe /opt/pulse/bin/pulse-agent-windows-arm64 && \ + ln -s pulse-agent-windows-386.exe /opt/pulse/bin/pulse-agent-windows-386 + # Sensor proxy binaries (all Linux architectures) COPY --from=backend-builder /app/pulse-sensor-proxy-linux-amd64 /opt/pulse/bin/ COPY --from=backend-builder /app/pulse-sensor-proxy-linux-arm64 /opt/pulse/bin/ diff --git a/cmd/pulse-agent/main.go b/cmd/pulse-agent/main.go index 5a4403f..7a7fa56 100644 --- a/cmd/pulse-agent/main.go +++ b/cmd/pulse-agent/main.go @@ -10,6 +10,7 @@ import ( "syscall" "time" + "github.com/rcourtman/pulse-go-rewrite/internal/agentupdate" "github.com/rcourtman/pulse-go-rewrite/internal/dockeragent" "github.com/rcourtman/pulse-go-rewrite/internal/hostagent" "github.com/rcourtman/pulse-go-rewrite/internal/utils" @@ -52,9 +53,27 @@ func main() { Str("pulse_url", cfg.PulseURL). Bool("host_agent", cfg.EnableHost). Bool("docker_agent", cfg.EnableDocker). + Bool("auto_update", !cfg.DisableAutoUpdate). Msg("Starting Pulse Unified Agent") - // 4. Start Host Agent (if enabled) + // 4. Start Auto-Updater + updater := agentupdate.New(agentupdate.Config{ + PulseURL: cfg.PulseURL, + APIToken: cfg.APIToken, + AgentName: "pulse-agent", + CurrentVersion: Version, + CheckInterval: 1 * time.Hour, + InsecureSkipVerify: cfg.InsecureSkipVerify, + Logger: &logger, + Disabled: cfg.DisableAutoUpdate, + }) + + g.Go(func() error { + updater.RunLoop(ctx) + return nil + }) + + // 5. Start Host Agent (if enabled) if cfg.EnableHost { hostCfg := hostagent.Config{ PulseURL: cfg.PulseURL, @@ -138,6 +157,9 @@ type Config struct { // Module flags EnableHost bool EnableDocker bool + + // Auto-update + DisableAutoUpdate bool } func loadConfig() Config { @@ -152,6 +174,7 @@ func loadConfig() Config { envLogLevel := utils.GetenvTrim("LOG_LEVEL") envEnableHost := utils.GetenvTrim("PULSE_ENABLE_HOST") envEnableDocker := utils.GetenvTrim("PULSE_ENABLE_DOCKER") + envDisableAutoUpdate := utils.GetenvTrim("PULSE_DISABLE_AUTO_UPDATE") // Defaults defaultInterval := 30 * time.Second @@ -182,12 +205,19 @@ func loadConfig() Config { enableHostFlag := flag.Bool("enable-host", defaultEnableHost, "Enable Host Agent module") enableDockerFlag := flag.Bool("enable-docker", defaultEnableDocker, "Enable Docker Agent module") + disableAutoUpdateFlag := flag.Bool("disable-auto-update", utils.ParseBool(envDisableAutoUpdate), "Disable automatic updates") + showVersion := flag.Bool("version", false, "Print the agent version and exit") var tagFlags multiValue flag.Var(&tagFlags, "tag", "Tag to apply (repeatable)") flag.Parse() + if *showVersion { + fmt.Println(Version) + os.Exit(0) + } + // Validation pulseURL := strings.TrimSpace(*urlFlag) if pulseURL == "" { @@ -218,6 +248,7 @@ func loadConfig() Config { LogLevel: logLevel, EnableHost: *enableHostFlag, EnableDocker: *enableDockerFlag, + DisableAutoUpdate: *disableAutoUpdateFlag, } } diff --git a/docs/UNIFIED_AGENT.md b/docs/UNIFIED_AGENT.md new file mode 100644 index 0000000..c652775 --- /dev/null +++ b/docs/UNIFIED_AGENT.md @@ -0,0 +1,139 @@ +# Pulse Unified Agent + +The unified agent (`pulse-agent`) combines host and Docker monitoring into a single binary. It replaces the separate `pulse-host-agent` and `pulse-docker-agent` for simpler deployment and management. + +## Quick Start + +Generate an installation command in the UI: +**Settings > Agents > "Install New Agent"** + +### Linux (systemd) +```bash +curl -fsSL http://:7655/install.sh | \ + bash -s -- --url http://:7655 --token +``` + +### macOS +```bash +curl -fsSL http://:7655/install.sh | \ + bash -s -- --url http://:7655 --token +``` + +### Synology NAS +```bash +curl -fsSL http://:7655/install.sh | \ + bash -s -- --url http://:7655 --token +``` + +## Features + +- **Host Metrics**: CPU, memory, disk, network I/O, temperatures +- **Docker Monitoring**: Container metrics, health checks, Swarm support (when enabled) +- **Auto-Update**: Automatically updates when a new version is released +- **Multi-Platform**: Linux, macOS, Windows support + +## Configuration + +| Flag | Env Var | Description | Default | +|------|---------|-------------|---------| +| `--url` | `PULSE_URL` | Pulse server URL | `http://localhost:7655` | +| `--token` | `PULSE_TOKEN` | API token | *(required)* | +| `--interval` | `PULSE_INTERVAL` | Reporting interval | `30s` | +| `--enable-host` | `PULSE_ENABLE_HOST` | Enable host metrics | `true` | +| `--enable-docker` | `PULSE_ENABLE_DOCKER` | Enable Docker metrics | `false` | +| `--disable-auto-update` | `PULSE_DISABLE_AUTO_UPDATE` | Disable auto-updates | `false` | +| `--insecure` | `PULSE_INSECURE_SKIP_VERIFY` | Skip TLS verification | `false` | +| `--hostname` | `PULSE_HOSTNAME` | Override hostname | *(OS hostname)* | +| `--agent-id` | `PULSE_AGENT_ID` | Unique agent identifier | *(machine-id)* | + +## Installation Options + +### Host Monitoring Only (default) +```bash +curl -fsSL http://:7655/install.sh | \ + bash -s -- --url http://:7655 --token +``` + +### Host + Docker Monitoring +```bash +curl -fsSL http://:7655/install.sh | \ + bash -s -- --url http://:7655 --token --enable-docker +``` + +### Docker Monitoring Only +```bash +curl -fsSL http://:7655/install.sh | \ + bash -s -- --url http://:7655 --token --disable-host --enable-docker +``` + +## Auto-Update + +The unified agent automatically checks for updates every hour. When a new version is available: + +1. Agent downloads the new binary from the Pulse server +2. Verifies the checksum +3. Replaces itself atomically (with backup) +4. Restarts with the same configuration + +To disable auto-updates: +```bash +# During installation +curl -fsSL http://:7655/install.sh | \ + bash -s -- --url http://:7655 --token --disable-auto-update + +# Or set environment variable +PULSE_DISABLE_AUTO_UPDATE=true +``` + +## Uninstall + +```bash +curl -fsSL http://:7655/install.sh | bash -s -- --uninstall +``` + +This removes: +- The agent binary +- The systemd/launchd service +- Any legacy agents (pulse-host-agent, pulse-docker-agent) + +## Migration from Legacy Agents + +The install script automatically removes legacy agents when installing the unified agent: +- `pulse-host-agent` service is stopped and removed +- `pulse-docker-agent` service is stopped and removed +- Binaries are deleted from `/usr/local/bin/` + +No manual cleanup is required. + +## Troubleshooting + +### Agent Not Updating +- Check logs: `journalctl -u pulse-agent -f` +- Verify network connectivity to Pulse server +- Ensure auto-update is not disabled + +### Duplicate Hosts +If cloned VMs appear as the same host: +```bash +sudo rm /etc/machine-id && sudo systemd-machine-id-setup +``` + +Or set a unique agent ID: +```bash +--agent-id my-unique-host-id +``` + +### Permission Denied (Docker) +Ensure the agent can access the Docker socket: +```bash +sudo usermod -aG docker $USER +``` + +### Check Status +```bash +# Linux +systemctl status pulse-agent + +# macOS +launchctl list | grep pulse +``` diff --git a/internal/agentupdate/update.go b/internal/agentupdate/update.go new file mode 100644 index 0000000..b1fc101 --- /dev/null +++ b/internal/agentupdate/update.go @@ -0,0 +1,361 @@ +// Package agentupdate provides self-update functionality for Pulse agents. +// It handles checking for new versions, downloading binaries, and performing +// atomic binary replacement with rollback support. +package agentupdate + +import ( + "context" + "crypto/sha256" + "crypto/tls" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "os" + "os/exec" + "runtime" + "strings" + "syscall" + "time" + + "github.com/rs/zerolog" +) + +// Config holds the configuration for the updater. +type Config struct { + // PulseURL is the base URL of the Pulse server (e.g., "https://pulse.example.com:7655") + PulseURL string + + // APIToken is the authentication token for the Pulse server + APIToken string + + // AgentName is the name of the agent binary to download (e.g., "pulse-agent", "pulse-docker-agent") + AgentName string + + // CurrentVersion is the version currently running + CurrentVersion string + + // CheckInterval is how often to check for updates (default: 1 hour) + CheckInterval time.Duration + + // InsecureSkipVerify skips TLS certificate verification + InsecureSkipVerify bool + + // Logger is the zerolog logger instance + Logger *zerolog.Logger + + // Disabled skips all update checks when true + Disabled bool +} + +// Updater handles automatic updates for Pulse agents. +type Updater struct { + cfg Config + client *http.Client + logger zerolog.Logger +} + +// New creates a new Updater with the given configuration. +func New(cfg Config) *Updater { + if cfg.CheckInterval == 0 { + cfg.CheckInterval = 1 * time.Hour + } + + logger := zerolog.Nop() + if cfg.Logger != nil { + logger = *cfg.Logger + } + + transport := &http.Transport{ + TLSClientConfig: &tls.Config{ + InsecureSkipVerify: cfg.InsecureSkipVerify, + }, + } + + return &Updater{ + cfg: cfg, + client: &http.Client{ + Transport: transport, + Timeout: 30 * time.Second, + }, + logger: logger, + } +} + +// RunLoop starts the update check loop. It blocks until the context is cancelled. +func (u *Updater) RunLoop(ctx context.Context) { + if u.cfg.Disabled { + u.logger.Info().Msg("Auto-update disabled") + return + } + + if u.cfg.CurrentVersion == "dev" { + u.logger.Debug().Msg("Auto-update disabled in development mode") + return + } + + // Initial check after a short delay + select { + case <-ctx.Done(): + return + case <-time.After(30 * time.Second): + u.CheckAndUpdate(ctx) + } + + ticker := time.NewTicker(u.cfg.CheckInterval) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + u.CheckAndUpdate(ctx) + } + } +} + +// CheckAndUpdate checks for a new version and performs the update if available. +func (u *Updater) CheckAndUpdate(ctx context.Context) { + if u.cfg.Disabled { + return + } + + if u.cfg.CurrentVersion == "dev" { + u.logger.Debug().Msg("Skipping update check - running in development mode") + return + } + + if u.cfg.PulseURL == "" { + u.logger.Debug().Msg("Skipping update check - no Pulse URL configured") + return + } + + u.logger.Debug().Msg("Checking for agent updates") + + serverVersion, err := u.getServerVersion(ctx) + if err != nil { + u.logger.Warn().Err(err).Msg("Failed to check for updates") + return + } + + if serverVersion == "dev" { + u.logger.Debug().Msg("Skipping update - server is in development mode") + return + } + + if serverVersion == u.cfg.CurrentVersion { + u.logger.Debug().Str("version", u.cfg.CurrentVersion).Msg("Agent is up to date") + return + } + + u.logger.Info(). + Str("currentVersion", u.cfg.CurrentVersion). + Str("availableVersion", serverVersion). + Msg("New agent version available, performing self-update") + + if err := u.performUpdate(ctx); err != nil { + u.logger.Error().Err(err).Msg("Failed to self-update agent") + return + } + + u.logger.Info().Msg("Agent updated successfully, restarting...") +} + +// getServerVersion fetches the current version from the Pulse server. +func (u *Updater) getServerVersion(ctx context.Context) (string, error) { + url := fmt.Sprintf("%s/api/agent/version", strings.TrimRight(u.cfg.PulseURL, "/")) + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return "", fmt.Errorf("failed to create request: %w", err) + } + + if u.cfg.APIToken != "" { + req.Header.Set("X-API-Token", u.cfg.APIToken) + req.Header.Set("Authorization", "Bearer "+u.cfg.APIToken) + } + + resp, err := u.client.Do(req) + if err != nil { + return "", fmt.Errorf("request failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("server returned status %d", resp.StatusCode) + } + + var versionResp struct { + Version string `json:"version"` + } + + if err := json.NewDecoder(resp.Body).Decode(&versionResp); err != nil { + return "", fmt.Errorf("failed to decode response: %w", err) + } + + return versionResp.Version, nil +} + +// performUpdate downloads and installs the new agent binary. +func (u *Updater) performUpdate(ctx context.Context) error { + execPath, err := os.Executable() + if err != nil { + return fmt.Errorf("failed to get executable path: %w", err) + } + + // Build download URL + downloadBase := fmt.Sprintf("%s/download/%s", strings.TrimRight(u.cfg.PulseURL, "/"), u.cfg.AgentName) + archParam := determineArch() + + // Try architecture-specific binary first, then fall back to default + candidates := []string{} + if archParam != "" { + candidates = append(candidates, fmt.Sprintf("%s?arch=%s", downloadBase, archParam)) + } + candidates = append(candidates, downloadBase) + + var resp *http.Response + var lastErr error + + for _, url := range candidates { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + lastErr = fmt.Errorf("failed to create download request: %w", err) + continue + } + + if u.cfg.APIToken != "" { + req.Header.Set("X-API-Token", u.cfg.APIToken) + req.Header.Set("Authorization", "Bearer "+u.cfg.APIToken) + } + + response, err := u.client.Do(req) + if err != nil { + lastErr = fmt.Errorf("failed to download binary: %w", err) + continue + } + + if response.StatusCode != http.StatusOK { + lastErr = fmt.Errorf("download failed with status: %s", response.Status) + response.Body.Close() + continue + } + + resp = response + u.logger.Debug().Str("url", url).Msg("Downloaded agent binary") + break + } + + if resp == nil { + if lastErr == nil { + lastErr = errors.New("failed to download binary") + } + return lastErr + } + defer resp.Body.Close() + + // Verify checksum if provided + checksumHeader := strings.TrimSpace(resp.Header.Get("X-Checksum-Sha256")) + + // Create temporary file + tmpFile, err := os.CreateTemp("", u.cfg.AgentName+"-*.tmp") + if err != nil { + return fmt.Errorf("failed to create temp file: %w", err) + } + tmpPath := tmpFile.Name() + defer os.Remove(tmpPath) // Clean up on failure + + // Write downloaded binary with checksum calculation + hasher := sha256.New() + if _, err := io.Copy(tmpFile, io.TeeReader(resp.Body, hasher)); err != nil { + tmpFile.Close() + return fmt.Errorf("failed to write binary: %w", err) + } + if err := tmpFile.Close(); err != nil { + return fmt.Errorf("failed to close temp file: %w", err) + } + + // Verify checksum + downloadChecksum := hex.EncodeToString(hasher.Sum(nil)) + if checksumHeader != "" { + expected := strings.ToLower(strings.TrimSpace(checksumHeader)) + actual := strings.ToLower(downloadChecksum) + if expected != actual { + return fmt.Errorf("checksum mismatch: expected %s, got %s", expected, actual) + } + u.logger.Debug().Str("checksum", downloadChecksum).Msg("Checksum verified") + } else { + u.logger.Warn().Msg("No checksum header; skipping verification") + } + + // Make executable + if err := os.Chmod(tmpPath, 0755); err != nil { + return fmt.Errorf("failed to chmod: %w", err) + } + + // Atomic replacement with backup + backupPath := execPath + ".backup" + if err := os.Rename(execPath, backupPath); err != nil { + return fmt.Errorf("failed to backup current binary: %w", err) + } + + if err := os.Rename(tmpPath, execPath); err != nil { + // Restore backup on failure + os.Rename(backupPath, execPath) + return fmt.Errorf("failed to replace binary: %w", err) + } + + // Remove backup on success + os.Remove(backupPath) + + // Restart with same arguments + args := os.Args + env := os.Environ() + + if err := syscall.Exec(execPath, args, env); err != nil { + return fmt.Errorf("failed to restart: %w", err) + } + + return nil +} + +// determineArch returns the architecture string for download URLs (e.g., "linux-amd64", "darwin-arm64"). +func determineArch() string { + os := runtime.GOOS + arch := runtime.GOARCH + + // Normalize architecture + switch arch { + case "arm": + arch = "armv7" + case "386": + arch = "386" + } + + // For known OS/arch combinations, return directly + switch os { + case "linux", "darwin", "windows": + return fmt.Sprintf("%s-%s", os, arch) + } + + // Fall back to uname for edge cases on unknown OS + out, err := exec.Command("uname", "-m").Output() + if err != nil { + return "" + } + + normalized := strings.ToLower(strings.TrimSpace(string(out))) + switch normalized { + case "x86_64", "amd64": + return "linux-amd64" + case "aarch64", "arm64": + return "linux-arm64" + case "armv7l", "armhf", "armv7": + return "linux-armv7" + default: + return "" + } +} diff --git a/internal/api/router.go b/internal/api/router.go index 4e5a04f..c54b80f 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -28,7 +28,6 @@ import ( "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" "github.com/rcourtman/pulse-go-rewrite/internal/models" "github.com/rcourtman/pulse-go-rewrite/internal/monitoring" "github.com/rcourtman/pulse-go-rewrite/internal/system" @@ -1374,7 +1373,7 @@ func (r *Router) ServeHTTP(w http.ResponseWriter, req *http.Request) { req.URL.Path != "/install-container-agent.sh" && req.URL.Path != "/install-host-agent.sh" && req.URL.Path != "/install-host-agent.ps1" && - req.URL.Path != "/install-host-agent.ps1" && + req.URL.Path != "/uninstall-host-agent.sh" && req.URL.Path != "/uninstall-host-agent.ps1" && req.URL.Path != "/install.sh" && req.URL.Path != "/install.ps1" @@ -2475,17 +2474,18 @@ func (r *Router) handleVersion(w http.ResponseWriter, req *http.Request) { json.NewEncoder(w).Encode(response) } -// handleAgentVersion returns the current Docker agent version for update checks +// handleAgentVersion returns the current server version for agent update checks. +// Agents compare this to their own version to determine if an update is available. func (r *Router) handleAgentVersion(w http.ResponseWriter, req *http.Request) { if req.Method != http.MethodGet && req.Method != http.MethodHead { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } - // Current agent version - matches the version baked into the Docker agent binary - version := strings.TrimSpace(dockeragent.Version) - if version == "" { - version = "dev" + // Return the server version - all agents should match the server version + version := "dev" + if versionInfo, err := updates.GetCurrentVersion(); err == nil { + version = versionInfo.Version } response := AgentVersionResponse{ @@ -2503,13 +2503,15 @@ func (r *Router) handleServerInfo(w http.ResponseWriter, req *http.Request) { versionInfo, err := updates.GetCurrentVersion() isDev := true + version := "dev" if err == nil { isDev = versionInfo.IsDevelopment + version = versionInfo.Version } response := map[string]interface{}{ "isDevelopment": isDev, - "version": dockeragent.Version, + "version": version, } w.Header().Set("Content-Type", "application/json") diff --git a/internal/api/unified_agent.go b/internal/api/unified_agent.go index 468cc82..f80ccff 100644 --- a/internal/api/unified_agent.go +++ b/internal/api/unified_agent.go @@ -1,7 +1,9 @@ package api import ( - "fmt" + "crypto/sha256" + "encoding/hex" + "io" "net/http" "os" "path/filepath" @@ -52,6 +54,35 @@ func (r *Router) handleDownloadUnifiedInstallScriptPS(w http.ResponseWriter, req http.ServeFile(w, req, scriptPath) } +// normalizeUnifiedAgentArch normalizes architecture strings for the unified agent. +func normalizeUnifiedAgentArch(arch string) string { + arch = strings.ToLower(strings.TrimSpace(arch)) + switch arch { + case "linux-amd64", "amd64", "x86_64": + return "linux-amd64" + case "linux-arm64", "arm64", "aarch64": + return "linux-arm64" + case "linux-armv7", "armv7", "armv7l", "armhf": + return "linux-armv7" + case "linux-armv6", "armv6": + return "linux-armv6" + case "linux-386", "386", "i386", "i686": + return "linux-386" + case "darwin-amd64", "macos-amd64": + return "darwin-amd64" + case "darwin-arm64", "macos-arm64": + return "darwin-arm64" + case "windows-amd64": + return "windows-amd64" + case "windows-arm64": + return "windows-arm64" + case "windows-386": + return "windows-386" + default: + return "" + } +} + // handleDownloadUnifiedAgent serves the pulse-agent binary func (r *Router) handleDownloadUnifiedAgent(w http.ResponseWriter, req *http.Request) { if req.Method != http.MethodGet && req.Method != http.MethodHead { @@ -59,52 +90,65 @@ func (r *Router) handleDownloadUnifiedAgent(w http.ResponseWriter, req *http.Req return } - // For now, we only have the locally built binary. - // In production, this would look up the correct binary for the requested OS/Arch. - // Query params: ?os=linux&arch=amd64 + // Prevent caching - always serve the latest version + w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate") + w.Header().Set("Pragma", "no-cache") + w.Header().Set("Expires", "0") - osName := req.URL.Query().Get("os") - arch := req.URL.Query().Get("arch") + archParam := strings.TrimSpace(req.URL.Query().Get("arch")) + searchPaths := make([]string, 0, 6) - if osName == "" { - osName = "linux" // Default - } - if arch == "" { - arch = "amd64" // Default + if normalized := normalizeUnifiedAgentArch(archParam); normalized != "" { + searchPaths = append(searchPaths, + filepath.Join(pulseBinDir(), "pulse-agent-"+normalized), + filepath.Join("/opt/pulse", "pulse-agent-"+normalized), + filepath.Join("/app", "pulse-agent-"+normalized), + filepath.Join(r.projectRoot, "bin", "pulse-agent-"+normalized), + ) } - // Normalize OS - osName = strings.ToLower(osName) - if osName == "darwin" { - osName = "macos" - } + // Default locations (host architecture) + searchPaths = append(searchPaths, + filepath.Join(pulseBinDir(), "pulse-agent"), + "/opt/pulse/pulse-agent", + filepath.Join("/app", "pulse-agent"), + filepath.Join(r.projectRoot, "bin", "pulse-agent"), + ) - // In dev mode, we just serve the binary we built in the root - // In prod, we'd look in a dist folder - binaryName := "pulse-agent" - if osName == "windows" { - binaryName = "pulse-agent.exe" - } - - // Try to find the binary - // 1. Check root (dev) - binaryPath := filepath.Join(r.config.AppRoot, binaryName) - if _, err := os.Stat(binaryPath); os.IsNotExist(err) { - // 2. Check dist folder (prod/build) - binaryPath = filepath.Join(r.config.AppRoot, "dist", fmt.Sprintf("%s-%s", osName, arch), binaryName) - if _, err := os.Stat(binaryPath); os.IsNotExist(err) { - // Fallback for dev: just serve the root binary regardless of requested OS/Arch - // This allows testing the flow even if cross-compilation hasn't happened - binaryPath = filepath.Join(r.config.AppRoot, "pulse-agent") - if _, err := os.Stat(binaryPath); os.IsNotExist(err) { - log.Error().Str("path", binaryPath).Msg("Unified agent binary not found") - http.Error(w, "Agent binary not found", http.StatusNotFound) - return - } + for _, candidate := range searchPaths { + if candidate == "" { + continue } + + info, err := os.Stat(candidate) + if err != nil || info.IsDir() { + continue + } + + file, err := os.Open(candidate) + if err != nil { + log.Error().Err(err).Str("path", candidate).Msg("Failed to open unified agent binary for download") + continue + } + + hasher := sha256.New() + if _, err := io.Copy(hasher, file); err != nil { + file.Close() + log.Error().Err(err).Str("path", candidate).Msg("Failed to hash unified agent binary") + continue + } + + if _, err := file.Seek(0, io.SeekStart); err != nil { + file.Close() + log.Error().Err(err).Str("path", candidate).Msg("Failed to rewind unified agent binary") + continue + } + + w.Header().Set("X-Checksum-Sha256", hex.EncodeToString(hasher.Sum(nil))) + http.ServeContent(w, req, filepath.Base(candidate), info.ModTime(), file) + file.Close() + return } - w.Header().Set("Content-Type", "application/octet-stream") - w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", binaryName)) - http.ServeFile(w, req, binaryPath) + http.Error(w, "Agent binary not found", http.StatusNotFound) } diff --git a/pulse-agent b/pulse-agent deleted file mode 100755 index fc9486e..0000000 Binary files a/pulse-agent and /dev/null differ diff --git a/scripts/build-release.sh b/scripts/build-release.sh index 9161110..84fa36f 100755 --- a/scripts/build-release.sh +++ b/scripts/build-release.sh @@ -64,6 +64,22 @@ for target in "${host_agent_order[@]}"; do ./cmd/pulse-host-agent done +# Build unified agents for every supported platform/architecture +echo "Building unified agents for all platforms..." +for target in "${host_agent_order[@]}"; do + build_env="${host_agent_builds[$target]}" + output_path="$BUILD_DIR/pulse-agent-$target" + if [[ "$target" == windows-* ]]; then + output_path="${output_path}.exe" + fi + + env $build_env go build \ + -ldflags="-s -w -X main.Version=v${VERSION}" \ + -trimpath \ + -o "$output_path" \ + ./cmd/pulse-agent +done + # Build for different architectures (server + docker agent + sensor proxy) declare -A builds=( ["linux-amd64"]="GOOS=linux GOARCH=amd64" @@ -132,6 +148,18 @@ for build_name in "${build_order[@]}"; do done ( cd "$staging_dir/bin" && ln -sf pulse-host-agent-windows-amd64.exe pulse-host-agent-windows-amd64 && ln -sf pulse-host-agent-windows-arm64.exe pulse-host-agent-windows-arm64 && ln -sf pulse-host-agent-windows-386.exe pulse-host-agent-windows-386 ) + # Copy unified agent binaries for every supported platform/architecture + for target in "${host_agent_order[@]}"; do + src="$BUILD_DIR/pulse-agent-$target" + dest="$staging_dir/bin/pulse-agent-$target" + if [[ "$target" == windows-* ]]; then + src="${src}.exe" + dest="${dest}.exe" + fi + cp "$src" "$dest" + done + ( cd "$staging_dir/bin" && ln -sf pulse-agent-windows-amd64.exe pulse-agent-windows-amd64 && ln -sf pulse-agent-windows-arm64.exe pulse-agent-windows-arm64 && ln -sf pulse-agent-windows-386.exe pulse-agent-windows-386 ) + # Copy scripts and VERSION metadata cp "scripts/install-docker-agent.sh" "$staging_dir/scripts/install-docker-agent.sh" cp "scripts/install-container-agent.sh" "$staging_dir/scripts/install-container-agent.sh" @@ -141,7 +169,10 @@ for build_name in "${build_order[@]}"; do cp "scripts/uninstall-host-agent.ps1" "$staging_dir/scripts/uninstall-host-agent.ps1" cp "scripts/install-sensor-proxy.sh" "$staging_dir/scripts/install-sensor-proxy.sh" cp "scripts/install-docker.sh" "$staging_dir/scripts/install-docker.sh" - chmod 755 "$staging_dir/scripts/"*.sh "$staging_dir/scripts/"*.ps1 + cp "scripts/install.sh" "$staging_dir/scripts/install.sh" + [ -f "scripts/install.ps1" ] && cp "scripts/install.ps1" "$staging_dir/scripts/install.ps1" + chmod 755 "$staging_dir/scripts/"*.sh + chmod 755 "$staging_dir/scripts/"*.ps1 2>/dev/null || true echo "$VERSION" > "$staging_dir/VERSION" # Create tarball from staging directory @@ -165,6 +196,7 @@ for build_name in "${build_order[@]}"; do cp "$BUILD_DIR/pulse-$build_name" "$universal_dir/bin/pulse-${build_name}" cp "$BUILD_DIR/pulse-docker-agent-$build_name" "$universal_dir/bin/pulse-docker-agent-${build_name}" cp "$BUILD_DIR/pulse-host-agent-$build_name" "$universal_dir/bin/pulse-host-agent-${build_name}" + cp "$BUILD_DIR/pulse-agent-$build_name" "$universal_dir/bin/pulse-agent-${build_name}" cp "$BUILD_DIR/pulse-sensor-proxy-$build_name" "$universal_dir/bin/pulse-sensor-proxy-${build_name}" done @@ -176,7 +208,10 @@ cp "scripts/uninstall-host-agent.sh" "$universal_dir/scripts/uninstall-host-agen cp "scripts/uninstall-host-agent.ps1" "$universal_dir/scripts/uninstall-host-agent.ps1" cp "scripts/install-sensor-proxy.sh" "$universal_dir/scripts/install-sensor-proxy.sh" cp "scripts/install-docker.sh" "$universal_dir/scripts/install-docker.sh" -chmod 755 "$universal_dir/scripts/"*.sh "$universal_dir/scripts/"*.ps1 +cp "scripts/install.sh" "$universal_dir/scripts/install.sh" +[ -f "scripts/install.ps1" ] && cp "scripts/install.ps1" "$universal_dir/scripts/install.ps1" +chmod 755 "$universal_dir/scripts/"*.sh +chmod 755 "$universal_dir/scripts/"*.ps1 2>/dev/null || true # Create a detection script that creates the pulse symlink based on architecture cat > "$universal_dir/bin/pulse" << 'EOF' @@ -271,6 +306,29 @@ esac EOF chmod +x "$universal_dir/bin/pulse-host-agent" +cat > "$universal_dir/bin/pulse-agent" << 'EOF' +#!/bin/sh +# Auto-detect architecture and run appropriate pulse-agent binary + +ARCH=$(uname -m) +case "$ARCH" in + x86_64|amd64) + exec "$(dirname "$0")/pulse-agent-linux-amd64" "$@" + ;; + aarch64|arm64) + exec "$(dirname "$0")/pulse-agent-linux-arm64" "$@" + ;; + armv7l|armhf) + exec "$(dirname "$0")/pulse-agent-linux-armv7" "$@" + ;; + *) + echo "Unsupported architecture: $ARCH" >&2 + exit 1 + ;; +esac +EOF +chmod +x "$universal_dir/bin/pulse-agent" + # Add VERSION file echo "$VERSION" > "$universal_dir/VERSION" @@ -281,6 +339,13 @@ zip -j "$RELEASE_DIR/pulse-host-agent-v${VERSION}-windows-amd64.zip" "$BUILD_DIR zip -j "$RELEASE_DIR/pulse-host-agent-v${VERSION}-windows-arm64.zip" "$BUILD_DIR/pulse-host-agent-windows-arm64.exe" zip -j "$RELEASE_DIR/pulse-host-agent-v${VERSION}-windows-386.zip" "$BUILD_DIR/pulse-host-agent-windows-386.exe" +# Package standalone unified agent binaries +tar -czf "$RELEASE_DIR/pulse-agent-v${VERSION}-darwin-amd64.tar.gz" -C "$BUILD_DIR" pulse-agent-darwin-amd64 +tar -czf "$RELEASE_DIR/pulse-agent-v${VERSION}-darwin-arm64.tar.gz" -C "$BUILD_DIR" pulse-agent-darwin-arm64 +zip -j "$RELEASE_DIR/pulse-agent-v${VERSION}-windows-amd64.zip" "$BUILD_DIR/pulse-agent-windows-amd64.exe" +zip -j "$RELEASE_DIR/pulse-agent-v${VERSION}-windows-arm64.zip" "$BUILD_DIR/pulse-agent-windows-arm64.exe" +zip -j "$RELEASE_DIR/pulse-agent-v${VERSION}-windows-386.zip" "$BUILD_DIR/pulse-agent-windows-386.exe" + # Copy Windows and macOS binaries into universal tarball for /download/ endpoint echo "Adding Windows and macOS binaries to universal tarball..." cp "$BUILD_DIR/pulse-host-agent-darwin-amd64" "$universal_dir/bin/" @@ -289,11 +354,21 @@ cp "$BUILD_DIR/pulse-host-agent-windows-amd64.exe" "$universal_dir/bin/" cp "$BUILD_DIR/pulse-host-agent-windows-arm64.exe" "$universal_dir/bin/" cp "$BUILD_DIR/pulse-host-agent-windows-386.exe" "$universal_dir/bin/" +cp "$BUILD_DIR/pulse-agent-darwin-amd64" "$universal_dir/bin/" +cp "$BUILD_DIR/pulse-agent-darwin-arm64" "$universal_dir/bin/" +cp "$BUILD_DIR/pulse-agent-windows-amd64.exe" "$universal_dir/bin/" +cp "$BUILD_DIR/pulse-agent-windows-arm64.exe" "$universal_dir/bin/" +cp "$BUILD_DIR/pulse-agent-windows-386.exe" "$universal_dir/bin/" + # Create symlinks for Windows binaries without .exe extension (required for download endpoint) ln -s pulse-host-agent-windows-amd64.exe "$universal_dir/bin/pulse-host-agent-windows-amd64" ln -s pulse-host-agent-windows-arm64.exe "$universal_dir/bin/pulse-host-agent-windows-arm64" ln -s pulse-host-agent-windows-386.exe "$universal_dir/bin/pulse-host-agent-windows-386" +ln -s pulse-agent-windows-amd64.exe "$universal_dir/bin/pulse-agent-windows-amd64" +ln -s pulse-agent-windows-arm64.exe "$universal_dir/bin/pulse-agent-windows-arm64" +ln -s pulse-agent-windows-386.exe "$universal_dir/bin/pulse-agent-windows-386" + # Create universal tarball cd "$universal_dir" tar -czf "../../$RELEASE_DIR/pulse-v${VERSION}.tar.gz" . diff --git a/scripts/install.ps1 b/scripts/install.ps1 index 1270cb6..f637f2e 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -40,7 +40,10 @@ if ([string]::IsNullOrWhiteSpace($Url) -or [string]::IsNullOrWhiteSpace($Token)) } # --- Download --- -$DownloadUrl = "$Url/download/pulse-agent?os=windows&arch=amd64" +# Determine architecture +$Arch = if ([Environment]::Is64BitOperatingSystem) { "amd64" } else { "386" } +$ArchParam = "windows-$Arch" +$DownloadUrl = "$Url/download/pulse-agent?arch=$ArchParam" Write-Host "Downloading agent from $DownloadUrl..." -ForegroundColor Cyan if (-not (Test-Path $InstallDir)) { @@ -76,7 +79,11 @@ if (Get-Service $AgentName -ErrorAction SilentlyContinue) { Start-Sleep -Seconds 2 } -$Args = "--url `"$Url`" --token `"$Token`" --interval `"$Interval`" --enable-host=$EnableHost --enable-docker=$EnableDocker --insecure=$Insecure" +# Build command line args +$Args = "--url `"$Url`" --token `"$Token`" --interval `"$Interval`"" +if ($EnableHost) { $Args += " --enable-host" } +if ($EnableDocker) { $Args += " --enable-docker" } +if ($Insecure) { $Args += " --insecure" } $BinPath = "`"$DestPath`" $Args" # Create Service diff --git a/scripts/install.sh b/scripts/install.sh index 764acd2..32d4219 100644 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -107,10 +107,16 @@ ARCH=$(uname -m) case "$ARCH" in x86_64) ARCH="amd64" ;; aarch64|arm64) ARCH="arm64" ;; + armv7l|armhf) ARCH="armv7" ;; + armv6l) ARCH="armv6" ;; + i386|i686) ARCH="386" ;; *) fail "Unsupported architecture: $ARCH" ;; esac -DOWNLOAD_URL="${PULSE_URL}/download/${BINARY_NAME}?os=${OS}&arch=${ARCH}" +# Construct arch param in format expected by download endpoint (e.g., linux-amd64) +ARCH_PARAM="${OS}-${ARCH}" + +DOWNLOAD_URL="${PULSE_URL}/download/${BINARY_NAME}?arch=${ARCH_PARAM}" log_info "Downloading agent from ${DOWNLOAD_URL}..." # Create temp file @@ -169,6 +175,28 @@ if [[ "$OS" == "darwin" ]]; then PLIST="/Library/LaunchDaemons/com.pulse.agent.plist" log_info "Configuring Launchd service at $PLIST..." + # Build program arguments array + PLIST_ARGS=" ${INSTALL_DIR}/${BINARY_NAME} + --url + ${PULSE_URL} + --token + ${PULSE_TOKEN} + --interval + ${INTERVAL}" + + if [[ "$ENABLE_HOST" == "true" ]]; then + PLIST_ARGS="${PLIST_ARGS} + --enable-host" + fi + if [[ "$ENABLE_DOCKER" == "true" ]]; then + PLIST_ARGS="${PLIST_ARGS} + --enable-docker" + fi + if [[ "$INSECURE" == "true" ]]; then + PLIST_ARGS="${PLIST_ARGS} + --insecure" + fi + cat > "$PLIST" < @@ -178,16 +206,7 @@ if [[ "$OS" == "darwin" ]]; then com.pulse.agent ProgramArguments - ${INSTALL_DIR}/${BINARY_NAME} - --url - ${PULSE_URL} - --token - ${PULSE_TOKEN} - --interval - ${INTERVAL} - --enable-host=${ENABLE_HOST} - --enable-docker=${ENABLE_DOCKER} - --insecure=${INSECURE} +${PLIST_ARGS} RunAtLoad @@ -212,6 +231,12 @@ if [[ -d /usr/syno/etc/rc.sysv ]]; then CONF="/etc/init/${AGENT_NAME}.conf" log_info "Configuring Upstart service at $CONF..." + # Build command line args + EXEC_ARGS="--url \"${PULSE_URL}\" --token \"${PULSE_TOKEN}\" --interval \"${INTERVAL}\"" + [[ "$ENABLE_HOST" == "true" ]] && EXEC_ARGS="$EXEC_ARGS --enable-host" + [[ "$ENABLE_DOCKER" == "true" ]] && EXEC_ARGS="$EXEC_ARGS --enable-docker" + [[ "$INSECURE" == "true" ]] && EXEC_ARGS="$EXEC_ARGS --insecure" + cat > "$CONF" <> ${LOG_FILE} 2>&1 +exec ${INSTALL_DIR}/${BINARY_NAME} ${EXEC_ARGS} >> ${LOG_FILE} 2>&1 EOF initctl stop "${AGENT_NAME}" 2>/dev/null || true initctl start "${AGENT_NAME}" @@ -242,6 +260,12 @@ if command -v systemctl >/dev/null 2>&1; then UNIT="/etc/systemd/system/${AGENT_NAME}.service" log_info "Configuring Systemd service at $UNIT..." + # Build command line args + EXEC_ARGS="--url ${PULSE_URL} --token ${PULSE_TOKEN} --interval ${INTERVAL}" + [[ "$ENABLE_HOST" == "true" ]] && EXEC_ARGS="$EXEC_ARGS --enable-host" + [[ "$ENABLE_DOCKER" == "true" ]] && EXEC_ARGS="$EXEC_ARGS --enable-docker" + [[ "$INSECURE" == "true" ]] && EXEC_ARGS="$EXEC_ARGS --insecure" + cat > "$UNIT" <&2; exit 1; }; case "$f" in *.sh|*.ps1) [ -x "$f" ] || { echo "$f not executable" >&2; exit 1; };; esac; done; echo "All scripts present and executable"' || { error "Script validation failed"; exit 1; } + docker run --rm --entrypoint /bin/sh "$IMAGE" -c 'set -euo pipefail; cd /opt/pulse/scripts; required="install-docker-agent.sh install-container-agent.sh install-host-agent.sh install-host-agent.ps1 uninstall-host-agent.sh uninstall-host-agent.ps1 install-sensor-proxy.sh install-docker.sh install.sh"; for f in $required; do [ -f "$f" ] || { echo "missing script $f" >&2; exit 1; }; case "$f" in *.sh|*.ps1) [ -x "$f" ] || { echo "$f not executable" >&2; exit 1; };; esac; done; echo "All scripts present and executable"' || { error "Script validation failed"; exit 1; } success "All installer/uninstaller scripts present and executable" # Validate all required binaries exist and are non-empty info "Checking downloadable binaries in /opt/pulse/bin/..." - docker run --rm --entrypoint /bin/sh "$IMAGE" -c 'set -euo pipefail; cd /opt/pulse/bin; required="pulse pulse-docker-agent pulse-docker-agent-linux-amd64 pulse-docker-agent-linux-arm64 pulse-docker-agent-linux-armv7 pulse-docker-agent-linux-armv6 pulse-docker-agent-linux-386 pulse-host-agent-linux-amd64 pulse-host-agent-linux-arm64 pulse-host-agent-linux-armv7 pulse-host-agent-linux-armv6 pulse-host-agent-linux-386 pulse-host-agent-darwin-amd64 pulse-host-agent-darwin-arm64 pulse-host-agent-windows-amd64.exe pulse-host-agent-windows-amd64 pulse-host-agent-windows-arm64.exe pulse-host-agent-windows-arm64 pulse-host-agent-windows-386.exe pulse-host-agent-windows-386 pulse-sensor-proxy pulse-sensor-proxy-linux-amd64 pulse-sensor-proxy-linux-arm64 pulse-sensor-proxy-linux-armv7 pulse-sensor-proxy-linux-armv6 pulse-sensor-proxy-linux-386"; for f in $required; do [ -e "$f" ] || { echo "missing binary $f" >&2; exit 1; }; [ -s "$f" ] || { echo "empty binary $f" >&2; exit 1; }; done; [ "$(readlink pulse-host-agent-windows-amd64)" = "pulse-host-agent-windows-amd64.exe" ] || { echo "windows amd64 symlink broken" >&2; exit 1; }; [ "$(readlink pulse-host-agent-windows-arm64)" = "pulse-host-agent-windows-arm64.exe" ] || { echo "windows arm64 symlink broken" >&2; exit 1; }; [ "$(readlink pulse-host-agent-windows-386)" = "pulse-host-agent-windows-386.exe" ] || { echo "windows 386 symlink broken" >&2; exit 1; }; echo "All binaries present"' || { error "Binary validation failed"; exit 1; } - success "All downloadable binaries present (26 binaries + 3 Windows symlinks)" + docker run --rm --entrypoint /bin/sh "$IMAGE" -c 'set -euo pipefail; cd /opt/pulse/bin; required="pulse pulse-docker-agent pulse-docker-agent-linux-amd64 pulse-docker-agent-linux-arm64 pulse-docker-agent-linux-armv7 pulse-docker-agent-linux-armv6 pulse-docker-agent-linux-386 pulse-host-agent-linux-amd64 pulse-host-agent-linux-arm64 pulse-host-agent-linux-armv7 pulse-host-agent-linux-armv6 pulse-host-agent-linux-386 pulse-host-agent-darwin-amd64 pulse-host-agent-darwin-arm64 pulse-host-agent-windows-amd64.exe pulse-host-agent-windows-amd64 pulse-host-agent-windows-arm64.exe pulse-host-agent-windows-arm64 pulse-host-agent-windows-386.exe pulse-host-agent-windows-386 pulse-agent-linux-amd64 pulse-agent-linux-arm64 pulse-agent-linux-armv7 pulse-agent-linux-armv6 pulse-agent-linux-386 pulse-agent-darwin-amd64 pulse-agent-darwin-arm64 pulse-agent-windows-amd64.exe pulse-agent-windows-amd64 pulse-agent-windows-arm64.exe pulse-agent-windows-arm64 pulse-agent-windows-386.exe pulse-agent-windows-386 pulse-sensor-proxy pulse-sensor-proxy-linux-amd64 pulse-sensor-proxy-linux-arm64 pulse-sensor-proxy-linux-armv7 pulse-sensor-proxy-linux-armv6 pulse-sensor-proxy-linux-386"; for f in $required; do [ -e "$f" ] || { echo "missing binary $f" >&2; exit 1; }; [ -s "$f" ] || { echo "empty binary $f" >&2; exit 1; }; done; [ "$(readlink pulse-host-agent-windows-amd64)" = "pulse-host-agent-windows-amd64.exe" ] || { echo "windows amd64 symlink broken" >&2; exit 1; }; [ "$(readlink pulse-host-agent-windows-arm64)" = "pulse-host-agent-windows-arm64.exe" ] || { echo "windows arm64 symlink broken" >&2; exit 1; }; [ "$(readlink pulse-host-agent-windows-386)" = "pulse-host-agent-windows-386.exe" ] || { echo "windows 386 symlink broken" >&2; exit 1; }; [ "$(readlink pulse-agent-windows-amd64)" = "pulse-agent-windows-amd64.exe" ] || { echo "unified agent windows amd64 symlink broken" >&2; exit 1; }; [ "$(readlink pulse-agent-windows-arm64)" = "pulse-agent-windows-arm64.exe" ] || { echo "unified agent windows arm64 symlink broken" >&2; exit 1; }; [ "$(readlink pulse-agent-windows-386)" = "pulse-agent-windows-386.exe" ] || { echo "unified agent windows 386 symlink broken" >&2; exit 1; }; echo "All binaries present"' || { error "Binary validation failed"; exit 1; } + success "All downloadable binaries present (39 binaries + 6 Windows symlinks)" # Validate version embedding in Docker image binaries info "Validating version embedding in Docker image binaries..." @@ -157,6 +157,10 @@ if [ "$SKIP_DOCKER" = false ]; then docker run --rm --entrypoint /bin/sh -e EXPECTED_TAG="$PULSE_TAG" "$IMAGE" -c 'set -euo pipefail; grep -aF "$EXPECTED_TAG" /opt/pulse/bin/pulse-docker-agent-linux-amd64 >/dev/null' || { error "Docker agent version string not found"; exit 1; } success "Docker agent version embedded: $PULSE_TAG" + # Unified agent binary + docker run --rm --entrypoint /opt/pulse/bin/pulse-agent-linux-amd64 "$IMAGE" --version 2>/dev/null | grep -Fx "$PULSE_TAG" >/dev/null || { error "Unified agent version mismatch"; exit 1; } + success "Unified agent version: $PULSE_TAG" + # Smoke test download endpoints from a running container info "Running download endpoint smoke tests..." HOST_PORT=8765 @@ -308,6 +312,11 @@ required_assets=( "pulse-host-agent-v${PULSE_VERSION}-windows-amd64.zip" "pulse-host-agent-v${PULSE_VERSION}-windows-arm64.zip" "pulse-host-agent-v${PULSE_VERSION}-windows-386.zip" + "pulse-agent-v${PULSE_VERSION}-darwin-amd64.tar.gz" + "pulse-agent-v${PULSE_VERSION}-darwin-arm64.tar.gz" + "pulse-agent-v${PULSE_VERSION}-windows-amd64.zip" + "pulse-agent-v${PULSE_VERSION}-windows-arm64.zip" + "pulse-agent-v${PULSE_VERSION}-windows-386.zip" ) missing_count=0 @@ -409,6 +418,21 @@ host_agent_entries=( ./bin/pulse-host-agent-windows-arm64 ./bin/pulse-host-agent-windows-386 ) +unified_agent_entries=( + ./bin/pulse-agent-linux-amd64 + ./bin/pulse-agent-linux-arm64 + ./bin/pulse-agent-linux-armv7 + ./bin/pulse-agent-linux-armv6 + ./bin/pulse-agent-linux-386 + ./bin/pulse-agent-darwin-amd64 + ./bin/pulse-agent-darwin-arm64 + ./bin/pulse-agent-windows-amd64.exe + ./bin/pulse-agent-windows-arm64.exe + ./bin/pulse-agent-windows-386.exe + ./bin/pulse-agent-windows-amd64 + ./bin/pulse-agent-windows-arm64 + ./bin/pulse-agent-windows-386 +) for arch in "${tar_arches[@]}"; do tarball="pulse-v${PULSE_VERSION}-${arch}.tar.gz" @@ -419,9 +443,10 @@ for arch in "${tar_arches[@]}"; do fi check_tar_entries_nonempty "$tarball" "${host_agent_entries[@]}" + check_tar_entries_nonempty "$tarball" "${unified_agent_entries[@]}" # Check scripts - tar -tzf "$tarball" ./scripts/install-docker-agent.sh ./scripts/install-container-agent.sh ./scripts/install-host-agent.sh ./scripts/install-host-agent.ps1 ./scripts/uninstall-host-agent.sh ./scripts/uninstall-host-agent.ps1 ./scripts/install-sensor-proxy.sh ./scripts/install-docker.sh >/dev/null 2>&1 || { error "$(basename $tarball) missing scripts"; exit 1; } + tar -tzf "$tarball" ./scripts/install-docker-agent.sh ./scripts/install-container-agent.sh ./scripts/install-host-agent.sh ./scripts/install-host-agent.ps1 ./scripts/uninstall-host-agent.sh ./scripts/uninstall-host-agent.ps1 ./scripts/install-sensor-proxy.sh ./scripts/install-docker.sh ./scripts/install.sh >/dev/null 2>&1 || { error "$(basename $tarball) missing scripts"; exit 1; } # Check VERSION file tar -tzf "$tarball" ./VERSION >/dev/null 2>&1 || { error "$(basename $tarball) missing VERSION file"; exit 1; } @@ -432,14 +457,16 @@ success "Platform-specific tarballs contain all required files (including cross- section "Validating universal tarball" tar -tzf "pulse-v${PULSE_VERSION}.tar.gz" ./VERSION >/dev/null 2>&1 || { error "Universal tarball missing VERSION file"; exit 1; } -# Validate universal tarball contains all host agent binaries for download endpoint -info "Validating universal tarball contains all host agent binaries..." +# Validate universal tarball contains all agent binaries for download endpoint +info "Validating universal tarball contains all agent binaries..." check_tar_entries_nonempty "pulse-v${PULSE_VERSION}.tar.gz" "${host_agent_entries[@]}" -success "Universal tarball validated (includes cross-platform host agents)" +check_tar_entries_nonempty "pulse-v${PULSE_VERSION}.tar.gz" "${unified_agent_entries[@]}" +success "Universal tarball validated (includes cross-platform host and unified agents)" -# Validate macOS tarball -tar -tzf "pulse-host-agent-v${PULSE_VERSION}-darwin-arm64.tar.gz" pulse-host-agent-darwin-arm64 >/dev/null 2>&1 || { error "macOS tarball validation failed"; exit 1; } -success "macOS host-agent tarball validated" +# Validate macOS tarballs +tar -tzf "pulse-host-agent-v${PULSE_VERSION}-darwin-arm64.tar.gz" pulse-host-agent-darwin-arm64 >/dev/null 2>&1 || { error "macOS host-agent tarball validation failed"; exit 1; } +tar -tzf "pulse-agent-v${PULSE_VERSION}-darwin-arm64.tar.gz" pulse-agent-darwin-arm64 >/dev/null 2>&1 || { error "macOS unified-agent tarball validation failed"; exit 1; } +success "macOS agent tarballs validated" # Validate checksums.txt info "Validating checksums..."