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>
This commit is contained in:
223
companion-agent/internal/update/updater.go
Normal file
223
companion-agent/internal/update/updater.go
Normal file
@@ -0,0 +1,223 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user