feat: add auto-update support for unified agent

Implement self-update capability for the unified pulse-agent binary:

- Add internal/agentupdate package with cross-platform update logic
- Hourly version checks against /api/agent/version endpoint
- SHA256 checksum verification for downloaded binaries
- Atomic binary replacement with backup/rollback on failure
- Support for Linux, macOS, and Windows (10 platform/arch combinations)

Build and release changes:
- Dockerfile builds unified agent for all platforms
- build-release.sh includes unified agent in release artifacts
- validate-release.sh validates unified agent binaries
- Install scripts (install.sh, install.ps1) use correct URL format

Related to #727, #737
This commit is contained in:
rcourtman 2025-11-25 23:15:03 +00:00
parent 5e3f1db5b3
commit 0436101ee5
12 changed files with 860 additions and 91 deletions

2
.gitignore vendored
View file

@ -7,6 +7,8 @@
/pulse-test
/pulse-host-agent
/pulse-host-agent-*
/pulse-agent
/pulse-agent-*
# Logs
*.log

View file

@ -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/

View file

@ -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,
}
}

139
docs/UNIFIED_AGENT.md Normal file
View file

@ -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://<pulse-ip>:7655/install.sh | \
bash -s -- --url http://<pulse-ip>:7655 --token <api-token>
```
### macOS
```bash
curl -fsSL http://<pulse-ip>:7655/install.sh | \
bash -s -- --url http://<pulse-ip>:7655 --token <api-token>
```
### Synology NAS
```bash
curl -fsSL http://<pulse-ip>:7655/install.sh | \
bash -s -- --url http://<pulse-ip>:7655 --token <api-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://<pulse-ip>:7655/install.sh | \
bash -s -- --url http://<pulse-ip>:7655 --token <token>
```
### Host + Docker Monitoring
```bash
curl -fsSL http://<pulse-ip>:7655/install.sh | \
bash -s -- --url http://<pulse-ip>:7655 --token <token> --enable-docker
```
### Docker Monitoring Only
```bash
curl -fsSL http://<pulse-ip>:7655/install.sh | \
bash -s -- --url http://<pulse-ip>:7655 --token <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://<pulse-ip>:7655/install.sh | \
bash -s -- --url http://<pulse-ip>:7655 --token <token> --disable-auto-update
# Or set environment variable
PULSE_DISABLE_AUTO_UPDATE=true
```
## Uninstall
```bash
curl -fsSL http://<pulse-ip>: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
```

View file

@ -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 ""
}
}

View file

@ -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")

View file

@ -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)
}

Binary file not shown.

View file

@ -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" .

View file

@ -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

View file

@ -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=" <string>${INSTALL_DIR}/${BINARY_NAME}</string>
<string>--url</string>
<string>${PULSE_URL}</string>
<string>--token</string>
<string>${PULSE_TOKEN}</string>
<string>--interval</string>
<string>${INTERVAL}</string>"
if [[ "$ENABLE_HOST" == "true" ]]; then
PLIST_ARGS="${PLIST_ARGS}
<string>--enable-host</string>"
fi
if [[ "$ENABLE_DOCKER" == "true" ]]; then
PLIST_ARGS="${PLIST_ARGS}
<string>--enable-docker</string>"
fi
if [[ "$INSECURE" == "true" ]]; then
PLIST_ARGS="${PLIST_ARGS}
<string>--insecure</string>"
fi
cat > "$PLIST" <<EOF
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
@ -178,16 +206,7 @@ if [[ "$OS" == "darwin" ]]; then
<string>com.pulse.agent</string>
<key>ProgramArguments</key>
<array>
<string>${INSTALL_DIR}/${BINARY_NAME}</string>
<string>--url</string>
<string>${PULSE_URL}</string>
<string>--token</string>
<string>${PULSE_TOKEN}</string>
<string>--interval</string>
<string>${INTERVAL}</string>
<string>--enable-host=${ENABLE_HOST}</string>
<string>--enable-docker=${ENABLE_DOCKER}</string>
<string>--insecure=${INSECURE}</string>
${PLIST_ARGS}
</array>
<key>RunAtLoad</key>
<true/>
@ -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" <<EOF
description "Pulse Unified Agent"
author "Pulse"
@ -222,14 +247,7 @@ stop on runlevel [06]
respawn
respawn limit 5 10
exec ${INSTALL_DIR}/${BINARY_NAME} \
--url "${PULSE_URL}" \
--token "${PULSE_TOKEN}" \
--interval "${INTERVAL}" \
--enable-host=${ENABLE_HOST} \
--enable-docker=${ENABLE_DOCKER} \
--insecure=${INSECURE} \
>> ${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" <<EOF
[Unit]
Description=Pulse Unified Agent
@ -250,13 +274,7 @@ Wants=network-online.target
[Service]
Type=simple
ExecStart=${INSTALL_DIR}/${BINARY_NAME} \
--url "${PULSE_URL}" \
--token "${PULSE_TOKEN}" \
--interval "${INTERVAL}" \
--enable-host=${ENABLE_HOST} \
--enable-docker=${ENABLE_DOCKER} \
--insecure=${INSECURE}
ExecStart=${INSTALL_DIR}/${BINARY_NAME} ${EXEC_ARGS}
Restart=always
RestartSec=5s
User=root

View file

@ -130,13 +130,13 @@ if [ "$SKIP_DOCKER" = false ]; then
# Validate all required scripts exist and are executable
info "Checking installer/uninstaller scripts in /opt/pulse/scripts/..."
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"; 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; }
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..."