archy/core/archipelago/src/api/rpc/container.rs

346 lines
13 KiB
Rust
Raw Normal View History

use super::RpcHandler;
use super::package::validate_app_id;
use anyhow::{Context, Result};
impl RpcHandler {
pub(super) async fn handle_container_install(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let orchestrator = self
.orchestrator
.as_ref()
.ok_or_else(|| anyhow::anyhow!("Container orchestrator not available (dev mode required)"))?;
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
let manifest_path = params
.get("manifest_path")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing manifest_path"))?;
// Validate manifest path: reject traversal, resolve to canonical path
if manifest_path.contains("..") || manifest_path.contains('\0') {
return Err(anyhow::anyhow!(
"Invalid manifest_path: path traversal not allowed"
));
}
let apps_dir = self.config.data_dir.join("apps");
let resolved = if std::path::Path::new(manifest_path).is_absolute() {
std::path::PathBuf::from(manifest_path)
} else {
apps_dir.join(manifest_path)
};
let canonical = resolved
.canonicalize()
.context("Invalid manifest_path: file not found")?;
if !canonical.starts_with(&apps_dir) {
return Err(anyhow::anyhow!(
"Invalid manifest_path: must be under the apps directory"
));
}
// Load manifest
let manifest_content = tokio::fs::read_to_string(&canonical)
.await
.context("Failed to read manifest file")?;
let manifest: archipelago_container::AppManifest = serde_yaml::from_str(&manifest_content)
.context("Failed to parse manifest")?;
let container_name = orchestrator
.install_container(&manifest, manifest_path)
.await
.context("Failed to install container")?;
Ok(serde_json::json!(container_name))
}
pub(super) async fn handle_container_start(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let orchestrator = self
.orchestrator
.as_ref()
.ok_or_else(|| anyhow::anyhow!("Container orchestrator not available (dev mode required)"))?;
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
let app_id = params
.get("app_id")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing app_id"))?;
validate_app_id(app_id)?;
orchestrator
.start_container(app_id)
.await
.context("Failed to start container")?;
Ok(serde_json::json!({ "status": "started" }))
}
pub(super) async fn handle_container_stop(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let orchestrator = self
.orchestrator
.as_ref()
.ok_or_else(|| anyhow::anyhow!("Container orchestrator not available (dev mode required)"))?;
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
let app_id = params
.get("app_id")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing app_id"))?;
validate_app_id(app_id)?;
orchestrator
.stop_container(app_id)
.await
.context("Failed to stop container")?;
Ok(serde_json::json!({ "status": "stopped" }))
}
pub(super) async fn handle_container_remove(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let orchestrator = self
.orchestrator
.as_ref()
.ok_or_else(|| anyhow::anyhow!("Container orchestrator not available (dev mode required)"))?;
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
let app_id = params
.get("app_id")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing app_id"))?;
validate_app_id(app_id)?;
let preserve_data = params
.get("preserve_data")
.and_then(|v| v.as_bool())
.unwrap_or(false);
orchestrator
.remove_container(app_id, preserve_data)
.await
.context("Failed to remove container")?;
Ok(serde_json::json!({ "status": "removed" }))
}
pub(super) async fn handle_container_list(&self) -> Result<serde_json::Value> {
// Use the scanner's cached state for consistency with WebSocket updates.
// This prevents the container-list RPC from returning different results
// than the WebSocket-delivered package_data, which caused apps to flicker
// between "installed" and "not-installed" in the UI.
let (data, _) = self.state_manager.get_snapshot().await;
if data.server_info.status_info.containers_scanned && !data.package_data.is_empty() {
let containers: Vec<serde_json::Value> = data.package_data.iter().map(|(id, pkg)| {
let state = match &pkg.state {
crate::data_model::PackageState::Running => "running",
crate::data_model::PackageState::Stopped => "stopped",
crate::data_model::PackageState::Exited => "exited",
crate::data_model::PackageState::Starting => "created",
_ => "unknown",
};
let lan = pkg.installed.as_ref()
.and_then(|i| i.interface_addresses.get("main"))
.and_then(|a| a.lan_address.as_deref());
serde_json::json!({
"id": id,
"name": id,
"state": state,
"image": "",
"created": "",
"ports": [],
"lan_address": lan,
})
}).collect();
return Ok(serde_json::json!(containers));
}
// Fallback: scanner hasn't run yet, query podman directly
if let Some(orchestrator) = &self.orchestrator {
if let Ok(containers) = orchestrator.list_containers().await {
if !containers.is_empty() {
return Ok(serde_json::to_value(containers)?);
}
}
}
feat: rootless podman, session hardening, boot stability, sidebar fix Rootless podman migration (TASK-11): - Remove sudo from all podman calls in PodmanClient + 8 backend files - Remove sudo from all podman/docker calls in deploy script - Restore full systemd security hardening: NoNewPrivileges, RestrictAddressFamilies, MemoryDenyWriteExecute, RestrictRealtime, RestrictNamespaces, RestrictSUIDSGID, SystemCallFilter, ProtectSystem=strict - Enable loginctl linger for rootless container persistence - Remove Ollama from auto-deploy (marketplace-only) Session & auth hardening: - Increase MAX_CONCURRENT_SESSIONS 20→50 (prevents eviction storms) - Debounced 401 redirect in rpc-client.ts (prevents redirect storms) Boot stability: - optimize-debian.sh: adds chrony, swap, removes policy-rc.d - deploy script: pre-restart chrony + swap setup - ISO build: chrony package, swap file creation - BootScreen: no longer clears localStorage (prevents splash replay) - RootRedirect: sole owner of localStorage clearing on server ready UI fixes: - Sidebar opacity default changed from 0→visible (fixes missing sidebar after page-persistence login without entrance animation) - Console.log/error wrapped in import.meta.env.DEV guards - Remove unused route import from RootRedirect Beta tracking: - CLAUDE.md: beta freeze protocol added - MASTER_PLAN.md: TASK-11, TASK-17, phase structure - BETA-PROGRESS.md: initial tracking doc - Tagged v1.2.0-alpha.1 as pre-rootless baseline Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 13:53:27 +00:00
let output = tokio::process::Command::new("podman")
.args(["ps", "-a", "--format", "json"])
.output()
.await
.context("Failed to list containers via podman")?;
if !output.status.success() {
return Ok(serde_json::json!([]));
}
let stdout = String::from_utf8_lossy(&output.stdout);
if stdout.trim().is_empty() {
return Ok(serde_json::json!([]));
}
let podman_containers: Vec<serde_json::Value> = serde_json::from_str(&stdout)
.unwrap_or_else(|_| Vec::new());
let containers: Vec<serde_json::Value> = podman_containers
.iter()
.map(|c| {
let state = c.get("State").and_then(|v| v.as_str()).unwrap_or("unknown");
let mapped_state = match state.to_lowercase().as_str() {
"running" => "running",
"exited" => "exited",
"stopped" => "stopped",
"created" => "created",
"paused" => "paused",
_ => "unknown",
};
let name = c.get("Names").and_then(|v| v.as_array()).and_then(|a| a.first()).and_then(|v| v.as_str()).unwrap_or("");
let ports: Vec<String> = c.get("Ports")
.and_then(|v| v.as_array())
.map(|a| {
a.iter().filter_map(|p| {
let host = p.get("host_port").and_then(|v| v.as_u64())?;
let container = p.get("container_port").and_then(|v| v.as_u64())?;
let proto = p.get("protocol").and_then(|v| v.as_str()).unwrap_or("tcp");
Some(format!("0.0.0.0:{}->{}/{}", host, container, proto))
}).collect()
})
.unwrap_or_default();
serde_json::json!({
"id": c.get("Id").and_then(|v| v.as_str()).unwrap_or(""),
"name": name,
"state": mapped_state,
"image": c.get("Image").and_then(|v| v.as_str()).unwrap_or(""),
"created": c.get("Created").and_then(|v| v.as_str()).unwrap_or(""),
"ports": ports,
"lan_address": serde_json::Value::Null,
})
})
.collect();
Ok(serde_json::json!(containers))
}
pub(super) async fn handle_container_status(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let orchestrator = self
.orchestrator
.as_ref()
.ok_or_else(|| anyhow::anyhow!("Container orchestrator not available (dev mode required)"))?;
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
let app_id = params
.get("app_id")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing app_id"))?;
validate_app_id(app_id)?;
let status = orchestrator
.get_container_status(app_id)
.await
.context("Failed to get container status")?;
Ok(serde_json::to_value(status)?)
}
pub(super) async fn handle_container_logs(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let orchestrator = self
.orchestrator
.as_ref()
.ok_or_else(|| anyhow::anyhow!("Container orchestrator not available (dev mode required)"))?;
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
let app_id = params
.get("app_id")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing app_id"))?;
validate_app_id(app_id)?;
let lines = params
.get("lines")
.and_then(|v| v.as_u64())
.unwrap_or(100) as u32;
let logs = orchestrator
.get_container_logs(app_id, lines)
.await
.context("Failed to get container logs")?;
Ok(serde_json::to_value(logs)?)
}
/// Used by HTTP GET /api/container/logs (same logic as container-logs RPC).
pub async fn get_container_logs_value(
&self,
app_id: &str,
lines: u32,
) -> Result<serde_json::Value> {
let orchestrator = self
.orchestrator
.as_ref()
.ok_or_else(|| anyhow::anyhow!("Container orchestrator not available (dev mode required)"))?;
let logs = orchestrator
.get_container_logs(app_id, lines)
.await
.context("Failed to get container logs")?;
Ok(serde_json::to_value(logs)?)
}
pub(super) async fn handle_container_health(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let orchestrator = self
.orchestrator
.as_ref()
.ok_or_else(|| anyhow::anyhow!("Container orchestrator not available (dev mode required)"))?;
// If app_id is provided, get health for that app
if let Some(params) = params {
if let Some(app_id) = params.get("app_id").and_then(|v| v.as_str()) {
let health = orchestrator
.get_health_status(app_id)
.await
.context("Failed to get container health")?;
return Ok(serde_json::json!({ app_id: health }));
}
}
// Otherwise, get health for all containers
let containers = orchestrator
.list_containers()
.await
.context("Failed to list containers")?;
let mut health_map = serde_json::Map::new();
for container in containers {
if let Some(app_id) = container.name.strip_prefix("archipelago-") {
if let Some(app_id) = app_id.strip_suffix("-dev") {
match orchestrator.get_health_status(app_id).await {
Ok(health) => {
health_map.insert(app_id.to_string(), serde_json::Value::String(health));
}
Err(_) => {
health_map.insert(app_id.to_string(), serde_json::Value::String("unknown".to_string()));
}
}
}
}
}
Ok(serde_json::Value::Object(health_map))
}
}