RPC handlers no longer block on podman operations. container-stop on
bitcoin-core used to hold the connection for up to 600s while the UI
showed a frozen spinner; it now returns in under a second with
{status: stopping} after flipping the package state to Stopping and
broadcasting over WebSocket. Same treatment for container-start and
the new container-restart route.
Widens container-list state mapping to emit the transitional variants
(stopping, starting, restarting, installing, updating, removing,
installed, and the backup states) instead of collapsing them to
"unknown". Keeps the mapping in sync with the UI ContainerStatus.state
union so the dashboard can render the right transitional label.
Mirrors the treatment in package/runtime.rs for package.start,
package.stop, and package.restart. The body of each handler is lifted
into pure do_package_* helpers that the background task runs; state
flipping is bracketed around the spawn with revert on error. The
pre-existing post-start exit-check verification and restart stop+start
fallback run inside the spawned task, not the RPC body.
Adds container-restart route to the dispatcher. mark_user_stopped
continues to run BEFORE the spawn, preserving the ordering contract
with the crash recovery layer at runtime.rs:145-148.
411 lines
16 KiB
Rust
411 lines
16 KiB
Rust
use super::package::validate_app_id;
|
|
use super::transitional::Op;
|
|
use super::RpcHandler;
|
|
use anyhow::{Context, Result};
|
|
|
|
impl RpcHandler {
|
|
pub(super) async fn handle_container_install(
|
|
&self,
|
|
params: Option<serde_json::Value>,
|
|
) -> Result<serde_json::Value> {
|
|
// The `container-install { manifest_path }` RPC is a dev-mode convenience
|
|
// that points at an arbitrary YAML on disk. Production install happens via
|
|
// the reconciler (BootReconciler, Step 5) and via the unified
|
|
// ContainerOrchestrator::install(app_id) trait call, which can be exposed
|
|
// through a separate `container-install-by-id` RPC when needed.
|
|
let dev = self.dev_orchestrator.as_ref().ok_or_else(|| {
|
|
anyhow::anyhow!("container-install with manifest_path is only available in dev mode")
|
|
})?;
|
|
|
|
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 = dev
|
|
.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 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)?;
|
|
|
|
// User explicitly started the app — clear the user-stopped marker so
|
|
// crash recovery / health monitor won't second-guess it. Must happen
|
|
// BEFORE the spawn (see runtime.rs:145-148 for the symmetric stop
|
|
// side and the ordering contract crash recovery depends on).
|
|
crate::crash_recovery::clear_user_stopped(&self.config.data_dir, app_id).await;
|
|
|
|
// spawn_transitional returns as soon as the background task is
|
|
// launched (<1s). The UI sees Starting… immediately via WebSocket.
|
|
self.spawn_transitional(Op::Start, app_id.to_string()).await?;
|
|
|
|
Ok(serde_json::json!({ "status": "starting" }))
|
|
}
|
|
|
|
pub(super) async fn handle_container_stop(
|
|
&self,
|
|
params: Option<serde_json::Value>,
|
|
) -> Result<serde_json::Value> {
|
|
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)?;
|
|
|
|
// Mark as user-stopped BEFORE the spawn — ordering is load-bearing
|
|
// (crash recovery / health monitor inspect this flag concurrently
|
|
// with the in-flight stop; see runtime.rs:145-148 for the package
|
|
// path that also writes this in the same order).
|
|
crate::crash_recovery::mark_user_stopped(&self.config.data_dir, app_id).await;
|
|
|
|
// podman stop -t 600 (bitcoin-core) / -t 330 (lnd) runs in the
|
|
// background; the RPC returns now with "stopping".
|
|
self.spawn_transitional(Op::Stop, app_id.to_string()).await?;
|
|
|
|
Ok(serde_json::json!({ "status": "stopping" }))
|
|
}
|
|
|
|
pub(super) async fn handle_container_restart(
|
|
&self,
|
|
params: Option<serde_json::Value>,
|
|
) -> Result<serde_json::Value> {
|
|
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)?;
|
|
|
|
// Restart does not mark user-stopped (the user wants the app to
|
|
// keep running). Clear the marker as a defensive measure in case a
|
|
// prior stop left it set and the restart is intended to revive the
|
|
// normal running state.
|
|
crate::crash_recovery::clear_user_stopped(&self.config.data_dir, app_id).await;
|
|
|
|
self.spawn_transitional(Op::Restart, app_id.to_string())
|
|
.await?;
|
|
|
|
Ok(serde_json::json!({ "status": "restarting" }))
|
|
}
|
|
|
|
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"))?;
|
|
|
|
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(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)| {
|
|
// Keep this mapping in sync with the UI's
|
|
// ContainerStatus.state union in
|
|
// neode-ui/src/api/container-client.ts. The UI maps
|
|
// transitional variants to single-button labels
|
|
// (Stopping… / Starting… / Restarting…).
|
|
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 => "starting",
|
|
crate::data_model::PackageState::Stopping => "stopping",
|
|
crate::data_model::PackageState::Restarting => "restarting",
|
|
crate::data_model::PackageState::Installing => "installing",
|
|
crate::data_model::PackageState::Installed => "installed",
|
|
crate::data_model::PackageState::Updating => "updating",
|
|
crate::data_model::PackageState::Removing => "removing",
|
|
crate::data_model::PackageState::CreatingBackup => "creating-backup",
|
|
crate::data_model::PackageState::RestoringBackup => "restoring-backup",
|
|
crate::data_model::PackageState::BackingUp => "backing-up",
|
|
};
|
|
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 the orchestrator directly.
|
|
if let Some(orchestrator) = &self.orchestrator {
|
|
if let Ok(containers) = orchestrator.list().await {
|
|
if !containers.is_empty() {
|
|
return Ok(serde_json::to_value(containers)?);
|
|
}
|
|
}
|
|
}
|
|
|
|
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"))?;
|
|
|
|
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
|
|
.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"))?;
|
|
|
|
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
|
|
.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"))?;
|
|
|
|
let logs = orchestrator
|
|
.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"))?;
|
|
|
|
// 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
|
|
.health(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()
|
|
.await
|
|
.context("Failed to list containers")?;
|
|
|
|
let mut health_map = serde_json::Map::new();
|
|
for container in containers {
|
|
// Map the runtime container name back to the app_id the orchestrator
|
|
// knows about. Dev orchestrator uses `archipelago-<id>-dev`; Prod
|
|
// uses bare `<id>` (or `archy-<id>` for UIs — health() accepts the
|
|
// app_id either way since UI_APP_IDS is centralised).
|
|
let app_id_candidate = container
|
|
.name
|
|
.strip_prefix("archipelago-")
|
|
.and_then(|s| s.strip_suffix("-dev"))
|
|
.or_else(|| container.name.strip_prefix("archy-"))
|
|
.unwrap_or(container.name.as_str());
|
|
match orchestrator.health(app_id_candidate).await {
|
|
Ok(health) => {
|
|
health_map.insert(
|
|
app_id_candidate.to_string(),
|
|
serde_json::Value::String(health),
|
|
);
|
|
}
|
|
Err(_) => {
|
|
health_map.insert(
|
|
app_id_candidate.to_string(),
|
|
serde_json::Value::String("unknown".to_string()),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
Ok(serde_json::Value::Object(health_map))
|
|
}
|
|
}
|