feat: overhaul server automation, files editor, and CS2 setup workflows
This commit is contained in:
Generated
+1
@@ -484,6 +484,7 @@ dependencies = [
|
||||
"bollard",
|
||||
"flate2",
|
||||
"futures",
|
||||
"libc",
|
||||
"prost",
|
||||
"prost-types",
|
||||
"reqwest",
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user