diff --git a/README.md b/README.md index 6187960..81284a5 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. @@ -23,6 +23,7 @@ Welcome to the Server Manager documentation. Whether you are a beginner or an ex ## ✹ Key Features * **28 Integrated Services**: Plex, ArrStack, Nextcloud, Mailserver, etc. * **Smart Hardware Detection**: Adapts configuration (RAM, Transcoding, Swap) to your machine (Low/Standard/High Profile). +* **Fast Configuration**: Uses internal orchestration for instant service toggling without subprocess overhead. * **Secure by Default**: UFW firewall configured, passwords generated, isolated networks. * **GPU Support**: Automatic detection and configuration for Nvidia & Intel QuickSync. @@ -186,6 +187,7 @@ Bienvenue sur la documentation de Server Manager. Que vous soyez dĂ©butant ou ex ## ✹ FonctionnalitĂ©s ClĂ©s * **28 Services IntĂ©grĂ©s** : Plex, ArrStack, Nextcloud, Mailserver, etc. * **DĂ©tection MatĂ©rielle Intelligente** : Adapte la configuration (RAM, Transcodage, Swap) selon votre machine (Low/Standard/High Profile). +* **Configuration Rapide** : Utilise une orchestration interne pour une activation instantanĂ©e des services. * **SĂ©curitĂ© par DĂ©faut** : Pare-feu UFW configurĂ©, mots de passe gĂ©nĂ©rĂ©s, rĂ©seaux isolĂ©s. * **Support GPU** : DĂ©tection et configuration automatique Nvidia & Intel QuickSync. diff --git a/server_manager/Cargo.toml b/server_manager/Cargo.toml index 475f80e..59a6547 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] diff --git a/server_manager/src/core/config.rs b/server_manager/src/core/config.rs index c57c2a7..7596e0c 100644 --- a/server_manager/src/core/config.rs +++ b/server_manager/src/core/config.rs @@ -3,7 +3,7 @@ use log::info; use serde::{Deserialize, Serialize}; use std::collections::HashSet; use std::fs; -use std::path::Path; +use std::path::{Path, PathBuf}; use std::sync::OnceLock; use std::time::SystemTime; use tokio::sync::RwLock; @@ -23,11 +23,21 @@ pub struct Config { } impl Config { + fn get_config_path() -> PathBuf { + let global_path = Path::new("/opt/server_manager/config.yaml"); + let local_path = Path::new("config.yaml"); + + if global_path.exists() { + global_path.to_path_buf() + } else { + local_path.to_path_buf() + } + } + pub fn load() -> Result { - let path = Path::new("config.yaml"); + let path = Self::get_config_path(); if path.exists() { - let content = fs::read_to_string(path).context("Failed to read config.yaml")?; - // If empty file, return default + let content = fs::read_to_string(&path).context("Failed to read config.yaml")?; if content.trim().is_empty() { return Ok(Config::default()); } @@ -38,6 +48,7 @@ impl Config { } pub async fn load_async() -> Result { + let path = Self::get_config_path(); let cache = CONFIG_CACHE.get_or_init(|| { RwLock::new(CachedConfig { config: Config::default(), @@ -49,8 +60,7 @@ impl Config { { let guard = cache.read().await; if let Some(cached_mtime) = guard.last_mtime { - // Check if file still matches - if let Ok(metadata) = tokio::fs::metadata("config.yaml").await { + if let Ok(metadata) = tokio::fs::metadata(&path).await { if let Ok(modified) = metadata.modified() { if modified == cached_mtime { return Ok(guard.config.clone()); @@ -63,8 +73,7 @@ impl Config { // Slow path: Update cache let mut guard = cache.write().await; - // Check metadata again (double-checked locking pattern) - let metadata_res = tokio::fs::metadata("config.yaml").await; + let metadata_res = tokio::fs::metadata(&path).await; match metadata_res { Ok(metadata) => { @@ -76,8 +85,7 @@ impl Config { } } - // Load file - match tokio::fs::read_to_string("config.yaml").await { + match tokio::fs::read_to_string(&path).await { Ok(content) => { let config = if content.trim().is_empty() { Config::default() @@ -94,7 +102,6 @@ impl Config { } } Err(e) if e.kind() == std::io::ErrorKind::NotFound => { - // File not found -> Default guard.config = Config::default(); guard.last_mtime = None; Ok(guard.config.clone()) @@ -104,8 +111,16 @@ impl Config { } pub fn save(&self) -> Result<()> { + let path = Self::get_config_path(); + // If config doesn't exist, try to default to /opt/server_manager if directory exists + let target_path = if !path.exists() && Path::new("/opt/server_manager").exists() { + Path::new("/opt/server_manager/config.yaml") + } else { + path.as_path() + }; + let content = serde_yaml_ng::to_string(self)?; - fs::write("config.yaml", content).context("Failed to write config.yaml")?; + fs::write(target_path, content).context("Failed to write config.yaml")?; Ok(()) } diff --git a/server_manager/src/core/hardware.rs b/server_manager/src/core/hardware.rs index 09f3ff2..484ef99 100644 --- a/server_manager/src/core/hardware.rs +++ b/server_manager/src/core/hardware.rs @@ -31,6 +31,7 @@ impl HardwareInfo { sys.refresh_memory(); sys.refresh_cpu(); sys.refresh_disks_list(); + sys.refresh_disks(); let total_memory = sys.total_memory(); // Bytes let ram_gb = total_memory / 1024 / 1024 / 1024; @@ -93,6 +94,11 @@ impl HardwareInfo { } } + // If running as root (and not via sudo), default to root + if nix::unistd::Uid::effective().is_root() { + return ("0".to_string(), "0".to_string()); + } + warn!("SUDO_USER not found or lookup failed. Defaulting to UID/GID 1000."); ("1000".to_string(), "1000".to_string()) } diff --git a/server_manager/src/core/mod.rs b/server_manager/src/core/mod.rs index 9aa751b..c9fd9a6 100644 --- a/server_manager/src/core/mod.rs +++ b/server_manager/src/core/mod.rs @@ -6,3 +6,4 @@ pub mod hardware; pub mod secrets; pub mod system; pub mod users; +pub mod orchestrator; diff --git a/server_manager/src/core/orchestrator.rs b/server_manager/src/core/orchestrator.rs new file mode 100644 index 0000000..7307150 --- /dev/null +++ b/server_manager/src/core/orchestrator.rs @@ -0,0 +1,116 @@ +use crate::core::{config, hardware, secrets}; +use crate::{build_compose_structure, services}; +use anyhow::{Context, Result}; +use log::{error, info}; +use std::process::Stdio; +use tokio::process::Command; + +pub async fn apply( + hw: &hardware::HardwareInfo, + secrets: &secrets::Secrets, + config: &config::Config, +) -> Result<()> { + // 1. Configure & Initialize (Blocking IO) + let hw_clone = hw.clone(); + let secrets_clone = secrets.clone(); + let config_clone = config.clone(); + + tokio::task::spawn_blocking(move || { + configure_services(&hw_clone, &secrets_clone, &config_clone)?; + initialize_services(&hw_clone, &secrets_clone, &config_clone)?; + generate_compose_file(&hw_clone, &secrets_clone, &config_clone)?; + Ok::<(), anyhow::Error>(()) + }) + .await + .context("Failed to execute blocking configuration tasks")??; + + // 2. Docker Compose Up (Async) + info!("Applying changes via Docker Compose..."); + let status = Command::new("docker") + .args(["compose", "up", "-d", "--remove-orphans"]) + .stdout(Stdio::inherit()) + .stderr(Stdio::inherit()) + .status() + .await + .context("Failed to run docker compose up")?; + + if status.success() { + info!("Stack deployed successfully!"); + } else { + error!("Docker Compose failed."); + anyhow::bail!("Docker Compose failed"); + } + + Ok(()) +} + +pub async fn generate_only( + hw: &hardware::HardwareInfo, + secrets: &secrets::Secrets, + config: &config::Config, +) -> Result<()> { + let hw_clone = hw.clone(); + let secrets_clone = secrets.clone(); + let config_clone = config.clone(); + + tokio::task::spawn_blocking(move || { + configure_services(&hw_clone, &secrets_clone, &config_clone)?; + generate_compose_file(&hw_clone, &secrets_clone, &config_clone) + }) + .await + .context("Failed to execute blocking generation tasks")??; + + Ok(()) +} + +fn configure_services( + hw: &hardware::HardwareInfo, + secrets: &secrets::Secrets, + config: &config::Config, +) -> Result<()> { + info!("Configuring services (generating config files)..."); + let services = services::get_all_services(); + for service in services { + if !config.is_enabled(service.name()) { + continue; + } + service + .configure(hw, secrets) + .with_context(|| format!("Failed to configure service: {}", service.name()))?; + } + Ok(()) +} + +fn initialize_services( + hw: &hardware::HardwareInfo, + secrets: &secrets::Secrets, + config: &config::Config, +) -> Result<()> { + info!("Initializing services (system setup)..."); + let services = services::get_all_services(); + for service in services { + if !config.is_enabled(service.name()) { + continue; + } + service + .initialize(hw, secrets) + .with_context(|| format!("Failed to initialize service: {}", service.name()))?; + } + Ok(()) +} + +fn generate_compose_file( + hw: &hardware::HardwareInfo, + secrets: &secrets::Secrets, + config: &config::Config, +) -> Result<()> { + info!("Generating docker-compose.yml based on hardware profile..."); + let top_level = build_compose_structure(hw, secrets, config)?; + let yaml_output = serde_yaml_ng::to_string(&top_level)?; + + std::fs::write("docker-compose.yml", yaml_output) + .context("Failed to write docker-compose.yml")?; + info!("docker-compose.yml generated."); + + Ok(()) +} diff --git a/server_manager/src/core/users.rs b/server_manager/src/core/users.rs index 41870d0..0a04701 100644 --- a/server_manager/src/core/users.rs +++ b/server_manager/src/core/users.rs @@ -6,7 +6,7 @@ use nix::unistd::Uid; use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::fs; -use std::path::Path; +use std::path::{Path, PathBuf}; #[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] pub enum Role { @@ -29,27 +29,26 @@ pub struct UserManager { } impl UserManager { + fn get_users_path() -> PathBuf { + let global_path = Path::new("/opt/server_manager/users.yaml"); + let local_path = Path::new("users.yaml"); + + if global_path.exists() { + global_path.to_path_buf() + } else { + local_path.to_path_buf() + } + } + pub async fn load_async() -> Result { tokio::task::spawn_blocking(Self::load).await? } pub fn load() -> Result { - // Try CWD or /opt/server_manager - let path = Path::new("users.yaml"); - let fallback_path = Path::new("/opt/server_manager/users.yaml"); - - // Priority: /opt/server_manager/users.yaml > ./users.yaml - // This aligns with save() behavior which prefers /opt if available. - let load_path = if fallback_path.exists() { - Some(fallback_path) - } else if path.exists() { - Some(path) - } else { - None - }; + let path = Self::get_users_path(); - let mut manager = if let Some(p) = load_path { - let content = fs::read_to_string(p).context("Failed to read users.yaml")?; + let mut manager = if path.exists() { + let content = fs::read_to_string(&path).context("Failed to read users.yaml")?; if content.trim().is_empty() { UserManager::default() } else { @@ -62,12 +61,6 @@ impl UserManager { // Ensure default admin exists if no users if manager.users.is_empty() { info!("No users found. Creating default 'admin' user."); - // We use a generated secret for the initial password if secrets exist, - // otherwise generate one. - // Better: use 'admin' / 'admin' but WARN, or generate random. - // Let's generate a random one and print it, safer. - // Re-using secrets generation logic if possible, or just simple random. - // For simplicity in this context, let's look for a stored password or default to 'admin' and log a warning. let pass = "admin"; let hash = hash(pass, DEFAULT_COST)?; @@ -88,15 +81,16 @@ impl UserManager { } pub fn save(&self) -> Result<()> { - // Prefer saving to /opt/server_manager if it exists/is writable, else CWD - let target = if Path::new("/opt/server_manager").exists() { + let path = Self::get_users_path(); + // If file doesn't exist, try to default to /opt/server_manager if directory exists + let target_path = if !path.exists() && Path::new("/opt/server_manager").exists() { Path::new("/opt/server_manager/users.yaml") } else { - Path::new("users.yaml") + path.as_path() }; let content = serde_yaml_ng::to_string(self)?; - fs::write(target, content).context("Failed to write users.yaml")?; + fs::write(target_path, content).context("Failed to write users.yaml")?; Ok(()) } diff --git a/server_manager/src/interface/cli.rs b/server_manager/src/interface/cli.rs index bdadad9..9974151 100644 --- a/server_manager/src/interface/cli.rs +++ b/server_manager/src/interface/cli.rs @@ -5,7 +5,7 @@ use std::fs; use std::io::{self, Write}; use std::process::Command; -use crate::build_compose_structure; +use crate::core::orchestrator; use crate::core::{config, docker, firewall, hardware, secrets, system, users}; use crate::services; @@ -165,32 +165,17 @@ 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 via Orchestrator 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?; + orchestrator::apply(&hw, &secrets, &config).await?; - info!("Applying changes via Docker Compose..."); - let status = Command::new("docker") - .args(["compose", "up", "-d", "--remove-orphans"]) - .status() - .context("Failed to run docker compose up")?; - - if status.success() { - info!( - "Service '{}' {} successfully!", - service_name, - if enable { "enabled" } else { "disabled" } - ); - } else { - error!("Failed to apply changes via Docker Compose."); - } + info!( + "Service '{}' {} successfully!", + service_name, + if enable { "enabled" } else { "disabled" } + ); Ok(()) } @@ -226,26 +211,11 @@ async fn run_install() -> Result<()> { // 5. Docker docker::install()?; - // 6. Initialize Services - configure_services(&hw, &secrets, &config)?; - initialize_services(&hw, &secrets, &config)?; - - // 7. Generate Compose - generate_compose(&hw, &secrets, &config).await?; - - // 8. Launch - info!("Launching Services via Docker Compose..."); - let status = Command::new("docker") - .args(["compose", "up", "-d", "--remove-orphans"]) - .status() - .context("Failed to run docker compose up")?; + // 6. Orchestrate Services + orchestrator::apply(&hw, &secrets, &config).await?; - if status.success() { - info!("Server Manager Stack Deployed Successfully! 🚀"); - print_deployment_summary(&secrets); - } else { - error!("Docker Compose failed."); - } + info!("Server Manager Stack Deployed Successfully! 🚀"); + print_deployment_summary(&secrets); Ok(()) } @@ -322,57 +292,6 @@ async fn run_generate() -> Result<()> { let secrets = secrets::Secrets::load_or_create().context("Failed to load or create secrets.yaml")?; let config = config::Config::load()?; - configure_services(&hw, &secrets, &config)?; - generate_compose(&hw, &secrets, &config).await -} - -fn configure_services( - hw: &hardware::HardwareInfo, - secrets: &secrets::Secrets, - config: &config::Config, -) -> Result<()> { - info!("Configuring services (generating config files)..."); - let services = services::get_all_services(); - for service in services { - if !config.is_enabled(service.name()) { - continue; - } - service - .configure(hw, secrets) - .with_context(|| format!("Failed to configure service: {}", service.name()))?; - } - Ok(()) -} - -fn initialize_services( - hw: &hardware::HardwareInfo, - secrets: &secrets::Secrets, - config: &config::Config, -) -> Result<()> { - info!("Initializing services (system setup)..."); - let services = services::get_all_services(); - for service in services { - if !config.is_enabled(service.name()) { - continue; - } - service - .initialize(hw, secrets) - .with_context(|| format!("Failed to initialize service: {}", service.name()))?; - } - Ok(()) -} -async fn generate_compose( - hw: &hardware::HardwareInfo, - secrets: &secrets::Secrets, - config: &config::Config, -) -> Result<()> { - info!("Generating docker-compose.yml based on hardware profile..."); - let top_level = build_compose_structure(hw, secrets, config)?; - let yaml_output = serde_yaml_ng::to_string(&top_level)?; - - fs::write("docker-compose.yml", yaml_output).context("Failed to write docker-compose.yml")?; - info!("docker-compose.yml generated."); - - Ok(()) + orchestrator::generate_only(&hw, &secrets, &config).await } diff --git a/server_manager/src/interface/web.rs b/server_manager/src/interface/web.rs index 1f677c3..f9959bd 100644 --- a/server_manager/src/interface/web.rs +++ b/server_manager/src/interface/web.rs @@ -1,5 +1,7 @@ use crate::core::config::Config; +use crate::core::orchestrator; use crate::core::users::{Role, UserManager}; +use crate::core::{hardware, secrets}; use crate::services; use axum::{ extract::{Form, Path, State}, @@ -16,7 +18,6 @@ use std::sync::{Arc, Mutex}; 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}; @@ -28,11 +29,6 @@ struct SessionUser { const SESSION_KEY: &str = "user"; -struct CachedConfig { - config: Config, - last_modified: Option, -} - struct CachedUsers { manager: UserManager, last_modified: Option, @@ -41,7 +37,6 @@ struct CachedUsers { struct AppState { system: Mutex, last_system_refresh: Mutex, - config_cache: RwLock, users_cache: RwLock, } @@ -49,45 +44,14 @@ 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 }; + let global_path = std::path::Path::new("/opt/server_manager/users.yaml"); + let local_path = std::path::Path::new("users.yaml"); + let file_path = if global_path.exists() { global_path } else { local_path }; // Fast path: check metadata let current_mtime = tokio::fs::metadata(file_path).await @@ -133,29 +97,17 @@ pub async fn start_server(port: u16) -> anyhow::Result<()> { // Initialize System once 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()); + sys.refresh_disks(); let initial_users = UserManager::load().unwrap_or_default(); - let initial_users_mtime = std::fs::metadata("users.yaml") + let initial_users_mtime = std::fs::metadata("/opt/server_manager/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()) - }); + .or_else(|| std::fs::metadata("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, @@ -637,9 +589,9 @@ async fn add_user_handler(State(state): State, session: Session, Fo } 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 }; + let global_path = std::path::Path::new("/opt/server_manager/users.yaml"); + let local_path = std::path::Path::new("users.yaml"); + let file_path = if global_path.exists() { global_path } else { local_path }; if let Ok(m) = std::fs::metadata(file_path) { cache.last_modified = m.modified().ok(); } @@ -668,9 +620,9 @@ async fn delete_user_handler(State(state): State, session: Session, } 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 }; + let global_path = std::path::Path::new("/opt/server_manager/users.yaml"); + let local_path = std::path::Path::new("users.yaml"); + let file_path = if global_path.exists() { global_path } else { local_path }; if let Ok(m) = std::fs::metadata(file_path) { cache.last_modified = m.modified().ok(); } @@ -702,25 +654,50 @@ async fn check_admin_role(session: Session, name: &str, enable: bool) -> impl In } fn run_cli_toggle(service: &str, enable: bool) { - let action = if enable { "enable" } else { "disable" }; - info!("Web UI triggering: server_manager {} {}", action, service); - - if let Ok(exe) = std::env::current_exe() { - match Command::new(exe).arg(action).arg(service).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. - tokio::spawn(async move { - if let Err(e) = child.wait().await { - error!("Failed to wait on child process: {}", e); - } - }); + let service_name = service.to_string(); + let action = if enable { "Enable" } else { "Disable" }; + info!("Web UI triggering: {} {}", action, service_name); + + tokio::spawn(async move { + // 1. Load Config + let mut config = match Config::load_async().await { + Ok(c) => c, + Err(e) => { + error!("Failed to load config for toggle: {}", e); + return; } + }; + + // 2. Toggle + if enable { + config.enable_service(&service_name); + } else { + config.disable_service(&service_name); + } + + // 3. Save + if let Err(e) = config.save() { + error!("Failed to save config: {}", e); + return; + } + + // 4. Apply changes + let hw = hardware::HardwareInfo::detect(); // This is synchronous and does IO. + // It's better to wrap it in spawn_blocking if it takes time. + // But detect() is fast enough (ms). + + let secrets = match secrets::Secrets::load_or_create() { + Ok(s) => s, Err(e) => { - error!("Failed to spawn command: {}", e); + error!("Failed to load secrets: {}", e); + return; } + }; + + if let Err(e) = orchestrator::apply(&hw, &secrets, &config).await { + error!("Failed to apply changes via orchestrator: {}", e); + } else { + info!("Service {} toggled and applied successfully.", service_name); } - } else { - error!("Failed to determine current executable path."); - } + }); }