Enhance Docker integration and API for container management
- Implemented Docker container scanning and periodic updates in the Server initialization. - Added new RPC endpoints for managing Docker containers, including start, stop, and restart functionalities. - Updated the API to handle package management for Docker-based applications. - Improved environment variable handling for user-specific configurations in Podman and Docker clients. - Enhanced the development startup script to include Docker container management and provide clearer instructions for full stack setup.
This commit is contained in:
@@ -73,6 +73,8 @@ impl RpcHandler {
|
||||
"server.echo" => self.handle_echo(rpc_req.params).await,
|
||||
"auth.login" => self.handle_auth_login(rpc_req.params).await,
|
||||
"auth.logout" => self.handle_auth_logout().await,
|
||||
|
||||
// Container orchestration (for Archipelago-managed containers)
|
||||
"container-install" => self.handle_container_install(rpc_req.params).await,
|
||||
"container-start" => self.handle_container_start(rpc_req.params).await,
|
||||
"container-stop" => self.handle_container_stop(rpc_req.params).await,
|
||||
@@ -81,6 +83,12 @@ impl RpcHandler {
|
||||
"container-status" => self.handle_container_status(rpc_req.params).await,
|
||||
"container-logs" => self.handle_container_logs(rpc_req.params).await,
|
||||
"container-health" => self.handle_container_health(rpc_req.params).await,
|
||||
|
||||
// Package management (for docker-compose apps)
|
||||
"package.start" => self.handle_package_start(rpc_req.params).await,
|
||||
"package.stop" => self.handle_package_stop(rpc_req.params).await,
|
||||
"package.restart" => self.handle_package_restart(rpc_req.params).await,
|
||||
|
||||
_ => {
|
||||
Err(anyhow::anyhow!("Unknown method: {}", rpc_req.method))
|
||||
}
|
||||
@@ -373,4 +381,92 @@ impl RpcHandler {
|
||||
|
||||
Ok(serde_json::Value::Object(health_map))
|
||||
}
|
||||
|
||||
// Package management methods for docker-compose containers
|
||||
async fn handle_package_start(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let package_id = params
|
||||
.get("id")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing package id"))?;
|
||||
|
||||
// Convert package ID to container name (e.g., "bitcoin" -> "archy-bitcoin")
|
||||
let container_name = format!("archy-{}", package_id);
|
||||
|
||||
// Use docker CLI to start the container
|
||||
let output = tokio::process::Command::new("docker")
|
||||
.arg("start")
|
||||
.arg(&container_name)
|
||||
.output()
|
||||
.await
|
||||
.context("Failed to execute docker start")?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(anyhow::anyhow!("Failed to start container: {}", stderr));
|
||||
}
|
||||
|
||||
Ok(serde_json::Value::Null)
|
||||
}
|
||||
|
||||
async fn handle_package_stop(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let package_id = params
|
||||
.get("id")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing package id"))?;
|
||||
|
||||
// Convert package ID to container name
|
||||
let container_name = format!("archy-{}", package_id);
|
||||
|
||||
// Use docker CLI to stop the container
|
||||
let output = tokio::process::Command::new("docker")
|
||||
.arg("stop")
|
||||
.arg(&container_name)
|
||||
.output()
|
||||
.await
|
||||
.context("Failed to execute docker stop")?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(anyhow::anyhow!("Failed to stop container: {}", stderr));
|
||||
}
|
||||
|
||||
Ok(serde_json::Value::Null)
|
||||
}
|
||||
|
||||
async fn handle_package_restart(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
let package_id = params
|
||||
.get("id")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing package id"))?;
|
||||
|
||||
// Convert package ID to container name
|
||||
let container_name = format!("archy-{}", package_id);
|
||||
|
||||
// Use docker CLI to restart the container
|
||||
let output = tokio::process::Command::new("docker")
|
||||
.arg("restart")
|
||||
.arg(&container_name)
|
||||
.output()
|
||||
.await
|
||||
.context("Failed to execute docker restart")?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(anyhow::anyhow!("Failed to restart container: {}", stderr));
|
||||
}
|
||||
|
||||
Ok(serde_json::Value::Null)
|
||||
}
|
||||
}
|
||||
|
||||
254
core/archipelago/src/container/docker_packages.rs
Normal file
254
core/archipelago/src/container/docker_packages.rs
Normal file
@@ -0,0 +1,254 @@
|
||||
// Docker Package Scanner
|
||||
// Scans docker-compose containers and converts them to package data
|
||||
|
||||
use anyhow::Result;
|
||||
use archipelago_container::{ContainerRuntime as ContainerRuntimeTrait, ContainerState};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use tracing::{debug, info};
|
||||
|
||||
use crate::data_model::{
|
||||
Description, InstalledPackageDataEntry, InterfaceAddress, Interfaces, MainInterface, Manifest,
|
||||
PackageDataEntry, PackageState, ServiceStatus, StaticFiles,
|
||||
};
|
||||
|
||||
pub struct DockerPackageScanner {
|
||||
runtime: Arc<dyn ContainerRuntimeTrait>,
|
||||
}
|
||||
|
||||
impl DockerPackageScanner {
|
||||
pub fn new(runtime: Arc<dyn ContainerRuntimeTrait>) -> Self {
|
||||
Self { runtime }
|
||||
}
|
||||
|
||||
/// Scan Docker containers and convert to package data
|
||||
pub async fn scan_containers(&self) -> Result<HashMap<String, PackageDataEntry>> {
|
||||
let containers = self.runtime.list_containers().await?;
|
||||
|
||||
debug!("Found {} containers", containers.len());
|
||||
|
||||
let mut packages = HashMap::new();
|
||||
|
||||
for container in containers {
|
||||
// Only process archy-* containers from docker-compose
|
||||
if !container.name.starts_with("archy-") {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Extract app ID from container name (archy-bitcoin -> bitcoin)
|
||||
let app_id = container.name.strip_prefix("archy-")
|
||||
.unwrap_or(&container.name)
|
||||
.to_string();
|
||||
|
||||
// Get metadata for this app
|
||||
let metadata = get_app_metadata(&app_id);
|
||||
|
||||
// Extract port from container
|
||||
let lan_address = extract_lan_address(&container.ports);
|
||||
|
||||
// Convert container state to package/service state
|
||||
let (package_state, service_status) = convert_state(&container.state);
|
||||
|
||||
let package = PackageDataEntry {
|
||||
state: package_state.clone(),
|
||||
static_files: StaticFiles {
|
||||
license: "MIT".to_string(),
|
||||
instructions: metadata.description.clone(),
|
||||
icon: metadata.icon.clone(),
|
||||
},
|
||||
manifest: Manifest {
|
||||
id: app_id.clone(),
|
||||
title: metadata.title.clone(),
|
||||
version: "1.0.0".to_string(),
|
||||
description: Description {
|
||||
short: metadata.description.clone(),
|
||||
long: metadata.description.clone(),
|
||||
},
|
||||
release_notes: "Docker container".to_string(),
|
||||
license: "MIT".to_string(),
|
||||
wrapper_repo: metadata.repo.clone(),
|
||||
upstream_repo: metadata.repo.clone(),
|
||||
support_site: metadata.repo.clone(),
|
||||
marketing_site: metadata.repo.clone(),
|
||||
donation_url: None,
|
||||
author: Some("Archipelago".to_string()),
|
||||
website: lan_address.clone(),
|
||||
interfaces: if lan_address.is_some() {
|
||||
Some(Interfaces {
|
||||
main: Some(MainInterface {
|
||||
ui: Some("true".to_string()),
|
||||
tor_config: None,
|
||||
lan_config: None,
|
||||
}),
|
||||
})
|
||||
} else {
|
||||
None
|
||||
},
|
||||
},
|
||||
installed: Some(InstalledPackageDataEntry {
|
||||
current_dependents: HashMap::new(),
|
||||
current_dependencies: HashMap::new(),
|
||||
last_backup: None,
|
||||
interface_addresses: if let Some(addr) = lan_address {
|
||||
let mut addresses = HashMap::new();
|
||||
addresses.insert(
|
||||
"main".to_string(),
|
||||
InterfaceAddress {
|
||||
tor_address: format!("{}.onion", app_id),
|
||||
lan_address: Some(addr),
|
||||
},
|
||||
);
|
||||
addresses
|
||||
} else {
|
||||
HashMap::new()
|
||||
},
|
||||
status: service_status,
|
||||
}),
|
||||
install_progress: None,
|
||||
};
|
||||
|
||||
packages.insert(app_id.clone(), package);
|
||||
info!("Detected container: {} ({})", metadata.title, package_state_str(&package_state));
|
||||
}
|
||||
|
||||
Ok(packages)
|
||||
}
|
||||
}
|
||||
|
||||
struct AppMetadata {
|
||||
title: String,
|
||||
description: String,
|
||||
icon: String,
|
||||
repo: String,
|
||||
}
|
||||
|
||||
fn get_app_metadata(app_id: &str) -> AppMetadata {
|
||||
match app_id {
|
||||
"bitcoin" => AppMetadata {
|
||||
title: "Bitcoin Core".to_string(),
|
||||
description: "Full Bitcoin node implementation".to_string(),
|
||||
icon: "/assets/img/app-icons/bitcoin.svg".to_string(),
|
||||
repo: "https://github.com/bitcoin/bitcoin".to_string(),
|
||||
},
|
||||
"btcpay" | "btcpay-server" => AppMetadata {
|
||||
title: "BTCPay Server".to_string(),
|
||||
description: "Self-hosted Bitcoin payment processor".to_string(),
|
||||
icon: "/assets/img/app-icons/btcpay-server.png".to_string(),
|
||||
repo: "https://github.com/btcpayserver/btcpayserver".to_string(),
|
||||
},
|
||||
"homeassistant" => AppMetadata {
|
||||
title: "Home Assistant".to_string(),
|
||||
description: "Open source home automation platform".to_string(),
|
||||
icon: "/assets/img/app-icons/homeassistant.png".to_string(),
|
||||
repo: "https://github.com/home-assistant/core".to_string(),
|
||||
},
|
||||
"grafana" => AppMetadata {
|
||||
title: "Grafana".to_string(),
|
||||
description: "Analytics and monitoring platform".to_string(),
|
||||
icon: "/assets/img/grafana.png".to_string(),
|
||||
repo: "https://github.com/grafana/grafana".to_string(),
|
||||
},
|
||||
"endurain" => AppMetadata {
|
||||
title: "Endurain".to_string(),
|
||||
description: "Application platform".to_string(),
|
||||
icon: "/assets/img/endurain.png".to_string(),
|
||||
repo: "#".to_string(),
|
||||
},
|
||||
"fedimint" => AppMetadata {
|
||||
title: "Fedimint".to_string(),
|
||||
description: "Federated Bitcoin mint".to_string(),
|
||||
icon: "/assets/img/icon-fedimint.jpeg".to_string(),
|
||||
repo: "https://github.com/fedimint/fedimint".to_string(),
|
||||
},
|
||||
"morphos" | "morphos-server" => AppMetadata {
|
||||
title: "MorphOS Server".to_string(),
|
||||
description: "Server platform".to_string(),
|
||||
icon: "/assets/img/morphos.png".to_string(),
|
||||
repo: "#".to_string(),
|
||||
},
|
||||
"lnd" | "lightning-stack" => AppMetadata {
|
||||
title: "Lightning Stack".to_string(),
|
||||
description: "Lightning Network (LND)".to_string(),
|
||||
icon: "/assets/img/app-icons/lightning-stack.png".to_string(),
|
||||
repo: "https://github.com/lightningnetwork/lnd".to_string(),
|
||||
},
|
||||
"mempool" | "mempool-web" => AppMetadata {
|
||||
title: "Mempool".to_string(),
|
||||
description: "Bitcoin blockchain explorer".to_string(),
|
||||
icon: "/assets/img/app-icons/mempool.png".to_string(),
|
||||
repo: "https://github.com/mempool/mempool".to_string(),
|
||||
},
|
||||
"ollama" => AppMetadata {
|
||||
title: "Ollama".to_string(),
|
||||
description: "Run large language models locally".to_string(),
|
||||
icon: "/assets/img/ollama.webp".to_string(),
|
||||
repo: "https://github.com/ollama/ollama".to_string(),
|
||||
},
|
||||
"searxng" => AppMetadata {
|
||||
title: "SearXNG".to_string(),
|
||||
description: "Privacy-respecting metasearch engine".to_string(),
|
||||
icon: "/assets/img/app-icons/searxng.png".to_string(),
|
||||
repo: "https://github.com/searxng/searxng".to_string(),
|
||||
},
|
||||
"onlyoffice" | "onlyoffice-documentserver" => AppMetadata {
|
||||
title: "OnlyOffice".to_string(),
|
||||
description: "Office suite and document collaboration".to_string(),
|
||||
icon: "/assets/img/onlyoffice.webp".to_string(),
|
||||
repo: "https://github.com/ONLYOFFICE/DocumentServer".to_string(),
|
||||
},
|
||||
"penpot" | "penpot-frontend" => AppMetadata {
|
||||
title: "Penpot".to_string(),
|
||||
description: "Open-source design and prototyping".to_string(),
|
||||
icon: "/assets/img/penpot.webp".to_string(),
|
||||
repo: "https://github.com/penpot/penpot".to_string(),
|
||||
},
|
||||
_ => AppMetadata {
|
||||
title: app_id.to_string(),
|
||||
description: format!("{} application", app_id),
|
||||
icon: "/assets/img/favico.png".to_string(),
|
||||
repo: "#".to_string(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn extract_lan_address(ports: &[String]) -> Option<String> {
|
||||
for port_str in ports {
|
||||
// Parse port strings like "0.0.0.0:18443->18443/tcp" or "0.0.0.0:18443-18444->18443-18444/tcp"
|
||||
if let Some(public_part) = port_str.split("->").next() {
|
||||
if let Some(port_part) = public_part.split(':').nth(1) {
|
||||
// Extract just the first port if it's a range (e.g., "18443-18444" -> "18443")
|
||||
let single_port = port_part.split('-').next().unwrap_or(port_part);
|
||||
return Some(format!("http://localhost:{}", single_port));
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn convert_state(container_state: &ContainerState) -> (PackageState, ServiceStatus) {
|
||||
match container_state {
|
||||
ContainerState::Running => (PackageState::Running, ServiceStatus::Running),
|
||||
ContainerState::Stopped | ContainerState::Exited => {
|
||||
(PackageState::Stopped, ServiceStatus::Stopped)
|
||||
}
|
||||
ContainerState::Created => (PackageState::Starting, ServiceStatus::Starting),
|
||||
ContainerState::Paused => (PackageState::Stopped, ServiceStatus::Stopped),
|
||||
ContainerState::Unknown(_) => (PackageState::Stopped, ServiceStatus::Stopped),
|
||||
}
|
||||
}
|
||||
|
||||
fn package_state_str(state: &PackageState) -> &str {
|
||||
match state {
|
||||
PackageState::Installing => "installing",
|
||||
PackageState::Installed => "installed",
|
||||
PackageState::Stopping => "stopping",
|
||||
PackageState::Stopped => "stopped",
|
||||
PackageState::Starting => "starting",
|
||||
PackageState::Running => "running",
|
||||
PackageState::Restarting => "restarting",
|
||||
PackageState::CreatingBackup => "creating-backup",
|
||||
PackageState::RestoringBackup => "restoring-backup",
|
||||
PackageState::Removing => "removing",
|
||||
PackageState::BackingUp => "backing-up",
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
pub mod data_manager;
|
||||
pub mod dev_orchestrator;
|
||||
pub mod docker_packages;
|
||||
|
||||
pub use dev_orchestrator::DevContainerOrchestrator;
|
||||
pub use docker_packages::DockerPackageScanner;
|
||||
|
||||
@@ -1,27 +1,54 @@
|
||||
use crate::api::ApiHandler;
|
||||
use crate::config::Config;
|
||||
use crate::config::{Config, ContainerRuntime};
|
||||
use crate::container::DockerPackageScanner;
|
||||
use crate::state::StateManager;
|
||||
use anyhow::Result;
|
||||
use hyper::server::conn::Http;
|
||||
use hyper::service::service_fn;
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use tokio::net::TcpListener;
|
||||
use tracing::error;
|
||||
use tracing::{error, info};
|
||||
|
||||
pub struct Server {
|
||||
_config: Config,
|
||||
api_handler: Arc<ApiHandler>,
|
||||
state_manager: Arc<StateManager>,
|
||||
}
|
||||
|
||||
impl Server {
|
||||
pub async fn new(config: Config) -> Result<Self> {
|
||||
let state_manager = Arc::new(StateManager::new());
|
||||
let api_handler = Arc::new(ApiHandler::new(config.clone(), state_manager).await?);
|
||||
let api_handler = Arc::new(ApiHandler::new(config.clone(), state_manager.clone()).await?);
|
||||
|
||||
// Initialize Docker scanner if in dev mode
|
||||
if config.dev_mode {
|
||||
let scanner = create_docker_scanner(&config).await?;
|
||||
let state = state_manager.clone();
|
||||
|
||||
// Initial scan
|
||||
tokio::spawn(async move {
|
||||
info!("🐳 Scanning Docker containers...");
|
||||
if let Err(e) = scan_and_update_packages(&scanner, &state).await {
|
||||
error!("Failed to scan Docker containers: {}", e);
|
||||
}
|
||||
|
||||
// Periodic scan every 5 seconds
|
||||
let mut interval = tokio::time::interval(Duration::from_secs(5));
|
||||
loop {
|
||||
interval.tick().await;
|
||||
if let Err(e) = scan_and_update_packages(&scanner, &state).await {
|
||||
error!("Failed to update Docker containers: {}", e);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Ok(Self {
|
||||
_config: config,
|
||||
api_handler,
|
||||
state_manager,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -59,3 +86,39 @@ impl Server {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn create_docker_scanner(config: &Config) -> Result<DockerPackageScanner> {
|
||||
let user = std::env::var("USER").unwrap_or_else(|_| "archipelago".to_string());
|
||||
|
||||
let runtime: Arc<dyn archipelago_container::ContainerRuntime> = match &config.container_runtime {
|
||||
ContainerRuntime::Podman => {
|
||||
Arc::new(archipelago_container::PodmanRuntime::new(user.clone()))
|
||||
}
|
||||
ContainerRuntime::Docker => {
|
||||
Arc::new(archipelago_container::DockerRuntime::new(user.clone()))
|
||||
}
|
||||
ContainerRuntime::Auto => {
|
||||
Arc::new(
|
||||
archipelago_container::AutoRuntime::new(user.clone())
|
||||
.await?
|
||||
)
|
||||
}
|
||||
};
|
||||
|
||||
Ok(DockerPackageScanner::new(runtime))
|
||||
}
|
||||
|
||||
async fn scan_and_update_packages(
|
||||
scanner: &DockerPackageScanner,
|
||||
state: &StateManager,
|
||||
) -> Result<()> {
|
||||
let packages = scanner.scan_containers().await?;
|
||||
|
||||
if !packages.is_empty() {
|
||||
let (mut data, _) = state.get_snapshot().await;
|
||||
data.package_data = packages;
|
||||
state.update_data(data).await;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user