docs(reference): import Dune: Awakening server-manager references
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:
@@ -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"
|
||||
@@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
std::process::exit(dune_manager_core::cli::run_cli_from_env());
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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, ®ion)?;
|
||||
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"));
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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}")))
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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>;
|
||||
@@ -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};
|
||||
@@ -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;
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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("/")
|
||||
)
|
||||
}
|
||||
@@ -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>,
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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};
|
||||
@@ -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")))
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
"#
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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"));
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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("/")
|
||||
)
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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
|
||||
"#;
|
||||
@@ -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
|
||||
"#;
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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')"));
|
||||
}
|
||||
@@ -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)
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)?,
|
||||
})
|
||||
}
|
||||
@@ -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>,
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
mod mock_providers;
|
||||
mod orchestrator_tests;
|
||||
@@ -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(())
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"
|
||||
)))
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
mod mocks;
|
||||
mod orchestration;
|
||||
@@ -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"));
|
||||
}
|
||||
@@ -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))
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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()),
|
||||
}
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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",
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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(())
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
@@ -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}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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]})
|
||||
);
|
||||
}
|
||||
@@ -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 = ¤t[*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"));
|
||||
}
|
||||
}
|
||||
@@ -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::*;
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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>>;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
"#;
|
||||
@@ -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::*;
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
)],
|
||||
),
|
||||
]
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user