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:
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
|
||||
}
|
||||
Reference in New Issue
Block a user