feat: Add companion agent one-click deployment + fix frontend TS errors
All checks were successful
Build Companion Agent / build (push) Successful in 30s
Test Asgard Runner / test (push) Successful in 3s

Go deployment orchestrator with platform-specific SteamCMD install,
Rust server download, server.cfg generation, and service registration.
Wire deploy command subscription in daemon, make GameServerPath optional,
add InstallDir config with OS-aware defaults. Fix unused imports and
WebSocket subscribe API in ServerView.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Vantz Stockwell
2026-02-21 14:49:48 -05:00
parent b94717d51b
commit 358adde496
7 changed files with 628 additions and 20 deletions

View File

@@ -0,0 +1,71 @@
package deploy
import (
"fmt"
"os"
"path/filepath"
)
// DeployConfig holds the configuration received from a NATS cmd.deploy command.
// These fields map directly to the Rust game server settings needed for initial deployment.
type DeployConfig struct {
ServerName string `json:"server_name"`
MaxPlayers int `json:"max_players"`
WorldSize int `json:"world_size"`
Seed int `json:"seed"`
ServerPort int `json:"server_port"`
RconPort int `json:"rcon_port"`
RconPassword string `json:"rcon_password"`
}
// DeployStatus represents a progress update published to NATS during deployment.
// The frontend listens on corrosion.{license_id}.deploy.status for these messages
// to display real-time deployment progress to the user.
type DeployStatus struct {
Stage string `json:"stage"`
Progress int `json:"progress"`
Message string `json:"message"`
Error string `json:"error,omitempty"`
Timestamp string `json:"timestamp"`
}
// Valid deployment stages:
// downloading_steamcmd - Downloading and extracting SteamCMD
// installing_steamcmd - Running SteamCMD initial setup
// downloading_rust - Downloading Rust Dedicated Server via SteamCMD
// configuring - Generating server.cfg and identity directories
// starting - Launching the Rust server process
// online - Server is running and accepting connections
// failed - Deployment failed at some stage
// GenerateServerCfg creates the server.cfg file for a Rust Dedicated Server.
// It writes to {installDir}/server/server/corrosion/cfg/server.cfg, creating
// the full directory tree if it does not already exist.
func GenerateServerCfg(installDir string, cfg DeployConfig) error {
cfgDir := filepath.Join(installDir, "server", "server", "corrosion", "cfg")
if err := os.MkdirAll(cfgDir, 0755); err != nil {
return fmt.Errorf("failed to create cfg directory %s: %w", cfgDir, err)
}
content := fmt.Sprintf(`server.hostname "%s"
server.maxplayers %d
server.worldsize %d
server.seed %d
server.port %d
rcon.port %d
rcon.password "%s"
rcon.web 1
server.identity "corrosion"
server.saveinterval 300
`, cfg.ServerName, cfg.MaxPlayers, cfg.WorldSize, cfg.Seed,
cfg.ServerPort, cfg.RconPort, cfg.RconPassword)
cfgPath := filepath.Join(cfgDir, "server.cfg")
if err := os.WriteFile(cfgPath, []byte(content), 0644); err != nil {
return fmt.Errorf("failed to write server.cfg to %s: %w", cfgPath, err)
}
return nil
}

View File

@@ -0,0 +1,180 @@
package deploy
import (
"encoding/json"
"fmt"
"log"
"os"
"os/exec"
"path/filepath"
"runtime"
"time"
"github.com/nats-io/nats.go"
)
// GameServerStarter abstracts the game server process manager so the deployer
// can set the executable path and start the server without depending on the
// concrete process.GameServer type. The existing GameServer will implement
// UpdatePath in a separate task.
type GameServerStarter interface {
Start() error
UpdatePath(path string)
}
// Deployer orchestrates one-click Rust server deployment. It downloads SteamCMD,
// installs the Rust Dedicated Server, generates server.cfg, and starts the server
// process — publishing progress updates to NATS at each stage so the frontend can
// display real-time deployment status.
type Deployer struct {
nc *nats.Conn
licenseID string
installDir string
gameServer GameServerStarter
}
// NewDeployer creates a new Deployer instance.
func NewDeployer(nc *nats.Conn, licenseID, installDir string, gs GameServerStarter) *Deployer {
return &Deployer{
nc: nc,
licenseID: licenseID,
installDir: installDir,
gameServer: gs,
}
}
// Deploy executes the full deployment pipeline: SteamCMD install, Rust server
// download, config generation, and server startup. If any stage fails, a "failed"
// status is published and the error is returned. Progress updates are published
// to NATS at each stage transition.
func (d *Deployer) Deploy(cfg DeployConfig) error {
// Stage 1: SteamCMD
log.Printf("Deploy: starting SteamCMD installation for license %s", d.licenseID)
d.publishStatus("downloading_steamcmd", 0, "Checking for existing SteamCMD installation...")
steamcmdPath, err := InstallSteamCMD(d.installDir)
if err != nil {
d.publishStatus("failed", 0, "SteamCMD installation failed", err.Error())
return fmt.Errorf("steamcmd install failed: %w", err)
}
log.Printf("Deploy: SteamCMD ready at %s", steamcmdPath)
d.publishStatus("downloading_steamcmd", 100, "SteamCMD ready")
// Stage 2: Download Rust Dedicated Server
log.Printf("Deploy: downloading Rust Dedicated Server via SteamCMD")
d.publishStatus("downloading_rust", 0, "Downloading Rust Dedicated Server via SteamCMD...")
if err := DownloadRustServer(steamcmdPath, d.installDir); err != nil {
d.publishStatus("failed", 0, "Rust server download failed", err.Error())
return fmt.Errorf("rust server download failed: %w", err)
}
log.Printf("Deploy: Rust Dedicated Server installed")
d.publishStatus("downloading_rust", 100, "Rust Dedicated Server installed")
// Stage 3: Generate server.cfg
log.Printf("Deploy: generating server.cfg")
d.publishStatus("configuring", 0, "Generating server.cfg...")
if err := GenerateServerCfg(d.installDir, cfg); err != nil {
d.publishStatus("failed", 0, "Server configuration failed", err.Error())
return fmt.Errorf("config generation failed: %w", err)
}
log.Printf("Deploy: server.cfg written")
d.publishStatus("configuring", 100, "Server configured")
// Stage 4: Start the server
log.Printf("Deploy: starting Rust server")
d.publishStatus("starting", 0, "Starting Rust server...")
var exePath string
switch runtime.GOOS {
case "windows":
exePath = filepath.Join(d.installDir, "server", "RustDedicated.exe")
default:
exePath = filepath.Join(d.installDir, "server", "RustDedicated")
}
d.gameServer.UpdatePath(exePath)
if err := d.gameServer.Start(); err != nil {
d.publishStatus("failed", 0, "Server failed to start", err.Error())
return fmt.Errorf("server start failed: %w", err)
}
log.Printf("Deploy: Rust server is now running")
d.publishStatus("online", 100, "Rust server is now running")
return nil
}
// DownloadRustServer runs SteamCMD to download/update the Rust Dedicated Server
// (App ID 258550) into {installDir}/server. This function is platform-agnostic —
// it simply executes the steamcmd binary which was installed by the platform-specific
// InstallSteamCMD function.
func DownloadRustServer(steamcmdPath, installDir string) error {
serverDir := filepath.Join(installDir, "server")
log.Printf("Downloading Rust Dedicated Server to %s", serverDir)
cmd := exec.Command(steamcmdPath,
"+login", "anonymous",
"+force_install_dir", serverDir,
"+app_update", "258550", "validate",
"+quit",
)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
return fmt.Errorf("steamcmd app_update 258550 failed: %w", err)
}
return nil
}
// CheckServerInstalled returns true if the Rust Dedicated Server executable
// exists at the expected path within the install directory.
func CheckServerInstalled(installDir string) bool {
var exePath string
switch runtime.GOOS {
case "windows":
exePath = filepath.Join(installDir, "server", "RustDedicated.exe")
default:
exePath = filepath.Join(installDir, "server", "RustDedicated")
}
_, err := os.Stat(exePath)
return err == nil
}
// publishStatus publishes a DeployStatus message to the NATS subject
// corrosion.{licenseID}.deploy.status. Publish errors are logged but do not
// fail the deployment — losing a progress update is not fatal.
func (d *Deployer) publishStatus(stage string, progress int, message string, errDetail ...string) {
subject := fmt.Sprintf("corrosion.%s.deploy.status", d.licenseID)
status := DeployStatus{
Stage: stage,
Progress: progress,
Message: message,
Timestamp: time.Now().UTC().Format(time.RFC3339),
}
if len(errDetail) > 0 && errDetail[0] != "" {
status.Error = errDetail[0]
}
data, err := json.Marshal(status)
if err != nil {
log.Printf("Failed to marshal deploy status: %v", err)
return
}
if err := d.nc.Publish(subject, data); err != nil {
log.Printf("Failed to publish deploy status to %s: %v", subject, err)
}
}

View File

@@ -0,0 +1,127 @@
//go:build linux
package deploy
import (
"fmt"
"io"
"log"
"net/http"
"os"
"os/exec"
"path/filepath"
)
// InstallSteamCMD downloads and installs SteamCMD for Linux into the given
// install directory. If SteamCMD is already present it returns the existing
// path without re-downloading. The returned string is the absolute path to
// the steamcmd.sh executable.
func InstallSteamCMD(installDir string) (string, error) {
steamcmdDir := filepath.Join(installDir, "steamcmd")
steamcmdPath := filepath.Join(steamcmdDir, "steamcmd.sh")
// Already installed — nothing to do.
if _, err := os.Stat(steamcmdPath); err == nil {
log.Printf("SteamCMD already installed at %s", steamcmdPath)
return steamcmdPath, nil
}
if err := os.MkdirAll(steamcmdDir, 0755); err != nil {
return "", fmt.Errorf("failed to create steamcmd directory %s: %w", steamcmdDir, err)
}
// Download the Linux tarball.
tarball := filepath.Join(steamcmdDir, "steamcmd_linux.tar.gz")
if err := downloadFile("https://steamcdn-a.akamaihd.net/client/installer/steamcmd_linux.tar.gz", tarball); err != nil {
return "", fmt.Errorf("failed to download steamcmd: %w", err)
}
// Extract with tar.
cmd := exec.Command("tar", "-xzf", "steamcmd_linux.tar.gz")
cmd.Dir = steamcmdDir
if out, err := cmd.CombinedOutput(); err != nil {
return "", fmt.Errorf("failed to extract steamcmd: %w — output: %s", err, string(out))
}
// Ensure the script is executable.
if err := os.Chmod(steamcmdPath, 0755); err != nil {
return "", fmt.Errorf("failed to chmod steamcmd.sh: %w", err)
}
// Verify the installation by running +quit (triggers first-time setup).
verify := exec.Command(steamcmdPath, "+quit")
verify.Dir = steamcmdDir
if out, err := verify.CombinedOutput(); err != nil {
return "", fmt.Errorf("steamcmd verification failed: %w — output: %s", err, string(out))
}
log.Printf("SteamCMD installed successfully at %s", steamcmdPath)
return steamcmdPath, nil
}
// RegisterService creates a systemd unit file for the Rust Dedicated Server
// and enables it. If the caller does not have root access, the unit file is
// written into installDir as a fallback so the user can install it manually.
func RegisterService(installDir string, cfg DeployConfig) error {
serverPath := filepath.Join(installDir, "server", "RustDedicated")
unit := fmt.Sprintf(`[Unit]
Description=Rust Dedicated Server (Corrosion Managed)
After=network.target
[Service]
Type=simple
User=root
WorkingDirectory=%s/server
ExecStart=%s -batchmode +server.hostname "%s" +server.port %d +rcon.port %d +rcon.password "%s" +rcon.web 1 +server.identity "corrosion" +server.maxplayers %d +server.worldsize %d +server.seed %d
Restart=on-failure
RestartSec=10
[Install]
WantedBy=multi-user.target
`, installDir, serverPath, cfg.ServerName, cfg.ServerPort, cfg.RconPort,
cfg.RconPassword, cfg.MaxPlayers, cfg.WorldSize, cfg.Seed)
systemdPath := "/etc/systemd/system/rustserver.service"
if err := os.WriteFile(systemdPath, []byte(unit), 0644); err != nil {
// Fallback — write into installDir so the user can place it manually.
fallback := filepath.Join(installDir, "rustserver.service")
log.Printf("WARNING: cannot write to %s (%v), falling back to %s", systemdPath, err, fallback)
if writeErr := os.WriteFile(fallback, []byte(unit), 0644); writeErr != nil {
return fmt.Errorf("failed to write service file to fallback %s: %w", fallback, writeErr)
}
}
// Best-effort daemon-reload and enable — ignore errors (systemctl may not
// exist or the user may lack privileges).
_ = exec.Command("systemctl", "daemon-reload").Run()
_ = exec.Command("systemctl", "enable", "rustserver").Run()
log.Println("Systemd service registered for rustserver")
return nil
}
// downloadFile fetches url and writes the response body to dest on disk.
func downloadFile(url, dest string) error {
resp, err := http.Get(url)
if err != nil {
return fmt.Errorf("GET %s failed: %w", url, err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("GET %s returned status %d", url, resp.StatusCode)
}
out, err := os.Create(dest)
if err != nil {
return fmt.Errorf("failed to create file %s: %w", dest, err)
}
defer out.Close()
if _, err := io.Copy(out, resp.Body); err != nil {
return fmt.Errorf("failed to write to %s: %w", dest, err)
}
return nil
}

View File

@@ -0,0 +1,145 @@
//go:build windows
package deploy
import (
"archive/zip"
"fmt"
"io"
"log"
"net/http"
"os"
"os/exec"
"path/filepath"
)
// InstallSteamCMD downloads and installs SteamCMD for Windows into the given
// install directory. If SteamCMD is already present it returns the existing
// path without re-downloading. The returned string is the absolute path to
// steamcmd.exe.
func InstallSteamCMD(installDir string) (string, error) {
steamcmdDir := filepath.Join(installDir, "steamcmd")
steamcmdPath := filepath.Join(steamcmdDir, "steamcmd.exe")
// Already installed — nothing to do.
if _, err := os.Stat(steamcmdPath); err == nil {
log.Printf("SteamCMD already installed at %s", steamcmdPath)
return steamcmdPath, nil
}
if err := os.MkdirAll(steamcmdDir, 0755); err != nil {
return "", fmt.Errorf("failed to create steamcmd directory %s: %w", steamcmdDir, err)
}
// Download the Windows zip.
zipPath := filepath.Join(steamcmdDir, "steamcmd.zip")
if err := downloadFile("https://steamcdn-a.akamaihd.net/client/installer/steamcmd.zip", zipPath); err != nil {
return "", fmt.Errorf("failed to download steamcmd: %w", err)
}
// Extract the zip into steamcmdDir.
if err := extractZip(zipPath, steamcmdDir); err != nil {
return "", fmt.Errorf("failed to extract steamcmd.zip: %w", err)
}
// Verify the exe landed where expected.
if _, err := os.Stat(steamcmdPath); err != nil {
return "", fmt.Errorf("steamcmd.exe not found after extraction: %w", err)
}
log.Printf("SteamCMD installed successfully at %s", steamcmdPath)
return steamcmdPath, nil
}
// RegisterService creates a Windows service for the Rust Dedicated Server
// using sc.exe. If the caller does not have administrator privileges the
// command will fail silently with a warning log.
func RegisterService(installDir string, cfg DeployConfig) error {
serverPath := filepath.Join(installDir, "server", "RustDedicated.exe")
binPath := fmt.Sprintf(`"%s" -batchmode +server.hostname "%s" +server.port %d +rcon.port %d +rcon.password "%s" +rcon.web 1 +server.identity "corrosion" +server.maxplayers %d +server.worldsize %d +server.seed %d`,
serverPath, cfg.ServerName, cfg.ServerPort, cfg.RconPort,
cfg.RconPassword, cfg.MaxPlayers, cfg.WorldSize, cfg.Seed)
cmd := exec.Command("sc.exe", "create", "RustServer", "binPath=", binPath, "start=", "auto")
if out, err := cmd.CombinedOutput(); err != nil {
log.Printf("WARNING: sc.exe create failed (may require admin): %v — output: %s", err, string(out))
} else {
log.Println("Windows service RustServer registered successfully")
}
return nil
}
// downloadFile fetches url and writes the response body to dest on disk.
func downloadFile(url, dest string) error {
resp, err := http.Get(url)
if err != nil {
return fmt.Errorf("GET %s failed: %w", url, err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("GET %s returned status %d", url, resp.StatusCode)
}
out, err := os.Create(dest)
if err != nil {
return fmt.Errorf("failed to create file %s: %w", dest, err)
}
defer out.Close()
if _, err := io.Copy(out, resp.Body); err != nil {
return fmt.Errorf("failed to write to %s: %w", dest, err)
}
return nil
}
// extractZip extracts all files from a zip archive into destDir, preserving
// the directory structure from the archive.
func extractZip(zipPath, destDir string) error {
r, err := zip.OpenReader(zipPath)
if err != nil {
return fmt.Errorf("failed to open zip %s: %w", zipPath, err)
}
defer r.Close()
for _, f := range r.File {
target := filepath.Join(destDir, f.Name)
if f.FileInfo().IsDir() {
if err := os.MkdirAll(target, 0755); err != nil {
return fmt.Errorf("failed to create directory %s: %w", target, err)
}
continue
}
// Ensure the parent directory exists.
if err := os.MkdirAll(filepath.Dir(target), 0755); err != nil {
return fmt.Errorf("failed to create parent dir for %s: %w", target, err)
}
rc, err := f.Open()
if err != nil {
return fmt.Errorf("failed to open zip entry %s: %w", f.Name, err)
}
outFile, err := os.Create(target)
if err != nil {
rc.Close()
return fmt.Errorf("failed to create file %s: %w", target, err)
}
if _, err := io.Copy(outFile, rc); err != nil {
outFile.Close()
rc.Close()
return fmt.Errorf("failed to extract %s: %w", f.Name, err)
}
outFile.Close()
rc.Close()
}
return nil
}