docs(reference): import Dune: Awakening server-manager references
All checks were successful
CI / backend-types (push) Successful in 10s
CI / frontend-build (push) Successful in 15s
CI / agent-tests (push) Successful in 39s
CI / integration (push) Successful in 22s

Phase 2 references for the host-agent Dune adapter, moved out of volatile /tmp
into docs/reference-repos/ (per Commander). Three upstream projects, .git +
node_modules + compiled binaries stripped (16MB source). Nested AI-instruction
files (.claude/, CLAUDE.md) removed so they don't pollute Corrosion sessions.

- icehunter/    dune-admin (Go+React) — 4 control planes; SETUP_DOCKER.md is the
                closest analog to our agent's Dune docker control plane (compose
                lifecycle, docker logs, RabbitMQ-via-exec, dune Postgres schema)
- adainrivers/  Rust/Tauri desktop — SSH+k8s BattleGroup control, maintenance
                daemon, in-game admin console (Rust idiom reference)
- the4rchangel/ Node web UI replacing battlegroup.bat — matches the Commander's
                Hyper-V self-host path + game-config schema

See docs/reference-repos/README.md for the full index + how we use each.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Vantz Stockwell
2026-06-11 21:08:05 -04:00
parent 0715492ddf
commit 651a35d4be
1334 changed files with 238971 additions and 0 deletions

View File

@@ -0,0 +1,22 @@
[package]
name = "dune-manager-core"
version = "0.1.0"
edition = "2021"
[lib]
name = "dune_manager_core"
[[bin]]
name = "dune-manager-cli"
path = "src/bin/dune-manager-cli.rs"
[dependencies]
postgres = { version = "0.19", default-features = false }
native-tls = "0.2"
reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "multipart", "native-tls"] }
russh = { version = "0.61", default-features = false, features = ["ring", "rsa", "flate2"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
sha2 = "0.10"
tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "io-util", "sync", "time"] }
url = "2"

View File

@@ -0,0 +1,3 @@
fn main() {
std::process::exit(dune_manager_core::cli::run_cli_from_env());
}

View File

@@ -0,0 +1,112 @@
//! CLI argument parser, usage text, and error JSON conversion.
use serde_json::{json, Value};
use crate::{
errors::failure,
models::{CommandFailure, CommandResult},
};
#[derive(Debug, Clone)]
pub(super) struct CliArgs {
args: Vec<String>,
}
impl CliArgs {
pub(super) fn new(args: Vec<String>) -> Self {
Self { args }
}
pub(super) fn is_empty(&self) -> bool {
self.args.is_empty()
}
pub(super) fn positional_slice(&self) -> Vec<&str> {
let mut values = Vec::new();
let mut index = 0;
while index < self.args.len() {
let arg = &self.args[index];
if arg.starts_with("--") {
if self
.args
.get(index + 1)
.is_some_and(|next| !next.starts_with("--"))
{
index += 2;
} else {
index += 1;
}
} else {
values.push(arg.as_str());
index += 1;
}
}
values
}
pub(super) fn has_flag(&self, name: &str) -> bool {
self.args.iter().any(|arg| arg == name)
}
pub(super) fn required(&self, name: &str) -> CommandResult<String> {
self.optional(name)
.filter(|value| !value.trim().is_empty())
.ok_or_else(|| failure(format!("Missing required argument {name}")))
}
pub(super) fn optional(&self, name: &str) -> Option<String> {
self.args
.windows(2)
.find(|pair| pair[0] == name)
.map(|pair| pair[1].clone())
}
pub(super) fn optional_u64(&self, name: &str) -> CommandResult<Option<u64>> {
self.optional(name)
.map(|value| {
value
.parse::<u64>()
.map_err(|_| failure(format!("{name} must be an unsigned integer")))
})
.transpose()
}
pub(super) fn required_u64(&self, name: &str) -> CommandResult<u64> {
self.optional_u64(name)?
.ok_or_else(|| failure(format!("Missing required argument {name}")))
}
}
pub(super) fn usage() -> Vec<&'static str> {
vec![
"dune-manager-cli flow battlegroup",
"dune-manager-cli db ping --db-host IP [--db-port 15432] [--db-name dune] [--db-user dune] [--db-password PASSWORD | --db-password-file PATH | --db-password-env NAME]",
"dune-manager-cli db world-partitions --db-host IP [--map MAP] [--db-port 15432] [--db-name dune] [--db-user dune] [--db-password PASSWORD | --db-password-file PATH | --db-password-env NAME]",
"dune-manager-cli bg list --key PATH --host IP [--port 22] [--user dune]",
"dune-manager-cli bg status --key PATH --host IP --namespace NS --name BG [--user dune]",
"dune-manager-cli bg start|stop|restart --key PATH --host IP --namespace NS --name BG [--director-timeout 60]",
"dune-manager-cli bg patch-region --key PATH --host IP --namespace NS --name BG --region Europe",
"dune-manager-cli bg instances set --key PATH --host IP --namespace NS --name BG --map survival-1|deep-desert --count N [--pvp-count N] [--restart]",
"dune-manager-cli bg display-name set --key PATH --host IP --namespace NS --name BG --map survival-1|deep-desert --dimension N --display-name NAME [--restart]",
"dune-manager-cli bg display-name clear --key PATH --host IP --namespace NS --name BG --map survival-1|deep-desert --dimension N [--restart]",
"dune-manager-cli bg pods --key PATH --host IP --namespace NS",
"dune-manager-cli bg pod-shell-spec --key PATH --host IP --namespace NS --pod POD",
"dune-manager-cli bg export-logs --key PATH --host IP --namespace NS",
"dune-manager-cli bg export-operator-logs --key PATH --host IP",
"dune-manager-cli bg update --key PATH --host IP --namespace NS --name BG",
"dune-manager-cli bg file-browser-url --key PATH --host IP --vm-ip IP",
"dune-manager-cli bg director-url --key PATH --host IP --namespace NS --name BG --vm-ip IP",
]
}
impl From<CommandFailure> for Value {
fn from(value: CommandFailure) -> Self {
json!({
"ok": false,
"error": value.message,
"stdout": value.stdout,
"stderr": value.stderr,
"code": value.code,
})
}
}

View File

@@ -0,0 +1,313 @@
//! Subcommand dispatch and helper builders for the non-interactive CLI.
use serde::Serialize;
use serde_json::{json, Value};
use crate::{
cli::args::{usage, CliArgs},
database::{DuneDatabase, DuneDatabaseConfig, DEFAULT_DUNE_DATABASE_PORT},
errors::failure,
models::CommandResult,
orchestration::{
battlegroup_command_catalog, BattlegroupManagementOrchestrator, BattlegroupRef,
InstanceMap, MapInstanceOrchestrator, RusshRunner, RusshTarget, SetMapDisplayNameRequest,
SetMapInstancesRequest, StructuredBattlegroupOps, StructuredKubectl, VecOperationSink,
VendorBattlegroupWrapper,
},
};
pub(super) fn run_cli(args: Vec<String>) -> CommandResult<Value> {
let args = CliArgs::new(args);
if args.is_empty() || args.has_flag("--help") || args.has_flag("-h") {
return Ok(json!({
"ok": true,
"usage": usage(),
}));
}
let positional = args.positional_slice();
match positional.as_slice() {
["flow", "battlegroup"] => to_json(battlegroup_command_catalog()),
["db", "ping"] => to_json(DuneDatabase::new(db_config(&args)?).health()?),
["db", "world-partitions"] => {
let map = args.optional("--map");
to_json(DuneDatabase::new(db_config(&args)?).world_partitions(map.as_deref())?)
}
["bg", "list"] => to_json(bg_ops(&args)?.list()?),
["bg", "patch-region"] => {
let bg = battlegroup_ref(&args)?;
let region = args.required("--region")?;
bg_ops(&args)?.patch_region(&bg, &region)?;
Ok(json!({ "ok": true }))
}
["bg", "instances", "set"] => {
let bg = battlegroup_ref(&args)?;
let map = InstanceMap::parse(&args.required("--map")?)?;
let count = usize::try_from(args.required_u64("--count")?)
.map_err(|_| failure("--count is too large"))?;
let mut request = SetMapInstancesRequest::new(bg, map, count);
request.pvp_instance_count = args
.optional_u64("--pvp-count")?
.map(|value| {
usize::try_from(value).map_err(|_| failure("--pvp-count is too large"))
})
.transpose()?;
let result =
MapInstanceOrchestrator::new(ssh_runner(&args)?).set_instances(&request)?;
let restart = if args.has_flag("--restart") {
Some(bg_lifecycle(&args, "restart")?)
} else {
None
};
Ok(json!({
"ok": true,
"result": result,
"restart": restart,
}))
}
["bg", "display-name", "set"] => {
let bg = battlegroup_ref(&args)?;
let map = InstanceMap::parse(&args.required("--map")?)?;
let dimension = i64::try_from(args.required_u64("--dimension")?)
.map_err(|_| failure("--dimension is too large"))?;
let request =
SetMapDisplayNameRequest::set(bg, map, dimension, args.required("--display-name")?);
let result =
MapInstanceOrchestrator::new(ssh_runner(&args)?).set_display_name(&request)?;
let restart = if args.has_flag("--restart") {
Some(bg_lifecycle(&args, "restart")?)
} else {
None
};
Ok(json!({
"ok": true,
"result": result,
"restart": restart,
}))
}
["bg", "display-name", "clear"] => {
let bg = battlegroup_ref(&args)?;
let map = InstanceMap::parse(&args.required("--map")?)?;
let dimension = i64::try_from(args.required_u64("--dimension")?)
.map_err(|_| failure("--dimension is too large"))?;
let request = SetMapDisplayNameRequest::clear(bg, map, dimension);
let result =
MapInstanceOrchestrator::new(ssh_runner(&args)?).set_display_name(&request)?;
let restart = if args.has_flag("--restart") {
Some(bg_lifecycle(&args, "restart")?)
} else {
None
};
Ok(json!({
"ok": true,
"result": result,
"restart": restart,
}))
}
["bg", "pods"] => {
let namespace = args.required("--namespace")?;
to_json(bg_ops(&args)?.list_pods(&namespace)?)
}
["bg", "pod-shell-spec"] => {
let namespace = args.required("--namespace")?;
let pod = args.required("--pod")?;
to_json(bg_ops(&args)?.pod_shell_spec(&namespace, &pod)?)
}
["bg", "export-logs"] => {
let namespace = args.required("--namespace")?;
to_json(bg_ops(&args)?.export_namespace_logs(&namespace)?)
}
["bg", "export-operator-logs"] => to_json(bg_ops(&args)?.export_operator_logs()?),
["bg", "file-browser-url"] => {
let vm_ip = args.required("--vm-ip")?;
to_json(bg_manager(&args)?.file_browser_url(&vm_ip)?)
}
["bg", "director-url"] => {
let bg = battlegroup_ref(&args)?;
let vm_ip = args.required("--vm-ip")?;
to_json(bg_manager(&args)?.director_url(&bg, &vm_ip)?)
}
["bg", "start"] => bg_lifecycle(&args, "start"),
["bg", "stop"] => bg_lifecycle(&args, "stop"),
["bg", "restart"] => bg_lifecycle(&args, "restart"),
["bg", "update"] => {
let bg = battlegroup_ref(&args)?;
let mut sink = VecOperationSink::default();
let stdout = bg_manager(&args)?.update(&bg, &mut sink)?;
Ok(json!({
"ok": true,
"events": sink.events,
"wrapperStdout": stdout,
}))
}
["bg", "status"] => {
let bg = battlegroup_ref(&args)?;
let state = bg_manager(&args)?.status(&bg)?;
Ok(json!({
"ok": true,
"stop": state.stop,
"status": state.phase,
"database": state.database_phase,
"gateway": state.server_group_phase,
"director": state.director_phase,
"uptime": state.uptime,
"servers": state.server_stats.iter().map(|row| json!({
"map": row.map,
"phase": row.phase,
"ready": row.ready,
"players": row.players,
"age": row.age,
})).collect::<Vec<_>>(),
}))
}
other => Err(failure(format!(
"Unknown command: {}",
if other.is_empty() {
"<none>".to_string()
} else {
other.join(" ")
}
))),
}
}
fn bg_lifecycle(args: &CliArgs, action: &str) -> CommandResult<Value> {
let bg = battlegroup_ref(args)?;
let timeout = args.optional_u64("--director-timeout")?.unwrap_or(60);
let orchestrator = bg_manager(args)?;
let mut sink = VecOperationSink::default();
let director_port = match action {
"start" => orchestrator.start_and_wait_director(&bg, timeout, &mut sink)?,
"stop" => {
orchestrator.stop(&bg, &mut sink)?;
None
}
"restart" => orchestrator.restart_and_wait_director(&bg, timeout, &mut sink)?,
_ => unreachable!("validated by caller"),
};
Ok(json!({
"ok": true,
"directorNodePort": director_port,
"events": sink.events,
}))
}
fn bg_manager(
args: &CliArgs,
) -> CommandResult<
BattlegroupManagementOrchestrator<
StructuredKubectl<RusshRunner>,
VendorBattlegroupWrapper<RusshRunner>,
>,
> {
let runner = ssh_runner(args)?;
let ssh_user = runner.target().user.clone();
Ok(BattlegroupManagementOrchestrator::new(
StructuredKubectl::new(runner.clone()),
VendorBattlegroupWrapper::with_ssh_user(runner, ssh_user),
))
}
fn bg_ops(args: &CliArgs) -> CommandResult<StructuredBattlegroupOps<RusshRunner>> {
Ok(StructuredBattlegroupOps::new(ssh_runner(args)?))
}
fn optional_port(args: &CliArgs, name: &str) -> CommandResult<Option<u16>> {
args.optional_u64(name)?
.map(|value| {
u16::try_from(value).map_err(|_| failure(format!("{name} must fit in a TCP port")))
})
.transpose()
}
fn db_config(args: &CliArgs) -> CommandResult<DuneDatabaseConfig> {
let host = args.required("--db-host")?;
let port = optional_port(args, "--db-port")?.unwrap_or(DEFAULT_DUNE_DATABASE_PORT);
let database = args
.optional("--db-name")
.unwrap_or_else(|| "dune".to_string());
let user = args
.optional("--db-user")
.unwrap_or_else(|| "dune".to_string());
let password = db_password(args)?.unwrap_or_else(|| "dune".to_string());
Ok(DuneDatabaseConfig {
host,
port,
database,
user,
password,
})
}
fn db_password(args: &CliArgs) -> CommandResult<Option<String>> {
if let Some(value) = args.optional("--db-password") {
return Ok(Some(value));
}
if let Some(path) = args.optional("--db-password-file") {
let text = std::fs::read_to_string(&path).map_err(|err| {
failure(format!(
"Failed to read database password file {path}: {err}"
))
})?;
let password = text.trim_end_matches(['\r', '\n']).to_string();
if password.is_empty() {
return Err(failure("Database password file is empty"));
}
return Ok(Some(password));
}
if let Some(name) = args.optional("--db-password-env") {
let password = std::env::var(&name)
.map_err(|_| failure(format!("Environment variable {name} is not set")))?;
if password.is_empty() {
return Err(failure(format!("Environment variable {name} is empty")));
}
return Ok(Some(password));
}
Ok(None)
}
fn battlegroup_ref(args: &CliArgs) -> CommandResult<BattlegroupRef> {
Ok(BattlegroupRef {
namespace: args.required("--namespace")?,
name: args.required("--name")?,
})
}
fn ssh_runner(args: &CliArgs) -> CommandResult<RusshRunner> {
ssh_runner_with_default_user(args, "dune")
}
fn ssh_runner_with_default_user(args: &CliArgs, default_user: &str) -> CommandResult<RusshRunner> {
let mut target = RusshTarget::new(
args.required("--key")?,
args.optional("--user")
.unwrap_or_else(|| default_user.to_string()),
args.required("--host")?,
);
if let Some(port) = args.optional_u64("--port")? {
target.port = u16::try_from(port).map_err(|_| failure("--port must fit in a TCP port"))?;
}
Ok(RusshRunner::new(target))
}
fn to_json(value: impl Serialize) -> CommandResult<Value> {
serde_json::to_value(value).map_err(|err| failure(format!("Failed to serialize output: {err}")))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn prints_usage_when_no_args_are_supplied() {
let value = run_cli(vec![]).unwrap();
assert_eq!(value["ok"], true);
assert!(value["usage"].as_array().unwrap().len() > 5);
}
#[test]
fn missing_required_arg_fails_cleanly() {
let err = run_cli(vec!["bg".into(), "status".into()]).unwrap_err();
assert!(err.message.contains("--namespace"));
}
}

View File

@@ -0,0 +1,43 @@
//! Non-interactive command-line entry point and argument dispatch.
mod args;
mod dispatch;
use serde_json::json;
use crate::security::redact_json;
use self::dispatch::run_cli;
/// Runs the CLI using process arguments and returns a process exit code.
///
/// Successful commands print pretty JSON to stdout. Failures print a redacted
/// JSON error envelope to stderr.
pub fn run_cli_from_env() -> i32 {
match run_cli(std::env::args().skip(1).collect()) {
Ok(mut value) => {
redact_json(&mut value);
println!(
"{}",
serde_json::to_string_pretty(&value).unwrap_or_else(|_| "{}".to_string())
);
0
}
Err(err) => {
let mut value = json!({
"ok": false,
"error": err.message,
"stdout": err.stdout,
"stderr": err.stderr,
"code": err.code,
});
redact_json(&mut value);
eprintln!(
"{}",
serde_json::to_string_pretty(&value)
.unwrap_or_else(|_| "{\"ok\":false}".to_string())
);
err.code.unwrap_or(1)
}
}
}

View File

@@ -0,0 +1,198 @@
//! PostgreSQL access for the Dune game database.
//!
//! The vendor stack exposes the game database inside the VM and, during local
//! development, we sometimes expose it to the host for inspection. This module
//! provides the small typed surface the core needs for setup verification and
//! instance-management tooling without leaking database credentials into CLI
//! output.
use postgres::{Client, NoTls};
use serde::Serialize;
use crate::errors::failure;
use crate::models::CommandResult;
/// Default database name used by the vendor server package.
pub const DEFAULT_DUNE_DATABASE: &str = "dune";
/// Default database user used by the vendor server package.
pub const DEFAULT_DUNE_DATABASE_USER: &str = "dune";
/// Default database port exposed by the test VM/database service.
pub const DEFAULT_DUNE_DATABASE_PORT: u16 = 15432;
/// Connection settings for the Dune PostgreSQL database.
#[derive(Debug, Clone)]
pub struct DuneDatabaseConfig {
/// Database host or IP address.
pub host: String,
/// TCP port.
pub port: u16,
/// Database name.
pub database: String,
/// Database user.
pub user: String,
/// Database password.
pub password: String,
}
impl DuneDatabaseConfig {
/// Creates a config using the vendor-default database name, user, and port.
pub fn local_vendor_defaults(host: impl Into<String>, password: impl Into<String>) -> Self {
Self {
host: host.into(),
port: DEFAULT_DUNE_DATABASE_PORT,
database: DEFAULT_DUNE_DATABASE.to_string(),
user: DEFAULT_DUNE_DATABASE_USER.to_string(),
password: password.into(),
}
}
/// Returns a password-free summary suitable for logs and JSON output.
pub fn redacted_summary(&self) -> DuneDatabaseSummary {
DuneDatabaseSummary {
host: self.host.clone(),
port: self.port,
database: self.database.clone(),
user: self.user.clone(),
}
}
fn connect(&self) -> CommandResult<Client> {
let mut params = postgres::Config::new();
params
.host(&self.host)
.port(self.port)
.dbname(&self.database)
.user(&self.user)
.password(&self.password);
params.connect(NoTls).map_err(|err| {
failure(format!(
"Failed to connect to PostgreSQL at {}:{} database {} as {}: {err}",
self.host, self.port, self.database, self.user
))
})
}
}
/// Password-free database connection summary.
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct DuneDatabaseSummary {
/// Database host or IP address.
pub host: String,
/// TCP port.
pub port: u16,
/// Database name.
pub database: String,
/// Database user.
pub user: String,
}
/// Lightweight database health result.
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct DuneDatabaseHealth {
/// Whether a connection and simple query succeeded.
pub ok: bool,
/// Password-free connection summary.
pub connection: DuneDatabaseSummary,
/// Database server version string from `version()`.
pub server_version: String,
}
/// Row from the game `world_partition` table.
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct WorldPartition {
/// Partition identifier used by game servers and PvP/PvE settings.
pub partition_id: i64,
/// Current server identifier assigned by running game servers, if any.
pub server_id: Option<String>,
/// Game map name, for example `DeepDesert_1`.
pub map: String,
/// JSON partition definition stored by the game.
pub partition_definition: String,
/// Dimension index exposed to the client instance selector.
pub dimension_index: i32,
/// Whether the partition is blocked.
pub blocked: bool,
/// Human-facing partition label, when present.
pub label: Option<String>,
}
/// Synchronous Dune database client.
#[derive(Debug, Clone)]
pub struct DuneDatabase {
config: DuneDatabaseConfig,
}
impl DuneDatabase {
/// Creates a database client from connection settings.
pub fn new(config: DuneDatabaseConfig) -> Self {
Self { config }
}
/// Runs a connection check and returns the database server version.
pub fn health(&self) -> CommandResult<DuneDatabaseHealth> {
let mut client = self.config.connect()?;
let row = client
.query_one("select version()", &[])
.map_err(|err| failure(format!("Failed to query PostgreSQL version: {err}")))?;
Ok(DuneDatabaseHealth {
ok: true,
connection: self.config.redacted_summary(),
server_version: row.get::<_, String>(0),
})
}
/// Lists world partitions, optionally filtered to a single map.
pub fn world_partitions(&self, map: Option<&str>) -> CommandResult<Vec<WorldPartition>> {
let mut client = self.config.connect()?;
let query = "select partition_id, server_id, map, partition_definition::text as partition_definition, dimension_index, blocked, label from world_partition";
let rows = if let Some(map) = map {
client
.query(
&format!("{query} where map = $1 order by partition_id"),
&[&map],
)
.map_err(|err| failure(format!("Failed to query world partitions: {err}")))?
} else {
client
.query(&format!("{query} order by map, partition_id"), &[])
.map_err(|err| failure(format!("Failed to query world partitions: {err}")))?
};
rows.into_iter()
.map(|row| {
Ok(WorldPartition {
partition_id: row.try_get("partition_id").map_err(row_error)?,
server_id: row.try_get("server_id").map_err(row_error)?,
map: row.try_get("map").map_err(row_error)?,
partition_definition: row.try_get("partition_definition").map_err(row_error)?,
dimension_index: row.try_get("dimension_index").map_err(row_error)?,
blocked: row.try_get("blocked").map_err(row_error)?,
label: row.try_get("label").map_err(row_error)?,
})
})
.collect()
}
}
fn row_error(err: postgres::Error) -> crate::models::CommandFailure {
failure(format!("Failed to read world_partition row: {err}"))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn redacted_summary_does_not_include_password() {
let config = DuneDatabaseConfig::local_vendor_defaults("192.0.2.10", "secret-password");
let summary = serde_json::to_string(&config.redacted_summary()).unwrap();
assert!(summary.contains("192.0.2.10"));
assert!(!summary.contains("secret-password"));
}
}

View File

@@ -0,0 +1,95 @@
//! Host environment detection for setup preflight.
//!
//! This module owns the ordered host preflight used by the desktop shell:
//! administrator/readiness checks first, then network candidate discovery.
use serde::{Deserialize, Serialize};
use crate::{
models::CommandResult,
orchestration::{
DriveCandidate, HostProvider, HostReadiness, NetworkAdapterCandidate,
StrictPowerShellHyperV,
},
shell::run_powershell,
};
/// Ordered setup environment detection result.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SetupEnvironment {
/// Administrator, virtualization, Hyper-V, service, and host memory state.
pub readiness: HostReadiness,
/// Host filesystem drives available for placing the VM data.
pub drives: Vec<DriveCandidate>,
/// Physical network adapters suitable for VM networking.
pub network_adapters: Vec<NetworkAdapterCandidate>,
/// Public IPv4 address detected from the host, when reachable.
pub external_ip: Option<String>,
}
/// Detects the host setup environment using the default Windows provider.
pub fn detect_setup_environment() -> CommandResult<SetupEnvironment> {
detect_setup_environment_with(&StrictPowerShellHyperV::new())
}
/// Detects the host setup environment with an injected provider.
pub fn detect_setup_environment_with<H>(host: &H) -> CommandResult<SetupEnvironment>
where
H: HostProvider,
{
let readiness = host.readiness()?;
let drives = host.drives_with_minimum_free_space(0)?;
let network_adapters = host.active_physical_adapters()?;
let external_ip = detect_external_ipv4();
Ok(SetupEnvironment {
readiness,
drives,
network_adapters,
external_ip,
})
}
fn detect_external_ipv4() -> Option<String> {
let script = r#"
$ProgressPreference = 'SilentlyContinue'
$ErrorActionPreference = 'Stop'
$ip = Invoke-RestMethod -Uri 'https://api.ipify.org' -TimeoutSec 5
if ($ip -match '^\d{1,3}(\.\d{1,3}){3}$') { $ip }
"#;
run_powershell(script).ok().and_then(|value| {
let trimmed = value.trim();
if is_ipv4_literal(trimmed) {
Some(trimmed.to_string())
} else {
None
}
})
}
fn is_ipv4_literal(value: &str) -> bool {
let parts = value.split('.').collect::<Vec<_>>();
parts.len() == 4
&& parts.iter().all(|part| {
!part.is_empty()
&& part.len() <= 3
&& part
.parse::<u8>()
.is_ok_and(|_| part.chars().all(|ch| ch.is_ascii_digit()))
})
}
#[cfg(test)]
mod tests {
use super::is_ipv4_literal;
#[test]
fn validates_ipv4_literals() {
assert!(is_ipv4_literal("192.0.2.75"));
assert!(is_ipv4_literal("8.8.8.8"));
assert!(!is_ipv4_literal(""));
assert!(!is_ipv4_literal("999.1.1.1"));
assert!(!is_ipv4_literal("example.com"));
}
}

View File

@@ -0,0 +1,37 @@
//! Error construction helpers for command-style APIs.
use serde::Deserialize;
use crate::models::{CommandFailure, CommandResult};
use crate::security::redact_text;
/// Creates a simple command failure with no process output attached.
pub fn failure(message: impl Into<String>) -> CommandFailure {
CommandFailure {
message: message.into(),
stdout: String::new(),
stderr: String::new(),
code: None,
}
}
/// Creates a command failure from a completed child-process output.
///
/// Captured stdout and stderr are redacted before they are stored.
pub fn command_failure(message: impl Into<String>, output: std::process::Output) -> CommandFailure {
CommandFailure {
message: message.into(),
stdout: redact_text(&String::from_utf8_lossy(&output.stdout))
.trim()
.to_string(),
stderr: redact_text(&String::from_utf8_lossy(&output.stderr))
.trim()
.to_string(),
code: output.status.code(),
}
}
/// Parses JSON text and labels parse failures with the caller-provided context.
pub fn parse_json<T: for<'de> Deserialize<'de>>(text: &str, label: &str) -> CommandResult<T> {
serde_json::from_str(text).map_err(|err| failure(format!("Failed to parse {label}: {err}")))
}

View File

@@ -0,0 +1,29 @@
//! Core orchestration and CLI support for the Dune dedicated server manager.
//!
//! This crate intentionally contains no Tauri or UI code. It owns the
//! Windows/Hyper-V setup orchestration, guest SSH bootstrap flow, Kubernetes
//! battlegroup operations, managed external tool installation, and the
//! non-interactive CLI used to exercise those capabilities.
#![warn(missing_docs)]
/// Non-interactive command-line entry point and argument dispatch.
pub mod cli;
/// PostgreSQL access for the Dune game database.
pub mod database;
/// Ordered host environment detection for setup preflight.
pub mod environment;
/// Shared error constructors and JSON parsing helpers.
pub mod errors;
/// Common command result types used by core operations.
pub mod models;
/// Native setup, guest bootstrap, and battlegroup orchestration primitives.
pub mod orchestration;
/// Secret redaction helpers for plain text and JSON payloads.
pub mod security;
/// Small process and PowerShell execution helpers.
pub mod shell;
/// Managed installation of app-owned external tools.
pub mod toolchain;
/// Validation helpers for shell-safe and Kubernetes-safe values.
pub mod validation;

View File

@@ -0,0 +1,19 @@
//! Shared command result model.
use serde::Serialize;
/// Error returned by library operations and serialized by the CLI.
#[derive(Debug, Serialize)]
pub struct CommandFailure {
/// Human-readable failure summary.
pub message: String,
/// Redacted stdout captured from a failed child process, when available.
pub stdout: String,
/// Redacted stderr captured from a failed child process, when available.
pub stderr: String,
/// Child-process exit code, when the failure came from a process.
pub code: Option<i32>,
}
/// Result type used by command-style operations.
pub type CommandResult<T> = Result<T, CommandFailure>;

View File

@@ -0,0 +1,8 @@
//! Kubernetes-backed battlegroup queries, patches, shell specs, and log exports.
mod ops;
mod region_patch;
mod types;
pub use ops::StructuredBattlegroupOps;
pub use types::{BattlegroupStatusSnapshot, LogFile, PodContainerRef, PodShellSpec};

View File

@@ -0,0 +1,241 @@
//! Structured battlegroup operations backed by a remote command runner.
use serde_json::Value;
use crate::{
errors::{failure, parse_json},
models::CommandResult,
orchestration::{BattlegroupRef, RemoteCommandRunner},
validation::validate_kube_arg,
};
use super::region_patch::{region_patch_operations, sh_single_quoted, validate_region};
use super::types::{BattlegroupStatusSnapshot, LogFile, PodContainerRef, PodShellSpec};
const BATTLEGROUP_NAMESPACE_PREFIX: &str = "funcom-seabass-";
/// Structured battlegroup operations over a remote command runner.
#[derive(Debug, Clone)]
pub struct StructuredBattlegroupOps<R> {
runner: R,
}
impl<R> StructuredBattlegroupOps<R>
where
R: RemoteCommandRunner,
{
/// Creates operations backed by a remote command runner.
pub fn new(runner: R) -> Self {
Self { runner }
}
/// Lists battlegroups from all Kubernetes namespaces.
pub fn list(&self) -> CommandResult<Vec<BattlegroupRef>> {
let value = self.runner.run_json(
"sudo kubectl get battlegroups -A -o json",
"battlegroup list",
)?;
let mut refs = value["items"]
.as_array()
.cloned()
.unwrap_or_default()
.into_iter()
.filter_map(|item| {
let namespace = item["metadata"]["namespace"].as_str()?.to_string();
let name = item["metadata"]["name"].as_str()?.to_string();
Some(BattlegroupRef { namespace, name })
})
.filter(|item| item.namespace.starts_with(BATTLEGROUP_NAMESPACE_PREFIX))
.collect::<Vec<_>>();
refs.sort_by(|left, right| left.namespace.cmp(&right.namespace));
Ok(refs)
}
/// Returns a battlegroup status snapshot.
pub fn status(&self, battlegroup: &BattlegroupRef) -> CommandResult<BattlegroupStatusSnapshot> {
battlegroup.validate()?;
let bg_command = format!(
"sudo kubectl get battlegroup {} -n {} -o json",
sh_single_quoted(&battlegroup.name),
sh_single_quoted(&battlegroup.namespace)
);
let battlegroup_json = self.runner.run_json(&bg_command, "battlegroup status")?;
Ok(BattlegroupStatusSnapshot {
battlegroup: battlegroup_json,
pods: self.list_pods(&battlegroup.namespace)?,
director_node_port: self.director_node_port(&battlegroup.namespace)?,
})
}
/// Patches the region-related fields in a live BattleGroup resource.
pub fn patch_region(&self, battlegroup: &BattlegroupRef, region: &str) -> CommandResult<()> {
battlegroup.validate()?;
validate_region(region)?;
let command = format!(
"sudo kubectl get battlegroup {} -n {} -o json",
sh_single_quoted(&battlegroup.name),
sh_single_quoted(&battlegroup.namespace)
);
let battlegroup_json = self
.runner
.run_json(&command, "battlegroup region source")?;
let operations = region_patch_operations(&battlegroup_json, region)?;
let patch_command = format!(
"sudo kubectl patch battlegroup {} -n {} --type=json -p {} -o json",
sh_single_quoted(&battlegroup.name),
sh_single_quoted(&battlegroup.namespace),
sh_single_quoted(&serde_json::to_string(&operations).map_err(|err| {
failure(format!(
"Failed to serialize region patch operations: {err}"
))
})?),
);
let output = self.runner.run(&patch_command)?;
let value: Value = parse_json(&output, "patched battlegroup")?;
let patched_name = value["metadata"]["name"].as_str().unwrap_or_default();
if patched_name != battlegroup.name {
return Err(failure(
"Region patch did not return the expected battlegroup",
));
}
Ok(())
}
/// Lists pods and containers for a namespace.
pub fn list_pods(&self, namespace: &str) -> CommandResult<Vec<PodContainerRef>> {
validate_kube_arg(namespace, "namespace")?;
let command = format!(
"sudo kubectl get pods -n {} -o json",
sh_single_quoted(namespace)
);
let value = self.runner.run_json(&command, "pod list")?;
let mut pods = Vec::new();
for item in value["items"].as_array().cloned().unwrap_or_default() {
let pod = item["metadata"]["name"].as_str().unwrap_or_default();
if pod.is_empty() {
continue;
}
let role = item["metadata"]["labels"]["role"]
.as_str()
.unwrap_or_default()
.to_string();
for container in item["spec"]["containers"]
.as_array()
.cloned()
.unwrap_or_default()
{
let container_name = container["name"].as_str().unwrap_or_default();
if !container_name.is_empty() {
pods.push(PodContainerRef {
pod: pod.to_string(),
container: container_name.to_string(),
role: role.clone(),
});
}
}
}
pods.sort_by(|left, right| {
left.pod
.cmp(&right.pod)
.then(left.container.cmp(&right.container))
});
Ok(pods)
}
/// Builds command candidates for opening a shell in a pod.
pub fn pod_shell_spec(&self, namespace: &str, pod: &str) -> CommandResult<PodShellSpec> {
validate_kube_arg(namespace, "namespace")?;
validate_kube_arg(pod, "pod")?;
Ok(PodShellSpec {
namespace: namespace.to_string(),
pod: pod.to_string(),
commands: vec![
vec![
"sudo".into(),
"kubectl".into(),
"exec".into(),
"-it".into(),
pod.into(),
"-n".into(),
namespace.into(),
"--".into(),
"/bin/bash".into(),
],
vec![
"sudo".into(),
"kubectl".into(),
"exec".into(),
"-it".into(),
pod.into(),
"-n".into(),
namespace.into(),
"--".into(),
"/bin/sh".into(),
],
],
})
}
/// Exports logs for all containers in a namespace.
pub fn export_namespace_logs(&self, namespace: &str) -> CommandResult<Vec<LogFile>> {
let pods = self.list_pods(namespace)?;
self.collect_logs(namespace, &pods)
}
/// Exports logs for all operator containers.
pub fn export_operator_logs(&self) -> CommandResult<Vec<LogFile>> {
let pods = self.list_pods("funcom-operators")?;
self.collect_logs("funcom-operators", &pods)
}
fn director_node_port(&self, namespace: &str) -> CommandResult<Option<u16>> {
validate_kube_arg(namespace, "namespace")?;
let command = format!(
"sudo kubectl get svc -n {} -o json",
sh_single_quoted(namespace)
);
let value = self.runner.run_json(&command, "service list")?;
for service in value["items"].as_array().cloned().unwrap_or_default() {
for port in service["spec"]["ports"]
.as_array()
.cloned()
.unwrap_or_default()
{
if port["port"].as_u64() == Some(11717) {
return Ok(port["nodePort"]
.as_u64()
.and_then(|value| u16::try_from(value).ok()));
}
}
}
Ok(None)
}
fn collect_logs(
&self,
namespace: &str,
pods: &[PodContainerRef],
) -> CommandResult<Vec<LogFile>> {
let mut files = Vec::new();
for item in pods {
validate_kube_arg(&item.pod, "pod")?;
validate_kube_arg(&item.container, "container")?;
let command = format!(
"sudo kubectl logs -n {} {} -c {} --timestamps --tail=-1",
sh_single_quoted(namespace),
sh_single_quoted(&item.pod),
sh_single_quoted(&item.container),
);
let contents = self.runner.run(&command)?;
files.push(LogFile {
relative_path: format!("{}/{}.log", item.pod, item.container),
contents,
});
}
Ok(files)
}
}
#[cfg(test)]
#[path = "ops_tests.rs"]
mod tests;

View File

@@ -0,0 +1,128 @@
use std::{cell::RefCell, collections::VecDeque, rc::Rc};
use super::*;
#[derive(Clone, Default)]
struct MockRemote {
outputs: Rc<RefCell<VecDeque<String>>>,
commands: Rc<RefCell<Vec<String>>>,
}
impl MockRemote {
fn with_outputs(outputs: impl IntoIterator<Item = impl Into<String>>) -> Self {
Self {
outputs: Rc::new(RefCell::new(outputs.into_iter().map(Into::into).collect())),
commands: Rc::new(RefCell::new(Vec::new())),
}
}
}
impl RemoteCommandRunner for MockRemote {
fn run(&self, command: &str) -> CommandResult<String> {
self.commands.borrow_mut().push(command.to_string());
self.outputs
.borrow_mut()
.pop_front()
.ok_or_else(|| failure("no mock output queued"))
}
fn run_script(&self, script: &str) -> CommandResult<String> {
self.run(script)
}
}
#[test]
fn lists_battlegroups_from_cluster_json() {
let remote = MockRemote::with_outputs([r#"{
"items": [
{"metadata":{"namespace":"default","name":"ignored"}},
{"metadata":{"namespace":"funcom-seabass-sh-host-bbbbbb","name":"sh-host-bbbbbb"}},
{"metadata":{"namespace":"funcom-seabass-sh-host-aaaaaa","name":"sh-host-aaaaaa"}}
]
}"#]);
let ops = StructuredBattlegroupOps::new(remote);
assert_eq!(
ops.list().unwrap(),
vec![
BattlegroupRef {
namespace: "funcom-seabass-sh-host-aaaaaa".to_string(),
name: "sh-host-aaaaaa".to_string(),
},
BattlegroupRef {
namespace: "funcom-seabass-sh-host-bbbbbb".to_string(),
name: "sh-host-bbbbbb".to_string(),
}
]
);
}
#[test]
fn region_patch_uses_rust_built_json_patch_without_jq() {
let remote = MockRemote::with_outputs([
r#"{
"metadata":{"name":"sh-host-abcdef"},
"spec":{
"dataCenter":"Old",
"args":["-FarmRegion=Old"],
"env":[{"name":"BATTLEGROUP_REGION_NAME","value":"Old"}]
}
}"#,
r#"{"metadata":{"name":"sh-host-abcdef"}}"#,
]);
let commands = remote.commands.clone();
let ops = StructuredBattlegroupOps::new(remote);
ops.patch_region(
&BattlegroupRef {
namespace: "funcom-seabass-sh-host-abcdef".to_string(),
name: "sh-host-abcdef".to_string(),
},
"Europe",
)
.unwrap();
let commands = commands.borrow();
assert!(commands[0].contains("kubectl get battlegroup"));
assert!(commands[1].contains("kubectl patch battlegroup"));
assert!(commands[1].contains("--type=json"));
assert!(
commands[1].contains("BATTLEGROUP_REGION_NAME") || commands[1].contains("/env/0/value")
);
assert!(commands[1].contains("dataCenter"));
assert!(commands[1].contains("-FarmRegion=Europe"));
assert!(!commands.join("\n").contains("jq"));
assert!(!commands.join("\n").contains(" sed "));
}
#[test]
fn exports_logs_by_enumerating_pods_and_containers() {
let remote = MockRemote::with_outputs([
r#"{
"items": [{
"metadata":{"name":"pod-a","labels":{"role":"gateway"}},
"spec":{"containers":[{"name":"main"},{"name":"sidecar"}]}
}]
}"#,
"main log",
"sidecar log",
]);
let commands = remote.commands.clone();
let ops = StructuredBattlegroupOps::new(remote);
let files = ops
.export_namespace_logs("funcom-seabass-sh-host-abcdef")
.unwrap();
assert_eq!(files.len(), 2);
assert_eq!(files[0].relative_path, "pod-a/main.log");
let commands = commands.borrow();
assert!(commands[0].contains("kubectl get pods"));
assert!(commands[1].contains("kubectl logs"));
assert!(commands[1].contains("--timestamps"));
}
#[test]
fn builds_pod_shell_command_candidates() {
let ops = StructuredBattlegroupOps::new(MockRemote::default());
let spec = ops
.pod_shell_spec("funcom-seabass-sh-host-abcdef", "pod-a")
.unwrap();
assert_eq!(spec.commands[0].last().unwrap(), "/bin/bash");
assert_eq!(spec.commands[1].last().unwrap(), "/bin/sh");
}

View File

@@ -0,0 +1,93 @@
//! Region patch operation building and shell quoting helpers.
use serde_json::{json, Value};
use crate::{errors::failure, models::CommandResult};
pub(super) fn validate_region(region: &str) -> CommandResult<()> {
match region {
"Asia" | "Europe" | "North America" | "Oceania" | "South America" => Ok(()),
_ => Err(failure(
"Region must be Asia, Europe, North America, Oceania, or South America",
)),
}
}
pub(super) fn sh_single_quoted(value: &str) -> String {
format!("'{}'", value.replace('\'', "'\"'\"'"))
}
pub(super) fn region_patch_operations(value: &Value, region: &str) -> CommandResult<Vec<Value>> {
let mut operations = Vec::new();
collect_region_patch_operations(value, &mut Vec::new(), region, &mut operations);
if operations.is_empty() {
return Err(failure("No battlegroup region fields were found to patch"));
}
Ok(operations)
}
fn collect_region_patch_operations(
value: &Value,
path: &mut Vec<String>,
region: &str,
operations: &mut Vec<Value>,
) {
match value {
Value::Object(map) => {
if map
.get("name")
.and_then(Value::as_str)
.is_some_and(|name| name == "BATTLEGROUP_REGION_NAME")
&& map.get("value").is_some()
{
let mut value_path = path.clone();
value_path.push("value".to_string());
operations.push(replace_operation(&value_path, json!(region)));
}
for (key, child) in map {
path.push(key.clone());
if key == "dataCenter" && child.is_string() {
operations.push(replace_operation(path, json!(region)));
}
collect_region_patch_operations(child, path, region, operations);
path.pop();
}
}
Value::Array(items) => {
for (index, child) in items.iter().enumerate() {
path.push(index.to_string());
if child
.as_str()
.is_some_and(|text| text.starts_with("-FarmRegion="))
{
operations.push(replace_operation(
path,
json!(format!("-FarmRegion={region}")),
));
}
collect_region_patch_operations(child, path, region, operations);
path.pop();
}
}
_ => {}
}
}
fn replace_operation(path: &[String], value: Value) -> Value {
json!({
"op": "replace",
"path": json_pointer(path),
"value": value,
})
}
fn json_pointer(path: &[String]) -> String {
format!(
"/{}",
path.iter()
.map(|item| item.replace('~', "~0").replace('/', "~1"))
.collect::<Vec<_>>()
.join("/")
)
}

View File

@@ -0,0 +1,50 @@
//! Data types for battlegroup status snapshots, pods, shells, and exported logs.
use serde::{Deserialize, Serialize};
use serde_json::Value;
/// Pod/container pair discovered in a namespace.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PodContainerRef {
/// Pod name.
pub pod: String,
/// Container name inside the pod.
pub container: String,
/// Workload role label, when present.
pub role: String,
}
/// Candidate commands for opening a shell into a pod.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PodShellSpec {
/// Kubernetes namespace.
pub namespace: String,
/// Pod name.
pub pod: String,
/// Ordered shell command candidates.
pub commands: Vec<Vec<String>>,
}
/// Exported log file contents.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct LogFile {
/// Relative path to use when writing the log archive.
pub relative_path: String,
/// Log contents.
pub contents: String,
}
/// Combined battlegroup resource and runtime status snapshot.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct BattlegroupStatusSnapshot {
/// Raw live BattleGroup custom resource JSON.
pub battlegroup: Value,
/// Pods and containers in the battlegroup namespace.
pub pods: Vec<PodContainerRef>,
/// Director NodePort, when discovered.
pub director_node_port: Option<u16>,
}

View File

@@ -0,0 +1,298 @@
use std::{thread, time::Duration};
use crate::{
errors::failure,
models::CommandResult,
orchestration::{
BattlegroupState, BattlegroupWrapperOps, KubernetesProvider, OperationSink,
OrchestrationEvent, ProviderKind, StepAction, StepDomain,
},
};
use super::models::{is_started_state, validate_ipv4ish, BattlegroupRef, ServiceUrl};
/// Performs routine BattleGroup lifecycle operations.
///
/// Start/stop/restart/update are delegated to the vendor wrapper at
/// `/home/dune/.dune/bin/battlegroup`. Waits and admin URL discovery use the
/// Kubernetes provider directly because the wrapper does not expose those.
pub struct BattlegroupManagementOrchestrator<K, W> {
kubernetes: K,
wrapper: W,
}
impl<K, W> BattlegroupManagementOrchestrator<K, W>
where
K: KubernetesProvider,
W: BattlegroupWrapperOps,
{
/// Creates an orchestrator from a Kubernetes provider and vendor wrapper.
pub fn new(kubernetes: K, wrapper: W) -> Self {
Self {
kubernetes,
wrapper,
}
}
/// Borrows the underlying Kubernetes provider.
pub fn kubernetes(&self) -> &K {
&self.kubernetes
}
/// Borrows the underlying vendor wrapper.
pub fn wrapper(&self) -> &W {
&self.wrapper
}
/// Starts a BattleGroup via the vendor wrapper's `start` action.
pub fn start(
&self,
battlegroup: &BattlegroupRef,
sink: &mut impl OperationSink,
) -> CommandResult<()> {
battlegroup.validate()?;
emit(sink, "bg.start", "Starting battlegroup.", StepAction::Start);
self.wrapper.start(battlegroup).map(|_| ())
}
/// Stops a BattleGroup via the vendor wrapper's `stop` action.
pub fn stop(
&self,
battlegroup: &BattlegroupRef,
sink: &mut impl OperationSink,
) -> CommandResult<()> {
battlegroup.validate()?;
emit(sink, "bg.stop", "Stopping battlegroup.", StepAction::Stop);
self.wrapper.stop(battlegroup).map(|_| ())
}
/// Restarts a BattleGroup via the vendor wrapper's `restart` action.
pub fn restart(
&self,
battlegroup: &BattlegroupRef,
sink: &mut impl OperationSink,
) -> CommandResult<()> {
battlegroup.validate()?;
emit(
sink,
"bg.restart",
"Restarting battlegroup.",
StepAction::Start,
);
self.wrapper.restart(battlegroup).map(|_| ())
}
/// Updates a BattleGroup via the vendor wrapper's `update` action.
///
/// The wrapper runs steamcmd, refreshes operators and map manifests,
/// loads new images via `ctr`, and patches the battlegroup's image fields.
/// This call blocks for the full duration of the update.
pub fn update(
&self,
battlegroup: &BattlegroupRef,
sink: &mut impl OperationSink,
) -> CommandResult<String> {
battlegroup.validate()?;
emit(
sink,
"bg.update",
"Updating battlegroup via vendor wrapper.",
StepAction::Patch,
);
let outcome = self.wrapper.update(battlegroup)?;
Ok(outcome.stdout)
}
/// Reads battlegroup state via the wrapper's `status` action.
///
/// The wrapper does not surface `.spec.stop`; this method overlays it from
/// the Kubernetes provider so callers get a complete state in one call.
pub fn status(&self, battlegroup: &BattlegroupRef) -> CommandResult<BattlegroupState> {
battlegroup.validate()?;
let mut state = self.wrapper.status(battlegroup)?;
match self
.kubernetes
.battlegroup_state(&battlegroup.namespace, &battlegroup.name)
{
Ok(kube) => state.stop = kube.stop,
Err(err) => {
// The wrapper's status is the source of truth; if .spec.stop
// read fails (e.g., RBAC), prefer a `stop=true` fallback when
// the status row clearly says the BG is stopped.
if status_phase_looks_stopped(&state.phase) {
state.stop = true;
}
let _ = err;
}
}
Ok(state)
}
/// Starts a BattleGroup and waits for the Director NodePort to appear.
pub fn start_and_wait_director(
&self,
battlegroup: &BattlegroupRef,
timeout_seconds: u64,
sink: &mut impl OperationSink,
) -> CommandResult<Option<u16>> {
self.start(battlegroup, sink)?;
self.wait_for_battlegroup_started(battlegroup, timeout_seconds, sink)?;
self.wait_for_director_node_port(battlegroup, timeout_seconds, sink)
}
/// Restarts a BattleGroup and waits for the Director NodePort to appear.
pub fn restart_and_wait_director(
&self,
battlegroup: &BattlegroupRef,
timeout_seconds: u64,
sink: &mut impl OperationSink,
) -> CommandResult<Option<u16>> {
self.restart(battlegroup, sink)?;
self.wait_for_battlegroup_started(battlegroup, timeout_seconds, sink)?;
self.wait_for_director_node_port(battlegroup, timeout_seconds, sink)
}
/// Polls the wrapper's status until the BattleGroup leaves a stopped state
/// or the timeout expires.
pub fn wait_for_battlegroup_started(
&self,
battlegroup: &BattlegroupRef,
timeout_seconds: u64,
sink: &mut impl OperationSink,
) -> CommandResult<BattlegroupState> {
battlegroup.validate()?;
emit(
sink,
"bg.wait-started",
"Waiting for battlegroup to leave stopped state.",
StepAction::Wait,
);
let mut elapsed = 0;
let mut last = None;
while elapsed <= timeout_seconds {
// Read started-ness from the stable Kubernetes schema, not the
// vendor wrapper's `status` text. That text layout drifts across
// Funcom releases (status="World", director="2/2", etc.) and was
// misparsed into unrecognised phases, so the wait never saw the BG
// as started and stalled until timeout even when it was up (#19).
match self
.kubernetes
.battlegroup_state(&battlegroup.namespace, &battlegroup.name)
{
Ok(state) => {
if is_started_state(&state) {
return Ok(state);
}
last = Some(state);
}
Err(_) => {
// Keep polling on transient errors; kubectl can briefly
// fail while the BG is reconciling.
}
}
thread::sleep(Duration::from_secs(2));
elapsed += 2;
}
let detail = last
.map(|state| {
format!(
"last status={}, stop={}, database={}, gateway={}, director={}",
state.phase,
state.stop,
state.database_phase,
state.server_group_phase,
state.director_phase
)
})
.unwrap_or_else(|| "no BattleGroup status was read".to_string());
Err(failure(format!(
"BattleGroup did not leave stopped state within {timeout_seconds}s ({detail})"
)))
}
/// Builds the file-browser URL for a VM IP.
pub fn file_browser_url(&self, vm_ip: &str) -> CommandResult<ServiceUrl> {
validate_ipv4ish(vm_ip, "VM IP")?;
Ok(ServiceUrl {
url: format!("http://{vm_ip}:18888/"),
})
}
/// Discovers and builds the Director URL for a BattleGroup, if exposed.
pub fn director_url(
&self,
battlegroup: &BattlegroupRef,
vm_ip: &str,
) -> CommandResult<Option<ServiceUrl>> {
battlegroup.validate()?;
validate_ipv4ish(vm_ip, "VM IP")?;
let Some(port) = self.kubernetes.director_node_port(&battlegroup.namespace)? else {
return Ok(None);
};
Ok(Some(ServiceUrl {
url: format!("http://{vm_ip}:{port}/"),
}))
}
/// Returns the only BattleGroup namespace when exactly one is present.
pub fn discover_single_battlegroup_namespace(&self) -> CommandResult<Option<String>> {
let namespaces = self.kubernetes.list_battlegroup_namespaces()?;
match namespaces.as_slice() {
[] => Ok(None),
[namespace] => Ok(Some(namespace.clone())),
_ => Err(failure("Multiple battlegroup namespaces were found")),
}
}
/// Polls Kubernetes until the Director service has a NodePort or times out.
pub fn wait_for_director_node_port(
&self,
battlegroup: &BattlegroupRef,
timeout_seconds: u64,
sink: &mut impl OperationSink,
) -> CommandResult<Option<u16>> {
battlegroup.validate()?;
emit(
sink,
"bg.director.wait-port",
"Waiting for Director service port.",
StepAction::Wait,
);
let mut elapsed = 0;
while elapsed <= timeout_seconds {
if let Some(port) = self.kubernetes.director_node_port(&battlegroup.namespace)? {
return Ok(Some(port));
}
thread::sleep(Duration::from_secs(2));
elapsed += 2;
}
Ok(None)
}
}
fn status_phase_looks_stopped(phase: &str) -> bool {
let normalized = phase.trim().to_ascii_lowercase();
matches!(
normalized.as_str(),
"stopped" | "suspended" | "notready" | "not_ready"
)
}
pub(super) fn emit(
sink: &mut impl OperationSink,
step_id: &'static str,
message: impl Into<String>,
action: StepAction,
) {
sink.emit(OrchestrationEvent {
step_id,
message: message.into(),
domain: StepDomain::Kubernetes,
action,
provider: ProviderKind::Kubernetes,
});
}
#[cfg(test)]
#[path = "lifecycle_tests.rs"]
mod tests;

View File

@@ -0,0 +1,266 @@
use std::{cell::RefCell, rc::Rc};
use crate::orchestration::{
BattlegroupWrapperOps, KubernetesProvider, VecOperationSink, WrapperOutcome,
};
use super::*;
#[derive(Default)]
struct MockKubernetes {
namespaces: Vec<String>,
director_ports: Rc<RefCell<Vec<Option<u16>>>>,
battlegroup_states: Rc<RefCell<Vec<BattlegroupState>>>,
}
impl KubernetesProvider for MockKubernetes {
fn list_battlegroup_namespaces(&self) -> CommandResult<Vec<String>> {
Ok(self.namespaces.clone())
}
fn patch_battlegroup_stop(
&self,
_namespace: &str,
_name: &str,
_stop: bool,
) -> CommandResult<()> {
Ok(())
}
fn battlegroup_state(&self, _namespace: &str, _name: &str) -> CommandResult<BattlegroupState> {
Ok(self
.battlegroup_states
.borrow_mut()
.pop()
.unwrap_or_default())
}
fn director_node_port(&self, _namespace: &str) -> CommandResult<Option<u16>> {
Ok(self.director_ports.borrow_mut().pop().unwrap_or(None))
}
}
#[derive(Default)]
struct MockWrapper {
calls: Rc<RefCell<Vec<String>>>,
statuses: Rc<RefCell<Vec<BattlegroupState>>>,
}
impl MockWrapper {
fn record(&self, action: &str, bg: &BattlegroupRef) -> WrapperOutcome {
self.calls
.borrow_mut()
.push(format!("{action}:{}/{}", bg.namespace, bg.name));
WrapperOutcome {
action: match action {
"start" => crate::orchestration::WrapperAction::Start,
"stop" => crate::orchestration::WrapperAction::Stop,
"restart" => crate::orchestration::WrapperAction::Restart,
"update" => crate::orchestration::WrapperAction::Update,
_ => crate::orchestration::WrapperAction::Status,
},
stdout: String::new(),
}
}
}
impl BattlegroupWrapperOps for MockWrapper {
fn status(&self, _battlegroup: &BattlegroupRef) -> CommandResult<BattlegroupState> {
Ok(self
.statuses
.borrow_mut()
.pop()
.unwrap_or_else(running_state))
}
fn start(&self, battlegroup: &BattlegroupRef) -> CommandResult<WrapperOutcome> {
Ok(self.record("start", battlegroup))
}
fn stop(&self, battlegroup: &BattlegroupRef) -> CommandResult<WrapperOutcome> {
Ok(self.record("stop", battlegroup))
}
fn restart(&self, battlegroup: &BattlegroupRef) -> CommandResult<WrapperOutcome> {
Ok(self.record("restart", battlegroup))
}
fn update(&self, battlegroup: &BattlegroupRef) -> CommandResult<WrapperOutcome> {
Ok(self.record("update", battlegroup))
}
}
fn running_state() -> BattlegroupState {
BattlegroupState {
phase: "Running".to_string(),
database_phase: "Running".to_string(),
server_group_phase: "Running".to_string(),
director_phase: "Healthy".to_string(),
..BattlegroupState::default()
}
}
fn sample_bg() -> BattlegroupRef {
BattlegroupRef {
namespace: "funcom-seabass-sh-host-abcdef".to_string(),
name: "sh-host-abcdef".to_string(),
}
}
#[test]
fn restart_invokes_wrapper_restart_once() {
let wrapper = MockWrapper::default();
let calls = wrapper.calls.clone();
let orchestrator = BattlegroupManagementOrchestrator::new(MockKubernetes::default(), wrapper);
let mut sink = VecOperationSink::default();
orchestrator.restart(&sample_bg(), &mut sink).unwrap();
assert_eq!(
calls.borrow().as_slice(),
&["restart:funcom-seabass-sh-host-abcdef/sh-host-abcdef"]
);
}
#[test]
fn update_invokes_wrapper_update() {
let wrapper = MockWrapper::default();
let calls = wrapper.calls.clone();
let orchestrator = BattlegroupManagementOrchestrator::new(MockKubernetes::default(), wrapper);
let mut sink = VecOperationSink::default();
orchestrator.update(&sample_bg(), &mut sink).unwrap();
assert_eq!(
calls.borrow().as_slice(),
&["update:funcom-seabass-sh-host-abcdef/sh-host-abcdef"]
);
}
#[test]
fn builds_service_urls_without_shelling_out() {
let kubernetes = MockKubernetes {
namespaces: vec![],
director_ports: Rc::new(RefCell::new(vec![Some(32527)])),
battlegroup_states: Rc::new(RefCell::new(vec![])),
};
let orchestrator = BattlegroupManagementOrchestrator::new(kubernetes, MockWrapper::default());
assert_eq!(
orchestrator.file_browser_url("10.0.0.4").unwrap().url,
"http://10.0.0.4:18888/"
);
assert_eq!(
orchestrator
.director_url(&sample_bg(), "10.0.0.4")
.unwrap()
.unwrap()
.url,
"http://10.0.0.4:32527/"
);
}
#[test]
fn start_waits_for_director_node_port_after_wrapper_start() {
let kubernetes = MockKubernetes {
namespaces: vec![],
director_ports: Rc::new(RefCell::new(vec![Some(32527)])),
battlegroup_states: Rc::new(RefCell::new(vec![running_state()])),
};
let wrapper = MockWrapper {
statuses: Rc::new(RefCell::new(vec![running_state()])),
..Default::default()
};
let calls = wrapper.calls.clone();
let orchestrator = BattlegroupManagementOrchestrator::new(kubernetes, wrapper);
let mut sink = VecOperationSink::default();
let port = orchestrator
.start_and_wait_director(&sample_bg(), 0, &mut sink)
.unwrap();
assert_eq!(port, Some(32527));
assert_eq!(
calls.borrow().as_slice(),
&["start:funcom-seabass-sh-host-abcdef/sh-host-abcdef"]
);
assert!(sink
.events
.iter()
.any(|event| event.step_id == "bg.director.wait-port"));
assert!(sink
.events
.iter()
.any(|event| event.step_id == "bg.wait-started"));
}
#[test]
fn start_wait_rejects_stopped_phase_even_when_stop_flag_is_false() {
// The wait reads from Kubernetes (the stable schema), so the stopped
// state must come from the kubernetes mock, not the vendor wrapper.
let stopped_state = BattlegroupState {
phase: "Stopped".to_string(),
server_group_phase: "Stopped".to_string(),
director_phase: "Suspended".to_string(),
..BattlegroupState::default()
};
let kubernetes = MockKubernetes {
battlegroup_states: Rc::new(RefCell::new(vec![stopped_state])),
..Default::default()
};
let orchestrator = BattlegroupManagementOrchestrator::new(kubernetes, MockWrapper::default());
let mut sink = VecOperationSink::default();
let result = orchestrator.wait_for_battlegroup_started(&sample_bg(), 0, &mut sink);
assert!(result.is_err());
}
#[test]
fn start_wait_accepts_reconciling_state_from_kubernetes() {
// #19/#20: a BG up but lingering at phase=Reconciling (serverGroupPhase
// Running, director Healthy) must satisfy the wait instead of stalling
// until timeout. The wrapper status is deliberately left as the broken
// shape it would produce in the field to prove it is no longer consulted.
let reconciling = BattlegroupState {
phase: "Reconciling".to_string(),
server_group_phase: "Running".to_string(),
director_phase: "Healthy".to_string(),
..BattlegroupState::default()
};
let kubernetes = MockKubernetes {
battlegroup_states: Rc::new(RefCell::new(vec![reconciling])),
..Default::default()
};
let broken_wrapper_state = BattlegroupState {
phase: "World".to_string(),
server_group_phase: "Ready".to_string(),
director_phase: "2/2".to_string(),
..BattlegroupState::default()
};
let wrapper = MockWrapper {
statuses: Rc::new(RefCell::new(vec![broken_wrapper_state])),
..Default::default()
};
let orchestrator = BattlegroupManagementOrchestrator::new(kubernetes, wrapper);
let mut sink = VecOperationSink::default();
let state = orchestrator
.wait_for_battlegroup_started(&sample_bg(), 0, &mut sink)
.expect("reconciling BG should satisfy the wait");
assert_eq!(state.phase, "Reconciling");
}
#[test]
fn status_overlays_spec_stop_from_kubernetes() {
let kubernetes = MockKubernetes {
namespaces: vec![],
director_ports: Rc::new(RefCell::new(vec![])),
battlegroup_states: Rc::new(RefCell::new(vec![BattlegroupState {
stop: true,
..BattlegroupState::default()
}])),
};
let wrapper = MockWrapper {
statuses: Rc::new(RefCell::new(vec![running_state()])),
..Default::default()
};
let orchestrator = BattlegroupManagementOrchestrator::new(kubernetes, wrapper);
let state = orchestrator.status(&sample_bg()).unwrap();
assert!(
state.stop,
"spec.stop overlay should win even if wrapper says running"
);
assert_eq!(state.phase, "Running");
}

View File

@@ -0,0 +1,7 @@
//! BattleGroup lifecycle and status orchestration.
mod lifecycle;
mod models;
pub use lifecycle::BattlegroupManagementOrchestrator;
pub use models::{is_started_state, BattlegroupRef, ServiceUrl};

View File

@@ -0,0 +1,67 @@
use serde::Serialize;
use crate::{
errors::failure, models::CommandResult, orchestration::BattlegroupState,
validation::validate_kube_arg,
};
/// Names a live BattleGroup custom resource.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct BattlegroupRef {
/// Kubernetes namespace containing the BattleGroup.
pub namespace: String,
/// BattleGroup resource name.
pub name: String,
}
impl BattlegroupRef {
/// Validates the namespace and resource name for safe kubectl usage.
pub fn validate(&self) -> CommandResult<()> {
validate_kube_arg(&self.namespace, "namespace")?;
validate_kube_arg(&self.name, "battlegroup name")?;
Ok(())
}
}
/// Browser-openable URL for a service exposed from the VM.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ServiceUrl {
/// Fully qualified HTTP URL.
pub url: String,
}
/// Returns whether the live BattleGroup state is operational enough to treat as started.
pub fn is_started_state(state: &BattlegroupState) -> bool {
!state.stop
&& is_started_phase(&state.phase)
&& is_started_phase(&state.server_group_phase)
&& is_director_ready_phase(&state.director_phase)
}
fn is_started_phase(phase: &str) -> bool {
let normalized = phase.trim().to_ascii_lowercase();
matches!(
normalized.as_str(),
"running" | "ready" | "healthy" | "available" | "reconciling"
)
}
fn is_director_ready_phase(phase: &str) -> bool {
let normalized = phase.trim().to_ascii_lowercase();
normalized.is_empty()
|| matches!(
normalized.as_str(),
"running" | "ready" | "healthy" | "available" | "reconciling"
)
}
pub(super) fn validate_ipv4ish(value: &str, label: &str) -> CommandResult<()> {
let parts = value.split('.').collect::<Vec<_>>();
if parts.len() == 4 && parts.iter().all(|part| part.parse::<u8>().is_ok()) {
Ok(())
} else {
Err(failure(format!("{label} must be an IPv4 address")))
}
}

View File

@@ -0,0 +1,18 @@
//! Vendor `/home/dune/.dune/bin/battlegroup` wrapper driver.
//!
//! The vendor wrapper is the source of truth for BattleGroup lifecycle and
//! status. This module shells out to it via a [`RemoteCommandRunner`] and
//! parses the human-readable status text into structured fields so the rest
//! of the orchestration code can keep typed [`BattlegroupState`] values.
//!
//! [`RemoteCommandRunner`]: crate::orchestration::RemoteCommandRunner
//! [`BattlegroupState`]: crate::orchestration::BattlegroupState
mod status_parser;
mod wrapper;
pub use status_parser::parse_wrapper_status;
pub use wrapper::{
BattlegroupWrapperOps, VendorBattlegroupWrapper, WrapperAction, WrapperOutcome,
VENDOR_EFFECTIVE_USER, VENDOR_WRAPPER_PATH,
};

View File

@@ -0,0 +1,174 @@
//! Parses the vendor `battlegroup status` text into structured fields.
//!
//! Wrapper output looks like:
//!
//! ```text
//! Battlegroup: my-bg
//! Battlegroup Info
//! Status Database Gateway Director Uptime
//! ---------- ---------- ---------- ---------- --------
//! Running Running Running Running 1h2m
//!
//! Game Servers
//! Map Phase Ready Players Age
//! --------------- ---------------------- ------ -------- ------
//! Survival_1 Running True 3 1h
//! DeepDesert_1 Stopped False 0 1h
//! ```
//!
//! Lines are whitespace-separated; column count is the contract, not column
//! widths.
use crate::orchestration::{BattlegroupState, ServerStatRow};
const INFO_HEADER: &str = "Battlegroup Info";
const SERVERS_HEADER: &str = "Game Servers";
/// Parses the vendor wrapper's `status` stdout into a [`BattlegroupState`].
///
/// Returns `None` when neither an info row nor a recognizable layout is
/// found, so callers can distinguish a parse failure from an empty result.
pub fn parse_wrapper_status(text: &str) -> Option<BattlegroupState> {
let lines: Vec<&str> = text.lines().collect();
let info_idx = lines.iter().position(|line| line.trim() == INFO_HEADER)?;
let info_row = data_row_after_header(&lines, info_idx)?;
let info_fields = whitespace_fields(info_row);
if info_fields.len() < 4 {
return None;
}
let phase = info_fields[0].clone();
let database_phase = info_fields[1].clone();
let gateway_phase = info_fields[2].clone();
let director_phase = info_fields[3].clone();
let uptime = info_fields.get(4).cloned().unwrap_or_default();
let mut server_stats = Vec::new();
if let Some(servers_idx) = lines.iter().position(|line| line.trim() == SERVERS_HEADER) {
for row in data_rows_after_header(&lines, servers_idx) {
let fields = whitespace_fields(row);
if fields.len() < 5 {
continue;
}
server_stats.push(ServerStatRow {
map: fields[0].clone(),
phase: fields[1].clone(),
ready: fields[2].clone(),
players: fields[3].clone(),
age: fields[4].clone(),
});
}
}
Some(BattlegroupState {
stop: false,
phase,
database_phase,
server_group_phase: gateway_phase,
director_phase,
uptime,
server_stats,
})
}
fn data_row_after_header<'a>(lines: &'a [&'a str], header_idx: usize) -> Option<&'a str> {
data_rows_after_header(lines, header_idx).into_iter().next()
}
fn data_rows_after_header<'a>(lines: &'a [&'a str], header_idx: usize) -> Vec<&'a str> {
let mut rows = Vec::new();
let mut started = false;
for line in lines.iter().skip(header_idx + 1) {
let trimmed = line.trim();
if trimmed.is_empty() {
if started {
break;
}
continue;
}
if is_divider(trimmed) {
started = true;
continue;
}
if !started {
// First non-empty line after the section header is the column
// header; skip it without yet collecting data.
started = false;
continue;
}
rows.push(*line);
}
rows
}
fn is_divider(value: &str) -> bool {
!value.is_empty()
&& value
.chars()
.all(|c| c == '-' || c == '=' || c.is_whitespace())
}
fn whitespace_fields(line: &str) -> Vec<String> {
line.split_whitespace().map(str::to_string).collect()
}
#[cfg(test)]
mod tests {
use super::*;
const SAMPLE: &str = "\
Battlegroup: my-bg
Battlegroup Info
Status Database Gateway Director Uptime
---------- ---------- ---------- ---------- --------
Running Running Running Running 1h2m
Game Servers
Map Phase Ready Players Age
--------------- ---------------------- ------ -------- ------
Survival_1 Running True 3 1h
DeepDesert_1 Stopped False 0 1h
";
#[test]
fn parses_info_row_into_battlegroup_state() {
let state = parse_wrapper_status(SAMPLE).expect("parse");
assert!(!state.stop);
assert_eq!(state.phase, "Running");
assert_eq!(state.database_phase, "Running");
assert_eq!(state.server_group_phase, "Running");
assert_eq!(state.director_phase, "Running");
assert_eq!(state.uptime, "1h2m");
}
#[test]
fn parses_server_stats_rows() {
let state = parse_wrapper_status(SAMPLE).expect("parse");
assert_eq!(state.server_stats.len(), 2);
assert_eq!(state.server_stats[0].map, "Survival_1");
assert_eq!(state.server_stats[0].phase, "Running");
assert_eq!(state.server_stats[0].ready, "True");
assert_eq!(state.server_stats[0].players, "3");
assert_eq!(state.server_stats[1].map, "DeepDesert_1");
assert_eq!(state.server_stats[1].phase, "Stopped");
}
#[test]
fn handles_missing_uptime_gracefully() {
let text = "\
Battlegroup: x
Battlegroup Info
Status Database Gateway Director Uptime
---------- ---------- ---------- ---------- --------
Stopped Stopped Stopped Stopped
";
let state = parse_wrapper_status(text).expect("parse");
assert_eq!(state.phase, "Stopped");
assert_eq!(state.uptime, "");
assert!(state.server_stats.is_empty());
}
#[test]
fn returns_none_when_info_section_missing() {
assert!(parse_wrapper_status("nothing here").is_none());
}
}

View File

@@ -0,0 +1,264 @@
//! Sync driver around the vendor `/home/dune/.dune/bin/battlegroup` wrapper.
use crate::{
errors::failure,
models::CommandResult,
orchestration::{BattlegroupRef, BattlegroupState, RemoteCommandRunner},
};
use super::status_parser::parse_wrapper_status;
/// Lifecycle and status operations exposed by a vendor wrapper driver.
///
/// Implemented by [`VendorBattlegroupWrapper`] and by test mocks. Production
/// orchestrators use this trait so they remain decoupled from the SSH
/// transport.
pub trait BattlegroupWrapperOps {
/// Reads current battlegroup state via the wrapper's `status` action.
fn status(&self, battlegroup: &BattlegroupRef) -> CommandResult<BattlegroupState>;
/// Starts the battlegroup via the wrapper's `start` action.
fn start(&self, battlegroup: &BattlegroupRef) -> CommandResult<WrapperOutcome>;
/// Stops the battlegroup via the wrapper's `stop` action.
fn stop(&self, battlegroup: &BattlegroupRef) -> CommandResult<WrapperOutcome>;
/// Restarts the battlegroup via the wrapper's `restart` action.
fn restart(&self, battlegroup: &BattlegroupRef) -> CommandResult<WrapperOutcome>;
/// Runs the wrapper's `update` action (steamcmd + operators + maps + images).
fn update(&self, battlegroup: &BattlegroupRef) -> CommandResult<WrapperOutcome>;
}
/// Path to the vendor wrapper script on the guest.
pub const VENDOR_WRAPPER_PATH: &str = "/home/dune/.dune/bin/battlegroup";
/// The user the vendor wrapper expects to be invoked as. When the SSH
/// session logs in as a different account (typically `root`), the wrapper
/// is re-launched under `sudo -u dune -H bash -lc ...` so file ownership
/// under `/home/dune` stays correct.
pub const VENDOR_EFFECTIVE_USER: &str = "dune";
/// One of the vendor wrapper actions this driver supports.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum WrapperAction {
/// `battlegroup status` — read current battlegroup state.
Status,
/// `battlegroup start` — clears `spec.stop`.
Start,
/// `battlegroup stop` — sets `spec.stop=true`.
Stop,
/// `battlegroup restart` — stop, sleep 5, start.
Restart,
/// `battlegroup update` — steamcmd, operator update, map update, image patch.
Update,
}
impl WrapperAction {
/// Returns the subcommand string passed to the wrapper.
pub fn as_str(self) -> &'static str {
match self {
Self::Status => "status",
Self::Start => "start",
Self::Stop => "stop",
Self::Restart => "restart",
Self::Update => "update",
}
}
}
/// Captured output of a wrapper invocation.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct WrapperOutcome {
/// Action that was executed.
pub action: WrapperAction,
/// Combined stdout captured from the wrapper.
pub stdout: String,
}
/// Driver that shells out to the vendor battlegroup wrapper.
#[derive(Debug, Clone)]
pub struct VendorBattlegroupWrapper<R> {
runner: R,
ssh_user: String,
}
impl<R> VendorBattlegroupWrapper<R>
where
R: RemoteCommandRunner,
{
/// Creates a wrapper that assumes the SSH session is already logged in
/// as the vendor's expected user (`dune`).
pub fn new(runner: R) -> Self {
Self::with_ssh_user(runner, VENDOR_EFFECTIVE_USER)
}
/// Creates a wrapper driver around a remote command runner, recording
/// the SSH login user so the script can drop to `dune` via `sudo -u`
/// when the operator logged in as a different account.
pub fn with_ssh_user(runner: R, ssh_user: impl Into<String>) -> Self {
Self {
runner,
ssh_user: ssh_user.into(),
}
}
/// Borrows the underlying runner.
pub fn runner(&self) -> &R {
&self.runner
}
/// Returns the SSH login user the wrapper was configured for.
pub fn ssh_user(&self) -> &str {
&self.ssh_user
}
/// Invokes the vendor wrapper for the given action against a specific
/// battlegroup. Returns captured stdout on success.
pub fn invoke(
&self,
battlegroup: &BattlegroupRef,
action: WrapperAction,
) -> CommandResult<WrapperOutcome> {
battlegroup.validate()?;
let script = build_wrapper_script(&battlegroup.namespace, action, &self.ssh_user);
let stdout = self.runner.run_script(&script)?;
Ok(WrapperOutcome { action, stdout })
}
}
impl<R> BattlegroupWrapperOps for VendorBattlegroupWrapper<R>
where
R: RemoteCommandRunner,
{
fn status(&self, battlegroup: &BattlegroupRef) -> CommandResult<BattlegroupState> {
let outcome = self.invoke(battlegroup, WrapperAction::Status)?;
parse_wrapper_status(&outcome.stdout).ok_or_else(|| {
failure(format!(
"Could not parse vendor battlegroup status output:\n{}",
outcome.stdout
))
})
}
fn start(&self, battlegroup: &BattlegroupRef) -> CommandResult<WrapperOutcome> {
self.invoke(battlegroup, WrapperAction::Start)
}
fn stop(&self, battlegroup: &BattlegroupRef) -> CommandResult<WrapperOutcome> {
self.invoke(battlegroup, WrapperAction::Stop)
}
fn restart(&self, battlegroup: &BattlegroupRef) -> CommandResult<WrapperOutcome> {
self.invoke(battlegroup, WrapperAction::Restart)
}
fn update(&self, battlegroup: &BattlegroupRef) -> CommandResult<WrapperOutcome> {
self.invoke(battlegroup, WrapperAction::Update)
}
}
/// Builds the POSIX-sh snippet that drives the vendor wrapper for a known
/// namespace. The wrapper's own `select_battlegroup` enumerates
/// `funcom-seabass-*` namespaces and prompts if there is more than one, so
/// the snippet finds the target namespace's 1-based index against the same
/// listing and pipes it on stdin. When only one namespace exists, the
/// wrapper auto-selects and the piped index is harmless.
///
/// A trailing `N` line is piped after the index so any `Retry? [Y/N]`
/// follow-up prompt is answered with "no" instead of blocking.
///
/// The launcher is intentionally POSIX-sh (no bash arrays / process
/// substitution) because [`crate::orchestration::RemoteCommandRunner::run_script`]
/// pipes scripts to `sh -s`, which is `dash` on Ubuntu.
fn build_wrapper_script(target_ns: &str, action: WrapperAction, ssh_user: &str) -> String {
let ns_literal = sh_single_quoted(target_ns);
let action_literal = sh_single_quoted(action.as_str());
let needs_impersonation = ssh_user != VENDOR_EFFECTIVE_USER;
// When the SSH login user is not `dune`, re-launch the wrapper inside
// `sudo -n -u dune -H bash -lc '...'` so writes under /home/dune stay
// owned by dune. The inner bash payload reads `$1` and `$2` (idx +
// action) so we don't have to wrestle with nested single-quote
// escaping when the wrapper path or action ever contain shell
// metacharacters.
let invocation = if needs_impersonation {
format!(
r#"sudo -n -u {target} -H bash -lc 'printf "%s\nN\n" "$1" | "{wrapper}" "$2"' _ "$idx" "$ACTION""#,
target = VENDOR_EFFECTIVE_USER,
wrapper = VENDOR_WRAPPER_PATH,
)
} else {
r#"printf '%s\nN\n' "$idx" | "$WRAPPER" "$ACTION""#.to_string()
};
format!(
r#"set -eu
TARGET_NS={ns_literal}
ACTION={action_literal}
WRAPPER={wrapper_literal}
if [ ! -x "$WRAPPER" ]; then
echo "Vendor wrapper not found at $WRAPPER" >&2
exit 1
fi
idx=$(sudo kubectl get ns --no-headers -o custom-columns=NAME:.metadata.name 2>/dev/null \
| grep '^funcom-seabass-' \
| awk -v target="$TARGET_NS" 'BEGIN{{ found=0 }} {{ i++; if ($1==target) {{ print i; found=1; exit }} }} END{{ if (!found) exit 1 }}')
if [ -z "$idx" ]; then
echo "Battlegroup namespace $TARGET_NS not found in funcom-seabass-* listing" >&2
exit 1
fi
{invocation}
"#,
ns_literal = ns_literal,
action_literal = action_literal,
wrapper_literal = sh_single_quoted(VENDOR_WRAPPER_PATH),
invocation = invocation,
)
}
fn sh_single_quoted(value: &str) -> String {
format!("'{}'", value.replace('\'', "'\"'\"'"))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn wrapper_actions_have_subcommand_names() {
assert_eq!(WrapperAction::Status.as_str(), "status");
assert_eq!(WrapperAction::Start.as_str(), "start");
assert_eq!(WrapperAction::Stop.as_str(), "stop");
assert_eq!(WrapperAction::Restart.as_str(), "restart");
assert_eq!(WrapperAction::Update.as_str(), "update");
}
#[test]
fn build_wrapper_script_quotes_namespace_and_action() {
let script = build_wrapper_script("funcom-seabass-it's", WrapperAction::Status, "dune");
assert!(script.contains("'funcom-seabass-it'\"'\"'s'"));
assert!(script.contains("'status'"));
assert!(script.contains("/home/dune/.dune/bin/battlegroup"));
assert!(script.contains("printf '%s\\nN\\n'"));
}
#[test]
fn build_wrapper_script_is_posix_sh_safe() {
let script = build_wrapper_script("funcom-seabass-foo", WrapperAction::Status, "dune");
// Bash arrays and process substitution must not appear; the script is
// sent to `sh -s`, which is dash on Ubuntu.
assert!(!script.contains("namespaces=()"));
assert!(!script.contains("namespaces+=("));
assert!(!script.contains("${!namespaces"));
assert!(!script.contains("<("));
}
#[test]
fn build_wrapper_script_impersonates_dune_when_ssh_user_is_root() {
let script = build_wrapper_script("funcom-seabass-foo", WrapperAction::Status, "root");
assert!(script.contains("sudo -n -u dune -H bash -lc"));
assert!(script.contains("\"/home/dune/.dune/bin/battlegroup\""));
assert!(script.contains("_ \"$idx\" \"$ACTION\""));
}
#[test]
fn build_wrapper_script_skips_impersonation_when_ssh_user_is_dune() {
let script = build_wrapper_script("funcom-seabass-foo", WrapperAction::Start, "dune");
assert!(!script.contains("sudo -n -u dune"));
}
}

View File

@@ -0,0 +1,271 @@
//! Host-side detection of Dune Awakening Hyper-V VMs.
use serde::{Deserialize, Serialize};
use crate::{
models::CommandResult,
orchestration::{VmInventoryRecord, VmProvider},
};
/// Canonical virtual disk file name used by the vendor Dune server VM.
pub const DUNE_SERVER_VHDX_NAME: &str = "dune-server.vhdx";
/// Confidence level for host-only Dune VM detection.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum DuneVmConfidence {
/// Strong host-side fingerprint, usually the canonical Dune VHD name.
High,
/// Several soft host-side hints matched, but no canonical disk fingerprint.
Medium,
/// A single weak host-side hint matched.
Low,
}
/// A Hyper-V VM that appears to be a Dune Awakening dedicated server.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DuneVmCandidate {
/// Host VM inventory record.
pub vm: VmInventoryRecord,
/// Host-only confidence level.
pub confidence: DuneVmConfidence,
/// Human-readable detection reasons.
pub reasons: Vec<String>,
}
/// Detects Dune Awakening VMs from host-side Hyper-V inventory.
pub struct DuneVmDetector<V> {
vm: V,
}
impl<V> DuneVmDetector<V>
where
V: VmProvider,
{
/// Creates a detector from a VM provider.
pub fn new(vm: V) -> Self {
Self { vm }
}
/// Lists VMs that match Dune host-side fingerprints.
pub fn detect(&self) -> CommandResult<Vec<DuneVmCandidate>> {
Ok(self
.vm
.list_vms()?
.into_iter()
.filter_map(classify_dune_vm)
.collect())
}
}
/// Classifies a single VM inventory record using host-side Dune fingerprints.
pub fn classify_dune_vm(vm: VmInventoryRecord) -> Option<DuneVmCandidate> {
let mut reasons = Vec::new();
let mut strong = false;
if vm
.hard_disk_paths
.iter()
.any(|path| path_file_name_eq(path, DUNE_SERVER_VHDX_NAME))
{
strong = true;
reasons.push(format!("attached virtual disk is {DUNE_SERVER_VHDX_NAME}"));
}
let name = vm.name.to_ascii_lowercase();
if name.contains("dune") {
reasons.push("VM name contains dune".to_string());
}
if name.contains("awakening") {
reasons.push("VM name contains awakening".to_string());
}
let path_text = format!("{} {}", vm.path, vm.configuration_location).to_ascii_lowercase();
if path_text.contains("dune") {
reasons.push("VM path contains dune".to_string());
}
if path_text.contains("awakening") {
reasons.push("VM path contains awakening".to_string());
}
if vm
.switch_names
.iter()
.any(|switch| switch.to_ascii_lowercase().contains("dune"))
{
reasons.push("connected switch name contains dune".to_string());
}
if reasons.is_empty() {
return None;
}
let confidence = if strong {
DuneVmConfidence::High
} else if reasons.len() >= 2 {
DuneVmConfidence::Medium
} else {
DuneVmConfidence::Low
};
Some(DuneVmCandidate {
vm,
confidence,
reasons,
})
}
fn path_file_name_eq(path: &str, expected: &str) -> bool {
path.replace('/', "\\")
.rsplit('\\')
.next()
.is_some_and(|name| name.eq_ignore_ascii_case(expected))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::orchestration::{VmPowerState, VmProvider};
#[test]
fn canonical_dune_vhd_is_high_confidence() {
let candidate = classify_dune_vm(sample_vm(
"renamed",
vec!["D:\\VMs\\Virtual Hard Disks\\dune-server.vhdx"],
vec![],
))
.unwrap();
assert_eq!(candidate.confidence, DuneVmConfidence::High);
assert!(candidate
.reasons
.iter()
.any(|reason| reason.contains(DUNE_SERVER_VHDX_NAME)));
}
#[test]
fn soft_host_hints_are_medium_confidence() {
let candidate = classify_dune_vm(sample_vm(
"dune-test",
vec!["D:\\VMs\\disk.vhdx"],
vec!["DuneAwakeningServerSwitch"],
))
.unwrap();
assert_eq!(candidate.confidence, DuneVmConfidence::Medium);
}
#[test]
fn unrelated_vm_is_not_a_candidate() {
assert!(
classify_dune_vm(sample_vm("linux-test", vec!["D:\\VMs\\disk.vhdx"], vec![])).is_none()
);
}
#[test]
fn detector_filters_inventory() {
struct MockVmProvider;
impl VmProvider for MockVmProvider {
fn list_vms(&self) -> CommandResult<Vec<VmInventoryRecord>> {
Ok(vec![
sample_vm("linux-test", vec!["D:\\VMs\\disk.vhdx"], vec![]),
sample_vm(
"server",
vec!["D:\\VMs\\Virtual Hard Disks\\dune-server.vhdx"],
vec![],
),
])
}
fn get_vm(&self, _name: &str) -> CommandResult<Option<VmInventoryRecord>> {
unreachable!()
}
fn compare_import(
&self,
_request: &crate::orchestration::VmImportRequest,
) -> CommandResult<crate::orchestration::VmCompatibilityReport> {
unreachable!()
}
fn import_vm(
&self,
_request: &crate::orchestration::VmImportRequest,
) -> CommandResult<crate::orchestration::ImportedVm> {
unreachable!()
}
fn remove_vm(&self, _name: &str) -> CommandResult<()> {
unreachable!()
}
fn start_vm(&self, _name: &str) -> CommandResult<()> {
unreachable!()
}
fn stop_vm(&self, _name: &str, _turn_off: bool) -> CommandResult<()> {
unreachable!()
}
fn connect_network_adapter(
&self,
_vm_name: &str,
_switch_name: &str,
) -> CommandResult<()> {
unreachable!()
}
fn ensure_external_switch(
&self,
_request: &crate::orchestration::EnsureSwitchRequest,
) -> CommandResult<crate::orchestration::ExternalSwitch> {
unreachable!()
}
fn resize_first_vhd(&self, _vm_name: &str, _size_bytes: u64) -> CommandResult<()> {
unreachable!()
}
fn set_first_boot_disk(&self, _vm_name: &str) -> CommandResult<()> {
unreachable!()
}
fn set_startup_memory(&self, _vm_name: &str, _bytes: u64) -> CommandResult<()> {
unreachable!()
}
fn set_processor_count(&self, _vm_name: &str, _count: u32) -> CommandResult<()> {
unreachable!()
}
}
let candidates = DuneVmDetector::new(MockVmProvider).detect().unwrap();
assert_eq!(candidates.len(), 1);
assert_eq!(candidates[0].confidence, DuneVmConfidence::High);
}
fn sample_vm(
name: &str,
hard_disk_paths: Vec<&str>,
switch_names: Vec<&str>,
) -> VmInventoryRecord {
VmInventoryRecord {
name: name.to_string(),
state: VmPowerState::Off,
raw_state: "Off".to_string(),
configuration_location: "D:\\VMs".to_string(),
path: "D:\\VMs".to_string(),
memory_assigned_bytes: 0,
processor_count: 0,
uptime_seconds: 0,
ipv4_addresses: vec![],
hard_disk_paths: hard_disk_paths.into_iter().map(str::to_string).collect(),
disk_size_bytes: 0,
disk_file_size_bytes: 0,
switch_names: switch_names.into_iter().map(str::to_string).collect(),
}
}
}

View File

@@ -0,0 +1,12 @@
//! Experimental low-memory swap profile for BattleGroup guests.
mod models;
mod orchestrator;
mod patch;
mod scripts;
pub use models::{
ExperimentalSwapRequest, ExperimentalSwapResult, ExperimentalSwapStatus,
LowMemoryBattlegroupProfileRequest,
};
pub use orchestrator::ExperimentalSwapOrchestrator;

View File

@@ -0,0 +1,104 @@
use serde::{Deserialize, Serialize};
use crate::{errors::failure, models::CommandResult, validation::validate_kube_arg};
/// Request for enabling the vendor experimental low-memory profile.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ExperimentalSwapRequest {
/// Kubernetes namespace containing the BattleGroup.
pub namespace: String,
/// BattleGroup resource name.
pub battlegroup_name: String,
/// Swap file size in GiB.
pub swap_size_gib: u64,
/// Whether k3s should be restarted to apply kubelet swap settings.
pub restart_k3s: bool,
}
impl ExperimentalSwapRequest {
/// Creates a request using the vendor-style 30 GiB swap file and k3s restart.
pub fn new(namespace: impl Into<String>, battlegroup_name: impl Into<String>) -> Self {
Self {
namespace: namespace.into(),
battlegroup_name: battlegroup_name.into(),
swap_size_gib: 30,
restart_k3s: true,
}
}
pub(super) fn validate(&self) -> CommandResult<()> {
validate_kube_arg(&self.namespace, "namespace")?;
validate_kube_arg(&self.battlegroup_name, "battlegroup name")?;
if !(1..=256).contains(&self.swap_size_gib) {
return Err(failure("--swap-size-gib must be between 1 and 256"));
}
Ok(())
}
}
/// Request for applying the low-memory BattleGroup resource profile.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LowMemoryBattlegroupProfileRequest {
/// Kubernetes namespace containing the BattleGroup.
pub namespace: String,
/// BattleGroup resource name.
pub battlegroup_name: String,
/// Swap file size in GiB used to choose the profile strength.
pub swap_size_gib: u64,
}
impl LowMemoryBattlegroupProfileRequest {
/// Creates a low-memory resource profile request.
pub fn new(
namespace: impl Into<String>,
battlegroup_name: impl Into<String>,
swap_size_gib: u64,
) -> Self {
Self {
namespace: namespace.into(),
battlegroup_name: battlegroup_name.into(),
swap_size_gib,
}
}
pub(super) fn validate(&self) -> CommandResult<()> {
validate_kube_arg(&self.namespace, "namespace")?;
validate_kube_arg(&self.battlegroup_name, "battlegroup name")?;
if !(1..=256).contains(&self.swap_size_gib) {
return Err(failure("swap size must be between 1 and 256 GiB"));
}
Ok(())
}
}
/// Snapshot of the guest experimental swap state.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ExperimentalSwapStatus {
/// Whether `/swapfile` exists.
pub swap_file_exists: bool,
/// Whether `/swapfile` is currently active.
pub swap_active: bool,
/// Configured `/swapfile` size in bytes, when known.
pub swap_file_bytes: Option<u64>,
/// Active swap size in bytes, when active and reported by the kernel.
pub active_swap_bytes: Option<u64>,
/// Whether `/etc/fstab` contains a `/swapfile` entry.
pub fstab_configured: bool,
/// Whether OpenRC has the swap service enabled.
pub openrc_swap_enabled: bool,
/// Whether the k3s kubelet config enables `failSwapOn: false`.
pub kubelet_swap_configured: bool,
/// Whether the BattleGroup memory profile already matches this experimental profile.
pub battlegroup_profile_applied: Option<bool>,
}
/// Result of applying the experimental swap profile.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ExperimentalSwapResult {
/// Status after applying the profile.
pub status: ExperimentalSwapStatus,
/// Number of JSON Patch operations applied to the BattleGroup resource.
pub battlegroup_patch_operations: usize,
}

View File

@@ -0,0 +1,230 @@
use serde_json::Value;
use crate::{
errors::failure,
models::CommandResult,
orchestration::{
OperationSink, OrchestrationEvent, ProviderKind, RemoteCommandRunner, StepAction,
StepDomain,
},
validation::validate_kube_arg,
};
use super::models::{
ExperimentalSwapRequest, ExperimentalSwapResult, ExperimentalSwapStatus,
LowMemoryBattlegroupProfileRequest,
};
use super::patch::{
experimental_swap_patch_operations, experimental_swap_patch_operations_for_swap,
};
use super::scripts::{enable_swap_script, EXPERIMENTAL_SWAP_STATUS_SCRIPT};
/// Enables the guest swap file and patches BattleGroup memory requests/limits.
pub struct ExperimentalSwapOrchestrator<R> {
runner: R,
}
impl<R> ExperimentalSwapOrchestrator<R>
where
R: RemoteCommandRunner,
{
/// Creates an experimental swap orchestrator around a remote guest runner.
pub fn new(runner: R) -> Self {
Self { runner }
}
/// Reads guest swap state and, optionally, BattleGroup memory profile state.
pub fn status(
&self,
battlegroup: Option<(&str, &str)>,
) -> CommandResult<ExperimentalSwapStatus> {
let mut status: ExperimentalSwapStatus = serde_json::from_value(
self.runner
.run_json(EXPERIMENTAL_SWAP_STATUS_SCRIPT, "experimental swap status")?,
)
.map_err(|err| failure(format!("Failed to parse experimental swap status: {err}")))?;
if let Some((namespace, battlegroup_name)) = battlegroup {
validate_kube_arg(namespace, "namespace")?;
validate_kube_arg(battlegroup_name, "battlegroup name")?;
let value = self.battlegroup(namespace, battlegroup_name)?;
status.battlegroup_profile_applied =
Some(experimental_swap_patch_operations(&value)?.is_empty());
}
Ok(status)
}
/// Enables swap and applies the experimental low-memory BattleGroup profile.
pub fn enable(
&self,
request: &ExperimentalSwapRequest,
sink: &mut impl OperationSink,
) -> CommandResult<ExperimentalSwapResult> {
request.validate()?;
emit(
sink,
"guest-swap.enable",
"Enabling guest experimental swap.",
StepDomain::Guest,
StepAction::Configure,
);
self.runner.run_script(&enable_swap_script(
request.swap_size_gib,
request.restart_k3s,
))?;
let operation_count = self.apply_battlegroup_memory_profile(
&LowMemoryBattlegroupProfileRequest::new(
&request.namespace,
&request.battlegroup_name,
request.swap_size_gib,
),
sink,
)?;
emit(
sink,
"guest-swap.status",
"Verifying experimental swap status.",
StepDomain::Guest,
StepAction::Check,
);
let status = self.status(Some((&request.namespace, &request.battlegroup_name)))?;
Ok(ExperimentalSwapResult {
status,
battlegroup_patch_operations: operation_count,
})
}
/// Applies only the BattleGroup memory profile, without touching swap or k3s.
pub fn apply_battlegroup_memory_profile(
&self,
request: &LowMemoryBattlegroupProfileRequest,
sink: &mut impl OperationSink,
) -> CommandResult<usize> {
request.validate()?;
emit(
sink,
"bg-swap.patch-memory",
"Applying low-memory BattleGroup memory profile.",
StepDomain::Kubernetes,
StepAction::Patch,
);
let battlegroup = self.battlegroup(&request.namespace, &request.battlegroup_name)?;
let operations =
experimental_swap_patch_operations_for_swap(&battlegroup, request.swap_size_gib)?;
let operation_count = operations.len();
if !operations.is_empty() {
let patch = serde_json::to_string(&operations).map_err(|err| {
failure(format!(
"Failed to serialize experimental swap patch: {err}"
))
})?;
let command = format!(
"sudo kubectl patch battlegroup {} -n {} --type=json -p {} -o json",
sh_single_quoted(&request.battlegroup_name),
sh_single_quoted(&request.namespace),
sh_single_quoted(&patch),
);
self.runner
.run_json(&command, "experimental swap battlegroup patch")?;
}
Ok(operation_count)
}
fn battlegroup(&self, namespace: &str, battlegroup_name: &str) -> CommandResult<Value> {
let command = format!(
"sudo kubectl get battlegroup {} -n {} -o json",
sh_single_quoted(battlegroup_name),
sh_single_quoted(namespace),
);
self.runner
.run_json(&command, "experimental swap battlegroup")
}
}
fn emit(
sink: &mut impl OperationSink,
step_id: &'static str,
message: &str,
domain: StepDomain,
action: StepAction,
) {
sink.emit(OrchestrationEvent {
step_id,
message: message.to_string(),
domain,
action,
provider: ProviderKind::Ssh,
});
}
fn sh_single_quoted(value: &str) -> String {
format!("'{}'", value.replace('\'', "'\"'\"'"))
}
#[cfg(test)]
mod tests {
use std::{cell::RefCell, collections::VecDeque, rc::Rc};
use super::*;
#[derive(Clone, Default)]
struct MockRemote {
outputs: Rc<RefCell<VecDeque<String>>>,
scripts: Rc<RefCell<Vec<String>>>,
}
impl MockRemote {
fn with_outputs(outputs: impl IntoIterator<Item = impl Into<String>>) -> Self {
Self {
outputs: Rc::new(RefCell::new(outputs.into_iter().map(Into::into).collect())),
scripts: Rc::new(RefCell::new(Vec::new())),
}
}
}
impl RemoteCommandRunner for MockRemote {
fn run(&self, command: &str) -> CommandResult<String> {
self.run_script(command)
}
fn run_script(&self, script: &str) -> CommandResult<String> {
self.scripts.borrow_mut().push(script.to_string());
Ok(self.outputs.borrow_mut().pop_front().unwrap_or_default())
}
}
#[test]
fn enable_runs_swap_script_and_battlegroup_patch() {
let remote = MockRemote::with_outputs([
"",
r#"{"metadata":{"name":"bg"},"spec":{"serverGroup":{"template":{"spec":{"sets":[{"map":"DeepDesert_1","resources":{"limits":{"memory":"15Gi"},"requests":{"memory":"15Gi"}}}]}}}}}"#,
r#"{"metadata":{"name":"bg"}}"#,
r#"{"swapFileExists":true,"swapActive":true,"swapFileBytes":32212254720,"activeSwapBytes":32212254720,"fstabConfigured":true,"openrcSwapEnabled":true,"kubeletSwapConfigured":true,"battlegroupProfileApplied":null}"#,
r#"{"metadata":{"name":"bg"},"spec":{"serverGroup":{"template":{"spec":{"sets":[{"map":"DeepDesert_1","resources":{"limits":{"memory":"10Gi"},"requests":{"memory":"3Gi"}}}]}}}}}"#,
]);
let scripts = remote.scripts.clone();
let mut sink = crate::orchestration::VecOperationSink::default();
let result = ExperimentalSwapOrchestrator::new(remote)
.enable(
&ExperimentalSwapRequest::new("funcom-seabass-sh-host-abcdef", "bg"),
&mut sink,
)
.unwrap();
assert_eq!(result.battlegroup_patch_operations, 2);
let scripts = scripts.borrow().join("\n");
assert!(scripts.contains("dd if=/dev/zero of=/swapfile"));
assert!(scripts.contains("kubectl patch battlegroup"));
assert!(sink
.events
.iter()
.any(|event| event.step_id == "bg-swap.patch-memory"));
}
}

View File

@@ -0,0 +1,268 @@
use serde_json::{json, Value};
use crate::{errors::failure, models::CommandResult};
pub(super) fn experimental_swap_patch_operations(value: &Value) -> CommandResult<Vec<Value>> {
experimental_swap_patch_operations_for_swap(value, 30)
}
pub(super) fn experimental_swap_patch_operations_for_swap(
value: &Value,
swap_size_gib: u64,
) -> CommandResult<Vec<Value>> {
let sets_path = ["spec", "serverGroup", "template", "spec", "sets"];
let sets = value
.pointer("/spec/serverGroup/template/spec/sets")
.and_then(Value::as_array)
.ok_or_else(|| {
failure("BattleGroup did not contain spec.serverGroup.template.spec.sets")
})?;
let mut operations = Vec::new();
for (index, set) in sets.iter().enumerate() {
let map = set["map"].as_str().unwrap_or_default();
let profile = memory_profile_for_map(map, swap_size_gib);
let mut base = sets_path
.iter()
.map(|part| (*part).to_string())
.collect::<Vec<_>>();
base.push(index.to_string());
let resources = set.get("resources");
if resources.is_none() || !resources.is_some_and(Value::is_object) {
let mut path = base.clone();
path.push("resources".to_string());
operations.push(add_operation(
&path,
json!({
"limits": { "memory": profile.limit },
"requests": { "memory": profile.request },
}),
));
continue;
}
ensure_memory_value(
set,
&base,
"limits",
profile.limit.as_str(),
&mut operations,
);
ensure_memory_value(
set,
&base,
"requests",
profile.request.as_str(),
&mut operations,
);
}
Ok(operations)
}
fn ensure_memory_value(
set: &Value,
base_path: &[String],
resource_kind: &str,
desired: &str,
operations: &mut Vec<Value>,
) {
let resource = set
.get("resources")
.and_then(|resources| resources.get(resource_kind));
if resource.is_none() || !resource.is_some_and(Value::is_object) {
let mut path = base_path.to_owned();
path.push("resources".to_string());
path.push(resource_kind.to_string());
operations.push(add_operation(&path, json!({ "memory": desired })));
return;
}
let current = resource
.and_then(|value| value.get("memory"))
.and_then(Value::as_str);
if current == Some(desired) {
return;
}
let op = if current.is_some() { "replace" } else { "add" };
let mut path = base_path.to_owned();
path.push("resources".to_string());
path.push(resource_kind.to_string());
path.push("memory".to_string());
operations.push(json!({
"op": op,
"path": json_pointer(&path),
"value": desired,
}));
}
#[derive(Debug, Clone)]
struct MemoryProfile {
limit: String,
request: String,
}
fn memory_profile_for_map(map: &str, swap_size_gib: u64) -> MemoryProfile {
match map {
"Survival_1" => MemoryProfile {
limit: scaled_gi_profile(20, 12, swap_size_gib),
request: scaled_gi_profile(20, 5, swap_size_gib),
},
"DeepDesert_1" => MemoryProfile {
limit: "10Gi".to_string(),
request: scaled_gi_profile(10, 3, swap_size_gib),
},
_ => MemoryProfile {
limit: "1Gi".to_string(),
request: "200Mi".to_string(),
},
}
}
fn scaled_gi_profile(no_swap_gib: u64, vendor_swap_gib: u64, swap_size_gib: u64) -> String {
const VENDOR_SWAP_GIB: u64 = 30;
let swap = swap_size_gib.min(VENDOR_SWAP_GIB);
let delta = no_swap_gib.saturating_sub(vendor_swap_gib);
let reduction = (delta * swap).div_ceil(VENDOR_SWAP_GIB);
let value = no_swap_gib.saturating_sub(reduction).max(vendor_swap_gib);
format!("{value}Gi")
}
fn add_operation(path: &[String], value: Value) -> Value {
json!({
"op": "add",
"path": json_pointer(path),
"value": value,
})
}
fn json_pointer(path: &[String]) -> String {
format!(
"/{}",
path.iter()
.map(|item| item.replace('~', "~0").replace('/', "~1"))
.collect::<Vec<_>>()
.join("/")
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn patch_sets_experimental_memory_without_jq() {
let battlegroup = json!({
"spec": {
"serverGroup": {
"template": {
"spec": {
"sets": [
{
"map": "Survival_1",
"resources": {
"limits": { "memory": "12Gi" },
"requests": { "memory": "12Gi" }
}
},
{
"map": "DeepDesert_1",
"resources": {
"limits": { "memory": "15Gi" },
"requests": { "memory": "15Gi" }
}
},
{
"map": "Overmap",
"resources": {
"limits": { "memory": "2Gi" }
}
}
]
}
}
}
}
});
let operations = experimental_swap_patch_operations(&battlegroup).unwrap();
let text = serde_json::to_string(&operations).unwrap();
assert!(text.contains("/spec/serverGroup/template/spec/sets/0/resources/requests/memory"));
assert!(text.contains("/spec/serverGroup/template/spec/sets/1/resources/limits/memory"));
assert!(text.contains("/spec/serverGroup/template/spec/sets/2/resources/requests"));
assert!(text.contains("3Gi"));
assert!(text.contains("200Mi"));
assert!(!text.contains("jq"));
}
#[test]
fn smaller_swap_uses_softer_memory_profile() {
let battlegroup = json!({
"spec": {
"serverGroup": {
"template": {
"spec": {
"sets": [
{
"map": "Survival_1",
"resources": {
"limits": { "memory": "20Gi" },
"requests": { "memory": "20Gi" }
}
},
{
"map": "DeepDesert_1",
"resources": {
"limits": { "memory": "10Gi" },
"requests": { "memory": "10Gi" }
}
}
]
}
}
}
}
});
let operations = experimental_swap_patch_operations_for_swap(&battlegroup, 10).unwrap();
let text = serde_json::to_string(&operations).unwrap();
assert!(text.contains("\"17Gi\""));
assert!(text.contains("\"15Gi\""));
assert!(text.contains("\"7Gi\""));
assert!(!text.contains("\"12Gi\""));
assert!(!text.contains("\"5Gi\""));
}
#[test]
fn matching_profile_needs_no_patch() {
let battlegroup = json!({
"spec": {
"serverGroup": {
"template": {
"spec": {
"sets": [
{
"map": "Survival_1",
"resources": {
"limits": { "memory": "12Gi" },
"requests": { "memory": "5Gi" }
}
},
{
"map": "DeepDesert_1",
"resources": {
"limits": { "memory": "10Gi" },
"requests": { "memory": "3Gi" }
}
}
]
}
}
}
}
});
assert!(experimental_swap_patch_operations(&battlegroup)
.unwrap()
.is_empty());
}
}

View File

@@ -0,0 +1,83 @@
pub(super) const EXPERIMENTAL_SWAP_STATUS_SCRIPT: &str = r#"
set -euo pipefail
swap_file_exists=false
swap_active=false
swap_file_bytes=null
active_swap_bytes=null
fstab_configured=false
openrc_swap_enabled=false
kubelet_swap_configured=false
if [ -f /swapfile ]; then
swap_file_exists=true
swap_file_bytes=$(stat -c '%s' /swapfile 2>/dev/null || echo null)
fi
if awk '$1 == "/swapfile" { found = 1 } END { exit(found ? 0 : 1) }' /proc/swaps 2>/dev/null; then
swap_active=true
active_swap_bytes=$(awk '$1 == "/swapfile" { print $3 * 1024 }' /proc/swaps | head -n1)
fi
if grep -Eq '^[[:space:]]*/swapfile[[:space:]]' /etc/fstab 2>/dev/null; then
fstab_configured=true
fi
if rc-update show 2>/dev/null | grep -Eq '^[[:space:]]*swap[[:space:]].*boot'; then
openrc_swap_enabled=true
fi
if grep -REq '^[[:space:]]*failSwapOn:[[:space:]]*false[[:space:]]*$' /etc/rancher/k3s/kubelet.config /etc/rancher/k3s/kubelet-config.yaml 2>/dev/null \
&& { grep -Eq 'config=/etc/rancher/k3s/kubelet-config.yaml' /etc/rancher/k3s/config.yaml 2>/dev/null \
|| grep -REq 'config=/etc/rancher/k3s/kubelet.config|config=/etc/rancher/k3s/kubelet-config.yaml' /etc/rancher/k3s/config.yaml.d 2>/dev/null; }; then
kubelet_swap_configured=true
fi
printf '{"swapFileExists":%s,"swapActive":%s,"swapFileBytes":%s,"activeSwapBytes":%s,"fstabConfigured":%s,"openrcSwapEnabled":%s,"kubeletSwapConfigured":%s,"battlegroupProfileApplied":null}\n' \
"$swap_file_exists" "$swap_active" "$swap_file_bytes" "$active_swap_bytes" "$fstab_configured" "$openrc_swap_enabled" "$kubelet_swap_configured"
"#;
pub(super) fn enable_swap_script(swap_size_gib: u64, restart_k3s: bool) -> String {
let restart = if restart_k3s { "true" } else { "false" };
format!(
r#"
set -euo pipefail
swap_size_gib={swap_size_gib}
restart_k3s={restart}
swap_bytes=$((swap_size_gib * 1024 * 1024 * 1024))
if [ ! -f /swapfile ] || [ "$(stat -c '%s' /swapfile 2>/dev/null || echo 0)" -lt "$swap_bytes" ]; then
sudo swapoff /swapfile >/dev/null 2>&1 || true
sudo rm -f /swapfile
sudo dd if=/dev/zero of=/swapfile bs=1M count=$((swap_size_gib * 1024)) status=none
sudo chmod 600 /swapfile
sudo mkswap /swapfile >/dev/null
fi
if ! grep -Eq '^[[:space:]]*/swapfile[[:space:]]' /etc/fstab 2>/dev/null; then
printf '/swapfile none swap sw 0 0\n' | sudo tee -a /etc/fstab >/dev/null
fi
sudo swapon /swapfile >/dev/null 2>&1 || true
sudo rc-update add swap boot >/dev/null 2>&1 || true
sudo mkdir -p /etc/rancher/k3s
if [ ! -f /etc/rancher/k3s/kubelet-config.yaml ] || ! grep -Eq '^[[:space:]]*failSwapOn:[[:space:]]*false[[:space:]]*$' /etc/rancher/k3s/kubelet-config.yaml; then
printf 'failSwapOn: false\nmemorySwap:\n swapBehavior: LimitedSwap\n' | sudo tee /etc/rancher/k3s/kubelet-config.yaml >/dev/null
fi
if ! grep -Eq 'config=/etc/rancher/k3s/kubelet-config.yaml' /etc/rancher/k3s/config.yaml /etc/rancher/k3s/config.yaml.d/*.yaml 2>/dev/null; then
sudo mkdir -p /etc/rancher/k3s/config.yaml.d
printf 'kubelet-arg+:\n- config=/etc/rancher/k3s/kubelet-config.yaml\n' | sudo tee /etc/rancher/k3s/config.yaml.d/99-dune-manager-swap.yaml >/dev/null
fi
if [ "$restart_k3s" = "true" ]; then
sudo rc-service k3s stop >/dev/null 2>&1 || true
if [ -x /usr/local/bin/k3s-killall.sh ]; then
sudo /usr/local/bin/k3s-killall.sh >/dev/null 2>&1 || true
fi
sudo rc-service k3s start >/dev/null
elapsed=0
until sudo kubectl get nodes >/dev/null 2>&1; do
sleep 2
elapsed=$((elapsed + 2))
if [ "$elapsed" -ge 180 ]; then echo "k3s API did not return after enabling swap" >&2; exit 1; fi
done
sudo kubectl wait --for=condition=Ready node --all --timeout=180s >/dev/null || true
fi
"#
)
}

View File

@@ -0,0 +1,10 @@
//! Guest bootstrap planning, validation, and orchestration of the guest setup sequence.
mod orchestrator;
mod plan;
pub use orchestrator::{GuestBootstrapOrchestrator, GuestBootstrapResult};
pub use plan::{
host_id_from_self_host_token, random_lowercase_suffix, validate_host_id, validate_region,
validate_world_name, validate_world_suffix, GuestBootstrapPlan,
};

View File

@@ -0,0 +1,211 @@
//! Guest bootstrap orchestrator that drives the provider through the setup sequence.
use serde::Serialize;
use crate::{
models::CommandResult,
orchestration::{
GuestBootstrapProvider, OperationSink, OrchestrationEvent, ProviderKind, StepAction,
StepDomain, WorldManifestRequest,
},
};
use super::plan::GuestBootstrapPlan;
/// Identifies the world resources created by guest bootstrap.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct GuestBootstrapResult {
/// Kubernetes namespace created for the BattleGroup.
pub namespace: String,
/// BattleGroup resource name.
pub battlegroup_name: String,
/// Vendor unique world name used for namespace and resource creation.
pub world_unique_name: String,
}
/// Runs the native replacement for the vendor guest setup script.
pub struct GuestBootstrapOrchestrator<P> {
provider: P,
}
impl<P> GuestBootstrapOrchestrator<P>
where
P: GuestBootstrapProvider,
{
/// Creates a guest bootstrap orchestrator around a provider.
pub fn new(provider: P) -> Self {
Self { provider }
}
/// Executes disk, payload, k3s, operator, world, image, and defaults setup.
pub fn run(
&self,
plan: &GuestBootstrapPlan,
sink: &mut impl OperationSink,
) -> CommandResult<GuestBootstrapResult> {
plan.validate()?;
emit(
sink,
"guest-settings",
"Writing player-facing server address.",
StepDomain::Guest,
StepAction::Configure,
);
// The existing guest provider split still owns the actual settings file write.
// This bootstrap provider starts at the vendor bootstrap/setup boundary.
emit(
sink,
"guest-disk",
"Checking guest disk capacity.",
StepDomain::Guest,
StepAction::Configure,
);
self.provider.validate_and_resize_root_disk()?;
emit(
sink,
"guest-download",
"Ensuring guest server payload is installed.",
StepDomain::Steam,
StepAction::Download,
);
self.provider.ensure_server_payload()?;
emit(
sink,
"guest-k3s.start",
"Starting k3s.",
StepDomain::Guest,
StepAction::Start,
);
self.provider.start_k3s_and_wait()?;
emit(
sink,
"guest-k3s.import-core-images",
"Importing k3s prerequisite images.",
StepDomain::Guest,
StepAction::Import,
);
self.provider.import_core_images()?;
emit(
sink,
"guest-k3s.scale-core",
"Starting k3s core deployments.",
StepDomain::Kubernetes,
StepAction::Configure,
);
self.provider.scale_core_deployments()?;
emit(
sink,
"guest-operators.update-crds",
"Updating operator resources.",
StepDomain::Kubernetes,
StepAction::Configure,
);
self.provider.update_operator_crds()?;
emit(
sink,
"guest-operators.patch-images",
"Updating operator images.",
StepDomain::Kubernetes,
StepAction::Patch,
);
self.provider.patch_operator_images()?;
emit(
sink,
"guest-operators.scale",
"Starting operator deployments.",
StepDomain::Kubernetes,
StepAction::Configure,
);
self.provider.scale_operator_deployments()?;
emit(
sink,
"guest-system.install-helper",
"Installing guest battlegroup helper.",
StepDomain::Guest,
StepAction::Configure,
);
self.provider.install_battlegroup_helper()?;
let world_unique_name = plan.world_unique_name();
emit(
sink,
"guest-world.create",
"Creating battlegroup world resources.",
StepDomain::Kubernetes,
StepAction::Create,
);
let world = self.provider.create_world(&WorldManifestRequest {
world_name: plan.world_name.clone(),
world_region: plan.world_region.clone(),
player_ip: plan.player_ip.clone(),
world_unique_name: world_unique_name.clone(),
self_host_token: plan.self_host_token.clone(),
})?;
emit(
sink,
"guest-images.import",
"Importing battlegroup images.",
StepDomain::Guest,
StepAction::Import,
);
self.provider.import_battlegroup_images()?;
emit(
sink,
"guest-images.patch",
"Patching battlegroup image revisions.",
StepDomain::Kubernetes,
StepAction::Patch,
);
self.provider
.patch_battlegroup_images(&world.namespace, &world.battlegroup_name)?;
emit(
sink,
"guest-defaults.apply",
"Applying default user settings.",
StepDomain::Kubernetes,
StepAction::Configure,
);
self.provider
.apply_default_user_settings(&world.namespace, &world.battlegroup_name)?;
Ok(GuestBootstrapResult {
namespace: world.namespace,
battlegroup_name: world.battlegroup_name,
world_unique_name,
})
}
}
fn emit(
sink: &mut impl OperationSink,
step_id: &'static str,
message: impl Into<String>,
domain: StepDomain,
action: StepAction,
) {
sink.emit(OrchestrationEvent {
step_id,
message: message.into(),
domain,
action,
provider: ProviderKind::HyperV,
});
}
#[cfg(test)]
#[path = "orchestrator_tests.rs"]
mod tests;

View File

@@ -0,0 +1,135 @@
use std::{cell::RefCell, rc::Rc};
use crate::orchestration::{CreatedWorld, VecOperationSink, WorldManifestRequest};
use super::*;
#[derive(Default)]
struct MockGuestBootstrap {
calls: Rc<RefCell<Vec<&'static str>>>,
}
impl GuestBootstrapProvider for MockGuestBootstrap {
fn validate_and_resize_root_disk(&self) -> CommandResult<()> {
self.calls.borrow_mut().push("disk");
Ok(())
}
fn ensure_server_payload(&self) -> CommandResult<()> {
self.calls.borrow_mut().push("payload");
Ok(())
}
fn start_k3s_and_wait(&self) -> CommandResult<()> {
self.calls.borrow_mut().push("k3s");
Ok(())
}
fn import_core_images(&self) -> CommandResult<()> {
self.calls.borrow_mut().push("core_images");
Ok(())
}
fn scale_core_deployments(&self) -> CommandResult<()> {
self.calls.borrow_mut().push("core_scale");
Ok(())
}
fn update_operator_crds(&self) -> CommandResult<()> {
self.calls.borrow_mut().push("operator_crds");
Ok(())
}
fn patch_operator_images(&self) -> CommandResult<()> {
self.calls.borrow_mut().push("operator_images");
Ok(())
}
fn scale_operator_deployments(&self) -> CommandResult<()> {
self.calls.borrow_mut().push("operator_scale");
Ok(())
}
fn install_battlegroup_helper(&self) -> CommandResult<()> {
self.calls.borrow_mut().push("helper");
Ok(())
}
fn create_world(&self, request: &WorldManifestRequest) -> CommandResult<CreatedWorld> {
self.calls.borrow_mut().push("world");
Ok(CreatedWorld {
namespace: format!("funcom-seabass-{}", request.world_unique_name),
battlegroup_name: request.world_unique_name.clone(),
})
}
fn import_battlegroup_images(&self) -> CommandResult<()> {
self.calls.borrow_mut().push("bg_images");
Ok(())
}
fn patch_battlegroup_images(
&self,
_namespace: &str,
_battlegroup_name: &str,
) -> CommandResult<()> {
self.calls.borrow_mut().push("bg_patch");
Ok(())
}
fn apply_default_user_settings(
&self,
_namespace: &str,
_battlegroup_name: &str,
) -> CommandResult<()> {
self.calls.borrow_mut().push("defaults");
Ok(())
}
}
#[test]
fn orchestrates_guest_bootstrap_sequence() {
let calls = Rc::new(RefCell::new(Vec::new()));
let provider = MockGuestBootstrap {
calls: calls.clone(),
};
let orchestrator = GuestBootstrapOrchestrator::new(provider);
let mut sink = VecOperationSink::default();
let result = orchestrator
.run(
&GuestBootstrapPlan {
player_ip: "10.0.0.4".to_string(),
world_name: "Adain".to_string(),
world_region: "Europe".to_string(),
self_host_token: "token".to_string(),
host_id: "abc123".to_string(),
world_suffix: "abcdef".to_string(),
},
&mut sink,
)
.unwrap();
assert_eq!(result.namespace, "funcom-seabass-sh-abc123-abcdef");
assert_eq!(
calls.borrow().as_slice(),
&[
"disk",
"payload",
"k3s",
"core_images",
"core_scale",
"operator_crds",
"operator_images",
"operator_scale",
"helper",
"world",
"bg_images",
"bg_patch",
"defaults",
]
);
assert!(sink
.events
.iter()
.any(|event| event.step_id == "guest-world.create"));
}

View File

@@ -0,0 +1,220 @@
//! Guest bootstrap plan, validators, and self-host token helpers.
use serde_json::Value;
use std::time::{SystemTime, UNIX_EPOCH};
use crate::{errors::failure, models::CommandResult};
/// User-selected and token-derived inputs for guest-side bootstrap.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GuestBootstrapPlan {
/// Player-facing address written into guest settings.
pub player_ip: String,
/// Human-readable world name.
pub world_name: String,
/// Vendor region label for the world.
pub world_region: String,
/// Self-host JWT used to create the world. Treat as secret.
pub self_host_token: String,
/// Lowercase host identifier decoded from the self-host token.
pub host_id: String,
/// Six-letter lowercase suffix used in the unique world resource name.
pub world_suffix: String,
}
impl GuestBootstrapPlan {
/// Builds a bootstrap plan from a self-host token and generated world suffix.
pub fn from_self_host_token(
player_ip: impl Into<String>,
world_name: impl Into<String>,
world_region: impl Into<String>,
self_host_token: impl Into<String>,
) -> CommandResult<Self> {
let token = self_host_token.into();
Ok(Self {
player_ip: player_ip.into(),
world_name: world_name.into(),
world_region: world_region.into(),
host_id: host_id_from_self_host_token(&token)?,
world_suffix: random_lowercase_suffix(),
self_host_token: token,
})
}
/// Returns the Kubernetes-safe vendor world identifier.
pub fn world_unique_name(&self) -> String {
format!("sh-{}-{}", self.host_id, self.world_suffix)
}
/// Validates addresses, world naming, region choice, and token presence.
pub fn validate(&self) -> CommandResult<()> {
validate_ipv4ish(&self.player_ip, "player-facing IP")?;
validate_world_name(&self.world_name)?;
validate_region(&self.world_region)?;
validate_host_id(&self.host_id)?;
validate_world_suffix(&self.world_suffix)?;
if self.self_host_token.trim().is_empty()
|| self.self_host_token.contains('\n')
|| self.self_host_token.contains('\r')
{
return Err(failure("Self-host token is required"));
}
Ok(())
}
}
/// Validates a vendor-supported world region label.
pub fn validate_region(value: &str) -> CommandResult<()> {
match value {
"Asia" | "Europe" | "North America" | "Oceania" | "South America" => Ok(()),
_ => Err(failure(
"Region must be Asia, Europe, North America, Oceania, or South America",
)),
}
}
/// Validates the six-letter suffix used in generated world names.
pub fn validate_world_suffix(value: &str) -> CommandResult<()> {
if value.len() == 6 && value.bytes().all(|byte| byte.is_ascii_lowercase()) {
Ok(())
} else {
Err(failure(
"World suffix must be exactly six lowercase letters",
))
}
}
/// Extracts the lowercase host id from a self-host JWT payload.
pub fn host_id_from_self_host_token(token: &str) -> CommandResult<String> {
let payload = token
.split('.')
.nth(1)
.ok_or_else(|| failure("Self-host token must be a JWT-like token"))?;
let decoded = base64url_decode(payload)?;
let value: Value = serde_json::from_slice(&decoded)
.map_err(|err| failure(format!("Self-host token payload was not JSON: {err}")))?;
let host_id = value["HostId"]
.as_str()
.ok_or_else(|| failure("Self-host token did not contain HostId"))?
.to_ascii_lowercase();
validate_host_id(&host_id)?;
Ok(host_id)
}
/// Generates a six-letter lowercase suffix for a world identifier.
pub fn random_lowercase_suffix() -> String {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|duration| duration.as_nanos() as u64)
.unwrap_or(0);
let mut state = nanos ^ ((std::process::id() as u64) << 32) ^ 0xa5a5_5a5a_d3c7_b901;
let mut suffix = String::with_capacity(6);
for _ in 0..6 {
state ^= state << 13;
state ^= state >> 7;
state ^= state << 17;
suffix.push((b'a' + (state % 26) as u8) as char);
}
suffix
}
fn base64url_decode(value: &str) -> CommandResult<Vec<u8>> {
let mut bits = 0u32;
let mut bit_count = 0u8;
let mut decoded = Vec::new();
for byte in value.bytes() {
if byte == b'=' {
break;
}
let next = match byte {
b'A'..=b'Z' => byte - b'A',
b'a'..=b'z' => byte - b'a' + 26,
b'0'..=b'9' => byte - b'0' + 52,
b'-' => 62,
b'_' => 63,
_ => return Err(failure("Self-host token payload is not base64url")),
};
bits = (bits << 6) | u32::from(next);
bit_count += 6;
while bit_count >= 8 {
bit_count -= 8;
decoded.push(((bits >> bit_count) & 0xff) as u8);
}
}
Ok(decoded)
}
/// Validates a decoded host id for use in Kubernetes resource names.
pub fn validate_host_id(value: &str) -> CommandResult<()> {
if !value.is_empty()
&& value
.bytes()
.all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit())
{
Ok(())
} else {
Err(failure(
"HostId must contain only lowercase letters and digits",
))
}
}
/// Validates a user-facing world name.
pub fn validate_world_name(value: &str) -> CommandResult<()> {
let trimmed = value.trim();
if trimmed.is_empty()
|| trimmed.chars().count() > 50
|| trimmed.contains('\n')
|| trimmed.contains('\r')
{
Err(failure(
"World name must be 1-50 characters and single-line",
))
} else {
Ok(())
}
}
pub(super) fn validate_ipv4ish(value: &str, label: &str) -> CommandResult<()> {
let parts = value.split('.').collect::<Vec<_>>();
if parts.len() == 4 && parts.iter().all(|part| part.parse::<u8>().is_ok()) {
Ok(())
} else {
Err(failure(format!("{label} must be an IPv4 address")))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rejects_non_vendor_suffix_shape() {
assert!(validate_world_suffix("52d16d").is_err());
assert!(validate_world_suffix("abcdef").is_ok());
}
#[test]
fn builds_plan_from_self_host_token_host_id() {
let plan = GuestBootstrapPlan::from_self_host_token(
"10.0.0.4",
"Adain",
"Europe",
"e30.eyJIb3N0SWQiOiJBQkMxMjMifQ.sig",
)
.unwrap();
assert_eq!(plan.host_id, "abc123");
assert_eq!(plan.world_suffix.len(), 6);
assert!(plan
.world_suffix
.bytes()
.all(|byte| byte.is_ascii_lowercase()));
assert!(plan.world_unique_name().starts_with("sh-abc123-"));
}
#[test]
fn rejects_token_without_host_id() {
assert!(host_id_from_self_host_token("e30.e30.sig").is_err());
}
}

View File

@@ -0,0 +1,80 @@
use serde_json::{json, Value};
use crate::{errors::failure, models::CommandResult};
pub(super) fn battlegroup_image_patch_operations(
value: &Value,
new_version: &str,
) -> CommandResult<Vec<Value>> {
let mut operations = Vec::new();
collect_battlegroup_image_patch_operations(
value,
&mut Vec::new(),
new_version,
&mut operations,
);
if operations.is_empty() {
return Err(failure("No battlegroup server images were found to patch"));
}
Ok(operations)
}
fn collect_battlegroup_image_patch_operations(
value: &Value,
path: &mut Vec<String>,
new_version: &str,
operations: &mut Vec<Value>,
) {
match value {
Value::Object(map) => {
for (key, child) in map {
path.push(key.clone());
if key == "image" {
if let Some(updated) = child
.as_str()
.and_then(|image| revised_seabass_server_image(image, new_version))
{
operations.push(replace_operation(path, json!(updated)));
}
}
collect_battlegroup_image_patch_operations(child, path, new_version, operations);
path.pop();
}
}
Value::Array(items) => {
for (index, child) in items.iter().enumerate() {
path.push(index.to_string());
collect_battlegroup_image_patch_operations(child, path, new_version, operations);
path.pop();
}
}
_ => {}
}
}
fn revised_seabass_server_image(image: &str, new_version: &str) -> Option<String> {
let file = image.rsplit('/').next().unwrap_or(image);
if !file.starts_with("seabass-server") {
return None;
}
let (prefix, _) = image.rsplit_once(':')?;
Some(format!("{prefix}:{new_version}"))
}
fn replace_operation(path: &[String], value: Value) -> Value {
json!({
"op": "replace",
"path": json_pointer(path),
"value": value,
})
}
fn json_pointer(path: &[String]) -> String {
format!(
"/{}",
path.iter()
.map(|item| item.replace('~', "~0").replace('/', "~1"))
.collect::<Vec<_>>()
.join("/")
)
}

View File

@@ -0,0 +1,21 @@
//! SSH-backed guest bootstrap provider.
//!
//! Splits the single-file implementation into focused submodules:
//! - [`provider`] hosts the [`SshGuestBootstrapProvider`] struct and the
//! [`GuestBootstrapProvider`] trait implementation.
//! - [`world_creation`] handles world manifest validation and the
//! `create_world` script construction.
//! - [`image_patching`] owns battlegroup image patch operations and the
//! JSON-patch helpers used to revise seabass server images.
//! - [`scripts`] contains the embedded shell-script constants and small
//! shell-quoting helpers shared between the other submodules.
//!
//! [`GuestBootstrapProvider`]: crate::orchestration::GuestBootstrapProvider
mod image_patching;
mod provider;
mod scripts;
mod scripts_kubernetes;
mod world_creation;
pub use provider::SshGuestBootstrapProvider;

View File

@@ -0,0 +1,205 @@
use crate::{
errors::failure,
models::CommandResult,
orchestration::{
parse_single_json_document, CreatedWorld, GuestBootstrapProvider, RemoteCommandRunner,
WorldManifestRequest,
},
validation::validate_kube_arg,
};
use super::image_patching::battlegroup_image_patch_operations;
use super::scripts::{
download_script, sh_single_quoted, shell_value, with_guest_path, CONTAINER_IMAGE_HELPERS,
DISK_SCRIPT, IMPORT_CORE_IMAGES_SCRIPT, INSTALL_HELPER_SCRIPT, KUBECTL_HELPERS,
SCALE_CORE_SCRIPT, START_K3S_SCRIPT,
};
use super::scripts_kubernetes::{
APPLY_DEFAULT_SETTINGS_SCRIPT, IMPORT_BATTLEGROUP_IMAGES_SCRIPT,
PATCH_DATABASE_OPERATOR_SCRIPT, PATCH_OPERATOR_IMAGES_SCRIPT, READ_BATTLEGROUP_VERSION_SCRIPT,
SCALE_OPERATOR_SCRIPT, SYNC_POSTGRES_SUPERUSER_PASSWORD_SCRIPT, UPDATE_OPERATOR_CRDS_SCRIPT,
};
use super::world_creation::{
create_world_script, validate_world_manifest_request, CreateWorldOutput,
};
/// SSH-backed implementation of the guest bootstrap phases.
#[derive(Debug, Clone)]
pub struct SshGuestBootstrapProvider<R> {
runner: R,
}
impl<R> SshGuestBootstrapProvider<R>
where
R: RemoteCommandRunner,
{
/// Creates a bootstrap provider around a remote command runner.
pub fn new(runner: R) -> Self {
Self { runner }
}
pub(super) fn run_phase(&self, body: &str) -> CommandResult<String> {
self.runner.run_script(&with_guest_path(body))
}
}
impl<R> GuestBootstrapProvider for SshGuestBootstrapProvider<R>
where
R: RemoteCommandRunner,
{
fn validate_and_resize_root_disk(&self) -> CommandResult<()> {
self.run_phase(DISK_SCRIPT)?;
Ok(())
}
fn ensure_server_payload(&self) -> CommandResult<()> {
self.run_phase(&download_script())?;
Ok(())
}
fn start_k3s_and_wait(&self) -> CommandResult<()> {
self.run_phase(START_K3S_SCRIPT)?;
Ok(())
}
fn import_core_images(&self) -> CommandResult<()> {
self.run_phase(&format!(
"{}\n{}",
CONTAINER_IMAGE_HELPERS, IMPORT_CORE_IMAGES_SCRIPT
))?;
Ok(())
}
fn scale_core_deployments(&self) -> CommandResult<()> {
self.run_phase(&format!("{}\n{}", KUBECTL_HELPERS, SCALE_CORE_SCRIPT))?;
Ok(())
}
fn update_operator_crds(&self) -> CommandResult<()> {
self.run_phase(&format!(
"{}\n{}",
KUBECTL_HELPERS, UPDATE_OPERATOR_CRDS_SCRIPT
))?;
Ok(())
}
fn patch_operator_images(&self) -> CommandResult<()> {
self.run_phase(&format!(
"{}\n{}\n{}\n{}",
KUBECTL_HELPERS,
CONTAINER_IMAGE_HELPERS,
PATCH_DATABASE_OPERATOR_SCRIPT,
PATCH_OPERATOR_IMAGES_SCRIPT
))?;
Ok(())
}
fn scale_operator_deployments(&self) -> CommandResult<()> {
self.run_phase(&format!("{}\n{}", KUBECTL_HELPERS, SCALE_OPERATOR_SCRIPT))?;
Ok(())
}
fn install_battlegroup_helper(&self) -> CommandResult<()> {
self.run_phase(INSTALL_HELPER_SCRIPT)?;
Ok(())
}
fn create_world(&self, request: &WorldManifestRequest) -> CommandResult<CreatedWorld> {
validate_world_manifest_request(request)?;
let script = create_world_script(request);
let output = self.run_phase(&script)?;
let result: CreateWorldOutput = parse_single_json_document(&output, "create world")?;
Ok(CreatedWorld {
namespace: result.namespace,
battlegroup_name: result.battlegroup_name,
})
}
fn import_battlegroup_images(&self) -> CommandResult<()> {
self.run_phase(&format!(
"{}\n{}",
CONTAINER_IMAGE_HELPERS, IMPORT_BATTLEGROUP_IMAGES_SCRIPT
))?;
Ok(())
}
fn patch_battlegroup_images(
&self,
namespace: &str,
battlegroup_name: &str,
) -> CommandResult<()> {
validate_kube_arg(namespace, "namespace")?;
validate_kube_arg(battlegroup_name, "battlegroup name")?;
let new_version = self
.run_phase(READ_BATTLEGROUP_VERSION_SCRIPT)?
.trim()
.to_string();
if new_version.is_empty() {
return Err(failure("Battlegroup image version file was empty"));
}
self.sync_existing_postgres_credentials(namespace, battlegroup_name)?;
let command = format!(
"sudo kubectl get battlegroup {} -n {} -o json",
sh_single_quoted(battlegroup_name),
sh_single_quoted(namespace),
);
let battlegroup_json = self
.runner
.run_json(&command, "battlegroup image patch source")?;
let operations = battlegroup_image_patch_operations(&battlegroup_json, &new_version)?;
let patch_command = format!(
"sudo kubectl patch battlegroup {} -n {} --type=json -p {} -o json",
sh_single_quoted(battlegroup_name),
sh_single_quoted(namespace),
sh_single_quoted(&serde_json::to_string(&operations).map_err(|err| {
failure(format!(
"Failed to serialize battlegroup image patch: {err}"
))
})?),
);
self.runner.run(&patch_command)?;
Ok(())
}
fn apply_default_user_settings(
&self,
namespace: &str,
battlegroup_name: &str,
) -> CommandResult<()> {
validate_kube_arg(namespace, "namespace")?;
validate_kube_arg(battlegroup_name, "battlegroup name")?;
let mut script = String::new();
script.push_str("set -eu\n");
script.push_str(&shell_value("NS", namespace));
script.push_str(APPLY_DEFAULT_SETTINGS_SCRIPT);
self.run_phase(&script)?;
Ok(())
}
}
impl<R> SshGuestBootstrapProvider<R>
where
R: RemoteCommandRunner,
{
fn sync_existing_postgres_credentials(
&self,
namespace: &str,
battlegroup_name: &str,
) -> CommandResult<()> {
validate_kube_arg(namespace, "namespace")?;
validate_kube_arg(battlegroup_name, "battlegroup name")?;
let mut script = String::new();
script.push_str("set -eu\n");
script.push_str(&shell_value("NS", namespace));
script.push_str(&shell_value("BG", battlegroup_name));
script.push_str(SYNC_POSTGRES_SUPERUSER_PASSWORD_SCRIPT);
self.run_phase(&script)?;
Ok(())
}
}
#[cfg(test)]
mod tests;

View File

@@ -0,0 +1,203 @@
use std::{cell::RefCell, collections::VecDeque, rc::Rc};
use super::SshGuestBootstrapProvider;
use crate::orchestration::guest_bootstrap_ssh::scripts::download_script;
use crate::{
models::CommandResult,
orchestration::{GuestBootstrapProvider, RemoteCommandRunner, WorldManifestRequest},
};
#[derive(Clone, Default)]
struct MockRemote {
outputs: Rc<RefCell<VecDeque<String>>>,
scripts: Rc<RefCell<Vec<String>>>,
}
impl MockRemote {
fn with_outputs(outputs: impl IntoIterator<Item = impl Into<String>>) -> Self {
Self {
outputs: Rc::new(RefCell::new(outputs.into_iter().map(Into::into).collect())),
scripts: Rc::new(RefCell::new(Vec::new())),
}
}
}
impl RemoteCommandRunner for MockRemote {
fn run(&self, command: &str) -> CommandResult<String> {
self.run_script(command)
}
fn run_script(&self, script: &str) -> CommandResult<String> {
self.scripts.borrow_mut().push(script.to_string());
Ok(self.outputs.borrow_mut().pop_front().unwrap_or_default())
}
}
#[test]
fn create_world_returns_structured_json_only() {
let remote = MockRemote::with_outputs([
r#"{"namespace":"funcom-seabass-sh-host-abcdef","battlegroupName":"sh-host-abcdef"}"#,
]);
let scripts = remote.scripts.clone();
let provider = SshGuestBootstrapProvider::new(remote);
let world = provider
.create_world(&WorldManifestRequest {
world_name: "Adain".to_string(),
world_region: "Europe".to_string(),
player_ip: "203.0.113.10".to_string(),
world_unique_name: "sh-host-abcdef".to_string(),
self_host_token: "header.payload.signature".to_string(),
})
.unwrap();
assert_eq!(world.namespace, "funcom-seabass-sh-host-abcdef");
let script = scripts.borrow().first().cloned().unwrap();
assert!(script.contains("printf '{\"namespace\":\"%s\",\"battlegroupName\":\"%s\"}"));
assert!(script.contains("kubectl create ns \"$NS\" >/dev/null"));
assert!(script.contains("kubectl apply -n \"$NS\""));
assert!(script.contains("DB_PASSWORD=$(openssl rand -hex 32)"));
assert!(script.contains("s/{WORLD_DUNE_PASS}/$(escape_sed \"$DB_PASSWORD\")/g"));
assert!(script.contains("s/{WORLD_POSTGRES_PASS}/$(escape_sed \"$DB_SUPER_PASSWORD\")/g"));
assert!(script.contains("s/{WORLD_IMAGE_TAG}/0-0-shipping/g"));
assert!(script.contains("HOST_DATACENTER_IP_ADDRESS"));
assert!(script.contains("PLAYER_IP=$(cat <<"));
assert!(!script.contains(
"WORLD_IMAGE_TAG=$(cat \"$G_SPEC_PATH/download/images/battlegroup/version.txt\")"
));
}
#[test]
fn create_world_patches_full_title_after_template_creation() {
let remote = MockRemote::with_outputs([
r#"{"namespace":"funcom-seabass-sh-host-abcdef","battlegroupName":"sh-host-abcdef"}"#,
]);
let scripts = remote.scripts.clone();
let provider = SshGuestBootstrapProvider::new(remote);
provider
.create_world(&WorldManifestRequest {
world_name: "Great Banana".to_string(),
world_region: "Europe".to_string(),
player_ip: "203.0.113.10".to_string(),
world_unique_name: "sh-host-abcdef".to_string(),
self_host_token: "header.payload.signature".to_string(),
})
.unwrap();
let script = scripts.borrow().first().cloned().unwrap();
assert!(script.contains("\"title\":\"Great Banana\""));
assert!(script.contains("kubectl patch battlegroup \"$WORLD_UNIQUE_NAME\""));
}
#[test]
fn provider_splits_vendor_k3s_work_into_explicit_phases() {
let remote = MockRemote::default();
let scripts = remote.scripts.clone();
let provider = SshGuestBootstrapProvider::new(remote);
provider.start_k3s_and_wait().unwrap();
provider.import_core_images().unwrap();
provider.scale_core_deployments().unwrap();
let scripts = scripts.borrow();
assert!(scripts[0].contains("rc-service k3s restart"));
assert!(scripts[1].contains("coredns-coredns.tar"));
assert!(scripts[1].contains("restart_k3s_and_wait_until_ready"));
assert!(scripts[2].contains("scale_deployment kube-system coredns 1"));
}
#[test]
fn operator_update_includes_vendor_database_concurrency_patch() {
let remote = MockRemote::default();
let scripts = remote.scripts.clone();
let provider = SshGuestBootstrapProvider::new(remote);
provider.patch_operator_images().unwrap();
let script = scripts.borrow().first().cloned().unwrap();
assert!(script.contains("patch_database_operator_concurrency"));
assert!(script.contains("dbutil-max-concurrent=2"));
assert!(script.contains("dbutil-max-concurrent=1"));
assert!(script.contains("kubectl_retry rollout -n funcom-operators status"));
}
#[test]
fn helper_install_links_battlegroup_and_bg_util() {
let remote = MockRemote::default();
let scripts = remote.scripts.clone();
let provider = SshGuestBootstrapProvider::new(remote);
provider.install_battlegroup_helper().unwrap();
let script = scripts.borrow().first().cloned().unwrap();
assert!(script.contains("/home/dune/.dune/bin/battlegroup"));
assert!(script.contains("/home/dune/.dune/bin/bg-util"));
assert!(script.contains("chmod +x /home/dune/.dune/download/scripts/bg-util"));
}
#[test]
fn guest_download_uses_validating_app_update() {
let script = download_script();
assert!(script.contains("+app_update 4754530 validate"));
}
#[test]
fn guest_download_retries_without_interactive_prompts() {
let script = download_script();
assert!(script.contains("+@ShutdownOnFailedCommand 1"));
assert!(script.contains("+@NoPromptForPassword 1"));
assert!(script.contains("< /dev/null"));
assert!(script.contains("max_attempts=5"));
assert!(script.contains("retrying in ${sleep_seconds}s"));
}
#[test]
fn battlegroup_image_patch_uses_rust_built_json_patch_without_jq() {
let remote = MockRemote::with_outputs([
"1952287-0-shipping",
"",
r#"{
"metadata":{"name":"sh-host-abcdef"},
"spec":{
"serverSets":[
{"image":"registry.funcom.com/funcom/self-hosting/seabass-server:old"},
{"image":"registry.funcom.com/funcom/self-hosting/other:old"}
],
"nested":{"image":"registry.funcom.com/funcom/self-hosting/seabass-server-gateway:old"}
}
}"#,
r#"{"metadata":{"name":"sh-host-abcdef"}}"#,
]);
let scripts = remote.scripts.clone();
let provider = SshGuestBootstrapProvider::new(remote);
provider
.patch_battlegroup_images("funcom-seabass-sh-host-abcdef", "sh-host-abcdef")
.unwrap();
let scripts = scripts.borrow();
assert!(scripts[0].contains("version.txt"));
assert!(scripts[1].contains("ALTER ROLE"));
assert!(scripts[1].contains("superPassword"));
assert!(scripts[2].contains("kubectl get battlegroup"));
assert!(scripts[3].contains("kubectl patch battlegroup"));
assert!(scripts[3].contains("--type=json"));
assert!(scripts[3].contains("1952287-0-shipping"));
assert!(scripts[3].contains("seabass-server-gateway"));
assert!(!scripts.join("\n").contains("jq"));
}
#[test]
fn rejects_invalid_world_manifest_before_script_execution() {
let remote = MockRemote::default();
let scripts = remote.scripts.clone();
let provider = SshGuestBootstrapProvider::new(remote);
let result = provider.create_world(&WorldManifestRequest {
world_name: "Adain".to_string(),
world_region: "Mars".to_string(),
player_ip: "203.0.113.10".to_string(),
world_unique_name: "sh-host-abcdef".to_string(),
self_host_token: "token".to_string(),
});
assert!(result.is_err());
assert!(scripts.borrow().is_empty());
}

View File

@@ -0,0 +1,217 @@
pub(super) const DUNE_HOME: &str = "/home/dune/.dune";
pub(super) const SERVER_APP_ID: &str = "4754530";
pub(super) fn with_guest_path(script: &str) -> String {
format!(
"export PATH=\"/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:$PATH\"\n{script}"
)
}
pub(super) fn download_script() -> String {
format!(
r#"
set -eu
DUNE_USER_PATH={dune_home}
DOWNLOAD_PATH="$DUNE_USER_PATH/download"
mkdir -p "$DOWNLOAD_PATH"
steamcmd_update_once() {{
if command -v timeout >/dev/null 2>&1; then
timeout 45m steamcmd +@ShutdownOnFailedCommand 1 +@NoPromptForPassword 1 +set_spew_level 1 1 +force_install_dir "$DOWNLOAD_PATH" +login anonymous +app_update {app_id} validate +logoff +quit < /dev/null >&2
else
steamcmd +@ShutdownOnFailedCommand 1 +@NoPromptForPassword 1 +set_spew_level 1 1 +force_install_dir "$DOWNLOAD_PATH" +login anonymous +app_update {app_id} validate +logoff +quit < /dev/null >&2
fi
}}
attempt=1
max_attempts=5
while [ "$attempt" -le "$max_attempts" ]; do
echo "SteamCMD payload download attempt $attempt/$max_attempts." >&2
if steamcmd_update_once; then
break
fi
status=$?
if [ "$attempt" -ge "$max_attempts" ]; then
echo "SteamCMD payload download failed after $max_attempts attempts, last exit code $status." >&2
exit "$status"
fi
sleep_seconds=$((attempt * 15))
echo "SteamCMD payload download failed with exit code $status; retrying in ${{sleep_seconds}}s." >&2
sleep "$sleep_seconds"
attempt=$((attempt + 1))
done
test -f "$DOWNLOAD_PATH/scripts/battlegroup.sh"
test -f "$DOWNLOAD_PATH/scripts/setup.sh"
"#,
dune_home = DUNE_HOME,
app_id = SERVER_APP_ID
)
}
pub(super) fn shell_value(name: &str, value: &str) -> String {
let delimiter = format!("__DUNE_MANAGER_{name}__");
format!("{name}=$(cat <<'{delimiter}'\n{value}\n{delimiter}\n)\n")
}
pub(super) fn sh_single_quoted(value: &str) -> String {
format!("'{}'", value.replace('\'', "'\"'\"'"))
}
pub(super) const DISK_SCRIPT: &str = r#"
set -eu
required_gb=30
available_gb=$(df -B1G -P / | awk '$NF == "/" {print $(NF-2)+0}')
if [ "$available_gb" -le "$required_gb" ]; then
sudo growpart /dev/sda 2 >&2 || true
sudo pvresize /dev/sda2 >&2 || true
sudo lvextend -l +100%FREE /dev/mapper/vg0-lv_root >&2 || true
sudo resize2fs /dev/mapper/vg0-lv_root >&2 || true
fi
available_gb=$(df -B1G -P / | awk '$NF == "/" {print $(NF-2)+0}')
if [ "$available_gb" -le "$required_gb" ]; then
echo "Not enough guest disk space after resize: ${available_gb}GB available, need more than ${required_gb}GB" >&2
exit 1
fi
"#;
pub(super) const START_K3S_SCRIPT: &str = r#"
set -eu
sudo rc-service k3s start >&2 || true
sudo rc-service k3s restart >&2
elapsed=0
while [ ! -S /run/k3s/containerd/containerd.sock ]; do
sleep 2
elapsed=$((elapsed + 2))
if [ "$elapsed" -ge 60 ]; then echo "k3s containerd did not return in 60s" >&2; exit 1; fi
done
elapsed=0
until sudo kubectl get nodes >/dev/null 2>&1; do
sleep 2
elapsed=$((elapsed + 2))
if [ "$elapsed" -ge 60 ]; then echo "k3s API did not return in 60s" >&2; exit 1; fi
done
sudo kubectl wait --for=condition=Ready node --all --timeout=180s >/dev/null || true
sudo rc-update add k3s >/dev/null
"#;
pub(super) const CONTAINER_IMAGE_HELPERS: &str = r#"
set -eu
DOWNLOAD_PATH=/home/dune/.dune/download
load_image_from_file() {
local file_name="$1"
if [ ! -f "$DOWNLOAD_PATH/$file_name" ]; then
echo "Image file $DOWNLOAD_PATH/$file_name does not exist" >&2
exit 1
fi
local attempt=1
while [ "$attempt" -le 3 ]; do
if sudo ctr -n k8s.io images import "$DOWNLOAD_PATH/$file_name" >&2; then
return 0
fi
echo "Import of $file_name failed (attempt $attempt/3)." >&2
if ! sudo ctr -n k8s.io version >/dev/null 2>&1; then
echo "k3s/containerd is not responding; restarting k3s." >&2
restart_k3s_and_wait_until_ready
else
sleep 5
fi
attempt=$((attempt + 1))
done
echo "Failed to import $file_name after 3 attempts" >&2
exit 1
}
"#;
pub(super) const IMPORT_CORE_IMAGES_SCRIPT: &str = r#"
load_image_from_file "images/prerequisites/coredns-coredns.tar"
load_image_from_file "images/prerequisites/local-path-provisioner.tar"
load_image_from_file "images/prerequisites/metrics-server.tar"
load_image_from_file "images/prerequisites/cert-manager-webhook.tar"
load_image_from_file "images/prerequisites/cert-manager-controller.tar"
load_image_from_file "images/prerequisites/cert-manager-cainjector.tar"
load_image_from_file "images/prerequisites/igw-postgres.tar"
"#;
pub(super) const KUBECTL_HELPERS: &str = r#"
set -eu
kubectl_retry() {
local attempt=1 out rc
while [ "$attempt" -le 5 ]; do
out=$(sudo kubectl "$@" 2>&1)
rc=$?
if [ "$rc" -eq 0 ]; then
[ -n "$out" ] && printf '%s\n' "$out" >&2
return 0
fi
if printf '%s' "$out" | grep -qiE 'connection refused|unable to connect to the server|i/o timeout|tls handshake|no route to host|EOF'; then
sleep 5
attempt=$((attempt + 1))
continue
fi
printf '%s\n' "$out" >&2
return "$rc"
done
echo "kubectl $* still failing after retries" >&2
return 1
}
restart_k3s_and_wait_until_ready() {
local elapsed=0
sudo rc-service k3s restart >&2
echo "Waiting for k3s containerd socket..." >&2
while [ ! -S /run/k3s/containerd/containerd.sock ]; do
sleep 2
elapsed=$((elapsed + 2))
if [ "$elapsed" -ge 60 ]; then echo "k3s containerd did not return in 60s" >&2; return 1; fi
done
echo "Waiting for k3s API server..." >&2
elapsed=0
until sudo kubectl get nodes >/dev/null 2>&1; do
sleep 2
elapsed=$((elapsed + 2))
if [ "$elapsed" -ge 60 ]; then echo "k3s API did not return in 60s" >&2; return 1; fi
done
}
wait_for_deployment() {
local ns="$1" name="$2" timeout="${3:-120}" elapsed=0
until sudo kubectl get -n "$ns" deployment "$name" >/dev/null 2>&1; do
sleep 2
elapsed=$((elapsed + 2))
if [ "$elapsed" -ge "$timeout" ]; then echo "deployment $ns/$name did not appear within ${timeout}s" >&2; return 1; fi
done
}
scale_deployment() {
local ns="$1" name="$2" replicas="$3"
wait_for_deployment "$ns" "$name" 120
kubectl_retry scale -n "$ns" "deployment/$name" "--replicas=$replicas"
}
operator_versions_differ() {
local version_file="$DOWNLOAD_PATH/images/operators/version.txt"
if [ ! -f "$version_file" ]; then
echo "No operator version file found at $version_file" >&2
return 1
fi
local current_version new_operator_version
current_version=$(kubectl_retry get -n funcom-operators deployment/battlegroupoperator-controller-manager -o jsonpath='{.spec.template.spec.containers[0].image}' | sed 's/.*://')
new_operator_version=$(cat "$version_file")
[ "$current_version" != "$new_operator_version" ]
}
DOWNLOAD_PATH=/home/dune/.dune/download
"#;
pub(super) const SCALE_CORE_SCRIPT: &str = r#"
scale_deployment kube-system coredns 1
scale_deployment kube-system local-path-provisioner 1
scale_deployment kube-system metrics-server 1
scale_deployment cert-manager cert-manager 1
scale_deployment cert-manager cert-manager-cainjector 1
scale_deployment cert-manager cert-manager-webhook 1
"#;
pub(super) const INSTALL_HELPER_SCRIPT: &str = r#"
set -eu
mkdir -p /home/dune/.dune/bin
test -f /home/dune/.dune/download/scripts/battlegroup.sh
test -f /home/dune/.dune/download/scripts/bg-util
ln -sfn /home/dune/.dune/download/scripts/battlegroup.sh /home/dune/.dune/bin/battlegroup
chmod +x /home/dune/.dune/download/scripts/battlegroup.sh
ln -sfn /home/dune/.dune/download/scripts/bg-util /home/dune/.dune/bin/bg-util
chmod +x /home/dune/.dune/download/scripts/bg-util
"#;

View File

@@ -0,0 +1,148 @@
pub(super) const UPDATE_OPERATOR_CRDS_SCRIPT: &str = r#"
if operator_versions_differ; then
kubectl_retry replace -n funcom-operators -f "$DOWNLOAD_PATH/images/operators/crds/" || kubectl_retry apply -n funcom-operators -f "$DOWNLOAD_PATH/images/operators/crds/"
fi
"#;
pub(super) const PATCH_OPERATOR_IMAGES_SCRIPT: &str = r#"
if operator_versions_differ; then
new_operator_version=$(cat "$DOWNLOAD_PATH/images/operators/version.txt")
patch_database_operator_concurrency
load_image_from_file "images/operators/battlegroup-operator.tar"
load_image_from_file "images/operators/database-operator.tar"
load_image_from_file "images/operators/server-operator.tar"
load_image_from_file "images/operators/utilities-operator.tar"
kubectl_retry set -n funcom-operators image deployment/battlegroupoperator-controller-manager manager=registry.funcom.com/funcom/self-hosting/igw-k8s-battlegroup-operator:"$new_operator_version"
kubectl_retry set -n funcom-operators image deployment/databaseoperator-controller-manager manager=registry.funcom.com/funcom/self-hosting/igw-k8s-database-operator:"$new_operator_version"
kubectl_retry set -n funcom-operators image deployment/serveroperator-controller-manager manager=registry.funcom.com/funcom/self-hosting/igw-k8s-server-operator:"$new_operator_version"
kubectl_retry set -n funcom-operators image deployment/utilitiesoperator-controller-manager manager=registry.funcom.com/funcom/self-hosting/igw-k8s-utilities-operator:"$new_operator_version"
fi
"#;
pub(super) const PATCH_DATABASE_OPERATOR_SCRIPT: &str = r#"
patch_database_operator_concurrency() {
current_args=$(sudo kubectl get -n funcom-operators deployment/databaseoperator-controller-manager -o jsonpath='{.spec.template.spec.containers[0].args}' 2>/dev/null || true)
if ! printf '%s' "$current_args" | grep -q 'dbutil-max-concurrent=2'; then
return 0
fi
patch='[{"op":"replace","path":"/spec/template/spec/containers/0/args","value":["--leader-elect","--zap-devel=false","--zap-log-level=debug","--zap-time-encoding=iso8601","--db-max-concurrent=1","--dbdepl-max-concurrent=1","--dbutil-max-concurrent=1","--dbop-max-concurrent=1","--dbb-max-concurrent=1","--dbbs-max-concurrent=1","--dbr-max-concurrent=1","--dbm-max-concurrent=1","--dbutil-supports-prometheus=false"]}]'
kubectl_retry patch deployment -n funcom-operators databaseoperator-controller-manager --type=json -p="$patch"
kubectl_retry rollout -n funcom-operators status deployment/databaseoperator-controller-manager --timeout=120s
}
"#;
pub(super) const SCALE_OPERATOR_SCRIPT: &str = r#"
scale_deployment funcom-operators battlegroupoperator-controller-manager 1
scale_deployment funcom-operators databaseoperator-controller-manager 1
scale_deployment funcom-operators serveroperator-controller-manager 1
scale_deployment funcom-operators utilitiesoperator-controller-manager 1
"#;
pub(super) const IMPORT_BATTLEGROUP_IMAGES_SCRIPT: &str = r#"
load_image_from_file "images/battlegroup/server-rabbitmq.tar"
load_image_from_file "images/battlegroup/server-text-router.tar"
load_image_from_file "images/battlegroup/server-bg-director.tar"
load_image_from_file "images/battlegroup/server-gateway.tar"
load_image_from_file "images/battlegroup/server-db-utils.tar"
load_image_from_file "images/battlegroup/server.tar"
"#;
pub(super) const READ_BATTLEGROUP_VERSION_SCRIPT: &str = r#"
DOWNLOAD_PATH=/home/dune/.dune/download
version_file="$DOWNLOAD_PATH/images/battlegroup/version.txt"
if [ ! -f "$version_file" ]; then
echo "No battlegroup version file found at $version_file" >&2
exit 1
fi
cat "$version_file"
"#;
pub(super) const SYNC_POSTGRES_SUPERUSER_PASSWORD_SCRIPT: &str = r#"
DDEP="$BG-db-dbdepl"
if ! sudo kubectl get databasedeployment "$DDEP" -n "$NS" >/dev/null 2>&1; then
DDEP=$(sudo kubectl get databasedeployments -n "$NS" --no-headers -o custom-columns=NAME:.metadata.name 2>/dev/null | awk -v bg="$BG" '$1 ~ "^" bg ".*dbdepl$" { print $1; exit }' || true)
fi
if [ -z "$DDEP" ]; then
echo "No existing database deployment found for $BG; skipping Postgres password sync." >&2
exit 0
fi
DBPOD="$DDEP-sts-0"
elapsed=0
while [ "$elapsed" -lt 180 ]; do
phase=$(sudo kubectl get pod "$DBPOD" -n "$NS" -o jsonpath='{.status.phase}' 2>/dev/null || true)
if [ "$phase" = "Running" ]; then break; fi
sleep 5
elapsed=$((elapsed + 5))
done
if [ "${phase:-}" != "Running" ]; then
echo "No running database pod found for $DDEP; skipping Postgres credential sync." >&2
exit 0
fi
SUPER_PASSWORD=$(sudo kubectl get databasedeployment "$DDEP" -n "$NS" -o jsonpath='{.spec.superPassword}' 2>/dev/null || true)
if [ -z "$SUPER_PASSWORD" ]; then
echo "Database deployment $DDEP has no superPassword; skipping Postgres password sync." >&2
exit 0
fi
SUPER_USER=$(sudo kubectl get databasedeployment "$DDEP" -n "$NS" -o jsonpath='{.spec.superUser}' 2>/dev/null || true)
DB_PORT=$(sudo kubectl get databasedeployment "$DDEP" -n "$NS" -o jsonpath='{.spec.port}' 2>/dev/null || true)
if [ -z "$SUPER_USER" ]; then SUPER_USER=postgres; fi
if [ -z "$DB_PORT" ]; then DB_PORT=15432; fi
DB_USER=$(sudo kubectl get databasedeployment "$DDEP" -n "$NS" -o jsonpath='{.spec.user}' 2>/dev/null || true)
DB_PASSWORD=$(sudo kubectl get databasedeployment "$DDEP" -n "$NS" -o jsonpath='{.spec.password}' 2>/dev/null || true)
DB_NAME=$(sudo kubectl get databasedeployment "$DDEP" -n "$NS" -o jsonpath='{.spec.gameDatabaseName}' 2>/dev/null || true)
if [ -z "$DB_USER" ]; then DB_USER=dune; fi
if [ -z "$DB_NAME" ]; then DB_NAME=dune; fi
if [ -z "$DB_PASSWORD" ]; then
echo "Database deployment $DDEP has no game database password; skipping game role sync." >&2
else
sudo kubectl exec -i -n "$NS" "$DBPOD" -- \
psql -h 127.0.0.1 -p "$DB_PORT" -U "$SUPER_USER" -d postgres \
-v ON_ERROR_STOP=1 \
-v db_user="$DB_USER" \
-v db_password="$DB_PASSWORD" \
-v db_name="$DB_NAME" >/dev/null <<'SQL'
SELECT format('CREATE ROLE %I LOGIN PASSWORD %L', :'db_user', :'db_password')
WHERE NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = :'db_user') \gexec
ALTER ROLE :"db_user" WITH LOGIN PASSWORD :'db_password';
SELECT format('CREATE DATABASE %I OWNER %I', :'db_name', :'db_user')
WHERE NOT EXISTS (SELECT 1 FROM pg_database WHERE datname = :'db_name') \gexec
SQL
fi
ESCAPED_PASSWORD=$(printf '%s' "$SUPER_PASSWORD" | sed "s/'/''/g")
ESCAPED_USER=$(printf '%s' "$SUPER_USER" | sed 's/"/""/g')
printf "ALTER ROLE \"%s\" WITH PASSWORD '%s';\n" "$ESCAPED_USER" "$ESCAPED_PASSWORD" |
sudo kubectl exec -i -n "$NS" "$DBPOD" -- \
psql -h 127.0.0.1 -p "$DB_PORT" -U "$SUPER_USER" -d postgres -v ON_ERROR_STOP=1 >/dev/null
echo "Postgres credentials are aligned with database deployment $DDEP." >&2
"#;
pub(super) const APPLY_DEFAULT_SETTINGS_SCRIPT: &str = r#"
DOWNLOAD_PATH=/home/dune/.dune/download
config_dir="$DOWNLOAD_PATH/scripts/setup/config"
if ! ls "$config_dir"/User*.ini >/dev/null 2>&1; then
echo "No User*.ini files found in $config_dir" >&2
exit 1
fi
elapsed=0
fb_pod=""
while [ "$elapsed" -lt 240 ]; do
fb_pod=$(sudo kubectl get pods -n "$NS" -l role=igw-filebrowser --no-headers -o custom-columns=NAME:.metadata.name 2>/dev/null | head -n1 || true)
if [ -n "$fb_pod" ]; then break; fi
sleep 5
elapsed=$((elapsed + 5))
done
if [ -z "$fb_pod" ]; then
echo "No filebrowser pod became available in $NS" >&2
exit 1
fi
sudo kubectl exec -n "$NS" "$fb_pod" -- mkdir -p /srv/UserSettings >&2
for config_file in "$config_dir"/User*.ini; do
filename=$(basename "$config_file")
sudo kubectl cp "$config_file" "$NS/$fb_pod:/srv/UserSettings/$filename" >&2
done
"#;

View File

@@ -0,0 +1,147 @@
use serde::Deserialize;
use serde_json::json;
use crate::{
errors::failure, models::CommandResult, orchestration::WorldManifestRequest,
validation::validate_kube_arg,
};
use super::scripts::shell_value;
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(super) struct CreateWorldOutput {
pub(super) namespace: String,
pub(super) battlegroup_name: String,
}
pub(super) fn validate_world_manifest_request(request: &WorldManifestRequest) -> CommandResult<()> {
validate_kube_arg(&request.world_unique_name, "world unique name")?;
validate_ipv4ish(&request.player_ip, "player-facing IP")?;
if request.world_name.trim().is_empty()
|| request.world_name.chars().count() > 50
|| request.world_name.contains('\n')
|| request.world_name.contains('\r')
{
return Err(failure(
"World name must be 1-50 characters and single-line",
));
}
match request.world_region.as_str() {
"Asia" | "Europe" | "North America" | "Oceania" | "South America" => {}
_ => {
return Err(failure(
"Region must be Asia, Europe, North America, Oceania, or South America",
))
}
}
if request.self_host_token.trim().is_empty()
|| request.self_host_token.contains('\n')
|| request.self_host_token.contains('\r')
{
return Err(failure("Self-host token is required"));
}
Ok(())
}
fn validate_ipv4ish(value: &str, label: &str) -> CommandResult<()> {
let parts = value.trim().split('.').collect::<Vec<_>>();
if parts.len() == 4 && parts.iter().all(|part| part.parse::<u8>().is_ok()) {
Ok(())
} else {
Err(failure(format!("{label} must be an IPv4 address")))
}
}
pub(super) fn create_world_script(request: &WorldManifestRequest) -> String {
let namespace = format!("funcom-seabass-{}", request.world_unique_name);
let title_patch = json!({
"spec": {
"title": request.world_name.trim(),
}
})
.to_string();
let mut script = String::from("set -eu\n");
script.push_str("G_SPEC_PATH=/home/dune/.dune\n");
script.push_str("G_SCRIPT_PATH=/home/dune/.dune/download/scripts/setup\n");
script.push_str(&shell_value("WORLD_NAME", request.world_name.trim()));
script.push_str(&shell_value("WORLD_REGION", request.world_region.trim()));
script.push_str(&shell_value("PLAYER_IP", request.player_ip.trim()));
script.push_str(&shell_value(
"WORLD_UNIQUE_NAME",
&request.world_unique_name,
));
script.push_str(&shell_value("NS", &namespace));
script.push_str(&shell_value("FLS_TOKEN", request.self_host_token.trim()));
script.push_str(&shell_value("TITLE_PATCH", &title_patch));
script.push_str(
r#"
if sudo kubectl get battlegroup "$WORLD_UNIQUE_NAME" -n "$NS" >/dev/null 2>&1; then
sudo kubectl patch battlegroup "$WORLD_UNIQUE_NAME" -n "$NS" --type=merge -p "$TITLE_PATCH" >/dev/null
printf '%s' "$WORLD_UNIQUE_NAME" > /home/dune/.dune/.manager-bootstrap-world-name
printf '{"namespace":"%s","battlegroupName":"%s"}\n' "$NS" "$WORLD_UNIQUE_NAME"
exit 0
fi
RMQ_SECRET=$(openssl rand -base64 64 | tr -d '\n')
DB_PASSWORD=$(openssl rand -hex 32)
DB_SUPER_PASSWORD=$(openssl rand -hex 32)
escape_sed() { printf '%s' "$1" | sed -e 's/[\/&]/\\&/g'; }
escape_sed_pipe() { printf '%s' "$1" | sed -e 's/[|&]/\\&/g'; }
cp "$G_SCRIPT_PATH/templates/world-template.yaml" "$G_SPEC_PATH/$WORLD_UNIQUE_NAME.yaml"
cp "$G_SCRIPT_PATH/templates/fls-secret.yaml" "$G_SPEC_PATH/$WORLD_UNIQUE_NAME-fls-secret.yaml"
cp "$G_SCRIPT_PATH/templates/rmq-secret.yaml" "$G_SPEC_PATH/$WORLD_UNIQUE_NAME-rmq-secret.yaml"
sed -i "s/{WORLD_NAME}/$(escape_sed "$WORLD_NAME")/g" "$G_SPEC_PATH/$WORLD_UNIQUE_NAME.yaml"
sed -i "s/{WORLD_UNIQUE_NAME}/$(escape_sed "$WORLD_UNIQUE_NAME")/g" "$G_SPEC_PATH/$WORLD_UNIQUE_NAME.yaml"
sed -i "s/{WORLD_REGION}/$(escape_sed "$WORLD_REGION")/g" "$G_SPEC_PATH/$WORLD_UNIQUE_NAME.yaml"
sed -i "s/{WORLD_IMAGE_TAG}/0-0-shipping/g" "$G_SPEC_PATH/$WORLD_UNIQUE_NAME.yaml"
sed -i "s/{FLS_SECRET}/$(escape_sed "$FLS_TOKEN")/g" "$G_SPEC_PATH/$WORLD_UNIQUE_NAME.yaml"
sed -i "s/{WORLD_DUNE_PASS}/$(escape_sed "$DB_PASSWORD")/g" "$G_SPEC_PATH/$WORLD_UNIQUE_NAME.yaml"
sed -i "s/{WORLD_POSTGRES_PASS}/$(escape_sed "$DB_SUPER_PASSWORD")/g" "$G_SPEC_PATH/$WORLD_UNIQUE_NAME.yaml"
sed -i "s/{FLS_SECRET}/$(escape_sed "$FLS_TOKEN")/g" "$G_SPEC_PATH/$WORLD_UNIQUE_NAME-fls-secret.yaml"
sed -i "s|{RMQ_SECRET}|$(escape_sed_pipe "$RMQ_SECRET")|g" "$G_SPEC_PATH/$WORLD_UNIQUE_NAME-rmq-secret.yaml"
world_tmp="$G_SPEC_PATH/$WORLD_UNIQUE_NAME.yaml.tmp"
awk -v player_ip="$PLAYER_IP" '
next_is_host_ip {
if ($0 ~ /^[[:space:]]*value:/) {
sub(/value:.*/, "value: " player_ip)
replaced++
}
next_is_host_ip=0
}
/name:[[:space:]]*HOST_DATACENTER_IP_ADDRESS/ { next_is_host_ip=1 }
{ print }
END { if (replaced == 0) exit 42 }
' "$G_SPEC_PATH/$WORLD_UNIQUE_NAME.yaml" > "$world_tmp" || {
rm -f "$world_tmp"
echo "No HOST_DATACENTER_IP_ADDRESS values were found in world manifest" >&2
exit 1
}
mv "$world_tmp" "$G_SPEC_PATH/$WORLD_UNIQUE_NAME.yaml"
elapsed=0
while [ "$elapsed" -lt 300 ]; do
all_ready=true
for op in battlegroupoperator-controller-manager databaseoperator-controller-manager serveroperator-controller-manager utilitiesoperator-controller-manager; do
ready=$(sudo kubectl get -n funcom-operators deployment/"$op" -o jsonpath='{.status.readyReplicas}' 2>/dev/null || true)
if [ "$ready" != "1" ]; then all_ready=false; break; fi
done
if $all_ready; then break; fi
sleep 5
elapsed=$((elapsed + 5))
done
if [ "$elapsed" -ge 300 ]; then
echo "Timed out waiting for operators" >&2
exit 1
fi
if ! sudo kubectl get ns "$NS" >/dev/null 2>&1; then
sudo kubectl create ns "$NS" >/dev/null
fi
sudo kubectl apply -n "$NS" -f "$G_SPEC_PATH/$WORLD_UNIQUE_NAME-fls-secret.yaml" >/dev/null
sudo kubectl apply -n "$NS" -f "$G_SPEC_PATH/$WORLD_UNIQUE_NAME-rmq-secret.yaml" >/dev/null
sudo kubectl apply -n "$NS" -f "$G_SPEC_PATH/$WORLD_UNIQUE_NAME.yaml" >/dev/null
sudo kubectl patch battlegroup "$WORLD_UNIQUE_NAME" -n "$NS" --type=merge -p "$TITLE_PATCH" >/dev/null
printf '%s' "$WORLD_UNIQUE_NAME" > /home/dune/.dune/.manager-bootstrap-world-name
printf '{"namespace":"%s","battlegroupName":"%s"}\n' "$NS" "$WORLD_UNIQUE_NAME"
"#,
);
script
}

View File

@@ -0,0 +1,89 @@
use serde::Deserialize;
use crate::{
errors::failure,
models::CommandResult,
orchestration::{
powershell_json_command, StrictCommandRunner, VmInventoryRecord, VmPowerState,
},
};
/// Hyper-V provider implemented through strict JSON PowerShell commands.
#[derive(Debug, Clone)]
pub struct StrictPowerShellHyperV {
runner: StrictCommandRunner,
}
impl StrictPowerShellHyperV {
/// Creates a Hyper-V bridge that invokes local PowerShell.
pub fn new() -> Self {
Self {
runner: StrictCommandRunner,
}
}
pub(super) fn run_json<T: for<'de> Deserialize<'de>>(
&self,
id: &'static str,
script: String,
) -> CommandResult<T> {
self.runner.run_json(&powershell_json_command(id, &script))
}
pub(super) fn run_unit(&self, id: &'static str, script: String) -> CommandResult<()> {
let output: UnitOutput = self.run_json(id, script)?;
if !output.ok {
return Err(failure(format!("{id} returned ok=false")));
}
Ok(())
}
}
impl Default for StrictPowerShellHyperV {
fn default() -> Self {
Self::new()
}
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct UnitOutput {
ok: bool,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(super) struct RawVmRecord {
name: String,
state: String,
configuration_location: String,
path: String,
memory_assigned_bytes: Option<u64>,
processor_count: Option<u32>,
uptime_seconds: Option<u64>,
ipv4_addresses: Vec<String>,
hard_disk_paths: Vec<String>,
disk_size_bytes: Option<u64>,
disk_file_size_bytes: Option<u64>,
switch_names: Vec<String>,
}
impl From<RawVmRecord> for VmInventoryRecord {
fn from(value: RawVmRecord) -> Self {
Self {
name: value.name,
state: VmPowerState::from_hyperv_state(&value.state),
raw_state: value.state,
configuration_location: value.configuration_location,
path: value.path,
memory_assigned_bytes: value.memory_assigned_bytes.unwrap_or_default(),
processor_count: value.processor_count.unwrap_or_default(),
uptime_seconds: value.uptime_seconds.unwrap_or_default(),
ipv4_addresses: value.ipv4_addresses,
hard_disk_paths: value.hard_disk_paths,
disk_size_bytes: value.disk_size_bytes.unwrap_or_default(),
disk_file_size_bytes: value.disk_file_size_bytes.unwrap_or_default(),
switch_names: value.switch_names,
}
}
}

View File

@@ -0,0 +1,143 @@
use crate::{
models::CommandResult,
orchestration::{DriveCandidate, HostProvider, HostReadiness, NetworkAdapterCandidate},
};
use super::bridge::StrictPowerShellHyperV;
impl HostProvider for StrictPowerShellHyperV {
fn readiness(&self) -> CommandResult<HostReadiness> {
self.run_json(
"hyperv.host.readiness",
r#"
$ErrorActionPreference = 'Stop'
$principal = New-Object Security.Principal.WindowsPrincipal([Security.Principal.WindowsIdentity]::GetCurrent())
$vmms = Get-Service -Name vmms -ErrorAction SilentlyContinue
$cpu = Get-CimInstance -ClassName Win32_Processor -ErrorAction SilentlyContinue | Select-Object -First 1
$os = Get-CimInstance -ClassName Win32_OperatingSystem -ErrorAction SilentlyContinue | Select-Object -First 1
$processors = @(Get-CimInstance -ClassName Win32_Processor -ErrorAction SilentlyContinue)
$vmHost = Get-VMHost -ErrorAction SilentlyContinue
$logicalProcessorCount = [uint32]0
foreach ($processor in $processors) {
if ($processor.NumberOfLogicalProcessors) {
$logicalProcessorCount += [uint32]$processor.NumberOfLogicalProcessors
}
}
[pscustomobject]@{
elevated = $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
hypervAvailable = [bool](Get-Command Get-VM -ErrorAction SilentlyContinue)
vmmsRunning = if ($vmms) { $vmms.Status.ToString() -eq 'Running' } else { $false }
virtualizationFirmwareEnabled = if ($vmHost) { $true } elseif ($cpu) { [bool]$cpu.VirtualizationFirmwareEnabled } else { $null }
totalPhysicalMemoryBytes = if ($os) { [uint64]$os.TotalVisibleMemorySize * 1024 } else { 0 }
availablePhysicalMemoryBytes = if ($os) { [uint64]$os.FreePhysicalMemory * 1024 } else { 0 }
logicalProcessorCount = $logicalProcessorCount
} | ConvertTo-Json -Compress -Depth 4
"#
.to_string(),
)
}
fn drives_with_minimum_free_space(
&self,
minimum_free_bytes: u64,
) -> CommandResult<Vec<DriveCandidate>> {
self.run_json(
"hyperv.host.drives",
format!(
r#"
$ErrorActionPreference = 'Stop'
$items = @(Get-PSDrive -PSProvider FileSystem |
Where-Object {{ $_.Free -ge {minimum_free_bytes} }} |
Sort-Object Name |
ForEach-Object {{
[pscustomobject]@{{
name = $_.Name
root = $_.Root
freeBytes = [uint64]$_.Free
}}
}})
ConvertTo-Json -InputObject $items -Compress -Depth 4
"#
),
)
}
fn active_physical_adapters(&self) -> CommandResult<Vec<NetworkAdapterCandidate>> {
self.run_json(
"hyperv.host.network-adapters",
r#"
$ErrorActionPreference = 'Stop'
function ConvertTo-IPv4Int($address) {
$bytes = [System.Net.IPAddress]::Parse($address).GetAddressBytes()
[Array]::Reverse($bytes)
return [BitConverter]::ToUInt32($bytes, 0)
}
function ConvertFrom-IPv4Int([uint32]$value) {
$bytes = [BitConverter]::GetBytes($value)
[Array]::Reverse($bytes)
return ([System.Net.IPAddress]::new($bytes)).ToString()
}
function Get-SuggestedIPv4Address($address, [int]$prefixLength, $gateway) {
if ([string]::IsNullOrWhiteSpace($address) -or $prefixLength -lt 1 -or $prefixLength -gt 30) {
return ''
}
$ipInt = ConvertTo-IPv4Int $address
$mask = [uint32]::MaxValue -shl (32 - $prefixLength)
$network = $ipInt -band $mask
$broadcast = $network -bor (-bnot $mask)
$reserved = @{}
@($address, $gateway) | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | ForEach-Object {
$reserved[$_] = $true
}
Get-NetIPAddress -AddressFamily IPv4 -ErrorAction SilentlyContinue |
ForEach-Object { $reserved[$_.IPAddress] = $true }
Get-NetNeighbor -AddressFamily IPv4 -ErrorAction SilentlyContinue |
Where-Object { $_.State -in @('Reachable', 'Stale', 'Delay', 'Probe', 'Permanent') } |
ForEach-Object { $reserved[$_.IPAddress] = $true }
for ($candidate = [uint32]($broadcast - 2); $candidate -gt [uint32]($network + 1); $candidate--) {
$candidateIp = ConvertFrom-IPv4Int $candidate
if (-not $reserved.ContainsKey($candidateIp)) {
return $candidateIp
}
}
return ''
}
$switches = @(Get-VMSwitch -SwitchType External -ErrorAction SilentlyContinue)
$adapters = @(Get-NetAdapter)
$items = @(Get-NetAdapter |
Where-Object { $_.Status -eq 'Up' -and $_.HardwareInterface -eq $true } |
ForEach-Object {
$adapter = $_
$boundSwitch = $switches | Where-Object { $_.NetAdapterInterfaceDescription -eq $adapter.InterfaceDescription } | Select-Object -First 1
$ipAdapter = $adapter
if ($boundSwitch) {
$managementAdapterName = "vEthernet ($($boundSwitch.Name))"
$managementAdapter = $adapters | Where-Object { $_.Name -eq $managementAdapterName } | Select-Object -First 1
if ($managementAdapter) {
$ipAdapter = $managementAdapter
}
}
$ip = Get-NetIPAddress -InterfaceIndex $ipAdapter.ifIndex -AddressFamily IPv4 -ErrorAction SilentlyContinue |
Where-Object { $_.IPAddress -notlike '169.254.*' } |
Select-Object -First 1
$route = Get-NetRoute -InterfaceIndex $ipAdapter.ifIndex -DestinationPrefix '0.0.0.0/0' -ErrorAction SilentlyContinue |
Sort-Object RouteMetric |
Select-Object -First 1
if ($ip -and $route -and -not [string]::IsNullOrWhiteSpace($route.NextHop) -and $route.NextHop -ne '0.0.0.0') {
[pscustomobject]@{
name = $adapter.Name
interfaceDescription = $adapter.InterfaceDescription
ipv4Address = $ip.IPAddress
prefixLength = [int]$ip.PrefixLength
gateway = $route.NextHop
suggestedIpv4Address = Get-SuggestedIPv4Address $ip.IPAddress ([int]$ip.PrefixLength) $route.NextHop
existingExternalSwitch = if ($boundSwitch) { $boundSwitch.Name } else { '' }
}
}
})
ConvertTo-Json -InputObject $items -Compress -Depth 5
"#
.to_string(),
)
}
}

View File

@@ -0,0 +1,11 @@
//! PowerShell-backed Hyper-V provider implementation, grouped by Hyper-V domain
//! (bridge core, host operations, VM operations).
mod bridge;
mod host_operations;
mod vm_operations;
#[cfg(test)]
mod tests;
pub use bridge::StrictPowerShellHyperV;

View File

@@ -0,0 +1,38 @@
use crate::{
orchestration::{powershell_json_command, StrictCommandSpec},
shell::ps_single_quoted,
};
#[test]
fn powershell_json_command_uses_noninteractive_mode() {
let spec: StrictCommandSpec =
powershell_json_command("test", "[pscustomobject]@{ok=$true}|ConvertTo-Json");
assert_eq!(spec.program, "powershell");
assert!(spec.args.contains(&"-NonInteractive".to_string()));
assert!(spec.args.iter().any(|arg| arg.contains("ConvertTo-Json")));
}
#[test]
fn bridge_escapes_single_quotes_in_vm_name() {
let script = format!(
"Start-VM -Name {} -ErrorAction Stop",
ps_single_quoted("bad'name")
);
assert!(script.contains("'bad''name'"));
}
#[test]
fn missing_vm_script_emits_json_null() {
let script = format!(
r#"
$vmName = {}
$vm = Get-VM -Name $vmName -ErrorAction SilentlyContinue
if (-not $vm) {{
[Console]::Out.Write('null')
exit 0
}}
"#,
ps_single_quoted("sample")
);
assert!(script.contains("[Console]::Out.Write('null')"));
}

View File

@@ -0,0 +1,291 @@
use crate::{
errors::failure,
models::CommandResult,
orchestration::{
EnsureSwitchRequest, ExternalSwitch, VmCompatibilityReport, VmImportRequest,
VmInventoryRecord, VmProvider,
},
shell::ps_single_quoted,
};
use super::bridge::{RawVmRecord, StrictPowerShellHyperV};
impl VmProvider for StrictPowerShellHyperV {
fn list_vms(&self) -> CommandResult<Vec<VmInventoryRecord>> {
let raw: Vec<RawVmRecord> = self.run_json(
"hyperv.vm.list",
r#"
$ErrorActionPreference = 'Stop'
$items = @(Get-VM | Sort-Object Name | ForEach-Object {
$vm = $_
$adapters = @(Get-VMNetworkAdapter -VMName $vm.Name -ErrorAction SilentlyContinue)
$ips = @($adapters.IPAddresses | Where-Object { $_ -match '^\d+\.\d+\.\d+\.\d+$' })
$ips = @($ips | Where-Object { $_ -match '^\d+\.\d+\.\d+\.\d+$' } | Select-Object -Unique)
if ($ips.Count -eq 0) {
$ips = @($adapters | ForEach-Object {
$m = $_.MacAddress; if ($m.Length -ne 12) { return }
$fmt = ($m -replace '(.{2})(.{2})(.{2})(.{2})(.{2})(.{2})', '$1-$2-$3-$4-$5-$6').ToUpper()
Get-NetNeighbor -AddressFamily IPv4 -ErrorAction SilentlyContinue |
Where-Object { $_.LinkLayerAddress -ieq $fmt -and $_.IPAddress -notlike '169.254.*' -and $_.State -in @('Reachable','Stale','Delay','Probe','Permanent') } |
Select-Object -ExpandProperty IPAddress -First 1
} | Where-Object { $_ } | Select-Object -Unique)
}
$switches = @($adapters | ForEach-Object { $_.SwitchName } | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | Sort-Object -Unique)
$disks = @(Get-VMHardDiskDrive -VMName $vm.Name -ErrorAction SilentlyContinue | ForEach-Object { $_.Path })
$diskSizeBytes = [uint64]0
$diskFileSizeBytes = [uint64]0
foreach ($diskPath in $disks) {
$vhd = Get-VHD -Path $diskPath -ErrorAction SilentlyContinue
if ($vhd) {
$diskSizeBytes += [uint64]$vhd.Size
$diskFileSizeBytes += [uint64]$vhd.FileSize
}
}
[pscustomobject]@{
name = $vm.Name
state = $vm.State.ToString()
configurationLocation = $vm.ConfigurationLocation
path = $vm.Path
memoryAssignedBytes = [uint64]$vm.MemoryAssigned
processorCount = [uint32]$vm.ProcessorCount
uptimeSeconds = [uint64]$vm.Uptime.TotalSeconds
ipv4Addresses = $ips
hardDiskPaths = $disks
diskSizeBytes = $diskSizeBytes
diskFileSizeBytes = $diskFileSizeBytes
switchNames = $switches
}
})
ConvertTo-Json -InputObject $items -Compress -Depth 6
"#
.to_string(),
)?;
Ok(raw.into_iter().map(Into::into).collect())
}
fn get_vm(&self, name: &str) -> CommandResult<Option<VmInventoryRecord>> {
let raw: Option<RawVmRecord> = self.run_json(
"hyperv.vm.get",
format!(
r#"
$ErrorActionPreference = 'Stop'
$vmName = {name}
$vm = Get-VM -Name $vmName -ErrorAction SilentlyContinue
if (-not $vm) {{
[Console]::Out.Write('null')
exit 0
}}
$adapters = @(Get-VMNetworkAdapter -VMName $vm.Name -ErrorAction SilentlyContinue)
$ips = @($adapters.IPAddresses | Where-Object {{ $_ -match '^\d+\.\d+\.\d+\.\d+$' }})
$ips = @($ips | Where-Object {{ $_ -match '^\d+\.\d+\.\d+\.\d+$' }} | Select-Object -Unique)
if ($ips.Count -eq 0) {{
$ips = @($adapters | ForEach-Object {{
$m = $_.MacAddress; if ($m.Length -ne 12) {{ return }}
$fmt = ($m -replace '(.{{2}})(.{{2}})(.{{2}})(.{{2}})(.{{2}})(.{{2}})', '$1-$2-$3-$4-$5-$6').ToUpper()
Get-NetNeighbor -AddressFamily IPv4 -ErrorAction SilentlyContinue |
Where-Object {{ $_.LinkLayerAddress -ieq $fmt -and $_.IPAddress -notlike '169.254.*' -and $_.State -in @('Reachable','Stale','Delay','Probe','Permanent') }} |
Select-Object -ExpandProperty IPAddress -First 1
}} | Where-Object {{ $_ }} | Select-Object -Unique)
}}
$switches = @($adapters | ForEach-Object {{ $_.SwitchName }} | Where-Object {{ -not [string]::IsNullOrWhiteSpace($_) }} | Sort-Object -Unique)
$disks = @(Get-VMHardDiskDrive -VMName $vm.Name -ErrorAction SilentlyContinue | ForEach-Object {{ $_.Path }})
$diskSizeBytes = [uint64]0
$diskFileSizeBytes = [uint64]0
foreach ($diskPath in $disks) {{
$vhd = Get-VHD -Path $diskPath -ErrorAction SilentlyContinue
if ($vhd) {{
$diskSizeBytes += [uint64]$vhd.Size
$diskFileSizeBytes += [uint64]$vhd.FileSize
}}
}}
[pscustomobject]@{{
name = $vm.Name
state = $vm.State.ToString()
configurationLocation = $vm.ConfigurationLocation
path = $vm.Path
memoryAssignedBytes = [uint64]$vm.MemoryAssigned
processorCount = [uint32]$vm.ProcessorCount
uptimeSeconds = [uint64]$vm.Uptime.TotalSeconds
ipv4Addresses = $ips
hardDiskPaths = $disks
diskSizeBytes = $diskSizeBytes
diskFileSizeBytes = $diskFileSizeBytes
switchNames = $switches
}} | ConvertTo-Json -Compress -Depth 5
"#,
name = ps_single_quoted(name)
),
)?;
Ok(raw.map(Into::into))
}
fn compare_import(&self, request: &VmImportRequest) -> CommandResult<VmCompatibilityReport> {
self.run_json(
"hyperv.vm.compare-import",
format!(
r#"
$ErrorActionPreference = 'Stop'
$report = Compare-VM -Path {vmcx} -Copy -GenerateNewId -VirtualMachinePath {dest} -VhdDestinationPath (Join-Path {dest} 'Virtual Hard Disks') -ErrorAction Stop
$messages = @($report.Incompatibilities | ForEach-Object {{ $_.Message }})
[pscustomobject]@{{
compatible = $messages.Count -eq 0
incompatibilities = $messages
}} | ConvertTo-Json -Compress -Depth 6
"#,
vmcx = ps_single_quoted(&request.vmcx_path),
dest = ps_single_quoted(&request.destination_path)
),
)
}
fn import_vm(
&self,
request: &VmImportRequest,
) -> CommandResult<crate::orchestration::ImportedVm> {
self.run_json(
"hyperv.vm.import",
format!(
r#"
$ErrorActionPreference = 'Stop'
$report = Compare-VM -Path {vmcx} -Copy -GenerateNewId -VirtualMachinePath {dest} -VhdDestinationPath (Join-Path {dest} 'Virtual Hard Disks') -ErrorAction Stop
$vm = Import-VM -CompatibilityReport $report -ErrorAction Stop
[pscustomobject]@{{
name = $vm.Name
configurationLocation = $vm.ConfigurationLocation
}} | ConvertTo-Json -Compress -Depth 4
"#,
vmcx = ps_single_quoted(&request.vmcx_path),
dest = ps_single_quoted(&request.destination_path)
),
)
}
fn remove_vm(&self, name: &str) -> CommandResult<()> {
self.run_unit(
"hyperv.vm.remove",
format!(
"Remove-VM -Name {} -Force -ErrorAction Stop; [pscustomobject]@{{ ok = $true }} | ConvertTo-Json -Compress",
ps_single_quoted(name)
),
)
}
fn start_vm(&self, name: &str) -> CommandResult<()> {
self.run_unit(
"hyperv.vm.start",
format!(
"Start-VM -Name {} -ErrorAction Stop | Out-Null; [pscustomobject]@{{ ok = $true }} | ConvertTo-Json -Compress",
ps_single_quoted(name)
),
)
}
fn stop_vm(&self, name: &str, turn_off: bool) -> CommandResult<()> {
let flag = if turn_off { " -TurnOff" } else { "" };
self.run_unit(
"hyperv.vm.stop",
format!(
"Stop-VM -Name {}{flag} -Force -ErrorAction Stop | Out-Null; [pscustomobject]@{{ ok = $true }} | ConvertTo-Json -Compress",
ps_single_quoted(name)
),
)
}
fn connect_network_adapter(&self, vm_name: &str, switch_name: &str) -> CommandResult<()> {
self.run_unit(
"hyperv.vm.connect-network-adapter",
format!(
"Connect-VMNetworkAdapter -VMName {} -SwitchName {} -ErrorAction Stop; [pscustomobject]@{{ ok = $true }} | ConvertTo-Json -Compress",
ps_single_quoted(vm_name),
ps_single_quoted(switch_name)
),
)
}
fn ensure_external_switch(
&self,
request: &EnsureSwitchRequest,
) -> CommandResult<ExternalSwitch> {
self.run_json(
"hyperv.switch.ensure-external",
format!(
r#"
$ErrorActionPreference = 'Stop'
$switchName = {switch_name}
$adapterName = {adapter_name}
$adapter = Get-NetAdapter -Name $adapterName -ErrorAction Stop
$switch = Get-VMSwitch -SwitchType External -ErrorAction SilentlyContinue |
Where-Object {{ $_.NetAdapterInterfaceDescription -eq $adapter.InterfaceDescription }} |
Select-Object -First 1
if (-not $switch) {{
$switch = New-VMSwitch -Name $switchName -NetAdapterName $adapterName -AllowManagementOS $true -ErrorAction Stop
}}
[pscustomobject]@{{
name = $switch.Name
netAdapterInterfaceDescription = $switch.NetAdapterInterfaceDescription
}} | ConvertTo-Json -Compress -Depth 4
"#,
switch_name = ps_single_quoted(&request.switch_name),
adapter_name = ps_single_quoted(&request.adapter_name)
),
)
}
fn resize_first_vhd(&self, vm_name: &str, size_bytes: u64) -> CommandResult<()> {
self.run_unit(
"hyperv.vhd.resize-first",
format!(
r#"
$ErrorActionPreference = 'Stop'
$drive = Get-VMHardDiskDrive -VMName {vm_name} | Select-Object -First 1
if (-not $drive) {{ throw 'VM has no hard disk drive' }}
Resize-VHD -Path $drive.Path -SizeBytes {size_bytes} -ErrorAction Stop
[pscustomobject]@{{ ok = $true }} | ConvertTo-Json -Compress
"#,
vm_name = ps_single_quoted(vm_name)
),
)
}
fn set_first_boot_disk(&self, vm_name: &str) -> CommandResult<()> {
self.run_unit(
"hyperv.vm.set-first-boot-disk",
format!(
r#"
$ErrorActionPreference = 'Stop'
$drive = Get-VMHardDiskDrive -VMName {vm_name} | Select-Object -First 1
if (-not $drive) {{ throw 'VM has no hard disk drive' }}
Set-VMFirmware -VMName {vm_name} -FirstBootDevice $drive -ErrorAction Stop
[pscustomobject]@{{ ok = $true }} | ConvertTo-Json -Compress
"#,
vm_name = ps_single_quoted(vm_name)
),
)
}
fn set_startup_memory(&self, vm_name: &str, bytes: u64) -> CommandResult<()> {
if bytes == 0 {
return Err(failure("VM memory must be greater than zero"));
}
self.run_unit(
"hyperv.vm.set-startup-memory",
format!(
"Set-VMMemory -VMName {} -StartupBytes {bytes} -ErrorAction Stop; [pscustomobject]@{{ ok = $true }} | ConvertTo-Json -Compress",
ps_single_quoted(vm_name)
),
)
}
fn set_processor_count(&self, vm_name: &str, count: u32) -> CommandResult<()> {
if count == 0 {
return Err(failure("VM processor count must be greater than zero"));
}
self.run_unit(
"hyperv.vm.set-processor-count",
format!(
"Set-VMProcessor -VMName {} -Count {count} -ErrorAction Stop; [pscustomobject]@{{ ok = $true }} | ConvertTo-Json -Compress",
ps_single_quoted(vm_name)
),
)
}
}

View File

@@ -0,0 +1,17 @@
//! Hyper-V initial setup orchestration: imports the VM, waits for guest connectivity,
//! optionally applies static networking, and runs guest bootstrap.
mod orchestrator;
mod player_address;
mod request;
mod wait;
#[cfg(test)]
mod tests;
pub use orchestrator::HyperVInitialSetupOrchestrator;
pub use player_address::detect_player_address_candidates;
pub use request::{
GuestNetworkPlan, HyperVInitialSetupRequest, HyperVInitialSetupResult, PlayerAddressCandidates,
};
pub use wait::wait_for_vm_ipv4;

View File

@@ -0,0 +1,108 @@
use crate::{
models::CommandResult,
orchestration::{
emit_hyperv_event, GuestBootstrapOrchestrator, GuestBootstrapProvider, GuestProvider,
HyperVVmSetupOrchestrator, OperationSink, StepAction, StepDomain, VmProvider,
},
};
use super::request::{GuestNetworkPlan, HyperVInitialSetupRequest, HyperVInitialSetupResult};
use super::wait::wait_for_vm_ipv4;
/// Orchestrates full first-time setup across host, VM, guest, and bootstrap providers.
pub struct HyperVInitialSetupOrchestrator<H, V, G, B> {
host: H,
vm: V,
guest: G,
bootstrap: B,
}
impl<H, V, G, B> HyperVInitialSetupOrchestrator<H, V, G, B>
where
H: crate::orchestration::HostProvider,
V: VmProvider,
G: GuestProvider,
B: GuestBootstrapProvider,
{
/// Creates an initial setup orchestrator from its provider boundaries.
pub fn new(host: H, vm: V, guest: G, bootstrap: B) -> Self {
Self {
host,
vm,
guest,
bootstrap,
}
}
/// Imports and starts the VM, waits for SSH, applies network settings, and bootstraps k3s.
pub fn run(
&self,
request: &HyperVInitialSetupRequest,
sink: &mut impl OperationSink,
) -> CommandResult<HyperVInitialSetupResult> {
request.validate()?;
let vm_setup = HyperVVmSetupOrchestrator::new(&self.host, &self.vm);
let vm = vm_setup.import_and_prepare_vm(&request.vm, sink)?;
emit_hyperv_event(
sink,
"hyperv.wait-for-ip",
"Waiting for VM IPv4 address.",
StepDomain::HyperV,
StepAction::Detect,
);
let first_ip = wait_for_vm_ipv4(&self.vm, &vm.vm_name, request.vm_ip_timeout_seconds)?;
emit_hyperv_event(
sink,
"guest.wait-for-ssh",
"Waiting for guest SSH.",
StepDomain::Guest,
StepAction::Check,
);
self.guest
.wait_for_ssh(&first_ip, request.ssh_timeout_seconds)?;
let guest_ip = match &request.guest_network {
GuestNetworkPlan::Dhcp => first_ip,
GuestNetworkPlan::Static(config) => {
emit_hyperv_event(
sink,
"guest.apply-static-network",
"Applying static guest network.",
StepDomain::Guest,
StepAction::Configure,
);
self.guest.apply_static_network(&first_ip, config)?;
let static_ip = config
.address_cidr
.split_once('/')
.map(|(ip, _)| ip.to_string())
.unwrap_or_else(|| config.address_cidr.clone());
self.guest
.wait_for_ssh(&static_ip, request.ssh_timeout_seconds)?;
static_ip
}
};
emit_hyperv_event(
sink,
"guest.write-player-settings",
"Writing player-facing server address.",
StepDomain::Guest,
StepAction::Configure,
);
self.guest
.write_player_settings(&guest_ip, &request.guest_bootstrap.player_ip)?;
let bootstrap =
GuestBootstrapOrchestrator::new(&self.bootstrap).run(&request.guest_bootstrap, sink)?;
Ok(HyperVInitialSetupResult {
vm,
guest_ip,
bootstrap,
})
}
}

View File

@@ -0,0 +1,25 @@
use crate::{
models::CommandResult,
orchestration::{emit_hyperv_event, GuestProvider, OperationSink, StepAction, StepDomain},
};
use super::request::PlayerAddressCandidates;
/// Detects LAN and optional public player-facing address candidates.
pub fn detect_player_address_candidates(
guest: &impl GuestProvider,
guest_ip: &str,
sink: &mut impl OperationSink,
) -> CommandResult<PlayerAddressCandidates> {
emit_hyperv_event(
sink,
"guest.detect-public-ip",
"Detecting public player-facing IP.",
StepDomain::Guest,
StepAction::Detect,
);
Ok(PlayerAddressCandidates {
guest_lan_ip: guest_ip.to_string(),
public_ip: guest.detect_public_ip(guest_ip)?,
})
}

View File

@@ -0,0 +1,71 @@
use serde::Serialize;
use crate::{
errors::failure,
models::CommandResult,
orchestration::{
GuestBootstrapPlan, GuestBootstrapResult, GuestNetworkConfig, HyperVVmSetupRequest,
HyperVVmSetupResult,
},
};
/// Guest network mode applied during initial setup.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum GuestNetworkPlan {
/// Keep the VM on DHCP after first boot.
Dhcp,
/// Reconfigure the guest to use a static address.
Static(GuestNetworkConfig),
}
/// Full request for creating the Hyper-V VM and bootstrapping the guest.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HyperVInitialSetupRequest {
/// Host-side Hyper-V import and VM preparation request.
pub vm: HyperVVmSetupRequest,
/// Network configuration to apply after the first DHCP boot.
pub guest_network: GuestNetworkPlan,
/// Guest bootstrap plan used after SSH becomes available.
pub guest_bootstrap: GuestBootstrapPlan,
/// Seconds to wait for Hyper-V to report a guest IPv4 address.
pub vm_ip_timeout_seconds: u64,
/// Seconds to wait for SSH reachability.
pub ssh_timeout_seconds: u64,
}
impl HyperVInitialSetupRequest {
/// Validates the complete initial setup request.
pub fn validate(&self) -> CommandResult<()> {
self.vm.validate()?;
self.guest_bootstrap.validate()?;
if self.vm_ip_timeout_seconds == 0 {
return Err(failure("VM IP timeout must be greater than zero"));
}
if self.ssh_timeout_seconds == 0 {
return Err(failure("SSH timeout must be greater than zero"));
}
Ok(())
}
}
/// Result of a completed Hyper-V initial setup flow.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct HyperVInitialSetupResult {
/// Host-side VM setup result.
pub vm: HyperVVmSetupResult,
/// Final guest IP used for bootstrap.
pub guest_ip: String,
/// Guest bootstrap result.
pub bootstrap: GuestBootstrapResult,
}
/// Suggested addresses for player-facing server configuration.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct PlayerAddressCandidates {
/// LAN address currently assigned to the guest.
pub guest_lan_ip: String,
/// Public address detected from inside the guest, when reachable.
pub public_ip: Option<String>,
}

View File

@@ -0,0 +1,2 @@
mod mock_providers;
mod orchestrator_tests;

View File

@@ -0,0 +1,244 @@
use std::{cell::RefCell, rc::Rc};
use crate::{
models::CommandResult,
orchestration::{
CreatedWorld, DriveCandidate, ExternalSwitch, GuestBootstrapProvider, GuestNetworkConfig,
GuestProvider, HostProvider, HostReadiness, ImportedVm, NetworkAdapterCandidate,
VmCompatibilityReport, VmImportRequest, VmInventoryRecord, VmPowerState, VmProvider,
WorldManifestRequest,
},
};
#[derive(Default)]
pub(super) struct MockHost;
impl HostProvider for MockHost {
fn readiness(&self) -> CommandResult<HostReadiness> {
Ok(HostReadiness {
elevated: true,
hyperv_available: true,
vmms_running: true,
virtualization_firmware_enabled: Some(true),
total_physical_memory_bytes: 64 * 1024 * 1024 * 1024,
available_physical_memory_bytes: 48 * 1024 * 1024 * 1024,
logical_processor_count: 16,
})
}
fn drives_with_minimum_free_space(
&self,
_minimum_free_bytes: u64,
) -> CommandResult<Vec<DriveCandidate>> {
Ok(vec![])
}
fn active_physical_adapters(&self) -> CommandResult<Vec<NetworkAdapterCandidate>> {
Ok(vec![])
}
}
#[derive(Default)]
pub(super) struct MockVm {
pub calls: Rc<RefCell<Vec<&'static str>>>,
pub get_vm_calls: RefCell<usize>,
}
impl VmProvider for MockVm {
fn get_vm(&self, _name: &str) -> CommandResult<Option<VmInventoryRecord>> {
self.calls.borrow_mut().push("get_vm");
let mut get_vm_calls = self.get_vm_calls.borrow_mut();
*get_vm_calls += 1;
if *get_vm_calls == 1 {
return Ok(None);
}
Ok(Some(VmInventoryRecord {
name: "test-vm".to_string(),
state: VmPowerState::Running,
raw_state: "Running".to_string(),
configuration_location: String::new(),
path: String::new(),
memory_assigned_bytes: 0,
processor_count: 0,
uptime_seconds: 0,
ipv4_addresses: vec!["10.0.0.4".to_string()],
hard_disk_paths: vec![],
disk_size_bytes: 0,
disk_file_size_bytes: 0,
switch_names: vec![],
}))
}
fn compare_import(&self, _request: &VmImportRequest) -> CommandResult<VmCompatibilityReport> {
self.calls.borrow_mut().push("compare_import");
Ok(VmCompatibilityReport {
compatible: true,
incompatibilities: vec![],
})
}
fn import_vm(&self, _request: &VmImportRequest) -> CommandResult<ImportedVm> {
self.calls.borrow_mut().push("import_vm");
Ok(ImportedVm {
name: "test-vm".to_string(),
configuration_location: String::new(),
})
}
fn remove_vm(&self, _name: &str) -> CommandResult<()> {
Ok(())
}
fn start_vm(&self, _name: &str) -> CommandResult<()> {
self.calls.borrow_mut().push("start_vm");
Ok(())
}
fn stop_vm(&self, _name: &str, _turn_off: bool) -> CommandResult<()> {
Ok(())
}
fn connect_network_adapter(&self, _vm_name: &str, _switch_name: &str) -> CommandResult<()> {
self.calls.borrow_mut().push("connect_network_adapter");
Ok(())
}
fn ensure_external_switch(
&self,
_request: &crate::orchestration::EnsureSwitchRequest,
) -> CommandResult<ExternalSwitch> {
self.calls.borrow_mut().push("ensure_external_switch");
Ok(ExternalSwitch {
name: "switch".to_string(),
net_adapter_interface_description: "adapter".to_string(),
})
}
fn resize_first_vhd(&self, _vm_name: &str, _size_bytes: u64) -> CommandResult<()> {
self.calls.borrow_mut().push("resize_first_vhd");
Ok(())
}
fn set_first_boot_disk(&self, _vm_name: &str) -> CommandResult<()> {
self.calls.borrow_mut().push("set_first_boot_disk");
Ok(())
}
fn set_startup_memory(&self, _vm_name: &str, _bytes: u64) -> CommandResult<()> {
self.calls.borrow_mut().push("set_startup_memory");
Ok(())
}
fn set_processor_count(&self, _vm_name: &str, _count: u32) -> CommandResult<()> {
self.calls.borrow_mut().push("set_processor_count");
Ok(())
}
}
#[derive(Default)]
pub(super) struct MockGuest {
pub calls: Rc<RefCell<Vec<&'static str>>>,
}
impl GuestProvider for MockGuest {
fn wait_for_ssh(&self, _ip: &str, _timeout_seconds: u64) -> CommandResult<()> {
self.calls.borrow_mut().push("wait_for_ssh");
Ok(())
}
fn upload_bytes(
&self,
_ip: &str,
_remote_path: &str,
_bytes: &[u8],
_mode: u32,
) -> CommandResult<()> {
Ok(())
}
fn write_player_settings(&self, _ip: &str, _player_ip: &str) -> CommandResult<()> {
self.calls.borrow_mut().push("write_player_settings");
Ok(())
}
fn apply_static_network(&self, _ip: &str, _config: &GuestNetworkConfig) -> CommandResult<()> {
self.calls.borrow_mut().push("apply_static_network");
Ok(())
}
fn detect_public_ip(&self, _ip: &str) -> CommandResult<Option<String>> {
self.calls.borrow_mut().push("detect_public_ip");
Ok(Some("203.0.113.10".to_string()))
}
}
#[derive(Default)]
pub(super) struct MockBootstrap {
pub calls: Rc<RefCell<Vec<&'static str>>>,
}
impl GuestBootstrapProvider for MockBootstrap {
fn validate_and_resize_root_disk(&self) -> CommandResult<()> {
self.calls.borrow_mut().push("disk");
Ok(())
}
fn ensure_server_payload(&self) -> CommandResult<()> {
self.calls.borrow_mut().push("payload");
Ok(())
}
fn start_k3s_and_wait(&self) -> CommandResult<()> {
self.calls.borrow_mut().push("k3s");
Ok(())
}
fn import_core_images(&self) -> CommandResult<()> {
self.calls.borrow_mut().push("core_images");
Ok(())
}
fn scale_core_deployments(&self) -> CommandResult<()> {
self.calls.borrow_mut().push("core_scale");
Ok(())
}
fn update_operator_crds(&self) -> CommandResult<()> {
self.calls.borrow_mut().push("operator_crds");
Ok(())
}
fn patch_operator_images(&self) -> CommandResult<()> {
self.calls.borrow_mut().push("operator_images");
Ok(())
}
fn scale_operator_deployments(&self) -> CommandResult<()> {
self.calls.borrow_mut().push("operator_scale");
Ok(())
}
fn install_battlegroup_helper(&self) -> CommandResult<()> {
self.calls.borrow_mut().push("helper");
Ok(())
}
fn create_world(&self, request: &WorldManifestRequest) -> CommandResult<CreatedWorld> {
self.calls.borrow_mut().push("world");
Ok(CreatedWorld {
namespace: format!("funcom-seabass-{}", request.world_unique_name),
battlegroup_name: request.world_unique_name.clone(),
})
}
fn import_battlegroup_images(&self) -> CommandResult<()> {
self.calls.borrow_mut().push("bg_images");
Ok(())
}
fn patch_battlegroup_images(
&self,
_namespace: &str,
_battlegroup_name: &str,
) -> CommandResult<()> {
self.calls.borrow_mut().push("bg_patch");
Ok(())
}
fn apply_default_user_settings(
&self,
_namespace: &str,
_battlegroup_name: &str,
) -> CommandResult<()> {
self.calls.borrow_mut().push("defaults");
Ok(())
}
}

View File

@@ -0,0 +1,128 @@
use std::{
cell::RefCell,
fs,
path::PathBuf,
rc::Rc,
time::{SystemTime, UNIX_EPOCH},
};
use crate::orchestration::{
GuestBootstrapPlan, HyperVVmSetupRequest, MemoryProfile, VecOperationSink,
DEFAULT_VM_DISK_BYTES,
};
use super::super::{
detect_player_address_candidates, GuestNetworkPlan, HyperVInitialSetupOrchestrator,
HyperVInitialSetupRequest,
};
use super::mock_providers::{MockBootstrap, MockGuest, MockHost, MockVm};
#[test]
fn orchestrates_hyperv_initial_setup_across_provider_boundaries() {
let temp = test_dir();
let install = temp.join("server");
let vm_dir = install.join("Virtual Machines");
fs::create_dir_all(&vm_dir).unwrap();
fs::write(vm_dir.join("server.vmcx"), "").unwrap();
let guest_calls = Rc::new(RefCell::new(Vec::new()));
let bootstrap_calls = Rc::new(RefCell::new(Vec::new()));
let orchestrator = HyperVInitialSetupOrchestrator::new(
MockHost,
MockVm::default(),
MockGuest {
calls: guest_calls.clone(),
},
MockBootstrap {
calls: bootstrap_calls.clone(),
},
);
let mut sink = VecOperationSink::default();
let result = orchestrator
.run(
&HyperVInitialSetupRequest {
vm: HyperVVmSetupRequest {
install_path: install,
vm_name: "test-vm".to_string(),
destination_path: temp.join("vm"),
switch_name: "switch".to_string(),
adapter_name: "Ethernet".to_string(),
memory: MemoryProfile::Sietch20Gb,
processor_count: 4,
replace_existing_vm: false,
clear_destination: false,
disk_size_bytes: DEFAULT_VM_DISK_BYTES,
},
guest_network: GuestNetworkPlan::Dhcp,
guest_bootstrap: GuestBootstrapPlan {
player_ip: "10.0.0.4".to_string(),
world_name: "Adain".to_string(),
world_region: "Europe".to_string(),
self_host_token: "token".to_string(),
host_id: "host123".to_string(),
world_suffix: "abcdef".to_string(),
},
vm_ip_timeout_seconds: 2,
ssh_timeout_seconds: 2,
},
&mut sink,
)
.unwrap();
assert_eq!(result.guest_ip, "10.0.0.4");
assert_eq!(
guest_calls.borrow().as_slice(),
&["wait_for_ssh", "write_player_settings"]
);
assert_eq!(
bootstrap_calls.borrow().as_slice(),
&[
"disk",
"payload",
"k3s",
"core_images",
"core_scale",
"operator_crds",
"operator_images",
"operator_scale",
"helper",
"world",
"bg_images",
"bg_patch",
"defaults",
]
);
assert!(sink
.events
.iter()
.any(|event| event.step_id == "guest.write-player-settings"));
}
#[test]
fn detects_player_address_candidates_as_a_distinct_setup_step() {
let calls = Rc::new(RefCell::new(Vec::new()));
let guest = MockGuest {
calls: calls.clone(),
};
let mut sink = VecOperationSink::default();
let candidates = detect_player_address_candidates(&guest, "10.0.0.4", &mut sink).unwrap();
assert_eq!(candidates.guest_lan_ip, "10.0.0.4");
assert_eq!(candidates.public_ip, Some("203.0.113.10".to_string()));
assert_eq!(calls.borrow().as_slice(), &["detect_public_ip"]);
assert!(sink
.events
.iter()
.any(|event| event.step_id == "guest.detect-public-ip"));
}
fn test_dir() -> PathBuf {
let mut path = std::env::temp_dir();
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
path.push(format!("dune-manager-initial-setup-test-{nanos}"));
fs::create_dir_all(&path).unwrap();
path
}

View File

@@ -0,0 +1,34 @@
use std::{thread, time::Duration};
use crate::{
errors::failure,
models::CommandResult,
orchestration::{VmPowerState, VmProvider},
};
/// Waits for a running Hyper-V VM to report a non-link-local IPv4 address.
pub fn wait_for_vm_ipv4(
provider: &impl VmProvider,
vm_name: &str,
timeout_seconds: u64,
) -> CommandResult<String> {
let mut elapsed = 0;
while elapsed <= timeout_seconds {
if let Some(vm) = provider.get_vm(vm_name)? {
if vm.state == VmPowerState::Running {
if let Some(ip) = vm
.ipv4_addresses
.iter()
.find(|ip| !ip.starts_with("169.254.") && !ip.trim().is_empty())
{
return Ok(ip.clone());
}
}
}
thread::sleep(Duration::from_secs(2));
elapsed += 2;
}
Err(failure(format!(
"VM {vm_name} did not report an IPv4 address within {timeout_seconds} seconds"
)))
}

View File

@@ -0,0 +1,141 @@
use crate::{
models::CommandResult,
orchestration::{emit_hyperv_event, OperationSink, StepAction, StepDomain, VmProvider},
};
/// Starts and stops an existing Hyper-V VM.
pub struct HyperVVmLifecycleOrchestrator<V> {
vm: V,
}
impl<V> HyperVVmLifecycleOrchestrator<V>
where
V: VmProvider,
{
/// Creates a lifecycle orchestrator around a VM provider.
pub fn new(vm: V) -> Self {
Self { vm }
}
/// Starts the named VM.
pub fn start(&self, vm_name: &str, sink: &mut impl OperationSink) -> CommandResult<()> {
emit_hyperv_event(
sink,
"hyperv.lifecycle.start-vm",
"Starting VM.",
StepDomain::HyperV,
StepAction::Start,
);
self.vm.start_vm(vm_name)
}
/// Turns off the named VM.
pub fn stop(&self, vm_name: &str, sink: &mut impl OperationSink) -> CommandResult<()> {
emit_hyperv_event(
sink,
"hyperv.lifecycle.stop-vm",
"Stopping VM.",
StepDomain::HyperV,
StepAction::Stop,
);
self.vm.stop_vm(vm_name, true)
}
}
#[cfg(test)]
mod tests {
use std::{cell::RefCell, rc::Rc};
use crate::orchestration::{
EnsureSwitchRequest, ExternalSwitch, ImportedVm, VecOperationSink, VmCompatibilityReport,
VmImportRequest, VmInventoryRecord,
};
use super::*;
#[derive(Default)]
struct MockVm {
calls: Rc<RefCell<Vec<String>>>,
}
impl VmProvider for MockVm {
fn get_vm(&self, _name: &str) -> CommandResult<Option<VmInventoryRecord>> {
Ok(None)
}
fn compare_import(
&self,
_request: &VmImportRequest,
) -> CommandResult<VmCompatibilityReport> {
unreachable!("lifecycle does not import")
}
fn import_vm(&self, _request: &VmImportRequest) -> CommandResult<ImportedVm> {
unreachable!("lifecycle does not import")
}
fn remove_vm(&self, _name: &str) -> CommandResult<()> {
Ok(())
}
fn start_vm(&self, name: &str) -> CommandResult<()> {
self.calls.borrow_mut().push(format!("start:{name}"));
Ok(())
}
fn stop_vm(&self, name: &str, turn_off: bool) -> CommandResult<()> {
self.calls
.borrow_mut()
.push(format!("stop:{name}:{turn_off}"));
Ok(())
}
fn connect_network_adapter(&self, _vm_name: &str, _switch_name: &str) -> CommandResult<()> {
Ok(())
}
fn ensure_external_switch(
&self,
_request: &EnsureSwitchRequest,
) -> CommandResult<ExternalSwitch> {
unreachable!("lifecycle does not create switches")
}
fn resize_first_vhd(&self, _vm_name: &str, _size_bytes: u64) -> CommandResult<()> {
Ok(())
}
fn set_first_boot_disk(&self, _vm_name: &str) -> CommandResult<()> {
Ok(())
}
fn set_startup_memory(&self, _vm_name: &str, _bytes: u64) -> CommandResult<()> {
Ok(())
}
fn set_processor_count(&self, _vm_name: &str, _count: u32) -> CommandResult<()> {
Ok(())
}
}
#[test]
fn lifecycle_orchestrator_starts_and_stops_vm() {
let calls = Rc::new(RefCell::new(Vec::new()));
let orchestrator = HyperVVmLifecycleOrchestrator::new(MockVm {
calls: calls.clone(),
});
let mut sink = VecOperationSink::default();
orchestrator.start("test-vm", &mut sink).unwrap();
orchestrator.stop("test-vm", &mut sink).unwrap();
assert_eq!(
calls.borrow().as_slice(),
&["start:test-vm", "stop:test-vm:true"]
);
assert!(sink
.events
.iter()
.any(|event| event.step_id == "hyperv.lifecycle.stop-vm"));
}
}

View File

@@ -0,0 +1,54 @@
use serde::Serialize;
use crate::orchestration::{ProviderKind, StepAction, StepDomain};
/// Structured event emitted while an orchestration flow is running.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct OrchestrationEvent {
/// Stable step identifier.
pub step_id: &'static str,
/// User-facing message for the step.
pub message: String,
/// Operational domain the step belongs to.
pub domain: StepDomain,
/// Kind of action being performed.
pub action: StepAction,
/// Provider boundary responsible for the step.
pub provider: ProviderKind,
}
/// Receives orchestration progress events.
pub trait OperationSink {
/// Emits a single orchestration event.
fn emit(&mut self, event: OrchestrationEvent);
}
/// Operation sink that stores all events in memory.
#[derive(Default)]
pub struct VecOperationSink {
/// Events emitted so far.
pub events: Vec<OrchestrationEvent>,
}
impl OperationSink for VecOperationSink {
fn emit(&mut self, event: OrchestrationEvent) {
self.events.push(event);
}
}
pub(crate) fn emit_hyperv_event(
sink: &mut impl OperationSink,
step_id: &'static str,
message: impl Into<String>,
domain: StepDomain,
action: StepAction,
) {
sink.emit(OrchestrationEvent {
step_id,
message: message.into(),
domain,
action,
provider: ProviderKind::HyperV,
});
}

View File

@@ -0,0 +1,14 @@
//! Hyper-V VM import and preparation orchestration.
mod events;
mod models;
mod orchestrator;
mod vm_import;
#[cfg(test)]
mod tests;
pub(crate) use events::emit_hyperv_event;
pub use events::{OperationSink, OrchestrationEvent, VecOperationSink};
pub use models::{HyperVVmSetupRequest, HyperVVmSetupResult, MemoryProfile, DEFAULT_VM_DISK_BYTES};
pub use orchestrator::HyperVVmSetupOrchestrator;

View File

@@ -0,0 +1,147 @@
use std::path::{Path, PathBuf};
use serde::Serialize;
use crate::{errors::failure, models::CommandResult};
/// Default virtual disk size used when importing the vendor VM.
pub const DEFAULT_VM_DISK_BYTES: u64 = 100 * 1024 * 1024 * 1024;
/// Memory presets for the imported dedicated-server VM.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum MemoryProfile {
/// 20 GiB VM profile for a small Sietch-style server.
Sietch20Gb,
/// 30 GiB VM profile for Sietch plus story content.
SietchStory30Gb,
/// 40 GiB VM profile for Sietch, story, and Deep Desert content.
SietchStoryDeepDesert40Gb,
/// Caller-provided startup memory in bytes.
CustomBytes(u64),
}
impl MemoryProfile {
/// Returns the configured memory size in bytes.
pub fn bytes(self) -> u64 {
match self {
Self::Sietch20Gb => 20 * 1024 * 1024 * 1024,
Self::SietchStory30Gb => 30 * 1024 * 1024 * 1024,
Self::SietchStoryDeepDesert40Gb => 40 * 1024 * 1024 * 1024,
Self::CustomBytes(bytes) => bytes,
}
}
}
/// Host-side request for importing and preparing the Hyper-V VM.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HyperVVmSetupRequest {
/// Server package folder containing the vendor VM files.
pub install_path: PathBuf,
/// Hyper-V VM name to create or replace.
pub vm_name: String,
/// Destination folder where VM files are copied.
pub destination_path: PathBuf,
/// External switch name to create or reuse.
pub switch_name: String,
/// Host network adapter backing the external switch.
pub adapter_name: String,
/// Startup memory profile for the VM.
pub memory: MemoryProfile,
/// Virtual processor count assigned during initial setup.
pub processor_count: u32,
/// Whether an existing VM registration with the same name may be removed.
pub replace_existing_vm: bool,
/// Whether an existing destination folder may be deleted first.
pub clear_destination: bool,
/// Final virtual disk size in bytes.
pub disk_size_bytes: u64,
}
impl HyperVVmSetupRequest {
/// Validates required paths, names, memory, and disk settings.
pub fn validate(&self) -> CommandResult<()> {
if self.vm_name.trim().is_empty() {
return Err(failure("VM name is required"));
}
if self.switch_name.trim().is_empty() {
return Err(failure("Hyper-V switch name is required"));
}
if self.adapter_name.trim().is_empty() {
return Err(failure("Host network adapter name is required"));
}
if self.memory.bytes() == 0 {
return Err(failure("VM memory must be greater than zero"));
}
if self.processor_count == 0 {
return Err(failure("VM processor count must be greater than zero"));
}
if self.disk_size_bytes == 0 {
return Err(failure("VM disk size must be greater than zero"));
}
validate_existing_dir(&self.install_path, "server install path")?;
validate_destination_parent(&self.destination_path)?;
Ok(())
}
}
impl Default for HyperVVmSetupRequest {
fn default() -> Self {
Self {
install_path: PathBuf::new(),
vm_name: String::new(),
destination_path: PathBuf::new(),
switch_name: "DuneAwakeningServerSwitch".to_string(),
adapter_name: String::new(),
memory: MemoryProfile::Sietch20Gb,
processor_count: 4,
replace_existing_vm: false,
clear_destination: false,
disk_size_bytes: DEFAULT_VM_DISK_BYTES,
}
}
}
/// Host-side result of importing and preparing the Hyper-V VM.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct HyperVVmSetupResult {
/// Name of the imported VM.
pub vm_name: String,
/// Destination path used for VM files.
pub destination_path: String,
/// External switch connected to the VM.
pub switch_name: String,
/// Vendor VM configuration file that was imported.
pub vmcx_path: String,
}
fn validate_existing_dir(path: &Path, label: &str) -> CommandResult<()> {
if !path.exists() {
return Err(failure(format!(
"{label} does not exist: {}",
path.display()
)));
}
if !path.is_dir() {
return Err(failure(format!(
"{label} is not a directory: {}",
path.display()
)));
}
Ok(())
}
fn validate_destination_parent(path: &Path) -> CommandResult<()> {
let parent = path
.parent()
.filter(|value| !value.as_os_str().is_empty())
.ok_or_else(|| failure("VM destination must have a parent directory"))?;
if !parent.exists() {
return Err(failure(format!(
"VM destination parent does not exist: {}",
parent.display()
)));
}
Ok(())
}

View File

@@ -0,0 +1,222 @@
use crate::{
errors::failure,
models::CommandResult,
orchestration::{
EnsureSwitchRequest, HostProvider, StepAction, StepDomain, VmImportRequest, VmPowerState,
VmProvider,
},
};
use super::events::{emit_hyperv_event, OperationSink};
use super::models::{HyperVVmSetupRequest, HyperVVmSetupResult};
use super::vm_import::{clear_destination_dir, destination_has_vm_artifacts, single_vmcx};
/// Orchestrates host-side VM import, networking, disk, memory, and startup.
pub struct HyperVVmSetupOrchestrator<H, V> {
host: H,
vm: V,
}
impl<H, V> HyperVVmSetupOrchestrator<H, V>
where
H: HostProvider,
V: VmProvider,
{
/// Creates a VM setup orchestrator from host and VM providers.
pub fn new(host: H, vm: V) -> Self {
Self { host, vm }
}
/// Imports the packaged VM and prepares it for guest bootstrap.
pub fn import_and_prepare_vm(
&self,
request: &HyperVVmSetupRequest,
sink: &mut impl OperationSink,
) -> CommandResult<HyperVVmSetupResult> {
request.validate()?;
emit_hyperv_event(
sink,
"host.readiness",
"Checking host virtualization readiness.",
StepDomain::Host,
StepAction::Check,
);
let readiness = self.host.readiness()?;
if !readiness.elevated {
return Err(failure("Hyper-V setup requires elevated host privileges"));
}
if !readiness.hyperv_available {
return Err(failure("Hyper-V is not available on this host"));
}
if !readiness.vmms_running {
return Err(failure("Hyper-V vmms service is not running"));
}
emit_hyperv_event(
sink,
"package.locate-vmcx",
"Locating packaged VM configuration.",
StepDomain::Files,
StepAction::Detect,
);
let vmcx_path = single_vmcx(&request.install_path)?;
emit_hyperv_event(
sink,
"hyperv.detect-existing-vm",
"Checking for an existing VM.",
StepDomain::HyperV,
StepAction::Detect,
);
if let Some(existing) = self.vm.get_vm(&request.vm_name)? {
if !request.replace_existing_vm {
return Err(failure(format!(
"VM '{}' already exists and replacement was not requested",
existing.name
)));
}
if existing.state == VmPowerState::Running {
emit_hyperv_event(
sink,
"hyperv.stop-existing-vm",
"Stopping existing VM before replacement.",
StepDomain::HyperV,
StepAction::Stop,
);
self.vm.stop_vm(&request.vm_name, true)?;
}
emit_hyperv_event(
sink,
"hyperv.remove-existing-vm",
"Removing existing VM registration.",
StepDomain::HyperV,
StepAction::Stop,
);
self.vm.remove_vm(&request.vm_name)?;
}
if destination_has_vm_artifacts(&request.destination_path) {
if !request.clear_destination {
return Err(failure(format!(
"VM destination already contains VM files: {}",
request.destination_path.display()
)));
}
emit_hyperv_event(
sink,
"host.clear-vm-destination",
"Clearing VM destination folder.",
StepDomain::Files,
StepAction::Configure,
);
clear_destination_dir(&request.destination_path)?;
}
let import_request = VmImportRequest {
vmcx_path: vmcx_path.clone(),
destination_path: request.destination_path.to_string_lossy().to_string(),
};
emit_hyperv_event(
sink,
"hyperv.compare-vm",
"Checking VM import compatibility.",
StepDomain::HyperV,
StepAction::Check,
);
let compatibility = self.vm.compare_import(&import_request)?;
if !compatibility.compatible {
return Err(failure(format!(
"VM import compatibility failed: {}",
compatibility.incompatibilities.join("; ")
)));
}
emit_hyperv_event(
sink,
"hyperv.import-vm",
"Importing VM.",
StepDomain::HyperV,
StepAction::Import,
);
let imported = self.vm.import_vm(&import_request)?;
emit_hyperv_event(
sink,
"hyperv.ensure-switch",
"Preparing Hyper-V external switch.",
StepDomain::HyperV,
StepAction::Create,
);
let switch = self.vm.ensure_external_switch(&EnsureSwitchRequest {
switch_name: request.switch_name.clone(),
adapter_name: request.adapter_name.clone(),
})?;
emit_hyperv_event(
sink,
"hyperv.connect-switch",
"Connecting VM network adapter.",
StepDomain::HyperV,
StepAction::Configure,
);
self.vm
.connect_network_adapter(&imported.name, &switch.name)?;
emit_hyperv_event(
sink,
"hyperv.resize-vhd",
"Sizing VM virtual disk.",
StepDomain::HyperV,
StepAction::Configure,
);
self.vm
.resize_first_vhd(&imported.name, request.disk_size_bytes)?;
emit_hyperv_event(
sink,
"hyperv.set-first-boot",
"Configuring VM boot disk.",
StepDomain::HyperV,
StepAction::Configure,
);
self.vm.set_first_boot_disk(&imported.name)?;
emit_hyperv_event(
sink,
"hyperv.set-memory",
"Configuring VM memory.",
StepDomain::HyperV,
StepAction::Configure,
);
self.vm
.set_startup_memory(&imported.name, request.memory.bytes())?;
emit_hyperv_event(
sink,
"hyperv.set-processors",
"Configuring VM processor count.",
StepDomain::HyperV,
StepAction::Configure,
);
self.vm
.set_processor_count(&imported.name, request.processor_count)?;
emit_hyperv_event(
sink,
"hyperv.start-vm",
"Starting VM.",
StepDomain::HyperV,
StepAction::Start,
);
self.vm.start_vm(&imported.name)?;
Ok(HyperVVmSetupResult {
vm_name: imported.name,
destination_path: request.destination_path.to_string_lossy().to_string(),
switch_name: switch.name,
vmcx_path,
})
}
}

View File

@@ -0,0 +1,138 @@
use std::{
cell::RefCell,
fs,
path::PathBuf,
rc::Rc,
time::{SystemTime, UNIX_EPOCH},
};
use crate::{
models::CommandResult,
orchestration::{
DriveCandidate, EnsureSwitchRequest, ExternalSwitch, HostProvider, HostReadiness,
NetworkAdapterCandidate, VmCompatibilityReport, VmImportRequest, VmInventoryRecord,
VmProvider,
},
};
#[derive(Default)]
pub(super) struct MockHost;
impl HostProvider for MockHost {
fn readiness(&self) -> CommandResult<HostReadiness> {
Ok(HostReadiness {
elevated: true,
hyperv_available: true,
vmms_running: true,
virtualization_firmware_enabled: Some(true),
total_physical_memory_bytes: 64 * 1024 * 1024 * 1024,
available_physical_memory_bytes: 48 * 1024 * 1024 * 1024,
logical_processor_count: 16,
})
}
fn drives_with_minimum_free_space(
&self,
_minimum_free_bytes: u64,
) -> CommandResult<Vec<DriveCandidate>> {
Ok(vec![])
}
fn active_physical_adapters(&self) -> CommandResult<Vec<NetworkAdapterCandidate>> {
Ok(vec![])
}
}
#[derive(Default)]
pub(super) struct MockVm {
pub(super) calls: Rc<RefCell<Vec<&'static str>>>,
pub(super) existing: Option<VmInventoryRecord>,
}
impl VmProvider for MockVm {
fn get_vm(&self, _name: &str) -> CommandResult<Option<VmInventoryRecord>> {
self.calls.borrow_mut().push("get_vm");
Ok(self.existing.clone())
}
fn compare_import(&self, _request: &VmImportRequest) -> CommandResult<VmCompatibilityReport> {
self.calls.borrow_mut().push("compare_import");
Ok(VmCompatibilityReport {
compatible: true,
incompatibilities: vec![],
})
}
fn import_vm(
&self,
_request: &VmImportRequest,
) -> CommandResult<crate::orchestration::ImportedVm> {
self.calls.borrow_mut().push("import_vm");
Ok(crate::orchestration::ImportedVm {
name: "test-vm".to_string(),
configuration_location: "dest".to_string(),
})
}
fn remove_vm(&self, _name: &str) -> CommandResult<()> {
self.calls.borrow_mut().push("remove_vm");
Ok(())
}
fn start_vm(&self, _name: &str) -> CommandResult<()> {
self.calls.borrow_mut().push("start_vm");
Ok(())
}
fn stop_vm(&self, _name: &str, _turn_off: bool) -> CommandResult<()> {
self.calls.borrow_mut().push("stop_vm");
Ok(())
}
fn connect_network_adapter(&self, _vm_name: &str, _switch_name: &str) -> CommandResult<()> {
self.calls.borrow_mut().push("connect_network_adapter");
Ok(())
}
fn ensure_external_switch(
&self,
_request: &EnsureSwitchRequest,
) -> CommandResult<ExternalSwitch> {
self.calls.borrow_mut().push("ensure_external_switch");
Ok(ExternalSwitch {
name: "switch".to_string(),
net_adapter_interface_description: "adapter".to_string(),
})
}
fn resize_first_vhd(&self, _vm_name: &str, _size_bytes: u64) -> CommandResult<()> {
self.calls.borrow_mut().push("resize_first_vhd");
Ok(())
}
fn set_first_boot_disk(&self, _vm_name: &str) -> CommandResult<()> {
self.calls.borrow_mut().push("set_first_boot_disk");
Ok(())
}
fn set_startup_memory(&self, _vm_name: &str, _bytes: u64) -> CommandResult<()> {
self.calls.borrow_mut().push("set_startup_memory");
Ok(())
}
fn set_processor_count(&self, _vm_name: &str, _count: u32) -> CommandResult<()> {
self.calls.borrow_mut().push("set_processor_count");
Ok(())
}
}
pub(super) fn test_dir() -> PathBuf {
let mut path = std::env::temp_dir();
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
path.push(format!("dune-manager-orchestration-test-{nanos}"));
fs::create_dir_all(&path).unwrap();
path
}

View File

@@ -0,0 +1,2 @@
mod mocks;
mod orchestration;

View File

@@ -0,0 +1,185 @@
use std::{cell::RefCell, fs, rc::Rc};
use crate::orchestration::{VmInventoryRecord, VmPowerState};
use super::super::events::VecOperationSink;
use super::super::models::{HyperVVmSetupRequest, MemoryProfile, DEFAULT_VM_DISK_BYTES};
use super::super::orchestrator::HyperVVmSetupOrchestrator;
use super::mocks::{test_dir, MockHost, MockVm};
#[test]
fn orchestrates_hyperv_vm_import_sequence() {
let temp = test_dir();
let install = temp.join("server");
let vm_dir = install.join("Virtual Machines");
fs::create_dir_all(&vm_dir).unwrap();
fs::write(vm_dir.join("server.vmcx"), "").unwrap();
let destination = temp.join("vm");
let calls = Rc::new(RefCell::new(Vec::new()));
let vm = MockVm {
calls: calls.clone(),
existing: None,
};
let orchestrator = HyperVVmSetupOrchestrator::new(MockHost, vm);
let mut sink = VecOperationSink::default();
let result = orchestrator
.import_and_prepare_vm(
&HyperVVmSetupRequest {
install_path: install,
vm_name: "test-vm".to_string(),
destination_path: destination,
switch_name: "switch".to_string(),
adapter_name: "Ethernet".to_string(),
memory: MemoryProfile::Sietch20Gb,
processor_count: 4,
replace_existing_vm: false,
clear_destination: false,
disk_size_bytes: DEFAULT_VM_DISK_BYTES,
},
&mut sink,
)
.unwrap();
assert_eq!(result.vm_name, "test-vm");
assert_eq!(
calls.borrow().as_slice(),
&[
"get_vm",
"compare_import",
"import_vm",
"ensure_external_switch",
"connect_network_adapter",
"resize_first_vhd",
"set_first_boot_disk",
"set_startup_memory",
"set_processor_count",
"start_vm",
]
);
assert!(sink
.events
.iter()
.any(|event| event.step_id == "hyperv.import-vm"));
}
#[test]
fn refuses_existing_vm_without_replace_flag() {
let temp = test_dir();
let install = temp.join("server");
let vm_dir = install.join("Virtual Machines");
fs::create_dir_all(&vm_dir).unwrap();
fs::write(vm_dir.join("server.vmcx"), "").unwrap();
let vm = MockVm {
calls: Rc::new(RefCell::new(Vec::new())),
existing: Some(VmInventoryRecord {
name: "test-vm".to_string(),
state: VmPowerState::Off,
raw_state: "Off".to_string(),
configuration_location: String::new(),
path: String::new(),
memory_assigned_bytes: 0,
processor_count: 0,
uptime_seconds: 0,
ipv4_addresses: vec![],
hard_disk_paths: vec![],
disk_size_bytes: 0,
disk_file_size_bytes: 0,
switch_names: vec![],
}),
};
let orchestrator = HyperVVmSetupOrchestrator::new(MockHost, vm);
let mut sink = VecOperationSink::default();
let err = orchestrator
.import_and_prepare_vm(
&HyperVVmSetupRequest {
install_path: install,
vm_name: "test-vm".to_string(),
destination_path: temp.join("vm"),
switch_name: "switch".to_string(),
adapter_name: "Ethernet".to_string(),
memory: MemoryProfile::Sietch20Gb,
processor_count: 4,
replace_existing_vm: false,
clear_destination: false,
disk_size_bytes: DEFAULT_VM_DISK_BYTES,
},
&mut sink,
)
.unwrap_err();
assert!(err.message.contains("already exists"));
}
#[test]
fn allows_existing_destination_folder_without_vm_artifacts() {
let temp = test_dir();
let install = temp.join("server");
let vm_dir = install.join("Virtual Machines");
fs::create_dir_all(&vm_dir).unwrap();
fs::write(vm_dir.join("server.vmcx"), "").unwrap();
let destination = temp.join("vm");
fs::create_dir_all(&destination).unwrap();
let calls = Rc::new(RefCell::new(Vec::new()));
let vm = MockVm {
calls: calls.clone(),
existing: None,
};
let orchestrator = HyperVVmSetupOrchestrator::new(MockHost, vm);
let mut sink = VecOperationSink::default();
orchestrator
.import_and_prepare_vm(
&HyperVVmSetupRequest {
install_path: install,
vm_name: "test-vm".to_string(),
destination_path: destination,
switch_name: "switch".to_string(),
adapter_name: "Ethernet".to_string(),
memory: MemoryProfile::Sietch20Gb,
processor_count: 4,
replace_existing_vm: false,
clear_destination: false,
disk_size_bytes: DEFAULT_VM_DISK_BYTES,
},
&mut sink,
)
.unwrap();
assert!(calls.borrow().contains(&"import_vm"));
}
#[test]
fn refuses_destination_folder_with_vm_artifacts() {
let temp = test_dir();
let install = temp.join("server");
let vm_dir = install.join("Virtual Machines");
fs::create_dir_all(&vm_dir).unwrap();
fs::write(vm_dir.join("server.vmcx"), "").unwrap();
let destination = temp.join("vm");
fs::create_dir_all(destination.join("Virtual Machines")).unwrap();
let vm = MockVm {
calls: Rc::new(RefCell::new(Vec::new())),
existing: None,
};
let orchestrator = HyperVVmSetupOrchestrator::new(MockHost, vm);
let mut sink = VecOperationSink::default();
let err = orchestrator
.import_and_prepare_vm(
&HyperVVmSetupRequest {
install_path: install,
vm_name: "test-vm".to_string(),
destination_path: destination,
switch_name: "switch".to_string(),
adapter_name: "Ethernet".to_string(),
memory: MemoryProfile::Sietch20Gb,
processor_count: 4,
replace_existing_vm: false,
clear_destination: false,
disk_size_bytes: DEFAULT_VM_DISK_BYTES,
},
&mut sink,
)
.unwrap_err();
assert!(err.message.contains("contains VM files"));
}

View File

@@ -0,0 +1,52 @@
use std::path::Path;
use crate::{errors::failure, models::CommandResult, orchestration::packaged_vmcx_candidates};
pub(super) fn single_vmcx(install_path: &Path) -> CommandResult<String> {
let candidates = packaged_vmcx_candidates(install_path)?;
match candidates.as_slice() {
[path] => Ok(path.clone()),
[] => Err(failure(format!(
"No .vmcx file found under {}",
install_path.join("Virtual Machines").display()
))),
_ => Err(failure(format!(
"Multiple .vmcx files found under {}",
install_path.join("Virtual Machines").display()
))),
}
}
pub(super) fn clear_destination_dir(path: &Path) -> CommandResult<()> {
if !path.exists() {
return Ok(());
}
if path.parent().is_none() {
return Err(failure(
"Refusing to clear destination without a parent directory",
));
}
std::fs::remove_dir_all(path)
.map_err(|err| failure(format!("Failed to clear {}: {err}", path.display())))
}
pub(super) fn destination_has_vm_artifacts(path: &Path) -> bool {
if !path.exists() {
return false;
}
if path.join("Virtual Machines").is_dir() || path.join("Virtual Hard Disks").is_dir() {
return true;
}
path.read_dir()
.ok()
.into_iter()
.flatten()
.filter_map(Result::ok)
.any(|entry| {
entry.path().extension().is_some_and(|extension| {
["vmcx", "vmrs", "vhd", "vhdx"]
.iter()
.any(|candidate| extension.eq_ignore_ascii_case(candidate))
})
})
}

View File

@@ -0,0 +1,105 @@
//! Request and result types for setting map instance counts.
use serde::Serialize;
use crate::{
errors::failure,
models::CommandResult,
orchestration::{instance_management::instance_map::InstanceMap, BattlegroupRef},
};
/// Request for setting the desired number of map partitions.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SetMapInstancesRequest {
/// BattleGroup namespace and resource name.
pub battlegroup: BattlegroupRef,
/// Map family to modify.
pub map: InstanceMap,
/// Desired partition count. Must be at least one.
pub count: usize,
/// Deep Desert partition IDs that should be marked PvP in user config.
///
/// `None` leaves config files untouched. `Some(Vec::new())` clears the
/// configured PvP partition list.
pub pvp_partition_ids: Option<Vec<i64>>,
/// Number of Deep Desert instances that should be marked PvP.
///
/// When set, the highest selected Deep Desert partition IDs are marked as
/// PvP and the remaining selected partitions stay PvE. This is the
/// user-facing setup flow; `pvp_partition_ids` is the lower-level escape
/// hatch for exact partition control.
pub pvp_instance_count: Option<usize>,
}
impl SetMapInstancesRequest {
/// Creates a request without PvP config changes.
pub fn new(battlegroup: BattlegroupRef, map: InstanceMap, count: usize) -> Self {
Self {
battlegroup,
map,
count,
pvp_partition_ids: None,
pvp_instance_count: None,
}
}
pub(super) fn validate(&self) -> CommandResult<()> {
self.battlegroup.validate()?;
if self.count == 0 || self.count > 64 {
return Err(failure("--count must be between 1 and 64"));
}
if self.map == InstanceMap::DeepDesert && self.count > 1 {
return Err(failure(
"Only one Deep Desert instance is supported in this build",
));
}
if self.pvp_partition_ids.is_some() && self.pvp_instance_count.is_some() {
return Err(failure(
"Use either explicit PvP partition IDs or a PvP instance count, not both",
));
}
if let Some(ids) = &self.pvp_partition_ids {
for id in ids {
if *id <= 0 {
return Err(failure("PvP partition IDs must be positive"));
}
}
if self.map != InstanceMap::DeepDesert {
return Err(failure(
"PvP partition config is currently supported only for deep-desert",
));
}
}
if let Some(count) = self.pvp_instance_count {
if self.map != InstanceMap::DeepDesert {
return Err(failure(
"PvP instance config is currently supported only for deep-desert",
));
}
if count > self.count {
return Err(failure(
"PvP instance count cannot exceed total instance count",
));
}
}
Ok(())
}
}
/// Result of setting map partitions.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct SetMapInstancesResult {
/// Map name that was modified.
pub map: String,
/// Partition IDs after the patch.
pub partition_ids: Vec<i64>,
/// PvP partition IDs written to config.
pub pvp_partition_ids: Vec<i64>,
/// Whether a BattleGroup restart is required for all consumers to see the change.
pub restart_required: bool,
/// Whether the BattleGroup resource was patched.
pub battlegroup_patched: bool,
/// Whether PvP config files were updated.
pub pvp_config_updated: bool,
}

View File

@@ -0,0 +1,184 @@
//! Display-name patch construction for BattleGroup server-group pod specs.
use serde_json::{json, Value};
use crate::{
errors::failure,
models::CommandResult,
orchestration::instance_management::{
display_name_models::SetMapDisplayNameRequest, instance_map::InstanceMap, shell::descend,
},
};
pub(super) const SERVER_DISPLAY_NAME_ARGUMENT_PREFIX: &str =
"-ini:engine:[ConsoleVariables]:Bgd.ServerDisplayName=";
#[derive(Debug, Clone, PartialEq, Eq)]
pub(super) struct DisplayNameUpdate {
pub(super) partition_id: i64,
pub(super) patch_required: bool,
pub(super) patch_operations: Vec<Value>,
}
pub(super) fn build_display_name_update(
battlegroup: &Value,
request: &SetMapDisplayNameRequest,
) -> CommandResult<DisplayNameUpdate> {
let partition_id = partition_id_for_dimension(battlegroup, request.map, request.dimension)?;
let sets_path = ["spec", "serverGroup", "template", "spec", "sets"];
let sets = descend(battlegroup, &sets_path)?
.as_array()
.ok_or_else(|| failure("BattleGroup serverGroup sets is not an array"))?;
let map_name = request.map.map_name();
let set_index = sets
.iter()
.position(|item| item["map"].as_str() == Some(map_name))
.ok_or_else(|| {
failure(format!(
"BattleGroup has no serverGroup set entry for {map_name}"
))
})?;
let set = &sets[set_index];
let desired_arg = request
.display_name
.as_ref()
.map(|name| format!("{SERVER_DISPLAY_NAME_ARGUMENT_PREFIX}{name}"));
let patch_operations =
display_name_patch_operations(set, set_index, partition_id, desired_arg)?;
Ok(DisplayNameUpdate {
partition_id,
patch_required: !patch_operations.is_empty(),
patch_operations,
})
}
fn partition_id_for_dimension(
battlegroup: &Value,
map: InstanceMap,
dimension: i64,
) -> CommandResult<i64> {
let world_partitions_path = [
"spec",
"database",
"template",
"spec",
"deployment",
"spec",
"worldPartitions",
];
let world_partitions = descend(battlegroup, &world_partitions_path)?
.as_array()
.ok_or_else(|| failure("BattleGroup worldPartitions is not an array"))?;
let map_name = map.map_name();
let entry = world_partitions
.iter()
.find(|item| item["map"].as_str() == Some(map_name))
.ok_or_else(|| {
failure(format!(
"BattleGroup has no worldPartitions entry for {map_name}"
))
})?;
let partitions = entry["partitions"]
.as_array()
.ok_or_else(|| failure(format!("{map_name} partitions is not an array")))?;
let partition = partitions
.iter()
.find(|item| item["dimension"].as_i64() == Some(dimension))
.ok_or_else(|| {
failure(format!(
"{map_name} has no partition for dimension {dimension}"
))
})?;
partition["id"]
.as_i64()
.ok_or_else(|| failure(format!("{map_name} dimension {dimension} is missing id")))
}
fn display_name_patch_operations(
set: &Value,
set_index: usize,
partition_id: i64,
desired_arg: Option<String>,
) -> CommandResult<Vec<Value>> {
let pod_specs = set.get("podSpecs");
let Some(pod_specs) = pod_specs else {
return Ok(desired_arg
.map(|arg| {
vec![json!({
"op": "add",
"path": format!("/spec/serverGroup/template/spec/sets/{set_index}/podSpecs"),
"value": [{
"index": partition_id,
"arguments": [arg],
}],
})]
})
.unwrap_or_default());
};
let pod_specs = pod_specs
.as_array()
.ok_or_else(|| failure("BattleGroup serverGroup podSpecs is not an array"))?;
let Some(pod_spec_index) = pod_specs
.iter()
.position(|item| item["index"].as_i64() == Some(partition_id))
else {
return Ok(desired_arg
.map(|arg| {
vec![json!({
"op": "add",
"path": format!("/spec/serverGroup/template/spec/sets/{set_index}/podSpecs/-"),
"value": {
"index": partition_id,
"arguments": [arg],
},
})]
})
.unwrap_or_default());
};
let pod_spec = &pod_specs[pod_spec_index];
let arguments = pod_spec.get("arguments");
let arguments_path = format!(
"/spec/serverGroup/template/spec/sets/{set_index}/podSpecs/{pod_spec_index}/arguments"
);
let Some(arguments) = arguments else {
return Ok(desired_arg
.map(|arg| {
vec![json!({
"op": "add",
"path": arguments_path,
"value": [arg],
})]
})
.unwrap_or_default());
};
let arguments = arguments
.as_array()
.ok_or_else(|| failure("BattleGroup serverGroup podSpec arguments is not an array"))?;
let current_index = arguments.iter().position(|item| {
item.as_str()
.is_some_and(|arg| arg.starts_with(SERVER_DISPLAY_NAME_ARGUMENT_PREFIX))
});
match (desired_arg, current_index) {
(Some(desired), Some(arg_index)) if arguments[arg_index].as_str() == Some(&desired) => {
Ok(Vec::new())
}
(Some(desired), Some(arg_index)) => Ok(vec![json!({
"op": "replace",
"path": format!("{arguments_path}/{arg_index}"),
"value": desired,
})]),
(Some(desired), None) => Ok(vec![json!({
"op": "add",
"path": format!("{arguments_path}/-"),
"value": desired,
})]),
(None, Some(arg_index)) => Ok(vec![json!({
"op": "remove",
"path": format!("{arguments_path}/{arg_index}"),
})]),
(None, None) => Ok(Vec::new()),
}
}

View File

@@ -0,0 +1,90 @@
//! Request and result types for setting map display-name overrides.
use serde::Serialize;
use crate::{
errors::failure,
models::CommandResult,
orchestration::{instance_management::instance_map::InstanceMap, BattlegroupRef},
};
/// Request for changing one map dimension's player-facing display name.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SetMapDisplayNameRequest {
/// BattleGroup namespace and resource name.
pub battlegroup: BattlegroupRef,
/// Map family to modify.
pub map: InstanceMap,
/// Dimension index from the BattleGroup world partition list.
pub dimension: i64,
/// New display name. `None` clears the per-partition override.
pub display_name: Option<String>,
}
impl SetMapDisplayNameRequest {
/// Creates a request that sets a display-name override.
pub fn set(
battlegroup: BattlegroupRef,
map: InstanceMap,
dimension: i64,
display_name: impl Into<String>,
) -> Self {
Self {
battlegroup,
map,
dimension,
display_name: Some(display_name.into()),
}
}
/// Creates a request that removes a display-name override.
pub fn clear(battlegroup: BattlegroupRef, map: InstanceMap, dimension: i64) -> Self {
Self {
battlegroup,
map,
dimension,
display_name: None,
}
}
pub(super) fn validate(&self) -> CommandResult<()> {
self.battlegroup.validate()?;
if self.dimension < 0 {
return Err(failure("--dimension must be zero or greater"));
}
if let Some(display_name) = &self.display_name {
validate_display_name(display_name)?;
}
Ok(())
}
}
/// Result of changing a map dimension display name.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct SetMapDisplayNameResult {
/// Map name that was modified.
pub map: String,
/// Dimension index that was modified.
pub dimension: i64,
/// Backing partition ID used as the per-partition pod spec index.
pub partition_id: i64,
/// Effective display name override after the operation.
pub display_name: Option<String>,
/// Whether a BattleGroup restart/reconcile may be required for clients to see the change.
pub restart_required: bool,
/// Whether the BattleGroup resource was patched.
pub battlegroup_patched: bool,
}
fn validate_display_name(value: &str) -> CommandResult<()> {
if value.is_empty() || value.chars().any(char::is_control) {
return Err(failure(
"Display name must be a non-empty single-line value",
));
}
if value.chars().count() > 128 {
return Err(failure("Display name must be 128 characters or fewer"));
}
Ok(())
}

View File

@@ -0,0 +1,38 @@
//! Map identifiers supported by instance count and display-name operations.
use serde::{Deserialize, Serialize};
use crate::{errors::failure, models::CommandResult};
/// Supported map family for user-facing instance count operations.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum InstanceMap {
/// The primary survival map, stored as `Survival_1`.
Survival1,
/// The Deep Desert map, stored as `DeepDesert_1`.
DeepDesert,
}
impl InstanceMap {
/// Parses a CLI/user map name.
pub fn parse(value: &str) -> CommandResult<Self> {
match value.to_ascii_lowercase().as_str() {
"survival-1" | "survival_1" | "survival" => Ok(Self::Survival1),
"deep-desert" | "deep_desert" | "deepdesert" | "deepdesert_1" | "deep-desert-1" => {
Ok(Self::DeepDesert)
}
_ => Err(failure(format!(
"Unsupported instance map {value}; use survival-1 or deep-desert"
))),
}
}
/// Returns the Kubernetes/game map name.
pub fn map_name(self) -> &'static str {
match self {
Self::Survival1 => "Survival_1",
Self::DeepDesert => "DeepDesert_1",
}
}
}

View File

@@ -0,0 +1,28 @@
//! BattleGroup map instance partition management.
//!
//! The vendor BattleGroup custom resource stores the durable list of map
//! partitions at `spec.database.template.spec.deployment.spec.worldPartitions`.
//! Updating that list, then restarting the BattleGroup, lets the operators and
//! game database converge on additional Survival or Deep Desert instances.
//! Deep Desert instances are distinct partition IDs on dimension zero; Survival
//! instances use distinct dimensions.
mod count_models;
mod display_name_helpers;
mod display_name_models;
mod instance_map;
mod orchestrator;
mod orchestrator_helpers;
mod shell;
pub use count_models::{SetMapInstancesRequest, SetMapInstancesResult};
pub use display_name_models::{SetMapDisplayNameRequest, SetMapDisplayNameResult};
pub use instance_map::InstanceMap;
pub use orchestrator::MapInstanceOrchestrator;
#[cfg(test)]
mod tests_display_name;
#[cfg(test)]
mod tests_fixtures;
#[cfg(test)]
mod tests_instance_count;

View File

@@ -0,0 +1,142 @@
//! Orchestrates durable BattleGroup map instance updates.
use serde_json::Value;
use crate::{
errors::failure,
models::CommandResult,
orchestration::{
instance_management::{
count_models::{SetMapInstancesRequest, SetMapInstancesResult},
display_name_helpers::build_display_name_update,
display_name_models::{SetMapDisplayNameRequest, SetMapDisplayNameResult},
orchestrator_helpers::{
build_world_partition_update, deep_desert_pvp_ids, write_pvp_config_script,
},
shell::sh_single_quoted,
},
BattlegroupRef, RemoteCommandRunner,
},
validation::validate_kube_arg,
};
/// Orchestrates durable BattleGroup map instance updates.
#[derive(Debug, Clone)]
pub struct MapInstanceOrchestrator<R> {
runner: R,
}
impl<R> MapInstanceOrchestrator<R>
where
R: RemoteCommandRunner,
{
/// Creates a map instance orchestrator around a remote command runner.
pub fn new(runner: R) -> Self {
Self { runner }
}
/// Sets the desired partition count in the BattleGroup resource.
pub fn set_instances(
&self,
request: &SetMapInstancesRequest,
) -> CommandResult<SetMapInstancesResult> {
request.validate()?;
let battlegroup = self.battlegroup(&request.battlegroup)?;
let update = build_world_partition_update(&battlegroup, request.map, request.count)?;
let mut battlegroup_patched = false;
if update.patch_required {
let patch = serde_json::to_string(&update.patch_operations)
.map_err(|err| failure(format!("Failed to serialize instance patch: {err}")))?;
let command = format!(
"sudo kubectl patch battlegroup {} -n {} --type=json -p {} -o json",
sh_single_quoted(&request.battlegroup.name),
sh_single_quoted(&request.battlegroup.namespace),
sh_single_quoted(&patch),
);
self.runner
.run_json(&command, "map instance battlegroup patch")?;
battlegroup_patched = true;
}
let pvp_partition_ids = request.pvp_partition_ids.clone().or_else(|| {
request
.pvp_instance_count
.map(|count| deep_desert_pvp_ids(&update.partition_ids, count))
});
let mut pvp_config_updated = false;
if let Some(ids) = &pvp_partition_ids {
self.write_deep_desert_pvp_config(&request.battlegroup.namespace, ids)?;
pvp_config_updated = true;
}
Ok(SetMapInstancesResult {
map: request.map.map_name().to_string(),
partition_ids: update.partition_ids,
pvp_partition_ids: pvp_partition_ids.unwrap_or_default(),
restart_required: battlegroup_patched || pvp_config_updated,
battlegroup_patched,
pvp_config_updated,
})
}
/// Sets or clears the display-name override for a single map dimension.
pub fn set_display_name(
&self,
request: &SetMapDisplayNameRequest,
) -> CommandResult<SetMapDisplayNameResult> {
request.validate()?;
let battlegroup = self.battlegroup(&request.battlegroup)?;
let update = build_display_name_update(&battlegroup, request)?;
if update.patch_required {
let patch = serde_json::to_string(&update.patch_operations)
.map_err(|err| failure(format!("Failed to serialize display-name patch: {err}")))?;
let command = format!(
"sudo kubectl patch battlegroup {} -n {} --type=json -p {} -o json",
sh_single_quoted(&request.battlegroup.name),
sh_single_quoted(&request.battlegroup.namespace),
sh_single_quoted(&patch),
);
self.runner
.run_json(&command, "map display-name battlegroup patch")?;
}
Ok(SetMapDisplayNameResult {
map: request.map.map_name().to_string(),
dimension: request.dimension,
partition_id: update.partition_id,
display_name: request.display_name.clone(),
restart_required: update.patch_required,
battlegroup_patched: update.patch_required,
})
}
fn battlegroup(&self, battlegroup: &BattlegroupRef) -> CommandResult<Value> {
battlegroup.validate()?;
let command = format!(
"sudo kubectl get battlegroup {} -n {} -o json",
sh_single_quoted(&battlegroup.name),
sh_single_quoted(&battlegroup.namespace),
);
self.runner.run_json(&command, "map instance battlegroup")
}
fn write_deep_desert_pvp_config(
&self,
namespace: &str,
pvp_partition_ids: &[i64],
) -> CommandResult<()> {
validate_kube_arg(namespace, "namespace")?;
let list = pvp_partition_ids
.iter()
.map(i64::to_string)
.collect::<Vec<_>>()
.join(" ");
self.runner
.run_script(&write_pvp_config_script(namespace, &list))?;
Ok(())
}
}

View File

@@ -0,0 +1,263 @@
//! World-partition patch builders and PvP config script generation.
use serde_json::{json, Value};
use crate::{
errors::failure,
models::CommandResult,
orchestration::instance_management::{
instance_map::InstanceMap,
shell::{descend, sh_single_quoted},
},
};
#[derive(Debug, Clone, PartialEq, Eq)]
pub(super) struct WorldPartitionUpdate {
pub(super) partition_ids: Vec<i64>,
pub(super) patch_required: bool,
pub(super) patch_operations: Vec<Value>,
}
pub(super) fn build_world_partition_update(
battlegroup: &Value,
map: InstanceMap,
count: usize,
) -> CommandResult<WorldPartitionUpdate> {
let world_partitions_path = [
"spec",
"database",
"template",
"spec",
"deployment",
"spec",
"worldPartitions",
];
let world_partitions = descend(battlegroup, &world_partitions_path)?
.as_array()
.ok_or_else(|| failure("BattleGroup worldPartitions is not an array"))?;
let map_name = map.map_name();
let map_index = world_partitions
.iter()
.position(|item| item["map"].as_str() == Some(map_name))
.ok_or_else(|| {
failure(format!(
"BattleGroup has no worldPartitions entry for {map_name}"
))
})?;
let entry = &world_partitions[map_index];
let current = entry["partitions"]
.as_array()
.ok_or_else(|| failure(format!("{map_name} partitions is not an array")))?;
if current.is_empty() {
return Err(failure(format!(
"{map_name} has no template partition to clone"
)));
}
let mut desired = current.clone();
desired.sort_by_key(|item| {
(
item["dimension"].as_i64().unwrap_or(i64::MAX),
item["id"].as_i64().unwrap_or(i64::MAX),
)
});
let used_ids = collect_partition_ids(world_partitions);
while desired.len() < count {
let dimension = next_partition_dimension(map, &desired);
let id = next_free_partition_id(&used_ids, &desired)?;
let mut next = desired[0].clone();
next["id"] = json!(id);
next["dimension"] = json!(dimension);
next["disable"] = json!(false);
desired.push(next);
}
desired.truncate(count);
let partition_ids = desired
.iter()
.map(|item| {
item["id"]
.as_i64()
.ok_or_else(|| failure("Desired partition is missing id"))
})
.collect::<CommandResult<Vec<_>>>()?;
let patch_required = desired != *current;
let patch_operations = if patch_required {
vec![json!({
"op": "replace",
"path": format!(
"/spec/database/template/spec/deployment/spec/worldPartitions/{map_index}/partitions"
),
"value": desired,
})]
} else {
Vec::new()
};
let mut patch_operations = patch_operations;
if map == InstanceMap::Survival1 {
append_server_group_set_patch(battlegroup, map, &partition_ids, &mut patch_operations)?;
}
Ok(WorldPartitionUpdate {
partition_ids,
patch_required: !patch_operations.is_empty(),
patch_operations,
})
}
fn append_server_group_set_patch(
battlegroup: &Value,
map: InstanceMap,
partition_ids: &[i64],
patch_operations: &mut Vec<Value>,
) -> CommandResult<()> {
let sets_path = ["spec", "serverGroup", "template", "spec", "sets"];
let sets = descend(battlegroup, &sets_path)?
.as_array()
.ok_or_else(|| failure("BattleGroup serverGroup sets is not an array"))?;
let map_name = map.map_name();
let set_index = sets
.iter()
.position(|item| item["map"].as_str() == Some(map_name))
.ok_or_else(|| {
failure(format!(
"BattleGroup has no serverGroup set entry for {map_name}"
))
})?;
let set = &sets[set_index];
let desired_replicas = partition_ids.len() as u64;
let current_replicas = set["replicas"].as_u64();
if current_replicas != Some(desired_replicas) {
patch_operations.push(json!({
"op": if set.get("replicas").is_some() { "replace" } else { "add" },
"path": format!("/spec/serverGroup/template/spec/sets/{set_index}/replicas"),
"value": desired_replicas,
}));
}
let desired_partitions = partition_ids.iter().map(|id| json!(id)).collect::<Vec<_>>();
let current_partitions = set.get("partitions").and_then(Value::as_array);
if current_partitions != Some(&desired_partitions) {
patch_operations.push(json!({
"op": if set.get("partitions").is_some() { "replace" } else { "add" },
"path": format!("/spec/serverGroup/template/spec/sets/{set_index}/partitions"),
"value": desired_partitions,
}));
}
Ok(())
}
fn next_partition_dimension(map: InstanceMap, desired: &[Value]) -> i64 {
match map {
InstanceMap::DeepDesert => 0,
InstanceMap::Survival1 => {
desired
.iter()
.filter_map(|item| item["dimension"].as_i64())
.max()
.unwrap_or(-1)
+ 1
}
}
}
pub(super) fn deep_desert_pvp_ids(partition_ids: &[i64], pvp_instance_count: usize) -> Vec<i64> {
if pvp_instance_count == 0 {
return Vec::new();
}
partition_ids
.iter()
.rev()
.take(pvp_instance_count)
.copied()
.collect::<Vec<_>>()
.into_iter()
.rev()
.collect()
}
fn collect_partition_ids(world_partitions: &[Value]) -> Vec<i64> {
let mut ids = Vec::new();
for entry in world_partitions {
for partition in entry["partitions"].as_array().into_iter().flatten() {
if let Some(id) = partition["id"].as_i64() {
ids.push(id);
}
}
}
ids
}
fn next_free_partition_id(existing: &[i64], desired: &[Value]) -> CommandResult<i64> {
let mut used = existing.to_vec();
used.extend(desired.iter().filter_map(|item| item["id"].as_i64()));
let max = used.into_iter().max().unwrap_or(0);
max.checked_add(1)
.ok_or_else(|| failure("No free partition ID is available"))
}
pub(super) fn write_pvp_config_script(namespace: &str, pvp_ids: &str) -> String {
format!(
r#"
set -eu
ns={namespace}
pvp_ids={pvp_ids}
pvc=$(sudo kubectl get pvc -n "$ns" --no-headers | awk '$1 !~ /-db-pvc$/ && $1 ~ /-pvc$/ {{ print $1; exit }}')
if [ -z "$pvc" ]; then
echo "No shared battlegroup PVC found in $ns" >&2
exit 1
fi
pv=$(sudo kubectl get pvc "$pvc" -n "$ns" -o jsonpath='{{.spec.volumeName}}')
pv_path=$(sudo kubectl get pv "$pv" -o jsonpath='{{.spec.local.path}}{{.spec.hostPath.path}}')
if [ -z "$pv_path" ]; then
echo "No host path found for PVC $pvc" >&2
exit 1
fi
update_ini() {{
file="$1"
sudo mkdir -p "$(dirname "$file")"
sudo touch "$file"
backup="$file.manager-backup-$(date +%Y%m%d%H%M%S)"
sudo cp "$file" "$backup"
tmp=$(mktemp)
sudo awk -v ids="$pvp_ids" '
BEGIN {{ section="[/Script/DuneSandbox.PvpPveSettings]"; insec=0; wrote=0 }}
function write_block( n, parts, i) {{
if (!wrote) {{
print section
print "; Managed by Dune Dedicated Server Manager"
print "m_bIsInitialized=True"
print "m_bShouldForceEnablePvpOnAllPartitions=False"
print "!m_PvpEnabledPartitions=ClearArray"
n=split(ids, parts, " ")
for (i=1; i<=n; i++) if (parts[i] != "") print "+m_PvpEnabledPartitions=" parts[i]
print "!m_EffectivePvpEnabledPartitions=ClearArray"
for (i=1; i<=n; i++) if (parts[i] != "") print "+m_EffectivePvpEnabledPartitions=(UID=" parts[i] ")"
wrote=1
}}
}}
$0 == section {{ insec=1; next }}
/^\[/ {{
if (insec) {{ write_block(); insec=0 }}
print
next
}}
insec {{ next }}
{{ print }}
END {{ if (insec || !wrote) write_block() }}
' "$file" > "$tmp"
sudo cp "$tmp" "$file"
rm -f "$tmp"
}}
update_ini "$pv_path/Saved/UserSettings/UserGame.ini"
update_ini "$pv_path/Saved/Config/LinuxServer/Game.ini"
"#,
namespace = sh_single_quoted(namespace),
pvp_ids = sh_single_quoted(pvp_ids)
)
}

View File

@@ -0,0 +1,19 @@
//! Shared shell-quoting and JSON descent helpers.
use serde_json::Value;
use crate::{errors::failure, models::CommandResult};
pub(super) fn sh_single_quoted(value: &str) -> String {
format!("'{}'", value.replace('\'', "'\"'\"'"))
}
pub(super) fn descend<'a>(value: &'a Value, path: &[&str]) -> CommandResult<&'a Value> {
let mut current = value;
for segment in path {
current = current
.get(*segment)
.ok_or_else(|| failure(format!("BattleGroup is missing {segment}")))?;
}
Ok(current)
}

View File

@@ -0,0 +1,186 @@
//! Tests for display-name patch behavior.
use serde_json::json;
use crate::orchestration::{
instance_management::{
display_name_helpers::build_display_name_update,
display_name_models::SetMapDisplayNameRequest, instance_map::InstanceMap,
tests_fixtures::sample_battlegroup,
},
BattlegroupRef,
};
#[test]
fn display_name_adds_per_partition_pod_specs_for_dimension() {
let mut bg = sample_battlegroup();
bg["spec"]["database"]["template"]["spec"]["deployment"]["spec"]["worldPartitions"][0]
["partitions"] = json!([
{"id":1,"dimension":0,"disable":false},
{"id":29,"dimension":1,"disable":false}
]);
bg["spec"]["serverGroup"]["template"]["spec"]["sets"][0]["partitions"] = json!([1, 29]);
bg["spec"]["serverGroup"]["template"]["spec"]["sets"][0]["replicas"] = json!(2);
let request = SetMapDisplayNameRequest::set(
BattlegroupRef {
namespace: "funcom-seabass-sh-host-abcdef".to_string(),
name: "sh-host-abcdef".to_string(),
},
InstanceMap::Survival1,
0,
"Bob",
);
let update = build_display_name_update(&bg, &request).unwrap();
assert_eq!(update.partition_id, 1);
assert!(update.patch_required);
assert_eq!(
update.patch_operations,
vec![json!({
"op": "add",
"path": "/spec/serverGroup/template/spec/sets/0/podSpecs",
"value": [{
"index": 1,
"arguments": ["-ini:engine:[ConsoleVariables]:Bgd.ServerDisplayName=Bob"]
}]
})]
);
}
#[test]
fn display_name_adds_new_pod_spec_without_touching_other_dimensions() {
let mut bg = sample_battlegroup();
bg["spec"]["database"]["template"]["spec"]["deployment"]["spec"]["worldPartitions"][0]
["partitions"] = json!([
{"id":1,"dimension":0,"disable":false},
{"id":29,"dimension":1,"disable":false}
]);
bg["spec"]["serverGroup"]["template"]["spec"]["sets"][0]["podSpecs"] =
json!([{"index":29,"arguments":["-SomeOtherArg=value"]}]);
let request = SetMapDisplayNameRequest::set(
BattlegroupRef {
namespace: "funcom-seabass-sh-host-abcdef".to_string(),
name: "sh-host-abcdef".to_string(),
},
InstanceMap::Survival1,
0,
"Bob",
);
let update = build_display_name_update(&bg, &request).unwrap();
assert_eq!(
update.patch_operations,
vec![json!({
"op": "add",
"path": "/spec/serverGroup/template/spec/sets/0/podSpecs/-",
"value": {
"index": 1,
"arguments": ["-ini:engine:[ConsoleVariables]:Bgd.ServerDisplayName=Bob"]
}
})]
);
}
#[test]
fn display_name_replaces_existing_override() {
let mut bg = sample_battlegroup();
bg["spec"]["serverGroup"]["template"]["spec"]["sets"][0]["podSpecs"] = json!([{
"index": 1,
"arguments": [
"-Other=value",
"-ini:engine:[ConsoleVariables]:Bgd.ServerDisplayName=Alice"
]
}]);
let request = SetMapDisplayNameRequest::set(
BattlegroupRef {
namespace: "funcom-seabass-sh-host-abcdef".to_string(),
name: "sh-host-abcdef".to_string(),
},
InstanceMap::Survival1,
0,
"Bob",
);
let update = build_display_name_update(&bg, &request).unwrap();
assert_eq!(
update.patch_operations,
vec![json!({
"op": "replace",
"path": "/spec/serverGroup/template/spec/sets/0/podSpecs/0/arguments/1",
"value": "-ini:engine:[ConsoleVariables]:Bgd.ServerDisplayName=Bob"
})]
);
}
#[test]
fn display_name_clear_removes_only_override_argument() {
let mut bg = sample_battlegroup();
bg["spec"]["serverGroup"]["template"]["spec"]["sets"][0]["podSpecs"] = json!([{
"index": 1,
"arguments": [
"-Other=value",
"-ini:engine:[ConsoleVariables]:Bgd.ServerDisplayName=Alice"
]
}]);
let request = SetMapDisplayNameRequest::clear(
BattlegroupRef {
namespace: "funcom-seabass-sh-host-abcdef".to_string(),
name: "sh-host-abcdef".to_string(),
},
InstanceMap::Survival1,
0,
);
let update = build_display_name_update(&bg, &request).unwrap();
assert_eq!(
update.patch_operations,
vec![json!({
"op": "remove",
"path": "/spec/serverGroup/template/spec/sets/0/podSpecs/0/arguments/1"
})]
);
}
#[test]
fn display_name_is_noop_when_value_is_current() {
let mut bg = sample_battlegroup();
bg["spec"]["serverGroup"]["template"]["spec"]["sets"][0]["podSpecs"] = json!([{
"index": 1,
"arguments": ["-ini:engine:[ConsoleVariables]:Bgd.ServerDisplayName=Bob"]
}]);
let request = SetMapDisplayNameRequest::set(
BattlegroupRef {
namespace: "funcom-seabass-sh-host-abcdef".to_string(),
name: "sh-host-abcdef".to_string(),
},
InstanceMap::Survival1,
0,
"Bob",
);
let update = build_display_name_update(&bg, &request).unwrap();
assert!(!update.patch_required);
assert!(update.patch_operations.is_empty());
}
#[test]
fn display_name_rejects_missing_dimension() {
let request = SetMapDisplayNameRequest::set(
BattlegroupRef {
namespace: "funcom-seabass-sh-host-abcdef".to_string(),
name: "sh-host-abcdef".to_string(),
},
InstanceMap::Survival1,
9,
"Bob",
);
let err = build_display_name_update(&sample_battlegroup(), &request).unwrap_err();
assert!(err.message.contains("dimension 9"));
}

View File

@@ -0,0 +1,36 @@
//! Shared test fixtures for instance management tests.
use serde_json::{json, Value};
pub(super) fn sample_battlegroup() -> Value {
json!({
"spec": {
"database": {
"template": {
"spec": {
"deployment": {
"spec": {
"worldPartitions": [
{"map":"Survival_1","partitions":[{"id":1,"dimension":0,"disable":false,"minX":0,"minY":0,"maxX":1,"maxY":1}]},
{"map":"Other","partitions":[{"id":2,"dimension":0,"disable":false}]},
{"map":"DeepDesert_1","partitions":[{"id":8,"dimension":0,"disable":false,"minX":0,"minY":0,"maxX":1,"maxY":1}]}
]
}
}
}
}
},
"serverGroup": {
"template": {
"spec": {
"sets": [
{"map":"Survival_1","replicas":1,"partitions":[1]},
{"map":"Overmap","replicas":1,"partitions":[2]},
{"map":"DeepDesert_1","replicas":0}
]
}
}
}
}
})
}

View File

@@ -0,0 +1,145 @@
//! Tests for world-partition / instance-count behavior.
use serde_json::json;
use crate::orchestration::{
instance_management::{
count_models::SetMapInstancesRequest,
instance_map::InstanceMap,
orchestrator_helpers::{build_world_partition_update, deep_desert_pvp_ids},
tests_fixtures::sample_battlegroup,
},
BattlegroupRef,
};
#[test]
fn preserves_single_deep_desert_partition_on_dimension_zero() {
let update =
build_world_partition_update(&sample_battlegroup(), InstanceMap::DeepDesert, 1).unwrap();
assert_eq!(update.partition_ids, vec![8]);
assert!(!update.patch_required);
assert!(update.patch_operations.is_empty());
}
#[test]
fn rejects_multiple_deep_desert_world_partitions() {
let request = SetMapInstancesRequest::new(
BattlegroupRef {
namespace: "funcom-seabass-sh-host-abcdef".to_string(),
name: "sh-host-abcdef".to_string(),
},
InstanceMap::DeepDesert,
2,
);
assert!(request.validate().is_err());
}
#[test]
fn derives_deep_desert_pvp_ids_from_instance_count() {
assert_eq!(deep_desert_pvp_ids(&[8], 0), Vec::<i64>::new());
assert_eq!(deep_desert_pvp_ids(&[8], 1), vec![8]);
}
#[test]
fn rejects_pvp_instance_count_for_survival() {
let mut request = SetMapInstancesRequest::new(
BattlegroupRef {
namespace: "funcom-seabass-sh-host-abcdef".to_string(),
name: "sh-host-abcdef".to_string(),
},
InstanceMap::Survival1,
2,
);
request.pvp_instance_count = Some(1);
assert!(request.validate().is_err());
}
#[test]
fn rejects_pvp_instance_count_above_deep_desert_total() {
let mut request = SetMapInstancesRequest::new(
BattlegroupRef {
namespace: "funcom-seabass-sh-host-abcdef".to_string(),
name: "sh-host-abcdef".to_string(),
},
InstanceMap::DeepDesert,
1,
);
request.pvp_instance_count = Some(2);
assert!(request.validate().is_err());
}
#[test]
fn shrinks_survival_partitions_by_dimension_order() {
let mut bg = sample_battlegroup();
bg["spec"]["database"]["template"]["spec"]["deployment"]["spec"]["worldPartitions"][0]
["partitions"] = json!([
{"id":1,"dimension":0,"disable":false},
{"id":30,"dimension":2,"disable":false},
{"id":29,"dimension":1,"disable":false}
]);
let update = build_world_partition_update(&bg, InstanceMap::Survival1, 2).unwrap();
assert_eq!(update.partition_ids, vec![1, 29]);
assert!(update.patch_required);
assert_eq!(
update.patch_operations[1],
json!({"op":"replace","path":"/spec/serverGroup/template/spec/sets/0/replicas","value":2})
);
assert_eq!(
update.patch_operations[2],
json!({"op":"replace","path":"/spec/serverGroup/template/spec/sets/0/partitions","value":[1,29]})
);
}
#[test]
fn adds_survival_partitions_with_new_dimensions() {
let update =
build_world_partition_update(&sample_battlegroup(), InstanceMap::Survival1, 3).unwrap();
assert_eq!(update.partition_ids, vec![1, 9, 10]);
assert_eq!(
update.patch_operations[0]["value"],
json!([
{"id":1,"dimension":0,"disable":false,"minX":0,"minY":0,"maxX":1,"maxY":1},
{"id":9,"dimension":1,"disable":false,"minX":0,"minY":0,"maxX":1,"maxY":1},
{"id":10,"dimension":2,"disable":false,"minX":0,"minY":0,"maxX":1,"maxY":1}
])
);
assert_eq!(
update.patch_operations[1],
json!({"op":"replace","path":"/spec/serverGroup/template/spec/sets/0/replicas","value":3})
);
assert_eq!(
update.patch_operations[2],
json!({"op":"replace","path":"/spec/serverGroup/template/spec/sets/0/partitions","value":[1,9,10]})
);
}
#[test]
fn leaves_survival_server_group_when_count_is_current() {
let update =
build_world_partition_update(&sample_battlegroup(), InstanceMap::Survival1, 1).unwrap();
assert_eq!(update.partition_ids, vec![1]);
assert!(!update.patch_required);
assert!(update.patch_operations.is_empty());
}
#[test]
fn adds_survival_partitions_field_when_missing() {
let mut bg = sample_battlegroup();
bg["spec"]["serverGroup"]["template"]["spec"]["sets"][0] =
json!({"map":"Survival_1","replicas":1});
let update = build_world_partition_update(&bg, InstanceMap::Survival1, 1).unwrap();
assert_eq!(
update.patch_operations[0],
json!({"op":"add","path":"/spec/serverGroup/template/spec/sets/0/partitions","value":[1]})
);
}

View File

@@ -0,0 +1,239 @@
use serde_json::{json, Value};
use crate::{
errors::{failure, parse_json},
models::CommandResult,
orchestration::{BattlegroupState, KubernetesProvider},
validation::validate_kube_arg,
};
/// Runs commands on a remote guest and returns text or JSON output.
pub trait RemoteCommandRunner {
/// Runs a single remote shell command and returns stdout/stderr text.
fn run(&self, command: &str) -> CommandResult<String>;
/// Runs a multi-line remote shell script and returns stdout/stderr text.
fn run_script(&self, script: &str) -> CommandResult<String>;
/// Runs a command and parses the output as JSON.
fn run_json(&self, command: &str, label: &str) -> CommandResult<Value> {
parse_json(&self.run(command)?, label)
}
}
/// Kubernetes provider backed by `kubectl -o json` on a remote guest.
#[derive(Debug, Clone)]
pub struct StructuredKubectl<R> {
runner: R,
}
impl<R> StructuredKubectl<R>
where
R: RemoteCommandRunner,
{
/// Creates a structured Kubernetes provider around a remote runner.
pub fn new(runner: R) -> Self {
Self { runner }
}
}
impl<R> KubernetesProvider for StructuredKubectl<R>
where
R: RemoteCommandRunner,
{
fn list_battlegroup_namespaces(&self) -> CommandResult<Vec<String>> {
let value = self
.runner
.run_json("sudo kubectl get ns -o json", "namespace list")?;
let mut namespaces = value["items"]
.as_array()
.cloned()
.unwrap_or_default()
.into_iter()
.filter_map(|item| item["metadata"]["name"].as_str().map(str::to_string))
.filter(|name| name.starts_with("funcom-seabass-"))
.collect::<Vec<_>>();
namespaces.sort();
Ok(namespaces)
}
fn patch_battlegroup_stop(&self, namespace: &str, name: &str, stop: bool) -> CommandResult<()> {
validate_kube_arg(namespace, "namespace")?;
validate_kube_arg(name, "battlegroup name")?;
let patch = json!({ "spec": { "stop": stop } }).to_string();
let command = format!(
"sudo kubectl patch battlegroup {} -n {} --type=merge -p {} -o json",
sh_single_quoted(name),
sh_single_quoted(namespace),
sh_single_quoted(&patch)
);
let value = self.runner.run_json(&command, "battlegroup patch")?;
let patched_name = value["metadata"]["name"].as_str().unwrap_or_default();
if patched_name != name {
return Err(failure(
"Battlegroup patch did not return the expected resource",
));
}
Ok(())
}
fn battlegroup_state(&self, namespace: &str, name: &str) -> CommandResult<BattlegroupState> {
validate_kube_arg(namespace, "namespace")?;
validate_kube_arg(name, "battlegroup name")?;
let command = format!(
"sudo kubectl get battlegroup {} -n {} -o json",
sh_single_quoted(name),
sh_single_quoted(namespace),
);
let value = self.runner.run_json(&command, "battlegroup state")?;
Ok(BattlegroupState {
stop: value["spec"]["stop"].as_bool().unwrap_or(false),
phase: value["status"]["phase"]
.as_str()
.unwrap_or_default()
.to_string(),
database_phase: String::new(),
server_group_phase: string_at_paths(
&value,
&[
&["status", "serverGroup", "phase"],
&["status", "serverGroupPhase"],
],
),
director_phase: string_at_paths(
&value,
&[
&["status", "director", "phase"],
&["status", "utilities", "director", "phase"],
],
),
uptime: String::new(),
server_stats: Vec::new(),
})
}
fn director_node_port(&self, namespace: &str) -> CommandResult<Option<u16>> {
validate_kube_arg(namespace, "namespace")?;
let command = format!(
"sudo kubectl get svc -n {} -o json",
sh_single_quoted(namespace)
);
let value = self.runner.run_json(&command, "service list")?;
for service in value["items"].as_array().cloned().unwrap_or_default() {
for port in service["spec"]["ports"]
.as_array()
.cloned()
.unwrap_or_default()
{
if port["port"].as_u64() == Some(11717) {
return Ok(port["nodePort"]
.as_u64()
.and_then(|value| u16::try_from(value).ok()));
}
}
}
Ok(None)
}
}
fn sh_single_quoted(value: &str) -> String {
format!("'{}'", value.replace('\'', "'\"'\"'"))
}
fn string_at_paths(value: &Value, paths: &[&[&str]]) -> String {
for path in paths {
let mut current = value;
for key in *path {
current = &current[*key];
}
if let Some(text) = current.as_str().filter(|text| !text.is_empty()) {
return text.to_string();
}
}
String::new()
}
#[cfg(test)]
mod tests {
use std::{cell::RefCell, collections::VecDeque, rc::Rc};
use super::*;
#[derive(Clone, Default)]
struct MockRemote {
outputs: Rc<RefCell<VecDeque<String>>>,
commands: Rc<RefCell<Vec<String>>>,
}
impl MockRemote {
fn with_outputs(outputs: impl IntoIterator<Item = impl Into<String>>) -> Self {
Self {
outputs: Rc::new(RefCell::new(outputs.into_iter().map(Into::into).collect())),
commands: Rc::new(RefCell::new(Vec::new())),
}
}
}
impl RemoteCommandRunner for MockRemote {
fn run(&self, command: &str) -> CommandResult<String> {
self.commands.borrow_mut().push(command.to_string());
self.outputs
.borrow_mut()
.pop_front()
.ok_or_else(|| failure("no mock output queued"))
}
fn run_script(&self, script: &str) -> CommandResult<String> {
self.run(script)
}
}
#[test]
fn lists_battlegroup_namespaces_from_json_only() {
let remote = MockRemote::with_outputs([r#"{
"items": [
{"metadata":{"name":"default"}},
{"metadata":{"name":"funcom-seabass-sh-host-bbbbbb"}},
{"metadata":{"name":"funcom-seabass-sh-host-aaaaaa"}}
]
}"#]);
let provider = StructuredKubectl::new(remote);
assert_eq!(
provider.list_battlegroup_namespaces().unwrap(),
vec![
"funcom-seabass-sh-host-aaaaaa".to_string(),
"funcom-seabass-sh-host-bbbbbb".to_string(),
]
);
}
#[test]
fn discovers_director_node_port_from_services_json() {
let remote = MockRemote::with_outputs([r#"{
"items": [
{"spec":{"ports":[{"port":18888,"nodePort":30000}]}},
{"spec":{"ports":[{"port":11717,"nodePort":32527}]}}
]
}"#]);
let provider = StructuredKubectl::new(remote);
assert_eq!(
provider
.director_node_port("funcom-seabass-sh-host-abcdef")
.unwrap(),
Some(32527)
);
}
#[test]
fn patch_uses_json_merge_patch_and_validates_returned_resource() {
let remote = MockRemote::with_outputs([r#"{"metadata":{"name":"sh-host-abcdef"}}"#]);
let commands = remote.commands.clone();
let provider = StructuredKubectl::new(remote);
provider
.patch_battlegroup_stop("funcom-seabass-sh-host-abcdef", "sh-host-abcdef", true)
.unwrap();
let command = commands.borrow().first().cloned().unwrap();
assert!(command.contains("--type=merge"));
assert!(command.contains("\"stop\":true"));
assert!(command.contains("-o json"));
}
}

View File

@@ -0,0 +1,68 @@
//! Native orchestration primitives for replacing the vendor scripts.
//!
//! The UI-facing Tauri commands still contain legacy glue. This module is the
//! typed target shape: script behavior is expressed as explicit flow plans,
//! executor boundaries, and strict command contracts that can be reused by
//! Hyper-V now and Docker/Kubernetes providers later.
/// Kubernetes-backed battlegroup inspection, patching, shell, and log operations.
pub mod battlegroup_kubernetes;
/// High-level battlegroup lifecycle orchestration.
pub mod battlegroup_management;
/// Vendor `/home/dune/.dune/bin/battlegroup` wrapper driver.
pub mod battlegroup_wrapper;
/// Host-side Dune VM detection from Hyper-V inventory.
pub mod dune_vm_detection;
/// Guest experimental swap and low-memory BattleGroup profile support.
pub mod experimental_swap;
/// Guest bootstrap planning and sequencing.
pub mod guest_bootstrap;
/// SSH implementation of the guest bootstrap provider.
pub mod guest_bootstrap_ssh;
/// Strict PowerShell implementation of Hyper-V provider traits.
pub mod hyperv_bridge;
/// End-to-end Hyper-V initial setup orchestration.
pub mod hyperv_initial_setup;
/// Hyper-V VM lifecycle orchestration.
pub mod hyperv_lifecycle;
/// Hyper-V VM import and preparation orchestration.
pub mod hyperv_setup;
/// BattleGroup map instance partition management.
pub mod instance_management;
/// SSH-backed Kubernetes provider.
pub mod kubernetes_ssh;
/// Provider traits and shared provider data models.
pub mod providers;
/// Pure-Rust SSH runner backed by russh.
pub mod russh_runner;
/// Pure-Rust local SSH port forwarder backed by russh.
pub mod russh_tunnel;
/// Strict command execution and strict JSON parsing.
pub mod strict_command;
/// Ubuntu-over-SSH remote setup phases.
pub mod ubuntu_ssh_setup;
/// Declarative flow descriptions derived from the vendor scripts.
pub mod vendor_flows;
/// Stdio driver for the vendor Hyper-V setup script.
pub mod vendor_hyperv_setup;
pub use battlegroup_kubernetes::*;
pub use battlegroup_management::*;
pub use battlegroup_wrapper::*;
pub use dune_vm_detection::*;
pub use experimental_swap::*;
pub use guest_bootstrap::*;
pub use guest_bootstrap_ssh::*;
pub use hyperv_bridge::*;
pub use hyperv_initial_setup::*;
pub use hyperv_lifecycle::*;
pub use hyperv_setup::*;
pub use instance_management::*;
pub use kubernetes_ssh::*;
pub use providers::*;
pub use russh_runner::*;
pub use russh_tunnel::*;
pub use strict_command::*;
pub use ubuntu_ssh_setup::*;
pub use vendor_flows::*;
pub use vendor_hyperv_setup::*;

View File

@@ -0,0 +1,107 @@
//! Guest bootstrap provider trait covering disk, payload, k3s, operators, and world setup.
use crate::models::CommandResult;
use crate::orchestration::providers::shared_types::{CreatedWorld, WorldManifestRequest};
/// Provider for the guest bootstrap phases.
pub trait GuestBootstrapProvider {
/// Validates and expands guest root disk if needed.
fn validate_and_resize_root_disk(&self) -> CommandResult<()>;
/// Ensures the server payload is downloaded inside the guest.
fn ensure_server_payload(&self) -> CommandResult<()>;
/// Starts k3s and waits until it is reachable.
fn start_k3s_and_wait(&self) -> CommandResult<()>;
/// Imports prerequisite k3s images.
fn import_core_images(&self) -> CommandResult<()>;
/// Starts core k3s deployments.
fn scale_core_deployments(&self) -> CommandResult<()>;
/// Updates operator CRDs and RBAC.
fn update_operator_crds(&self) -> CommandResult<()>;
/// Patches operator deployment images.
fn patch_operator_images(&self) -> CommandResult<()>;
/// Starts operator deployments.
fn scale_operator_deployments(&self) -> CommandResult<()>;
/// Installs the guest battlegroup helper script.
fn install_battlegroup_helper(&self) -> CommandResult<()>;
/// Creates the world namespace, secrets, and battlegroup resource.
fn create_world(&self, request: &WorldManifestRequest) -> CommandResult<CreatedWorld>;
/// Imports battlegroup container images.
fn import_battlegroup_images(&self) -> CommandResult<()>;
/// Patches battlegroup image tags to the downloaded version.
fn patch_battlegroup_images(
&self,
namespace: &str,
battlegroup_name: &str,
) -> CommandResult<()>;
/// Applies default user settings files through the file browser pod.
fn apply_default_user_settings(
&self,
namespace: &str,
battlegroup_name: &str,
) -> CommandResult<()>;
}
impl<T> GuestBootstrapProvider for &T
where
T: GuestBootstrapProvider + ?Sized,
{
fn validate_and_resize_root_disk(&self) -> CommandResult<()> {
(*self).validate_and_resize_root_disk()
}
fn ensure_server_payload(&self) -> CommandResult<()> {
(*self).ensure_server_payload()
}
fn start_k3s_and_wait(&self) -> CommandResult<()> {
(*self).start_k3s_and_wait()
}
fn import_core_images(&self) -> CommandResult<()> {
(*self).import_core_images()
}
fn scale_core_deployments(&self) -> CommandResult<()> {
(*self).scale_core_deployments()
}
fn update_operator_crds(&self) -> CommandResult<()> {
(*self).update_operator_crds()
}
fn patch_operator_images(&self) -> CommandResult<()> {
(*self).patch_operator_images()
}
fn scale_operator_deployments(&self) -> CommandResult<()> {
(*self).scale_operator_deployments()
}
fn install_battlegroup_helper(&self) -> CommandResult<()> {
(*self).install_battlegroup_helper()
}
fn create_world(&self, request: &WorldManifestRequest) -> CommandResult<CreatedWorld> {
(*self).create_world(request)
}
fn import_battlegroup_images(&self) -> CommandResult<()> {
(*self).import_battlegroup_images()
}
fn patch_battlegroup_images(
&self,
namespace: &str,
battlegroup_name: &str,
) -> CommandResult<()> {
(*self).patch_battlegroup_images(namespace, battlegroup_name)
}
fn apply_default_user_settings(
&self,
namespace: &str,
battlegroup_name: &str,
) -> CommandResult<()> {
(*self).apply_default_user_settings(namespace, battlegroup_name)
}
}

View File

@@ -0,0 +1,55 @@
//! Guest VM access provider trait.
use crate::models::CommandResult;
use crate::orchestration::providers::shared_types::GuestNetworkConfig;
/// Guest VM access provider.
pub trait GuestProvider {
/// Waits for SSH to become reachable.
fn wait_for_ssh(&self, ip: &str, timeout_seconds: u64) -> CommandResult<()>;
/// Uploads bytes to a guest path with a file mode.
fn upload_bytes(
&self,
ip: &str,
remote_path: &str,
bytes: &[u8],
mode: u32,
) -> CommandResult<()>;
/// Writes player-facing IP settings inside the guest.
fn write_player_settings(&self, ip: &str, player_ip: &str) -> CommandResult<()>;
/// Applies static guest networking.
fn apply_static_network(&self, ip: &str, config: &GuestNetworkConfig) -> CommandResult<()>;
/// Detects the guest's public egress IP, when possible.
fn detect_public_ip(&self, ip: &str) -> CommandResult<Option<String>>;
}
impl<T> GuestProvider for &T
where
T: GuestProvider + ?Sized,
{
fn wait_for_ssh(&self, ip: &str, timeout_seconds: u64) -> CommandResult<()> {
(*self).wait_for_ssh(ip, timeout_seconds)
}
fn upload_bytes(
&self,
ip: &str,
remote_path: &str,
bytes: &[u8],
mode: u32,
) -> CommandResult<()> {
(*self).upload_bytes(ip, remote_path, bytes, mode)
}
fn write_player_settings(&self, ip: &str, player_ip: &str) -> CommandResult<()> {
(*self).write_player_settings(ip, player_ip)
}
fn apply_static_network(&self, ip: &str, config: &GuestNetworkConfig) -> CommandResult<()> {
(*self).apply_static_network(ip, config)
}
fn detect_public_ip(&self, ip: &str) -> CommandResult<Option<String>> {
(*self).detect_public_ip(ip)
}
}

View File

@@ -0,0 +1,39 @@
//! Host-level discovery provider trait.
use crate::models::CommandResult;
use crate::orchestration::providers::shared_types::{
DriveCandidate, HostReadiness, NetworkAdapterCandidate,
};
/// Host-level discovery provider.
pub trait HostProvider {
/// Returns host readiness information.
fn readiness(&self) -> CommandResult<HostReadiness>;
/// Lists drives with at least the requested free space.
fn drives_with_minimum_free_space(
&self,
minimum_free_bytes: u64,
) -> CommandResult<Vec<DriveCandidate>>;
/// Lists active physical IPv4 adapters suitable for setup.
fn active_physical_adapters(&self) -> CommandResult<Vec<NetworkAdapterCandidate>>;
}
impl<T> HostProvider for &T
where
T: HostProvider + ?Sized,
{
fn readiness(&self) -> CommandResult<HostReadiness> {
(*self).readiness()
}
fn drives_with_minimum_free_space(
&self,
minimum_free_bytes: u64,
) -> CommandResult<Vec<DriveCandidate>> {
(*self).drives_with_minimum_free_space(minimum_free_bytes)
}
fn active_physical_adapters(&self) -> CommandResult<Vec<NetworkAdapterCandidate>> {
(*self).active_physical_adapters()
}
}

View File

@@ -0,0 +1,16 @@
//! Kubernetes operations provider trait used by battlegroup orchestration.
use crate::models::CommandResult;
use crate::orchestration::providers::shared_types::BattlegroupState;
/// Kubernetes operations needed by battlegroup lifecycle orchestration.
pub trait KubernetesProvider {
/// Lists battlegroup namespaces.
fn list_battlegroup_namespaces(&self) -> CommandResult<Vec<String>>;
/// Patches the battlegroup stop flag.
fn patch_battlegroup_stop(&self, namespace: &str, name: &str, stop: bool) -> CommandResult<()>;
/// Returns the current BattleGroup lifecycle state.
fn battlegroup_state(&self, namespace: &str, name: &str) -> CommandResult<BattlegroupState>;
/// Returns the Director NodePort for a namespace, when present.
fn director_node_port(&self, namespace: &str) -> CommandResult<Option<u16>>;
}

View File

@@ -0,0 +1,20 @@
//! Provider traits and shared data models for host, VM, guest, and Kubernetes operations.
mod guest_bootstrap_provider;
mod guest_provider;
mod host_provider;
mod kubernetes_provider;
mod shared_types;
mod vm_provider;
pub use guest_bootstrap_provider::GuestBootstrapProvider;
pub use guest_provider::GuestProvider;
pub use host_provider::HostProvider;
pub use kubernetes_provider::KubernetesProvider;
pub use shared_types::{
packaged_vmcx_candidates, BattlegroupState, CreatedWorld, DriveCandidate, EnsureSwitchRequest,
ExternalSwitch, GuestNetworkConfig, HostReadiness, ImportedVm, NetworkAdapterCandidate,
ServerStatRow, VmCompatibilityReport, VmImportRequest, VmInventoryRecord, VmPowerState,
WorldManifestRequest,
};
pub use vm_provider::VmProvider;

View File

@@ -0,0 +1,296 @@
//! Shared data models exchanged between provider traits.
use std::path::Path;
use serde::{Deserialize, Serialize};
use crate::models::CommandResult;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
/// Normalized VM power state across host providers.
pub enum VmPowerState {
/// No VM record was found.
Missing,
/// VM is powered off.
Off,
/// VM is transitioning to running.
Starting,
/// VM is running.
Running,
/// VM is transitioning to off.
Stopping,
/// VM is saved/checkpointed by Hyper-V.
Saved,
/// VM is paused.
Paused,
/// Hyper-V returned an unrecognized state.
Other,
}
impl VmPowerState {
/// Maps a raw Hyper-V state string into the normalized enum.
pub fn from_hyperv_state(value: &str) -> Self {
match value.to_ascii_lowercase().as_str() {
"" => Self::Missing,
"off" => Self::Off,
"starting" => Self::Starting,
"running" => Self::Running,
"stopping" => Self::Stopping,
"saved" => Self::Saved,
"paused" => Self::Paused,
_ => Self::Other,
}
}
}
/// Host readiness information required for Hyper-V setup.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct HostReadiness {
/// Whether the current process has elevated privileges.
pub elevated: bool,
/// Whether the Hyper-V PowerShell module is available.
pub hyperv_available: bool,
/// Whether the Hyper-V VM management service is running.
pub vmms_running: bool,
/// Whether firmware virtualization is enabled, when the host can report it.
pub virtualization_firmware_enabled: Option<bool>,
/// Total physical memory on the host in bytes.
pub total_physical_memory_bytes: u64,
/// Currently available physical memory on the host in bytes.
pub available_physical_memory_bytes: u64,
/// Logical processor count reported by the host.
pub logical_processor_count: u32,
}
/// Host drive candidate suitable for placing VM files.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DriveCandidate {
/// Display name for the drive.
pub name: String,
/// Root path such as `C:\`.
pub root: String,
/// Available free space in bytes.
pub free_bytes: u64,
}
/// Physical IPv4 network adapter candidate for a Hyper-V external switch.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct NetworkAdapterCandidate {
/// Adapter name used by Hyper-V commands.
pub name: String,
/// Adapter hardware/interface description.
pub interface_description: String,
/// Active IPv4 address on the adapter.
pub ipv4_address: String,
/// IPv4 CIDR prefix length.
pub prefix_length: u8,
/// Default gateway for the adapter.
pub gateway: String,
/// Suggested static IPv4 address for a VM on this adapter subnet.
pub suggested_ipv4_address: String,
/// Existing external switch bound to this adapter, if any.
pub existing_external_switch: String,
}
/// Hyper-V external switch record.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ExternalSwitch {
/// Switch name.
pub name: String,
/// Adapter description backing the switch.
pub net_adapter_interface_description: String,
}
/// VM inventory snapshot from the host provider.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct VmInventoryRecord {
/// VM name.
pub name: String,
/// Normalized power state.
pub state: VmPowerState,
/// Raw provider-specific power state.
pub raw_state: String,
/// Hyper-V configuration location.
pub configuration_location: String,
/// VM path.
pub path: String,
/// Assigned memory in bytes.
pub memory_assigned_bytes: u64,
/// Virtual processor count assigned to the VM.
pub processor_count: u32,
/// Uptime in seconds.
pub uptime_seconds: u64,
/// IPv4 addresses reported for the VM.
pub ipv4_addresses: Vec<String>,
/// Attached virtual hard disk paths.
pub hard_disk_paths: Vec<String>,
/// Sum of attached virtual hard disk maximum sizes in bytes.
pub disk_size_bytes: u64,
/// Sum of attached virtual hard disk file sizes in bytes.
pub disk_file_size_bytes: u64,
/// Connected Hyper-V switch names.
pub switch_names: Vec<String>,
}
/// Compatibility result for importing a packaged VM.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct VmCompatibilityReport {
/// Whether the VM can be imported.
pub compatible: bool,
/// Human-readable incompatibility reasons.
pub incompatibilities: Vec<String>,
}
/// Result of importing a VM.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ImportedVm {
/// Imported VM name.
pub name: String,
/// Hyper-V configuration location.
pub configuration_location: String,
}
/// Request to import a packaged VM into a destination directory.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct VmImportRequest {
/// Source `.vmcx` path.
pub vmcx_path: String,
/// Destination path for the imported VM files.
pub destination_path: String,
}
/// Request to create or locate a Hyper-V external switch.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct EnsureSwitchRequest {
/// Desired switch name.
pub switch_name: String,
/// Host adapter to bind.
pub adapter_name: String,
}
/// Static guest network configuration.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GuestNetworkConfig {
/// Guest interface name.
pub interface: String,
/// Static address with CIDR prefix.
pub address_cidr: String,
/// Static gateway.
pub gateway: String,
/// DNS server list or value.
pub dns: String,
}
/// Minimal BattleGroup lifecycle state used by start/restart waits.
///
/// The phase fields mirror the columns shown by the vendor
/// `/home/dune/.dune/bin/battlegroup status` wrapper. `stop` is read separately
/// from `.spec.stop` because the wrapper does not surface it.
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct BattlegroupState {
/// Vendor stop flag from `spec.stop`.
pub stop: bool,
/// Top-level BattleGroup status phase (the wrapper's `Status` column).
pub phase: String,
/// Database phase from the wrapper's `Database` column.
pub database_phase: String,
/// Gateway phase from the wrapper's `Gateway` column.
///
/// Kept under the old `server_group_phase` name in the struct for
/// backwards compatibility with existing callers.
pub server_group_phase: String,
/// Director phase from the wrapper's `Director` column.
pub director_phase: String,
/// Optional uptime string from the wrapper's `Uptime` column.
pub uptime: String,
/// Per-map server stats parsed from the wrapper's `Game Servers` table.
pub server_stats: Vec<ServerStatRow>,
}
/// One row of the vendor wrapper's `Game Servers` table.
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct ServerStatRow {
/// Map name (e.g. `Survival_1`, `DeepDesert_1`, `SH_Arrakeen`).
pub map: String,
/// Server pod phase.
pub phase: String,
/// Pod readiness as reported by the wrapper.
pub ready: String,
/// Connected players count.
pub players: String,
/// Pod age.
pub age: String,
}
/// Request to render and create a world manifest.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct WorldManifestRequest {
/// User-facing world name.
pub world_name: String,
/// Vendor region label.
pub world_region: String,
/// Player-facing IPv4 address advertised through gateway metadata.
pub player_ip: String,
/// Unique Kubernetes battlegroup/world name.
pub world_unique_name: String,
/// Self-host token used by the vendor manifest.
pub self_host_token: String,
}
/// Result of creating world resources.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CreatedWorld {
/// Created namespace.
pub namespace: String,
/// Created battlegroup resource name.
pub battlegroup_name: String,
}
/// Returns packaged `.vmcx` candidates under a server install path.
pub fn packaged_vmcx_candidates(install_path: &Path) -> CommandResult<Vec<String>> {
let vm_dir = install_path.join("Virtual Machines");
let entries = std::fs::read_dir(&vm_dir).map_err(|err| {
crate::errors::failure(format!("Failed to read {}: {err}", vm_dir.display()))
})?;
let mut candidates = entries
.filter_map(Result::ok)
.map(|entry| entry.path())
.filter(|path| {
path.extension()
.is_some_and(|ext| ext.eq_ignore_ascii_case("vmcx"))
})
.map(|path| path.to_string_lossy().to_string())
.collect::<Vec<_>>();
candidates.sort();
Ok(candidates)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn maps_hyperv_power_states() {
assert_eq!(
VmPowerState::from_hyperv_state("Running"),
VmPowerState::Running
);
assert_eq!(
VmPowerState::from_hyperv_state("Starting"),
VmPowerState::Starting
);
assert_eq!(VmPowerState::from_hyperv_state("Off"), VmPowerState::Off);
assert_eq!(
VmPowerState::from_hyperv_state("SomethingElse"),
VmPowerState::Other
);
}
}

View File

@@ -0,0 +1,102 @@
//! VM lifecycle and import provider trait.
use crate::orchestration::providers::shared_types::{
EnsureSwitchRequest, ExternalSwitch, ImportedVm, VmCompatibilityReport, VmImportRequest,
VmInventoryRecord,
};
use crate::{errors::failure, models::CommandResult};
/// VM lifecycle and import provider.
pub trait VmProvider {
/// Lists all VMs known to the provider.
fn list_vms(&self) -> CommandResult<Vec<VmInventoryRecord>> {
Err(failure("VM listing is not supported by this provider"))
}
/// Returns a VM inventory record by name, or `None` when absent.
fn get_vm(&self, name: &str) -> CommandResult<Option<VmInventoryRecord>>;
/// Checks whether a packaged VM import is compatible.
fn compare_import(&self, request: &VmImportRequest) -> CommandResult<VmCompatibilityReport>;
/// Imports a packaged VM.
fn import_vm(&self, request: &VmImportRequest) -> CommandResult<ImportedVm>;
/// Removes a VM registration.
fn remove_vm(&self, name: &str) -> CommandResult<()>;
/// Starts a VM.
fn start_vm(&self, name: &str) -> CommandResult<()>;
/// Stops a VM.
fn stop_vm(&self, name: &str, turn_off: bool) -> CommandResult<()>;
/// Connects the VM network adapter to a switch.
fn connect_network_adapter(&self, vm_name: &str, switch_name: &str) -> CommandResult<()>;
/// Ensures an external switch exists.
fn ensure_external_switch(
&self,
request: &EnsureSwitchRequest,
) -> CommandResult<ExternalSwitch>;
/// Resizes the first VHD attached to a VM.
fn resize_first_vhd(&self, vm_name: &str, size_bytes: u64) -> CommandResult<()>;
/// Sets the first disk as boot disk.
fn set_first_boot_disk(&self, vm_name: &str) -> CommandResult<()>;
/// Sets startup memory.
fn set_startup_memory(&self, vm_name: &str, bytes: u64) -> CommandResult<()>;
/// Sets virtual processor count.
fn set_processor_count(&self, vm_name: &str, count: u32) -> CommandResult<()>;
}
impl<T> VmProvider for &T
where
T: VmProvider + ?Sized,
{
fn list_vms(&self) -> CommandResult<Vec<VmInventoryRecord>> {
(*self).list_vms()
}
fn get_vm(&self, name: &str) -> CommandResult<Option<VmInventoryRecord>> {
(*self).get_vm(name)
}
fn compare_import(&self, request: &VmImportRequest) -> CommandResult<VmCompatibilityReport> {
(*self).compare_import(request)
}
fn import_vm(&self, request: &VmImportRequest) -> CommandResult<ImportedVm> {
(*self).import_vm(request)
}
fn remove_vm(&self, name: &str) -> CommandResult<()> {
(*self).remove_vm(name)
}
fn start_vm(&self, name: &str) -> CommandResult<()> {
(*self).start_vm(name)
}
fn stop_vm(&self, name: &str, turn_off: bool) -> CommandResult<()> {
(*self).stop_vm(name, turn_off)
}
fn connect_network_adapter(&self, vm_name: &str, switch_name: &str) -> CommandResult<()> {
(*self).connect_network_adapter(vm_name, switch_name)
}
fn ensure_external_switch(
&self,
request: &EnsureSwitchRequest,
) -> CommandResult<ExternalSwitch> {
(*self).ensure_external_switch(request)
}
fn resize_first_vhd(&self, vm_name: &str, size_bytes: u64) -> CommandResult<()> {
(*self).resize_first_vhd(vm_name, size_bytes)
}
fn set_first_boot_disk(&self, vm_name: &str) -> CommandResult<()> {
(*self).set_first_boot_disk(vm_name)
}
fn set_startup_memory(&self, vm_name: &str, bytes: u64) -> CommandResult<()> {
(*self).set_startup_memory(vm_name, bytes)
}
fn set_processor_count(&self, vm_name: &str, count: u32) -> CommandResult<()> {
(*self).set_processor_count(vm_name, count)
}
}

View File

@@ -0,0 +1,14 @@
//! Pure-Rust SSH runner backed by the `russh` crate.
//!
//! Replaces the legacy `OpenSshRunner` that shelled out to `ssh.exe`. The
//! runner exposes the sync [`crate::orchestration::RemoteCommandRunner`]
//! interface by driving a small internal Tokio runtime, and caches one SSH
//! session per `(user, host, port, key_path)` to avoid paying TCP+auth on
//! every command.
mod runner;
pub(crate) mod session;
mod target;
pub use runner::RusshRunner;
pub use target::RusshTarget;

View File

@@ -0,0 +1,136 @@
//! Sync [`RemoteCommandRunner`] backed by russh with a cached session.
use std::sync::Arc;
use tokio::sync::Mutex as AsyncMutex;
use crate::models::CommandResult;
use crate::orchestration::RemoteCommandRunner;
use super::session::{
close as close_session, connect_and_authenticate, exec_capture, shared_runtime, SessionHandle,
};
use super::target::RusshTarget;
/// Remote command runner that exposes a sync interface backed by a cached
/// russh session.
///
/// The runner keeps one SSH session alive per instance. The session is
/// established lazily on the first call and reconnected automatically if a
/// command fails (e.g. the server dropped the connection). Cloned runners
/// share the cached session, so commands issued through multiple clones are
/// serialized over a single SSH connection.
#[derive(Clone)]
pub struct RusshRunner {
target: RusshTarget,
session: Arc<AsyncMutex<Option<SessionHandle>>>,
}
impl std::fmt::Debug for RusshRunner {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("RusshRunner")
.field("target", &self.target)
.finish()
}
}
impl RusshRunner {
/// Creates a runner that will lazily open a session to the given target.
pub fn new(target: RusshTarget) -> Self {
Self {
target,
session: Arc::new(AsyncMutex::new(None)),
}
}
/// Returns the connection target used by this runner.
pub fn target(&self) -> &RusshTarget {
&self.target
}
/// Closes the cached session if one exists.
pub fn close(&self) {
let session = self.session.clone();
shared_runtime().block_on(async move {
if let Some(handle) = session.lock().await.take() {
close_session(&handle).await;
}
});
}
/// Runs a command while streaming arbitrary stdin bytes to the remote
/// process. This is intended for binary payload uploads where embedding
/// base64 in a shell script would create a very large command body.
pub fn run_with_stdin(&self, command: &str, stdin_body: &[u8]) -> CommandResult<String> {
let runner = self.clone();
let command = command.to_string();
let stdin_body = stdin_body.to_vec();
shared_runtime()
.block_on(async move { runner.exec_with_retry(&command, Some(&stdin_body)).await })
}
async fn exec_with_retry(
&self,
command: &str,
stdin_body: Option<&[u8]>,
) -> CommandResult<String> {
let mut guard = self.session.lock().await;
if guard.is_none() {
self.target.validate()?;
*guard = Some(connect_and_authenticate(&self.target).await?);
}
let first_attempt = {
let handle = guard.as_ref().expect("session populated above");
exec_capture(handle, command, stdin_body).await
};
match first_attempt {
Ok(text) => Ok(text),
Err(err) if is_remote_command_error(&err) => Err(err),
Err(_) => {
if let Some(handle) = guard.take() {
close_session(&handle).await;
}
self.target.validate()?;
*guard = Some(connect_and_authenticate(&self.target).await?);
let handle = guard.as_ref().expect("session populated above");
exec_capture(handle, command, stdin_body).await
}
}
}
}
fn is_remote_command_error(err: &crate::models::CommandFailure) -> bool {
err.code.is_some() || !err.stdout.is_empty() || !err.stderr.is_empty()
}
impl RemoteCommandRunner for RusshRunner {
fn run(&self, command: &str) -> CommandResult<String> {
let runner = self.clone();
let command = command.to_string();
shared_runtime().block_on(async move { runner.exec_with_retry(&command, None).await })
}
fn run_script(&self, script: &str) -> CommandResult<String> {
let runner = self.clone();
let script = script.to_string();
shared_runtime().block_on(async move {
runner
.exec_with_retry("sh -s", Some(script.as_bytes()))
.await
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn runner_is_clone_and_debug() {
let target = RusshTarget::new("key", "dune", "10.0.0.4");
let runner = RusshRunner::new(target.clone());
let _clone = runner.clone();
assert_eq!(runner.target(), &target);
assert!(format!("{runner:?}").contains("dune"));
}
}

View File

@@ -0,0 +1,209 @@
//! Russh session helpers: shared Tokio runtime, connect, authenticate, exec.
use std::sync::{Arc, OnceLock};
use std::time::Duration;
use russh::client::{self, Handle};
use russh::keys::{load_secret_key, PrivateKeyWithHashAlg};
use russh::{ChannelMsg, Disconnect};
use tokio::runtime::{Builder, Runtime};
use crate::errors::failure;
use crate::models::{CommandFailure, CommandResult};
use super::target::RusshTarget;
/// Type alias for the russh client handle used by the runner.
pub(crate) type SessionHandle = Handle<AcceptAllHandler>;
/// Returns the process-wide Tokio runtime used to drive russh I/O.
///
/// The sync [`crate::orchestration::RemoteCommandRunner`] callers `block_on`
/// against this runtime, so the surrounding thread must not already be inside
/// a Tokio runtime.
pub(crate) fn shared_runtime() -> &'static Runtime {
static RT: OnceLock<Runtime> = OnceLock::new();
RT.get_or_init(|| {
Builder::new_multi_thread()
.worker_threads(2)
.thread_name("dune-russh")
.enable_all()
.build()
.expect("failed to build russh tokio runtime")
})
}
/// Russh client handler that accepts any server key.
///
/// Matches the legacy OpenSSH wrapper which used
/// `StrictHostKeyChecking=no` + `UserKnownHostsFile=NUL`.
pub(crate) struct AcceptAllHandler;
impl client::Handler for AcceptAllHandler {
type Error = russh::Error;
async fn check_server_key(
&mut self,
_server_public_key: &russh::keys::ssh_key::PublicKey,
) -> Result<bool, Self::Error> {
Ok(true)
}
}
/// Default russh config for one-shot exec commands. 60s inactivity timeout is
/// fine because the command itself should finish well under that.
pub(crate) fn default_exec_config() -> Arc<client::Config> {
Arc::new(client::Config {
inactivity_timeout: Some(Duration::from_secs(60)),
..client::Config::default()
})
}
/// Russh config for long-lived port forwarders. Disables the inactivity
/// timeout (idle tunnels were getting silently torn down after 60s) and turns
/// on protocol-level keepalives so intermediate NAT/firewalls keep the TCP
/// connection alive. After `keepalive_max` consecutive missed keepalive
/// responses (~90s) russh will mark the session dead.
pub(crate) fn tunnel_config() -> Arc<client::Config> {
Arc::new(client::Config {
inactivity_timeout: None,
keepalive_interval: Some(Duration::from_secs(30)),
keepalive_max: 3,
..client::Config::default()
})
}
/// Opens a TCP connection, performs the SSH handshake, and authenticates with
/// the configured private key using the default exec-style config.
pub(crate) async fn connect_and_authenticate(target: &RusshTarget) -> CommandResult<SessionHandle> {
connect_with_config(target, default_exec_config()).await
}
/// Like [`connect_and_authenticate`] but lets the caller supply a custom
/// `client::Config` (e.g. the tunnel config with keepalives enabled).
pub(crate) async fn connect_with_config(
target: &RusshTarget,
config: Arc<client::Config>,
) -> CommandResult<SessionHandle> {
let key_pair = load_secret_key(&target.key_path, None).map_err(|err| {
failure(format!(
"Failed to load ssh key {}: {err}",
target.key_path.display()
))
})?;
let addr = (target.host.as_str(), target.port);
let connect = tokio::time::timeout(
Duration::from_secs(target.connect_timeout_seconds),
client::connect(config, addr, AcceptAllHandler),
)
.await
.map_err(|_| {
failure(format!(
"ssh connect to {} timed out after {}s",
target.destination(),
target.connect_timeout_seconds
))
})?
.map_err(|err| {
failure(format!(
"ssh connect to {} failed: {err}",
target.destination()
))
})?;
let mut session = connect;
let rsa_hash = session
.best_supported_rsa_hash()
.await
.map_err(|err| failure(format!("ssh negotiation failed: {err}")))?
.flatten();
let auth = session
.authenticate_publickey(
&target.user,
PrivateKeyWithHashAlg::new(Arc::new(key_pair), rsa_hash),
)
.await
.map_err(|err| failure(format!("ssh public-key auth failed: {err}")))?;
if !auth.success() {
return Err(failure(format!(
"ssh public-key authentication was rejected for {}",
target.destination()
)));
}
Ok(session)
}
/// Runs a single command on the given session, optionally streaming `stdin`.
///
/// Returns the trimmed stdout on success. Non-zero exit produces a
/// [`CommandFailure`] populated with stdout, stderr, and the exit code.
pub(crate) async fn exec_capture(
handle: &SessionHandle,
command: &str,
stdin_body: Option<&[u8]>,
) -> CommandResult<String> {
let mut channel = handle
.channel_open_session()
.await
.map_err(|err| failure(format!("ssh open channel failed: {err}")))?;
channel
.exec(true, command)
.await
.map_err(|err| failure(format!("ssh exec failed: {err}")))?;
if let Some(body) = stdin_body {
if !body.is_empty() {
let mut written = 0usize;
let total = body.len();
for chunk in body.chunks(32 * 1024) {
channel.data(chunk).await.map_err(|err| {
failure(format!(
"ssh stdin write failed after {written}/{total} bytes: {err}"
))
})?;
written += chunk.len();
}
}
channel.eof().await.map_err(|err| {
let total = body.len();
failure(format!(
"ssh stdin close failed after sending {total} bytes: {err}"
))
})?;
}
let mut stdout = Vec::new();
let mut stderr = Vec::new();
let mut exit_code: Option<u32> = None;
while let Some(msg) = channel.wait().await {
match msg {
ChannelMsg::Data { data } => stdout.extend_from_slice(&data[..]),
ChannelMsg::ExtendedData { data, ext } => {
if ext == 1 {
stderr.extend_from_slice(&data[..]);
} else {
stdout.extend_from_slice(&data[..]);
}
}
ChannelMsg::ExitStatus { exit_status } => exit_code = Some(exit_status),
_ => {}
}
}
let stdout_text = String::from_utf8_lossy(&stdout).into_owned();
let stderr_text = String::from_utf8_lossy(&stderr).into_owned();
let code = exit_code.unwrap_or(0);
if code != 0 {
return Err(CommandFailure {
message: format!("ssh remote command exited with status {code}"),
stdout: stdout_text.trim().to_string(),
stderr: stderr_text.trim().to_string(),
code: i32::try_from(code).ok(),
});
}
Ok(stdout_text.trim().to_string())
}
/// Sends a polite SSH disconnect on the session.
pub(crate) async fn close(handle: &SessionHandle) {
let _ = handle
.disconnect(Disconnect::ByApplication, "client closing", "en")
.await;
}

View File

@@ -0,0 +1,95 @@
//! Connection target description for the russh-based remote runner.
use std::path::PathBuf;
use crate::{errors::failure, models::CommandResult};
/// Default SSH port used when callers do not specify one.
pub const DEFAULT_SSH_PORT: u16 = 22;
/// Default connection timeout in seconds.
pub const DEFAULT_CONNECT_TIMEOUT_SECONDS: u64 = 8;
/// Connection settings for opening a russh session to a remote host.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RusshTarget {
/// Path to the private key file.
pub key_path: PathBuf,
/// Remote username.
pub user: String,
/// Remote host or IP address.
pub host: String,
/// TCP port the SSH server listens on.
pub port: u16,
/// SSH connection timeout in seconds.
pub connect_timeout_seconds: u64,
}
impl RusshTarget {
/// Creates a target with default port and timeout.
pub fn new(
key_path: impl Into<PathBuf>,
user: impl Into<String>,
host: impl Into<String>,
) -> Self {
Self {
key_path: key_path.into(),
user: user.into(),
host: host.into(),
port: DEFAULT_SSH_PORT,
connect_timeout_seconds: DEFAULT_CONNECT_TIMEOUT_SECONDS,
}
}
/// Returns the `user@host` destination string used in error messages.
pub fn destination(&self) -> String {
format!("{}@{}", self.user, self.host)
}
/// Validates that required files and connection fields are present.
pub fn validate(&self) -> CommandResult<()> {
if self.key_path.as_os_str().is_empty() {
return Err(failure("ssh key path is required"));
}
if !self.key_path.is_file() {
return Err(failure(format!(
"ssh key was not found: {}",
self.key_path.display()
)));
}
if self.user.trim().is_empty() {
return Err(failure("ssh user is required"));
}
if self.host.trim().is_empty() {
return Err(failure("ssh host is required"));
}
if self.port == 0 {
return Err(failure("ssh port must be non-zero"));
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn destination_uses_user_and_host_only() {
let target = RusshTarget::new("key", "dune", "10.0.0.4");
assert_eq!(target.destination(), "dune@10.0.0.4");
assert_eq!(target.port, DEFAULT_SSH_PORT);
}
#[test]
fn validate_rejects_empty_fields() {
let temp = std::env::temp_dir().join("russh-target-test-key");
std::fs::write(&temp, b"x").unwrap();
let mut target = RusshTarget::new(&temp, "dune", "10.0.0.4");
target.validate().unwrap();
target.user = String::new();
assert!(target.validate().is_err());
let _ = std::fs::remove_file(&temp);
}
}

View File

@@ -0,0 +1,259 @@
//! Local SSH port forwarder backed by russh `direct-tcpip` channels.
//!
//! The local TCP listener stays up for the lifetime of the [`LocalForwarder`].
//! The SSH session itself is owned by [`SessionProvider`], which lazily
//! reconnects with backoff when the previous session dies — so a flaky upstream
//! turns into a brief blocking reconnect on the next request rather than a
//! permanent dead-tunnel state.
use std::net::SocketAddr;
use std::sync::Arc;
use std::time::{Duration, Instant};
use tokio::net::TcpListener;
use tokio::sync::{watch, Mutex};
use tokio::task::JoinHandle;
use crate::errors::failure;
use crate::models::{CommandFailure, CommandResult};
use crate::orchestration::russh_runner::session::{
close as close_session, connect_with_config, shared_runtime, tunnel_config, SessionHandle,
};
use crate::orchestration::russh_runner::RusshTarget;
use super::proxy::proxy_one_connection;
/// How long [`SessionProvider::get`] will block trying to re-establish a dead
/// session before giving up and returning the last error. Single client
/// requests will see this as their request latency on first contact after a
/// blip.
const RECONNECT_DEADLINE: Duration = Duration::from_secs(15);
/// Initial reconnect backoff. Doubles up to `RECONNECT_MAX_BACKOFF`.
const RECONNECT_INITIAL_BACKOFF: Duration = Duration::from_millis(500);
const RECONNECT_MAX_BACKOFF: Duration = Duration::from_secs(3);
/// Active local SSH port forwarder.
///
/// Dropping the forwarder requests shutdown and aborts the accept loop. To
/// shut down gracefully and wait for the loop to exit, call
/// [`LocalForwarder::stop`].
pub struct LocalForwarder {
shutdown: watch::Sender<bool>,
task: Option<JoinHandle<()>>,
local_addr: SocketAddr,
provider: Arc<SessionProvider>,
}
impl LocalForwarder {
/// Connects to `target`, binds a TCP listener on `127.0.0.1:local_port`
/// (or a randomly chosen port if `local_port` is `0`), and spawns the
/// background accept loop. The initial SSH session is established eagerly
/// so an unreachable host fails fast at start() rather than on first use.
pub fn start(
target: &RusshTarget,
local_port: u16,
remote_host: &str,
remote_port: u16,
) -> CommandResult<Self> {
target.validate()?;
let target = target.clone();
let remote_host = remote_host.to_string();
let rt = shared_runtime();
rt.block_on(async move {
let provider = Arc::new(SessionProvider::new(target));
// Eager initial connect to surface bad creds / unreachable host
// before we hand back a forwarder handle.
provider.get().await?;
let listener = TcpListener::bind(("127.0.0.1", local_port))
.await
.map_err(|err| {
failure(format!(
"Failed to bind local tunnel port {local_port}: {err}"
))
})?;
let local_addr = listener
.local_addr()
.map_err(|err| failure(format!("Failed to read local tunnel port: {err}")))?;
let (shutdown_tx, shutdown_rx) = watch::channel(false);
let task = tokio::spawn(accept_loop(
listener,
provider.clone(),
remote_host,
remote_port,
shutdown_rx,
));
Ok(LocalForwarder {
shutdown: shutdown_tx,
task: Some(task),
local_addr,
provider,
})
})
}
/// Returns the actual bound local TCP port.
pub fn local_port(&self) -> u16 {
self.local_addr.port()
}
/// Returns `true` only when the forwarder has been explicitly stopped — a
/// transiently dead SSH session no longer flips this, since the provider
/// will reconnect on demand.
pub fn is_finished(&self) -> bool {
self.task
.as_ref()
.map(JoinHandle::is_finished)
.unwrap_or(true)
}
/// Signals shutdown, waits for the accept loop to exit, and closes the
/// SSH session.
pub fn stop(mut self) {
let _ = self.shutdown.send(true);
if let Some(task) = self.task.take() {
let provider = self.provider.clone();
shared_runtime().block_on(async move {
let _ = tokio::time::timeout(Duration::from_secs(5), task).await;
provider.close().await;
});
}
}
}
impl Drop for LocalForwarder {
fn drop(&mut self) {
let _ = self.shutdown.send(true);
if let Some(task) = self.task.take() {
task.abort();
}
}
}
/// Owns the current SSH session and reconnects on demand. Callers hand off a
/// clone of this and never touch raw [`SessionHandle`]s directly.
pub(crate) struct SessionProvider {
target: RusshTarget,
// A single Mutex (not RwLock) is fine: lookups are cheap, only one
// reconnect should ever be in flight, and contention is only on the rare
// session-died path.
current: Mutex<Option<Arc<SessionHandle>>>,
}
impl SessionProvider {
fn new(target: RusshTarget) -> Self {
Self {
target,
current: Mutex::new(None),
}
}
/// Returns a healthy SSH session, reconnecting with backoff if the
/// previous one died. Blocks for at most `RECONNECT_DEADLINE` before
/// returning the last reconnect error.
pub(crate) async fn get(&self) -> CommandResult<Arc<SessionHandle>> {
let mut guard = self.current.lock().await;
if let Some(session) = guard.as_ref() {
if !session.is_closed() {
return Ok(session.clone());
}
// Drop the dead handle so we can take a fresh one.
*guard = None;
}
let started = Instant::now();
let mut backoff = RECONNECT_INITIAL_BACKOFF;
let mut last_err: Option<CommandFailure> = None;
loop {
match connect_with_config(&self.target, tunnel_config()).await {
Ok(session) => {
let session = Arc::new(session);
*guard = Some(session.clone());
if last_err.is_some() {
eprintln!(
"russh tunnel: ssh session re-established to {}",
self.target.destination()
);
}
return Ok(session);
}
Err(err) => {
eprintln!(
"russh tunnel: reconnect to {} failed: {}",
self.target.destination(),
err.message
);
last_err = Some(err);
if started.elapsed() >= RECONNECT_DEADLINE {
return Err(last_err.unwrap_or_else(|| {
failure(format!(
"ssh reconnect to {} timed out",
self.target.destination()
))
}));
}
tokio::time::sleep(backoff).await;
backoff = (backoff * 2).min(RECONNECT_MAX_BACKOFF);
}
}
}
}
async fn close(&self) {
let mut guard = self.current.lock().await;
if let Some(session) = guard.take() {
close_session(&session).await;
}
}
}
async fn accept_loop(
listener: TcpListener,
provider: Arc<SessionProvider>,
remote_host: String,
remote_port: u16,
mut shutdown: watch::Receiver<bool>,
) {
loop {
tokio::select! {
biased;
changed = shutdown.changed() => {
if changed.is_err() || *shutdown.borrow() {
break;
}
}
accept = listener.accept() => {
let (stream, peer) = match accept {
Ok(pair) => pair,
Err(_) => continue,
};
let provider = provider.clone();
let remote_host = remote_host.clone();
let origin_ip = peer.ip().to_string();
let origin_port = peer.port();
tokio::spawn(async move {
let session = match provider.get().await {
Ok(s) => s,
Err(err) => {
eprintln!(
"russh tunnel: dropping incoming connection — no healthy session ({}:{}): {}",
remote_host, remote_port, err.message
);
// stream is dropped; client sees an immediate
// connection reset rather than a hung read.
return;
}
};
proxy_one_connection(
&session,
stream,
remote_host,
remote_port,
(origin_ip, origin_port),
)
.await;
});
}
}
}
}

View File

@@ -0,0 +1,11 @@
//! Russh-backed local SSH port forwarder.
//!
//! Replaces `ssh -N -L 127.0.0.1:<local>:<remote_host>:<remote_port>` with a
//! pure-Rust local forwarder. The forwarder binds a TCP listener on
//! 127.0.0.1, opens one `direct-tcpip` channel per accepted connection over
//! the cached SSH session, and proxies bytes bidirectionally.
mod forwarder;
mod proxy;
pub use forwarder::LocalForwarder;

View File

@@ -0,0 +1,86 @@
//! Bidirectional byte proxy between a local TCP connection and a russh
//! `direct-tcpip` channel.
use russh::ChannelMsg;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpStream;
use crate::orchestration::russh_runner::session::SessionHandle;
const COPY_BUFFER_BYTES: usize = 16 * 1024;
/// Opens a `direct-tcpip` channel to `remote_host:remote_port` over the given
/// SSH session and proxies bytes between it and `local`.
///
/// `originator` is reported to the SSH server as the source of the
/// forwarded connection. Errors are intentionally swallowed: this is a
/// best-effort per-connection proxy and any failure simply closes both ends.
pub(super) async fn proxy_one_connection(
session: &SessionHandle,
mut local: TcpStream,
remote_host: String,
remote_port: u16,
originator: (String, u16),
) {
let channel = match session
.channel_open_direct_tcpip(
remote_host,
u32::from(remote_port),
originator.0,
u32::from(originator.1),
)
.await
{
Ok(channel) => channel,
Err(_) => {
let _ = local.shutdown().await;
return;
}
};
let mut channel = channel;
let (mut local_read, mut local_write) = local.split();
let mut buf = vec![0u8; COPY_BUFFER_BYTES];
let mut local_eof = false;
loop {
tokio::select! {
biased;
read = local_read.read(&mut buf), if !local_eof => {
match read {
Ok(0) => {
local_eof = true;
let _ = channel.eof().await;
}
Ok(n) => {
if channel.data(&buf[..n]).await.is_err() {
break;
}
}
Err(_) => {
let _ = channel.eof().await;
break;
}
}
}
msg = channel.wait() => {
match msg {
Some(ChannelMsg::Data { data }) => {
if local_write.write_all(&data[..]).await.is_err() {
break;
}
}
Some(ChannelMsg::ExtendedData { .. }) => {}
Some(ChannelMsg::Eof) => {
let _ = local_write.shutdown().await;
}
Some(ChannelMsg::ExitStatus { .. }) => {}
Some(ChannelMsg::Close) => break,
Some(_) => {}
None => break,
}
}
}
}
let _ = local_write.shutdown().await;
}

View File

@@ -0,0 +1,137 @@
use std::{
fmt,
process::{Command, Stdio},
};
use serde::{de::DeserializeOwned, Serialize};
use crate::{
errors::{command_failure, failure},
models::CommandResult,
shell::suppress_console_window,
};
/// Host execution bridge used by an operation.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum HostBridge {
/// Direct process invocation.
Native,
/// PowerShell invocation expected to emit exactly one JSON document.
StrictJsonPowerShell,
}
impl fmt::Display for HostBridge {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
HostBridge::Native => f.write_str("native"),
HostBridge::StrictJsonPowerShell => f.write_str("strict-json-powershell"),
}
}
}
/// Concrete command request with a stable operation id.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct StrictCommandSpec {
/// Stable command id used in errors and logs.
pub id: &'static str,
/// Executable path or name.
pub program: String,
/// Command-line arguments.
pub args: Vec<String>,
}
impl StrictCommandSpec {
/// Creates a strict command spec.
pub fn new(
id: &'static str,
program: impl Into<String>,
args: impl IntoIterator<Item = impl Into<String>>,
) -> Self {
Self {
id,
program: program.into(),
args: args.into_iter().map(Into::into).collect(),
}
}
}
/// Runs commands and optionally parses strict JSON output.
#[derive(Debug, Clone, Default)]
pub struct StrictCommandRunner;
impl StrictCommandRunner {
/// Runs a command and returns trimmed stdout text.
pub fn run_text(&self, spec: &StrictCommandSpec) -> CommandResult<String> {
let mut command = Command::new(&spec.program);
suppress_console_window(&mut command);
let output = command
.args(&spec.args)
.stdin(Stdio::null())
.output()
.map_err(|err| failure(format!("Failed to run {}: {err}", spec.id)))?;
if !output.status.success() {
return Err(command_failure(
format!("{} exited with an error", spec.id),
output,
));
}
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
}
/// Runs a command and parses stdout as a single JSON document.
pub fn run_json<T: DeserializeOwned>(&self, spec: &StrictCommandSpec) -> CommandResult<T> {
parse_single_json_document(&self.run_text(spec)?, spec.id)
}
}
/// Parses text that must contain exactly one JSON document.
pub fn parse_single_json_document<T: DeserializeOwned>(
text: &str,
label: &str,
) -> CommandResult<T> {
let mut deserializer = serde_json::Deserializer::from_str(text);
let value = T::deserialize(&mut deserializer)
.map_err(|err| failure(format!("Failed to parse {label} JSON: {err}")))?;
deserializer
.end()
.map_err(|err| failure(format!("{label} produced trailing non-JSON output: {err}")))?;
Ok(value)
}
/// Builds a non-interactive PowerShell command that should emit JSON.
pub fn powershell_json_command(id: &'static str, script: &str) -> StrictCommandSpec {
StrictCommandSpec::new(
id,
"powershell",
[
"-NoProfile",
"-NonInteractive",
"-ExecutionPolicy",
"Bypass",
"-Command",
script,
],
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn strict_json_rejects_trailing_console_text() {
let result =
parse_single_json_document::<serde_json::Value>("{\"ok\":true}\nextra", "sample");
assert!(result.is_err());
}
#[test]
fn strict_json_accepts_single_document() {
let value =
parse_single_json_document::<serde_json::Value>("{\"ok\":true}\n", "sample").unwrap();
assert_eq!(value["ok"], true);
}
}

View File

@@ -0,0 +1,224 @@
use super::models::{sh_single_quoted, UbuntuSshPrepareRequest};
use super::operator_yaml::OPERATOR_DEPLOYMENTS_YAML;
pub(super) fn bootstrap_kubernetes_script(request: &UbuntuSshPrepareRequest) -> String {
format!(
r#"
set -eu
SERVER_ROOT={server_root}
DOWNLOAD_PATH="$SERVER_ROOT/download"
if [ ! -d "$DOWNLOAD_PATH/images/operators/crds" ]; then
echo "Dune server payload is missing operator CRDs at $DOWNLOAD_PATH/images/operators/crds." >&2
exit 1
fi
wait_k3s_ready() {{
elapsed=0
while [ ! -S /run/k3s/containerd/containerd.sock ]; do
sleep 2
elapsed=$((elapsed + 2))
if [ "$elapsed" -ge 180 ]; then
echo "k3s containerd socket did not become ready in 180s" >&2
return 1
fi
done
elapsed=0
until sudo k3s ctr version >/dev/null 2>&1; do
sleep 2
elapsed=$((elapsed + 2))
if [ "$elapsed" -ge 180 ]; then
echo "k3s containerd did not accept commands in 180s" >&2
return 1
fi
done
elapsed=0
until sudo kubectl get nodes >/dev/null 2>&1; do
sleep 2
elapsed=$((elapsed + 2))
if [ "$elapsed" -ge 180 ]; then
echo "k3s API did not become ready in 180s" >&2
return 1
fi
done
}}
load_image_from_file() {{
file_name="$1"
if [ ! -f "$DOWNLOAD_PATH/$file_name" ]; then
echo "Image file $DOWNLOAD_PATH/$file_name does not exist" >&2
exit 1
fi
attempt=1
while [ "$attempt" -le 8 ]; do
wait_k3s_ready
if out=$(sudo k3s ctr images import "$DOWNLOAD_PATH/$file_name" 2>&1); then
return 0
fi
printf '%s\n' "$out" >&2
sleep 10
attempt=$((attempt + 1))
done
echo "Failed to import $file_name after 8 attempts" >&2
exit 1
}}
kubectl_retry() {{
attempt=1
last_out=""
while [ "$attempt" -le 30 ]; do
if out=$(sudo kubectl "$@" 2>&1); then
[ -n "$out" ] && printf '%s\n' "$out" >&2
return 0
fi
last_out="$out"
if printf '%s' "$out" | grep -qiE 'connection refused|unable to connect to the server|i/o timeout|tls handshake|no route to host|EOF|ServiceUnavailable|currently unable to handle the request|Too Many Requests|timeout awaiting response headers'; then
sleep 10
attempt=$((attempt + 1))
continue
fi
printf '%s\n' "$out" >&2
return 1
done
echo "kubectl $* still failing after retries" >&2
[ -n "$last_out" ] && printf '%s\n' "$last_out" >&2
return 1
}}
wait_k3s_settled() {{
elapsed=0
stable=0
while [ "$elapsed" -lt 300 ]; do
if sudo kubectl get --raw=/readyz >/dev/null 2>&1 \
&& sudo kubectl get namespaces >/dev/null 2>&1 \
&& sudo kubectl get nodes >/dev/null 2>&1; then
stable=$((stable + 1))
if [ "$stable" -ge 3 ]; then
return 0
fi
else
stable=0
fi
sleep 10
elapsed=$((elapsed + 10))
done
echo "k3s API did not stay ready for 3 consecutive checks within 300s" >&2
return 1
}}
scale_deployment() {{
ns="$1"
name="$2"
replicas="$3"
elapsed=0
until sudo kubectl get -n "$ns" deployment "$name" >/dev/null 2>&1; do
sleep 2
elapsed=$((elapsed + 2))
if [ "$elapsed" -ge 180 ]; then
echo "deployment $ns/$name did not appear within 180s" >&2
return 1
fi
done
kubectl_retry scale -n "$ns" "deployment/$name" "--replicas=$replicas"
}}
wait_deployment_created() {{
ns="$1"
name="$2"
elapsed=0
until sudo kubectl get -n "$ns" deployment "$name" >/dev/null 2>&1; do
sleep 3
elapsed=$((elapsed + 3))
if [ "$elapsed" -ge 240 ]; then
echo "deployment $ns/$name did not appear within 240s" >&2
return 1
fi
done
}}
wait_k3s_ready
load_image_from_file "images/prerequisites/coredns-coredns.tar"
load_image_from_file "images/prerequisites/local-path-provisioner.tar"
load_image_from_file "images/prerequisites/metrics-server.tar"
load_image_from_file "images/prerequisites/cert-manager-webhook.tar"
load_image_from_file "images/prerequisites/cert-manager-controller.tar"
load_image_from_file "images/prerequisites/cert-manager-cainjector.tar"
load_image_from_file "images/prerequisites/igw-postgres.tar"
wait_k3s_settled
if ! sudo kubectl get deployment cert-manager -n cert-manager >/dev/null 2>&1; then
kubectl_retry apply --validate=false -f https://github.com/cert-manager/cert-manager/releases/download/v1.8.0/cert-manager.yaml
fi
wait_deployment_created cert-manager cert-manager
wait_deployment_created cert-manager cert-manager-cainjector
wait_deployment_created cert-manager cert-manager-webhook
scale_deployment kube-system coredns 1
scale_deployment kube-system local-path-provisioner 1
scale_deployment kube-system metrics-server 1
scale_deployment cert-manager cert-manager 1
scale_deployment cert-manager cert-manager-cainjector 1
scale_deployment cert-manager cert-manager-webhook 1
sudo kubectl create namespace funcom-operators --dry-run=client -o yaml | sudo kubectl apply -f -
node_name=$(sudo kubectl get nodes -o jsonpath='{{.items[0].metadata.name}}')
sudo kubectl label node "$node_name" node.funcom.com/workload=infrastructure --overwrite >/dev/null
load_image_from_file "images/operators/battlegroup-operator.tar"
load_image_from_file "images/operators/database-operator.tar"
load_image_from_file "images/operators/server-operator.tar"
load_image_from_file "images/operators/utilities-operator.tar"
kubectl_retry apply --server-side --validate=false -f "$DOWNLOAD_PATH/images/operators/crds/"
operator_version=$(cat "$DOWNLOAD_PATH/images/operators/version.txt")
manifest="/tmp/dune-operator-deployments.yaml"
cat > "$manifest" <<'YAML'
{operator_deployments}
YAML
sed -i "s/__OPERATOR_VERSION__/$operator_version/g" "$manifest"
kubectl_retry apply --validate=false -f "$manifest"
rm -f "$manifest"
for op in battlegroupoperator databaseoperator serveroperator utilitiesoperator; do
secret="${{op}}-webhook-server-cert"
if ! sudo kubectl get secret "$secret" -n funcom-operators >/dev/null 2>&1; then
sudo openssl req -x509 -nodes -newkey rsa:2048 -days 3650 \
-keyout /tmp/dune-webhook.key -out /tmp/dune-webhook.crt \
-subj "/CN=${{op}}-webhook.funcom-operators.svc" >/dev/null 2>&1
sudo kubectl create secret tls "$secret" -n funcom-operators \
--cert=/tmp/dune-webhook.crt --key=/tmp/dune-webhook.key >/dev/null
sudo rm -f /tmp/dune-webhook.key /tmp/dune-webhook.crt
fi
if ! sudo kubectl get clusterrolebinding "${{op}}-manager-rolebinding" >/dev/null 2>&1; then
sudo kubectl create clusterrolebinding "${{op}}-manager-rolebinding" \
--clusterrole="${{op}}-manager-role" \
--serviceaccount="funcom-operators:${{op}}-controller-manager" >/dev/null
fi
if ! sudo kubectl get role "${{op}}-leader-election-role" -n funcom-operators >/dev/null 2>&1; then
sudo kubectl create role "${{op}}-leader-election-role" \
-n funcom-operators \
--verb=get,list,watch,create,update,patch,delete \
--resource=leases.coordination.k8s.io \
--resource=events >/dev/null
sudo kubectl create rolebinding "${{op}}-leader-election-rolebinding" \
-n funcom-operators \
--role="${{op}}-leader-election-role" \
--serviceaccount="funcom-operators:${{op}}-controller-manager" >/dev/null
fi
done
scale_deployment funcom-operators battlegroupoperator-controller-manager 1
scale_deployment funcom-operators databaseoperator-controller-manager 1
scale_deployment funcom-operators serveroperator-controller-manager 1
scale_deployment funcom-operators utilitiesoperator-controller-manager 1
sudo kubectl wait --for=condition=Available -n funcom-operators deployment/battlegroupoperator-controller-manager --timeout=180s
sudo kubectl wait --for=condition=Available -n funcom-operators deployment/databaseoperator-controller-manager --timeout=180s
sudo kubectl wait --for=condition=Available -n funcom-operators deployment/serveroperator-controller-manager --timeout=180s
sudo kubectl wait --for=condition=Available -n funcom-operators deployment/utilitiesoperator-controller-manager --timeout=180s
"#,
server_root = sh_single_quoted(&request.server_root),
operator_deployments = OPERATOR_DEPLOYMENTS_YAML,
)
}

View File

@@ -0,0 +1,123 @@
pub(super) const PREFLIGHT_SCRIPT: &str = r#"
set -eu
python3 - <<'PY'
import json
import os
import platform
import shutil
import socket
import subprocess
def os_release():
values = {}
try:
with open("/etc/os-release", "r", encoding="utf-8") as handle:
for line in handle:
if "=" not in line:
continue
key, value = line.rstrip("\n").split("=", 1)
values[key] = value.strip('"')
except FileNotFoundError:
pass
return values
def command_ok(*args):
return subprocess.run(args, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL).returncode == 0
def meminfo_value(name):
try:
with open("/proc/meminfo", "r", encoding="utf-8") as handle:
for line in handle:
if line.startswith(name + ":"):
return int(line.split()[1]) * 1024
except FileNotFoundError:
pass
return 0
def public_ip():
for url in ("https://api.ipify.org", "https://ifconfig.me/ip"):
try:
result = subprocess.run(
["curl", "-fsSL", "--max-time", "5", url],
stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL,
text=True,
)
value = result.stdout.strip()
if result.returncode == 0 and value:
return value
except OSError:
return None
return None
release = os_release()
stat = os.statvfs("/")
ip_result = subprocess.run(
["sh", "-c", "ip -o -4 addr show scope global | awk '{print $4}'"],
stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL,
text=True,
)
print(json.dumps({
"hostname": socket.gethostname(),
"osPrettyName": release.get("PRETTY_NAME", ""),
"osId": release.get("ID", ""),
"versionId": release.get("VERSION_ID", ""),
"architecture": platform.machine(),
"kernelRelease": platform.release(),
"user": os.environ.get("USER") or subprocess.run(["id", "-un"], stdout=subprocess.PIPE, text=True).stdout.strip(),
"uid": os.geteuid(),
"passwordlessSudo": os.geteuid() == 0 or command_ok("sudo", "-n", "true"),
"systemdAvailable": shutil.which("systemctl") is not None,
"logicalProcessorCount": os.cpu_count() or 0,
"totalMemoryBytes": meminfo_value("MemTotal"),
"availableMemoryBytes": meminfo_value("MemAvailable"),
"swapTotalBytes": meminfo_value("SwapTotal"),
"rootDiskTotalBytes": stat.f_blocks * stat.f_frsize,
"rootDiskAvailableBytes": stat.f_bavail * stat.f_frsize,
"publicIp": public_ip(),
"ipv4Addresses": [line.strip() for line in ip_result.stdout.splitlines() if line.strip()],
"steamcmdInstalled": os.path.exists("/home/dune/Steam/steamcmd.sh"),
"k3sInstalled": shutil.which("k3s") is not None,
"kubectlAvailable": command_ok("sh", "-c", "command -v kubectl >/dev/null || command -v k3s >/dev/null"),
}))
PY
"#;
pub(super) const K3S_INSTALL_SCRIPT: &str = r#"
set -eu
if [ "$(id -u)" -ne 0 ] && ! sudo -n true >/dev/null 2>&1; then
echo "This setup phase requires root or passwordless sudo." >&2
exit 1
fi
SUDO=""
if [ "$(id -u)" -ne 0 ]; then SUDO="sudo"; fi
if ! command -v k3s >/dev/null 2>&1; then
installer="$(mktemp -t dune-k3s-install.XXXXXX.sh)"
curl -sfL https://get.k3s.io -o "$installer"
chmod 0755 "$installer"
if [ "$(id -u)" -eq 0 ]; then
INSTALL_K3S_EXEC='server --disable=traefik --write-kubeconfig-mode=644' sh "$installer"
else
sudo INSTALL_K3S_EXEC='server --disable=traefik --write-kubeconfig-mode=644' sh "$installer"
fi
rm -f "$installer"
fi
$SUDO systemctl enable k3s >/dev/null
$SUDO systemctl start k3s
elapsed=0
while [ ! -S /run/k3s/containerd/containerd.sock ]; do
sleep 2
elapsed=$((elapsed + 2))
if [ "$elapsed" -ge 120 ]; then echo "k3s containerd did not become ready in 120s" >&2; exit 1; fi
done
elapsed=0
until $SUDO kubectl get nodes >/dev/null 2>&1; do
sleep 2
elapsed=$((elapsed + 2))
if [ "$elapsed" -ge 120 ]; then echo "k3s API did not become ready in 120s" >&2; exit 1; fi
done
$SUDO kubectl wait --for=condition=Ready node --all --timeout=180s >/dev/null || true
"#;

View File

@@ -0,0 +1,12 @@
//! Ubuntu-over-SSH remote setup phases.
mod kubernetes_bootstrap;
mod kubernetes_scripts;
mod models;
mod operator_yaml;
mod provider;
mod scripts;
mod swap_script;
pub use models::*;
pub use provider::*;

View File

@@ -0,0 +1,208 @@
use serde::{Deserialize, Serialize};
use crate::{errors::failure, models::CommandResult};
pub(super) const DEFAULT_SERVER_ROOT: &str = "/home/dune/.dune";
pub(super) const DEFAULT_LINUX_USER: &str = "dune";
pub(super) const DEFAULT_STEAMCMD_URL: &str =
"https://steamcdn-a.akamaihd.net/client/installer/steamcmd_linux.tar.gz";
pub(super) const SERVER_APP_ID: &str = "4754530";
pub(super) const LEGACY_SERVER_APP_ID: &str = "3104830";
/// Read-only inventory of a remote Ubuntu host before setup begins.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct UbuntuSshPreflight {
/// Kernel host name.
pub hostname: String,
/// Operating system pretty name from `/etc/os-release`.
pub os_pretty_name: String,
/// Distribution identifier from `/etc/os-release`.
pub os_id: String,
/// Distribution version identifier.
pub version_id: String,
/// CPU architecture reported by Python's platform module.
pub architecture: String,
/// Linux kernel release.
pub kernel_release: String,
/// Connected SSH username.
pub user: String,
/// Effective user id for the SSH session.
pub uid: u32,
/// Whether the session can run privileged commands without a password.
pub passwordless_sudo: bool,
/// Whether `systemctl` is available.
pub systemd_available: bool,
/// Logical CPU count.
pub logical_processor_count: u32,
/// Total physical memory in bytes.
pub total_memory_bytes: u64,
/// Available physical memory in bytes.
pub available_memory_bytes: u64,
/// Configured swap in bytes.
pub swap_total_bytes: u64,
/// Root filesystem size in bytes.
pub root_disk_total_bytes: u64,
/// Root filesystem free bytes.
pub root_disk_available_bytes: u64,
/// Public egress IP detected from the host, if reachable.
pub public_ip: Option<String>,
/// Non-loopback IPv4 addresses found on the host.
pub ipv4_addresses: Vec<String>,
/// Whether the app-owned SteamCMD path already exists.
pub steamcmd_installed: bool,
/// Whether k3s is already installed.
pub k3s_installed: bool,
/// Whether kubectl is reachable through k3s.
pub kubectl_available: bool,
}
/// Request for creating and enabling a native Ubuntu swapfile.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UbuntuSwapRequest {
/// Swapfile size in GiB.
pub swap_size_gib: u64,
}
impl UbuntuSwapRequest {
/// Creates a request for a fixed-size `/swapfile`.
pub fn new(swap_size_gib: u64) -> Self {
Self { swap_size_gib }
}
/// Validates the requested swapfile size.
pub fn validate(&self) -> CommandResult<()> {
if !(1..=256).contains(&self.swap_size_gib) {
return Err(failure("Ubuntu swap size must be between 1 and 256 GiB"));
}
Ok(())
}
}
/// Result of applying the native Ubuntu swapfile configuration.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct UbuntuSwapResult {
/// Whether a swapfile exists after configuration.
pub swap_file_exists: bool,
/// Whether swap is active after configuration.
pub swap_active: bool,
/// Configured `/swapfile` size in bytes.
pub swap_file_bytes: u64,
/// Total swap bytes reported by `/proc/meminfo`.
pub swap_total_bytes: u64,
/// Whether `/etc/fstab` contains the `/swapfile` entry.
pub fstab_configured: bool,
/// Whether k3s kubelet is configured for limited swap.
pub kubelet_swap_configured: bool,
}
/// Request for preparing a fresh Ubuntu host for Dune server installation.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UbuntuSshPrepareRequest {
/// Remote user that owns the server payload and writable config.
pub linux_user: String,
/// Root directory for app-managed server state.
pub server_root: String,
/// URL for the SteamCMD Linux tarball.
pub steamcmd_url: String,
}
impl Default for UbuntuSshPrepareRequest {
fn default() -> Self {
Self {
linux_user: DEFAULT_LINUX_USER.to_string(),
server_root: DEFAULT_SERVER_ROOT.to_string(),
steamcmd_url: DEFAULT_STEAMCMD_URL.to_string(),
}
}
}
impl UbuntuSshPrepareRequest {
/// Validates names and absolute paths before sending shell to the host.
pub fn validate(&self) -> CommandResult<()> {
validate_linux_user(&self.linux_user)?;
validate_absolute_path(&self.server_root, "server root")?;
if self.steamcmd_url.trim().is_empty()
|| self.steamcmd_url.contains('\n')
|| self.steamcmd_url.contains('\r')
{
return Err(failure("SteamCMD source URL is required"));
}
Ok(())
}
}
/// Remote paths prepared for subsequent Ubuntu setup phases.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct UbuntuSshPreparedHost {
/// Remote user that owns the server files.
pub linux_user: String,
/// Server root directory.
pub server_root: String,
/// Server payload download directory.
pub download_path: String,
/// SteamCMD shell script path.
pub steamcmd_path: String,
}
/// Result of downloading the Steam server package on Ubuntu.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct UbuntuServerPayload {
/// Server payload directory.
pub download_path: String,
/// Whether the expected setup script is present.
pub setup_script_present: bool,
/// Whether the expected battlegroup script is present.
pub battlegroup_script_present: bool,
}
pub(super) fn validate_linux_user(value: &str) -> CommandResult<()> {
if value.is_empty()
|| value.len() > 32
|| !value
.chars()
.all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '-' || ch == '_')
|| value.starts_with('-')
{
return Err(failure(
"Linux user must contain only lowercase letters, digits, hyphen, or underscore",
));
}
Ok(())
}
pub(super) fn validate_absolute_path(value: &str, label: &str) -> CommandResult<()> {
if !value.starts_with('/') || value == "/" || value.contains('\n') || value.contains('\r') {
return Err(failure(format!("{label} must be an absolute Linux path")));
}
Ok(())
}
pub(super) fn sh_single_quoted(value: &str) -> String {
format!("'{}'", value.replace('\'', "'\"'\"'"))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_request_uses_app_owned_guest_paths() {
let request = UbuntuSshPrepareRequest::default();
assert_eq!(request.linux_user, "dune");
assert_eq!(request.server_root, "/home/dune/.dune");
request.validate().unwrap();
}
#[test]
fn rejects_non_absolute_server_root() {
let request = UbuntuSshPrepareRequest {
server_root: "relative".to_string(),
..UbuntuSshPrepareRequest::default()
};
assert!(request.validate().is_err());
}
}

View File

@@ -0,0 +1,225 @@
pub(super) const OPERATOR_DEPLOYMENTS_YAML: &str = r#"apiVersion: v1
kind: ServiceAccount
metadata:
name: battlegroupoperator-controller-manager
namespace: funcom-operators
---
apiVersion: v1
kind: ServiceAccount
metadata:
name: databaseoperator-controller-manager
namespace: funcom-operators
---
apiVersion: v1
kind: ServiceAccount
metadata:
name: serveroperator-controller-manager
namespace: funcom-operators
---
apiVersion: v1
kind: ServiceAccount
metadata:
name: utilitiesoperator-controller-manager
namespace: funcom-operators
---
apiVersion: apps/v1
kind: Deployment
metadata:
labels:
control-plane: battlegroup-controller-manager
name: battlegroupoperator-controller-manager
namespace: funcom-operators
spec:
replicas: 1
selector:
matchLabels:
control-plane: battlegroup-controller-manager
template:
metadata:
labels:
control-plane: battlegroup-controller-manager
spec:
serviceAccountName: battlegroupoperator-controller-manager
containers:
- name: manager
command: ["/app/operator"]
args:
- --leader-elect
- --database-default-port=15432
- --filebrowser-default-port=18888
- --pghero-default-port=21111
- --zap-devel=false
- --zap-log-level=debug
- --zap-time-encoding=iso8601
- --bg-max-concurrent=2
- --dr-max-concurrent=2
- --sr-max-concurrent=2
- --cfo-taints-ignore=node.kubernetes.io/unschedulable,node.funcom.com/new
image: registry.funcom.com/funcom/self-hosting/igw-k8s-battlegroup-operator:__OPERATOR_VERSION__
imagePullPolicy: IfNotPresent
ports:
- containerPort: 9443
name: webhook-server
volumeMounts:
- mountPath: /tmp/k8s-webhook-server/serving-certs
name: cert
readOnly: true
volumes:
- name: cert
secret:
secretName: battlegroupoperator-webhook-server-cert
---
apiVersion: apps/v1
kind: Deployment
metadata:
labels:
control-plane: database-controller-manager
name: databaseoperator-controller-manager
namespace: funcom-operators
spec:
replicas: 1
selector:
matchLabels:
control-plane: database-controller-manager
template:
metadata:
labels:
control-plane: database-controller-manager
spec:
serviceAccountName: databaseoperator-controller-manager
containers:
- name: manager
command: ["/app/operator"]
args:
- --leader-elect
- --zap-devel=false
- --zap-log-level=debug
- --zap-time-encoding=iso8601
- --db-max-concurrent=1
- --dbdepl-max-concurrent=1
- --dbutil-max-concurrent=1
- --dbop-max-concurrent=1
- --dbb-max-concurrent=1
- --dbbs-max-concurrent=1
- --dbr-max-concurrent=1
- --dbm-max-concurrent=1
- --dbutil-supports-prometheus=false
image: registry.funcom.com/funcom/self-hosting/igw-k8s-database-operator:__OPERATOR_VERSION__
imagePullPolicy: IfNotPresent
ports:
- containerPort: 9443
name: webhook-server
volumeMounts:
- mountPath: /tmp/k8s-webhook-server/serving-certs
name: cert
readOnly: true
volumes:
- name: cert
secret:
secretName: databaseoperator-webhook-server-cert
---
apiVersion: apps/v1
kind: Deployment
metadata:
labels:
control-plane: server-controller-manager
name: serveroperator-controller-manager
namespace: funcom-operators
spec:
replicas: 1
selector:
matchLabels:
control-plane: server-controller-manager
template:
metadata:
labels:
control-plane: server-controller-manager
spec:
serviceAccountName: serveroperator-controller-manager
containers:
- name: manager
command: ["/app/operator"]
args:
- --leader-elect
- --zap-devel=false
- --zap-log-level=debug
- --zap-time-encoding=iso8601
- --sg-max-concurrent=2
- --ss-max-concurrent=2
image: registry.funcom.com/funcom/self-hosting/igw-k8s-server-operator:__OPERATOR_VERSION__
imagePullPolicy: IfNotPresent
ports:
- containerPort: 9443
name: webhook-server
volumeMounts:
- mountPath: /tmp/k8s-webhook-server/serving-certs
name: cert
readOnly: true
volumes:
- name: cert
secret:
secretName: serveroperator-webhook-server-cert
---
apiVersion: apps/v1
kind: Deployment
metadata:
labels:
control-plane: utilities-controller-manager
name: utilitiesoperator-controller-manager
namespace: funcom-operators
spec:
replicas: 1
selector:
matchLabels:
control-plane: utilities-controller-manager
template:
metadata:
labels:
control-plane: utilities-controller-manager
spec:
serviceAccountName: utilitiesoperator-controller-manager
containers:
- name: manager
command: ["/app/operator"]
args:
- --leader-elect
- --zap-devel=false
- --zap-log-level=debug
- --zap-time-encoding=iso8601
- --sgw-max-concurrent=2
- --bgd-max-concurrent=2
- --fb-max-concurrent=1
- --mq-max-concurrent=2
- --tr-max-concurrent=2
image: registry.funcom.com/funcom/self-hosting/igw-k8s-utilities-operator:__OPERATOR_VERSION__
imagePullPolicy: IfNotPresent
ports:
- containerPort: 9443
name: webhook-server
volumeMounts:
- mountPath: /tmp/k8s-webhook-server/serving-certs
name: cert
readOnly: true
volumes:
- name: cert
secret:
secretName: utilitiesoperator-webhook-server-cert
"#;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn ubuntu_operator_manifest_matches_vendor_database_concurrency_patch() {
assert!(OPERATOR_DEPLOYMENTS_YAML.contains("--db-max-concurrent=1"));
assert!(OPERATOR_DEPLOYMENTS_YAML.contains("--dbdepl-max-concurrent=1"));
assert!(OPERATOR_DEPLOYMENTS_YAML.contains("--dbutil-max-concurrent=1"));
assert!(OPERATOR_DEPLOYMENTS_YAML.contains("--dbop-max-concurrent=1"));
assert!(OPERATOR_DEPLOYMENTS_YAML.contains("--dbb-max-concurrent=1"));
assert!(OPERATOR_DEPLOYMENTS_YAML.contains("--dbbs-max-concurrent=1"));
assert!(OPERATOR_DEPLOYMENTS_YAML.contains("--dbr-max-concurrent=1"));
assert!(OPERATOR_DEPLOYMENTS_YAML.contains("--dbm-max-concurrent=1"));
assert!(!OPERATOR_DEPLOYMENTS_YAML.contains("--dbutil-max-concurrent=2"));
}
}

View File

@@ -0,0 +1,235 @@
use serde_json::json;
use crate::{
errors::failure,
models::CommandResult,
orchestration::{
parse_single_json_document, OperationSink, OrchestrationEvent, ProviderKind,
RemoteCommandRunner, StepAction, StepDomain,
},
};
use super::kubernetes_bootstrap::bootstrap_kubernetes_script;
use super::kubernetes_scripts::{K3S_INSTALL_SCRIPT, PREFLIGHT_SCRIPT};
use super::models::{
sh_single_quoted, UbuntuServerPayload, UbuntuSshPreflight, UbuntuSshPrepareRequest,
UbuntuSshPreparedHost, UbuntuSwapRequest, UbuntuSwapResult,
};
use super::scripts::{install_payload_script, prepare_host_script};
use super::swap_script::ubuntu_swap_script;
/// SSH-backed Ubuntu setup phases for remote or bare-metal servers.
#[derive(Debug, Clone)]
pub struct UbuntuSshSetup<R> {
runner: R,
}
impl<R> UbuntuSshSetup<R>
where
R: RemoteCommandRunner,
{
/// Creates an Ubuntu SSH setup provider from a remote command runner.
pub fn new(runner: R) -> Self {
Self { runner }
}
/// Performs read-only OS, resource, and tool detection.
pub fn preflight(&self) -> CommandResult<UbuntuSshPreflight> {
let output = self.runner.run_script(PREFLIGHT_SCRIPT)?;
let result: UbuntuSshPreflight = parse_single_json_document(&output, "ubuntu preflight")?;
if result.os_id != "ubuntu" {
return Err(failure(format!(
"Remote host is {}, expected Ubuntu",
result.os_pretty_name
)));
}
Ok(result)
}
/// Installs base packages, creates the service user, and installs SteamCMD.
pub fn prepare_host(
&self,
request: &UbuntuSshPrepareRequest,
sink: &mut impl OperationSink,
) -> CommandResult<UbuntuSshPreparedHost> {
request.validate()?;
emit(
sink,
"ubuntu.prepare.packages",
"Installing Ubuntu prerequisites.",
StepDomain::Guest,
StepAction::Configure,
);
let output = self
.runner
.run_script(&prepare_host_script(request, false))?;
parse_single_json_document(&output, "ubuntu prepare host")
}
/// Installs or starts k3s using systemd.
pub fn install_k3s(
&self,
request: &UbuntuSshPrepareRequest,
sink: &mut impl OperationSink,
) -> CommandResult<()> {
request.validate()?;
emit(
sink,
"ubuntu.k3s.install",
"Installing or validating k3s.",
StepDomain::Guest,
StepAction::Configure,
);
self.runner.run_script(K3S_INSTALL_SCRIPT)?;
Ok(())
}
/// Creates and enables a native Ubuntu swapfile for low-memory hosts.
pub fn configure_swap(
&self,
request: &UbuntuSwapRequest,
sink: &mut impl OperationSink,
) -> CommandResult<UbuntuSwapResult> {
request.validate()?;
emit(
sink,
"ubuntu.swap.native",
"Creating or validating Ubuntu swapfile.",
StepDomain::Guest,
StepAction::Configure,
);
let output = self
.runner
.run_script(&ubuntu_swap_script(request.swap_size_gib))?;
parse_single_json_document(&output, "ubuntu swap")
}
/// Bootstraps cert-manager and the initial Funcom operator deployments on fresh Ubuntu.
pub fn bootstrap_kubernetes(
&self,
request: &UbuntuSshPrepareRequest,
sink: &mut impl OperationSink,
) -> CommandResult<()> {
request.validate()?;
emit(
sink,
"ubuntu.k3s.bootstrap",
"Bootstrapping Kubernetes images and operators.",
StepDomain::Kubernetes,
StepAction::Configure,
);
self.runner
.run_script(&bootstrap_kubernetes_script(request))?;
Ok(())
}
/// Downloads the Dune server package through SteamCMD on the Ubuntu host.
pub fn install_server_payload(
&self,
request: &UbuntuSshPrepareRequest,
sink: &mut impl OperationSink,
) -> CommandResult<UbuntuServerPayload> {
request.validate()?;
emit(
sink,
"ubuntu.steam.download",
"Installing or validating the Dune server payload.",
StepDomain::Steam,
StepAction::Download,
);
let output = self.runner.run_script(&install_payload_script(request))?;
parse_single_json_document(&output, "ubuntu server payload")
}
/// Writes the player-facing address consumed by the vendor world creation scripts.
pub fn write_player_settings(
&self,
player_ip: &str,
sink: &mut impl OperationSink,
) -> CommandResult<()> {
if player_ip.trim().is_empty() || player_ip.contains('\n') || player_ip.contains('\r') {
return Err(failure("Player-facing IP is required"));
}
emit(
sink,
"ubuntu.settings.player-ip",
"Writing player-facing server address.",
StepDomain::Guest,
StepAction::Configure,
);
let script = format!(
"set -eu\nmkdir -p /home/dune/.dune\nprintf '\\n\\n\\n%s\\n' {} > /home/dune/.dune/settings.conf\nchown -R dune:dune /home/dune/.dune\n",
sh_single_quoted(player_ip)
);
self.runner.run_script(&script)?;
Ok(())
}
/// Removes vendor scheduler references so fresh Ubuntu hosts can use the default scheduler.
pub fn use_default_scheduler(
&self,
namespace: &str,
battlegroup_name: &str,
sink: &mut impl OperationSink,
) -> CommandResult<()> {
emit(
sink,
"ubuntu.scheduler.default",
"Using the default Kubernetes scheduler for Ubuntu.",
StepDomain::Kubernetes,
StepAction::Patch,
);
let command = format!(
"sudo kubectl get battlegroup {} -n {} -o json",
sh_single_quoted(battlegroup_name),
sh_single_quoted(namespace),
);
let value = self
.runner
.run_json(&command, "ubuntu battlegroup scheduler patch source")?;
let sets = value["spec"]["serverGroup"]["template"]["spec"]["sets"]
.as_array()
.cloned()
.unwrap_or_default();
let operations = sets
.iter()
.enumerate()
.filter(|(_, item)| item.get("schedulerName").is_some())
.map(|(index, _)| {
json!({
"op": "remove",
"path": format!("/spec/serverGroup/template/spec/sets/{index}/schedulerName"),
})
})
.collect::<Vec<_>>();
if operations.is_empty() {
return Ok(());
}
let patch = serde_json::to_string(&operations)
.map_err(|err| failure(format!("Failed to serialize scheduler patch: {err}")))?;
let patch_command = format!(
"sudo kubectl patch battlegroup {} -n {} --type=json -p {} -o name",
sh_single_quoted(battlegroup_name),
sh_single_quoted(namespace),
sh_single_quoted(&patch),
);
self.runner.run(&patch_command)?;
Ok(())
}
}
fn emit(
sink: &mut impl OperationSink,
step_id: &'static str,
message: &str,
domain: StepDomain,
action: StepAction,
) {
sink.emit(OrchestrationEvent {
step_id,
message: message.to_string(),
domain,
action,
provider: ProviderKind::Ssh,
});
}

View File

@@ -0,0 +1,180 @@
use super::models::{
sh_single_quoted, UbuntuSshPrepareRequest, LEGACY_SERVER_APP_ID, SERVER_APP_ID,
};
pub(super) fn prepare_host_script(
request: &UbuntuSshPrepareRequest,
force_steamcmd: bool,
) -> String {
format!(
r#"
set -eu
export DEBIAN_FRONTEND=noninteractive
LINUX_USER={linux_user}
SERVER_ROOT={server_root}
STEAMCMD_URL={steamcmd_url}
FORCE_STEAMCMD={force_steamcmd}
if [ "$(id -u)" -ne 0 ] && ! sudo -n true >/dev/null 2>&1; then
echo "This setup phase requires root or passwordless sudo." >&2
exit 1
fi
SUDO=""
if [ "$(id -u)" -ne 0 ]; then SUDO="sudo"; fi
$SUDO apt-get update -y >/dev/null
$SUDO apt-get install -y \
ca-certificates curl tar gzip unzip openssl util-linux iproute2 procps lsb-release \
sudo lib32gcc-s1 lib32stdc++6 >/dev/null
if ! id "$LINUX_USER" >/dev/null 2>&1; then
$SUDO useradd -m -s /bin/bash "$LINUX_USER"
fi
USER_HOME=$(getent passwd "$LINUX_USER" | cut -d: -f6)
STEAM_HOME="$USER_HOME/Steam"
DOWNLOAD_PATH="$SERVER_ROOT/download"
$SUDO mkdir -p "$SERVER_ROOT" "$DOWNLOAD_PATH" "$STEAM_HOME" "$USER_HOME/.steam"
$SUDO chown -R "$LINUX_USER:$LINUX_USER" "$SERVER_ROOT" "$STEAM_HOME" "$USER_HOME/.steam"
if [ "$FORCE_STEAMCMD" = "1" ] || [ ! -x "$STEAM_HOME/steamcmd.sh" ]; then
tmp="$(mktemp -t dune-steamcmd.XXXXXX.tar.gz)"
curl -fsSL "$STEAMCMD_URL" -o "$tmp"
chmod 0644 "$tmp"
sudo -u "$LINUX_USER" tar -xzf "$tmp" -C "$STEAM_HOME"
rm -f "$tmp"
fi
sudo -u "$LINUX_USER" mkdir -p "$USER_HOME/.steam"
sudo -u "$LINUX_USER" ln -sfn "$STEAM_HOME" "$USER_HOME/.steam/root"
sudo -u "$LINUX_USER" ln -sfn "$STEAM_HOME" "$USER_HOME/.steam/steam"
printf '{{"linuxUser":%s,"serverRoot":%s,"downloadPath":%s,"steamcmdPath":%s}}\n' \
"$(json_quote "$LINUX_USER")" \
"$(json_quote "$SERVER_ROOT")" \
"$(json_quote "$DOWNLOAD_PATH")" \
"$(json_quote "$STEAM_HOME/steamcmd.sh")"
"#,
linux_user = sh_single_quoted(&request.linux_user),
server_root = sh_single_quoted(&request.server_root),
steamcmd_url = sh_single_quoted(&request.steamcmd_url),
force_steamcmd = if force_steamcmd { "1" } else { "0" },
)
.replacen(
"set -eu\n",
"set -eu\njson_quote() { python3 -c 'import json,sys; print(json.dumps(sys.argv[1]))' \"$1\"; }\n",
1,
)
}
pub(super) fn install_payload_script(request: &UbuntuSshPrepareRequest) -> String {
format!(
r#"
set -eu
LINUX_USER={linux_user}
SERVER_ROOT={server_root}
STEAMCMD_URL={steamcmd_url}
DOWNLOAD_PATH="$SERVER_ROOT/download"
USER_HOME=$(getent passwd "$LINUX_USER" | cut -d: -f6)
STEAMCMD="$USER_HOME/Steam/steamcmd.sh"
STEAM_HOME="$USER_HOME/Steam"
if [ ! -x "$STEAMCMD" ]; then
echo "SteamCMD is missing at $STEAMCMD; reinstalling it." >&2
mkdir -p "$STEAM_HOME" "$USER_HOME/.steam"
chown -R "$LINUX_USER:$LINUX_USER" "$STEAM_HOME" "$USER_HOME/.steam"
tmp="$(mktemp -t dune-steamcmd.XXXXXX.tar.gz)"
curl -fsSL "$STEAMCMD_URL" -o "$tmp"
chmod 0644 "$tmp"
sudo -u "$LINUX_USER" tar -xzf "$tmp" -C "$STEAM_HOME"
rm -f "$tmp"
sudo -u "$LINUX_USER" ln -sfn "$STEAM_HOME" "$USER_HOME/.steam/root"
sudo -u "$LINUX_USER" ln -sfn "$STEAM_HOME" "$USER_HOME/.steam/steam"
fi
if [ ! -x "$STEAMCMD" ]; then
echo "SteamCMD install did not produce an executable at $STEAMCMD." >&2
exit 1
fi
mkdir -p "$DOWNLOAD_PATH"
chown -R "$LINUX_USER:$LINUX_USER" "$SERVER_ROOT"
if [ -f "$DOWNLOAD_PATH/steamapps/appmanifest_{legacy_app_id}.acf" ] && [ ! -f "$DOWNLOAD_PATH/steamapps/appmanifest_{app_id}.acf" ]; then
find "$DOWNLOAD_PATH" -mindepth 1 -maxdepth 1 -exec rm -rf {{}} +
mkdir -p "$DOWNLOAD_PATH"
chown -R "$LINUX_USER:$LINUX_USER" "$DOWNLOAD_PATH"
elif [ -f "$DOWNLOAD_PATH/steamapps/appmanifest_{legacy_app_id}.acf" ]; then
rm -f "$DOWNLOAD_PATH/steamapps/appmanifest_{legacy_app_id}.acf"
fi
steamcmd_update_once() {{
sudo -u "$LINUX_USER" env HOME="$USER_HOME" "$STEAMCMD" \
+@ShutdownOnFailedCommand 1 \
+@NoPromptForPassword 1 \
+set_spew_level 1 1 \
+force_install_dir "$DOWNLOAD_PATH" \
+login anonymous \
+app_update {app_id} validate \
+logoff \
+quit < /dev/null >/tmp/dune-steamcmd-stdout.log 2>/tmp/dune-steamcmd-stderr.log
}}
attempt=1
max_attempts=5
while [ "$attempt" -le "$max_attempts" ]; do
if steamcmd_update_once; then
break
fi
status=$?
if [ "$attempt" -ge "$max_attempts" ]; then
cat /tmp/dune-steamcmd-stdout.log >&2 || true
cat /tmp/dune-steamcmd-stderr.log >&2 || true
echo "SteamCMD payload download failed after $max_attempts attempts, last exit code $status." >&2
exit "$status"
fi
sleep_seconds=$((attempt * 20))
sleep "$sleep_seconds"
attempt=$((attempt + 1))
done
SETUP_PRESENT=false
BG_PRESENT=false
[ -f "$DOWNLOAD_PATH/scripts/setup.sh" ] && SETUP_PRESENT=true
[ -f "$DOWNLOAD_PATH/scripts/battlegroup.sh" ] && BG_PRESENT=true
printf '{{"downloadPath":%s,"setupScriptPresent":%s,"battlegroupScriptPresent":%s}}\n' \
"$(json_quote "$DOWNLOAD_PATH")" "$SETUP_PRESENT" "$BG_PRESENT"
"#,
linux_user = sh_single_quoted(&request.linux_user),
server_root = sh_single_quoted(&request.server_root),
steamcmd_url = sh_single_quoted(&request.steamcmd_url),
app_id = SERVER_APP_ID,
legacy_app_id = LEGACY_SERVER_APP_ID,
)
.replacen(
"set -eu\n",
"set -eu\njson_quote() { python3 -c 'import json,sys; print(json.dumps(sys.argv[1]))' \"$1\"; }\n",
1,
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn payload_install_migrates_old_playtest_manifest_to_release_app() {
let script = install_payload_script(&UbuntuSshPrepareRequest::default());
assert!(script.contains("appmanifest_3104830.acf"));
assert!(script.contains("appmanifest_4754530.acf"));
assert!(script.contains("find \"$DOWNLOAD_PATH\" -mindepth 1 -maxdepth 1 -exec rm -rf"));
assert!(script.contains("+app_update 4754530 validate"));
}
#[test]
fn payload_install_repairs_missing_steamcmd() {
let script = install_payload_script(&UbuntuSshPrepareRequest::default());
assert!(script.contains("SteamCMD is missing at $STEAMCMD; reinstalling it."));
assert!(script.contains("curl -fsSL \"$STEAMCMD_URL\" -o \"$tmp\""));
assert!(script.contains("tar -xzf \"$tmp\" -C \"$STEAM_HOME\""));
assert!(script.contains("SteamCMD install did not produce an executable"));
}
}

View File

@@ -0,0 +1,134 @@
pub(super) fn ubuntu_swap_script(swap_size_gib: u64) -> String {
format!(
r#"
set -eu
swap_size_gib={swap_size_gib}
swap_bytes=$((swap_size_gib * 1024 * 1024 * 1024))
if [ "$(id -u)" -ne 0 ] && ! sudo -n true >/dev/null 2>&1; then
echo "This setup phase requires root or passwordless sudo." >&2
exit 1
fi
SUDO=""
if [ "$(id -u)" -ne 0 ]; then SUDO="sudo"; fi
if [ ! -f /swapfile ] || [ "$(stat -c '%s' /swapfile 2>/dev/null || echo 0)" -lt "$swap_bytes" ]; then
$SUDO swapoff /swapfile >/dev/null 2>&1 || true
$SUDO rm -f /swapfile
if command -v fallocate >/dev/null 2>&1; then
$SUDO fallocate -l "$swap_bytes" /swapfile
else
$SUDO dd if=/dev/zero of=/swapfile bs=1M count=$((swap_size_gib * 1024)) status=none
fi
$SUDO chmod 600 /swapfile
$SUDO mkswap /swapfile >/dev/null
fi
if ! grep -Eq '^[[:space:]]*/swapfile[[:space:]]' /etc/fstab 2>/dev/null; then
printf '/swapfile none swap sw 0 0\n' | $SUDO tee -a /etc/fstab >/dev/null
fi
$SUDO swapon /swapfile >/dev/null 2>&1 || true
$SUDO mkdir -p /etc/rancher/k3s
if [ ! -f /etc/rancher/k3s/kubelet-config.yaml ] \
|| ! grep -Eq '^[[:space:]]*kind:[[:space:]]*KubeletConfiguration[[:space:]]*$' /etc/rancher/k3s/kubelet-config.yaml \
|| ! grep -Eq '^[[:space:]]*failSwapOn:[[:space:]]*false[[:space:]]*$' /etc/rancher/k3s/kubelet-config.yaml; then
cat <<'EOF' | $SUDO tee /etc/rancher/k3s/kubelet-config.yaml >/dev/null
apiVersion: kubelet.config.k8s.io/v1beta1
kind: KubeletConfiguration
imageGCHighThresholdPercent: 99
imageGCLowThresholdPercent: 98
failSwapOn: false
memorySwap:
swapBehavior: LimitedSwap
evictionHard:
memory.available: "100Mi"
nodefs.available: "1%"
nodefs.inodesFree: "1%"
imagefs.available: "1%"
imagefs.inodesFree: "1%"
containerLogMaxSize: "50Mi"
containerLogMaxFiles: 2
systemReserved:
memory: "2Gi"
EOF
fi
if ! grep -Eq 'config=/etc/rancher/k3s/kubelet-config.yaml' /etc/rancher/k3s/config.yaml /etc/rancher/k3s/config.yaml.d/*.yaml 2>/dev/null; then
$SUDO mkdir -p /etc/rancher/k3s/config.yaml.d
printf 'kubelet-arg+:\n- config=/etc/rancher/k3s/kubelet-config.yaml\n' | $SUDO tee /etc/rancher/k3s/config.yaml.d/99-dune-manager-swap.yaml >/dev/null
fi
restarted_k3s=false
if systemctl is-active --quiet k3s 2>/dev/null; then
$SUDO systemctl restart k3s
restarted_k3s=true
fi
if [ "$restarted_k3s" = true ]; then
elapsed=0
while [ ! -S /run/k3s/containerd/containerd.sock ]; do
sleep 2
elapsed=$((elapsed + 2))
if [ "$elapsed" -ge 180 ]; then echo "k3s containerd did not return after enabling swap" >&2; exit 1; fi
done
elapsed=0
consecutive_successes=0
while [ "$consecutive_successes" -lt 2 ]; do
if $SUDO kubectl get nodes >/dev/null 2>&1; then
consecutive_successes=$((consecutive_successes + 1))
sleep 2
else
consecutive_successes=0
sleep 2
fi
elapsed=$((elapsed + 2))
if [ "$elapsed" -ge 240 ]; then echo "k3s API did not return after enabling swap" >&2; exit 1; fi
done
$SUDO kubectl wait --for=condition=Ready node --all --timeout=180s >/dev/null || true
fi
swap_file_exists=false
swap_active=false
swap_file_bytes=0
swap_total_bytes=0
fstab_configured=false
kubelet_swap_configured=false
[ -f /swapfile ] && swap_file_exists=true
[ -f /swapfile ] && swap_file_bytes=$(stat -c '%s' /swapfile 2>/dev/null || echo 0)
if awk '$1 == "/swapfile" {{ found = 1 }} END {{ exit(found ? 0 : 1) }}' /proc/swaps 2>/dev/null; then
swap_active=true
fi
swap_total_bytes=$(awk '$1 == "SwapTotal:" {{ print $2 * 1024 }}' /proc/meminfo 2>/dev/null | head -n1)
[ -n "$swap_total_bytes" ] || swap_total_bytes=0
if grep -Eq '^[[:space:]]*/swapfile[[:space:]]' /etc/fstab 2>/dev/null; then
fstab_configured=true
fi
if grep -Eq '^[[:space:]]*kind:[[:space:]]*KubeletConfiguration[[:space:]]*$' /etc/rancher/k3s/kubelet-config.yaml 2>/dev/null \
&& grep -Eq '^[[:space:]]*failSwapOn:[[:space:]]*false[[:space:]]*$' /etc/rancher/k3s/kubelet-config.yaml 2>/dev/null \
&& grep -Eq 'config=/etc/rancher/k3s/kubelet-config.yaml' /etc/rancher/k3s/config.yaml /etc/rancher/k3s/config.yaml.d/*.yaml 2>/dev/null; then
kubelet_swap_configured=true
fi
printf '{{"swapFileExists":%s,"swapActive":%s,"swapFileBytes":%s,"swapTotalBytes":%s,"fstabConfigured":%s,"kubeletSwapConfigured":%s}}\n' \
"$swap_file_exists" "$swap_active" "$swap_file_bytes" "$swap_total_bytes" "$fstab_configured" "$kubelet_swap_configured"
"#
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn ubuntu_swap_uses_k3s_drop_in_without_overwriting_config() {
let script = ubuntu_swap_script(30);
assert!(script.contains("/etc/rancher/k3s/config.yaml.d"));
assert!(script.contains("kubelet-arg+:"));
assert!(!script.contains("tee /etc/rancher/k3s/config.yaml >/dev/null"));
assert!(script.contains("consecutive_successes"));
assert!(script.contains("k3s API did not return after enabling swap"));
}
}

View File

@@ -0,0 +1,162 @@
use super::flow_models::{
step, BattlegroupCommandSpec, FlowSpec, FlowStep, ProviderKind, StepAction, StepDomain,
StepFlags,
};
/// Returns the native replacement map for the vendor battlegroup menu shell.
pub fn battlegroup_management_flow() -> FlowSpec {
FlowSpec {
id: "vendor.battlegroup.hyperv",
title: "Battlegroup management",
provider: ProviderKind::HyperV,
source_scripts: &[
"battlegroup.bat",
"internal-scripts/battlegroup.ps1",
"battlegroup-management/battlegroup.ps1",
"download/scripts/battlegroup.sh",
],
steps: vec![
step(
"bg.host.require-admin",
"Require elevated host privileges",
StepDomain::Host,
StepAction::Check,
"battlegroup.ps1 #Requires -RunAsAdministrator",
"Split into admin VM operations and non-admin guest operations",
StepFlags::new(true, false),
),
step(
"bg.hyperv.get-vm",
"Load vendor VM",
StepDomain::HyperV,
StepAction::Detect,
"Get-VM -Name <vendor-vm-name>",
"Hyper-V provider get_vm",
StepFlags::new(true, false),
),
step(
"bg.ssh.prepare-key",
"Prepare active or bootstrap SSH key",
StepDomain::Ssh,
StepAction::Configure,
"%LOCALAPPDATA% active key, falling back to bundled bootstrap key",
"Rust key candidate manager with Windows ACL helper",
StepFlags::new(false, false),
),
step(
"bg.hyperv.get-ip-if-running",
"Read VM IPv4 when running",
StepDomain::HyperV,
StepAction::Detect,
"Get-VMNetworkAdapter IPAddresses",
"Hyper-V provider vm_ipv4",
StepFlags::new(true, true),
),
step(
"bg.menu.dispatch",
"Dispatch selected command",
StepDomain::Interactive,
StepAction::Choose,
"Read-Host menu",
"Typed command enum",
StepFlags::new(false, false),
),
],
}
}
pub(super) fn battlegroup_kubernetes_step(
id: &'static str,
description: &'static str,
action: StepAction,
source: &'static str,
native_strategy: &'static str,
) -> FlowStep {
let mut flow_step = step(
id,
description,
StepDomain::Kubernetes,
action,
source,
native_strategy,
StepFlags::new(false, false),
);
flow_step.provider = ProviderKind::Kubernetes;
flow_step
}
/// Returns the catalog of supported battlegroup management commands.
pub fn battlegroup_command_catalog() -> Vec<BattlegroupCommandSpec> {
let mut catalog = super::battlegroup_flows_part2::core_command_specs();
catalog.extend(super::battlegroup_flows_part3::extended_command_specs());
catalog
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn battlegroup_catalog_matches_vendor_menu_names() {
let names = battlegroup_command_catalog()
.into_iter()
.map(|command| command.menu_name)
.collect::<Vec<_>>();
assert_eq!(
names,
vec![
"list",
"status",
"start",
"restart",
"stop",
"update",
"edit-battlegroup",
"edit-battlegroup-advanced",
"enable-experimental-swap",
"backup",
"import",
"logs-export",
"operator-logs-export",
"open-file-browser",
"open-director",
"shell-vm",
"shell-pod",
"start-vm",
"stop-vm",
"quit",
]
);
}
#[test]
fn battlegroup_catalog_points_to_native_replacements() {
let catalog = battlegroup_command_catalog();
let text = catalog
.iter()
.flat_map(|command| command.steps.iter())
.map(|step| format!("{} {}", step.source, step.native_strategy))
.collect::<Vec<_>>()
.join("\n");
assert!(!text.contains("/home/dune/.dune/bin/battlegroup command"));
assert!(!text.contains("until replaced"));
assert!(!text.contains("sed | replace"));
for required in [
"StructuredBattlegroupOps::list",
"StructuredBattlegroupOps::status",
"BattlegroupManagementOrchestrator::start_and_wait_director",
"BattlegroupManagementOrchestrator::update",
"StructuredBattlegroupOps::patch_region",
"StructuredBattlegroupOps::export_namespace_logs",
"StructuredBattlegroupOps::export_operator_logs",
"Hyper-V provider start_vm",
"Hyper-V provider stop_vm",
] {
assert!(
text.contains(required),
"missing native strategy {required}"
);
}
}
}

View File

@@ -0,0 +1,163 @@
use super::battlegroup_flows::battlegroup_kubernetes_step;
use super::flow_models::{
step, BattlegroupCommand, BattlegroupCommandSpec, StepAction, StepDomain, StepFlags,
};
pub(super) fn core_command_specs() -> Vec<BattlegroupCommandSpec> {
vec![
BattlegroupCommandSpec::new(
BattlegroupCommand::List,
"list",
"Lists all available battlegroups",
vec![battlegroup_kubernetes_step(
"bg.command.list",
"List battlegroups",
StepAction::Detect,
"kubectl get battlegroups -A -o json",
"StructuredBattlegroupOps::list",
)],
),
BattlegroupCommandSpec::new(
BattlegroupCommand::Status,
"status",
"Shows the status of the selected battlegroup",
vec![battlegroup_kubernetes_step(
"bg.command.status",
"Read battlegroup status snapshot",
StepAction::Detect,
"kubectl get battlegroup/pods/services -o json",
"StructuredBattlegroupOps::status",
)],
),
BattlegroupCommandSpec::new(
BattlegroupCommand::Start,
"start",
"Starts the selected battlegroup",
vec![
battlegroup_kubernetes_step(
"bg.command.start",
"Start battlegroup",
StepAction::Start,
"kubectl patch battlegroup spec.stop=false",
"BattlegroupManagementOrchestrator::start_and_wait_director",
),
step(
"bg.director.wait-port-after-start",
"Wait for Director NodePort",
StepDomain::Kubernetes,
StepAction::Wait,
"kubectl get svc port 11717 nodePort",
"Kubernetes service discovery",
StepFlags::new(false, false),
),
],
),
BattlegroupCommandSpec::new(
BattlegroupCommand::Restart,
"restart",
"Restarts the selected battlegroup",
vec![
battlegroup_kubernetes_step(
"bg.command.restart.stop",
"Stop battlegroup for restart",
StepAction::Stop,
"kubectl patch battlegroup spec.stop=true",
"BattlegroupManagementOrchestrator::restart_and_wait_director",
),
battlegroup_kubernetes_step(
"bg.command.restart.start",
"Start battlegroup after restart",
StepAction::Start,
"kubectl patch battlegroup spec.stop=false",
"BattlegroupManagementOrchestrator::restart_and_wait_director",
),
step(
"bg.director.wait-port-after-restart",
"Wait for Director NodePort",
StepDomain::Kubernetes,
StepAction::Wait,
"kubectl get svc port 11717 nodePort",
"Kubernetes service discovery",
StepFlags::new(false, false),
),
],
),
BattlegroupCommandSpec::new(
BattlegroupCommand::Stop,
"stop",
"Stops the selected battlegroup",
vec![battlegroup_kubernetes_step(
"bg.command.stop",
"Stop battlegroup",
StepAction::Stop,
"kubectl patch battlegroup spec.stop=true",
"BattlegroupManagementOrchestrator::stop",
)],
),
BattlegroupCommandSpec::new(
BattlegroupCommand::Update,
"update",
"Checks for new versions and applies them",
vec![battlegroup_kubernetes_step(
"bg.command.update.wrapper",
"Run vendor `battlegroup update` (steamcmd + operators + maps + image patch)",
StepAction::Patch,
"/home/dune/.dune/bin/battlegroup update",
"BattlegroupManagementOrchestrator::update",
)],
),
BattlegroupCommandSpec::new(
BattlegroupCommand::EditBattlegroup,
"edit-battlegroup",
"Edit settings of the battlegroup",
vec![
step(
"bg.edit.discover-namespace",
"Discover battlegroup namespace",
StepDomain::Kubernetes,
StepAction::Detect,
"kubectl get ns grep funcom-seabass",
"Kubernetes namespace list",
StepFlags::new(false, false),
),
step(
"bg.edit.region",
"Patch region settings",
StepDomain::Kubernetes,
StepAction::Patch,
"kubectl get battlegroup -o json; Rust JSON patch; kubectl patch",
"StructuredBattlegroupOps::patch_region",
StepFlags::new(false, false),
),
],
),
BattlegroupCommandSpec::new(
BattlegroupCommand::EditBattlegroupAdvanced,
"edit-battlegroup-advanced",
"Manually edit the live battlegroup YAML",
vec![step(
"bg.edit-advanced.open",
"Open advanced battlegroup YAML editor",
StepDomain::Kubernetes,
StepAction::Shell,
"kubectl edit battlegroup",
"Future guarded native YAML/diff editor; currently vendor capability metadata",
StepFlags::new(false, true),
)],
),
BattlegroupCommandSpec::new(
BattlegroupCommand::EnableExperimentalSwap,
"enable-experimental-swap",
"Enable experimental swap memory profile",
vec![step(
"bg.swap.enable",
"Enable guest swap and patch BattleGroup memory",
StepDomain::Guest,
StepAction::Configure,
"/home/dune/.dune/bin/battlegroup enable-experimental-swap",
"ExperimentalSwapOrchestrator::enable",
StepFlags::new(false, true),
)],
),
]
}

View File

@@ -0,0 +1,192 @@
use super::battlegroup_flows::battlegroup_kubernetes_step;
use super::flow_models::{
step, BattlegroupCommand, BattlegroupCommandSpec, StepAction, StepDomain, StepFlags,
};
pub(super) fn extended_command_specs() -> Vec<BattlegroupCommandSpec> {
vec![
BattlegroupCommandSpec::new(
BattlegroupCommand::BackupDatabase,
"backup",
"Take a database backup",
vec![step(
"bg.database.backup",
"Take database backup",
StepDomain::Kubernetes,
StepAction::Export,
"/home/dune/.dune/bin/battlegroup backup",
"Future native database backup workflow",
StepFlags::new(false, true),
)],
),
BattlegroupCommandSpec::new(
BattlegroupCommand::ImportDatabase,
"import",
"Import a database backup",
vec![step(
"bg.database.import",
"Import database backup",
StepDomain::Kubernetes,
StepAction::Import,
"/home/dune/.dune/bin/battlegroup import",
"Future guarded native database import workflow",
StepFlags::new(false, true),
)],
),
BattlegroupCommandSpec::new(
BattlegroupCommand::LogsExport,
"logs-export",
"Retrieves logs from all pods in the selected battlegroup",
vec![battlegroup_kubernetes_step(
"bg.command.logs-export",
"Collect battlegroup pod logs",
StepAction::Export,
"kubectl get pods -o json; kubectl logs per container",
"StructuredBattlegroupOps::export_namespace_logs",
)],
),
BattlegroupCommandSpec::new(
BattlegroupCommand::OperatorLogsExport,
"operator-logs-export",
"Retrieves logs from all operator pods",
vec![battlegroup_kubernetes_step(
"bg.command.operator-logs-export",
"Collect operator pod logs",
StepAction::Export,
"kubectl get pods -n funcom-operators -o json; kubectl logs per container",
"StructuredBattlegroupOps::export_operator_logs",
)],
),
BattlegroupCommandSpec::new(
BattlegroupCommand::OpenFileBrowser,
"open-file-browser",
"Open the file browser in a web browser",
vec![step(
"bg.filebrowser.open",
"Open File Browser URL",
StepDomain::Browser,
StepAction::Open,
"Start-Process http://ip:18888",
"Return URL to caller; UI opens it",
StepFlags::new(false, false),
)],
),
BattlegroupCommandSpec::new(
BattlegroupCommand::OpenDirector,
"open-director",
"Open the director web app in a web browser",
vec![
step(
"bg.director.discover-port",
"Discover Director NodePort",
StepDomain::Kubernetes,
StepAction::Detect,
"kubectl get svc port 11717 nodePort",
"Kubernetes service discovery",
StepFlags::new(false, false),
),
step(
"bg.director.open",
"Open Director URL",
StepDomain::Browser,
StepAction::Open,
"Start-Process http://ip:nodePort",
"Return URL to caller; UI opens it",
StepFlags::new(false, false),
),
],
),
BattlegroupCommandSpec::new(
BattlegroupCommand::ShellVm,
"shell-vm",
"Open an SSH shell on the VM",
vec![step(
"bg.shell.vm",
"Open VM shell",
StepDomain::Ssh,
StepAction::Shell,
"ssh -t dune@ip",
"Interactive terminal adapter",
StepFlags::new(false, false),
)],
),
BattlegroupCommandSpec::new(
BattlegroupCommand::ShellPod,
"shell-pod",
"Open a shell inside a pod of the selected battlegroup",
vec![
step(
"bg.shell-pod.discover",
"Discover namespace and pods",
StepDomain::Kubernetes,
StepAction::Detect,
"kubectl get pods -n ns",
"Kubernetes pod list",
StepFlags::new(false, false),
),
step(
"bg.shell-pod.open",
"Open pod shell",
StepDomain::Kubernetes,
StepAction::Shell,
"kubectl exec -it pod -- bash/sh",
"Interactive Kubernetes exec adapter",
StepFlags::new(false, false),
),
],
),
BattlegroupCommandSpec::new(
BattlegroupCommand::StartVm,
"start-vm",
"Start the VM",
vec![
step(
"bg.vm.start",
"Start VM",
StepDomain::HyperV,
StepAction::Start,
"Start-VM",
"Hyper-V provider start_vm",
StepFlags::new(true, false),
),
step(
"bg.vm.wait-ip",
"Wait for VM IPv4",
StepDomain::HyperV,
StepAction::Wait,
"Get-VMNetworkAdapter IPAddresses loop",
"Hyper-V provider wait_ipv4",
StepFlags::new(true, false),
),
],
),
BattlegroupCommandSpec::new(
BattlegroupCommand::StopVm,
"stop-vm",
"Stop the VM",
vec![step(
"bg.vm.stop",
"Stop VM",
StepDomain::HyperV,
StepAction::Stop,
"Stop-VM -Force",
"Hyper-V provider stop_vm",
StepFlags::new(true, false),
)],
),
BattlegroupCommandSpec::new(
BattlegroupCommand::Quit,
"quit",
"Exit this script",
vec![step(
"bg.quit",
"Quit menu",
StepDomain::Interactive,
StepAction::Complete,
"break loop",
"Return control to caller",
StepFlags::new(false, false),
)],
),
]
}

Some files were not shown because too many files have changed in this diff Show More