diff --git a/companion-agent/cmd/agent/main.go b/companion-agent/cmd/agent/main.go index 28f6de4..29affa4 100644 --- a/companion-agent/cmd/agent/main.go +++ b/companion-agent/cmd/agent/main.go @@ -5,6 +5,7 @@ import ( "log" "os" "os/signal" + "runtime" "syscall" "time" @@ -24,9 +25,12 @@ type Config struct { // Game server configuration 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"` + // Install directory for deployment + InstallDir string `envconfig:"INSTALL_DIR" default:""` + // Optional settings HeartbeatInterval int `envconfig:"HEARTBEAT_INTERVAL" default:"60"` LogLevel string `envconfig:"LOG_LEVEL" default:"info"` @@ -44,11 +48,21 @@ func main() { 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(" NATS URL: %s", cfg.NATSUrl) log.Printf(" License ID: %s", cfg.LicenseID) log.Printf(" Game Server Path: %s", cfg.GameServerPath) log.Printf(" SteamCMD Path: %s", cfg.SteamCMDPath) + log.Printf(" Install Dir: %s", cfg.InstallDir) log.Printf(" Heartbeat Interval: %ds", cfg.HeartbeatInterval) // Create context with signal handling for graceful shutdown @@ -73,6 +87,7 @@ func main() { GameServerPath: cfg.GameServerPath, GameServerArgs: cfg.GameServerArgs, Version: version, + InstallDir: cfg.InstallDir, } // Start daemon diff --git a/companion-agent/internal/app/daemon.go b/companion-agent/internal/app/daemon.go index 0f4c878..8c31ab6 100644 --- a/companion-agent/internal/app/daemon.go +++ b/companion-agent/internal/app/daemon.go @@ -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) diff --git a/companion-agent/internal/deploy/config.go b/companion-agent/internal/deploy/config.go new file mode 100644 index 0000000..9264317 --- /dev/null +++ b/companion-agent/internal/deploy/config.go @@ -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 +} diff --git a/companion-agent/internal/deploy/deploy.go b/companion-agent/internal/deploy/deploy.go new file mode 100644 index 0000000..9a07f05 --- /dev/null +++ b/companion-agent/internal/deploy/deploy.go @@ -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) + } +} diff --git a/companion-agent/internal/deploy/deploy_linux.go b/companion-agent/internal/deploy/deploy_linux.go new file mode 100644 index 0000000..5fea003 --- /dev/null +++ b/companion-agent/internal/deploy/deploy_linux.go @@ -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 +} diff --git a/companion-agent/internal/deploy/deploy_windows.go b/companion-agent/internal/deploy/deploy_windows.go new file mode 100644 index 0000000..820eec7 --- /dev/null +++ b/companion-agent/internal/deploy/deploy_windows.go @@ -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 +} diff --git a/frontend/src/views/admin/ServerView.vue b/frontend/src/views/admin/ServerView.vue index d251587..93ba05c 100644 --- a/frontend/src/views/admin/ServerView.vue +++ b/frontend/src/views/admin/ServerView.vue @@ -17,8 +17,6 @@ import { Rocket, AlertTriangle, Check, - ClipboardCopy, - ChevronRight, } from 'lucide-vue-next' import type { DeploymentConfig, DeploymentStatus } from '@/types' import { useWebSocket } from '@/composables/useWebSocket' @@ -194,9 +192,9 @@ onMounted(async () => { loadFormFromConfig() const ws = useWebSocket() - ws.on('event', (data: any) => { - if (data.event === 'deploy_status') { - server.updateDeploymentStatus(data.data as DeploymentStatus) + ws.subscribe((msg) => { + if (msg.type === 'event' && msg.event === 'deploy_status') { + server.updateDeploymentStatus(msg.data as DeploymentStatus) } }) })