chore(release): stage v1.7.52-alpha
This commit is contained in:
@@ -379,11 +379,22 @@ impl RpcHandler {
|
||||
// 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 }));
|
||||
let mut last_err: Option<anyhow::Error> = None;
|
||||
for candidate in status_app_id_candidates(app_id) {
|
||||
match orchestrator.health(&candidate).await {
|
||||
Ok(health) => return Ok(serde_json::json!({ app_id: health })),
|
||||
Err(e) => last_err = Some(e),
|
||||
}
|
||||
}
|
||||
for name in status_container_name_candidates(app_id) {
|
||||
if let Some(health) = inspect_container_health_value(&name).await {
|
||||
return Ok(serde_json::json!({ app_id: health }));
|
||||
}
|
||||
}
|
||||
if let Some(e) = last_err {
|
||||
return Err(e.context("Failed to get container health"));
|
||||
}
|
||||
return Err(anyhow::anyhow!("Failed to get container health"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -449,6 +460,14 @@ fn status_app_id_candidates(app_id: &str) -> Vec<String> {
|
||||
push("mempool-electrs");
|
||||
push("electrumx");
|
||||
}
|
||||
"mempool" | "mempool-web" => {
|
||||
push("mempool");
|
||||
push("archy-mempool-web");
|
||||
}
|
||||
"immich" => {
|
||||
push("immich");
|
||||
push("immich_server");
|
||||
}
|
||||
_ => push(app_id),
|
||||
}
|
||||
|
||||
@@ -469,6 +488,8 @@ fn status_container_name_candidates(app_id: &str) -> Vec<String> {
|
||||
"lnd-ui" => push("archy-lnd-ui"),
|
||||
"electrs-ui" => push("archy-electrs-ui"),
|
||||
"electrs" | "mempool-electrs" => push("electrumx"),
|
||||
"mempool" | "mempool-web" | "archy-mempool-web" => push("mempool"),
|
||||
"immich" => push("immich_server"),
|
||||
_ => {}
|
||||
}
|
||||
|
||||
@@ -511,3 +532,14 @@ async fn inspect_container_state_value(name: &str) -> Option<serde_json::Value>
|
||||
"running": running,
|
||||
}))
|
||||
}
|
||||
|
||||
async fn inspect_container_health_value(name: &str) -> Option<String> {
|
||||
let v = inspect_container_state_value(name).await?;
|
||||
match v.get("state").and_then(|s| s.as_str()).unwrap_or("unknown") {
|
||||
"running" => Some("healthy".to_string()),
|
||||
"created" => Some("starting".to_string()),
|
||||
"paused" => Some("paused".to_string()),
|
||||
"exited" | "stopped" => Some("unhealthy".to_string()),
|
||||
other => Some(format!("unknown:{other}")),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ impl RpcHandler {
|
||||
match method {
|
||||
"echo" => self.handle_echo(params).await,
|
||||
"server.echo" => self.handle_echo(params).await,
|
||||
"server.get-state" => self.handle_server_get_state().await,
|
||||
"health" => self.handle_health().await,
|
||||
"auth.login" => self.handle_auth_login(params).await,
|
||||
"auth.logout" => self.handle_auth_logout().await,
|
||||
@@ -530,6 +531,11 @@ impl RpcHandler {
|
||||
Ok(serde_json::json!({ "message": "Hello from Archipelago!" }))
|
||||
}
|
||||
|
||||
async fn handle_server_get_state(&self) -> Result<serde_json::Value> {
|
||||
let (data, rev) = self.state_manager.get_snapshot().await;
|
||||
Ok(serde_json::json!({ "data": data, "rev": rev }))
|
||||
}
|
||||
|
||||
pub(super) async fn handle_health(&self) -> Result<serde_json::Value> {
|
||||
let recovery_complete = crate::crash_recovery::is_recovery_complete();
|
||||
let uptime = crate::crash_recovery::uptime_seconds();
|
||||
|
||||
@@ -309,16 +309,23 @@ pub(super) fn all_container_names(package_id: &str) -> Vec<String> {
|
||||
let archy = format!("archy-{}", package_id);
|
||||
|
||||
match package_id {
|
||||
// Bitcoin: multiple historical names
|
||||
"bitcoin" | "bitcoin-core" | "bitcoin-knots" => vec![
|
||||
// Bitcoin variants share the UI but not the backend process. Keep
|
||||
// backend names precise so stopping one implementation does not clear
|
||||
// stop markers or issue podman operations for the other.
|
||||
"bitcoin" | "bitcoin-knots" => vec![
|
||||
"bitcoin-knots".into(),
|
||||
"bitcoin".into(),
|
||||
"bitcoin-core".into(),
|
||||
"archy-bitcoin-knots".into(),
|
||||
"archy-bitcoin".into(),
|
||||
"bitcoin-ui".into(),
|
||||
"archy-bitcoin-ui".into(),
|
||||
],
|
||||
"bitcoin-core" => vec![
|
||||
"bitcoin-core".into(),
|
||||
"archy-bitcoin-core".into(),
|
||||
"bitcoin-ui".into(),
|
||||
"archy-bitcoin-ui".into(),
|
||||
],
|
||||
// LND + UI
|
||||
"lnd" => vec!["lnd".into(), "archy-lnd".into(), "archy-lnd-ui".into()],
|
||||
// Electrumx: multiple aliases
|
||||
@@ -377,6 +384,15 @@ pub(super) fn all_container_names(package_id: &str) -> Vec<String> {
|
||||
"penpot-exporter".into(),
|
||||
"penpot-frontend".into(),
|
||||
],
|
||||
"indeedhub" => vec![
|
||||
"indeedhub-postgres".into(),
|
||||
"indeedhub-redis".into(),
|
||||
"indeedhub-minio".into(),
|
||||
"indeedhub-relay".into(),
|
||||
"indeedhub-api".into(),
|
||||
"indeedhub-ffmpeg".into(),
|
||||
"indeedhub".into(),
|
||||
],
|
||||
"nostr-vpn" => vec![
|
||||
"nostr-vpn".into(),
|
||||
"archy-nostr-vpn".into(),
|
||||
@@ -411,6 +427,22 @@ pub(super) async fn get_containers_for_app(package_id: &str) -> Result<Vec<Strin
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::all_container_names;
|
||||
|
||||
#[test]
|
||||
fn bitcoin_variant_container_names_are_precise() {
|
||||
let core = all_container_names("bitcoin-core");
|
||||
assert!(core.contains(&"bitcoin-core".to_string()));
|
||||
assert!(!core.contains(&"bitcoin-knots".to_string()));
|
||||
|
||||
let knots = all_container_names("bitcoin-knots");
|
||||
assert!(knots.contains(&"bitcoin-knots".to_string()));
|
||||
assert!(!knots.contains(&"bitcoin-core".to_string()));
|
||||
}
|
||||
}
|
||||
|
||||
/// Get data directories to clean for an app.
|
||||
/// Caller must validate package_id before calling.
|
||||
pub(super) fn get_data_dirs_for_app(package_id: &str) -> Vec<String> {
|
||||
@@ -802,7 +834,11 @@ pub(super) async fn get_app_config(
|
||||
vec!["/var/lib/archipelago/uptime-kuma:/app/data".to_string()],
|
||||
vec!["TZ=UTC".to_string()],
|
||||
None,
|
||||
None,
|
||||
Some(vec![
|
||||
"--".to_string(),
|
||||
"node".to_string(),
|
||||
"server/server.js".to_string(),
|
||||
]),
|
||||
),
|
||||
"tailscale" => (
|
||||
vec!["8240:8240".to_string()],
|
||||
@@ -817,7 +853,7 @@ pub(super) async fn get_app_config(
|
||||
Some(vec![
|
||||
"sh".to_string(),
|
||||
"-c".to_string(),
|
||||
"tailscale web --listen 0.0.0.0:8240 & exec tailscaled".to_string(),
|
||||
"tailscaled --tun=userspace-networking & sleep 2; tailscale web --listen 0.0.0.0:8240 & wait".to_string(),
|
||||
]),
|
||||
),
|
||||
"fedimint" => (
|
||||
@@ -978,8 +1014,8 @@ pub(super) async fn get_app_config(
|
||||
None,
|
||||
)
|
||||
}
|
||||
// Gitea binds to 3001 internally. Nginx on port 3000 strips X-Frame-Options
|
||||
// so Gitea works in Archipelago's iframe. See nginx-gitea-iframe.conf.
|
||||
// Gitea listens on container port 3000 and is launched directly on
|
||||
// host port 3001 because it blocks iframe embedding.
|
||||
"gitea" => (
|
||||
vec!["3001:3000".to_string(), "2222:22".to_string()],
|
||||
vec![
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
use super::config::get_containers_for_app;
|
||||
use anyhow::Result;
|
||||
use crate::data_model::{PackageDataEntry, PackageState};
|
||||
use anyhow::{Context, Result};
|
||||
use std::collections::HashMap;
|
||||
use tracing::info;
|
||||
|
||||
/// Names of container variants that represent a running Bitcoin node
|
||||
@@ -8,6 +10,13 @@ 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"];
|
||||
|
||||
fn requires_unpruned_bitcoin(package_id: &str) -> bool {
|
||||
matches!(
|
||||
package_id,
|
||||
"electrumx" | "mempool-electrs" | "electrs" | "mempool" | "mempool-web"
|
||||
)
|
||||
}
|
||||
|
||||
/// Snapshot of which dependency services are currently running.
|
||||
pub(super) struct RunningDeps {
|
||||
pub has_bitcoin: bool,
|
||||
@@ -15,13 +24,43 @@ pub(super) struct RunningDeps {
|
||||
pub has_lnd: bool,
|
||||
}
|
||||
|
||||
pub(super) fn detect_running_deps_from_package_data(
|
||||
packages: &HashMap<String, PackageDataEntry>,
|
||||
) -> RunningDeps {
|
||||
let is_running = |names: &[&str]| {
|
||||
names.iter().any(|name| {
|
||||
packages
|
||||
.get(*name)
|
||||
.map(|pkg| pkg.state == PackageState::Running)
|
||||
.unwrap_or(false)
|
||||
})
|
||||
};
|
||||
|
||||
RunningDeps {
|
||||
has_bitcoin: is_running(BITCOIN_NAMES),
|
||||
has_electrumx: is_running(ELECTRUM_NAMES),
|
||||
has_lnd: is_running(&["lnd"]),
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 dep_check = tokio::time::timeout(
|
||||
std::time::Duration::from_secs(30),
|
||||
tokio::process::Command::new("podman")
|
||||
.args(["ps", "--format", "{{.Names}}"])
|
||||
.output(),
|
||||
)
|
||||
.await
|
||||
.map_err(|_| anyhow::anyhow!("Timed out checking running containers"))?
|
||||
.map_err(|e| anyhow::anyhow!("Failed to check running containers: {}", e))?;
|
||||
|
||||
if !dep_check.status.success() {
|
||||
anyhow::bail!(
|
||||
"Failed to check running containers: {}",
|
||||
String::from_utf8_lossy(&dep_check.stderr).trim()
|
||||
);
|
||||
}
|
||||
|
||||
let running = String::from_utf8_lossy(&dep_check.stdout);
|
||||
let is_running = |names: &[&str]| {
|
||||
@@ -76,6 +115,65 @@ pub(super) fn check_install_deps(package_id: &str, deps: &RunningDeps) -> Result
|
||||
}
|
||||
}
|
||||
|
||||
/// ElectrumX and Mempool's Electrum backend need historical blocks from an
|
||||
/// unpruned node while building their indexes. A pruned Bitcoin node can be
|
||||
/// running and RPC-reachable but still leave them stuck with closed ports.
|
||||
pub(super) async fn check_bitcoin_pruning_compatibility(package_id: &str) -> Result<()> {
|
||||
if !requires_unpruned_bitcoin(package_id) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let (rpc_user, rpc_pass) = crate::bitcoin_rpc::bitcoin_rpc_credentials().await;
|
||||
let body = serde_json::json!({
|
||||
"jsonrpc": "1.0",
|
||||
"id": "package-install-prune-check",
|
||||
"method": "getblockchaininfo",
|
||||
"params": [],
|
||||
});
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(10))
|
||||
.build()
|
||||
.context("building Bitcoin RPC client")?;
|
||||
let resp = client
|
||||
.post(crate::constants::BITCOIN_RPC_URL)
|
||||
.basic_auth(rpc_user, Some(rpc_pass))
|
||||
.header("Content-Type", "application/json")
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
.context("checking Bitcoin pruning status")?;
|
||||
|
||||
let status = resp.status();
|
||||
let json: serde_json::Value = resp.json().await.context("decode Bitcoin RPC response")?;
|
||||
if !status.is_success() {
|
||||
anyhow::bail!(
|
||||
"Bitcoin RPC returned {} while checking pruning status",
|
||||
status
|
||||
);
|
||||
}
|
||||
if let Some(error) = json.get("error").filter(|e| !e.is_null()) {
|
||||
anyhow::bail!("Bitcoin RPC error while checking pruning status: {}", error);
|
||||
}
|
||||
|
||||
let Some(result) = json.get("result") else {
|
||||
anyhow::bail!("Bitcoin RPC response missing result while checking pruning status");
|
||||
};
|
||||
if result
|
||||
.get("pruned")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false)
|
||||
{
|
||||
anyhow::bail!(
|
||||
"{} requires an unpruned Bitcoin node while indexing. Current Bitcoin is pruned; use a full node with enough disk for txindex/full block history, then reinstall/restart {}.",
|
||||
package_id,
|
||||
package_id
|
||||
);
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -129,6 +227,18 @@ pub(super) fn startup_order(package_id: &str) -> &'static [&'static str] {
|
||||
"mempool",
|
||||
],
|
||||
"immich" => &["immich_postgres", "immich_redis", "immich_server"],
|
||||
"indeedhub" => &[
|
||||
"indeedhub-postgres",
|
||||
"indeedhub-redis",
|
||||
"indeedhub-minio",
|
||||
"indeedhub-relay",
|
||||
"indeedhub-api",
|
||||
"indeedhub-ffmpeg",
|
||||
"indeedhub",
|
||||
],
|
||||
"btcpay-server" | "btcpayserver" | "btcpay" => {
|
||||
&["archy-btcpay-db", "archy-nbxplorer", "btcpay-server"]
|
||||
}
|
||||
"penpot" | "penpot-frontend" => &[
|
||||
"penpot-postgres",
|
||||
"penpot-valkey",
|
||||
@@ -211,3 +321,24 @@ pub(super) fn configure_fedimint_lnd(
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::requires_unpruned_bitcoin;
|
||||
|
||||
#[test]
|
||||
fn unpruned_bitcoin_required_for_electrum_indexers_and_mempool() {
|
||||
for package_id in [
|
||||
"electrumx",
|
||||
"mempool-electrs",
|
||||
"electrs",
|
||||
"mempool",
|
||||
"mempool-web",
|
||||
] {
|
||||
assert!(requires_unpruned_bitcoin(package_id), "{package_id}");
|
||||
}
|
||||
for package_id in ["bitcoin-knots", "btcpay-server", "lnd", "fedimint"] {
|
||||
assert!(!requires_unpruned_bitcoin(package_id), "{package_id}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,8 +3,9 @@ use super::config::{
|
||||
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,
|
||||
check_bitcoin_pruning_compatibility, check_install_deps, configure_fedimint_lnd,
|
||||
detect_running_deps, detect_running_deps_from_package_data, log_optional_dep_info,
|
||||
needs_archy_net, RunningDeps,
|
||||
};
|
||||
use super::progress::parse_pull_progress;
|
||||
use super::validation::validate_app_id;
|
||||
@@ -32,6 +33,130 @@ pub(in crate::api::rpc) async fn install_log(msg: &str) {
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) async fn patch_indeedhub_nostr_provider() {
|
||||
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
|
||||
|
||||
let _ = tokio::process::Command::new("podman")
|
||||
.args([
|
||||
"exec",
|
||||
"indeedhub",
|
||||
"sed",
|
||||
"-i",
|
||||
"/X-Frame-Options/d",
|
||||
"/etc/nginx/conf.d/default.conf",
|
||||
])
|
||||
.output()
|
||||
.await;
|
||||
|
||||
let provider_src = "/opt/archipelago/web-ui/nostr-provider.js";
|
||||
if tokio::fs::metadata(provider_src).await.is_ok() {
|
||||
let _ = tokio::process::Command::new("podman")
|
||||
.args([
|
||||
"cp",
|
||||
provider_src,
|
||||
"indeedhub:/usr/share/nginx/html/nostr-provider.js",
|
||||
])
|
||||
.output()
|
||||
.await;
|
||||
}
|
||||
|
||||
let check = tokio::process::Command::new("podman")
|
||||
.args([
|
||||
"exec",
|
||||
"indeedhub",
|
||||
"grep",
|
||||
"-q",
|
||||
"nostr-provider",
|
||||
"/etc/nginx/conf.d/default.conf",
|
||||
])
|
||||
.output()
|
||||
.await;
|
||||
let already_patched = check.map(|o| o.status.success()).unwrap_or(false);
|
||||
|
||||
if !already_patched {
|
||||
let cat_out = tokio::process::Command::new("podman")
|
||||
.args(["exec", "indeedhub", "cat", "/etc/nginx/conf.d/default.conf"])
|
||||
.output()
|
||||
.await;
|
||||
|
||||
if let Ok(out) = cat_out {
|
||||
if out.status.success() {
|
||||
let conf = String::from_utf8_lossy(&out.stdout).to_string();
|
||||
let conf = conf.replace(
|
||||
"location = /sw.js {",
|
||||
"location = /nostr-provider.js {\n\
|
||||
add_header Cache-Control \"no-cache, no-store, must-revalidate\";\n\
|
||||
expires off;\n\
|
||||
}\n\n\
|
||||
location = /sw.js {",
|
||||
);
|
||||
let conf = if conf.contains("try_files") && !conf.contains("sub_filter") {
|
||||
conf.replacen(
|
||||
"try_files $uri $uri/ /index.html;",
|
||||
"try_files $uri $uri/ /index.html;\n\
|
||||
sub_filter_once on;\n\
|
||||
sub_filter '</head>' '<script src=\"/nostr-provider.js\"></script></head>';",
|
||||
1,
|
||||
)
|
||||
} else {
|
||||
conf
|
||||
};
|
||||
|
||||
let tmp_path = "/tmp/indeedhub-nginx-patch.conf";
|
||||
if tokio::fs::write(tmp_path, &conf).await.is_ok() {
|
||||
let _ = tokio::process::Command::new("podman")
|
||||
.args(["cp", tmp_path, "indeedhub:/etc/nginx/conf.d/default.conf"])
|
||||
.output()
|
||||
.await;
|
||||
let _ = tokio::fs::remove_file(tmp_path).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let _ = tokio::process::Command::new("podman")
|
||||
.args([
|
||||
"exec",
|
||||
"indeedhub",
|
||||
"sed",
|
||||
"-i",
|
||||
"s|proxy_set_header X-Forwarded-Prefix /api;|proxy_set_header X-Forwarded-Prefix $http_x_forwarded_prefix/api;|",
|
||||
"/etc/nginx/conf.d/default.conf",
|
||||
])
|
||||
.output()
|
||||
.await;
|
||||
|
||||
let reload = tokio::process::Command::new("podman")
|
||||
.args(["exec", "indeedhub", "nginx", "-s", "reload"])
|
||||
.output()
|
||||
.await;
|
||||
match reload {
|
||||
Ok(o) if o.status.success() => {
|
||||
info!("IndeeHub: NIP-07 provider injected, nginx patched and reloaded");
|
||||
}
|
||||
Ok(o) => {
|
||||
tracing::warn!(
|
||||
"IndeeHub nginx reload failed: {}",
|
||||
String::from_utf8_lossy(&o.stderr)
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("IndeeHub nginx reload error: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn dependency_cache_satisfies(package_id: &str, deps: &RunningDeps) -> bool {
|
||||
match package_id {
|
||||
"electrumx" | "mempool-electrs" | "electrs" | "lnd" | "btcpay-server" | "btcpayserver" => {
|
||||
deps.has_bitcoin
|
||||
}
|
||||
"mempool" | "mempool-web" => deps.has_bitcoin && deps.has_electrumx,
|
||||
"fedimint" => true,
|
||||
_ => true,
|
||||
}
|
||||
}
|
||||
|
||||
impl RpcHandler {
|
||||
/// Install a package from a Docker image.
|
||||
/// Security: Image verification, resource limits, network isolation.
|
||||
@@ -62,6 +187,8 @@ impl RpcHandler {
|
||||
package_id, docker_image
|
||||
);
|
||||
|
||||
cleanup_stale_package_ports(package_id).await;
|
||||
|
||||
if !is_valid_docker_image(docker_image) {
|
||||
install_log(&format!(
|
||||
"INSTALL FAIL: {} — invalid image format",
|
||||
@@ -108,11 +235,22 @@ impl RpcHandler {
|
||||
return self.install_indeedhub_stack().await;
|
||||
}
|
||||
|
||||
// Dependency checks
|
||||
let deps = detect_running_deps().await?;
|
||||
// Dependency checks. Prefer the scanner's cached package state so a
|
||||
// congested Podman API does not turn an already-running dependency into
|
||||
// a false install failure. Fall back to a bounded direct Podman probe
|
||||
// only when the cache does not show the dependency.
|
||||
let deps = {
|
||||
let (data, _) = self.state_manager.get_snapshot().await;
|
||||
let cached = detect_running_deps_from_package_data(&data.package_data);
|
||||
if dependency_cache_satisfies(package_id, &cached) {
|
||||
cached
|
||||
} else {
|
||||
detect_running_deps().await?
|
||||
}
|
||||
};
|
||||
check_install_deps(package_id, &deps)?;
|
||||
check_bitcoin_pruning_compatibility(package_id).await?;
|
||||
log_optional_dep_info(package_id, &deps);
|
||||
check_bitcoin_implementation_conflict(package_id).await?;
|
||||
let repaired_bitcoin_conf =
|
||||
if matches!(package_id, "bitcoin" | "bitcoin-core" | "bitcoin-knots") {
|
||||
// Materialise the RPC password file before any install path
|
||||
@@ -243,6 +381,7 @@ impl RpcHandler {
|
||||
package_id
|
||||
))
|
||||
.await;
|
||||
ensure_host_port_listener(package_id, package_id).await?;
|
||||
return Ok(serde_json::json!({
|
||||
"success": true,
|
||||
"package_id": package_id,
|
||||
@@ -268,6 +407,8 @@ impl RpcHandler {
|
||||
Ok(container_name) => {
|
||||
self.set_install_phase(package_id, InstallPhase::WaitingHealthy)
|
||||
.await;
|
||||
crate::api::rpc::package::runtime::reconcile_companions_for(package_id)
|
||||
.await;
|
||||
install_log(&format!(
|
||||
"INSTALL ORCH OK: {} (app={}) — container={}",
|
||||
package_id, orchestrator_app_id, container_name
|
||||
@@ -368,17 +509,15 @@ impl RpcHandler {
|
||||
"--restart=unless-stopped",
|
||||
];
|
||||
|
||||
let is_tailscale = package_id == "tailscale";
|
||||
// Explicit DNS alias for aardvark-dns (must outlive run_args)
|
||||
let network_alias_flag = format!("--network-alias={}", container_name);
|
||||
|
||||
// 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");
|
||||
if package_id == "uptime-kuma" || package_id == "gitea" || package_id == "tailscale" {
|
||||
// These standalone web UIs have repeatedly lost host listeners
|
||||
// under Podman's rootless pasta backend while staying healthy internally.
|
||||
// Use slirp4netns/rootlessport for this standalone web UI.
|
||||
run_args.push("--network=slirp4netns:allow_host_loopback=true");
|
||||
} else if needs_archy_net(package_id) {
|
||||
// Create archy-net if it doesn't exist (idempotent — "already exists" is fine)
|
||||
match tokio::process::Command::new("podman")
|
||||
@@ -420,30 +559,24 @@ impl RpcHandler {
|
||||
run_args.push(&host_gateway_flag);
|
||||
|
||||
// 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);
|
||||
let security_caps: Vec<String> = get_app_capabilities(package_id);
|
||||
let readonly_compatible = is_readonly_compatible(package_id);
|
||||
|
||||
if !is_tailscale {
|
||||
run_args.push("--cap-drop=ALL");
|
||||
run_args.push("--security-opt=no-new-privileges:true");
|
||||
run_args.push("--pids-limit=4096");
|
||||
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");
|
||||
}
|
||||
run_args.push("--cap-drop=ALL");
|
||||
run_args.push("--security-opt=no-new-privileges:true");
|
||||
run_args.push("--pids-limit=4096");
|
||||
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");
|
||||
}
|
||||
|
||||
// Jellyfin: .NET CoreCLR needs exec-enabled /tmp for JIT compilation
|
||||
if package_id == "jellyfin" {
|
||||
run_args.push("--tmpfs=/tmp:rw,exec,size=256m");
|
||||
}
|
||||
// Jellyfin: .NET CoreCLR needs exec-enabled /tmp for JIT compilation
|
||||
if package_id == "jellyfin" {
|
||||
run_args.push("--tmpfs=/tmp:rw,exec,size=256m");
|
||||
}
|
||||
|
||||
// Create data directories (mkdir only — chown happens AFTER config files are written)
|
||||
@@ -490,12 +623,9 @@ impl RpcHandler {
|
||||
// NOW chown data directories to container UID (after all config files are written)
|
||||
self.create_data_dirs(package_id, &volumes).await;
|
||||
|
||||
// Port mappings (skip for host-network containers)
|
||||
if !is_tailscale {
|
||||
for port in &ports {
|
||||
run_args.push("-p");
|
||||
run_args.push(port);
|
||||
}
|
||||
for port in &ports {
|
||||
run_args.push("-p");
|
||||
run_args.push(port);
|
||||
}
|
||||
|
||||
// Volume mounts
|
||||
@@ -570,7 +700,14 @@ impl RpcHandler {
|
||||
cmd.args(args);
|
||||
}
|
||||
|
||||
let run_output = cmd.output().await.context("Failed to run container")?;
|
||||
let mut run_output = cmd.output().await.context("Failed to run container")?;
|
||||
|
||||
if !run_output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&run_output.stderr).to_string();
|
||||
if cleanup_start_conflict(package_id, &stderr).await {
|
||||
run_output = cmd.output().await.context("Failed to rerun container")?;
|
||||
}
|
||||
}
|
||||
|
||||
if !run_output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&run_output.stderr);
|
||||
@@ -680,6 +817,12 @@ impl RpcHandler {
|
||||
// Post-install hooks — await completion before returning success
|
||||
self.run_post_install_hooks(package_id).await;
|
||||
|
||||
if package_id == "nextcloud" {
|
||||
repair_nextcloud_permissions().await;
|
||||
}
|
||||
|
||||
ensure_host_port_listener(package_id, container_name).await?;
|
||||
|
||||
install_log(&format!(
|
||||
"INSTALL OK: {} (container: {})",
|
||||
package_id,
|
||||
@@ -744,36 +887,16 @@ impl RpcHandler {
|
||||
Ok(has_local_fallback)
|
||||
}
|
||||
|
||||
/// Pull image with retry and exponential backoff (3 attempts: 5s, 15s, 45s).
|
||||
/// Pull image once through the configured registry list. Each registry URL
|
||||
/// already has a bounded timeout, so retrying the full list can leave the UI
|
||||
/// in Installing for close to an hour when a large image is unavailable or
|
||||
/// a registry stalls.
|
||||
async fn pull_image_with_progress(&self, package_id: &str, docker_image: &str) -> Result<()> {
|
||||
const MAX_ATTEMPTS: u32 = 3;
|
||||
const BACKOFF_SECS: [u64; 3] = [5, 15, 45];
|
||||
|
||||
for attempt in 1..=MAX_ATTEMPTS {
|
||||
match self.do_pull_image(package_id, docker_image).await {
|
||||
Ok(()) => return Ok(()),
|
||||
Err(e) if attempt < MAX_ATTEMPTS => {
|
||||
let delay = BACKOFF_SECS[(attempt - 1) as usize];
|
||||
tracing::warn!(
|
||||
"Image pull failed for {} (attempt {}/{}): {}. Retrying in {}s...",
|
||||
docker_image,
|
||||
attempt,
|
||||
MAX_ATTEMPTS,
|
||||
e,
|
||||
delay
|
||||
);
|
||||
tokio::time::sleep(std::time::Duration::from_secs(delay)).await;
|
||||
}
|
||||
Err(e) => {
|
||||
self.clear_install_progress(package_id).await;
|
||||
return Err(e.context(format!(
|
||||
"Failed to pull {} after {} attempts",
|
||||
docker_image, MAX_ATTEMPTS
|
||||
)));
|
||||
}
|
||||
}
|
||||
if let Err(e) = self.do_pull_image(package_id, docker_image).await {
|
||||
self.clear_install_progress(package_id).await;
|
||||
return Err(e.context(format!("Failed to pull {}", docker_image)));
|
||||
}
|
||||
unreachable!()
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Pull one image URL with live progress streamed through
|
||||
@@ -799,24 +922,29 @@ impl RpcHandler {
|
||||
.spawn()
|
||||
.context("Failed to start image pull")?;
|
||||
|
||||
// 10-minute per-URL budget — large layers (Minio, Postgres,
|
||||
// ffmpeg) regularly take several minutes and we'd rather wait
|
||||
// than bounce to the next mirror mid-download.
|
||||
let pull_result = tokio::time::timeout(std::time::Duration::from_secs(600), async {
|
||||
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();
|
||||
// 5-minute per-URL budget. A full install tries each configured mirror
|
||||
// once, so a two-registry setup fails visibly in roughly 10 minutes
|
||||
// instead of staying in Installing for up to an hour.
|
||||
const PULL_URL_TIMEOUT_SECS: u64 = 300;
|
||||
let pull_result = tokio::time::timeout(
|
||||
std::time::Duration::from_secs(PULL_URL_TIMEOUT_SECS),
|
||||
async {
|
||||
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;
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
child.wait().await
|
||||
})
|
||||
child.wait().await
|
||||
},
|
||||
)
|
||||
.await;
|
||||
|
||||
match pull_result {
|
||||
@@ -826,7 +954,11 @@ impl RpcHandler {
|
||||
Ok(false)
|
||||
}
|
||||
Err(_) => {
|
||||
tracing::warn!("Image pull timed out after 600s: {}", url);
|
||||
tracing::warn!(
|
||||
"Image pull timed out after {}s: {}",
|
||||
PULL_URL_TIMEOUT_SECS,
|
||||
url
|
||||
);
|
||||
let _ = child.kill().await;
|
||||
let _ = child.wait().await; // reap zombie
|
||||
Ok(false)
|
||||
@@ -963,7 +1095,7 @@ impl RpcHandler {
|
||||
// Current BTCPay Postgres image runs as uid 999 inside the
|
||||
// container, so its rootless host-mapped uid is 100998.
|
||||
"btcpay-postgres" | "archy-btcpay-db" => 999,
|
||||
"electrumx" | "electrs" => 1000,
|
||||
"electrumx" | "electrs" => 0,
|
||||
_ => 0, // Most containers run as root (UID 0)
|
||||
};
|
||||
if container_uid == 0 {
|
||||
@@ -1392,18 +1524,18 @@ autopilot.active=false\n",
|
||||
}
|
||||
}
|
||||
|
||||
// Gitea: keep it on its native host port (3001) and serve it under
|
||||
// /app/gitea/ via the main Archipelago nginx config. Avoids colliding
|
||||
// with Grafana, which also uses host port 3000.
|
||||
// Gitea: keep it on its native host port (3001). The UI opens Gitea
|
||||
// in a new tab on that direct port so absolute asset URLs must be
|
||||
// rooted at the host port rather than Archipelago's /app/gitea/ path.
|
||||
if package_id == "gitea" {
|
||||
let _ = tokio::fs::remove_file("/etc/nginx/conf.d/gitea-iframe.conf").await;
|
||||
|
||||
// Set ROOT_URL to the UI path-based route so links/assets stay
|
||||
// anchored under Archipelago's app proxy endpoint.
|
||||
// Set ROOT_URL to the direct launch route so links/assets stay
|
||||
// anchored under the same origin Gitea is launched from.
|
||||
let host_ip = &self.config.host_ip;
|
||||
let _ = tokio::process::Command::new("podman")
|
||||
.args(["exec", "gitea", "sh", "-c",
|
||||
&format!("grep -q ROOT_URL /data/gitea/conf/app.ini && sed -i 's|ROOT_URL.*|ROOT_URL = http://{}/app/gitea/|' /data/gitea/conf/app.ini || true", host_ip)])
|
||||
&format!("grep -q ROOT_URL /data/gitea/conf/app.ini && sed -i 's|ROOT_URL.*|ROOT_URL = http://{}:3001/|' /data/gitea/conf/app.ini || true", host_ip)])
|
||||
.output()
|
||||
.await;
|
||||
// Also ensure X_FRAME_OPTIONS is empty so Gitea doesn't send the header
|
||||
@@ -1413,14 +1545,8 @@ autopilot.active=false\n",
|
||||
.output()
|
||||
.await;
|
||||
|
||||
// Reload main nginx so /app/gitea/ routing changes take effect.
|
||||
let _ = tokio::process::Command::new("nginx")
|
||||
.args(["-s", "reload"])
|
||||
.output()
|
||||
.await;
|
||||
|
||||
info!(
|
||||
"Gitea: ROOT_URL set to http://{}/app/gitea/, X_FRAME_OPTIONS cleared",
|
||||
"Gitea: ROOT_URL set to http://{}:3001/, X_FRAME_OPTIONS cleared",
|
||||
host_ip
|
||||
);
|
||||
}
|
||||
@@ -1661,6 +1787,159 @@ autopilot.active=false\n",
|
||||
}
|
||||
}
|
||||
|
||||
async fn cleanup_stale_package_ports(package_id: &str) {
|
||||
match package_id {
|
||||
"grafana" => cleanup_stale_pasta_port("3000").await,
|
||||
"searxng" => cleanup_stale_pasta_port("8888").await,
|
||||
"uptime-kuma" => cleanup_stale_pasta_port("3002").await,
|
||||
"gitea" => {
|
||||
cleanup_stale_pasta_port("3001").await;
|
||||
cleanup_stale_pasta_port("2222").await;
|
||||
cleanup_stale_pasta_port("3000").await;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
async fn cleanup_start_conflict(package_id: &str, stderr: &str) -> bool {
|
||||
match package_id {
|
||||
"grafana"
|
||||
if stderr.contains("pasta failed") || stderr.contains("address already in use") =>
|
||||
{
|
||||
cleanup_stale_pasta_port("3000").await;
|
||||
true
|
||||
}
|
||||
"searxng"
|
||||
if stderr.contains("pasta failed") || stderr.contains("address already in use") =>
|
||||
{
|
||||
cleanup_stale_pasta_port("8888").await;
|
||||
true
|
||||
}
|
||||
"uptime-kuma"
|
||||
if stderr.contains("pasta failed") || stderr.contains("address already in use") =>
|
||||
{
|
||||
cleanup_stale_pasta_port("3002").await;
|
||||
true
|
||||
}
|
||||
"gitea" if stderr.contains("pasta failed") || stderr.contains("address already in use") => {
|
||||
cleanup_stale_pasta_port("3001").await;
|
||||
cleanup_stale_pasta_port("2222").await;
|
||||
cleanup_stale_pasta_port("3000").await;
|
||||
true
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
async fn cleanup_stale_pasta_port(port: &str) {
|
||||
let kill_listener = format!(
|
||||
"ss -ltnp 'sport = :{}' 2>/dev/null | sed -n 's/.*pid=\\([0-9]*\\).*/\\1/p' | xargs -r kill 2>/dev/null || true",
|
||||
port
|
||||
);
|
||||
let _ = tokio::process::Command::new("sh")
|
||||
.args(["-c", &kill_listener])
|
||||
.output()
|
||||
.await;
|
||||
|
||||
let pattern = format!("pasta.*{}", port);
|
||||
let _ = tokio::process::Command::new("pkill")
|
||||
.args(["-f", &pattern])
|
||||
.output()
|
||||
.await;
|
||||
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
|
||||
}
|
||||
|
||||
async fn repair_nextcloud_permissions() {
|
||||
let script = "chmod 755 /var/www/html /var/www/html/config /var/www/html/data 2>/dev/null || true; chmod 644 /var/www/html/.htaccess /var/www/html/index.php /var/www/html/status.php /var/www/html/config/.htaccess 2>/dev/null || true; chmod 640 /var/www/html/config/config.php 2>/dev/null || true";
|
||||
let output = tokio::process::Command::new("podman")
|
||||
.args(["exec", "nextcloud", "sh", "-lc", script])
|
||||
.output()
|
||||
.await;
|
||||
match output {
|
||||
Ok(out) if out.status.success() => {}
|
||||
Ok(out) => {
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
tracing::warn!("Nextcloud permission repair failed: {}", stderr.trim());
|
||||
}
|
||||
Err(err) => tracing::warn!("Failed to run Nextcloud permission repair: {}", err),
|
||||
}
|
||||
}
|
||||
|
||||
async fn ensure_host_port_listener(package_id: &str, container_name: &str) -> Result<()> {
|
||||
let Some(port) = required_host_port(package_id) else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
if wait_for_host_port(port, 10).await {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
install_log(&format!(
|
||||
"INSTALL REPAIR: {} — host port {} missing after start; restarting container",
|
||||
package_id, port
|
||||
))
|
||||
.await;
|
||||
cleanup_stale_package_ports(package_id).await;
|
||||
|
||||
let output = tokio::process::Command::new("podman")
|
||||
.args(["restart", container_name])
|
||||
.output()
|
||||
.await
|
||||
.context("failed to restart container after missing host port")?;
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(anyhow::anyhow!(
|
||||
"Container {} host port {} was not listening and restart failed: {}",
|
||||
container_name,
|
||||
port,
|
||||
stderr.trim()
|
||||
));
|
||||
}
|
||||
|
||||
if wait_for_host_port(port, 60).await {
|
||||
install_log(&format!(
|
||||
"INSTALL REPAIR OK: {} — host port {} is listening after restart",
|
||||
package_id, port
|
||||
))
|
||||
.await;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
Err(anyhow::anyhow!(
|
||||
"Container {} is running but host port {} is not listening",
|
||||
container_name,
|
||||
port
|
||||
))
|
||||
}
|
||||
|
||||
fn required_host_port(package_id: &str) -> Option<u16> {
|
||||
match package_id {
|
||||
"grafana" => Some(3000),
|
||||
"searxng" => Some(8888),
|
||||
"uptime-kuma" => Some(3002),
|
||||
"gitea" => Some(3001),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
async fn wait_for_host_port(port: u16, timeout_secs: u64) -> bool {
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(timeout_secs);
|
||||
loop {
|
||||
if tokio::net::TcpStream::connect(("127.0.0.1", port))
|
||||
.await
|
||||
.is_ok()
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if std::time::Instant::now() >= deadline {
|
||||
return false;
|
||||
}
|
||||
|
||||
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve the host gateway IP for --add-host flag.
|
||||
/// Resolve the default gateway IP from the routing table for --add-host flag.
|
||||
/// Explicit IP avoids issues with "host-gateway" in rootless Podman.
|
||||
@@ -1792,73 +2071,6 @@ fn should_try_orchestrator_install(package_id: &str, orchestrator_available: boo
|
||||
orchestrator_available && uses_orchestrator_install_flow(package_id)
|
||||
}
|
||||
|
||||
async fn check_bitcoin_implementation_conflict(package_id: &str) -> Result<()> {
|
||||
let other = match package_id {
|
||||
"bitcoin-core" => "bitcoin-knots",
|
||||
"bitcoin-knots" => "bitcoin-core",
|
||||
_ => return Ok(()),
|
||||
};
|
||||
|
||||
// Three cases for the OTHER variant:
|
||||
// - missing → no conflict, continue
|
||||
// - running → real conflict, refuse install
|
||||
// - any other state (created/exited/configured/...) → stuck from a
|
||||
// prior failed install. Auto-remove so reinstall is reachable
|
||||
// without a manual `podman rm`. This is what unblocks the .198
|
||||
// "bitcoin-core stuck in created, port 8332 held by bitcoin-knots"
|
||||
// deadlock that no UI path could exit.
|
||||
let inspect = tokio::process::Command::new("podman")
|
||||
.args(["inspect", other, "--format", "{{.State.Status}}"])
|
||||
.output()
|
||||
.await
|
||||
.context("Failed to inspect conflicting Bitcoin container")?;
|
||||
if !inspect.status.success() {
|
||||
return Ok(());
|
||||
}
|
||||
let state = String::from_utf8_lossy(&inspect.stdout).trim().to_string();
|
||||
|
||||
if state == "running" {
|
||||
let current = pretty_bitcoin_name(other);
|
||||
let requested = pretty_bitcoin_name(package_id);
|
||||
return Err(anyhow::anyhow!(
|
||||
"{} is currently running. Stop and uninstall {} before installing {}; both implementations use the same Bitcoin data directory and ports.",
|
||||
current, current, requested
|
||||
));
|
||||
}
|
||||
|
||||
info!(
|
||||
"Removing stuck {} container (state={}) before installing {}",
|
||||
other, state, package_id
|
||||
);
|
||||
install_log(&format!(
|
||||
"INSTALL UNSTUCK: removing {} (state={}) before installing {}",
|
||||
other, state, package_id
|
||||
))
|
||||
.await;
|
||||
let rm = tokio::process::Command::new("podman")
|
||||
.args(["rm", "-f", other])
|
||||
.output()
|
||||
.await
|
||||
.context("Failed to remove stuck Bitcoin container")?;
|
||||
if !rm.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&rm.stderr);
|
||||
return Err(anyhow::anyhow!(
|
||||
"Failed to remove stuck {} container: {}",
|
||||
other,
|
||||
stderr.trim()
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn pretty_bitcoin_name(id: &str) -> &'static str {
|
||||
match id {
|
||||
"bitcoin-core" => "Bitcoin Core",
|
||||
"bitcoin-knots" => "Bitcoin Knots",
|
||||
_ => "another Bitcoin node",
|
||||
}
|
||||
}
|
||||
|
||||
fn orchestrator_install_app_id(package_id: &str) -> &str {
|
||||
match package_id {
|
||||
"electrs" | "mempool-electrs" => "electrumx",
|
||||
@@ -1903,6 +2115,7 @@ mod tests {
|
||||
orchestrator_install_app_id, should_try_orchestrator_install,
|
||||
uses_orchestrator_install_flow,
|
||||
};
|
||||
use crate::api::rpc::package::runtime::orchestrator_uninstall_app_ids;
|
||||
|
||||
#[test]
|
||||
fn orchestrator_install_allowlist_includes_ported_backends() {
|
||||
@@ -1955,4 +2168,44 @@ mod tests {
|
||||
assert_eq!(orchestrator_install_app_id("mempool-electrs"), "electrumx");
|
||||
assert_eq!(orchestrator_install_app_id("lnd"), "lnd");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn uninstall_aliases_map_to_exact_manifest_app_ids() {
|
||||
assert_eq!(
|
||||
orchestrator_uninstall_app_ids("bitcoin-knots"),
|
||||
vec!["bitcoin-knots", "bitcoin-ui"]
|
||||
);
|
||||
assert_eq!(
|
||||
orchestrator_uninstall_app_ids("electrs"),
|
||||
vec!["electrumx", "electrs-ui"]
|
||||
);
|
||||
assert_eq!(
|
||||
orchestrator_uninstall_app_ids("btcpay-server"),
|
||||
vec!["btcpay-server", "archy-nbxplorer", "archy-btcpay-db"]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn companion_reconcile_aliases_include_ui_app_ids() {
|
||||
use crate::api::rpc::package::runtime::reconcile_companions_for;
|
||||
|
||||
// Smoke only: unknown/non-companion apps are a no-op. Full companion
|
||||
// behavior is covered in container::companion tests; this guards that
|
||||
// the helper remains callable from install/start/restart paths.
|
||||
reconcile_companions_for("filebrowser").await;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_companion_is_ok_only_for_known_ui_companions() {
|
||||
use crate::api::rpc::package::runtime::is_missing_companion_ok;
|
||||
|
||||
assert!(is_missing_companion_ok(
|
||||
"archy-bitcoin-ui",
|
||||
"Error: no container with name or ID \"archy-bitcoin-ui\" found"
|
||||
));
|
||||
assert!(!is_missing_companion_ok(
|
||||
"bitcoin-knots",
|
||||
"Error: no container with name or ID \"bitcoin-knots\" found"
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,6 +59,8 @@ impl RpcHandler {
|
||||
}
|
||||
|
||||
let package_id_owned = package_id.to_string();
|
||||
let companion_app_id = package_id_owned.clone();
|
||||
let orchestrator = self.orchestrator.clone();
|
||||
let state_manager = Arc::clone(&self.state_manager);
|
||||
let pre_state =
|
||||
flip_package_state(&state_manager, &package_id_owned, PackageState::Starting).await;
|
||||
@@ -70,8 +72,14 @@ impl RpcHandler {
|
||||
.await;
|
||||
|
||||
tokio::spawn(async move {
|
||||
match do_package_start(&to_start).await {
|
||||
let result = if let Some(orchestrator) = orchestrator.as_ref() {
|
||||
do_orchestrator_package_start(orchestrator.as_ref(), &to_start).await
|
||||
} else {
|
||||
do_package_start(&to_start).await
|
||||
};
|
||||
match result {
|
||||
Ok(()) => {
|
||||
reconcile_companions_for(&companion_app_id).await;
|
||||
set_package_state(&state_manager, &package_id_owned, PackageState::Running)
|
||||
.await;
|
||||
}
|
||||
@@ -123,6 +131,8 @@ impl RpcHandler {
|
||||
}
|
||||
|
||||
let package_id_owned = package_id.to_string();
|
||||
let to_stop = containers.clone();
|
||||
let orchestrator = self.orchestrator.clone();
|
||||
let state_manager = Arc::clone(&self.state_manager);
|
||||
let pre_state =
|
||||
flip_package_state(&state_manager, &package_id_owned, PackageState::Stopping).await;
|
||||
@@ -134,7 +144,12 @@ impl RpcHandler {
|
||||
.await;
|
||||
|
||||
tokio::spawn(async move {
|
||||
match do_package_stop(&containers).await {
|
||||
let result = if let Some(orchestrator) = orchestrator.as_ref() {
|
||||
do_orchestrator_package_stop(orchestrator.as_ref(), &to_stop).await
|
||||
} else {
|
||||
do_package_stop(&containers).await
|
||||
};
|
||||
match result {
|
||||
Ok(()) => {
|
||||
set_package_state(&state_manager, &package_id_owned, PackageState::Stopped)
|
||||
.await;
|
||||
@@ -182,7 +197,10 @@ impl RpcHandler {
|
||||
}
|
||||
|
||||
let package_id_owned = package_id.to_string();
|
||||
let companion_app_id = package_id_owned.clone();
|
||||
let to_restart = ordered_containers_for_start(package_id).await?;
|
||||
let state_manager = Arc::clone(&self.state_manager);
|
||||
let orchestrator = self.orchestrator.clone();
|
||||
let pre_state =
|
||||
flip_package_state(&state_manager, &package_id_owned, PackageState::Restarting).await;
|
||||
|
||||
@@ -193,8 +211,14 @@ impl RpcHandler {
|
||||
.await;
|
||||
|
||||
tokio::spawn(async move {
|
||||
match do_package_restart(&containers).await {
|
||||
let result = if let Some(orchestrator) = orchestrator.as_ref() {
|
||||
do_orchestrator_package_restart(orchestrator.as_ref(), &to_restart).await
|
||||
} else {
|
||||
do_package_restart(&containers).await
|
||||
};
|
||||
match result {
|
||||
Ok(()) => {
|
||||
reconcile_companions_for(&companion_app_id).await;
|
||||
set_package_state(&state_manager, &package_id_owned, PackageState::Running)
|
||||
.await;
|
||||
}
|
||||
@@ -232,6 +256,15 @@ impl RpcHandler {
|
||||
// within ~10s of `podman rm`, leaving them orphaned post-uninstall.
|
||||
crate::container::companion::remove_for(package_id).await;
|
||||
|
||||
// Keep the production reconciler from recreating an app immediately
|
||||
// after uninstall. The reconciler owns a manifest map independent of
|
||||
// podman state, so a raw `podman rm` alone is not enough.
|
||||
if let Some(orchestrator) = &self.orchestrator {
|
||||
for app_id in orchestrator_uninstall_app_ids(package_id) {
|
||||
let _ = orchestrator.remove(&app_id, preserve_data).await;
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
@@ -576,6 +609,7 @@ async fn do_package_start(to_start: &[String]) -> Result<()> {
|
||||
if i > 0 {
|
||||
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
|
||||
}
|
||||
repair_before_package_start(name).await;
|
||||
tracing::info!("Starting container: {}", name);
|
||||
let out = tokio::process::Command::new("podman")
|
||||
.args(["start", name])
|
||||
@@ -585,6 +619,7 @@ async fn do_package_start(to_start: &[String]) -> Result<()> {
|
||||
if !out.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string();
|
||||
tracing::error!("Failed to start {}: {}", name, stderr);
|
||||
cleanup_start_conflict(name, &stderr).await;
|
||||
install_log(&format!("START FAIL: {} — {}", name, stderr)).await;
|
||||
errors.push(format!("{}: {}", name, stderr));
|
||||
}
|
||||
@@ -630,9 +665,86 @@ async fn do_package_start(to_start: &[String]) -> Result<()> {
|
||||
errors.join("; ")
|
||||
));
|
||||
}
|
||||
|
||||
for name in to_start {
|
||||
ensure_runtime_host_port_listener(name).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn do_orchestrator_package_start(
|
||||
orchestrator: &dyn crate::container::traits::ContainerOrchestrator,
|
||||
to_start: &[String],
|
||||
) -> Result<()> {
|
||||
let mut errors = Vec::new();
|
||||
for (i, name) in to_start.iter().enumerate() {
|
||||
if i > 0 {
|
||||
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
|
||||
}
|
||||
match orchestrator.start(name).await {
|
||||
Ok(()) => wait_after_orchestrator_start(name).await,
|
||||
Err(e) if is_unknown_app_id_error(&e) => {
|
||||
do_package_start(&[name.clone()]).await?;
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!(container = %name, error = %e, "orchestrator start failed");
|
||||
install_log(&format!("START FAIL: {} — {:#}", name, e)).await;
|
||||
errors.push(format!("{}: {:#}", name, e));
|
||||
}
|
||||
}
|
||||
}
|
||||
if errors.is_empty() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(anyhow::anyhow!("Start failed: {}", errors.join("; ")))
|
||||
}
|
||||
}
|
||||
|
||||
async fn wait_after_orchestrator_start(name: &str) {
|
||||
let delay = match name {
|
||||
"archy-btcpay-db" => 5,
|
||||
"archy-nbxplorer" => 8,
|
||||
_ => 0,
|
||||
};
|
||||
if delay > 0 {
|
||||
tokio::time::sleep(std::time::Duration::from_secs(delay)).await;
|
||||
}
|
||||
}
|
||||
|
||||
async fn do_orchestrator_package_stop(
|
||||
orchestrator: &dyn crate::container::traits::ContainerOrchestrator,
|
||||
containers: &[String],
|
||||
) -> Result<()> {
|
||||
let mut errors = Vec::new();
|
||||
for name in containers.iter().rev() {
|
||||
match orchestrator.stop(name).await {
|
||||
Ok(()) => {}
|
||||
Err(e) if is_unknown_app_id_error(&e) => {
|
||||
if let Err(e) = do_package_stop(&[name.clone()]).await {
|
||||
errors.push(format!("{}: {:#}", name, e));
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!(container = %name, error = %e, "orchestrator stop failed");
|
||||
errors.push(format!("{}: {:#}", name, e));
|
||||
}
|
||||
}
|
||||
}
|
||||
if errors.is_empty() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(anyhow::anyhow!("Stop failed: {}", errors.join("; ")))
|
||||
}
|
||||
}
|
||||
|
||||
async fn do_orchestrator_package_restart(
|
||||
orchestrator: &dyn crate::container::traits::ContainerOrchestrator,
|
||||
to_restart: &[String],
|
||||
) -> Result<()> {
|
||||
do_orchestrator_package_stop(orchestrator, to_restart).await?;
|
||||
do_orchestrator_package_start(orchestrator, to_restart).await
|
||||
}
|
||||
|
||||
/// Stop all containers with their per-container graceful-shutdown timeout.
|
||||
async fn do_package_stop(containers: &[String]) -> Result<()> {
|
||||
let mut errors = Vec::new();
|
||||
@@ -649,6 +761,10 @@ async fn do_package_stop(containers: &[String]) -> Result<()> {
|
||||
.context(format!("Failed to exec podman stop {}", name))?;
|
||||
if !out.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string();
|
||||
if is_missing_companion_ok(name, &stderr) {
|
||||
tracing::debug!(container = %name, "companion already absent during stop");
|
||||
continue;
|
||||
}
|
||||
tracing::error!("Failed to stop {}: {}", name, stderr);
|
||||
errors.push(format!("{}: {}", name, stderr));
|
||||
}
|
||||
@@ -665,6 +781,7 @@ async fn do_package_restart(containers: &[String]) -> Result<()> {
|
||||
let mut errors = Vec::new();
|
||||
for name in containers {
|
||||
tracing::info!("Restarting container: {}", name);
|
||||
repair_before_package_start(name).await;
|
||||
let out = tokio::process::Command::new("podman")
|
||||
.args(["restart", "-t", stop_timeout_secs(name), name])
|
||||
.output()
|
||||
@@ -673,6 +790,10 @@ async fn do_package_restart(containers: &[String]) -> Result<()> {
|
||||
|
||||
if !out.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string();
|
||||
if is_missing_companion_ok(name, &stderr) {
|
||||
tracing::debug!(container = %name, "companion absent during restart; reconcile will recreate it");
|
||||
continue;
|
||||
}
|
||||
tracing::warn!(
|
||||
"podman restart {} failed: {}, trying stop+start",
|
||||
name,
|
||||
@@ -692,12 +813,18 @@ async fn do_package_restart(containers: &[String]) -> Result<()> {
|
||||
let start_err = String::from_utf8_lossy(&start_out.stderr)
|
||||
.trim()
|
||||
.to_string();
|
||||
cleanup_start_conflict(name, &start_err).await;
|
||||
if is_missing_companion_ok(name, &start_err) {
|
||||
tracing::debug!(container = %name, "companion absent during restart fallback; reconcile will recreate it");
|
||||
continue;
|
||||
}
|
||||
tracing::error!("stop+start {} also failed: {}", name, start_err);
|
||||
errors.push(format!("{}: {}", name, start_err));
|
||||
} else {
|
||||
tracing::info!("Restarted {} via stop+start fallback", name);
|
||||
}
|
||||
}
|
||||
ensure_runtime_host_port_listener(name).await?;
|
||||
}
|
||||
if !errors.is_empty() {
|
||||
return Err(anyhow::anyhow!("Restart failed: {}", errors.join("; ")));
|
||||
@@ -705,6 +832,239 @@ async fn do_package_restart(containers: &[String]) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn is_unknown_app_id_error(err: &anyhow::Error) -> bool {
|
||||
err.chain()
|
||||
.any(|cause| cause.to_string().contains("unknown app_id"))
|
||||
}
|
||||
|
||||
async fn repair_before_package_start(container_name: &str) {
|
||||
match container_name {
|
||||
"btcpay-server" | "archy-nbxplorer" => repair_btcpay_dirs().await,
|
||||
"indeedhub-postgres" | "indeedhub-redis" | "indeedhub-minio" | "indeedhub-relay"
|
||||
| "indeedhub-api" | "indeedhub-ffmpeg" | "indeedhub" => repair_indeedhub_network().await,
|
||||
"grafana" => cleanup_stale_pasta_port("3000").await,
|
||||
"gitea" => cleanup_gitea_stale_ports().await,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
async fn ensure_runtime_host_port_listener(container_name: &str) -> Result<()> {
|
||||
let Some(port) = runtime_required_host_port(container_name) else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
if wait_for_runtime_host_port(port, 10).await {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
install_log(&format!(
|
||||
"START REPAIR: {} — host port {} missing after start; restarting container",
|
||||
container_name, port
|
||||
))
|
||||
.await;
|
||||
let output = tokio::process::Command::new("podman")
|
||||
.args(["restart", container_name])
|
||||
.output()
|
||||
.await
|
||||
.context("failed to restart container after missing host port")?;
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(anyhow::anyhow!(
|
||||
"Container {} host port {} was not listening and restart failed: {}",
|
||||
container_name,
|
||||
port,
|
||||
stderr.trim()
|
||||
));
|
||||
}
|
||||
|
||||
if wait_for_runtime_host_port(port, 60).await {
|
||||
install_log(&format!(
|
||||
"START REPAIR OK: {} — host port {} is listening after restart",
|
||||
container_name, port
|
||||
))
|
||||
.await;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
Err(anyhow::anyhow!(
|
||||
"Container {} is running but host port {} is not listening",
|
||||
container_name,
|
||||
port
|
||||
))
|
||||
}
|
||||
|
||||
fn runtime_required_host_port(container_name: &str) -> Option<u16> {
|
||||
match container_name {
|
||||
"grafana" => Some(3000),
|
||||
"searxng" => Some(8888),
|
||||
"uptime-kuma" => Some(3002),
|
||||
"gitea" => Some(3001),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
async fn wait_for_runtime_host_port(port: u16, timeout_secs: u64) -> bool {
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(timeout_secs);
|
||||
loop {
|
||||
if tokio::net::TcpStream::connect(("127.0.0.1", port))
|
||||
.await
|
||||
.is_ok()
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if std::time::Instant::now() >= deadline {
|
||||
return false;
|
||||
}
|
||||
|
||||
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
|
||||
}
|
||||
}
|
||||
|
||||
async fn repair_btcpay_dirs() {
|
||||
let _ = tokio::process::Command::new("sudo")
|
||||
.args([
|
||||
"mkdir",
|
||||
"-p",
|
||||
"/var/lib/archipelago/btcpay/Main",
|
||||
"/var/lib/archipelago/nbxplorer/Main",
|
||||
])
|
||||
.output()
|
||||
.await;
|
||||
for dir in [
|
||||
"/var/lib/archipelago/btcpay",
|
||||
"/var/lib/archipelago/nbxplorer",
|
||||
] {
|
||||
let _ = tokio::process::Command::new("sudo")
|
||||
.args(["chown", "-R", "1000:1000", dir])
|
||||
.output()
|
||||
.await;
|
||||
}
|
||||
repair_btcpay_database_password().await;
|
||||
}
|
||||
|
||||
async fn repair_btcpay_database_password() {
|
||||
let Ok(db_pass) =
|
||||
tokio::fs::read_to_string("/var/lib/archipelago/secrets/btcpay-db-password").await
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let db_pass = db_pass.trim();
|
||||
if db_pass.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let _ = tokio::process::Command::new("podman")
|
||||
.args(["start", "archy-btcpay-db"])
|
||||
.output()
|
||||
.await;
|
||||
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
|
||||
|
||||
let escaped = db_pass.replace('\'', "''");
|
||||
let sql = format!("ALTER USER btcpay WITH PASSWORD '{}';", escaped);
|
||||
let _ = tokio::process::Command::new("podman")
|
||||
.args([
|
||||
"exec",
|
||||
"archy-btcpay-db",
|
||||
"psql",
|
||||
"-U",
|
||||
"btcpay",
|
||||
"-d",
|
||||
"btcpay",
|
||||
"-c",
|
||||
&sql,
|
||||
])
|
||||
.output()
|
||||
.await;
|
||||
let _ = tokio::process::Command::new("podman")
|
||||
.args([
|
||||
"exec",
|
||||
"archy-btcpay-db",
|
||||
"createdb",
|
||||
"-U",
|
||||
"btcpay",
|
||||
"nbxplorer",
|
||||
])
|
||||
.output()
|
||||
.await;
|
||||
}
|
||||
|
||||
async fn repair_indeedhub_network() {
|
||||
super::stacks::repair_indeedhub_network_aliases().await;
|
||||
}
|
||||
|
||||
async fn cleanup_start_conflict(container_name: &str, stderr: &str) {
|
||||
if !stderr.contains("address already in use") && !stderr.contains("pasta failed") {
|
||||
return;
|
||||
}
|
||||
|
||||
if container_name == "gitea" {
|
||||
cleanup_gitea_stale_ports().await;
|
||||
return;
|
||||
}
|
||||
|
||||
if container_name != "grafana" {
|
||||
return;
|
||||
}
|
||||
|
||||
cleanup_stale_pasta_port("3000").await;
|
||||
}
|
||||
|
||||
async fn cleanup_stale_pasta_port(port: &str) {
|
||||
let kill_listener = format!(
|
||||
"ss -ltnp 'sport = :{}' 2>/dev/null | sed -n 's/.*pid=\\([0-9]*\\).*/\\1/p' | xargs -r kill 2>/dev/null || true",
|
||||
port
|
||||
);
|
||||
let _ = tokio::process::Command::new("sh")
|
||||
.args(["-c", &kill_listener])
|
||||
.output()
|
||||
.await;
|
||||
|
||||
let pattern = format!("pasta.*{}", port);
|
||||
let _ = tokio::process::Command::new("pkill")
|
||||
.args(["-f", &pattern])
|
||||
.output()
|
||||
.await;
|
||||
let pattern = format!("rootlessport.*{}", port);
|
||||
let _ = tokio::process::Command::new("pkill")
|
||||
.args(["-f", &pattern])
|
||||
.output()
|
||||
.await;
|
||||
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
|
||||
}
|
||||
|
||||
async fn cleanup_gitea_stale_ports() {
|
||||
for port in ["3001", "2222", "3000"] {
|
||||
let kill_listener = format!(
|
||||
"ss -ltnp 'sport = :{}' 2>/dev/null | sed -n 's/.*pid=\\([0-9]*\\).*/\\1/p' | xargs -r kill 2>/dev/null || true",
|
||||
port
|
||||
);
|
||||
let _ = tokio::process::Command::new("sh")
|
||||
.args(["-c", &kill_listener])
|
||||
.output()
|
||||
.await;
|
||||
|
||||
let pattern = format!("pasta.*{}", port);
|
||||
let _ = tokio::process::Command::new("pkill")
|
||||
.args(["-f", &pattern])
|
||||
.output()
|
||||
.await;
|
||||
let pattern = format!("rootlessport.*{}", port);
|
||||
let _ = tokio::process::Command::new("pkill")
|
||||
.args(["-f", &pattern])
|
||||
.output()
|
||||
.await;
|
||||
}
|
||||
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
|
||||
}
|
||||
|
||||
pub(super) fn is_missing_companion_ok(name: &str, stderr: &str) -> bool {
|
||||
matches!(
|
||||
name,
|
||||
"archy-bitcoin-ui" | "archy-lnd-ui" | "archy-electrs-ui"
|
||||
) && stderr.contains("no container with name or ID")
|
||||
}
|
||||
|
||||
/// Flip the primary package entry's state and return the pre-transition
|
||||
/// state for revert on error. Mirrors `transitional::flip_to_transitional`
|
||||
/// but lives here because the package path keys by `package_id` (which may
|
||||
@@ -738,3 +1098,41 @@ async fn set_package_state(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) async fn reconcile_companions_for(package_id: &str) {
|
||||
let app_ids = match package_id {
|
||||
"bitcoin" | "bitcoin-core" => vec!["bitcoin-core".to_string(), "bitcoin-ui".to_string()],
|
||||
"bitcoin-knots" => vec!["bitcoin-knots".to_string(), "bitcoin-ui".to_string()],
|
||||
"lnd" => vec!["lnd".to_string(), "lnd-ui".to_string()],
|
||||
"electrumx" | "electrs" | "mempool-electrs" => {
|
||||
vec!["electrumx".to_string(), "electrs-ui".to_string()]
|
||||
}
|
||||
_ => return,
|
||||
};
|
||||
for (companion, err) in crate::container::companion::reconcile(&app_ids).await {
|
||||
tracing::warn!(companion = %companion, error = %err, "companion reconcile failed");
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn orchestrator_uninstall_app_ids(package_id: &str) -> Vec<String> {
|
||||
match package_id {
|
||||
"bitcoin" | "bitcoin-core" => vec!["bitcoin-core".into(), "bitcoin-ui".into()],
|
||||
"bitcoin-knots" => vec!["bitcoin-knots".into(), "bitcoin-ui".into()],
|
||||
"lnd" => vec!["lnd".into(), "lnd-ui".into()],
|
||||
"electrumx" | "electrs" | "mempool-electrs" => {
|
||||
vec!["electrumx".into(), "electrs-ui".into()]
|
||||
}
|
||||
"mempool" | "mempool-web" => vec![
|
||||
"mempool-api".into(),
|
||||
"archy-mempool-web".into(),
|
||||
"archy-mempool-db".into(),
|
||||
],
|
||||
"btcpay-server" | "btcpayserver" | "btcpay" => vec![
|
||||
"btcpay-server".into(),
|
||||
"archy-nbxplorer".into(),
|
||||
"archy-btcpay-db".into(),
|
||||
],
|
||||
"fedimint" => vec!["fedimint".into(), "fedimint-gateway".into()],
|
||||
_ => vec![package_id.to_string()],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ use crate::data_model::InstallPhase;
|
||||
use anyhow::{Context, Result};
|
||||
use tracing::info;
|
||||
|
||||
use super::install::install_log;
|
||||
use super::install::{install_log, patch_indeedhub_nostr_provider};
|
||||
|
||||
/// Adopt an existing container stack: start all named containers and return success.
|
||||
/// Returns `Ok(Some(json))` if the primary container was found (adopted),
|
||||
@@ -40,6 +40,8 @@ async fn adopt_stack_if_exists(
|
||||
))
|
||||
.await;
|
||||
|
||||
repair_stack_before_adopt(stack_name).await;
|
||||
|
||||
for container in all_containers {
|
||||
if names.iter().any(|n| n == container) {
|
||||
let _ = tokio::process::Command::new("podman")
|
||||
@@ -55,6 +57,10 @@ async fn adopt_stack_if_exists(
|
||||
.collect();
|
||||
wait_for_stack_containers(stack_name, &existing, 60).await?;
|
||||
|
||||
if stack_name == "indeedhub" {
|
||||
patch_indeedhub_nostr_provider().await;
|
||||
}
|
||||
|
||||
install_log(&format!(
|
||||
"INSTALL ADOPT OK: {} — started existing containers",
|
||||
stack_name
|
||||
@@ -67,6 +73,76 @@ async fn adopt_stack_if_exists(
|
||||
})))
|
||||
}
|
||||
|
||||
async fn repair_stack_before_adopt(stack_name: &str) {
|
||||
match stack_name {
|
||||
"btcpay" | "btcpay-server" => {
|
||||
let _ = tokio::process::Command::new("sudo")
|
||||
.args([
|
||||
"mkdir",
|
||||
"-p",
|
||||
"/var/lib/archipelago/btcpay/Main",
|
||||
"/var/lib/archipelago/nbxplorer/Main",
|
||||
])
|
||||
.output()
|
||||
.await;
|
||||
let user = std::env::var("USER").unwrap_or_else(|_| "archipelago".to_string());
|
||||
for dir in [
|
||||
"/var/lib/archipelago/btcpay",
|
||||
"/var/lib/archipelago/nbxplorer",
|
||||
] {
|
||||
let _ = tokio::process::Command::new("sudo")
|
||||
.args(["chown", "-R", &format!("{}:{}", user, user), dir])
|
||||
.output()
|
||||
.await;
|
||||
}
|
||||
}
|
||||
"indeedhub" => repair_indeedhub_network_aliases().await,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
pub(in crate::api::rpc::package) async fn repair_indeedhub_network_aliases() {
|
||||
let _ = tokio::process::Command::new("podman")
|
||||
.args(["network", "create", "indeedhub-net"])
|
||||
.output()
|
||||
.await;
|
||||
|
||||
for (container, alias) in [
|
||||
("indeedhub-postgres", "postgres"),
|
||||
("indeedhub-redis", "redis"),
|
||||
("indeedhub-minio", "minio"),
|
||||
("indeedhub-relay", "relay"),
|
||||
("indeedhub-api", "api"),
|
||||
("indeedhub", "indeedhub"),
|
||||
] {
|
||||
let exists = tokio::process::Command::new("podman")
|
||||
.args(["container", "exists", container])
|
||||
.status()
|
||||
.await
|
||||
.map(|s| s.success())
|
||||
.unwrap_or(false);
|
||||
if !exists {
|
||||
continue;
|
||||
}
|
||||
|
||||
let _ = tokio::process::Command::new("podman")
|
||||
.args(["network", "disconnect", "-f", "indeedhub-net", container])
|
||||
.output()
|
||||
.await;
|
||||
let _ = tokio::process::Command::new("podman")
|
||||
.args([
|
||||
"network",
|
||||
"connect",
|
||||
"--alias",
|
||||
alias,
|
||||
"indeedhub-net",
|
||||
container,
|
||||
])
|
||||
.output()
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
async fn run_required_stack_command(
|
||||
stack_name: &str,
|
||||
label: &str,
|
||||
@@ -480,6 +556,12 @@ impl RpcHandler {
|
||||
|
||||
/// Install BTCPay stack (postgres + nbxplorer + btcpay-server).
|
||||
pub(super) async fn install_btcpay_stack(&self) -> Result<serde_json::Value> {
|
||||
if let Some(orchestrated) =
|
||||
install_stack_via_orchestrator(self, "btcpay-server", btcpay_stack_app_ids()).await?
|
||||
{
|
||||
return Ok(orchestrated);
|
||||
}
|
||||
|
||||
if let Some(adopted) = adopt_stack_if_exists(
|
||||
"btcpay-server",
|
||||
"btcpay",
|
||||
@@ -490,12 +572,6 @@ impl RpcHandler {
|
||||
return Ok(adopted);
|
||||
}
|
||||
|
||||
if let Some(orchestrated) =
|
||||
install_stack_via_orchestrator(self, "btcpay-server", btcpay_stack_app_ids()).await?
|
||||
{
|
||||
return Ok(orchestrated);
|
||||
}
|
||||
|
||||
// Dependency check: Bitcoin must be running
|
||||
let deps = super::dependencies::detect_running_deps().await?;
|
||||
super::dependencies::check_install_deps("btcpay-server", &deps)?;
|
||||
@@ -1231,6 +1307,10 @@ impl RpcHandler {
|
||||
"indeedhub-net",
|
||||
"--restart",
|
||||
"unless-stopped",
|
||||
"--tmpfs",
|
||||
"/run:rw,nosuid,nodev,size=16m",
|
||||
"--tmpfs",
|
||||
"/var/cache/nginx:rw,nosuid,nodev,size=32m",
|
||||
"-p",
|
||||
"7778:7777",
|
||||
&format!("{}/indeedhub:1.0.0", registry),
|
||||
@@ -1265,6 +1345,8 @@ impl RpcHandler {
|
||||
.await;
|
||||
self.clear_install_progress("indeedhub").await;
|
||||
|
||||
patch_indeedhub_nostr_provider().await;
|
||||
|
||||
install_log("INSTALL OK: indeedhub stack").await;
|
||||
info!("IndeedHub stack installed");
|
||||
Ok(serde_json::json!({
|
||||
|
||||
Reference in New Issue
Block a user