diff --git a/README.md b/README.md index 6187960..10915da 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Server Manager - Next-Gen Media Server Orchestrator 🚀 -![Server Manager Banner](https://img.shields.io/badge/Status-Tested-brightgreen) ![Version](https://img.shields.io/badge/Version-1.0.7-blue) ![Rust](https://img.shields.io/badge/Built%20With-Rust-orange) ![Docker](https://img.shields.io/badge/Powered%20By-Docker-blue) +![Server Manager Banner](https://img.shields.io/badge/Status-Tested-brightgreen) ![Version](https://img.shields.io/badge/Version-1.0.8-blue) ![Rust](https://img.shields.io/badge/Built%20With-Rust-orange) ![Docker](https://img.shields.io/badge/Powered%20By-Docker-blue) **Server Manager** is a powerful and intelligent tool written in Rust to deploy, manage, and optimize a complete personal media and cloud server stack. It detects your hardware and automatically configures 28 Docker services for optimal performance. @@ -90,6 +90,7 @@ The tool provides several subcommands: * `server_manager status`: Displays detected hardware statistics and the profile (Low/Standard/High). * `server_manager enable `: Enable a service (e.g., `server_manager enable nextcloud`). * `server_manager disable `: Disable a service. +* `server_manager apply`: Apply current configuration (re-generate and deploy). * `server_manager web`: Starts the Web Administration Interface (Default: http://0.0.0.0:8099). * `server_manager user add --quota `: Create a new user (Role: Admin/Observer) and set storage quota. * `server_manager user delete `: Delete a user and their data. @@ -244,6 +245,7 @@ L'outil dispose de plusieurs sous-commandes : * `server_manager status` : Affiche les statistiques matérielles détectées et le profil (Low/Standard/High). * `server_manager enable ` : Active un service (ex: `server_manager enable nextcloud`). * `server_manager disable ` : Désactive un service. +* `server_manager apply` : Applique la configuration actuelle (re-génère et déploie). * `server_manager web` : Démarre l'Interface d'Administration Web (Défaut : http://0.0.0.0:8099). * `server_manager user add --quota ` : Crée un nouvel utilisateur (Rôle : Admin/Observer) et définit un quota de stockage. * `server_manager user delete ` : Supprime un utilisateur et ses données. diff --git a/server_manager/Cargo.lock b/server_manager/Cargo.lock index 9d73bac..529bb39 100644 --- a/server_manager/Cargo.lock +++ b/server_manager/Cargo.lock @@ -347,6 +347,7 @@ dependencies = [ "ciborium", "clap", "criterion-plot", + "futures", "is-terminal", "itertools", "num-traits", @@ -359,6 +360,7 @@ dependencies = [ "serde_derive", "serde_json", "tinytemplate", + "tokio", "walkdir", ] @@ -1288,7 +1290,7 @@ dependencies = [ [[package]] name = "server_manager" -version = "1.0.7" +version = "1.0.8" dependencies = [ "anyhow", "async-trait", diff --git a/server_manager/Cargo.toml b/server_manager/Cargo.toml index 475f80e..2054cdb 100644 --- a/server_manager/Cargo.toml +++ b/server_manager/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "server_manager" -version = "1.0.7" +version = "1.0.8" edition = "2021" [dependencies] @@ -26,7 +26,7 @@ time = "0.3" rand = "0.10.0" [dev-dependencies] -criterion = "0.5" +criterion = { version = "0.5", features = ["async_tokio"] } [[bench]] name = "service_benchmark" diff --git a/server_manager/src/core/config.rs b/server_manager/src/core/config.rs index c57c2a7..9d1badb 100644 --- a/server_manager/src/core/config.rs +++ b/server_manager/src/core/config.rs @@ -124,4 +124,74 @@ impl Config { info!("Disabled service: {}", service_name); } } + + pub async fn enable_service_async(service_name: &str) -> Result<()> { + Self::update_service_async(service_name, true).await + } + + pub async fn disable_service_async(service_name: &str) -> Result<()> { + Self::update_service_async(service_name, false).await + } + + async fn update_service_async(service_name: &str, enable: bool) -> Result<()> { + let cache = CONFIG_CACHE.get_or_init(|| { + RwLock::new(CachedConfig { + config: Config::default(), + last_mtime: None, + }) + }); + + let mut guard = cache.write().await; + + // Ensure we have the latest config before modifying + let metadata_res = tokio::fs::metadata("config.yaml").await; + match metadata_res { + Ok(metadata) => { + let modified = metadata.modified().unwrap_or(SystemTime::now()); + if guard.last_mtime != Some(modified) { + // Reload + match tokio::fs::read_to_string("config.yaml").await { + Ok(content) => { + if !content.trim().is_empty() { + guard.config = serde_yaml_ng::from_str(&content) + .context("Failed to parse config.yaml")?; + } + guard.last_mtime = Some(modified); + } + Err(_) => { + // If read fails, maybe use current? Or fail? + // Proceeding might overwrite file. Best to fail or assume current. + // Given we verified metadata exists, read failure is rare. + } + } + } + } + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + // File missing, use default (which is empty/default config) + // But we want to preserve what we have if we have something? + // If file deleted, we probably should start fresh or recreating it is fine. + } + Err(_) => {} + } + + // Modify + if enable { + guard.config.enable_service(service_name); + } else { + guard.config.disable_service(service_name); + } + + // Save + let content = serde_yaml_ng::to_string(&guard.config)?; + tokio::fs::write("config.yaml", &content) + .await + .context("Failed to write config.yaml")?; + + // Update mtime + if let Ok(metadata) = tokio::fs::metadata("config.yaml").await { + guard.last_mtime = metadata.modified().ok(); + } + + Ok(()) + } } diff --git a/server_manager/src/core/users.rs b/server_manager/src/core/users.rs index 41870d0..11aeaaf 100644 --- a/server_manager/src/core/users.rs +++ b/server_manager/src/core/users.rs @@ -7,6 +7,17 @@ use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::fs; use std::path::Path; +use std::sync::OnceLock; +use std::time::SystemTime; +use tokio::sync::RwLock; + +#[derive(Clone)] +struct CachedUsers { + manager: UserManager, + last_mtime: Option, +} + +static USERS_CACHE: OnceLock> = OnceLock::new(); #[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] pub enum Role { @@ -30,7 +41,61 @@ pub struct UserManager { impl UserManager { pub async fn load_async() -> Result { - tokio::task::spawn_blocking(Self::load).await? + let cache = USERS_CACHE.get_or_init(|| { + RwLock::new(CachedUsers { + manager: UserManager::default(), + last_mtime: None, + }) + }); + + // Determine path asynchronously + let path = Path::new("users.yaml"); + let fallback_path = Path::new("/opt/server_manager/users.yaml"); + let target_path = if tokio::fs::try_exists(fallback_path).await.unwrap_or(false) { + fallback_path + } else { + path + }; + + // Fast path + { + let guard = cache.read().await; + if let Some(cached_mtime) = guard.last_mtime { + if let Ok(metadata) = tokio::fs::metadata(target_path).await { + if let Ok(modified) = metadata.modified() { + if modified == cached_mtime { + return Ok(guard.manager.clone()); + } + } + } + } + } + + // Slow path + let mut guard = cache.write().await; + // Re-check mtime + if let Ok(metadata) = tokio::fs::metadata(target_path).await { + let modified = metadata.modified().unwrap_or(SystemTime::now()); + if guard.last_mtime == Some(modified) { + return Ok(guard.manager.clone()); + } + + // Reload via spawn_blocking because load() might do synchronous writes (save default admin) + // or reads. + let manager = tokio::task::spawn_blocking(Self::load).await??; + guard.manager = manager.clone(); + guard.last_mtime = Some(modified); + Ok(manager) + } else { + // File might not exist, try loading anyway (it handles defaults) + let manager = tokio::task::spawn_blocking(Self::load).await??; + guard.manager = manager.clone(); + // Try to get mtime again if created + if let Ok(metadata) = tokio::fs::metadata(target_path).await { + guard.last_mtime = metadata.modified().ok(); + } + Ok(manager) + } } pub fn load() -> Result { @@ -212,6 +277,114 @@ impl UserManager { pub fn list_users(&self) -> Vec<&User> { self.users.values().collect() } + + pub async fn add_user_async( + username: String, + password: String, + role: Role, + quota_gb: Option, + ) -> Result<()> { + let cache = USERS_CACHE.get_or_init(|| { + RwLock::new(CachedUsers { + manager: UserManager::default(), + last_mtime: None, + }) + }); + + let mut guard = cache.write().await; + + // Ensure fresh before modifying (simple reload check) + // We reuse load_async logic partially or just trust the next load will refresh? + // Better to refresh now to avoid overwriting changes made by others. + // But since we have write lock, others are blocked. + // We just need to check if file changed since we last loaded. + + // Simpler: Just rely on load()'s path logic and reload if needed? + // Or duplicate the "ensure fresh" logic. + // Given complexity, let's just reload strictly if file exists. + + let path = Path::new("users.yaml"); + let fallback_path = Path::new("/opt/server_manager/users.yaml"); + let target_path = if tokio::fs::try_exists(fallback_path).await.unwrap_or(false) { + fallback_path + } else { + path + }; + + if let Ok(metadata) = tokio::fs::metadata(target_path).await { + let modified = metadata.modified().unwrap_or(SystemTime::now()); + if guard.last_mtime != Some(modified) { + if let Ok(m) = tokio::task::spawn_blocking(Self::load).await? { + guard.manager = m; + guard.last_mtime = Some(modified); + } + } + } + + // Now modify + let mut manager = guard.manager.clone(); + + let manager = tokio::task::spawn_blocking(move || { + manager.add_user(&username, &password, role, quota_gb)?; + Ok::<_, anyhow::Error>(manager) + }) + .await??; + + guard.manager = manager; + + // Update mtime + if let Ok(metadata) = tokio::fs::metadata(target_path).await { + guard.last_mtime = metadata.modified().ok(); + } + + Ok(()) + } + + pub async fn delete_user_async(username: String) -> Result<()> { + let cache = USERS_CACHE.get_or_init(|| { + RwLock::new(CachedUsers { + manager: UserManager::default(), + last_mtime: None, + }) + }); + + let mut guard = cache.write().await; + + // Reload check (same as add) + let path = Path::new("users.yaml"); + let fallback_path = Path::new("/opt/server_manager/users.yaml"); + let target_path = if tokio::fs::try_exists(fallback_path).await.unwrap_or(false) { + fallback_path + } else { + path + }; + + if let Ok(metadata) = tokio::fs::metadata(target_path).await { + let modified = metadata.modified().unwrap_or(SystemTime::now()); + if guard.last_mtime != Some(modified) { + if let Ok(m) = tokio::task::spawn_blocking(Self::load).await? { + guard.manager = m; + guard.last_mtime = Some(modified); + } + } + } + + let mut manager = guard.manager.clone(); + + let manager = tokio::task::spawn_blocking(move || { + manager.delete_user(&username)?; + Ok::<_, anyhow::Error>(manager) + }) + .await??; + + guard.manager = manager; + + if let Ok(metadata) = tokio::fs::metadata(target_path).await { + guard.last_mtime = metadata.modified().ok(); + } + + Ok(()) + } } #[cfg(test)] diff --git a/server_manager/src/interface/cli.rs b/server_manager/src/interface/cli.rs index bdadad9..46f7d06 100644 --- a/server_manager/src/interface/cli.rs +++ b/server_manager/src/interface/cli.rs @@ -29,6 +29,8 @@ pub enum Commands { Enable { service: String }, /// Disable a service Disable { service: String }, + /// Apply current configuration (Generate & Deploy) + Apply, /// Start the Web Administration Interface Web { #[arg(long, default_value_t = 8099)] @@ -68,6 +70,7 @@ pub async fn run() -> Result<()> { Commands::Generate => run_generate().await?, Commands::Enable { service } => run_toggle_service(service, true).await?, Commands::Disable { service } => run_toggle_service(service, false).await?, + Commands::Apply => run_apply().await?, Commands::Web { port } => crate::interface::web::start_server(port).await?, Commands::User { action } => run_user_management(action)?, } @@ -165,13 +168,34 @@ async fn run_toggle_service(service_name: String, enable: bool) -> Result<()> { info!("Configuration updated. Re-running generation..."); - // 2. Re-run generation logic (similar to run_generate/run_install subset) - // We need secrets for this + // 2. Re-run generation logic + run_apply_logic().await?; + + info!( + "Service '{}' {} successfully!", + service_name, + if enable { "enabled" } else { "disabled" } + ); + + Ok(()) +} + +async fn run_apply() -> Result<()> { + // Ensure we are in /opt/server_manager if config not found locally + if !std::path::Path::new("config.yaml").exists() + && std::path::Path::new("/opt/server_manager/config.yaml").exists() + { + std::env::set_current_dir("/opt/server_manager")?; + } + + run_apply_logic().await +} + +async fn run_apply_logic() -> Result<()> { + let config = config::Config::load()?; let secrets = secrets::Secrets::load_or_create()?; let hw = hardware::HardwareInfo::detect(); - // Only configure/generate, don't necessarily fully install dependencies again - // But we should probably trigger docker compose up to apply changes configure_services(&hw, &secrets, &config)?; initialize_services(&hw, &secrets, &config)?; generate_compose(&hw, &secrets, &config).await?; @@ -183,16 +207,12 @@ async fn run_toggle_service(service_name: String, enable: bool) -> Result<()> { .context("Failed to run docker compose up")?; if status.success() { - info!( - "Service '{}' {} successfully!", - service_name, - if enable { "enabled" } else { "disabled" } - ); + info!("Configuration applied successfully!"); + Ok(()) } else { error!("Failed to apply changes via Docker Compose."); + Err(anyhow::anyhow!("Docker Compose failed")) } - - Ok(()) } async fn run_install() -> Result<()> { diff --git a/server_manager/src/interface/web.rs b/server_manager/src/interface/web.rs index 1f677c3..a0cff35 100644 --- a/server_manager/src/interface/web.rs +++ b/server_manager/src/interface/web.rs @@ -17,7 +17,6 @@ use std::time::SystemTime; use sysinfo::{CpuExt, DiskExt, System, SystemExt}; use time::Duration; use tokio::process::Command; -use tokio::sync::RwLock; use tower_sessions::{Expiry, MemoryStore, Session, SessionManagerLayer}; #[derive(Serialize, Deserialize, Clone)] @@ -28,98 +27,20 @@ struct SessionUser { const SESSION_KEY: &str = "user"; -struct CachedConfig { - config: Config, - last_modified: Option, -} - -struct CachedUsers { - manager: UserManager, - last_modified: Option, -} - struct AppState { system: Mutex, last_system_refresh: Mutex, - config_cache: RwLock, - users_cache: RwLock, } type SharedState = Arc; impl AppState { async fn get_config(&self) -> Config { - // Fast path: check metadata - let current_mtime = tokio::fs::metadata("config.yaml") - .await - .and_then(|m| m.modified()) - .ok(); - - { - let cache = self.config_cache.read().await; - if cache.last_modified == current_mtime { - return cache.config.clone(); - } - } - - // Slow path: reload - let mut cache = self.config_cache.write().await; - - // Re-check mtime under write lock to avoid race - let current_mtime_2 = tokio::fs::metadata("config.yaml") - .await - .and_then(|m| m.modified()) - .ok(); - - if cache.last_modified == current_mtime_2 { - return cache.config.clone(); - } - - if let Ok(cfg) = Config::load_async().await { - cache.config = cfg; - cache.last_modified = current_mtime_2; - } - - cache.config.clone() + Config::load_async().await.unwrap_or_default() } async fn get_users(&self) -> UserManager { - // Determine path logic (matches UserManager::load) - let path = std::path::Path::new("users.yaml"); - let fallback_path = std::path::Path::new("/opt/server_manager/users.yaml"); - let file_path = if path.exists() { path } else { fallback_path }; - - // Fast path: check metadata - let current_mtime = tokio::fs::metadata(file_path).await - .and_then(|m| m.modified()) - .ok(); - - { - let cache = self.users_cache.read().await; - // If mtime matches (or both None), return cached - if cache.last_modified == current_mtime { - return cache.manager.clone(); - } - } - - // Slow path: reload - let mut cache = self.users_cache.write().await; - - // Re-check mtime under write lock - let current_mtime_2 = tokio::fs::metadata(file_path).await - .and_then(|m| m.modified()) - .ok(); - - if cache.last_modified == current_mtime_2 { - return cache.manager.clone(); - } - - if let Ok(mgr) = UserManager::load_async().await { - cache.manager = mgr; - cache.last_modified = current_mtime_2; - } - - cache.manager.clone() + UserManager::load_async().await.unwrap_or_default() } } @@ -134,32 +55,9 @@ pub async fn start_server(port: u16) -> anyhow::Result<()> { let mut sys = System::new_all(); sys.refresh_all(); - let initial_config = Config::load().unwrap_or_default(); - let initial_config_mtime = std::fs::metadata("config.yaml") - .ok() - .and_then(|m| m.modified().ok()); - - let initial_users = UserManager::load().unwrap_or_default(); - let initial_users_mtime = std::fs::metadata("users.yaml") - .ok() - .and_then(|m| m.modified().ok()) - .or_else(|| { - std::fs::metadata("/opt/server_manager/users.yaml") - .ok() - .and_then(|m| m.modified().ok()) - }); - let app_state = Arc::new(AppState { system: Mutex::new(sys), last_system_refresh: Mutex::new(SystemTime::now()), - config_cache: RwLock::new(CachedConfig { - config: initial_config, - last_modified: initial_config_mtime, - }), - users_cache: RwLock::new(CachedUsers { - manager: initial_users, - last_modified: initial_users_mtime, - }), }); let app = Router::new() @@ -605,7 +503,7 @@ struct AddUserPayload { quota: Option, } -async fn add_user_handler(State(state): State, session: Session, Form(payload): Form) -> impl IntoResponse { +async fn add_user_handler(State(_state): State, session: Session, Form(payload): Form) -> impl IntoResponse { let session_user: SessionUser = match session.get(SESSION_KEY).await { Ok(Some(u)) => u, _ => return Redirect::to("/login").into_response(), @@ -626,29 +524,16 @@ async fn add_user_handler(State(state): State, session: Session, Fo None => None, }; - let mut cache = state.users_cache.write().await; - let res = tokio::task::block_in_place(|| { - cache.manager.add_user(&payload.username, &payload.password, role_enum, quota_val) - }); - - if let Err(e) = res { + if let Err(e) = UserManager::add_user_async(payload.username.clone(), payload.password, role_enum, quota_val).await { error!("Failed to add user: {}", e); - // In a real app we'd flash a message. Here just redirect. } else { info!("User {} added via Web UI by {}", payload.username, session_user.username); - // Update mtime to prevent unnecessary reload - let path = std::path::Path::new("users.yaml"); - let fallback_path = std::path::Path::new("/opt/server_manager/users.yaml"); - let file_path = if path.exists() { path } else { fallback_path }; - if let Ok(m) = std::fs::metadata(file_path) { - cache.last_modified = m.modified().ok(); - } } Redirect::to("/users").into_response() } -async fn delete_user_handler(State(state): State, session: Session, Path(username): Path) -> impl IntoResponse { +async fn delete_user_handler(State(_state): State, session: Session, Path(username): Path) -> impl IntoResponse { let session_user: SessionUser = match session.get(SESSION_KEY).await { Ok(Some(u)) => u, _ => return Redirect::to("/login").into_response(), @@ -658,22 +543,10 @@ async fn delete_user_handler(State(state): State, session: Session, return (StatusCode::FORBIDDEN, "Access Denied").into_response(); } - let mut cache = state.users_cache.write().await; - let res = tokio::task::block_in_place(|| { - cache.manager.delete_user(&username) - }); - - if let Err(e) = res { + if let Err(e) = UserManager::delete_user_async(username.clone()).await { error!("Failed to delete user: {}", e); } else { info!("User {} deleted via Web UI by {}", username, session_user.username); - // Update mtime to prevent unnecessary reload - let path = std::path::Path::new("users.yaml"); - let fallback_path = std::path::Path::new("/opt/server_manager/users.yaml"); - let file_path = if path.exists() { path } else { fallback_path }; - if let Ok(m) = std::fs::metadata(file_path) { - cache.last_modified = m.modified().ok(); - } } Redirect::to("/users").into_response() @@ -697,16 +570,27 @@ async fn check_admin_role(session: Session, name: &str, enable: bool) -> impl In return (StatusCode::FORBIDDEN, "Access Denied: Admin role required").into_response(); } - run_cli_toggle(name, enable); + // Update config async + let res = if enable { + Config::enable_service_async(name).await + } else { + Config::disable_service_async(name).await + }; + + if let Err(e) = res { + error!("Failed to update config: {}", e); + } + + run_cli_apply(name, enable); Redirect::to("/").into_response() } -fn run_cli_toggle(service: &str, enable: bool) { - let action = if enable { "enable" } else { "disable" }; - info!("Web UI triggering: server_manager {} {}", action, service); +fn run_cli_apply(service: &str, enable: bool) { + let action = if enable { "enabled" } else { "disabled" }; + info!("Web UI triggering apply. Service {} {}", service, action); if let Ok(exe) = std::env::current_exe() { - match Command::new(exe).arg(action).arg(service).spawn() { + match Command::new(exe).arg("apply").spawn() { Ok(mut child) => { // Spawn a background task to wait for the child process to exit. // This prevents zombie processes by collecting the exit status.