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

@@ -9,6 +9,7 @@ import (
"time"
"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/process"
"github.com/vigilcyber/corrosion-companion/internal/update"
@@ -22,6 +23,7 @@ type DaemonConfig struct {
GameServerPath string
GameServerArgs string
Version string
InstallDir string
}
// Daemon manages the companion agent's main operations
@@ -31,6 +33,7 @@ type Daemon struct {
gameServer *process.GameServer
fileOps *files.Operations
updater *update.Updater
deployer *deploy.Deployer
subscriptions []*nats.Subscription
}
@@ -44,9 +47,26 @@ type HeartbeatPayload struct {
CPUPercent float64 `json:"cpu_percent"`
LastUpdate string `json:"last_update"`
PlayerCount int `json:"player_count"`
Version string `json:"version"`
OS string `json:"os"`
Arch string `json:"arch"`
Version string `json:"version"`
OS string `json:"os"`
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
@@ -54,6 +74,8 @@ func NewDaemon(nc *nats.Conn, cfg *DaemonConfig) (*Daemon, error) {
gameServer := process.NewGameServer(cfg.GameServerPath, cfg.GameServerArgs)
fileOps := files.NewOperations()
updater := update.NewUpdater(cfg.Version)
adapter := &gameServerAdapter{gs: gameServer, cfg: cfg}
deployer := deploy.NewDeployer(nc, cfg.LicenseID, cfg.InstallDir, adapter)
d := &Daemon{
nc: nc,
@@ -61,6 +83,7 @@ func NewDaemon(nc *nats.Conn, cfg *DaemonConfig) (*Daemon, error) {
gameServer: gameServer,
fileOps: fileOps,
updater: updater,
deployer: deployer,
}
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)
}
// 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")
// Start heartbeat ticker
@@ -267,6 +295,49 @@ func (d *Daemon) subscribeSelfUpdate() error {
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
func (d *Daemon) handleFileOperation(msg *nats.Msg) {
// Parse common fields
@@ -325,17 +396,18 @@ func (d *Daemon) publishHeartbeat() {
diskFree := getDiskFreeSpace(d.cfg.GameServerPath)
payload := HeartbeatPayload{
Timestamp: time.Now().UTC().Format(time.RFC3339),
Status: "running",
ServerStatus: status,
UptimeSeconds: int64(uptime.Seconds()),
DiskFreeMB: diskFree,
CPUPercent: 0.0, // TODO: Implement CPU monitoring
LastUpdate: "", // TODO: Track last SteamCMD update
PlayerCount: 0, // Populated by plugin, not companion
Version: d.cfg.Version,
OS: runtime.GOOS,
Arch: runtime.GOARCH,
Timestamp: time.Now().UTC().Format(time.RFC3339),
Status: "running",
ServerStatus: status,
UptimeSeconds: int64(uptime.Seconds()),
DiskFreeMB: diskFree,
CPUPercent: 0.0, // TODO: Implement CPU monitoring
LastUpdate: "", // TODO: Track last SteamCMD update
PlayerCount: 0, // Populated by plugin, not companion
Version: d.cfg.Version,
OS: runtime.GOOS,
Arch: runtime.GOARCH,
ServerInstalled: deploy.CheckServerInstalled(d.cfg.InstallDir),
}
data, err := json.Marshal(payload)