feat: add auto-update with --no-auto-update flag

Collector now checks for updates at startup (after 30s) and every 6h.
When a newer version is detected via the /collector/release/latest endpoint,
it downloads the new binary, atomically replaces itself, then re-execs
in-place so the service manager sees no interruption.

Auto-update is enabled by default; pass --no-auto-update to disable.
Version is injected at build time via -ldflags for release builds.
This commit is contained in:
Joel Male
2026-02-25 14:36:42 +10:00
parent 4aa08d0ef4
commit c2241e8d87
2 changed files with 191 additions and 17 deletions
+5 -2
View File
@@ -1,4 +1,7 @@
#!/bin/bash
set -e
go build -o pulsewise-collector .
echo "Built: pulsewise-collector"
VERSION="${VERSION:-0.1.0}"
go build -ldflags "-X main.version=${VERSION}" -o pulsewise-collector .
echo "Built: pulsewise-collector (v${VERSION})"
+186 -15
View File
@@ -4,13 +4,16 @@ import (
"bufio"
"bytes"
"encoding/json"
"flag"
"fmt"
"io"
"log"
"net/http"
"os"
"path/filepath"
"runtime"
"strings"
"syscall"
"time"
"github.com/shirou/gopsutil/v3/cpu"
@@ -22,19 +25,25 @@ import (
"github.com/shirou/gopsutil/v3/process"
)
// version is set at build time via -ldflags "-X main.version=x.y.z"
var version = "0.1.0"
const (
defaultURL = "https://pulsewise.app/api/collect"
collectionInterval = 30 * time.Second
configFilePath = "/etc/pulsewise-collector/config"
topProcessesCount = 10
defaultURL = "https://pulsewise.app/api/collect"
versionCheckURL = "https://pulsewise.app/collector/release/latest"
collectionInterval = 30 * time.Second
updateCheckInterval = 6 * time.Hour
configFilePath = "/etc/pulsewise-collector/config"
topProcessesCount = 10
)
type Config struct {
Token string
Hostname string
URL string
Interval time.Duration
Debug bool
Token string
Hostname string
URL string
Interval time.Duration
Debug bool
AutoUpdate bool
}
type Metrics struct {
@@ -135,12 +144,25 @@ type ProcessInfo struct {
var prevNet *networkSnapshot
func main() {
config := loadConfig()
noAutoUpdate := flag.Bool("no-auto-update", false, "disable automatic updates")
flag.Parse()
log.Printf("Starting Pulsewise collector...")
config := loadConfig()
config.AutoUpdate = !*noAutoUpdate
log.Printf("Starting Pulsewise collector (v%s)...", version)
log.Printf("Hostname: %s", config.Hostname)
log.Printf("Target URL: %s", config.URL)
log.Printf("Collection interval: %v", config.Interval)
if config.AutoUpdate {
log.Printf("Auto-update: enabled")
} else {
log.Printf("Auto-update: disabled")
}
if config.AutoUpdate {
go runAutoUpdater(config)
}
ticker := time.NewTicker(config.Interval)
defer ticker.Stop()
@@ -152,6 +174,155 @@ func main() {
}
}
// ─────────────────────────────────────────────────────────────
// Auto-update
// ─────────────────────────────────────────────────────────────
type latestRelease struct {
Version string `json:"version"`
}
func runAutoUpdater(config *Config) {
// Wait before first check so the initial collection can complete.
time.Sleep(30 * time.Second)
checkAndUpdate(config)
ticker := time.NewTicker(updateCheckInterval)
defer ticker.Stop()
for range ticker.C {
checkAndUpdate(config)
}
}
func checkAndUpdate(config *Config) {
latest, err := fetchLatestVersion()
if err != nil {
if config.Debug {
log.Printf("[DEBUG] Update check failed: %v", err)
}
return
}
if latest == version {
if config.Debug {
log.Printf("[DEBUG] Already on latest version %s", version)
}
return
}
log.Printf("Update available: v%s → v%s. Downloading...", version, latest)
if err := downloadAndReplace(); err != nil {
log.Printf("Auto-update failed: %v", err)
return
}
log.Printf("Update successful. Restarting...")
execSelf()
}
func fetchLatestVersion() (string, error) {
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Get(versionCheckURL)
if err != nil {
return "", fmt.Errorf("version check request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("version check returned status %d", resp.StatusCode)
}
var release latestRelease
if err := json.NewDecoder(resp.Body).Decode(&release); err != nil {
return "", fmt.Errorf("failed to parse version response: %w", err)
}
if release.Version == "" {
return "", fmt.Errorf("empty version in response")
}
return release.Version, nil
}
func downloadAndReplace() error {
goos := runtime.GOOS
goarch := runtime.GOARCH
downloadURL := fmt.Sprintf("https://pulsewise.app/collector/download/%s-%s", goos, goarch)
selfPath, err := os.Executable()
if err != nil {
return fmt.Errorf("failed to determine executable path: %w", err)
}
selfPath, err = filepath.EvalSymlinks(selfPath)
if err != nil {
return fmt.Errorf("failed to resolve symlinks: %w", err)
}
// Create temp file in the same directory so os.Rename is atomic (same filesystem).
tmpFile, err := os.CreateTemp(filepath.Dir(selfPath), ".pulsewise-update-*")
if err != nil {
return fmt.Errorf("failed to create temp file: %w", err)
}
tmpPath := tmpFile.Name()
cleanup := func() {
tmpFile.Close()
os.Remove(tmpPath)
}
client := &http.Client{Timeout: 60 * time.Second}
resp, err := client.Get(downloadURL)
if err != nil {
cleanup()
return fmt.Errorf("download request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
cleanup()
return fmt.Errorf("download failed with status %d", resp.StatusCode)
}
if _, err := io.Copy(tmpFile, resp.Body); err != nil {
cleanup()
return fmt.Errorf("failed to write downloaded binary: %w", err)
}
tmpFile.Close()
if err := os.Chmod(tmpPath, 0755); err != nil {
os.Remove(tmpPath)
return fmt.Errorf("failed to make binary executable: %w", err)
}
if err := os.Rename(tmpPath, selfPath); err != nil {
os.Remove(tmpPath)
return fmt.Errorf("failed to replace binary: %w", err)
}
return nil
}
// execSelf replaces the current process image with the newly downloaded binary.
// If exec fails, we exit and let the service manager restart the process.
func execSelf() {
self, err := os.Executable()
if err != nil {
log.Printf("Failed to get executable path for restart: %v — exiting for service manager restart", err)
os.Exit(0)
}
if err := syscall.Exec(self, os.Args, os.Environ()); err != nil {
log.Printf("Failed to re-exec after update: %v — exiting for service manager restart", err)
os.Exit(0)
}
}
// ─────────────────────────────────────────────────────────────
// Config
// ─────────────────────────────────────────────────────────────
func loadConfig() *Config {
token, hostname, url, debug, err := loadConfigFromFile()
if err != nil {
@@ -159,11 +330,11 @@ func loadConfig() *Config {
}
return &Config{
Token: token,
Token: token,
Hostname: hostname,
URL: url,
Interval: collectionInterval,
Debug: debug,
URL: url,
Interval: collectionInterval,
Debug: debug,
}
}