feat: overhaul server automation, files editor, and CS2 setup workflows

This commit is contained in:
2026-02-26 21:01:00 +00:00
parent 44c439e2f9
commit 2a3ad5e78f
40 changed files with 4675 additions and 468 deletions
+1
View File
@@ -484,6 +484,7 @@ dependencies = [
"bollard",
"flate2",
"futures",
"libc",
"prost",
"prost-types",
"reqwest",
+1
View File
@@ -32,6 +32,7 @@ tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] }
# Error handling
anyhow = "1"
thiserror = "2"
libc = "0.2"
# UUID
uuid = { version = "1", features = ["v4"] }
+52 -2
View File
@@ -21,6 +21,17 @@ pub fn container_name(server_uuid: &str) -> String {
format!("{}{}", CONTAINER_PREFIX, server_uuid)
}
fn container_data_path_for_image(image: &str) -> &'static str {
let normalized = image.to_ascii_lowercase();
if normalized.contains("cm2network/cs2") || normalized.contains("joedwards32/cs2") {
return "/home/steam/cs2-dedicated";
}
if normalized.contains("cm2network/csgo") {
return "/home/steam/csgo-dedicated";
}
"/data"
}
impl DockerManager {
async fn run_exec(&self, container_name: &str, cmd: Vec<String>) -> Result<String> {
let exec = self
@@ -100,6 +111,7 @@ impl DockerManager {
/// Create and configure a container for a game server.
pub async fn create_container(&self, spec: &ServerSpec) -> Result<String> {
let name = container_name(&spec.uuid);
let data_mount_path = container_data_path_for_image(&spec.docker_image);
// Build port bindings
let mut port_bindings: HashMap<String, Option<Vec<PortBinding>>> = HashMap::new();
@@ -135,8 +147,10 @@ impl DockerManager {
port_bindings: Some(port_bindings),
network_mode: Some(self.network_name().to_string()),
binds: Some(vec![format!(
"{}:/data",
"{}:{}",
spec.data_path.display()
,
data_mount_path
)]),
..Default::default()
};
@@ -147,7 +161,13 @@ impl DockerManager {
env: Some(env),
exposed_ports: Some(exposed_ports),
host_config: Some(host_config),
working_dir: Some("/data".to_string()),
// Preserve image default working directory when no custom startup command is set.
// Some game images rely on their built-in WORKDIR and entrypoint scripts.
working_dir: if spec.startup_command.is_empty() {
None
} else {
Some(data_mount_path.to_string())
},
cmd: if spec.startup_command.is_empty() {
None
} else {
@@ -268,6 +288,36 @@ impl DockerManager {
}
}
/// Read container runtime metadata (image + env vars) from Docker inspect.
pub async fn container_runtime_metadata(
&self,
server_uuid: &str,
) -> Result<(String, HashMap<String, String>)> {
let name = container_name(server_uuid);
let info = self.client().inspect_container(&name, None).await?;
let image = info
.config
.as_ref()
.and_then(|cfg| cfg.image.clone())
.unwrap_or_default();
let mut env_map = HashMap::new();
if let Some(env_vars) = info
.config
.as_ref()
.and_then(|cfg| cfg.env.clone())
{
for entry in env_vars {
if let Some((key, value)) = entry.split_once('=') {
env_map.insert(key.to_string(), value.to_string());
}
}
}
Ok((image, env_map))
}
/// Stream container logs (stdout + stderr). Returns an owned stream.
pub fn stream_logs(
self: &Arc<Self>,
+91 -22
View File
@@ -34,36 +34,28 @@ fn parse_status_response(response: &str) -> (Vec<Cs2Player>, u32) {
for line in response.lines() {
let trimmed = line.trim();
// Parse max players from "players : X humans, Y bots (Z/M max)"
if trimmed.starts_with("players") && trimmed.contains("max") {
if let Some(max_str) = trimmed.split('/').last() {
if let Some(num) = max_str.split_whitespace().next() {
max_players = num.parse().unwrap_or(0);
}
// Parse max players from status line variants:
// "players : X humans, Y bots (Z/M max)"
// "players : X humans, Y bots (Z max)"
if trimmed.starts_with("players") {
if let Some(parsed_max) = parse_max_players_from_line(trimmed) {
max_players = parsed_max;
}
}
// Player table header: starts with #
if trimmed.starts_with("# userid") {
if trimmed.contains("---------players--------") || trimmed.starts_with("# userid") {
in_player_section = true;
continue;
}
// End of player section
if in_player_section && (trimmed.is_empty() || trimmed.starts_with('#')) {
if trimmed.is_empty() {
in_player_section = false;
continue;
}
if in_player_section && (trimmed == "#end" || trimmed.starts_with("---------")) {
in_player_section = false;
continue;
}
// Parse player lines: "# userid name steamid ..."
if in_player_section && trimmed.starts_with('#') {
let parts: Vec<&str> = trimmed.splitn(6, char::is_whitespace).collect();
if parts.len() >= 4 {
let name = parts.get(2).unwrap_or(&"").trim_matches('"').to_string();
let steamid = parts.get(3).unwrap_or(&"").to_string();
// Parse player lines for both old and current CS2 status formats.
if in_player_section {
if let Some((name, steamid)) = parse_player_line(trimmed) {
players.push(Cs2Player {
name,
steamid,
@@ -77,6 +69,62 @@ fn parse_status_response(response: &str) -> (Vec<Cs2Player>, u32) {
(players, max_players)
}
fn parse_max_players_from_line(line: &str) -> Option<u32> {
let start = line.find('(')?;
let end = line[start + 1..].find(')')? + start + 1;
let inside = &line[start + 1..end];
inside
.split(|c: char| !c.is_ascii_digit())
.filter(|s| !s.is_empty())
.filter_map(|s| s.parse::<u32>().ok())
.max()
}
fn parse_player_line(line: &str) -> Option<(String, String)> {
// Skip table/header rows.
if line.is_empty()
|| line.starts_with("id ")
|| line.contains("userid")
|| line.contains("steamid")
|| line.contains("adr name")
{
return None;
}
// Legacy format: # 2 "Player" STEAM_...
if let Some(quote_start) = line.find('"') {
let quote_end = line[quote_start + 1..].find('"')? + quote_start + 1;
let name = line[quote_start + 1..quote_end].trim().to_string();
if name.is_empty() {
return None;
}
let rest = line[quote_end + 1..].trim();
let steamid = rest.split_whitespace().next()?.to_string();
if steamid.is_empty() {
return None;
}
return Some((name, steamid));
}
// Current CS2 format: ... 'PlayerName'
let quote_end = line.rfind('\'')?;
let before_end = &line[..quote_end];
let quote_start = before_end.rfind('\'')?;
if quote_start >= quote_end {
return None;
}
let name = line[quote_start + 1..quote_end].trim().to_string();
if name.is_empty() {
return None;
}
// New status output does not include steamid in player rows.
Some((name, String::new()))
}
#[cfg(test)]
mod tests {
use super::*;
@@ -91,7 +139,28 @@ players : 2 humans, 0 bots (16/0 max) (not hibernating)
# 3 "Player2" STEAM_1:0:67890 00:10 30 0 active 128000
"#;
let (players, max) = parse_status_response(response);
assert_eq!(max, 0); // simplified parser
assert_eq!(max, 16);
assert_eq!(players.len(), 2);
}
#[test]
fn test_parse_status_current_cs2_format() {
let response = r#"Server: Running [0.0.0.0:27015]
players : 1 humans, 2 bots (0 max) (not hibernating) (unreserved)
---------players--------
id time ping loss state rate adr name
65535 [NoChan] 0 0 challenging 0unknown ''
1 BOT 0 0 active 0 'Rezan'
2 00:21 11 0 active 786432 212.154.6.153:57008 'hibna'
3 BOT 0 0 active 0 'Squad'
#end
"#;
let (players, max) = parse_status_response(response);
assert_eq!(max, 0);
assert_eq!(players.len(), 3);
assert_eq!(players[0].name, "Rezan");
assert_eq!(players[1].name, "hibna");
assert_eq!(players[2].name, "Squad");
}
}
+289 -72
View File
@@ -2,6 +2,11 @@ use std::pin::Pin;
use std::sync::Arc;
use std::time::Instant;
use std::collections::HashMap;
#[cfg(unix)]
use std::ffi::CString;
#[cfg(unix)]
use std::os::unix::ffi::OsStrExt;
use std::path::{Path, PathBuf};
use futures::StreamExt;
use tokio_stream::wrappers::ReceiverStream;
@@ -10,6 +15,7 @@ use tracing::{info, error, warn};
use crate::server::{ServerManager, PortMap};
use crate::filesystem::FileSystem;
use crate::backup::BackupManager;
// Import generated protobuf types
pub mod pb {
@@ -21,14 +27,28 @@ use pb::*;
pub struct DaemonServiceImpl {
server_manager: Arc<ServerManager>,
backup_manager: BackupManager,
daemon_token: String,
start_time: Instant,
}
impl DaemonServiceImpl {
pub fn new(server_manager: Arc<ServerManager>, daemon_token: String) -> Self {
pub fn new(
server_manager: Arc<ServerManager>,
daemon_token: String,
backup_root: PathBuf,
api_url: String,
) -> Self {
let backup_manager = BackupManager::new(
server_manager.clone(),
backup_root,
api_url,
daemon_token.clone(),
);
Self {
server_manager,
backup_manager,
daemon_token,
start_time: Instant::now(),
}
@@ -51,6 +71,41 @@ impl DaemonServiceImpl {
let data_path = self.server_manager.data_root().join(uuid);
FileSystem::new(data_path)
}
async fn get_server_runtime(
&self,
uuid: &str,
) -> Option<(String, HashMap<String, String>)> {
if let Ok(spec) = self.server_manager.get_server(uuid).await {
return Some((spec.docker_image, spec.environment));
}
self.server_manager
.docker()
.container_runtime_metadata(uuid)
.await
.ok()
}
fn env_value(env: &HashMap<String, String>, keys: &[&str]) -> Option<String> {
keys.iter()
.find_map(|k| env.get(*k))
.map(|v| v.trim().to_string())
.filter(|v| !v.is_empty())
}
fn env_u16(env: &HashMap<String, String>, keys: &[&str]) -> Option<u16> {
Self::env_value(env, keys).and_then(|v| v.parse::<u16>().ok())
}
fn env_i32(env: &HashMap<String, String>, keys: &[&str]) -> Option<i32> {
Self::env_value(env, keys).and_then(|v| v.parse::<i32>().ok())
}
fn cs2_rcon_password(env: &HashMap<String, String>) -> String {
Self::env_value(env, &["CS2_RCONPW", "CS2_RCON_PASSWORD", "SRCDS_RCONPW", "RCON_PASSWORD"])
.unwrap_or_else(|| "changeme".to_string())
}
}
type GrpcStream<T> = Pin<Box<dyn futures::Stream<Item = Result<T, Status>> + Send>>;
@@ -88,17 +143,12 @@ impl DaemonService for DaemonServiceImpl {
self.check_auth(&request)?;
let (tx, rx) = tokio::sync::mpsc::channel(32);
let data_root = self.server_manager.data_root().clone();
tokio::spawn(async move {
let mut previous_cpu = read_cpu_sample();
loop {
// Read system stats
let stats = NodeStats {
cpu_percent: 0.0, // TODO: real system stats
memory_used: 0,
memory_total: 0,
disk_used: 0,
disk_total: 0,
};
let stats = read_node_stats(&data_root, &mut previous_cpu);
if tx.send(Ok(stats)).await.is_err() {
break;
}
@@ -281,6 +331,29 @@ impl DaemonService for DaemonServiceImpl {
self.check_auth(&request)?;
let req = request.into_inner();
if let Some((image, env)) = self.get_server_runtime(&req.uuid).await {
let image = image.to_lowercase();
if image.contains("cs2") || image.contains("csgo") {
let host = Self::env_value(&env, &["RCON_HOST"])
.unwrap_or_else(|| "127.0.0.1".to_string());
let port = Self::env_u16(&env, &["RCON_PORT", "CS2_PORT"]).unwrap_or(27015);
let password = Self::cs2_rcon_password(&env);
let address = format!("{}:{}", host, port);
match crate::game::rcon::RconClient::connect(&address, &password).await {
Ok(mut client) => match client.command(&req.command).await {
Ok(_) => return Ok(Response::new(Empty {})),
Err(e) => {
warn!(uuid = %req.uuid, command = %req.command, error = %e, "CS2 RCON command failed");
}
},
Err(e) => {
warn!(uuid = %req.uuid, command = %req.command, error = %e, "CS2 RCON connect failed");
}
}
}
}
self.server_manager
.docker()
.send_command(&req.uuid, &req.command)
@@ -389,8 +462,20 @@ impl DaemonService for DaemonServiceImpl {
request: Request<BackupRequest>,
) -> Result<Response<BackupResponse>, Status> {
self.check_auth(&request)?;
// TODO: implement backup creation
Err(Status::unimplemented("Not yet implemented"))
let req = request.into_inner();
let (_path, size_bytes, checksum) = self
.backup_manager
.create_backup(&req.server_uuid, &req.backup_id)
.await
.map_err(|e| Status::internal(format!("Failed to create backup: {e}")))?;
Ok(Response::new(BackupResponse {
backup_id: req.backup_id,
size_bytes: size_bytes.min(i64::MAX as u64) as i64,
checksum,
success: true,
}))
}
async fn restore_backup(
@@ -398,8 +483,21 @@ impl DaemonService for DaemonServiceImpl {
request: Request<RestoreBackupRequest>,
) -> Result<Response<Empty>, Status> {
self.check_auth(&request)?;
// TODO: implement backup restoration
Err(Status::unimplemented("Not yet implemented"))
let req = request.into_inner();
let cdn_path = if req.cdn_download_url.trim().is_empty() {
None
} else {
Some(req.cdn_download_url.as_str())
};
self
.backup_manager
.restore_backup(&req.server_uuid, &req.backup_id, cdn_path)
.await
.map_err(|e| Status::internal(format!("Failed to restore backup: {e}")))?;
Ok(Response::new(Empty {}))
}
async fn delete_backup(
@@ -407,8 +505,15 @@ impl DaemonService for DaemonServiceImpl {
request: Request<BackupIdentifier>,
) -> Result<Response<Empty>, Status> {
self.check_auth(&request)?;
// TODO: implement backup deletion
Err(Status::unimplemented("Not yet implemented"))
let req = request.into_inner();
self
.backup_manager
.delete_backup(&req.server_uuid, &req.backup_id, None)
.await
.map_err(|e| Status::internal(format!("Failed to delete backup: {e}")))?;
Ok(Response::new(Empty {}))
}
// === Stats ===
@@ -504,19 +609,13 @@ impl DaemonService for DaemonServiceImpl {
.and_then(|v| v.parse::<u16>().ok())
.unwrap_or(25575);
// Try RCON-based player discovery for known games when runtime spec exists.
if let Ok(spec) = self.server_manager.get_server(&uuid).await {
let image = spec.docker_image.to_lowercase();
// Try game-specific player discovery using runtime metadata (works even after daemon restart).
let mut max_from_runtime_env = 0;
if let Some((image, env)) = self.get_server_runtime(&uuid).await {
let image = image.to_lowercase();
if image.contains("minecraft") {
let password_from_env = spec
.environment
.get("RCON_PASSWORD")
.or_else(|| spec.environment.get("MCRCON_PASSWORD"))
.filter(|v| !v.trim().is_empty());
let password = password_from_env
.cloned()
let password = Self::env_value(&env, &["RCON_PASSWORD", "MCRCON_PASSWORD"])
.or_else(|| {
if rcon_enabled_from_properties {
rcon_password_from_properties.clone()
@@ -526,16 +625,9 @@ impl DaemonService for DaemonServiceImpl {
});
if let Some(password) = password {
let host = spec
.environment
.get("RCON_HOST")
.filter(|v| !v.trim().is_empty())
.cloned()
let host = Self::env_value(&env, &["RCON_HOST"])
.unwrap_or_else(|| "127.0.0.1".to_string());
let port = spec
.environment
.get("RCON_PORT")
.and_then(|v| v.parse::<u16>().ok())
let port = Self::env_u16(&env, &["RCON_PORT"])
.unwrap_or(rcon_port_from_properties);
let address = format!("{}:{}", host, port);
@@ -560,43 +652,33 @@ impl DaemonService for DaemonServiceImpl {
}
}
} else if image.contains("csgo") || image.contains("cs2") {
if let Some(password) = spec
.environment
.get("SRCDS_RCONPW")
.or_else(|| spec.environment.get("RCON_PASSWORD"))
.filter(|v| !v.trim().is_empty())
{
let host = spec
.environment
.get("RCON_HOST")
.filter(|v| !v.trim().is_empty())
.cloned()
.unwrap_or_else(|| "127.0.0.1".to_string());
let port = spec
.environment
.get("RCON_PORT")
.and_then(|v| v.parse::<u16>().ok())
.unwrap_or(27015);
let address = format!("{}:{}", host, port);
max_from_runtime_env = Self::env_i32(&env, &["CS2_MAXPLAYERS", "SRCDS_MAXPLAYERS"])
.unwrap_or(0);
match crate::game::cs2::get_players(&address, password).await {
Ok((players, max)) => {
let mapped = players
.into_iter()
.map(|p| Player {
name: p.name,
uuid: p.steamid,
connected_at: 0,
})
.collect();
return Ok(Response::new(PlayerList {
players: mapped,
max_players: max as i32,
}));
}
Err(e) => {
warn!(uuid = %uuid, error = %e, "CS2 RCON player query failed");
}
let host = Self::env_value(&env, &["RCON_HOST"])
.unwrap_or_else(|| "127.0.0.1".to_string());
let port = Self::env_u16(&env, &["RCON_PORT", "CS2_PORT"]).unwrap_or(27015);
let password = Self::cs2_rcon_password(&env);
let address = format!("{}:{}", host, port);
match crate::game::cs2::get_players(&address, &password).await {
Ok((players, max)) => {
let mapped = players
.into_iter()
.map(|p| Player {
name: p.name,
uuid: p.steamid,
connected_at: 0,
})
.collect();
let max_players = if max > 0 { max as i32 } else { max_from_runtime_env };
return Ok(Response::new(PlayerList {
players: mapped,
max_players,
}));
}
Err(e) => {
warn!(uuid = %uuid, error = %e, "CS2 RCON player query failed");
}
}
}
@@ -624,11 +706,146 @@ impl DaemonService for DaemonServiceImpl {
Ok(Response::new(PlayerList {
players: vec![],
max_players: max_from_properties,
max_players: if max_from_runtime_env > 0 {
max_from_runtime_env
} else {
max_from_properties
},
}))
}
}
#[derive(Clone, Copy)]
struct CpuSample {
total: u64,
idle: u64,
}
fn read_node_stats(data_root: &Path, previous_cpu: &mut Option<CpuSample>) -> NodeStats {
let current_cpu = read_cpu_sample();
let cpu_percent = match (*previous_cpu, current_cpu) {
(Some(prev), Some(current)) => calculate_node_cpu_percent(prev, current),
_ => 0.0,
};
*previous_cpu = current_cpu;
let (memory_used, memory_total) = read_memory_stats().unwrap_or((0, 0));
let (disk_used, disk_total) = read_disk_stats(data_root).unwrap_or((0, 0));
NodeStats {
cpu_percent,
memory_used,
memory_total,
disk_used,
disk_total,
}
}
fn read_cpu_sample() -> Option<CpuSample> {
let content = std::fs::read_to_string("/proc/stat").ok()?;
let line = content.lines().next()?;
if !line.starts_with("cpu ") {
return None;
}
let mut values = line
.split_whitespace()
.skip(1)
.filter_map(|value| value.parse::<u64>().ok());
let user = values.next()?;
let nice = values.next()?;
let system = values.next()?;
let idle = values.next()?;
let iowait = values.next().unwrap_or(0);
let irq = values.next().unwrap_or(0);
let softirq = values.next().unwrap_or(0);
let steal = values.next().unwrap_or(0);
let total_idle = idle.saturating_add(iowait);
let total = user
.saturating_add(nice)
.saturating_add(system)
.saturating_add(total_idle)
.saturating_add(irq)
.saturating_add(softirq)
.saturating_add(steal);
Some(CpuSample {
total,
idle: total_idle,
})
}
fn calculate_node_cpu_percent(previous: CpuSample, current: CpuSample) -> f64 {
let total_delta = current.total.saturating_sub(previous.total) as f64;
let idle_delta = current.idle.saturating_sub(previous.idle) as f64;
if total_delta <= 0.0 {
return 0.0;
}
((total_delta - idle_delta) / total_delta * 100.0).clamp(0.0, 100.0)
}
fn read_memory_stats() -> Option<(i64, i64)> {
let content = std::fs::read_to_string("/proc/meminfo").ok()?;
let mut total_kib: Option<u64> = None;
let mut available_kib: Option<u64> = None;
for line in content.lines() {
if line.starts_with("MemTotal:") {
total_kib = line
.split_whitespace()
.nth(1)
.and_then(|value| value.parse::<u64>().ok());
} else if line.starts_with("MemAvailable:") {
available_kib = line
.split_whitespace()
.nth(1)
.and_then(|value| value.parse::<u64>().ok());
}
if total_kib.is_some() && available_kib.is_some() {
break;
}
}
let total_bytes = total_kib?.saturating_mul(1024);
let available_bytes = available_kib?.saturating_mul(1024);
let used_bytes = total_bytes.saturating_sub(available_bytes);
Some((
used_bytes.min(i64::MAX as u64) as i64,
total_bytes.min(i64::MAX as u64) as i64,
))
}
#[cfg(unix)]
fn read_disk_stats(path: &Path) -> Option<(i64, i64)> {
let c_path = CString::new(path.as_os_str().as_bytes()).ok()?;
let mut stats: libc::statvfs = unsafe { std::mem::zeroed() };
if unsafe { libc::statvfs(c_path.as_ptr(), &mut stats) } != 0 {
return None;
}
let block_size = if stats.f_frsize > 0 {
stats.f_frsize as u128
} else {
stats.f_bsize as u128
};
let total = block_size.saturating_mul(stats.f_blocks as u128);
let available = block_size.saturating_mul(stats.f_bavail as u128);
let used = total.saturating_sub(available);
let max = i64::MAX as u128;
Some((used.min(max) as i64, total.min(max) as i64))
}
#[cfg(not(unix))]
fn read_disk_stats(_path: &Path) -> Option<(i64, i64)> {
None
}
/// Calculate CPU percentage from Docker stats.
fn calculate_cpu_percent(stats: &bollard::container::Stats) -> f64 {
let cpu_delta = stats.cpu_stats.cpu_usage.total_usage as f64
+9 -1
View File
@@ -20,6 +20,8 @@ use crate::grpc::DaemonServiceImpl;
use crate::grpc::service::pb::daemon_service_server::DaemonServiceServer;
use crate::server::ServerManager;
const MAX_GRPC_MESSAGE_SIZE_BYTES: usize = 32 * 1024 * 1024;
#[tokio::main]
async fn main() -> Result<()> {
// Initialize logging
@@ -47,6 +49,8 @@ async fn main() -> Result<()> {
let daemon_service = DaemonServiceImpl::new(
server_manager.clone(),
config.node_token.clone(),
config.backup_path.clone(),
config.api_url.clone(),
);
// Start gRPC server
@@ -73,8 +77,12 @@ async fn main() -> Result<()> {
info!("Scheduler initialized");
// Start serving
let daemon_service = DaemonServiceServer::new(daemon_service)
.max_decoding_message_size(MAX_GRPC_MESSAGE_SIZE_BYTES)
.max_encoding_message_size(MAX_GRPC_MESSAGE_SIZE_BYTES);
Server::builder()
.add_service(DaemonServiceServer::new(daemon_service))
.add_service(daemon_service)
.serve_with_shutdown(addr, async {
tokio::signal::ctrl_c().await.ok();
info!("Shutdown signal received");
+43 -6
View File
@@ -4,6 +4,8 @@ use std::sync::Arc;
use tokio::sync::RwLock;
use tracing::{info, error, warn};
use anyhow::Result;
#[cfg(unix)]
use std::os::unix::fs::PermissionsExt;
use crate::config::DaemonConfig;
use crate::docker::DockerManager;
@@ -64,6 +66,15 @@ impl ServerManager {
tokio::fs::create_dir_all(&data_path)
.await
.map_err(DaemonError::Io)?;
#[cfg(unix)]
{
// Containers may run with non-root users (e.g. steam uid 1000).
// Keep server directory writable to avoid install/start failures.
let permissions = std::fs::Permissions::from_mode(0o777);
tokio::fs::set_permissions(&data_path, permissions)
.await
.map_err(DaemonError::Io)?;
}
let spec = ServerSpec {
uuid: uuid.clone(),
@@ -131,23 +142,36 @@ impl ServerManager {
/// Start a server.
pub async fn start_server(&self, uuid: &str) -> Result<(), DaemonError> {
let mut managed = false;
let mut previous_state: Option<ServerState> = None;
{
let mut servers = self.servers.write().await;
if let Some(spec) = servers.get_mut(uuid) {
// Recover from stale transitional state left by a previous failed start attempt.
if spec.state == ServerState::Starting {
warn!(uuid = %uuid, "Recovering stale starting state");
spec.state = ServerState::Stopped;
}
if !spec.can_transition_to(&ServerState::Starting) {
return Err(DaemonError::InvalidStateTransition {
current: spec.state.to_string(),
requested: "starting".to_string(),
});
}
previous_state = Some(spec.state.clone());
spec.state = ServerState::Starting;
managed = true;
}
}
self.docker.start_container(uuid).await.map_err(|e| {
DaemonError::Internal(format!("Failed to start container: {}", e))
})?;
if let Err(e) = self.docker.start_container(uuid).await {
if managed {
let mut servers = self.servers.write().await;
if let Some(spec) = servers.get_mut(uuid) {
spec.state = previous_state.unwrap_or(ServerState::Error);
}
}
return Err(DaemonError::Internal(format!("Failed to start container: {}", e)));
}
if managed {
let mut servers = self.servers.write().await;
@@ -164,23 +188,36 @@ impl ServerManager {
/// Stop a server.
pub async fn stop_server(&self, uuid: &str) -> Result<(), DaemonError> {
let mut managed = false;
let mut previous_state: Option<ServerState> = None;
{
let mut servers = self.servers.write().await;
if let Some(spec) = servers.get_mut(uuid) {
// Recover from stale transitional state left by a previous failed stop attempt.
if spec.state == ServerState::Stopping {
warn!(uuid = %uuid, "Recovering stale stopping state");
spec.state = ServerState::Running;
}
if !spec.can_transition_to(&ServerState::Stopping) {
return Err(DaemonError::InvalidStateTransition {
current: spec.state.to_string(),
requested: "stopping".to_string(),
});
}
previous_state = Some(spec.state.clone());
spec.state = ServerState::Stopping;
managed = true;
}
}
self.docker.stop_container(uuid, 30).await.map_err(|e| {
DaemonError::Internal(format!("Failed to stop container: {}", e))
})?;
if let Err(e) = self.docker.stop_container(uuid, 30).await {
if managed {
let mut servers = self.servers.write().await;
if let Some(spec) = servers.get_mut(uuid) {
spec.state = previous_state.unwrap_or(ServerState::Error);
}
}
return Err(DaemonError::Internal(format!("Failed to stop container: {}", e)));
}
if managed {
let mut servers = self.servers.write().await;