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:
archipelago
2026-04-29 12:31:45 -04:00
parent 6c970dc969
commit dacdab9f6e
38 changed files with 1699 additions and 1805 deletions

View File

@@ -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![

View File

@@ -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 {}: {}",

View File

@@ -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!({

View File

@@ -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");
}

View File

@@ -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 {

View File

@@ -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(),
},
]