bug fixing and deploy and build diagnostics
This commit is contained in:
@@ -0,0 +1,216 @@
|
||||
use super::config::get_containers_for_app;
|
||||
use anyhow::Result;
|
||||
use tracing::info;
|
||||
|
||||
/// Names of container variants that represent a running Bitcoin node
|
||||
const BITCOIN_NAMES: &[&str] = &["bitcoin-knots", "bitcoin-core", "bitcoin"];
|
||||
|
||||
/// Names of container variants that represent a running Electrum indexer
|
||||
const ELECTRUM_NAMES: &[&str] = &["electrumx", "mempool-electrs", "electrs"];
|
||||
|
||||
/// Snapshot of which dependency services are currently running.
|
||||
pub(super) struct RunningDeps {
|
||||
pub has_bitcoin: bool,
|
||||
pub has_electrumx: bool,
|
||||
pub has_lnd: bool,
|
||||
}
|
||||
|
||||
/// Query podman for currently running containers and return dependency status.
|
||||
pub(super) async fn detect_running_deps() -> Result<RunningDeps> {
|
||||
let dep_check = tokio::process::Command::new("podman")
|
||||
.args(["ps", "--format", "{{.Names}}"])
|
||||
.output()
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("Failed to check running containers: {}", e))?;
|
||||
|
||||
let running = String::from_utf8_lossy(&dep_check.stdout);
|
||||
let is_running = |names: &[&str]| {
|
||||
running.lines().any(|l| {
|
||||
let name = l.trim();
|
||||
names.iter().any(|n| name == *n)
|
||||
})
|
||||
};
|
||||
|
||||
Ok(RunningDeps {
|
||||
has_bitcoin: is_running(BITCOIN_NAMES),
|
||||
has_electrumx: is_running(ELECTRUM_NAMES),
|
||||
has_lnd: is_running(&["lnd"]),
|
||||
})
|
||||
}
|
||||
|
||||
/// Verify that required dependency services are running before installing an app.
|
||||
/// Returns an error with a user-friendly message if dependencies are missing.
|
||||
pub(super) fn check_install_deps(package_id: &str, deps: &RunningDeps) -> Result<()> {
|
||||
match package_id {
|
||||
"electrumx" | "mempool-electrs" | "electrs" if !deps.has_bitcoin => {
|
||||
Err(anyhow::anyhow!(
|
||||
"ElectrumX requires a running Bitcoin node (Bitcoin Knots). \
|
||||
Please install and start Bitcoin Knots first."
|
||||
))
|
||||
}
|
||||
"lnd" if !deps.has_bitcoin => Err(anyhow::anyhow!(
|
||||
"LND requires a running Bitcoin node (Bitcoin Knots). \
|
||||
Please install and start Bitcoin Knots first."
|
||||
)),
|
||||
"btcpay-server" | "btcpayserver" if !deps.has_bitcoin => Err(anyhow::anyhow!(
|
||||
"BTCPay Server requires a running Bitcoin node (Bitcoin Knots). \
|
||||
Please install and start Bitcoin Knots first."
|
||||
)),
|
||||
"mempool" | "mempool-web" if !deps.has_bitcoin || !deps.has_electrumx => {
|
||||
let mut missing = vec![];
|
||||
if !deps.has_bitcoin {
|
||||
missing.push("Bitcoin Knots");
|
||||
}
|
||||
if !deps.has_electrumx {
|
||||
missing.push("ElectrumX");
|
||||
}
|
||||
Err(anyhow::anyhow!(
|
||||
"Mempool requires {} to be running. Please install and start {} first.",
|
||||
missing.join(" and "),
|
||||
missing.join(" and ")
|
||||
))
|
||||
}
|
||||
"fedimint" if !deps.has_bitcoin => Err(anyhow::anyhow!(
|
||||
"Fedimint requires a running Bitcoin node (Bitcoin Knots). \
|
||||
Please install and start Bitcoin Knots first."
|
||||
)),
|
||||
_ => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Log informational messages about optional dependencies.
|
||||
pub(super) fn log_optional_dep_info(package_id: &str, deps: &RunningDeps) {
|
||||
if matches!(package_id, "btcpay-server" | "btcpayserver") && !deps.has_lnd {
|
||||
tracing::info!(
|
||||
"BTCPay Server installing without LND \
|
||||
— Lightning payments won't be available until LND is installed"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether an app requires the shared `archy-net` Podman network for
|
||||
/// inter-container DNS resolution.
|
||||
pub(super) fn needs_archy_net(package_id: &str) -> bool {
|
||||
matches!(
|
||||
package_id,
|
||||
"bitcoin-knots"
|
||||
| "bitcoin"
|
||||
| "bitcoin-core"
|
||||
| "lnd"
|
||||
| "mempool"
|
||||
| "mempool-web"
|
||||
| "mempool-api"
|
||||
| "electrumx"
|
||||
| "mempool-electrs"
|
||||
| "electrs"
|
||||
| "mysql-mempool"
|
||||
| "archy-mempool-db"
|
||||
| "archy-mempool-web"
|
||||
| "btcpay-server"
|
||||
| "btcpayserver"
|
||||
| "archy-btcpay-db"
|
||||
| "archy-nbxplorer"
|
||||
| "nbxplorer"
|
||||
| "fedimint"
|
||||
| "fedimint-gateway"
|
||||
)
|
||||
}
|
||||
|
||||
/// Return the correct startup order for a multi-container app stack.
|
||||
/// Containers are started in this order to satisfy dependency chains.
|
||||
pub(super) fn startup_order(package_id: &str) -> &'static [&'static str] {
|
||||
match package_id {
|
||||
"mempool" | "mempool-web" => &[
|
||||
"archy-mempool-db",
|
||||
"mysql-mempool",
|
||||
"electrumx",
|
||||
"mempool-electrs",
|
||||
"mempool-api",
|
||||
"archy-mempool-api",
|
||||
"archy-mempool-web",
|
||||
"mempool",
|
||||
],
|
||||
"immich" => &["immich_postgres", "immich_redis", "immich_server"],
|
||||
"penpot" | "penpot-frontend" => &[
|
||||
"penpot-postgres",
|
||||
"penpot-valkey",
|
||||
"penpot-backend",
|
||||
"penpot-exporter",
|
||||
"penpot-frontend",
|
||||
],
|
||||
_ => &[],
|
||||
}
|
||||
}
|
||||
|
||||
/// Sort a list of container names according to the dependency-aware startup
|
||||
/// order for the given app. Unknown containers sort to the end.
|
||||
pub(super) async fn ordered_containers_for_start(
|
||||
package_id: &str,
|
||||
) -> Result<Vec<String>> {
|
||||
let containers = get_containers_for_app(package_id).await?;
|
||||
if containers.is_empty() {
|
||||
return Ok(vec![format!("archy-{}", package_id)]);
|
||||
}
|
||||
let order = startup_order(package_id);
|
||||
// If no special order defined, fall back to mempool order (legacy behavior)
|
||||
let effective_order: &[&str] = if order.is_empty() {
|
||||
startup_order("mempool")
|
||||
} else {
|
||||
order
|
||||
};
|
||||
let mut sorted = containers;
|
||||
sorted.sort_by_key(|c| {
|
||||
effective_order
|
||||
.iter()
|
||||
.position(|o| *o == c)
|
||||
.unwrap_or(99)
|
||||
});
|
||||
Ok(sorted)
|
||||
}
|
||||
|
||||
/// Configure Fedimint Gateway to use LND instead of LDK.
|
||||
/// Modifies ports, volumes, and command args in place when LND credentials exist.
|
||||
pub(super) fn configure_fedimint_lnd(
|
||||
host_ip: &str,
|
||||
ports: &mut Vec<String>,
|
||||
volumes: &mut Vec<String>,
|
||||
custom_args: &mut Option<Vec<String>>,
|
||||
rpc_user: &str,
|
||||
rpc_pass: &str,
|
||||
) {
|
||||
let lnd_cert = "/var/lib/archipelago/lnd/tls.cert";
|
||||
let lnd_macaroon =
|
||||
"/var/lib/archipelago/lnd/data/chain/bitcoin/mainnet/admin.macaroon";
|
||||
if std::path::Path::new(lnd_cert).exists()
|
||||
&& std::path::Path::new(lnd_macaroon).exists()
|
||||
{
|
||||
info!("LND detected with credentials — configuring gateway in lnd mode");
|
||||
ports.retain(|p| p != "9737:9737");
|
||||
volumes.push(format!("{}:/lnd/tls.cert:ro", lnd_cert));
|
||||
volumes.push(format!("{}:/lnd/admin.macaroon:ro", lnd_macaroon));
|
||||
*custom_args = Some(vec![
|
||||
"gatewayd".to_string(),
|
||||
"--data-dir".to_string(),
|
||||
"/data".to_string(),
|
||||
"--listen".to_string(),
|
||||
"0.0.0.0:8176".to_string(),
|
||||
"--bcrypt-password-hash".to_string(),
|
||||
"$2y$10$t9YjjxkiktrlYvjajB/zgOMDnSNVg4HqrbDqh47u7Jf42whNdxNqC".to_string(),
|
||||
"--network".to_string(),
|
||||
"bitcoin".to_string(),
|
||||
"--bitcoind-url".to_string(),
|
||||
format!("http://{}:8332", host_ip),
|
||||
"--bitcoind-username".to_string(),
|
||||
rpc_user.to_string(),
|
||||
"--bitcoind-password".to_string(),
|
||||
rpc_pass.to_string(),
|
||||
"lnd".to_string(),
|
||||
"--lnd-rpc-host".to_string(),
|
||||
format!("{}:10009", host_ip),
|
||||
"--lnd-tls-cert".to_string(),
|
||||
"/lnd/tls.cert".to_string(),
|
||||
"--lnd-macaroon".to_string(),
|
||||
"/lnd/admin.macaroon".to_string(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,467 @@
|
||||
use super::config::{
|
||||
get_app_capabilities, get_app_config, get_health_check_args, get_memory_limit,
|
||||
is_readonly_compatible, is_valid_docker_image,
|
||||
};
|
||||
use super::dependencies::{
|
||||
check_install_deps, configure_fedimint_lnd, detect_running_deps, log_optional_dep_info,
|
||||
needs_archy_net,
|
||||
};
|
||||
use super::progress::parse_pull_progress;
|
||||
use super::validation::validate_app_id;
|
||||
use crate::api::rpc::RpcHandler;
|
||||
use anyhow::{Context, Result};
|
||||
use tokio::io::{AsyncBufReadExt, BufReader};
|
||||
use tracing::{debug, info};
|
||||
|
||||
impl RpcHandler {
|
||||
/// Install a package from a Docker image.
|
||||
/// Security: Image verification, resource limits, network isolation.
|
||||
pub(in crate::api::rpc) async fn handle_package_install(
|
||||
&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"))?;
|
||||
validate_app_id(package_id)?;
|
||||
|
||||
let docker_image = params
|
||||
.get("dockerImage")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing dockerImage"))?;
|
||||
|
||||
debug!(
|
||||
"Installing package {} from image {}",
|
||||
package_id, docker_image
|
||||
);
|
||||
|
||||
if !is_valid_docker_image(docker_image) {
|
||||
return Err(anyhow::anyhow!("Invalid Docker image format"));
|
||||
}
|
||||
|
||||
// Multi-container stacks get their own install path
|
||||
if package_id == "immich" {
|
||||
return self.install_immich_stack().await;
|
||||
}
|
||||
if package_id == "penpot" || package_id == "penpot-frontend" {
|
||||
return self.install_penpot_stack().await;
|
||||
}
|
||||
|
||||
// Dependency checks
|
||||
let deps = detect_running_deps().await?;
|
||||
check_install_deps(package_id, &deps)?;
|
||||
log_optional_dep_info(package_id, &deps);
|
||||
|
||||
// Check if container already exists
|
||||
let check_output = tokio::process::Command::new("podman")
|
||||
.args([
|
||||
"ps",
|
||||
"-a",
|
||||
"--format",
|
||||
"{{.Names}}",
|
||||
"--filter",
|
||||
&format!("name=^{}$", package_id),
|
||||
])
|
||||
.output()
|
||||
.await
|
||||
.context("Failed to check existing containers")?;
|
||||
|
||||
if !String::from_utf8_lossy(&check_output.stdout)
|
||||
.trim()
|
||||
.is_empty()
|
||||
{
|
||||
return Err(anyhow::anyhow!(
|
||||
"Container {} already exists. Stop and remove it first.",
|
||||
package_id
|
||||
));
|
||||
}
|
||||
|
||||
// Pull or verify image
|
||||
let has_local_fallback = self
|
||||
.pull_or_verify_image(package_id, docker_image)
|
||||
.await?;
|
||||
|
||||
// Normalize container name for legacy aliases
|
||||
let container_name = match package_id {
|
||||
"electrs" | "mempool-electrs" => "electrumx",
|
||||
_ => package_id,
|
||||
};
|
||||
|
||||
// Read Bitcoin RPC credentials for container configs
|
||||
let (rpc_user, rpc_pass) = crate::bitcoin_rpc::bitcoin_rpc_credentials().await;
|
||||
|
||||
// App-specific configuration
|
||||
let (mut ports, mut volumes, env_vars, custom_command, mut custom_args) = {
|
||||
let mut allocator = self.port_allocator.lock().await;
|
||||
get_app_config(
|
||||
package_id,
|
||||
&self.config.host_ip,
|
||||
&mut allocator,
|
||||
&rpc_user,
|
||||
&rpc_pass,
|
||||
)
|
||||
.await
|
||||
};
|
||||
|
||||
// Fedimint Gateway: auto-detect LND and switch to lnd mode
|
||||
if package_id == "fedimint-gateway" && deps.has_lnd {
|
||||
configure_fedimint_lnd(
|
||||
&self.config.host_ip,
|
||||
&mut ports,
|
||||
&mut volumes,
|
||||
&mut custom_args,
|
||||
&rpc_user,
|
||||
&rpc_pass,
|
||||
);
|
||||
}
|
||||
|
||||
// Build the podman run command
|
||||
let mut run_args = vec![
|
||||
"run",
|
||||
"-d",
|
||||
"--name",
|
||||
container_name,
|
||||
"--restart=unless-stopped",
|
||||
];
|
||||
|
||||
let is_tailscale = package_id == "tailscale";
|
||||
|
||||
// Network mode
|
||||
if is_tailscale {
|
||||
run_args.push("--network=host");
|
||||
run_args.push("--privileged");
|
||||
run_args.push("--cap-add=NET_ADMIN");
|
||||
run_args.push("--cap-add=NET_RAW");
|
||||
run_args.push("--device=/dev/net/tun");
|
||||
} else if needs_archy_net(package_id) {
|
||||
let _ = tokio::process::Command::new("podman")
|
||||
.args(["network", "create", "archy-net"])
|
||||
.output()
|
||||
.await;
|
||||
run_args.push("--network=archy-net");
|
||||
}
|
||||
|
||||
// Security hardening (skip for privileged containers)
|
||||
let security_caps: Vec<String> = if !is_tailscale {
|
||||
get_app_capabilities(package_id)
|
||||
} else {
|
||||
vec![]
|
||||
};
|
||||
let readonly_compatible = !is_tailscale && is_readonly_compatible(package_id);
|
||||
|
||||
if !is_tailscale {
|
||||
run_args.push("--cap-drop=ALL");
|
||||
run_args.push("--security-opt=no-new-privileges:true");
|
||||
for cap in &security_caps {
|
||||
run_args.push(cap);
|
||||
}
|
||||
if readonly_compatible {
|
||||
run_args.push("--read-only");
|
||||
run_args.push("--tmpfs=/tmp:rw,noexec,nosuid,size=256m");
|
||||
run_args.push("--tmpfs=/run:rw,noexec,nosuid,size=64m");
|
||||
}
|
||||
}
|
||||
|
||||
// Create data directories
|
||||
self.create_data_dirs(package_id, &volumes).await;
|
||||
|
||||
// Pre-install: bitcoin.conf with rpcauth
|
||||
if matches!(package_id, "bitcoin" | "bitcoin-core" | "bitcoin-knots") {
|
||||
self.write_bitcoin_conf(&rpc_user, &rpc_pass).await;
|
||||
}
|
||||
|
||||
// Port mappings (skip for host-network containers)
|
||||
if !is_tailscale {
|
||||
for port in &ports {
|
||||
run_args.push("-p");
|
||||
run_args.push(port);
|
||||
}
|
||||
}
|
||||
|
||||
// Volume mounts
|
||||
for volume in &volumes {
|
||||
run_args.push("-v");
|
||||
run_args.push(volume);
|
||||
}
|
||||
|
||||
// Environment variables
|
||||
for env in &env_vars {
|
||||
run_args.push("-e");
|
||||
run_args.push(env);
|
||||
}
|
||||
|
||||
// Resource limits
|
||||
let memory_limit = get_memory_limit(package_id);
|
||||
let mem_arg = format!("--memory={}", memory_limit);
|
||||
run_args.push(&mem_arg);
|
||||
run_args.push("--cpus=2");
|
||||
|
||||
// Health checks
|
||||
let health_args = get_health_check_args(package_id, &rpc_pass);
|
||||
for arg in &health_args {
|
||||
run_args.push(arg);
|
||||
}
|
||||
|
||||
// Image — prefer local build over registry
|
||||
let effective_image = if has_local_fallback {
|
||||
format!("localhost/{}:latest", package_id)
|
||||
} else {
|
||||
docker_image.to_string()
|
||||
};
|
||||
run_args.push(&effective_image);
|
||||
|
||||
debug!("Running container with args: {:?}", run_args);
|
||||
|
||||
// Build command with optional custom command/args
|
||||
let mut cmd = tokio::process::Command::new("podman");
|
||||
cmd.args(&run_args);
|
||||
if let Some(custom_cmd) = custom_command {
|
||||
cmd.arg(custom_cmd);
|
||||
} else if let Some(args) = custom_args {
|
||||
cmd.args(args);
|
||||
}
|
||||
|
||||
let run_output = cmd.output().await.context("Failed to run container")?;
|
||||
|
||||
if !run_output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&run_output.stderr);
|
||||
return Err(anyhow::anyhow!("Failed to start container: {}", stderr));
|
||||
}
|
||||
|
||||
let container_id = String::from_utf8_lossy(&run_output.stdout)
|
||||
.trim()
|
||||
.to_string();
|
||||
|
||||
// Post-install hooks
|
||||
self.run_post_install_hooks(package_id).await;
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"success": true,
|
||||
"package_id": package_id,
|
||||
"container_id": container_id,
|
||||
"message": format!("Package {} installed and started", package_id)
|
||||
}))
|
||||
}
|
||||
|
||||
// -- Private helpers for install --
|
||||
|
||||
/// Pull the image from a registry or verify a local image exists.
|
||||
/// Returns `true` if a local fallback image was found (registry pull skipped).
|
||||
async fn pull_or_verify_image(
|
||||
&self,
|
||||
package_id: &str,
|
||||
docker_image: &str,
|
||||
) -> Result<bool> {
|
||||
let is_local_image = docker_image.starts_with("localhost/");
|
||||
let has_local_fallback = if !is_local_image {
|
||||
let local_tag = format!("localhost/{}:latest", package_id);
|
||||
let check = tokio::process::Command::new("podman")
|
||||
.args(["images", "-q", &local_tag])
|
||||
.output()
|
||||
.await
|
||||
.ok();
|
||||
check.map_or(false, |o| {
|
||||
!String::from_utf8_lossy(&o.stdout).trim().is_empty()
|
||||
})
|
||||
} else {
|
||||
false
|
||||
};
|
||||
|
||||
if !is_local_image && !has_local_fallback {
|
||||
self.pull_image_with_progress(package_id, docker_image)
|
||||
.await?;
|
||||
} else if has_local_fallback {
|
||||
debug!(
|
||||
"Using local build for {} (skipping registry pull)",
|
||||
package_id
|
||||
);
|
||||
} else {
|
||||
// Local image — verify it exists
|
||||
let images_output = tokio::process::Command::new("podman")
|
||||
.args(["images", "-q", docker_image])
|
||||
.output()
|
||||
.await
|
||||
.context("Failed to check local image")?;
|
||||
if String::from_utf8_lossy(&images_output.stdout)
|
||||
.trim()
|
||||
.is_empty()
|
||||
{
|
||||
return Err(anyhow::anyhow!(
|
||||
"Local image {} not found. Build the image first \
|
||||
or ensure the registry is reachable.",
|
||||
docker_image
|
||||
));
|
||||
}
|
||||
debug!("Using local image: {}", docker_image);
|
||||
}
|
||||
|
||||
Ok(has_local_fallback)
|
||||
}
|
||||
|
||||
/// Stream `podman pull` while updating install progress state.
|
||||
async fn pull_image_with_progress(
|
||||
&self,
|
||||
package_id: &str,
|
||||
docker_image: &str,
|
||||
) -> Result<()> {
|
||||
debug!("Pulling image: {}", docker_image);
|
||||
self.set_install_progress(package_id, 0, 0).await;
|
||||
|
||||
let mut child = tokio::process::Command::new("podman")
|
||||
.args(["pull", docker_image])
|
||||
.stdout(std::process::Stdio::piped())
|
||||
.stderr(std::process::Stdio::piped())
|
||||
.spawn()
|
||||
.context("Failed to start image pull")?;
|
||||
|
||||
if let Some(stderr) = child.stderr.take() {
|
||||
let reader = BufReader::new(stderr);
|
||||
let mut lines = reader.lines();
|
||||
let pkg_id = package_id.to_string();
|
||||
let state_mgr = self.state_manager.clone();
|
||||
|
||||
while let Ok(Some(line)) = lines.next_line().await {
|
||||
if let Some((downloaded, total)) = parse_pull_progress(&line) {
|
||||
Self::update_install_progress(&state_mgr, &pkg_id, downloaded, total)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let status = child
|
||||
.wait()
|
||||
.await
|
||||
.context("Failed to wait for image pull")?;
|
||||
if !status.success() {
|
||||
self.clear_install_progress(package_id).await;
|
||||
return Err(anyhow::anyhow!("Failed to pull image"));
|
||||
}
|
||||
|
||||
self.set_install_progress(package_id, 100, 100).await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Create data directories for volume mounts under /var/lib/archipelago/.
|
||||
async fn create_data_dirs(&self, package_id: &str, volumes: &[String]) {
|
||||
for volume in volumes {
|
||||
if let Some(host_path) = volume.split(':').next() {
|
||||
if host_path.starts_with("/var/lib/archipelago/") {
|
||||
debug!("Creating directory: {}", host_path);
|
||||
let create_dir = tokio::process::Command::new("sudo")
|
||||
.args(["mkdir", "-p", host_path])
|
||||
.output()
|
||||
.await;
|
||||
if let Err(e) = create_dir {
|
||||
debug!("Failed to create directory {}: {}", host_path, e);
|
||||
}
|
||||
// Grafana runs as UID 472 — fix permissions
|
||||
if package_id == "grafana" && host_path.contains("grafana") {
|
||||
let _ = tokio::process::Command::new("sudo")
|
||||
.args(["chown", "-R", "472:472", host_path])
|
||||
.output()
|
||||
.await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Write bitcoin.conf with rpcauth (salted HMAC hash, no plaintext password).
|
||||
async fn write_bitcoin_conf(&self, rpc_user: &str, rpc_pass: &str) {
|
||||
let bitcoin_dir = "/var/lib/archipelago/bitcoin";
|
||||
let conf_path = format!("{}/bitcoin.conf", bitcoin_dir);
|
||||
|
||||
use hmac::{Hmac, Mac};
|
||||
use sha2::Sha256;
|
||||
let salt_bytes: [u8; 16] = rand::random();
|
||||
let salt_hex = hex::encode(salt_bytes);
|
||||
let mut mac = Hmac::<Sha256>::new_from_slice(salt_hex.as_bytes())
|
||||
.expect("HMAC accepts any key length");
|
||||
mac.update(rpc_pass.as_bytes());
|
||||
let hash_hex = hex::encode(mac.finalize().into_bytes());
|
||||
let rpcauth_line = format!("rpcauth={}:{}${}", rpc_user, salt_hex, hash_hex);
|
||||
|
||||
let bitcoin_conf = format!(
|
||||
"\
|
||||
# rpcauth: salted hash only — no plaintext password in config or CLI\n\
|
||||
{}\n\
|
||||
server=1\n\
|
||||
prune=550\n\
|
||||
rpcbind=0.0.0.0\n\
|
||||
rpcallowip=0.0.0.0/0\n\
|
||||
rpcport=8332\n\
|
||||
listen=1\n\
|
||||
printtoconsole=1\n",
|
||||
rpcauth_line
|
||||
);
|
||||
let _ = tokio::fs::create_dir_all(bitcoin_dir).await;
|
||||
let _ = tokio::fs::write(&conf_path, bitcoin_conf).await;
|
||||
info!("Created bitcoin.conf with rpcauth (no plaintext credentials)");
|
||||
}
|
||||
|
||||
/// Run post-install hooks (Nextcloud trusted domains, Bitcoin UI container).
|
||||
async fn run_post_install_hooks(&self, package_id: &str) {
|
||||
if package_id == "nextcloud" {
|
||||
let host_ip = self.config.host_ip.clone();
|
||||
tokio::spawn(async move {
|
||||
// Wait for Nextcloud to finish first-run initialization
|
||||
tokio::time::sleep(std::time::Duration::from_secs(30)).await;
|
||||
for domain_idx in 1..=2u8 {
|
||||
let value = if domain_idx == 1 {
|
||||
host_ip.as_str()
|
||||
} else {
|
||||
"localhost"
|
||||
};
|
||||
let _ = tokio::process::Command::new("podman")
|
||||
.args([
|
||||
"exec",
|
||||
"-u",
|
||||
"33",
|
||||
"nextcloud",
|
||||
"php",
|
||||
"occ",
|
||||
"config:system:set",
|
||||
"trusted_domains",
|
||||
&domain_idx.to_string(),
|
||||
"--value",
|
||||
value,
|
||||
])
|
||||
.output()
|
||||
.await;
|
||||
}
|
||||
info!("Nextcloud trusted domains configured for {}", host_ip);
|
||||
});
|
||||
}
|
||||
|
||||
if matches!(package_id, "bitcoin" | "bitcoin-core" | "bitcoin-knots") {
|
||||
tokio::spawn(async move {
|
||||
let ui_dir = "/opt/archipelago/docker/bitcoin-ui";
|
||||
let _ = tokio::process::Command::new("podman")
|
||||
.args(["build", "-t", "localhost/bitcoin-ui", ui_dir])
|
||||
.output()
|
||||
.await;
|
||||
let _ = tokio::process::Command::new("podman")
|
||||
.args(["rm", "-f", "bitcoin-ui"])
|
||||
.output()
|
||||
.await;
|
||||
let _ = tokio::process::Command::new("podman")
|
||||
.args([
|
||||
"run",
|
||||
"-d",
|
||||
"--name",
|
||||
"bitcoin-ui",
|
||||
"--restart=unless-stopped",
|
||||
"-p",
|
||||
"8334:80",
|
||||
"localhost/bitcoin-ui:latest",
|
||||
])
|
||||
.output()
|
||||
.await;
|
||||
info!("Bitcoin UI container started on port 8334");
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,10 @@
|
||||
mod config;
|
||||
mod dependencies;
|
||||
mod install;
|
||||
mod lifecycle;
|
||||
mod progress;
|
||||
mod runtime;
|
||||
mod stacks;
|
||||
mod validation;
|
||||
|
||||
// Re-export items needed by sibling modules (container.rs, security.rs)
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
//! Install progress tracking and podman pull output parsing.
|
||||
|
||||
use crate::api::rpc::RpcHandler;
|
||||
use crate::data_model::{
|
||||
Description, InstallProgress, Manifest, PackageDataEntry, PackageState, StaticFiles,
|
||||
};
|
||||
|
||||
impl RpcHandler {
|
||||
/// Set install progress for a package and broadcast the update.
|
||||
/// Creates a minimal package entry if one doesn't exist yet.
|
||||
pub(super) async fn set_install_progress(
|
||||
&self,
|
||||
package_id: &str,
|
||||
downloaded: u64,
|
||||
size: u64,
|
||||
) {
|
||||
let (mut data, _rev) = self.state_manager.get_snapshot().await;
|
||||
let entry = data
|
||||
.package_data
|
||||
.entry(package_id.to_string())
|
||||
.or_insert_with(|| create_installing_entry(package_id));
|
||||
entry.state = PackageState::Installing;
|
||||
entry.install_progress = Some(InstallProgress { size, downloaded });
|
||||
self.state_manager.update_data(data).await;
|
||||
}
|
||||
|
||||
/// Clear install progress after pull completes or fails.
|
||||
pub(super) async fn clear_install_progress(&self, package_id: &str) {
|
||||
let (mut data, _rev) = self.state_manager.get_snapshot().await;
|
||||
if let Some(entry) = data.package_data.get_mut(package_id) {
|
||||
entry.install_progress = None;
|
||||
}
|
||||
self.state_manager.update_data(data).await;
|
||||
}
|
||||
|
||||
/// Update install progress (static method for use in async closures).
|
||||
pub(super) async fn update_install_progress(
|
||||
state_manager: &crate::state::StateManager,
|
||||
package_id: &str,
|
||||
downloaded: u64,
|
||||
total: u64,
|
||||
) {
|
||||
let (mut data, _rev) = state_manager.get_snapshot().await;
|
||||
let entry = data
|
||||
.package_data
|
||||
.entry(package_id.to_string())
|
||||
.or_insert_with(|| create_installing_entry(package_id));
|
||||
entry.install_progress = Some(InstallProgress {
|
||||
size: total,
|
||||
downloaded,
|
||||
});
|
||||
state_manager.update_data(data).await;
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a minimal PackageDataEntry for a package being installed.
|
||||
fn create_installing_entry(package_id: &str) -> PackageDataEntry {
|
||||
PackageDataEntry {
|
||||
state: PackageState::Installing,
|
||||
health: None,
|
||||
static_files: StaticFiles {
|
||||
license: String::new(),
|
||||
instructions: String::new(),
|
||||
icon: format!("/assets/img/app-icons/{}.png", package_id),
|
||||
},
|
||||
manifest: Manifest {
|
||||
id: package_id.to_string(),
|
||||
title: package_id.to_string(),
|
||||
version: String::new(),
|
||||
description: Description {
|
||||
short: "Installing...".to_string(),
|
||||
long: String::new(),
|
||||
},
|
||||
release_notes: String::new(),
|
||||
license: String::new(),
|
||||
wrapper_repo: String::new(),
|
||||
upstream_repo: String::new(),
|
||||
support_site: String::new(),
|
||||
marketing_site: String::new(),
|
||||
donation_url: None,
|
||||
author: None,
|
||||
website: None,
|
||||
interfaces: None,
|
||||
tier: None,
|
||||
},
|
||||
installed: None,
|
||||
install_progress: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse podman pull progress output.
|
||||
/// Podman outputs lines like: "Copying blob sha256:abc done | 50.0MiB / 100.0MiB"
|
||||
/// Returns (downloaded_bytes, total_bytes) if parseable.
|
||||
pub(super) fn parse_pull_progress(line: &str) -> Option<(u64, u64)> {
|
||||
let line = line.trim();
|
||||
let parts: Vec<&str> = line.split('/').collect();
|
||||
if parts.len() != 2 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let downloaded = parse_size_value(parts[0].trim())?;
|
||||
let total = parse_size_value(parts[1].trim())?;
|
||||
|
||||
if total > 0 {
|
||||
Some((downloaded, total))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a size value like "50.0MiB", "1.2GiB", "500KiB" into bytes.
|
||||
fn parse_size_value(s: &str) -> Option<u64> {
|
||||
let s = s.trim();
|
||||
|
||||
let (num_str, multiplier) = if let Some(pos) = s.rfind("GiB") {
|
||||
(
|
||||
s[..pos].trim().split_whitespace().last()?,
|
||||
1024 * 1024 * 1024,
|
||||
)
|
||||
} else if let Some(pos) = s.rfind("MiB") {
|
||||
(s[..pos].trim().split_whitespace().last()?, 1024 * 1024)
|
||||
} else if let Some(pos) = s.rfind("KiB") {
|
||||
(s[..pos].trim().split_whitespace().last()?, 1024)
|
||||
} else if let Some(pos) = s.rfind("GB") {
|
||||
(
|
||||
s[..pos].trim().split_whitespace().last()?,
|
||||
1_000_000_000,
|
||||
)
|
||||
} else if let Some(pos) = s.rfind("MB") {
|
||||
(s[..pos].trim().split_whitespace().last()?, 1_000_000)
|
||||
} else if let Some(pos) = s.rfind("KB") {
|
||||
(s[..pos].trim().split_whitespace().last()?, 1_000)
|
||||
} else if let Some(pos) = s.rfind('B') {
|
||||
(s[..pos].trim().split_whitespace().last()?, 1)
|
||||
} else {
|
||||
return None;
|
||||
};
|
||||
|
||||
let num: f64 = num_str.parse().ok()?;
|
||||
Some((num * multiplier as f64) as u64)
|
||||
}
|
||||
@@ -0,0 +1,359 @@
|
||||
use super::config::{get_containers_for_app, get_data_dirs_for_app, is_valid_docker_image};
|
||||
use super::dependencies::ordered_containers_for_start;
|
||||
use super::validation::validate_app_id;
|
||||
use crate::api::rpc::RpcHandler;
|
||||
use anyhow::{Context, Result};
|
||||
|
||||
impl RpcHandler {
|
||||
/// Start a package: start all containers in dependency order.
|
||||
pub(in crate::api::rpc) 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"))?;
|
||||
validate_app_id(package_id)?;
|
||||
|
||||
let to_start = ordered_containers_for_start(package_id).await?;
|
||||
|
||||
// Clear user-stopped flag — user explicitly started this app
|
||||
crate::crash_recovery::clear_user_stopped(&self.config.data_dir, package_id).await;
|
||||
for name in &to_start {
|
||||
crate::crash_recovery::clear_user_stopped(&self.config.data_dir, name).await;
|
||||
}
|
||||
|
||||
for name in to_start {
|
||||
let _ = tokio::process::Command::new("podman")
|
||||
.args(["start", &name])
|
||||
.output()
|
||||
.await;
|
||||
}
|
||||
|
||||
Ok(serde_json::Value::Null)
|
||||
}
|
||||
|
||||
/// Stop a package: mark as user-stopped and stop all containers.
|
||||
pub(in crate::api::rpc) 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"))?;
|
||||
validate_app_id(package_id)?;
|
||||
|
||||
// Mark as user-stopped so health monitor and crash recovery don't auto-restart
|
||||
crate::crash_recovery::mark_user_stopped(&self.config.data_dir, package_id).await;
|
||||
|
||||
let containers = get_containers_for_app(package_id).await?;
|
||||
if containers.is_empty() {
|
||||
let container_name = format!("archy-{}", package_id);
|
||||
crate::crash_recovery::mark_user_stopped(&self.config.data_dir, &container_name)
|
||||
.await;
|
||||
let _ = tokio::process::Command::new("podman")
|
||||
.args(["stop", &container_name])
|
||||
.output()
|
||||
.await;
|
||||
return Ok(serde_json::Value::Null);
|
||||
}
|
||||
|
||||
for name in &containers {
|
||||
crate::crash_recovery::mark_user_stopped(&self.config.data_dir, name).await;
|
||||
}
|
||||
for name in containers {
|
||||
let _ = tokio::process::Command::new("podman")
|
||||
.args(["stop", &name])
|
||||
.output()
|
||||
.await;
|
||||
}
|
||||
|
||||
Ok(serde_json::Value::Null)
|
||||
}
|
||||
|
||||
/// Restart a package: restart all containers.
|
||||
pub(in crate::api::rpc) 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"))?;
|
||||
validate_app_id(package_id)?;
|
||||
|
||||
let containers = get_containers_for_app(package_id).await?;
|
||||
if containers.is_empty() {
|
||||
let container_name = format!("archy-{}", package_id);
|
||||
let _ = tokio::process::Command::new("podman")
|
||||
.args(["restart", &container_name])
|
||||
.output()
|
||||
.await;
|
||||
return Ok(serde_json::Value::Null);
|
||||
}
|
||||
|
||||
for name in containers {
|
||||
let _ = tokio::process::Command::new("podman")
|
||||
.args(["restart", &name])
|
||||
.output()
|
||||
.await;
|
||||
}
|
||||
|
||||
Ok(serde_json::Value::Null)
|
||||
}
|
||||
|
||||
/// Uninstall a package: stop and remove all related containers, clean data.
|
||||
pub(in crate::api::rpc) async fn handle_package_uninstall(
|
||||
&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"))?;
|
||||
validate_app_id(package_id)?;
|
||||
let preserve_data = params
|
||||
.get("preserve_data")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false);
|
||||
|
||||
let containers_to_remove = get_containers_for_app(package_id).await?;
|
||||
if containers_to_remove.is_empty() {
|
||||
tracing::warn!("Uninstall {}: no containers found", package_id);
|
||||
}
|
||||
|
||||
let mut stopped = 0u32;
|
||||
let mut removed = 0u32;
|
||||
let mut errors = Vec::new();
|
||||
|
||||
for name in &containers_to_remove {
|
||||
tracing::info!("Uninstall {}: stopping container {}", package_id, name);
|
||||
let stop_out = tokio::process::Command::new("podman")
|
||||
.args(["stop", "-t", "10", name])
|
||||
.output()
|
||||
.await;
|
||||
match stop_out {
|
||||
Ok(o) if o.status.success() => stopped += 1,
|
||||
Ok(o) => {
|
||||
let stderr = String::from_utf8_lossy(&o.stderr);
|
||||
tracing::warn!(
|
||||
"Uninstall {}: stop {} failed: {}",
|
||||
package_id,
|
||||
name,
|
||||
stderr.trim()
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
"Uninstall {}: stop {} error: {}",
|
||||
package_id,
|
||||
name,
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
tracing::info!("Uninstall {}: removing container {}", package_id, name);
|
||||
let rm_out = tokio::process::Command::new("podman")
|
||||
.args(["rm", "-f", name])
|
||||
.output()
|
||||
.await;
|
||||
match rm_out {
|
||||
Ok(o) if o.status.success() => removed += 1,
|
||||
Ok(o) => {
|
||||
let stderr = String::from_utf8_lossy(&o.stderr);
|
||||
let msg = format!("Failed to remove {}: {}", name, stderr.trim());
|
||||
tracing::error!("Uninstall {}: {}", package_id, msg);
|
||||
errors.push(msg);
|
||||
}
|
||||
Err(e) => {
|
||||
let msg = format!("Failed to remove {}: {}", name, e);
|
||||
tracing::error!("Uninstall {}: {}", package_id, msg);
|
||||
errors.push(msg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Release port allocation
|
||||
{
|
||||
let mut allocator = self.port_allocator.lock().await;
|
||||
let _ = allocator.release(package_id).await;
|
||||
}
|
||||
|
||||
// Clean data directories unless preserve_data
|
||||
if !preserve_data {
|
||||
let data_dirs = get_data_dirs_for_app(package_id);
|
||||
for dir in &data_dirs {
|
||||
tracing::info!("Uninstall {}: removing data {}", package_id, dir);
|
||||
let rm_out = tokio::process::Command::new("sudo")
|
||||
.args(["rm", "-rf", dir])
|
||||
.output()
|
||||
.await;
|
||||
if let Ok(o) = rm_out {
|
||||
if !o.status.success() {
|
||||
tracing::warn!("Uninstall {}: rm {} failed", package_id, dir);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !errors.is_empty() {
|
||||
tracing::error!(
|
||||
"Uninstall {} completed with errors: {:?}",
|
||||
package_id,
|
||||
errors
|
||||
);
|
||||
} else {
|
||||
tracing::info!(
|
||||
"Uninstall {} complete: stopped={}, removed={}",
|
||||
package_id,
|
||||
stopped,
|
||||
removed
|
||||
);
|
||||
}
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"status": if errors.is_empty() { "uninstalled" } else { "partial" },
|
||||
"stopped": stopped,
|
||||
"removed": removed,
|
||||
"errors": errors,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Start a bundled app (create container from pre-loaded image if needed).
|
||||
pub(in crate::api::rpc) async fn handle_bundled_app_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)?;
|
||||
let image = params
|
||||
.get("image")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing image"))?;
|
||||
if !is_valid_docker_image(image) {
|
||||
return Err(anyhow::anyhow!("Invalid Docker image format"));
|
||||
}
|
||||
let ports = params
|
||||
.get("ports")
|
||||
.and_then(|v| v.as_array())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing ports"))?;
|
||||
let volumes = params
|
||||
.get("volumes")
|
||||
.and_then(|v| v.as_array())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing volumes"))?;
|
||||
|
||||
let check_output = tokio::process::Command::new("podman")
|
||||
.args([
|
||||
"ps",
|
||||
"-a",
|
||||
"--format",
|
||||
"{{.Names}}",
|
||||
"--filter",
|
||||
&format!("name={}", app_id),
|
||||
])
|
||||
.output()
|
||||
.await
|
||||
.context("Failed to check container")?;
|
||||
|
||||
let existing = String::from_utf8_lossy(&check_output.stdout);
|
||||
|
||||
if existing.trim().is_empty() {
|
||||
let mut cmd = tokio::process::Command::new("podman");
|
||||
cmd.args(["run", "-d", "--name", app_id]);
|
||||
|
||||
for port in ports {
|
||||
if let (Some(host), Some(container)) = (
|
||||
port.get("host").and_then(|v| v.as_u64()),
|
||||
port.get("container").and_then(|v| v.as_u64()),
|
||||
) {
|
||||
cmd.arg("-p").arg(format!("{}:{}", host, container));
|
||||
}
|
||||
}
|
||||
|
||||
for volume in volumes {
|
||||
if let (Some(host), Some(container)) = (
|
||||
volume.get("host").and_then(|v| v.as_str()),
|
||||
volume.get("container").and_then(|v| v.as_str()),
|
||||
) {
|
||||
// Validate host path: must be under /var/lib/archipelago/
|
||||
if !host.starts_with("/var/lib/archipelago/")
|
||||
|| host.contains("..")
|
||||
|| host.contains('\0')
|
||||
{
|
||||
return Err(anyhow::anyhow!(
|
||||
"Volume host path must be under /var/lib/archipelago/ \
|
||||
and cannot contain path traversal"
|
||||
));
|
||||
}
|
||||
if container.contains("..") || container.contains('\0') {
|
||||
return Err(anyhow::anyhow!("Invalid container mount path"));
|
||||
}
|
||||
let _ = tokio::process::Command::new("sudo")
|
||||
.args(["mkdir", "-p", host])
|
||||
.output()
|
||||
.await;
|
||||
cmd.arg("-v").arg(format!("{}:{}", host, container));
|
||||
}
|
||||
}
|
||||
|
||||
cmd.arg(image);
|
||||
|
||||
let output = cmd.output().await.context("Failed to create container")?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(anyhow::anyhow!("Failed to create container: {}", stderr));
|
||||
}
|
||||
} else {
|
||||
let output = tokio::process::Command::new("podman")
|
||||
.args(["start", app_id])
|
||||
.output()
|
||||
.await
|
||||
.context("Failed to start container")?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(anyhow::anyhow!("Failed to start container: {}", stderr));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(serde_json::json!({ "status": "started", "app_id": app_id }))
|
||||
}
|
||||
|
||||
/// Stop a bundled app.
|
||||
pub(in crate::api::rpc) async fn handle_bundled_app_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)?;
|
||||
|
||||
let output = tokio::process::Command::new("podman")
|
||||
.args(["stop", app_id])
|
||||
.output()
|
||||
.await
|
||||
.context("Failed to stop container")?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(anyhow::anyhow!("Failed to stop container: {}", stderr));
|
||||
}
|
||||
|
||||
Ok(serde_json::json!({ "status": "stopped", "app_id": app_id }))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,335 @@
|
||||
//! Multi-container app stack installers (Immich, Penpot).
|
||||
//!
|
||||
//! Each stack pulls multiple images, creates a private network, and starts
|
||||
//! containers in dependency order.
|
||||
|
||||
use crate::api::rpc::RpcHandler;
|
||||
use anyhow::{Context, Result};
|
||||
use tracing::info;
|
||||
|
||||
impl RpcHandler {
|
||||
/// Install Immich stack (postgres + redis + server).
|
||||
pub(super) async fn install_immich_stack(&self) -> Result<serde_json::Value> {
|
||||
let check = tokio::process::Command::new("podman")
|
||||
.args(["ps", "-a", "--format", "{{.Names}}"])
|
||||
.output()
|
||||
.await
|
||||
.context("Failed to list containers")?;
|
||||
let stdout = String::from_utf8_lossy(&check.stdout);
|
||||
if stdout.contains("immich_server") {
|
||||
return Err(anyhow::anyhow!(
|
||||
"Immich already installed. Stop and remove it first."
|
||||
));
|
||||
}
|
||||
if stdout.contains("immich\n") || stdout.lines().any(|l| l.trim() == "immich") {
|
||||
let _ = tokio::process::Command::new("podman")
|
||||
.args(["stop", "immich"])
|
||||
.output()
|
||||
.await;
|
||||
let _ = tokio::process::Command::new("podman")
|
||||
.args(["rm", "-f", "immich"])
|
||||
.output()
|
||||
.await;
|
||||
}
|
||||
|
||||
let images = [
|
||||
"ghcr.io/immich-app/postgres:14-vectorchord0.4.3-pgvectors0.2.0",
|
||||
"docker.io/valkey/valkey:7-alpine",
|
||||
"ghcr.io/immich-app/immich-server:release",
|
||||
];
|
||||
for img in &images {
|
||||
let _ = tokio::process::Command::new("podman")
|
||||
.args(["pull", img])
|
||||
.output()
|
||||
.await;
|
||||
}
|
||||
|
||||
let _ = tokio::process::Command::new("sudo")
|
||||
.args([
|
||||
"mkdir",
|
||||
"-p",
|
||||
"/var/lib/archipelago/immich",
|
||||
"/var/lib/archipelago/immich-db",
|
||||
])
|
||||
.output()
|
||||
.await;
|
||||
let _ = tokio::process::Command::new("podman")
|
||||
.args(["network", "create", "immich-net"])
|
||||
.output()
|
||||
.await;
|
||||
|
||||
let _ = tokio::process::Command::new("podman")
|
||||
.args([
|
||||
"run",
|
||||
"-d",
|
||||
"--name",
|
||||
"immich_postgres",
|
||||
"--restart",
|
||||
"unless-stopped",
|
||||
"--network",
|
||||
"immich-net",
|
||||
"-v",
|
||||
"/var/lib/archipelago/immich-db:/var/lib/postgresql/data",
|
||||
"-e",
|
||||
"POSTGRES_PASSWORD=immichpass",
|
||||
"-e",
|
||||
"POSTGRES_USER=postgres",
|
||||
"-e",
|
||||
"POSTGRES_DB=immich",
|
||||
"ghcr.io/immich-app/postgres:14-vectorchord0.4.3-pgvectors0.2.0",
|
||||
])
|
||||
.output()
|
||||
.await;
|
||||
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
|
||||
|
||||
let _ = tokio::process::Command::new("podman")
|
||||
.args([
|
||||
"run",
|
||||
"-d",
|
||||
"--name",
|
||||
"immich_redis",
|
||||
"--restart",
|
||||
"unless-stopped",
|
||||
"--network",
|
||||
"immich-net",
|
||||
"docker.io/valkey/valkey:7-alpine",
|
||||
])
|
||||
.output()
|
||||
.await;
|
||||
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
|
||||
|
||||
let run = tokio::process::Command::new("podman")
|
||||
.args([
|
||||
"run",
|
||||
"-d",
|
||||
"--name",
|
||||
"immich_server",
|
||||
"--restart",
|
||||
"unless-stopped",
|
||||
"--network",
|
||||
"immich-net",
|
||||
"-p",
|
||||
"2283:2283",
|
||||
"-v",
|
||||
"/var/lib/archipelago/immich:/usr/src/app/upload",
|
||||
"-e",
|
||||
"DB_HOSTNAME=immich_postgres",
|
||||
"-e",
|
||||
"DB_USERNAME=postgres",
|
||||
"-e",
|
||||
"DB_PASSWORD=immichpass",
|
||||
"-e",
|
||||
"DB_DATABASE_NAME=immich",
|
||||
"-e",
|
||||
"REDIS_HOSTNAME=immich_redis",
|
||||
"-e",
|
||||
"UPLOAD_LOCATION=/usr/src/app/upload",
|
||||
"ghcr.io/immich-app/immich-server:release",
|
||||
])
|
||||
.output()
|
||||
.await
|
||||
.context("Failed to start immich_server")?;
|
||||
|
||||
if !run.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&run.stderr);
|
||||
return Err(anyhow::anyhow!(
|
||||
"Failed to start Immich server: {}",
|
||||
stderr
|
||||
));
|
||||
}
|
||||
|
||||
info!("Immich stack installed and started");
|
||||
Ok(serde_json::json!({
|
||||
"success": true,
|
||||
"package_id": "immich",
|
||||
"message": "Immich stack installed and started"
|
||||
}))
|
||||
}
|
||||
|
||||
/// Install Penpot stack (postgres + valkey + backend + exporter + frontend).
|
||||
pub(super) async fn install_penpot_stack(&self) -> Result<serde_json::Value> {
|
||||
let check = tokio::process::Command::new("podman")
|
||||
.args(["ps", "-a", "--format", "{{.Names}}"])
|
||||
.output()
|
||||
.await
|
||||
.context("Failed to list containers")?;
|
||||
let stdout = String::from_utf8_lossy(&check.stdout);
|
||||
if stdout.contains("penpot-frontend") {
|
||||
return Err(anyhow::anyhow!(
|
||||
"Penpot already installed. Stop and remove it first."
|
||||
));
|
||||
}
|
||||
|
||||
let images = [
|
||||
"docker.io/postgres:15",
|
||||
"docker.io/valkey/valkey:8.1",
|
||||
"docker.io/penpotapp/backend:2.4",
|
||||
"docker.io/penpotapp/exporter:2.4",
|
||||
"docker.io/penpotapp/frontend:2.4",
|
||||
];
|
||||
for img in &images {
|
||||
let _ = tokio::process::Command::new("podman")
|
||||
.args(["pull", img])
|
||||
.output()
|
||||
.await;
|
||||
}
|
||||
|
||||
let _ = tokio::process::Command::new("sudo")
|
||||
.args(["mkdir", "-p", "/var/lib/archipelago/penpot-assets"])
|
||||
.output()
|
||||
.await;
|
||||
let _ = tokio::process::Command::new("podman")
|
||||
.args(["network", "create", "penpot-net"])
|
||||
.output()
|
||||
.await;
|
||||
|
||||
// Generate a stable secret key derived from the data directory
|
||||
let secret = {
|
||||
use sha2::{Digest, Sha256};
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(b"penpot-secret-");
|
||||
hasher.update(self.config.data_dir.to_string_lossy().as_bytes());
|
||||
hex::encode(hasher.finalize())
|
||||
};
|
||||
let host_ip = &self.config.host_ip;
|
||||
|
||||
let _ = tokio::process::Command::new("podman")
|
||||
.args([
|
||||
"run",
|
||||
"-d",
|
||||
"--name",
|
||||
"penpot-postgres",
|
||||
"--restart",
|
||||
"unless-stopped",
|
||||
"--network",
|
||||
"penpot-net",
|
||||
"-v",
|
||||
"/var/lib/archipelago/penpot-postgres:/var/lib/postgresql/data",
|
||||
"-e",
|
||||
"POSTGRES_DB=penpot",
|
||||
"-e",
|
||||
"POSTGRES_USER=penpot",
|
||||
"-e",
|
||||
"POSTGRES_PASSWORD=penpot",
|
||||
"docker.io/postgres:15",
|
||||
])
|
||||
.output()
|
||||
.await;
|
||||
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
|
||||
|
||||
let _ = tokio::process::Command::new("podman")
|
||||
.args([
|
||||
"run",
|
||||
"-d",
|
||||
"--name",
|
||||
"penpot-valkey",
|
||||
"--restart",
|
||||
"unless-stopped",
|
||||
"--network",
|
||||
"penpot-net",
|
||||
"-e",
|
||||
"VALKEY_EXTRA_FLAGS=--maxmemory 128mb --maxmemory-policy volatile-lfu",
|
||||
"docker.io/valkey/valkey:8.1",
|
||||
])
|
||||
.output()
|
||||
.await;
|
||||
tokio::time::sleep(std::time::Duration::from_secs(3)).await;
|
||||
|
||||
let _ = tokio::process::Command::new("podman")
|
||||
.args([
|
||||
"run",
|
||||
"-d",
|
||||
"--name",
|
||||
"penpot-backend",
|
||||
"--restart",
|
||||
"unless-stopped",
|
||||
"--network",
|
||||
"penpot-net",
|
||||
"-v",
|
||||
"/var/lib/archipelago/penpot-assets:/opt/data/assets",
|
||||
"-e",
|
||||
&format!("PENPOT_PUBLIC_URI=http://{}:9001", host_ip),
|
||||
"-e",
|
||||
&format!("PENPOT_SECRET_KEY={}", secret),
|
||||
"-e",
|
||||
"PENPOT_DATABASE_URI=postgresql://penpot-postgres/penpot",
|
||||
"-e",
|
||||
"PENPOT_DATABASE_USERNAME=penpot",
|
||||
"-e",
|
||||
"PENPOT_DATABASE_PASSWORD=penpot",
|
||||
"-e",
|
||||
"PENPOT_REDIS_URI=redis://penpot-valkey/0",
|
||||
"-e",
|
||||
"PENPOT_OBJECTS_STORAGE_BACKEND=fs",
|
||||
"-e",
|
||||
"PENPOT_OBJECTS_STORAGE_FS_DIRECTORY=/opt/data/assets",
|
||||
"-e",
|
||||
"PENPOT_FLAGS=disable-email-verification enable-smtp enable-prepl-server disable-secure-session-cookies",
|
||||
"docker.io/penpotapp/backend:2.4",
|
||||
])
|
||||
.output()
|
||||
.await;
|
||||
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
|
||||
|
||||
let _ = tokio::process::Command::new("podman")
|
||||
.args([
|
||||
"run",
|
||||
"-d",
|
||||
"--name",
|
||||
"penpot-exporter",
|
||||
"--restart",
|
||||
"unless-stopped",
|
||||
"--network",
|
||||
"penpot-net",
|
||||
"-e",
|
||||
&format!("PENPOT_SECRET_KEY={}", secret),
|
||||
"-e",
|
||||
"PENPOT_PUBLIC_URI=http://penpot-frontend:8080",
|
||||
"-e",
|
||||
"PENPOT_REDIS_URI=redis://penpot-valkey/0",
|
||||
"docker.io/penpotapp/exporter:2.4",
|
||||
])
|
||||
.output()
|
||||
.await;
|
||||
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
|
||||
|
||||
let run = tokio::process::Command::new("podman")
|
||||
.args([
|
||||
"run",
|
||||
"-d",
|
||||
"--name",
|
||||
"penpot-frontend",
|
||||
"--restart",
|
||||
"unless-stopped",
|
||||
"--network",
|
||||
"penpot-net",
|
||||
"-p",
|
||||
"9001:8080",
|
||||
"-v",
|
||||
"/var/lib/archipelago/penpot-assets:/opt/data/assets",
|
||||
"-e",
|
||||
&format!("PENPOT_PUBLIC_URI=http://{}:9001", host_ip),
|
||||
"-e",
|
||||
"PENPOT_FLAGS=disable-email-verification enable-smtp enable-prepl-server disable-secure-session-cookies",
|
||||
"docker.io/penpotapp/frontend:2.4",
|
||||
])
|
||||
.output()
|
||||
.await
|
||||
.context("Failed to start penpot-frontend")?;
|
||||
|
||||
if !run.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&run.stderr);
|
||||
return Err(anyhow::anyhow!(
|
||||
"Failed to start Penpot frontend: {}",
|
||||
stderr
|
||||
));
|
||||
}
|
||||
|
||||
info!("Penpot stack installed and started");
|
||||
Ok(serde_json::json!({
|
||||
"success": true,
|
||||
"package_id": "penpot",
|
||||
"message": "Penpot stack installed and started"
|
||||
}))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user