mirror of
https://github.com/wearepixel/pulsewise-collector.git
synced 2026-11-14 22:12:01 +00:00
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:
@@ -1,4 +1,7 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
set -e
|
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})"
|
||||||
|
|||||||
@@ -4,13 +4,16 @@ import (
|
|||||||
"bufio"
|
"bufio"
|
||||||
"bytes"
|
"bytes"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
"strings"
|
"strings"
|
||||||
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/shirou/gopsutil/v3/cpu"
|
"github.com/shirou/gopsutil/v3/cpu"
|
||||||
@@ -22,9 +25,14 @@ import (
|
|||||||
"github.com/shirou/gopsutil/v3/process"
|
"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 (
|
const (
|
||||||
defaultURL = "https://pulsewise.app/api/collect"
|
defaultURL = "https://pulsewise.app/api/collect"
|
||||||
|
versionCheckURL = "https://pulsewise.app/collector/release/latest"
|
||||||
collectionInterval = 30 * time.Second
|
collectionInterval = 30 * time.Second
|
||||||
|
updateCheckInterval = 6 * time.Hour
|
||||||
configFilePath = "/etc/pulsewise-collector/config"
|
configFilePath = "/etc/pulsewise-collector/config"
|
||||||
topProcessesCount = 10
|
topProcessesCount = 10
|
||||||
)
|
)
|
||||||
@@ -35,6 +43,7 @@ type Config struct {
|
|||||||
URL string
|
URL string
|
||||||
Interval time.Duration
|
Interval time.Duration
|
||||||
Debug bool
|
Debug bool
|
||||||
|
AutoUpdate bool
|
||||||
}
|
}
|
||||||
|
|
||||||
type Metrics struct {
|
type Metrics struct {
|
||||||
@@ -135,12 +144,25 @@ type ProcessInfo struct {
|
|||||||
var prevNet *networkSnapshot
|
var prevNet *networkSnapshot
|
||||||
|
|
||||||
func main() {
|
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("Hostname: %s", config.Hostname)
|
||||||
log.Printf("Target URL: %s", config.URL)
|
log.Printf("Target URL: %s", config.URL)
|
||||||
log.Printf("Collection interval: %v", config.Interval)
|
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)
|
ticker := time.NewTicker(config.Interval)
|
||||||
defer ticker.Stop()
|
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 {
|
func loadConfig() *Config {
|
||||||
token, hostname, url, debug, err := loadConfigFromFile()
|
token, hostname, url, debug, err := loadConfigFromFile()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
Reference in New Issue
Block a user