Files
Vantz Stockwell 358adde496
All checks were successful
Build Companion Agent / build (push) Successful in 30s
Test Asgard Runner / test (push) Successful in 3s
feat: Add companion agent one-click deployment + fix frontend TS errors
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>
2026-02-21 14:49:48 -05:00

181 lines
5.6 KiB
Go

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)
}
}