feat: Add Oxide/uMod installer package + wire into companion agent daemon
All checks were successful
Test Asgard Runner / test (push) Successful in 2s
All checks were successful
Test Asgard Runner / test (push) Successful in 2s
New oxide package downloads latest Oxide.Rust release from GitHub,
extracts over the server directory, and restarts the game server.
Progress published to NATS (corrosion.{license_id}.oxide.status).
Heartbeat now reports oxide_installed status.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -11,6 +11,7 @@ import (
|
||||
"github.com/nats-io/nats.go"
|
||||
"github.com/vigilcyber/corrosion-companion/internal/deploy"
|
||||
"github.com/vigilcyber/corrosion-companion/internal/filemanager"
|
||||
"github.com/vigilcyber/corrosion-companion/internal/oxide"
|
||||
"github.com/vigilcyber/corrosion-companion/internal/files"
|
||||
"github.com/vigilcyber/corrosion-companion/internal/process"
|
||||
"github.com/vigilcyber/corrosion-companion/internal/rcon"
|
||||
@@ -32,14 +33,15 @@ type DaemonConfig struct {
|
||||
|
||||
// Daemon manages the companion agent's main operations
|
||||
type Daemon struct {
|
||||
nc *nats.Conn
|
||||
cfg *DaemonConfig
|
||||
gameServer *process.GameServer
|
||||
fileOps *files.Operations
|
||||
fm *filemanager.FileManager
|
||||
updater *update.Updater
|
||||
deployer *deploy.Deployer
|
||||
subscriptions []*nats.Subscription
|
||||
nc *nats.Conn
|
||||
cfg *DaemonConfig
|
||||
gameServer *process.GameServer
|
||||
fileOps *files.Operations
|
||||
fm *filemanager.FileManager
|
||||
updater *update.Updater
|
||||
deployer *deploy.Deployer
|
||||
oxideInstaller *oxide.OxideInstaller
|
||||
subscriptions []*nats.Subscription
|
||||
}
|
||||
|
||||
// HeartbeatPayload represents the data sent in heartbeat messages
|
||||
@@ -56,6 +58,7 @@ type HeartbeatPayload struct {
|
||||
OS string `json:"os"`
|
||||
Arch string `json:"arch"`
|
||||
ServerInstalled bool `json:"server_installed"`
|
||||
OxideInstalled bool `json:"oxide_installed"`
|
||||
}
|
||||
|
||||
// gameServerAdapter wraps process.GameServer to satisfy deploy.GameServerStarter
|
||||
@@ -74,6 +77,15 @@ func (a *gameServerAdapter) UpdatePath(path string) {
|
||||
*a.gs = *process.NewGameServer(path, a.cfg.GameServerArgs)
|
||||
}
|
||||
|
||||
// restartAdapter wraps process.GameServer to satisfy oxide.GameServerRestarter
|
||||
type restartAdapter struct {
|
||||
gs *process.GameServer
|
||||
}
|
||||
|
||||
func (a *restartAdapter) Restart() error {
|
||||
return a.gs.Restart()
|
||||
}
|
||||
|
||||
// NewDaemon creates a new daemon instance
|
||||
func NewDaemon(nc *nats.Conn, cfg *DaemonConfig) (*Daemon, error) {
|
||||
gameServer := process.NewGameServer(cfg.GameServerPath, cfg.GameServerArgs)
|
||||
@@ -82,15 +94,18 @@ func NewDaemon(nc *nats.Conn, cfg *DaemonConfig) (*Daemon, error) {
|
||||
updater := update.NewUpdater(cfg.Version)
|
||||
adapter := &gameServerAdapter{gs: gameServer, cfg: cfg}
|
||||
deployer := deploy.NewDeployer(nc, cfg.LicenseID, cfg.InstallDir, adapter)
|
||||
restarter := &restartAdapter{gs: gameServer}
|
||||
oxideInst := oxide.NewOxideInstaller(nc, cfg.LicenseID, cfg.InstallDir, restarter)
|
||||
|
||||
d := &Daemon{
|
||||
nc: nc,
|
||||
cfg: cfg,
|
||||
gameServer: gameServer,
|
||||
fileOps: fileOps,
|
||||
fm: fm,
|
||||
updater: updater,
|
||||
deployer: deployer,
|
||||
nc: nc,
|
||||
cfg: cfg,
|
||||
gameServer: gameServer,
|
||||
fileOps: fileOps,
|
||||
fm: fm,
|
||||
updater: updater,
|
||||
deployer: deployer,
|
||||
oxideInstaller: oxideInst,
|
||||
}
|
||||
|
||||
return d, nil
|
||||
@@ -125,6 +140,11 @@ func (d *Daemon) Run(ctx context.Context) error {
|
||||
return fmt.Errorf("failed to subscribe to deploy commands: %w", err)
|
||||
}
|
||||
|
||||
// Subscribe to Oxide install commands
|
||||
if err := d.subscribeOxideInstall(); err != nil {
|
||||
return fmt.Errorf("failed to subscribe to oxide install commands: %w", err)
|
||||
}
|
||||
|
||||
// Subscribe to file manager commands (VueFinder-compatible request-reply)
|
||||
if err := d.subscribeFileManager(); err != nil {
|
||||
return fmt.Errorf("failed to subscribe to file manager commands: %w", err)
|
||||
@@ -389,6 +409,38 @@ func (d *Daemon) subscribeFileManager() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// subscribeOxideInstall subscribes to Oxide installation commands
|
||||
func (d *Daemon) subscribeOxideInstall() error {
|
||||
subject := fmt.Sprintf("corrosion.%s.cmd.oxide", d.cfg.LicenseID)
|
||||
|
||||
sub, err := d.nc.Subscribe(subject, func(msg *nats.Msg) {
|
||||
log.Println("Received Oxide install command")
|
||||
|
||||
// Run installation in goroutine (it's long-running)
|
||||
go func() {
|
||||
if err := d.oxideInstaller.Install(); err != nil {
|
||||
log.Printf("Oxide installation failed: %v", err)
|
||||
} else {
|
||||
log.Println("Oxide installation completed successfully")
|
||||
}
|
||||
}()
|
||||
|
||||
// Immediately acknowledge the command
|
||||
d.respondSuccess(msg, map[string]interface{}{
|
||||
"status": "accepted",
|
||||
"message": "Oxide installation started, progress will be published to oxide.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
|
||||
@@ -459,6 +511,7 @@ func (d *Daemon) publishHeartbeat() {
|
||||
OS: runtime.GOOS,
|
||||
Arch: runtime.GOARCH,
|
||||
ServerInstalled: deploy.CheckServerInstalled(d.cfg.InstallDir),
|
||||
OxideInstalled: oxide.CheckOxideInstalled(d.cfg.InstallDir),
|
||||
}
|
||||
|
||||
data, err := json.Marshal(payload)
|
||||
|
||||
250
companion-agent/internal/oxide/installer.go
Normal file
250
companion-agent/internal/oxide/installer.go
Normal file
@@ -0,0 +1,250 @@
|
||||
package oxide
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/nats-io/nats.go"
|
||||
)
|
||||
|
||||
// GameServerRestarter abstracts the game server process manager so the installer
|
||||
// can restart the server after extracting Oxide files.
|
||||
type GameServerRestarter interface {
|
||||
Restart() error
|
||||
}
|
||||
|
||||
// OxideInstaller handles downloading and extracting Oxide/uMod over a Rust server installation.
|
||||
type OxideInstaller struct {
|
||||
nc *nats.Conn
|
||||
licenseID string
|
||||
installDir string
|
||||
gameServer GameServerRestarter
|
||||
}
|
||||
|
||||
// NewOxideInstaller creates a new OxideInstaller instance.
|
||||
func NewOxideInstaller(nc *nats.Conn, licenseID, installDir string, gs GameServerRestarter) *OxideInstaller {
|
||||
return &OxideInstaller{
|
||||
nc: nc,
|
||||
licenseID: licenseID,
|
||||
installDir: installDir,
|
||||
gameServer: gs,
|
||||
}
|
||||
}
|
||||
|
||||
// githubRelease represents the relevant fields from the GitHub Releases API response.
|
||||
type githubRelease struct {
|
||||
TagName string `json:"tag_name"`
|
||||
Assets []githubAsset `json:"assets"`
|
||||
}
|
||||
|
||||
type githubAsset struct {
|
||||
Name string `json:"name"`
|
||||
BrowserDownloadURL string `json:"browser_download_url"`
|
||||
}
|
||||
|
||||
// Install performs the full Oxide installation pipeline:
|
||||
// 1. Fetch latest release info from GitHub
|
||||
// 2. Download the zip
|
||||
// 3. Extract over {installDir}/server/
|
||||
// 4. Restart the game server
|
||||
func (o *OxideInstaller) Install() error {
|
||||
// Stage 1: Fetch latest release
|
||||
log.Printf("Oxide: fetching latest release for license %s", o.licenseID)
|
||||
o.publishStatus("fetching_release", 0, "Checking latest Oxide release...")
|
||||
|
||||
release, err := o.fetchLatestRelease()
|
||||
if err != nil {
|
||||
o.publishStatus("failed", 0, "Failed to fetch Oxide release info", err.Error())
|
||||
return fmt.Errorf("fetch release failed: %w", err)
|
||||
}
|
||||
|
||||
if len(release.Assets) == 0 {
|
||||
err := fmt.Errorf("no assets found in release %s", release.TagName)
|
||||
o.publishStatus("failed", 0, "No download assets in release", err.Error())
|
||||
return err
|
||||
}
|
||||
|
||||
downloadURL := release.Assets[0].BrowserDownloadURL
|
||||
version := release.TagName
|
||||
log.Printf("Oxide: latest version is %s, download URL: %s", version, downloadURL)
|
||||
o.publishStatus("fetching_release", 100, fmt.Sprintf("Found Oxide %s", version))
|
||||
|
||||
// Stage 2: Download zip
|
||||
log.Printf("Oxide: downloading %s", downloadURL)
|
||||
o.publishStatus("downloading", 0, fmt.Sprintf("Downloading Oxide %s...", version))
|
||||
|
||||
tmpPath := filepath.Join(os.TempDir(), "oxide-latest.zip")
|
||||
if err := o.downloadFile(downloadURL, tmpPath); err != nil {
|
||||
o.publishStatus("failed", 0, "Failed to download Oxide", err.Error())
|
||||
return fmt.Errorf("download failed: %w", err)
|
||||
}
|
||||
defer os.Remove(tmpPath)
|
||||
|
||||
log.Printf("Oxide: download complete")
|
||||
o.publishStatus("downloading", 100, "Download complete")
|
||||
|
||||
// Stage 3: Extract over server directory
|
||||
serverDir := filepath.Join(o.installDir, "server")
|
||||
log.Printf("Oxide: extracting to %s", serverDir)
|
||||
o.publishStatus("installing", 0, "Extracting Oxide over server directory...")
|
||||
|
||||
if err := o.extractZip(tmpPath, serverDir); err != nil {
|
||||
o.publishStatus("failed", 0, "Failed to extract Oxide", err.Error())
|
||||
return fmt.Errorf("extract failed: %w", err)
|
||||
}
|
||||
|
||||
log.Printf("Oxide: extraction complete")
|
||||
o.publishStatus("installing", 100, "Oxide files extracted")
|
||||
|
||||
// Stage 4: Restart server
|
||||
log.Printf("Oxide: restarting server")
|
||||
o.publishStatus("restarting", 0, "Restarting server to load Oxide...")
|
||||
|
||||
if err := o.gameServer.Restart(); err != nil {
|
||||
o.publishStatus("failed", 0, "Server restart failed", err.Error())
|
||||
return fmt.Errorf("server restart failed: %w", err)
|
||||
}
|
||||
|
||||
log.Printf("Oxide: server restarted, installation complete")
|
||||
o.publishStatus("complete", 100, fmt.Sprintf("Oxide %s installed successfully", version))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// fetchLatestRelease queries the GitHub API for the latest Oxide.Rust release.
|
||||
func (o *OxideInstaller) fetchLatestRelease() (*githubRelease, error) {
|
||||
client := &http.Client{Timeout: 30 * time.Second}
|
||||
|
||||
resp, err := client.Get("https://api.github.com/repos/OxideMod/Oxide.Rust/releases/latest")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("GitHub API request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("GitHub API returned status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var release githubRelease
|
||||
if err := json.NewDecoder(resp.Body).Decode(&release); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse GitHub API response: %w", err)
|
||||
}
|
||||
|
||||
return &release, nil
|
||||
}
|
||||
|
||||
// downloadFile downloads a URL to a local file path.
|
||||
func (o *OxideInstaller) downloadFile(url, destPath string) error {
|
||||
client := &http.Client{Timeout: 5 * time.Minute}
|
||||
|
||||
resp, err := client.Get(url)
|
||||
if err != nil {
|
||||
return fmt.Errorf("HTTP GET failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("download returned status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
out, err := os.Create(destPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create file %s: %w", destPath, err)
|
||||
}
|
||||
defer out.Close()
|
||||
|
||||
if _, err := io.Copy(out, resp.Body); err != nil {
|
||||
return fmt.Errorf("failed to write download: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// extractZip extracts a zip file to a destination directory, overwriting existing files.
|
||||
// This is used to overlay Oxide's DLLs over the Rust server's Managed directory
|
||||
// and create the oxide/ folder structure.
|
||||
func (o *OxideInstaller) extractZip(zipPath, destDir string) error {
|
||||
r, err := zip.OpenReader(zipPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to open zip: %w", err)
|
||||
}
|
||||
defer r.Close()
|
||||
|
||||
for _, f := range r.File {
|
||||
targetPath := filepath.Join(destDir, f.Name)
|
||||
|
||||
// Security: prevent path traversal
|
||||
if !strings.HasPrefix(targetPath, filepath.Clean(destDir)+string(os.PathSeparator)) && targetPath != filepath.Clean(destDir) {
|
||||
log.Printf("Oxide: skipping potentially unsafe path: %s", f.Name)
|
||||
continue
|
||||
}
|
||||
|
||||
if f.FileInfo().IsDir() {
|
||||
if err := os.MkdirAll(targetPath, 0755); err != nil {
|
||||
return fmt.Errorf("failed to create directory %s: %w", targetPath, err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Ensure parent directory exists
|
||||
if err := os.MkdirAll(filepath.Dir(targetPath), 0755); err != nil {
|
||||
return fmt.Errorf("failed to create parent directory for %s: %w", targetPath, err)
|
||||
}
|
||||
|
||||
outFile, err := os.OpenFile(targetPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, f.Mode())
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create file %s: %w", targetPath, err)
|
||||
}
|
||||
|
||||
rc, err := f.Open()
|
||||
if err != nil {
|
||||
outFile.Close()
|
||||
return fmt.Errorf("failed to open zip entry %s: %w", f.Name, err)
|
||||
}
|
||||
|
||||
_, err = io.Copy(outFile, rc)
|
||||
rc.Close()
|
||||
outFile.Close()
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to extract %s: %w", f.Name, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// publishStatus publishes an OxideStatus message to NATS. Publish errors are logged
|
||||
// but do not fail the installation — losing a progress update is not fatal.
|
||||
func (o *OxideInstaller) publishStatus(stage string, progress int, message string, errDetail ...string) {
|
||||
subject := fmt.Sprintf("corrosion.%s.oxide.status", o.licenseID)
|
||||
|
||||
status := OxideStatus{
|
||||
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 oxide status: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := o.nc.Publish(subject, data); err != nil {
|
||||
log.Printf("Failed to publish oxide status to %s: %v", subject, err)
|
||||
}
|
||||
}
|
||||
31
companion-agent/internal/oxide/status.go
Normal file
31
companion-agent/internal/oxide/status.go
Normal file
@@ -0,0 +1,31 @@
|
||||
package oxide
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// OxideStatus represents a progress update published to NATS during Oxide installation.
|
||||
// The frontend listens on corrosion.{license_id}.oxide.status for these messages.
|
||||
type OxideStatus struct {
|
||||
Stage string `json:"stage"`
|
||||
Progress int `json:"progress"`
|
||||
Message string `json:"message"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Timestamp string `json:"timestamp"`
|
||||
}
|
||||
|
||||
// Valid installation stages:
|
||||
// fetching_release - Querying GitHub API for latest Oxide.Rust release
|
||||
// downloading - Downloading the Oxide zip file
|
||||
// installing - Extracting zip over server directory
|
||||
// restarting - Restarting the game server to load Oxide
|
||||
// complete - Oxide installation finished successfully
|
||||
// failed - Installation failed at some stage
|
||||
|
||||
// CheckOxideInstalled returns true if the oxide/ directory exists in the
|
||||
// server installation directory, indicating that Oxide/uMod has been installed.
|
||||
func CheckOxideInstalled(installDir string) bool {
|
||||
_, err := os.Stat(filepath.Join(installDir, "server", "oxide"))
|
||||
return err == nil
|
||||
}
|
||||
Reference in New Issue
Block a user