botserver/src/core/package_manager/facade.rs

1188 lines
41 KiB
Rust
Raw Normal View History

use crate::package_manager::cache::{CacheResult, DownloadCache};
use crate::package_manager::component::{ComponentConfig, InstallResult};
2025-11-22 22:55:35 -03:00
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};
2025-11-22 22:55:35 -03:00
use anyhow::{Context, Result};
use log::{error, info, trace, warn};
2025-11-22 22:55:35 -03:00
use reqwest::Client;
use std::collections::HashMap;
use std::fmt::Write as FmtWrite;
2025-11-22 22:55:35 -03:00
use std::path::PathBuf;
use std::process::Command;
impl PackageManager {
pub async fn install(&self, component_name: &str) -> Result<Option<InstallResult>> {
2025-11-22 22:55:35 -03:00
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)?),
};
2025-11-22 22:55:35 -03:00
trace!(
"Component '{}' installation completed successfully",
component_name
);
Ok(result)
2025-11-22 22:55:35 -03:00
}
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() {
let cache_base = self.base_path.parent().unwrap_or(&self.base_path);
let cache = DownloadCache::new(cache_base).ok();
2025-11-22 22:55:35 -03:00
for url in &component.data_download_list {
let filename = DownloadCache::extract_filename(url);
2025-11-22 22:55:35 -03:00
let output_path = self
.base_path
.join("data")
.join(&component.name)
.join(&filename);
if output_path.exists() {
info!("Data file already exists: {}", output_path.display());
continue;
}
if let Some(parent) = output_path.parent() {
std::fs::create_dir_all(parent)?;
}
if let Some(ref c) = cache {
if let Some(cached_path) = c.get_cached_path(&filename) {
info!("Using cached data file: {}", cached_path.display());
std::fs::copy(&cached_path, &output_path)?;
continue;
}
}
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);
2025-12-28 19:29:18 -03:00
utils::download_file(url, download_target.to_str().unwrap_or_default()).await?;
if cache.is_some() && download_target != output_path {
std::fs::copy(&download_target, &output_path)?;
info!("Copied cached file to: {}", output_path.display());
}
2025-11-22 22:55:35 -03:00
}
}
self.run_commands(post_cmds, "local", &component.name)?;
Ok(())
}
pub fn install_container(&self, component: &ComponentConfig) -> Result<InstallResult> {
2025-11-22 22:55:35 -03:00
let container_name = format!("{}-{}", self.tenant, component.name);
let _ = Command::new("lxd").args(["init", "--auto"]).output();
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;
}
last_error = String::from_utf8_lossy(&output.stderr).to_string();
warn!("Failed to create container with {}: {}", image, last_error);
let _ = Command::new("lxc")
.args(["delete", &container_name, "--force"])
.output();
}
if !success {
2025-11-22 22:55:35 -03:00
return Err(anyhow::anyhow!(
"LXC container creation failed with all images. Last error: {}",
last_error
2025-11-22 22:55:35 -03:00
));
}
std::thread::sleep(std::time::Duration::from_secs(15));
self.exec_in_container(&container_name, "mkdir -p /opt/gbo/{bin,data,conf,logs}")?;
self.exec_in_container(
&container_name,
"echo 'nameserver 8.8.8.8' > /etc/resolv.conf",
)?;
self.exec_in_container(
&container_name,
"echo 'nameserver 8.8.4.4' >> /etc/resolv.conf",
)?;
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",
)?;
2025-11-22 22:55:35 -03:00
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
),
)?;
2025-11-22 22:55:35 -03:00
}
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)?;
let container_ip = Self::get_container_ip(&container_name)?;
if component.name == "vault" {
Self::initialize_vault(&container_name, &container_ip)?;
}
let (connection_info, env_vars) =
self.generate_connection_info(&component.name, &container_ip, &component.ports);
2025-11-22 22:55:35 -03:00
trace!(
"Container installation of '{}' completed in {}",
component.name,
container_name
);
Ok(InstallResult {
component: component.name.clone(),
container_name,
container_ip,
ports: component.ports.clone(),
env_vars,
connection_info,
})
}
fn get_container_ip(container_name: &str) -> Result<String> {
std::thread::sleep(std::time::Duration::from_secs(2));
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();
if !ip_output.is_empty() {
let ip = ip_output.split([' ', '(']).next().unwrap_or("").trim();
if !ip.is_empty() && ip.contains('.') {
return Ok(ip.to_string());
}
}
}
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())
}
fn initialize_vault(container_name: &str, ip: &str) -> Result<()> {
info!("Initializing Vault...");
std::thread::sleep(std::time::Duration::from_secs(5));
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);
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);
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")?;
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 {
let _ = writeln!(
unseal_content,
"VAULT_UNSEAL_KEY_{}={}",
i + 1,
key.as_str().unwrap_or("")
);
}
}
std::fs::write(&unseal_keys_file, &unseal_content)?;
#[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());
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() {
let existing = std::fs::read_to_string(&env_file)?;
if existing.contains("VAULT_ADDR=") {
warn!(".env already contains VAULT_ADDR, not overwriting");
} else {
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 {
std::fs::write(&env_file, env_content.trim_start())?;
info!("Created .env with Vault config");
}
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(())
}
fn generate_connection_info(
&self,
component: &str,
ip: &str,
ports: &[u16],
) -> (String, HashMap<String, String>) {
let env_vars = HashMap::new();
let connection_info = match component {
"vault" => {
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 <key>
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=<your-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=<your-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=<your-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=<your-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-id> client_secret=<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-key> api_secret=<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=<influx-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=<api-token>",
ip, ip, ip
)
}
_ => {
let ports_str = ports
.iter()
.map(|p| format!(" - {}:{}", ip, p))
.collect::<Vec<_>>()
.join("\n");
format!(
r"Component: {}
Container: {}-{}
IP: {}
Ports:
{}",
component, self.tenant, component, ip, ports_str
)
}
};
(connection_info, env_vars)
2025-11-22 22:55:35 -03:00
}
2025-11-22 22:55:35 -03:00
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();
2025-11-22 22:55:35 -03:00
let output = Command::new("lxc")
.args(["delete", &container_name])
2025-11-22 22:55:35 -03:00
.output()?;
if !output.status.success() {
warn!(
"Container deletion had issues: {}",
String::from_utf8_lossy(&output.stderr)
);
}
Ok(())
}
pub fn list(&self) -> Vec<String> {
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"])
2025-11-22 22:55:35 -03:00
.output()
2025-12-28 19:29:18 -03:00
.expect("valid syntax registration");
2025-11-22 22:55:35 -03:00
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.display()))?;
2025-11-22 22:55:35 -03:00
}
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()?;
2025-11-22 22:55:35 -03:00
if !output.status.success() {
warn!("apt-get update had issues");
}
let output = Command::new("apt-get")
.args(["install", "-y"])
2025-11-22 22:55:35 -03:00
.args(packages)
.output()?;
if !output.status.success() {
warn!("Some packages may have failed to install");
}
}
OsType::MacOS => {
let output = Command::new("brew")
.args(["install"])
2025-11-22 22:55:35 -03:00
.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)?;
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);
DownloadCache::new(&self.base_path).expect("Failed to create fallback cache")
});
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.display()
);
cached_path
}
CacheResult::Download {
url: download_url,
cache_path,
} => {
info!("Downloading {} from {}", component, download_url);
println!("Downloading {}", download_url);
self.download_with_reqwest(&download_url, &cache_path, component)
.await?;
info!("Cached {} to {}", component, cache_path.display());
cache_path
}
2025-11-22 22:55:35 -03:00
};
self.handle_downloaded_file(&source_file, &bin_path, binary_name)?;
2025-11-22 22:55:35 -03:00
Ok(())
}
pub async fn download_with_reqwest(
&self,
url: &str,
target_file: &std::path::Path,
2025-11-22 22:55:35 -03:00
component: &str,
) -> Result<()> {
const MAX_RETRIES: u32 = 3;
const RETRY_DELAY: std::time::Duration = std::time::Duration::from_secs(2);
if let Some(parent) = target_file.parent() {
std::fs::create_dir_all(parent)?;
}
2025-11-22 22:55:35 -03:00
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
{
2025-11-22 22:55:35 -03:00
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);
2025-11-22 22:55:35 -03:00
}
}
}
Err(anyhow::anyhow!(
"Failed to download {} after {} attempts. Last error: {}",
component,
MAX_RETRIES + 1,
2025-12-28 19:29:18 -03:00
last_error.unwrap_or_else(|| anyhow::anyhow!("unknown error"))
2025-11-22 22:55:35 -03:00
))
}
pub async fn attempt_reqwest_download(
&self,
_client: &Client,
url: &str,
temp_file: &std::path::Path,
2025-11-22 22:55:35 -03:00
) -> Result<u64> {
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: &std::path::Path,
bin_path: &std::path::Path,
2025-11-22 22:55:35 -03:00
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 {
2025-12-28 19:29:18 -03:00
let final_path = bin_path.join(temp_file.file_name().unwrap_or_default());
if temp_file.to_string_lossy().contains("botserver-installers") {
std::fs::copy(temp_file, &final_path)?;
} else {
std::fs::rename(temp_file, &final_path)?;
}
2025-11-22 22:55:35 -03:00
self.make_executable(&final_path)?;
}
}
}
Ok(())
}
pub fn extract_tar_gz(
&self,
temp_file: &std::path::Path,
bin_path: &std::path::Path,
) -> Result<()> {
2025-11-22 22:55:35 -03:00
let output = Command::new("tar")
.current_dir(bin_path)
.args([
"-xzf",
temp_file.to_str().unwrap_or_default(),
"--strip-components=1",
])
2025-11-22 22:55:35 -03:00
.output()?;
if !output.status.success() {
return Err(anyhow::anyhow!(
"tar extraction failed: {}",
String::from_utf8_lossy(&output.stderr)
));
}
if !temp_file.to_string_lossy().contains("botserver-installers") {
std::fs::remove_file(temp_file)?;
}
2025-11-22 22:55:35 -03:00
Ok(())
}
pub fn extract_zip(
&self,
temp_file: &std::path::Path,
bin_path: &std::path::Path,
) -> Result<()> {
2025-11-22 22:55:35 -03:00
let output = Command::new("unzip")
.current_dir(bin_path)
.args(["-o", "-q", temp_file.to_str().unwrap_or_default()])
2025-11-22 22:55:35 -03:00
.output()?;
if !output.status.success() {
return Err(anyhow::anyhow!(
"unzip extraction failed: {}",
String::from_utf8_lossy(&output.stderr)
));
}
#[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();
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.display());
}
}
}
}
}
}
if !temp_file.to_string_lossy().contains("botserver-installers") {
std::fs::remove_file(temp_file)?;
}
2025-11-22 22:55:35 -03:00
Ok(())
}
pub fn install_binary(
&self,
temp_file: &std::path::Path,
bin_path: &std::path::Path,
2025-11-22 22:55:35 -03:00
name: &str,
) -> Result<()> {
let final_path = bin_path.join(name);
if temp_file.to_string_lossy().contains("botserver-installers") {
std::fs::copy(temp_file, &final_path)?;
} else {
std::fs::rename(temp_file, &final_path)?;
}
2025-11-22 22:55:35 -03:00
self.make_executable(&final_path)?;
Ok(())
}
pub fn make_executable(&self, path: &std::path::Path) -> Result<()> {
2025-11-22 22:55:35 -03:00
#[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")
};
2025-11-22 22:55:35 -03:00
let conf_path = if target == "local" {
self.base_path.join("conf")
2025-11-22 22:55:35 -03:00
} 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")
};
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, using empty string");
String::new()
}
};
2025-11-22 22:55:35 -03:00
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);
2025-11-22 22:55:35 -03:00
if target == "local" {
trace!("Executing command: {}", rendered_cmd);
let output = Command::new("bash")
2025-11-22 22:55:35 -03:00
.current_dir(&bin_path)
.args(["-c", &rendered_cmd])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::piped())
.output()
2025-11-22 22:55:35 -03:00
.with_context(|| {
format!("Failed to execute command for component '{}'", component)
2025-11-22 22:55:35 -03:00
})?;
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<()> {
info!("Executing in container {}: {}", container, command);
2025-11-22 22:55:35 -03:00
let output = Command::new("lxc")
.args(["exec", container, "--", "bash", "-c", command])
2025-11-22 22:55:35 -03:00
.output()?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
let stdout = String::from_utf8_lossy(&output.stdout);
error!(
"Container command failed.\nCommand: {}\nStderr: {}\nStdout: {}",
command, stderr, stdout
2025-11-22 22:55:35 -03:00
);
return Err(anyhow::anyhow!(
"Container command failed: {}",
if stderr.is_empty() {
stdout.to_string()
} else {
stderr.to_string()
}
));
2025-11-22 22:55:35 -03:00
}
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)?;
let path = std::path::Path::new(url);
let is_tar_gz = path
.extension()
.is_some_and(|ext| ext.eq_ignore_ascii_case("tgz"))
|| (path
.extension()
.is_some_and(|ext| ext.eq_ignore_ascii_case("gz"))
&& path
.file_stem()
.and_then(|s| std::path::Path::new(s).extension())
.is_some_and(|e| e.eq_ignore_ascii_case("tar")));
let is_zip = path
.extension()
.is_some_and(|ext| ext.eq_ignore_ascii_case("zip"));
if is_tar_gz {
2025-11-22 22:55:35 -03:00
self.exec_in_container(container, "tar -xzf /tmp/download.tmp -C /opt/gbo/bin")?;
} else if is_zip {
2025-11-22 22:55:35 -03:00
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])
2025-11-22 22:55:35 -03:00
.output();
let output = Command::new("lxc")
.args([
2025-11-22 22:55:35 -03:00
"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<String, String>,
) -> Result<()> {
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()
}
};
2025-11-22 22:55:35 -03:00
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");
let _ = writeln!(env_section, "Environment={key}={rendered_value}");
2025-11-22 22:55:35 -03:00
}
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([
2025-11-22 22:55:35 -03:00
"file",
"push",
&service_file,
&format!("{container}/etc/systemd/system/{component}.service"),
2025-11-22 22:55:35 -03:00
])
.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])
2025-11-22 22:55:35 -03:00
.output();
let output = Command::new("lxc")
.args([
2025-11-22 22:55:35 -03:00
"config",
"device",
"add",
container,
&device_name,
"proxy",
&format!("listen=tcp:0.0.0.0:{port}"),
&format!("connect=tcp:127.0.0.1:{port}"),
2025-11-22 22:55:35 -03:00
])
.output()?;
if !output.status.success() {
warn!("Failed to setup port forwarding for port {}", port);
}
trace!(
"Port forwarding configured: {} -> container {}",
port,
container
);
}
Ok(())
}
}