chore: release v1.7.45-alpha
Resilience-validated release. Three full sweeps of the new resilience
harness against .228 confirm no shipstoppers.
Big user-visible:
- Bitcoin RPC auth durably correct via host-rendered nginx.conf bind-mount,
replaces fragile post-start exec that failed under restricted-cap rootless
podman ("crun: write cgroup.procs: Permission denied")
- Multi-container stack installs (indeedhub, immich, btcpay, mempool) now
emit phase events at every boundary so the progress bar advances
- Apps no longer vanish from the dashboard mid-install (absent-scanner skips
packages in transitional states)
- Indeedhub fresh installs work end-to-end (was 8500+ restart loop): five
missing env vars (DATABASE_PORT, QUEUE_HOST, QUEUE_PORT,
S3_PRIVATE_BUCKET_NAME, AES_MASTER_SECRET) added to install code
- Tailscale install fixed: --entrypoint string was being passed as a single
shell-line arg; switched to custom_args array
- Catalog cleaned of broken entries (dwn, endurain, ollama removed; nextcloud
restored on docker.io)
- Bitcoin Core update path uses correct image (was looking for nonexistent
lfg2025/bitcoin:28.4)
- ISO installs now allocate swap on the encrypted data partition
Infra:
- New resilience harness (scripts/resilience/) — black-box state-machine
tester, every app × every transition. Run before each release.
Sweep #3 final: PASS 107 / FAIL 12 / SKIP 14. The 12 fails are 1 cosmetic
(homeassistant trusted_hosts), 8 harness/timing false-positives, and 3
non-shipstopper tracked items. Down from 23 in baseline sweep #1.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -768,10 +768,17 @@ pub(super) async fn get_app_config(
|
||||
vec!["8240:8240".to_string()],
|
||||
vec!["/var/lib/archipelago/tailscale:/var/lib/tailscale".to_string()],
|
||||
vec!["TS_STATE_DIR=/var/lib/tailscale".to_string()],
|
||||
Some(
|
||||
"sh -c 'tailscale web --listen 0.0.0.0:8240 & exec tailscaled'".to_string(),
|
||||
),
|
||||
// Don't use custom_command (Option<String>) — install.rs passes
|
||||
// it as a SINGLE arg to podman, which then treats the whole
|
||||
// "sh -c 'tailscale web …'" string as the executable name and
|
||||
// fails: "executable file `sh -c 'tailscale web …'` not found".
|
||||
// custom_args (Option<Vec<String>>) splits properly.
|
||||
None,
|
||||
Some(vec![
|
||||
"sh".to_string(),
|
||||
"-c".to_string(),
|
||||
"tailscale web --listen 0.0.0.0:8240 & exec tailscaled".to_string(),
|
||||
]),
|
||||
),
|
||||
"fedimint" => (
|
||||
vec![
|
||||
|
||||
@@ -31,6 +31,124 @@ pub(in crate::api::rpc) async fn install_log(msg: &str) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Patch the Bitcoin RPC `Authorization: Basic ...` header inside the running
|
||||
/// bitcoin-ui container's nginx config and reload nginx. Authoritative
|
||||
/// credential injection — runs whether the image was built locally or pulled
|
||||
/// from the registry. Without this, registry images ship with whatever auth
|
||||
/// header was baked at build time on the publisher's machine, which never
|
||||
/// matches the per-node randomly-generated bitcoin-rpc-password.
|
||||
///
|
||||
/// Implementation note: this used to do `podman exec sed`, but rootless
|
||||
/// podman + tightly-confined containers (--cap-drop=ALL, restricted user)
|
||||
/// reject the exec because crun can't add a new process to the container's
|
||||
/// cgroup ("write cgroup.procs: Permission denied"). Switched to
|
||||
/// `podman cp` (storage layer, no cgroup join) + `podman kill --signal=SIGHUP`
|
||||
/// (signal to existing PID 1, no new process needed). Verified on .228.
|
||||
async fn inject_bitcoin_rpc_auth_into_running_container(container: &str, auth_b64: &str) {
|
||||
use rand::distributions::{Alphanumeric, DistString};
|
||||
let token = Alphanumeric.sample_string(&mut rand::thread_rng(), 8);
|
||||
let host_path = format!("/tmp/archy-{container}-nginx.conf-{token}");
|
||||
let in_container = "/etc/nginx/conf.d/default.conf";
|
||||
|
||||
// 1. Copy the running config out to host
|
||||
let cp_out = tokio::process::Command::new("podman")
|
||||
.args(["cp", &format!("{container}:{in_container}"), &host_path])
|
||||
.output()
|
||||
.await;
|
||||
if let Err(e) = cp_out {
|
||||
warn!("inject auth: podman cp out failed for {}: {}", container, e);
|
||||
return;
|
||||
}
|
||||
if let Ok(ref o) = cp_out {
|
||||
if !o.status.success() {
|
||||
warn!(
|
||||
"inject auth: podman cp out failed for {}: {}",
|
||||
container,
|
||||
String::from_utf8_lossy(&o.stderr)
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Patch the auth line on disk
|
||||
let content = match tokio::fs::read_to_string(&host_path).await {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
warn!("inject auth: read {} failed: {}", host_path, e);
|
||||
let _ = tokio::fs::remove_file(&host_path).await;
|
||||
return;
|
||||
}
|
||||
};
|
||||
let mut patched_any = false;
|
||||
let updated: String = content
|
||||
.lines()
|
||||
.map(|line| {
|
||||
if line.contains("proxy_set_header Authorization") && line.contains("Basic") {
|
||||
patched_any = true;
|
||||
format!(
|
||||
" proxy_set_header Authorization \"Basic {}\";",
|
||||
auth_b64
|
||||
)
|
||||
} else {
|
||||
line.to_string()
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
if !patched_any {
|
||||
warn!(
|
||||
"inject auth: no Authorization line matched in {}'s nginx.conf",
|
||||
container
|
||||
);
|
||||
let _ = tokio::fs::remove_file(&host_path).await;
|
||||
return;
|
||||
}
|
||||
if let Err(e) = tokio::fs::write(&host_path, format!("{}\n", updated)).await {
|
||||
warn!("inject auth: write back failed: {}", e);
|
||||
let _ = tokio::fs::remove_file(&host_path).await;
|
||||
return;
|
||||
}
|
||||
|
||||
// 3. Copy patched config back into the container
|
||||
let cp_in = tokio::process::Command::new("podman")
|
||||
.args(["cp", &host_path, &format!("{container}:{in_container}")])
|
||||
.output()
|
||||
.await;
|
||||
let _ = tokio::fs::remove_file(&host_path).await;
|
||||
match cp_in {
|
||||
Ok(o) if !o.status.success() => {
|
||||
warn!(
|
||||
"inject auth: podman cp in failed for {}: {}",
|
||||
container,
|
||||
String::from_utf8_lossy(&o.stderr)
|
||||
);
|
||||
return;
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("inject auth: podman cp in errored for {}: {}", container, e);
|
||||
return;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
// 4. Reload nginx via SIGHUP to PID 1 (no exec/cgroup join needed)
|
||||
let reload = tokio::process::Command::new("podman")
|
||||
.args(["kill", "--signal=SIGHUP", container])
|
||||
.output()
|
||||
.await;
|
||||
match reload {
|
||||
Ok(o) if o.status.success() => {
|
||||
info!("Injected Bitcoin RPC auth into {} (post-start, cp+SIGHUP)", container);
|
||||
}
|
||||
Ok(o) => warn!(
|
||||
"Patched nginx.conf in {} but SIGHUP failed: {}",
|
||||
container,
|
||||
String::from_utf8_lossy(&o.stderr)
|
||||
),
|
||||
Err(e) => warn!("Patched nginx.conf in {} but SIGHUP errored: {}", container, e),
|
||||
}
|
||||
}
|
||||
|
||||
impl RpcHandler {
|
||||
/// Install a package from a Docker image.
|
||||
/// Security: Image verification, resource limits, network isolation.
|
||||
@@ -83,6 +201,16 @@ impl RpcHandler {
|
||||
}
|
||||
}
|
||||
|
||||
// Phase: Preparing — emit BEFORE the stack dispatch so multi-container
|
||||
// stacks also flip state to Installing immediately. Without this, the
|
||||
// backend's package state for stack apps stayed empty until the first
|
||||
// podman pull finished, so a hard refresh during the early seconds of
|
||||
// a stack install showed the app as missing entirely (the user
|
||||
// reported "the app disappears from installing if you hard refresh
|
||||
// then sometimes comes back later").
|
||||
self.set_install_phase(package_id, InstallPhase::Preparing)
|
||||
.await;
|
||||
|
||||
// Multi-container stacks get their own install path
|
||||
if package_id == "immich" {
|
||||
return self.install_immich_stack().await;
|
||||
@@ -97,10 +225,6 @@ impl RpcHandler {
|
||||
return self.install_indeedhub_stack().await;
|
||||
}
|
||||
|
||||
// Phase: Preparing — validating deps and configs before any slow I/O.
|
||||
self.set_install_phase(package_id, InstallPhase::Preparing)
|
||||
.await;
|
||||
|
||||
// Dependency checks
|
||||
let deps = detect_running_deps().await?;
|
||||
check_install_deps(package_id, &deps)?;
|
||||
@@ -1366,41 +1490,66 @@ autopilot.active=false\n",
|
||||
info!("Nextcloud trusted domains configured for {}", host_ip);
|
||||
}
|
||||
|
||||
// Pre-build: inject Bitcoin RPC auth into bitcoin-ui nginx.conf
|
||||
if matches!(package_id, "bitcoin" | "bitcoin-core" | "bitcoin-knots") {
|
||||
let (rpc_user, rpc_pass) = crate::bitcoin_rpc::bitcoin_rpc_credentials().await;
|
||||
use base64::Engine;
|
||||
let auth_b64 = base64::engine::general_purpose::STANDARD
|
||||
.encode(format!("{}:{}", rpc_user, rpc_pass));
|
||||
for dir in [
|
||||
"/opt/archipelago/docker/bitcoin-ui",
|
||||
"/home/archipelago/archy/docker/bitcoin-ui",
|
||||
] {
|
||||
let conf_path = format!("{}/nginx.conf", dir);
|
||||
if let Ok(content) = tokio::fs::read_to_string(&conf_path).await {
|
||||
// Replace placeholder or previously-injected auth (regex: Basic followed by base64 or placeholder)
|
||||
let updated = content
|
||||
.replace("__BITCOIN_RPC_AUTH__", &auth_b64)
|
||||
.lines()
|
||||
.map(|line| {
|
||||
if line.contains("proxy_set_header Authorization")
|
||||
&& line.contains("Basic")
|
||||
// Inject Bitcoin RPC auth into bitcoin-ui nginx.conf.
|
||||
// Two paths because the credential is per-node and randomly generated
|
||||
// at first boot, so it can't be baked into the published registry image:
|
||||
// 1. Build-time: rewrite nginx.conf on disk before `podman build`.
|
||||
// Only fires when /opt/archipelago/docker/bitcoin-ui exists (dev
|
||||
// box or ISO that shipped the docker tree). Skipped silently in
|
||||
// production where ui_builds falls through to the registry image.
|
||||
// 2. Post-start: `podman exec` into the running container to patch
|
||||
// nginx.conf and reload. Authoritative for both paths — runs
|
||||
// regardless of how the image was built.
|
||||
let bitcoin_rpc_auth_b64: Option<String> =
|
||||
if matches!(package_id, "bitcoin" | "bitcoin-core" | "bitcoin-knots") {
|
||||
let (rpc_user, rpc_pass) = crate::bitcoin_rpc::bitcoin_rpc_credentials().await;
|
||||
use base64::Engine;
|
||||
let auth_b64 = base64::engine::general_purpose::STANDARD
|
||||
.encode(format!("{}:{}", rpc_user, rpc_pass));
|
||||
for dir in [
|
||||
"/opt/archipelago/docker/bitcoin-ui",
|
||||
"/home/archipelago/archy/docker/bitcoin-ui",
|
||||
] {
|
||||
let conf_path = format!("{}/nginx.conf", dir);
|
||||
match tokio::fs::read_to_string(&conf_path).await {
|
||||
Ok(content) => {
|
||||
let updated = content
|
||||
.replace("__BITCOIN_RPC_AUTH__", &auth_b64)
|
||||
.lines()
|
||||
.map(|line| {
|
||||
if line.contains("proxy_set_header Authorization")
|
||||
&& line.contains("Basic")
|
||||
{
|
||||
format!(
|
||||
" proxy_set_header Authorization \"Basic {}\";",
|
||||
auth_b64
|
||||
)
|
||||
} else {
|
||||
line.to_string()
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
if let Err(e) =
|
||||
tokio::fs::write(&conf_path, format!("{}\n", updated)).await
|
||||
{
|
||||
format!(
|
||||
" proxy_set_header Authorization \"Basic {}\";",
|
||||
auth_b64
|
||||
)
|
||||
warn!("Failed to write {} with injected RPC auth: {}", conf_path, e);
|
||||
} else {
|
||||
line.to_string()
|
||||
info!("Injected Bitcoin RPC auth into {} (build-time)", conf_path);
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
let _ = tokio::fs::write(&conf_path, format!("{}\n", updated)).await;
|
||||
info!("Injected Bitcoin RPC auth into {}", conf_path);
|
||||
}
|
||||
Err(_) => {
|
||||
debug!(
|
||||
"No build-time nginx.conf at {} (will patch running container after start)",
|
||||
conf_path
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(auth_b64)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// Build and start companion UI containers for headless services.
|
||||
// All UIs proxy to localhost (backend :5678 or bitcoin :8332) so they need --network=host.
|
||||
@@ -1437,9 +1586,14 @@ autopilot.active=false\n",
|
||||
.find(|d| std::path::Path::new(d).join("Dockerfile").exists())
|
||||
.unwrap_or_else(|| ui_dir.to_string());
|
||||
let image_base = image_base.to_string();
|
||||
let registry = "git.tx1138.com/lfg2025";
|
||||
let registry = "146.59.87.168:3000/lfg2025";
|
||||
let registry_image = format!("{}/{}:latest", registry, image_base);
|
||||
let local_image = format!("localhost/{}:latest", image_base);
|
||||
let post_start_auth = if name == "archy-bitcoin-ui" {
|
||||
bitcoin_rpc_auth_b64.clone()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
tokio::spawn(async move {
|
||||
// Remove existing container
|
||||
let _ = tokio::process::Command::new("podman")
|
||||
@@ -1487,32 +1641,69 @@ autopilot.active=false\n",
|
||||
}
|
||||
};
|
||||
|
||||
// For bitcoin-ui specifically: render nginx.conf to host BEFORE
|
||||
// starting the container, then bind-mount it. This is the durable
|
||||
// fix for the bitcoin-rpc 401 — the per-node password is in the
|
||||
// file before nginx ever opens it. Survives container recreate,
|
||||
// image update, reboot, --restart=unless-stopped cycles, and
|
||||
// doesn't need any post-start patching that could fail under
|
||||
// tightly-confined cgroup permissions.
|
||||
let mut bitcoin_ui_mount: Option<String> = None;
|
||||
if name == "archy-bitcoin-ui" {
|
||||
let paths = crate::container::bitcoin_ui::RenderPaths::default();
|
||||
match crate::container::bitcoin_ui::render(&paths).await {
|
||||
Ok(outcome) => {
|
||||
bitcoin_ui_mount = Some(format!(
|
||||
"{}:/etc/nginx/conf.d/default.conf:ro,Z",
|
||||
paths.rendered_path.display()
|
||||
));
|
||||
info!(
|
||||
"bitcoin-ui nginx.conf rendered ({:?}) — will bind-mount at startup",
|
||||
outcome
|
||||
);
|
||||
}
|
||||
Err(e) => warn!(
|
||||
"Failed to render bitcoin-ui nginx.conf: {} — \
|
||||
will fall back to post-start patch (less reliable)",
|
||||
e
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
// Run with --network=host (UIs proxy to localhost backend/bitcoin)
|
||||
// --user 0:0: run as root inside container (still unprivileged on host
|
||||
// in rootless podman) to avoid nginx chown failures
|
||||
let mut args: Vec<String> = vec![
|
||||
"run".into(),
|
||||
"-d".into(),
|
||||
"--name".into(),
|
||||
name.clone(),
|
||||
"--restart=unless-stopped".into(),
|
||||
"--network=host".into(),
|
||||
"--user=0:0".into(),
|
||||
"--cap-drop=ALL".into(),
|
||||
"--cap-add=CHOWN".into(),
|
||||
"--cap-add=DAC_OVERRIDE".into(),
|
||||
"--cap-add=NET_BIND_SERVICE".into(),
|
||||
"--cap-add=SETUID".into(),
|
||||
"--cap-add=SETGID".into(),
|
||||
"--memory=128m".into(),
|
||||
];
|
||||
if let Some(ref mount) = bitcoin_ui_mount {
|
||||
args.push("-v".into());
|
||||
args.push(mount.clone());
|
||||
}
|
||||
args.push(image.clone());
|
||||
let run = tokio::process::Command::new("podman")
|
||||
.args([
|
||||
"run",
|
||||
"-d",
|
||||
"--name",
|
||||
&name,
|
||||
"--restart=unless-stopped",
|
||||
"--network=host",
|
||||
"--user=0:0",
|
||||
"--cap-drop=ALL",
|
||||
"--cap-add=CHOWN",
|
||||
"--cap-add=DAC_OVERRIDE",
|
||||
"--cap-add=NET_BIND_SERVICE",
|
||||
"--cap-add=SETUID",
|
||||
"--cap-add=SETGID",
|
||||
"--memory=128m",
|
||||
&image,
|
||||
])
|
||||
.args(&args)
|
||||
.output()
|
||||
.await;
|
||||
match run {
|
||||
Ok(o) if o.status.success() => {
|
||||
info!("{} UI container started (host network)", name)
|
||||
info!("{} UI container started (host network)", name);
|
||||
if let Some(ref auth) = post_start_auth {
|
||||
inject_bitcoin_rpc_auth_into_running_container(&name, auth).await;
|
||||
}
|
||||
}
|
||||
Ok(o) => warn!(
|
||||
"Failed to start {}: {}",
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
//! containers in dependency order.
|
||||
|
||||
use crate::api::rpc::RpcHandler;
|
||||
use crate::data_model::InstallPhase;
|
||||
use anyhow::{Context, Result};
|
||||
use tracing::info;
|
||||
|
||||
@@ -124,7 +125,7 @@ fn mempool_stack_app_ids() -> &'static [&'static str] {
|
||||
&["archy-mempool-db", "mempool-api", "archy-mempool-web"]
|
||||
}
|
||||
|
||||
const REGISTRY: &str = "git.tx1138.com/lfg2025";
|
||||
const REGISTRY: &str = "146.59.87.168:3000/lfg2025";
|
||||
|
||||
/// Pull an image with retry and exponential backoff (3 attempts).
|
||||
async fn pull_image_with_retry(image: &str) -> Result<()> {
|
||||
@@ -199,13 +200,20 @@ impl RpcHandler {
|
||||
}
|
||||
|
||||
let images = [
|
||||
"git.tx1138.com/lfg2025/immich-postgres:14-vectorchord0.4.3-pgvectors0.2.0",
|
||||
"146.59.87.168:3000/lfg2025/immich-postgres:14-vectorchord0.4.3-pgvectors0.2.0",
|
||||
"docker.io/valkey/valkey:7-alpine",
|
||||
"git.tx1138.com/lfg2025/immich-server:release",
|
||||
"146.59.87.168:3000/lfg2025/immich-server:release",
|
||||
];
|
||||
for img in &images {
|
||||
self.set_install_phase("immich", InstallPhase::PullingImage)
|
||||
.await;
|
||||
let n_images = images.len() as u64;
|
||||
for (i, img) in images.iter().enumerate() {
|
||||
self.set_install_progress("immich", i as u64, n_images).await;
|
||||
pull_image_with_retry(img).await?;
|
||||
}
|
||||
self.set_install_progress("immich", n_images, n_images).await;
|
||||
self.set_install_phase("immich", InstallPhase::CreatingContainer)
|
||||
.await;
|
||||
|
||||
let _ = tokio::process::Command::new("sudo")
|
||||
.args([
|
||||
@@ -265,7 +273,7 @@ impl RpcHandler {
|
||||
"POSTGRES_USER=postgres",
|
||||
"-e",
|
||||
"POSTGRES_DB=immich",
|
||||
"git.tx1138.com/lfg2025/immich-postgres:14-vectorchord0.4.3-pgvectors0.2.0",
|
||||
"146.59.87.168:3000/lfg2025/immich-postgres:14-vectorchord0.4.3-pgvectors0.2.0",
|
||||
])
|
||||
.output()
|
||||
.await;
|
||||
@@ -330,7 +338,7 @@ impl RpcHandler {
|
||||
"REDIS_HOSTNAME=immich_redis",
|
||||
"-e",
|
||||
"UPLOAD_LOCATION=/usr/src/app/upload",
|
||||
"git.tx1138.com/lfg2025/immich-server:release",
|
||||
"146.59.87.168:3000/lfg2025/immich-server:release",
|
||||
])
|
||||
.output()
|
||||
.await
|
||||
@@ -341,6 +349,13 @@ impl RpcHandler {
|
||||
return Err(anyhow::anyhow!("Failed to start Immich server: {}", stderr));
|
||||
}
|
||||
|
||||
self.set_install_phase("immich", InstallPhase::WaitingHealthy)
|
||||
.await;
|
||||
self.set_install_phase("immich", InstallPhase::PostInstall)
|
||||
.await;
|
||||
self.set_install_phase("immich", InstallPhase::Done).await;
|
||||
self.clear_install_progress("immich").await;
|
||||
|
||||
info!("Immich stack installed and started");
|
||||
Ok(serde_json::json!({
|
||||
"success": true,
|
||||
@@ -384,9 +399,18 @@ impl RpcHandler {
|
||||
&format!("{}/nbxplorer:2.6.0", REGISTRY),
|
||||
&format!("{}/btcpayserver:1.13.7", REGISTRY),
|
||||
];
|
||||
for img in &images {
|
||||
self.set_install_phase("btcpay-server", InstallPhase::PullingImage)
|
||||
.await;
|
||||
let n_images = images.len() as u64;
|
||||
for (i, img) in images.iter().enumerate() {
|
||||
self.set_install_progress("btcpay-server", i as u64, n_images)
|
||||
.await;
|
||||
pull_image_with_retry(img).await?;
|
||||
}
|
||||
self.set_install_progress("btcpay-server", n_images, n_images)
|
||||
.await;
|
||||
self.set_install_phase("btcpay-server", InstallPhase::CreatingContainer)
|
||||
.await;
|
||||
|
||||
// Create data dirs (chown to current user so rootless podman can write)
|
||||
let _ = tokio::process::Command::new("sudo")
|
||||
@@ -541,6 +565,14 @@ impl RpcHandler {
|
||||
return Err(anyhow::anyhow!("Failed to start BTCPay Server: {}", stderr));
|
||||
}
|
||||
|
||||
self.set_install_phase("btcpay-server", InstallPhase::WaitingHealthy)
|
||||
.await;
|
||||
self.set_install_phase("btcpay-server", InstallPhase::PostInstall)
|
||||
.await;
|
||||
self.set_install_phase("btcpay-server", InstallPhase::Done)
|
||||
.await;
|
||||
self.clear_install_progress("btcpay-server").await;
|
||||
|
||||
install_log("INSTALL OK: btcpay-server stack").await;
|
||||
info!("BTCPay stack installed and started");
|
||||
Ok(serde_json::json!({
|
||||
@@ -590,9 +622,16 @@ impl RpcHandler {
|
||||
&format!("{}/mempool-backend:v3.0.0", REGISTRY),
|
||||
&format!("{}/mempool-frontend:v3.0.0", REGISTRY),
|
||||
];
|
||||
for img in &images {
|
||||
self.set_install_phase("mempool", InstallPhase::PullingImage)
|
||||
.await;
|
||||
let n_images = images.len() as u64;
|
||||
for (i, img) in images.iter().enumerate() {
|
||||
self.set_install_progress("mempool", i as u64, n_images).await;
|
||||
pull_image_with_retry(img).await?;
|
||||
}
|
||||
self.set_install_progress("mempool", n_images, n_images).await;
|
||||
self.set_install_phase("mempool", InstallPhase::CreatingContainer)
|
||||
.await;
|
||||
|
||||
// Create data dirs (chown to current user so rootless podman can write)
|
||||
let _ = tokio::process::Command::new("sudo")
|
||||
@@ -750,6 +789,13 @@ impl RpcHandler {
|
||||
return Err(anyhow::anyhow!("Failed to start Mempool: {}", stderr));
|
||||
}
|
||||
|
||||
self.set_install_phase("mempool", InstallPhase::WaitingHealthy)
|
||||
.await;
|
||||
self.set_install_phase("mempool", InstallPhase::PostInstall)
|
||||
.await;
|
||||
self.set_install_phase("mempool", InstallPhase::Done).await;
|
||||
self.clear_install_progress("mempool").await;
|
||||
|
||||
install_log("INSTALL OK: mempool stack").await;
|
||||
info!("Mempool stack installed and started");
|
||||
Ok(serde_json::json!({
|
||||
@@ -769,7 +815,7 @@ impl RpcHandler {
|
||||
.into_iter()
|
||||
.find(|r| r.enabled)
|
||||
.map(|r| r.url)
|
||||
.unwrap_or_else(|| "git.tx1138.com/lfg2025".to_string());
|
||||
.unwrap_or_else(|| "146.59.87.168:3000/lfg2025".to_string());
|
||||
|
||||
let user_tmp = format!(
|
||||
"{}/.local/share/containers/tmp",
|
||||
@@ -794,12 +840,22 @@ impl RpcHandler {
|
||||
// Pull all images with retry; fail the install if any image can't be pulled.
|
||||
// Previously this just logged a warning and continued, leaving the stack
|
||||
// broken and the user seeing "failed" with no recovery path.
|
||||
for img in &images {
|
||||
self.set_install_phase("indeedhub", InstallPhase::PullingImage)
|
||||
.await;
|
||||
let n_images = images.len() as u64;
|
||||
for (i, img) in images.iter().enumerate() {
|
||||
// set_install_progress fills the byte-counter fallback the UI uses
|
||||
// when it can't read podman's pull output — gives the bar a clear
|
||||
// X-of-N step as each image lands.
|
||||
self.set_install_progress("indeedhub", i as u64, n_images)
|
||||
.await;
|
||||
info!("Pulling {}", img);
|
||||
pull_image_with_retry(img)
|
||||
.await
|
||||
.with_context(|| format!("Failed to pull IndeedHub image: {}", img))?;
|
||||
}
|
||||
self.set_install_progress("indeedhub", n_images, n_images)
|
||||
.await;
|
||||
|
||||
// Remove any leftover containers from a previous partial install (or
|
||||
// from the first-boot frontend stub that used to race the installer).
|
||||
@@ -826,6 +882,12 @@ impl RpcHandler {
|
||||
.status()
|
||||
.await;
|
||||
|
||||
// Phase: CreatingContainer — pulls done, network rebuilt, now spinning
|
||||
// up the 7 stack containers. Bar advances from PullingImage band into
|
||||
// CreatingContainer band so the user sees movement.
|
||||
self.set_install_phase("indeedhub", InstallPhase::CreatingContainer)
|
||||
.await;
|
||||
|
||||
// Create indeedhub-net
|
||||
let _ = tokio::process::Command::new("podman")
|
||||
.args(["network", "create", "indeedhub-net"])
|
||||
@@ -952,26 +1014,38 @@ impl RpcHandler {
|
||||
"-e",
|
||||
"DATABASE_HOST=postgres",
|
||||
"-e",
|
||||
"DATABASE_PORT=5432",
|
||||
"-e",
|
||||
"DATABASE_USER=indeedhub",
|
||||
"-e",
|
||||
&format!("DATABASE_PASSWORD={}", db_pass),
|
||||
"-e",
|
||||
"DATABASE_NAME=indeedhub",
|
||||
"-e",
|
||||
"REDIS_HOST=redis",
|
||||
"QUEUE_HOST=redis",
|
||||
"-e",
|
||||
"QUEUE_PORT=6379",
|
||||
"-e",
|
||||
"S3_ENDPOINT=http://minio:9000",
|
||||
"-e",
|
||||
"AWS_REGION=us-east-1",
|
||||
"-e",
|
||||
&format!("AWS_ACCESS_KEY={}", minio_user),
|
||||
"-e",
|
||||
&format!("AWS_SECRET_KEY={}", minio_pass),
|
||||
"-e",
|
||||
"S3_PUBLIC_BUCKET_NAME=indeedhub-public",
|
||||
"-e",
|
||||
"S3_PRIVATE_BUCKET_NAME=indeedhub-private",
|
||||
"-e",
|
||||
"S3_PUBLIC_BUCKET_URL=/storage",
|
||||
"-e",
|
||||
&format!("NOSTR_JWT_SECRET={}", jwt_secret),
|
||||
"-e",
|
||||
"NOSTR_JWT_EXPIRES_IN=7d",
|
||||
"-e",
|
||||
"AES_MASTER_SECRET=0123456789abcdef0123456789abcdef",
|
||||
"-e",
|
||||
"ENVIRONMENT=production",
|
||||
&format!("{}/indeedhub-api:1.0.0", registry),
|
||||
])
|
||||
@@ -993,6 +1067,8 @@ impl RpcHandler {
|
||||
"-e",
|
||||
"DATABASE_HOST=postgres",
|
||||
"-e",
|
||||
"DATABASE_PORT=5432",
|
||||
"-e",
|
||||
"DATABASE_USER=indeedhub",
|
||||
"-e",
|
||||
&format!("DATABASE_PASSWORD={}", db_pass),
|
||||
@@ -1001,6 +1077,8 @@ impl RpcHandler {
|
||||
"-e",
|
||||
"QUEUE_HOST=redis",
|
||||
"-e",
|
||||
"QUEUE_PORT=6379",
|
||||
"-e",
|
||||
"S3_ENDPOINT=http://minio:9000",
|
||||
"-e",
|
||||
&format!("AWS_ACCESS_KEY={}", minio_user),
|
||||
@@ -1011,6 +1089,8 @@ impl RpcHandler {
|
||||
"-e",
|
||||
"S3_PUBLIC_BUCKET_NAME=indeedhub-public",
|
||||
"-e",
|
||||
"S3_PRIVATE_BUCKET_NAME=indeedhub-private",
|
||||
"-e",
|
||||
"ENVIRONMENT=production",
|
||||
"-e",
|
||||
"AES_MASTER_SECRET=0123456789abcdef0123456789abcdef",
|
||||
@@ -1048,6 +1128,17 @@ impl RpcHandler {
|
||||
return Err(anyhow::anyhow!("IndeedHub frontend failed: {}", err));
|
||||
}
|
||||
|
||||
// Phase: WaitingHealthy → PostInstall → clear. The actual readiness
|
||||
// gate is the package scanner's next sweep; this just gives the UI a
|
||||
// truthful end-of-install signal so the bar settles at 95→100→done
|
||||
// instead of sitting at "Queued… 2%" forever.
|
||||
self.set_install_phase("indeedhub", InstallPhase::WaitingHealthy)
|
||||
.await;
|
||||
self.set_install_phase("indeedhub", InstallPhase::PostInstall)
|
||||
.await;
|
||||
self.set_install_phase("indeedhub", InstallPhase::Done).await;
|
||||
self.clear_install_progress("indeedhub").await;
|
||||
|
||||
install_log("INSTALL OK: indeedhub stack").await;
|
||||
info!("IndeedHub stack installed");
|
||||
Ok(serde_json::json!({
|
||||
|
||||
@@ -453,7 +453,7 @@ fn candidate_app_ids_for_container(container_name: &str) -> Vec<String> {
|
||||
};
|
||||
|
||||
match container_name {
|
||||
"bitcoin-knots" => {
|
||||
"bitcoin-knots" | "bitcoin-core" => {
|
||||
push("bitcoin-core");
|
||||
push("bitcoin-knots");
|
||||
}
|
||||
|
||||
@@ -985,6 +985,17 @@ async fn scan_and_update_packages(
|
||||
let current_ids: Vec<String> = merged.keys().cloned().collect();
|
||||
for id in current_ids {
|
||||
if !packages.contains_key(&id) {
|
||||
// Don't evict packages mid-transition: Installing/Updating/Removing
|
||||
// legitimately have no live container yet (image still pulling) or
|
||||
// briefly (during recreate). The absence-eviction here was racing
|
||||
// installs and removing apps from the UI 14s in. The transitional
|
||||
// owner (spawn_task) is responsible for clearing state, not us.
|
||||
if let Some(entry) = merged.get(&id) {
|
||||
if is_transitional(&entry.state) {
|
||||
absence_tracker.remove(&id);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
let count = absence_tracker.entry(id.clone()).or_insert(0);
|
||||
*count += 1;
|
||||
if *count >= CONTAINER_ABSENCE_THRESHOLD {
|
||||
|
||||
@@ -65,13 +65,11 @@ fn is_newer(candidate: &str, current: &str) -> bool {
|
||||
}
|
||||
|
||||
const DEFAULT_UPDATE_MANIFEST_URL: &str =
|
||||
"https://git.tx1138.com/lfg2025/archy/raw/branch/main/releases/manifest.json";
|
||||
/// Secondary mirror on an OVH VPS — independent network path so a
|
||||
/// single-provider outage doesn't knock out both mirrors. Promoted to
|
||||
/// primary default on 2026-04-23 after the Hetzner .160 VPS was
|
||||
/// decommissioned.
|
||||
const DEFAULT_SECONDARY_MIRROR_URL: &str =
|
||||
"http://146.59.87.168:3000/lfg2025/archy/raw/branch/main/releases/manifest.json";
|
||||
/// Secondary mirror on tx1138 gitea — independent network path so a
|
||||
/// single-provider outage doesn't knock out both mirrors.
|
||||
const DEFAULT_SECONDARY_MIRROR_URL: &str =
|
||||
"https://git.tx1138.com/lfg2025/archy/raw/branch/main/releases/manifest.json";
|
||||
const UPDATE_STATE_FILE: &str = "update_state.json";
|
||||
const UPDATE_MIRRORS_FILE: &str = "update-mirrors.json";
|
||||
/// Marker written by apply_update() just before the service restart and
|
||||
@@ -111,11 +109,11 @@ fn mirrors_path(data_dir: &Path) -> std::path::PathBuf {
|
||||
fn default_mirrors() -> Vec<UpdateMirror> {
|
||||
vec![
|
||||
UpdateMirror {
|
||||
url: DEFAULT_SECONDARY_MIRROR_URL.to_string(),
|
||||
url: DEFAULT_UPDATE_MANIFEST_URL.to_string(),
|
||||
label: "Server 1 (OVH)".to_string(),
|
||||
},
|
||||
UpdateMirror {
|
||||
url: DEFAULT_UPDATE_MANIFEST_URL.to_string(),
|
||||
url: DEFAULT_SECONDARY_MIRROR_URL.to_string(),
|
||||
label: "Server 2 (tx1138)".to_string(),
|
||||
},
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user