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>
This commit is contained in:
180
companion-agent/internal/deploy/deploy.go
Normal file
180
companion-agent/internal/deploy/deploy.go
Normal 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user