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:
@@ -5,6 +5,7 @@ import (
|
|||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
|
"runtime"
|
||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -24,9 +25,12 @@ type Config struct {
|
|||||||
|
|
||||||
// Game server configuration
|
// Game server configuration
|
||||||
SteamCMDPath string `envconfig:"STEAMCMD_PATH" default:"/usr/games/steamcmd"`
|
SteamCMDPath string `envconfig:"STEAMCMD_PATH" default:"/usr/games/steamcmd"`
|
||||||
GameServerPath string `envconfig:"GAME_SERVER_PATH" required:"true"`
|
GameServerPath string `envconfig:"GAME_SERVER_PATH" default:""`
|
||||||
GameServerArgs string `envconfig:"GAME_SERVER_ARGS" default:"-batchmode"`
|
GameServerArgs string `envconfig:"GAME_SERVER_ARGS" default:"-batchmode"`
|
||||||
|
|
||||||
|
// Install directory for deployment
|
||||||
|
InstallDir string `envconfig:"INSTALL_DIR" default:""`
|
||||||
|
|
||||||
// Optional settings
|
// Optional settings
|
||||||
HeartbeatInterval int `envconfig:"HEARTBEAT_INTERVAL" default:"60"`
|
HeartbeatInterval int `envconfig:"HEARTBEAT_INTERVAL" default:"60"`
|
||||||
LogLevel string `envconfig:"LOG_LEVEL" default:"info"`
|
LogLevel string `envconfig:"LOG_LEVEL" default:"info"`
|
||||||
@@ -44,11 +48,21 @@ func main() {
|
|||||||
log.Fatalf("Failed to load configuration: %v", err)
|
log.Fatalf("Failed to load configuration: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Set default InstallDir based on OS if not configured
|
||||||
|
if cfg.InstallDir == "" {
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
cfg.InstallDir = `C:\RustServer`
|
||||||
|
} else {
|
||||||
|
cfg.InstallDir = "/opt/rustserver"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
log.Printf("Configuration loaded:")
|
log.Printf("Configuration loaded:")
|
||||||
log.Printf(" NATS URL: %s", cfg.NATSUrl)
|
log.Printf(" NATS URL: %s", cfg.NATSUrl)
|
||||||
log.Printf(" License ID: %s", cfg.LicenseID)
|
log.Printf(" License ID: %s", cfg.LicenseID)
|
||||||
log.Printf(" Game Server Path: %s", cfg.GameServerPath)
|
log.Printf(" Game Server Path: %s", cfg.GameServerPath)
|
||||||
log.Printf(" SteamCMD Path: %s", cfg.SteamCMDPath)
|
log.Printf(" SteamCMD Path: %s", cfg.SteamCMDPath)
|
||||||
|
log.Printf(" Install Dir: %s", cfg.InstallDir)
|
||||||
log.Printf(" Heartbeat Interval: %ds", cfg.HeartbeatInterval)
|
log.Printf(" Heartbeat Interval: %ds", cfg.HeartbeatInterval)
|
||||||
|
|
||||||
// Create context with signal handling for graceful shutdown
|
// Create context with signal handling for graceful shutdown
|
||||||
@@ -73,6 +87,7 @@ func main() {
|
|||||||
GameServerPath: cfg.GameServerPath,
|
GameServerPath: cfg.GameServerPath,
|
||||||
GameServerArgs: cfg.GameServerArgs,
|
GameServerArgs: cfg.GameServerArgs,
|
||||||
Version: version,
|
Version: version,
|
||||||
|
InstallDir: cfg.InstallDir,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start daemon
|
// Start daemon
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/nats-io/nats.go"
|
"github.com/nats-io/nats.go"
|
||||||
|
"github.com/vigilcyber/corrosion-companion/internal/deploy"
|
||||||
"github.com/vigilcyber/corrosion-companion/internal/files"
|
"github.com/vigilcyber/corrosion-companion/internal/files"
|
||||||
"github.com/vigilcyber/corrosion-companion/internal/process"
|
"github.com/vigilcyber/corrosion-companion/internal/process"
|
||||||
"github.com/vigilcyber/corrosion-companion/internal/update"
|
"github.com/vigilcyber/corrosion-companion/internal/update"
|
||||||
@@ -22,6 +23,7 @@ type DaemonConfig struct {
|
|||||||
GameServerPath string
|
GameServerPath string
|
||||||
GameServerArgs string
|
GameServerArgs string
|
||||||
Version string
|
Version string
|
||||||
|
InstallDir string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Daemon manages the companion agent's main operations
|
// Daemon manages the companion agent's main operations
|
||||||
@@ -31,6 +33,7 @@ type Daemon struct {
|
|||||||
gameServer *process.GameServer
|
gameServer *process.GameServer
|
||||||
fileOps *files.Operations
|
fileOps *files.Operations
|
||||||
updater *update.Updater
|
updater *update.Updater
|
||||||
|
deployer *deploy.Deployer
|
||||||
subscriptions []*nats.Subscription
|
subscriptions []*nats.Subscription
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -44,9 +47,26 @@ type HeartbeatPayload struct {
|
|||||||
CPUPercent float64 `json:"cpu_percent"`
|
CPUPercent float64 `json:"cpu_percent"`
|
||||||
LastUpdate string `json:"last_update"`
|
LastUpdate string `json:"last_update"`
|
||||||
PlayerCount int `json:"player_count"`
|
PlayerCount int `json:"player_count"`
|
||||||
Version string `json:"version"`
|
Version string `json:"version"`
|
||||||
OS string `json:"os"`
|
OS string `json:"os"`
|
||||||
Arch string `json:"arch"`
|
Arch string `json:"arch"`
|
||||||
|
ServerInstalled bool `json:"server_installed"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// gameServerAdapter wraps process.GameServer to satisfy deploy.GameServerStarter
|
||||||
|
type gameServerAdapter struct {
|
||||||
|
gs *process.GameServer
|
||||||
|
cfg *DaemonConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *gameServerAdapter) Start() error {
|
||||||
|
return a.gs.Start()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *gameServerAdapter) UpdatePath(path string) {
|
||||||
|
a.cfg.GameServerPath = path
|
||||||
|
// Recreate game server with new path
|
||||||
|
*a.gs = *process.NewGameServer(path, a.cfg.GameServerArgs)
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewDaemon creates a new daemon instance
|
// NewDaemon creates a new daemon instance
|
||||||
@@ -54,6 +74,8 @@ func NewDaemon(nc *nats.Conn, cfg *DaemonConfig) (*Daemon, error) {
|
|||||||
gameServer := process.NewGameServer(cfg.GameServerPath, cfg.GameServerArgs)
|
gameServer := process.NewGameServer(cfg.GameServerPath, cfg.GameServerArgs)
|
||||||
fileOps := files.NewOperations()
|
fileOps := files.NewOperations()
|
||||||
updater := update.NewUpdater(cfg.Version)
|
updater := update.NewUpdater(cfg.Version)
|
||||||
|
adapter := &gameServerAdapter{gs: gameServer, cfg: cfg}
|
||||||
|
deployer := deploy.NewDeployer(nc, cfg.LicenseID, cfg.InstallDir, adapter)
|
||||||
|
|
||||||
d := &Daemon{
|
d := &Daemon{
|
||||||
nc: nc,
|
nc: nc,
|
||||||
@@ -61,6 +83,7 @@ func NewDaemon(nc *nats.Conn, cfg *DaemonConfig) (*Daemon, error) {
|
|||||||
gameServer: gameServer,
|
gameServer: gameServer,
|
||||||
fileOps: fileOps,
|
fileOps: fileOps,
|
||||||
updater: updater,
|
updater: updater,
|
||||||
|
deployer: deployer,
|
||||||
}
|
}
|
||||||
|
|
||||||
return d, nil
|
return d, nil
|
||||||
@@ -90,6 +113,11 @@ func (d *Daemon) Run(ctx context.Context) error {
|
|||||||
return fmt.Errorf("failed to subscribe to self-update: %w", err)
|
return fmt.Errorf("failed to subscribe to self-update: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Subscribe to deploy commands
|
||||||
|
if err := d.subscribeDeployCommand(); err != nil {
|
||||||
|
return fmt.Errorf("failed to subscribe to deploy commands: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
log.Println("All subscriptions active")
|
log.Println("All subscriptions active")
|
||||||
|
|
||||||
// Start heartbeat ticker
|
// Start heartbeat ticker
|
||||||
@@ -267,6 +295,49 @@ func (d *Daemon) subscribeSelfUpdate() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// subscribeDeployCommand subscribes to server deployment commands
|
||||||
|
func (d *Daemon) subscribeDeployCommand() error {
|
||||||
|
subject := fmt.Sprintf("corrosion.%s.cmd.deploy", d.cfg.LicenseID)
|
||||||
|
|
||||||
|
sub, err := d.nc.Subscribe(subject, func(msg *nats.Msg) {
|
||||||
|
var cmd struct {
|
||||||
|
Action string `json:"action"`
|
||||||
|
Config deploy.DeployConfig `json:"config"`
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := json.Unmarshal(msg.Data, &cmd); err != nil {
|
||||||
|
log.Printf("Failed to parse deploy command: %v", err)
|
||||||
|
d.respondError(msg, "invalid_command", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("Received deploy command: %s", cmd.Action)
|
||||||
|
|
||||||
|
// Run deployment in goroutine (it's long-running)
|
||||||
|
go func() {
|
||||||
|
if err := d.deployer.Deploy(cmd.Config); err != nil {
|
||||||
|
log.Printf("Deployment failed: %v", err)
|
||||||
|
} else {
|
||||||
|
log.Println("Deployment completed successfully")
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Immediately acknowledge the command
|
||||||
|
d.respondSuccess(msg, map[string]interface{}{
|
||||||
|
"status": "accepted",
|
||||||
|
"message": "Deployment started, progress will be published to deploy.status",
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
d.subscriptions = append(d.subscriptions, sub)
|
||||||
|
log.Printf("Subscribed to: %s", subject)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// handleFileOperation processes file operation requests
|
// handleFileOperation processes file operation requests
|
||||||
func (d *Daemon) handleFileOperation(msg *nats.Msg) {
|
func (d *Daemon) handleFileOperation(msg *nats.Msg) {
|
||||||
// Parse common fields
|
// Parse common fields
|
||||||
@@ -325,17 +396,18 @@ func (d *Daemon) publishHeartbeat() {
|
|||||||
diskFree := getDiskFreeSpace(d.cfg.GameServerPath)
|
diskFree := getDiskFreeSpace(d.cfg.GameServerPath)
|
||||||
|
|
||||||
payload := HeartbeatPayload{
|
payload := HeartbeatPayload{
|
||||||
Timestamp: time.Now().UTC().Format(time.RFC3339),
|
Timestamp: time.Now().UTC().Format(time.RFC3339),
|
||||||
Status: "running",
|
Status: "running",
|
||||||
ServerStatus: status,
|
ServerStatus: status,
|
||||||
UptimeSeconds: int64(uptime.Seconds()),
|
UptimeSeconds: int64(uptime.Seconds()),
|
||||||
DiskFreeMB: diskFree,
|
DiskFreeMB: diskFree,
|
||||||
CPUPercent: 0.0, // TODO: Implement CPU monitoring
|
CPUPercent: 0.0, // TODO: Implement CPU monitoring
|
||||||
LastUpdate: "", // TODO: Track last SteamCMD update
|
LastUpdate: "", // TODO: Track last SteamCMD update
|
||||||
PlayerCount: 0, // Populated by plugin, not companion
|
PlayerCount: 0, // Populated by plugin, not companion
|
||||||
Version: d.cfg.Version,
|
Version: d.cfg.Version,
|
||||||
OS: runtime.GOOS,
|
OS: runtime.GOOS,
|
||||||
Arch: runtime.GOARCH,
|
Arch: runtime.GOARCH,
|
||||||
|
ServerInstalled: deploy.CheckServerInstalled(d.cfg.InstallDir),
|
||||||
}
|
}
|
||||||
|
|
||||||
data, err := json.Marshal(payload)
|
data, err := json.Marshal(payload)
|
||||||
|
|||||||
71
companion-agent/internal/deploy/config.go
Normal file
71
companion-agent/internal/deploy/config.go
Normal 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
|
||||||
|
}
|
||||||
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
127
companion-agent/internal/deploy/deploy_linux.go
Normal file
127
companion-agent/internal/deploy/deploy_linux.go
Normal 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
|
||||||
|
}
|
||||||
145
companion-agent/internal/deploy/deploy_windows.go
Normal file
145
companion-agent/internal/deploy/deploy_windows.go
Normal 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
|
||||||
|
}
|
||||||
@@ -17,8 +17,6 @@ import {
|
|||||||
Rocket,
|
Rocket,
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
Check,
|
Check,
|
||||||
ClipboardCopy,
|
|
||||||
ChevronRight,
|
|
||||||
} from 'lucide-vue-next'
|
} from 'lucide-vue-next'
|
||||||
import type { DeploymentConfig, DeploymentStatus } from '@/types'
|
import type { DeploymentConfig, DeploymentStatus } from '@/types'
|
||||||
import { useWebSocket } from '@/composables/useWebSocket'
|
import { useWebSocket } from '@/composables/useWebSocket'
|
||||||
@@ -194,9 +192,9 @@ onMounted(async () => {
|
|||||||
loadFormFromConfig()
|
loadFormFromConfig()
|
||||||
|
|
||||||
const ws = useWebSocket()
|
const ws = useWebSocket()
|
||||||
ws.on('event', (data: any) => {
|
ws.subscribe((msg) => {
|
||||||
if (data.event === 'deploy_status') {
|
if (msg.type === 'event' && msg.event === 'deploy_status') {
|
||||||
server.updateDeploymentStatus(data.data as DeploymentStatus)
|
server.updateDeploymentStatus(msg.data as DeploymentStatus)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user