Files
corrosion-admin-panel/companion-agent/internal/update/updater.go
Vantz Stockwell a62715409f feat: Add Go companion agent for bare metal server management
Implements complete companion agent for Rust servers not on managed panels.

Features:
- NATS integration with token auth and auto-reconnect
- Game server process management (start/stop/restart/monitor)
- File operations (read/write/delete/list) via NATS
- SteamCMD integration for automated updates
- Self-update capability with download and replace
- Heartbeat publishing every 60s with server status
- Graceful shutdown handling (SIGTERM/SIGINT)
- Zombie process prevention via cmd.Wait()
- Cross-platform builds (Linux amd64, Windows amd64)

Structure:
- cmd/agent/main.go: Entry point, config, signal handling
- internal/app/daemon.go: Main loop, NATS subscriptions
- internal/client/nats.go: NATS connection with reconnect
- internal/process/gameserver.go: Process management
- internal/process/steamcmd.go: Steam update execution
- internal/files/operations.go: File system operations
- internal/update/updater.go: Self-update logic
- Makefile: Cross-compilation targets
- README.md: Installation and configuration guide

NATS Subjects:
- Publishes: corrosion.{license_id}.companion.heartbeat
- Publishes: corrosion.{license_id}.files.response
- Subscribes: corrosion.{license_id}.cmd.server
- Subscribes: corrosion.{license_id}.files.{get|put|delete|list}
- Subscribes: corrosion.{license_id}.update.steam
- Subscribes: corrosion.{license_id}.update.companion

Built binaries: 7.0MB (Linux), 7.2MB (Windows)
Total code: 1,356 LOC across 8 files

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-15 12:05:23 -05:00

224 lines
5.8 KiB
Go

package update
import (
"fmt"
"io"
"log"
"net/http"
"os"
"path/filepath"
"runtime"
"time"
)
// Updater handles self-update operations for the companion agent
type Updater struct {
currentVersion string
}
// NewUpdater creates a new updater instance
func NewUpdater(currentVersion string) *Updater {
return &Updater{
currentVersion: currentVersion,
}
}
// PerformUpdate downloads a new version and replaces the current binary
func (u *Updater) PerformUpdate(downloadURL, newVersion string) error {
log.Printf("Performing self-update from %s to %s", u.currentVersion, newVersion)
log.Printf("Download URL: %s", downloadURL)
// Get current executable path
exePath, err := os.Executable()
if err != nil {
return fmt.Errorf("failed to get executable path: %w", err)
}
log.Printf("Current executable: %s", exePath)
// Create temporary file for download
tmpFile := exePath + ".new"
// Download new binary
if err := u.downloadBinary(downloadURL, tmpFile); err != nil {
return fmt.Errorf("failed to download update: %w", err)
}
// Make new binary executable (Unix only)
if runtime.GOOS != "windows" {
if err := os.Chmod(tmpFile, 0755); err != nil {
os.Remove(tmpFile)
return fmt.Errorf("failed to make binary executable: %w", err)
}
}
// Create backup of current binary
backupFile := exePath + ".backup"
if err := u.createBackup(exePath, backupFile); err != nil {
os.Remove(tmpFile)
return fmt.Errorf("failed to create backup: %w", err)
}
log.Println("Backup created successfully")
// Replace current binary with new one
if err := u.replaceBinary(tmpFile, exePath); err != nil {
// Attempt to restore from backup
log.Printf("Update failed, attempting to restore from backup: %v", err)
if restoreErr := u.replaceBinary(backupFile, exePath); restoreErr != nil {
return fmt.Errorf("update failed and backup restoration failed: %w (original error: %v)", restoreErr, err)
}
return fmt.Errorf("update failed, restored from backup: %w", err)
}
log.Printf("Successfully updated to version %s", newVersion)
log.Println("NOTE: Restart the agent to use the new version")
// Clean up backup file after successful update
os.Remove(backupFile)
return nil
}
// downloadBinary downloads a binary from the given URL to the destination path
func (u *Updater) downloadBinary(url, destPath string) error {
log.Printf("Downloading binary from %s", url)
// Create HTTP client with timeout
client := &http.Client{
Timeout: 10 * time.Minute, // Binary download may take longer
}
// Download file
resp, err := client.Get(url)
if err != nil {
return fmt.Errorf("failed to download: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("download failed with status: %d", resp.StatusCode)
}
// Create destination file
file, err := os.Create(destPath)
if err != nil {
return fmt.Errorf("failed to create file: %w", err)
}
defer file.Close()
// Copy content
written, err := io.Copy(file, resp.Body)
if err != nil {
return fmt.Errorf("failed to write file: %w", err)
}
log.Printf("Downloaded %d bytes", written)
return nil
}
// createBackup creates a backup copy of a file
func (u *Updater) createBackup(src, dest string) error {
srcFile, err := os.Open(src)
if err != nil {
return err
}
defer srcFile.Close()
destFile, err := os.Create(dest)
if err != nil {
return err
}
defer destFile.Close()
_, err = io.Copy(destFile, srcFile)
return err
}
// replaceBinary replaces the destination binary with the source binary
func (u *Updater) replaceBinary(src, dest string) error {
// On Windows, we can't replace a running executable directly
// We need to rename the old one and move the new one in place
if runtime.GOOS == "windows" {
oldExe := dest + ".old"
// Remove any existing .old file
os.Remove(oldExe)
// Rename current executable
if err := os.Rename(dest, oldExe); err != nil {
return fmt.Errorf("failed to rename current executable: %w", err)
}
// Move new executable into place
if err := os.Rename(src, dest); err != nil {
// Try to restore
os.Rename(oldExe, dest)
return fmt.Errorf("failed to move new executable: %w", err)
}
// Schedule old executable for deletion on next boot
// (Windows doesn't allow deleting running executables)
return nil
}
// On Unix, we can replace the file directly
// The running process will continue using the old inode
return os.Rename(src, dest)
}
// GetCurrentVersion returns the current version
func (u *Updater) GetCurrentVersion() string {
return u.currentVersion
}
// VerifyUpdate verifies that an update was successful by checking the version
func (u *Updater) VerifyUpdate(expectedVersion string) error {
exePath, err := os.Executable()
if err != nil {
return fmt.Errorf("failed to get executable path: %w", err)
}
info, err := os.Stat(exePath)
if err != nil {
return fmt.Errorf("failed to stat executable: %w", err)
}
log.Printf("Executable info: size=%d, mod_time=%s", info.Size(), info.ModTime())
// In a real implementation, you might embed version info in the binary
// or verify a checksum. For now, we just verify the file exists and was modified recently
if time.Since(info.ModTime()) > 5*time.Minute {
return fmt.Errorf("executable was not recently modified")
}
return nil
}
// CleanupOldVersions removes old backup files
func (u *Updater) CleanupOldVersions() error {
exePath, err := os.Executable()
if err != nil {
return err
}
dir := filepath.Dir(exePath)
baseName := filepath.Base(exePath)
// Remove backup files
patterns := []string{
baseName + ".backup",
baseName + ".old",
baseName + ".new",
}
for _, pattern := range patterns {
path := filepath.Join(dir, pattern)
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
log.Printf("Failed to remove old version %s: %v", path, err)
}
}
return nil
}