use crate::package_manager::cache::{CacheResult, DownloadCache}; use crate::package_manager::component::{ComponentConfig, InstallResult}; use crate::package_manager::installer::PackageManager; use crate::package_manager::InstallMode; use crate::package_manager::OsType; use crate::shared::utils::{self, get_database_url_sync, parse_database_url}; use anyhow::{Context, Result}; use log::{error, info, trace, warn}; use reqwest::Client; use std::collections::HashMap; use std::path::PathBuf; use std::process::Command; impl PackageManager { pub async fn install(&self, component_name: &str) -> Result> { let component = self .components .get(component_name) .context(format!("Component '{}' not found", component_name))?; trace!( "Starting installation of component '{}' in {:?} mode", component_name, self.mode ); for dep in &component.dependencies { if !self.is_installed(dep) { warn!("Installing missing dependency: {}", dep); Box::pin(self.install(dep)).await?; } } let result = match self.mode { InstallMode::Local => { self.install_local(component).await?; None } InstallMode::Container => Some(self.install_container(component)?), }; trace!( "Component '{}' installation completed successfully", component_name ); Ok(result) } pub async fn install_local(&self, component: &ComponentConfig) -> Result<()> { trace!( "Installing component '{}' locally to {}", component.name, self.base_path.display() ); self.create_directories(&component.name)?; let (pre_cmds, post_cmds) = match self.os_type { OsType::Linux => ( &component.pre_install_cmds_linux, &component.post_install_cmds_linux, ), OsType::MacOS => ( &component.pre_install_cmds_macos, &component.post_install_cmds_macos, ), OsType::Windows => ( &component.pre_install_cmds_windows, &component.post_install_cmds_windows, ), }; self.run_commands(pre_cmds, "local", &component.name)?; self.install_system_packages(component)?; if let Some(url) = &component.download_url { let url = url.clone(); let name = component.name.clone(); let binary_name = component.binary_name.clone(); self.download_and_install(&url, &name, binary_name.as_deref()) .await?; } if !component.data_download_list.is_empty() { // Initialize cache for data files (models, etc.) let cache_base = self.base_path.parent().unwrap_or(&self.base_path); let cache = DownloadCache::new(cache_base).ok(); for url in &component.data_download_list { let filename = DownloadCache::extract_filename(url); let output_path = self .base_path .join("data") .join(&component.name) .join(&filename); // Check if already exists at destination if output_path.exists() { info!("Data file already exists: {:?}", output_path); continue; } // Ensure data directory exists if let Some(parent) = output_path.parent() { std::fs::create_dir_all(parent)?; } // Check cache first if let Some(ref c) = cache { if let Some(cached_path) = c.get_cached_path(&filename) { info!("Using cached data file: {:?}", cached_path); std::fs::copy(&cached_path, &output_path)?; continue; } } // Download to cache if available, otherwise directly to destination let download_target = if let Some(ref c) = cache { c.get_cache_path(&filename) } else { output_path.clone() }; info!("Downloading data file: {}", url); println!("Downloading {}", url); utils::download_file(url, download_target.to_str().unwrap()).await?; // Copy from cache to destination if we downloaded to cache if cache.is_some() && download_target != output_path { std::fs::copy(&download_target, &output_path)?; info!("Copied cached file to: {:?}", output_path); } } } self.run_commands(post_cmds, "local", &component.name)?; Ok(()) } pub fn install_container(&self, component: &ComponentConfig) -> Result { let container_name = format!("{}-{}", self.tenant, component.name); // Ensure LXD is initialized (runs silently if already initialized) let _ = Command::new("lxd").args(&["init", "--auto"]).output(); // Try multiple image sources in case one is unavailable let images = [ "ubuntu:24.04", "ubuntu:22.04", "images:debian/12", "images:debian/11", ]; let mut last_error = String::new(); let mut success = false; for image in &images { info!("Attempting to create container with image: {}", image); let output = Command::new("lxc") .args(&[ "launch", image, &container_name, "-c", "security.privileged=true", ]) .output()?; if output.status.success() { info!("Successfully created container with image: {}", image); success = true; break; } else { last_error = String::from_utf8_lossy(&output.stderr).to_string(); warn!("Failed to create container with {}: {}", image, last_error); // Clean up any partial container before trying next image let _ = Command::new("lxc") .args(&["delete", &container_name, "--force"]) .output(); } } if !success { return Err(anyhow::anyhow!( "LXC container creation failed with all images. Last error: {}", last_error )); } std::thread::sleep(std::time::Duration::from_secs(15)); self.exec_in_container(&container_name, "mkdir -p /opt/gbo/{bin,data,conf,logs}")?; // Install base packages required for all containers (wget for downloads, unzip for .zip files, curl for health checks) self.exec_in_container(&container_name, "apt-get update -qq")?; self.exec_in_container( &container_name, "DEBIAN_FRONTEND=noninteractive apt-get install -y -qq wget unzip curl ca-certificates", )?; let (pre_cmds, post_cmds) = match self.os_type { OsType::Linux => ( &component.pre_install_cmds_linux, &component.post_install_cmds_linux, ), OsType::MacOS => ( &component.pre_install_cmds_macos, &component.post_install_cmds_macos, ), OsType::Windows => ( &component.pre_install_cmds_windows, &component.post_install_cmds_windows, ), }; self.run_commands(pre_cmds, &container_name, &component.name)?; let packages = match self.os_type { OsType::Linux => &component.linux_packages, OsType::MacOS => &component.macos_packages, OsType::Windows => &component.windows_packages, }; if !packages.is_empty() { let pkg_list = packages.join(" "); self.exec_in_container(&container_name, "apt-get update -qq")?; self.exec_in_container( &container_name, &format!( "DEBIAN_FRONTEND=noninteractive apt-get install -y -qq {}", pkg_list ), )?; } if let Some(url) = &component.download_url { self.download_in_container( &container_name, url, &component.name, component.binary_name.as_deref(), )?; } self.run_commands(post_cmds, &container_name, &component.name)?; self.exec_in_container( &container_name, "useradd --system --no-create-home --shell /bin/false gbuser", )?; self.mount_container_directories(&container_name, &component.name)?; if !component.exec_cmd.is_empty() { self.create_container_service( &container_name, &component.name, &component.exec_cmd, &component.env_vars, )?; } self.setup_port_forwarding(&container_name, &component.ports)?; // Get container IP let container_ip = self.get_container_ip(&container_name)?; // For Vault, initialize and create config files automatically if component.name == "vault" { self.initialize_vault(&container_name, &container_ip)?; } // Generate connection info based on component type let (connection_info, env_vars) = self.generate_connection_info(&component.name, &container_ip, &component.ports); trace!( "Container installation of '{}' completed in {}", component.name, container_name ); Ok(InstallResult { component: component.name.clone(), container_name: container_name.clone(), container_ip, ports: component.ports.clone(), env_vars, connection_info, }) } /// Get the IP address of a container fn get_container_ip(&self, container_name: &str) -> Result { // Wait a moment for network to be ready std::thread::sleep(std::time::Duration::from_secs(2)); // Try lxc list with network info let output = Command::new("lxc") .args(&["list", container_name, "-c", "4", "--format", "csv"]) .output()?; if output.status.success() { let ip_output = String::from_utf8_lossy(&output.stdout).trim().to_string(); // Parse IP from output like "10.16.164.168 (eth0)" or just "10.16.164.168" if !ip_output.is_empty() { // Extract just the IP address (first part before space or parenthesis) let ip = ip_output .split(|c| c == ' ' || c == '(') .next() .unwrap_or("") .trim(); if !ip.is_empty() && ip.contains('.') { return Ok(ip.to_string()); } } } // Fallback: try lxc exec to get IP from inside container let output = Command::new("lxc") .args(&["exec", container_name, "--", "hostname", "-I"]) .output()?; if output.status.success() { let ip_output = String::from_utf8_lossy(&output.stdout).trim().to_string(); if let Some(ip) = ip_output.split_whitespace().next() { if ip.contains('.') { return Ok(ip.to_string()); } } } Ok("unknown".to_string()) } /// Initialize Vault, get unseal keys and root token, create .env and secrets files fn initialize_vault(&self, container_name: &str, ip: &str) -> Result<()> { info!("Initializing Vault..."); // Wait for Vault to be ready std::thread::sleep(std::time::Duration::from_secs(5)); // Initialize Vault and capture output // Note: VAULT_ADDR must be set inside the container, not on host let output = Command::new("lxc") .args(&[ "exec", container_name, "--", "bash", "-c", "VAULT_ADDR=http://127.0.0.1:8200 /opt/gbo/bin/vault operator init -key-shares=5 -key-threshold=3 -format=json", ]) .output()?; if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); // Check if already initialized if stderr.contains("already initialized") { warn!("Vault already initialized, skipping file generation"); return Ok(()); } return Err(anyhow::anyhow!("Failed to initialize Vault: {}", stderr)); } let init_output = String::from_utf8_lossy(&output.stdout); // Parse JSON output let init_json: serde_json::Value = serde_json::from_str(&init_output).context("Failed to parse Vault init output")?; let unseal_keys = init_json["unseal_keys_b64"] .as_array() .context("No unseal keys in output")?; let root_token = init_json["root_token"] .as_str() .context("No root token in output")?; // Write vault-unseal-keys file in working directory let unseal_keys_file = PathBuf::from("vault-unseal-keys"); let mut unseal_content = String::new(); for (i, key) in unseal_keys.iter().enumerate() { if i < 3 { // Only need 3 keys for threshold unseal_content.push_str(&format!( "VAULT_UNSEAL_KEY_{}={}\n", i + 1, key.as_str().unwrap_or("") )); } } std::fs::write(&unseal_keys_file, &unseal_content)?; // Set permissions to 600 (owner read/write only) #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; std::fs::set_permissions(&unseal_keys_file, std::fs::Permissions::from_mode(0o600))?; } info!("Created {}", unseal_keys_file.display()); // Check if .env exists, create or append let env_file = PathBuf::from(".env"); let env_content = format!( "\n# Vault Configuration (auto-generated)\nVAULT_ADDR=http://{}:8200\nVAULT_TOKEN={}\nVAULT_UNSEAL_KEYS_FILE=vault-unseal-keys\n", ip, root_token ); if env_file.exists() { // Read existing content let existing = std::fs::read_to_string(&env_file)?; // Check if VAULT_ADDR already exists if !existing.contains("VAULT_ADDR=") { // Append to existing file let mut file = std::fs::OpenOptions::new().append(true).open(&env_file)?; use std::io::Write; file.write_all(env_content.as_bytes())?; info!("Appended Vault config to .env"); } else { warn!(".env already contains VAULT_ADDR, not overwriting"); } } else { // Create new .env file std::fs::write(&env_file, env_content.trim_start())?; info!("Created .env with Vault config"); } // Unseal Vault with the first 3 keys // Note: VAULT_ADDR must be set inside the container, not on host for i in 0..3 { if let Some(key) = unseal_keys.get(i) { let key_str = key.as_str().unwrap_or(""); let unseal_cmd = format!( "VAULT_ADDR=http://127.0.0.1:8200 /opt/gbo/bin/vault operator unseal {}", key_str ); let unseal_output = Command::new("lxc") .args(&["exec", container_name, "--", "bash", "-c", &unseal_cmd]) .output()?; if !unseal_output.status.success() { warn!("Unseal step {} may have failed", i + 1); } } } info!("Vault initialized and unsealed successfully"); Ok(()) } /// Generate connection info and env vars based on component type /// Only Vault returns .env vars - all others return Vault storage commands fn generate_connection_info( &self, component: &str, ip: &str, ports: &[u16], ) -> (String, HashMap) { let env_vars = HashMap::new(); let connection_info = match component { "vault" => { // Vault config files are auto-generated, just show confirmation format!( r#"Vault Server: URL: http://{}:8200 UI: http://{}:8200/ui ✓ Vault initialized and unsealed automatically ✓ Created .env with VAULT_ADDR, VAULT_TOKEN ✓ Created vault-unseal-keys (chmod 600) Files created: .env - Vault connection config vault-unseal-keys - Unseal keys for auto-unseal On server restart, run: botserver vault unseal Or manually: lxc exec {}-vault -- /opt/gbo/bin/vault operator unseal For other auto-unseal options (TPM, HSM, Transit), see: https://generalbots.github.io/botbook/chapter-08/secrets-management.html"#, ip, ip, self.tenant ) } "vector_db" => { format!( r#"Qdrant Vector Database: REST API: http://{}:6333 gRPC: {}:6334 Dashboard: http://{}:6333/dashboard Store credentials in Vault: botserver vault put gbo/vectordb host={} port=6333"#, ip, ip, ip, ip ) } "tables" => { format!( r#"PostgreSQL Database: Host: {} Port: 5432 Database: botserver User: gbuser Store credentials in Vault: botserver vault put gbo/tables host={} port=5432 database=botserver username=gbuser password="#, ip, ip ) } "drive" => { format!( r#"MinIO Object Storage: API: http://{}:9000 Console: http://{}:9001 Store credentials in Vault: botserver vault put gbo/drive server={} port=9000 accesskey=minioadmin secret="#, ip, ip, ip ) } "cache" => { format!( r#"Redis/Valkey Cache: Host: {} Port: 6379 Store credentials in Vault: botserver vault put gbo/cache host={} port=6379 password="#, ip, ip ) } "email" => { format!( r#"Email Server (Stalwart): SMTP: {}:25 IMAP: {}:143 Web: http://{}:8080 Store credentials in Vault: botserver vault put gbo/email server={} port=25 username=admin password="#, ip, ip, ip, ip ) } "directory" => { format!( r#"Zitadel Identity Provider: URL: http://{}:8080 Console: http://{}:8080/ui/console Store credentials in Vault: botserver vault put gbo/directory url=http://{}:8080 client_id= client_secret="#, ip, ip, ip ) } "llm" => { format!( r#"LLM Server (llama.cpp): API: http://{}:8081 Test: curl http://{}:8081/v1/models Store credentials in Vault: botserver vault put gbo/llm url=http://{}:8081 local=true"#, ip, ip, ip ) } "meeting" => { format!( r#"LiveKit Meeting Server: WebSocket: ws://{}:7880 API: http://{}:7880 Store credentials in Vault: botserver vault put gbo/meet url=ws://{}:7880 api_key= api_secret="#, ip, ip, ip ) } "proxy" => { format!( r#"Caddy Reverse Proxy: HTTP: http://{}:80 HTTPS: https://{}:443 Admin: http://{}:2019"#, ip, ip, ip ) } "timeseries_db" => { format!( r#"InfluxDB Time Series Database: API: http://{}:8086 Store credentials in Vault: botserver vault put gbo/observability url=http://{}:8086 token= org=pragmatismo bucket=metrics"#, ip, ip ) } "observability" => { format!( r#"Vector Log Aggregation: API: http://{}:8686 Store credentials in Vault: botserver vault put gbo/observability vector_url=http://{}:8686"#, ip, ip ) } "alm" => { format!( r#"Forgejo Git Server: Web: http://{}:3000 SSH: {}:22 Store credentials in Vault: botserver vault put gbo/alm url=http://{}:3000 token="#, ip, ip, ip ) } _ => { let ports_str = ports .iter() .map(|p| format!(" - {}:{}", ip, p)) .collect::>() .join("\n"); format!( r#"Component: {} Container: {}-{} IP: {} Ports: {}"#, component, self.tenant, component, ip, ports_str ) } }; (connection_info, env_vars) } pub fn remove(&self, component_name: &str) -> Result<()> { let component = self .components .get(component_name) .context(format!("Component '{}' not found", component_name))?; match self.mode { InstallMode::Local => self.remove_local(component)?, InstallMode::Container => self.remove_container(component)?, } Ok(()) } pub fn remove_local(&self, component: &ComponentConfig) -> Result<()> { let bin_path = self.base_path.join("bin").join(&component.name); let _ = std::fs::remove_dir_all(bin_path); Ok(()) } pub fn remove_container(&self, component: &ComponentConfig) -> Result<()> { let container_name = format!("{}-{}", self.tenant, component.name); let _ = Command::new("lxc") .args(&["stop", &container_name]) .output(); let output = Command::new("lxc") .args(&["delete", &container_name]) .output()?; if !output.status.success() { warn!( "Container deletion had issues: {}", String::from_utf8_lossy(&output.stderr) ); } Ok(()) } pub fn list(&self) -> Vec { self.components.keys().cloned().collect() } pub fn is_installed(&self, component_name: &str) -> bool { match self.mode { InstallMode::Local => { let bin_path = self.base_path.join("bin").join(component_name); bin_path.exists() } InstallMode::Container => { let container_name = format!("{}-{}", self.tenant, component_name); let output = Command::new("lxc") .args(&["list", &container_name, "--format=json"]) .output() .unwrap(); if !output.status.success() { return false; } let output_str = String::from_utf8_lossy(&output.stdout); !output_str.contains("\"name\":\"") || output_str.contains("\"status\":\"Stopped\"") } } } pub fn create_directories(&self, component: &str) -> Result<()> { let dirs = ["bin", "data", "conf", "logs"]; for dir in &dirs { let path = self.base_path.join(dir).join(component); std::fs::create_dir_all(&path) .context(format!("Failed to create directory: {:?}", path))?; } Ok(()) } pub fn install_system_packages(&self, component: &ComponentConfig) -> Result<()> { let packages = match self.os_type { OsType::Linux => &component.linux_packages, OsType::MacOS => &component.macos_packages, OsType::Windows => &component.windows_packages, }; if packages.is_empty() { return Ok(()); } trace!( "Installing {} system packages for component '{}'", packages.len(), component.name ); match self.os_type { OsType::Linux => { let output = Command::new("apt-get").args(&["update"]).output()?; if !output.status.success() { warn!("apt-get update had issues"); } let output = Command::new("apt-get") .args(&["install", "-y"]) .args(packages) .output()?; if !output.status.success() { warn!("Some packages may have failed to install"); } } OsType::MacOS => { let output = Command::new("brew") .args(&["install"]) .args(packages) .output()?; if !output.status.success() { warn!("Homebrew installation had warnings"); } } OsType::Windows => { warn!("Windows package installation not implemented"); } } Ok(()) } pub async fn download_and_install( &self, url: &str, component: &str, binary_name: Option<&str>, ) -> Result<()> { let bin_path = self.base_path.join("bin").join(component); std::fs::create_dir_all(&bin_path)?; // Initialize cache - use parent of base_path (botserver root) for cache let cache_base = self.base_path.parent().unwrap_or(&self.base_path); let cache = DownloadCache::new(cache_base).unwrap_or_else(|e| { warn!("Failed to initialize download cache: {}", e); // Create a fallback cache in base_path DownloadCache::new(&self.base_path).expect("Failed to create fallback cache") }); // Check cache first let cache_result = cache.resolve_component_url(component, url); let source_file = match cache_result { CacheResult::Cached(cached_path) => { info!("Using cached file for {}: {:?}", component, cached_path); cached_path } CacheResult::Download { url: download_url, cache_path, } => { info!("Downloading {} from {}", component, download_url); println!("Downloading {}", download_url); // Download to cache directory self.download_with_reqwest(&download_url, &cache_path, component) .await?; info!("Cached {} to {:?}", component, cache_path); cache_path } }; // Now extract/install from the source file (either cached or freshly downloaded) self.handle_downloaded_file(&source_file, &bin_path, binary_name)?; Ok(()) } pub async fn download_with_reqwest( &self, url: &str, target_file: &PathBuf, component: &str, ) -> Result<()> { const MAX_RETRIES: u32 = 3; const RETRY_DELAY: std::time::Duration = std::time::Duration::from_secs(2); // Ensure parent directory exists if let Some(parent) = target_file.parent() { std::fs::create_dir_all(parent)?; } let client = Client::builder() .timeout(std::time::Duration::from_secs(30)) .user_agent("botserver-package-manager/1.0") .build()?; let mut last_error = None; for attempt in 0..=MAX_RETRIES { if attempt > 0 { trace!( "Retry attempt {}/{} for {}", attempt, MAX_RETRIES, component ); std::thread::sleep(RETRY_DELAY * attempt); } match self .attempt_reqwest_download(&client, url, target_file) .await { Ok(_size) => { if attempt > 0 { trace!("Download succeeded on retry attempt {}", attempt); } return Ok(()); } Err(e) => { warn!("Download attempt {} failed: {}", attempt + 1, e); last_error = Some(e); let _ = std::fs::remove_file(target_file); } } } Err(anyhow::anyhow!( "Failed to download {} after {} attempts. Last error: {}", component, MAX_RETRIES + 1, last_error.unwrap() )) } pub async fn attempt_reqwest_download( &self, _client: &Client, url: &str, temp_file: &PathBuf, ) -> Result { let output_path = temp_file.to_str().context("Invalid temp file path")?; utils::download_file(url, output_path) .await .map_err(|e| anyhow::anyhow!("Failed to download file using shared utility: {}", e))?; let metadata = std::fs::metadata(temp_file).context("Failed to get file metadata")?; let size = metadata.len(); Ok(size) } pub fn handle_downloaded_file( &self, temp_file: &PathBuf, bin_path: &PathBuf, binary_name: Option<&str>, ) -> Result<()> { let metadata = std::fs::metadata(temp_file)?; if metadata.len() == 0 { return Err(anyhow::anyhow!("Downloaded file is empty")); } let file_extension = temp_file .extension() .and_then(|ext| ext.to_str()) .unwrap_or(""); match file_extension { "gz" | "tgz" => { self.extract_tar_gz(temp_file, bin_path)?; } "zip" => { self.extract_zip(temp_file, bin_path)?; } _ => { if let Some(name) = binary_name { self.install_binary(temp_file, bin_path, name)?; } else { let final_path = bin_path.join(temp_file.file_name().unwrap()); // Copy instead of rename if source is in cache directory // This preserves cached files for offline installation if temp_file.to_string_lossy().contains("botserver-installers") { std::fs::copy(temp_file, &final_path)?; } else { std::fs::rename(temp_file, &final_path)?; } self.make_executable(&final_path)?; } } } Ok(()) } pub fn extract_tar_gz(&self, temp_file: &PathBuf, bin_path: &PathBuf) -> Result<()> { let output = Command::new("tar") .current_dir(bin_path) .args(&["-xzf", temp_file.to_str().unwrap(), "--strip-components=1"]) .output()?; if !output.status.success() { return Err(anyhow::anyhow!( "tar extraction failed: {}", String::from_utf8_lossy(&output.stderr) )); } // Only delete if NOT in the cache directory (botserver-installers) // Cached files should be preserved for offline installation if !temp_file.to_string_lossy().contains("botserver-installers") { std::fs::remove_file(temp_file)?; } Ok(()) } pub fn extract_zip(&self, temp_file: &PathBuf, bin_path: &PathBuf) -> Result<()> { let output = Command::new("unzip") .current_dir(bin_path) .args(&["-o", "-q", temp_file.to_str().unwrap()]) .output()?; if !output.status.success() { return Err(anyhow::anyhow!( "unzip extraction failed: {}", String::from_utf8_lossy(&output.stderr) )); } // Make all extracted files executable (especially important for binaries like Vault) #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; if let Ok(entries) = std::fs::read_dir(bin_path) { for entry in entries.flatten() { let path = entry.path(); if path.is_file() { if let Ok(metadata) = std::fs::metadata(&path) { let mut perms = metadata.permissions(); // Only make executable if it looks like a binary (no extension or common binary extensions) let ext = path.extension().and_then(|e| e.to_str()).unwrap_or(""); if ext.is_empty() || ext == "sh" || ext == "bash" { perms.set_mode(0o755); let _ = std::fs::set_permissions(&path, perms); trace!("Made executable: {:?}", path); } } } } } } // Only delete if NOT in the cache directory (botserver-installers) // Cached files should be preserved for offline installation if !temp_file.to_string_lossy().contains("botserver-installers") { std::fs::remove_file(temp_file)?; } Ok(()) } pub fn install_binary( &self, temp_file: &PathBuf, bin_path: &PathBuf, name: &str, ) -> Result<()> { let final_path = bin_path.join(name); // Copy instead of rename if source is in cache directory // This preserves cached files for offline installation if temp_file.to_string_lossy().contains("botserver-installers") { std::fs::copy(temp_file, &final_path)?; } else { std::fs::rename(temp_file, &final_path)?; } self.make_executable(&final_path)?; Ok(()) } pub fn make_executable(&self, path: &PathBuf) -> Result<()> { #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; let mut perms = std::fs::metadata(path)?.permissions(); perms.set_mode(0o755); std::fs::set_permissions(path, perms)?; } Ok(()) } pub fn run_commands(&self, commands: &[String], target: &str, component: &str) -> Result<()> { let bin_path = if target == "local" { self.base_path.join("bin").join(component) } else { PathBuf::from("/opt/gbo/bin") }; let data_path = if target == "local" { self.base_path.join("data").join(component) } else { PathBuf::from("/opt/gbo/data") }; // CONF_PATH should be the base conf directory, not component-specific // Commands that need component subdirs include them explicitly (e.g., {{CONF_PATH}}/directory/zitadel.yaml) let conf_path = if target == "local" { self.base_path.join("conf") } else { PathBuf::from("/opt/gbo/conf") }; let logs_path = if target == "local" { self.base_path.join("logs").join(component) } else { PathBuf::from("/opt/gbo/logs") }; // Get DB password from Vault for commands that need it (e.g., PostgreSQL initdb) let db_password = match get_database_url_sync() { Ok(url) => { let (_, password, _, _, _) = parse_database_url(&url); password } Err(_) => { // Vault not available yet - this is OK during early bootstrap // Commands that don't need DB_PASSWORD will still work trace!("Vault not available for DB_PASSWORD, using empty string"); String::new() } }; for cmd in commands { let rendered_cmd = cmd .replace("{{BIN_PATH}}", &bin_path.to_string_lossy()) .replace("{{DATA_PATH}}", &data_path.to_string_lossy()) .replace("{{CONF_PATH}}", &conf_path.to_string_lossy()) .replace("{{LOGS_PATH}}", &logs_path.to_string_lossy()) .replace("{{DB_PASSWORD}}", &db_password); if target == "local" { trace!("Executing command: {}", rendered_cmd); let output = Command::new("bash") .current_dir(&bin_path) .args(&["-c", &rendered_cmd]) .stdout(std::process::Stdio::null()) .stderr(std::process::Stdio::piped()) .output() .with_context(|| { format!("Failed to execute command for component '{}'", component) })?; if !output.status.success() { error!( "Command had non-zero exit: {}", String::from_utf8_lossy(&output.stderr) ); } } else { self.exec_in_container(target, &rendered_cmd)?; } } Ok(()) } pub fn exec_in_container(&self, container: &str, command: &str) -> Result<()> { let output = Command::new("lxc") .args(&["exec", container, "--", "bash", "-c", command]) .output()?; if !output.status.success() { warn!( "Container command failed: {}", String::from_utf8_lossy(&output.stderr) ); } Ok(()) } pub fn download_in_container( &self, container: &str, url: &str, _component: &str, binary_name: Option<&str>, ) -> Result<()> { let download_cmd = format!("wget -O /tmp/download.tmp {}", url); self.exec_in_container(container, &download_cmd)?; if url.ends_with(".tar.gz") || url.ends_with(".tgz") { self.exec_in_container(container, "tar -xzf /tmp/download.tmp -C /opt/gbo/bin")?; } else if url.ends_with(".zip") { self.exec_in_container(container, "unzip -o /tmp/download.tmp -d /opt/gbo/bin")?; } else if let Some(name) = binary_name { let mv_cmd = format!( "mv /tmp/download.tmp /opt/gbo/bin/{} && chmod +x /opt/gbo/bin/{}", name, name ); self.exec_in_container(container, &mv_cmd)?; } self.exec_in_container(container, "rm -f /tmp/download.tmp")?; Ok(()) } pub fn mount_container_directories(&self, container: &str, component: &str) -> Result<()> { let host_base = format!("/opt/gbo/tenants/{}/{}", self.tenant, component); for dir in &["data", "conf", "logs"] { let host_path = format!("{}/{}", host_base, dir); std::fs::create_dir_all(&host_path)?; let device_name = format!("{}-{}", component, dir); let container_path = format!("/opt/gbo/{}", dir); let _ = Command::new("lxc") .args(&["config", "device", "remove", container, &device_name]) .output(); let output = Command::new("lxc") .args(&[ "config", "device", "add", container, &device_name, "disk", &format!("source={}", host_path), &format!("path={}", container_path), ]) .output()?; if !output.status.success() { warn!("Failed to mount {} in container {}", dir, container); } trace!( "Mounted {} to {} in container {}", host_path, container_path, container ); } Ok(()) } pub fn create_container_service( &self, container: &str, component: &str, exec_cmd: &str, env_vars: &HashMap, ) -> Result<()> { // Try to get DB password from Vault, but don't fail if Vault isn't available yet // (e.g., when installing Vault itself or other components that don't need DB) let db_password = match get_database_url_sync() { Ok(url) => { let (_, password, _, _, _) = parse_database_url(&url); password } Err(_) => { trace!( "Vault not available for DB_PASSWORD in container service, using empty string" ); String::new() } }; let rendered_cmd = exec_cmd .replace("{{DB_PASSWORD}}", &db_password) .replace("{{BIN_PATH}}", "/opt/gbo/bin") .replace("{{DATA_PATH}}", "/opt/gbo/data") .replace("{{CONF_PATH}}", "/opt/gbo/conf") .replace("{{LOGS_PATH}}", "/opt/gbo/logs"); let mut env_section = String::new(); for (key, value) in env_vars { let rendered_value = value .replace("{{DATA_PATH}}", "/opt/gbo/data") .replace("{{BIN_PATH}}", "/opt/gbo/bin") .replace("{{CONF_PATH}}", "/opt/gbo/conf") .replace("{{LOGS_PATH}}", "/opt/gbo/logs"); env_section.push_str(&format!("Environment={}={}\n", key, rendered_value)); } let service_content = format!( "[Unit]\nDescription={} Service\nAfter=network.target\n\n[Service]\nType=simple\n{}ExecStart={}\nWorkingDirectory=/opt/gbo/data\nRestart=always\nRestartSec=10\nUser=root\n\n[Install]\nWantedBy=multi-user.target\n", component, env_section, rendered_cmd ); let service_file = format!("/tmp/{}.service", component); std::fs::write(&service_file, &service_content)?; let output = Command::new("lxc") .args(&[ "file", "push", &service_file, &format!("{}/etc/systemd/system/{}.service", container, component), ]) .output()?; if !output.status.success() { warn!("Failed to push service file to container"); } self.exec_in_container(container, "systemctl daemon-reload")?; self.exec_in_container(container, &format!("systemctl enable {}", component))?; self.exec_in_container(container, &format!("systemctl start {}", component))?; std::fs::remove_file(&service_file)?; trace!( "Created and started service in container {}: {}", container, component ); Ok(()) } pub fn setup_port_forwarding(&self, container: &str, ports: &[u16]) -> Result<()> { for port in ports { let device_name = format!("port-{}", port); let _ = Command::new("lxc") .args(&["config", "device", "remove", container, &device_name]) .output(); let output = Command::new("lxc") .args(&[ "config", "device", "add", container, &device_name, "proxy", &format!("listen=tcp:0.0.0.0:{}", port), &format!("connect=tcp:127.0.0.1:{}", port), ]) .output()?; if !output.status.success() { warn!("Failed to setup port forwarding for port {}", port); } trace!( "Port forwarding configured: {} -> container {}", port, container ); } Ok(()) } }