Merge remote-tracking branch 'origin/main' into ark-merge

This commit is contained in:
Dorian
2026-07-14 22:08:55 +01:00
142 changed files with 7738 additions and 1336 deletions
@@ -400,10 +400,7 @@ pub async fn refresh_catalog(data_dir: &Path) -> anyhow::Result<CatalogRefresh>
Err(last_err.unwrap_or_else(|| anyhow::anyhow!("no catalog mirrors reachable")))
}
async fn fetch_one(
client: &reqwest::Client,
url: &str,
) -> anyhow::Result<(AppCatalog, String)> {
async fn fetch_one(client: &reqwest::Client, url: &str) -> anyhow::Result<(AppCatalog, String)> {
let resp = client.get(url).send().await?;
if !resp.status().is_success() {
anyhow::bail!("HTTP {}", resp.status());
@@ -510,7 +507,10 @@ mod tests {
// on the apps HashMap's nondeterministic key order (seen live on .228).
let dir = tempfile::tempdir().unwrap();
let body = r#"{"schema":1,"apps":{"demo":{"version":"1.0.0"}}}"#;
assert!(write_cache(dir.path(), body).unwrap(), "first write is a change");
assert!(
write_cache(dir.path(), body).unwrap(),
"first write is a change"
);
assert!(
!write_cache(dir.path(), body).unwrap(),
"identical rewrite is not a change"
@@ -547,48 +547,6 @@ fn get_app_metadata(app_id: &str) -> AppMetadata {
repo: "https://botfights.net".to_string(),
tier: "",
},
"nwnn" => AppMetadata {
title: "Next Web News Network".to_string(),
description: "Decentralized news and link aggregator, synced from Telegram".to_string(),
icon: "/assets/img/app-icons/nwnn.png".to_string(),
repo: "https://nwnn.l484.com".to_string(),
tier: "",
},
"484-kitchen" => AppMetadata {
title: "484 Kitchen".to_string(),
description: "K484 application platform".to_string(),
icon: "/assets/img/app-icons/484-kitchen.png".to_string(),
repo: "https://484.kitchen".to_string(),
tier: "",
},
"call-the-operator" => AppMetadata {
title: "Call the Operator".to_string(),
description: "Escape the Matrix — explore decentralized alternatives".to_string(),
icon: "/assets/img/app-icons/call-the-operator.png".to_string(),
repo: "https://cta.tx1138.com".to_string(),
tier: "",
},
"arch-presentation" => AppMetadata {
title: "Arch Presentation".to_string(),
description: "Archipelago: The Future of Decentralized Infrastructure".to_string(),
icon: "/assets/img/app-icons/arch-presentation.png".to_string(),
repo: "https://present.l484.com".to_string(),
tier: "",
},
"syntropy-institute" => AppMetadata {
title: "Syntropy Institute".to_string(),
description: "Medicine Reimagined — frequency analysis-therapy and digital homeopathy".to_string(),
icon: "/assets/img/app-icons/syntropy-institute.png".to_string(),
repo: "https://syntropy.institute".to_string(),
tier: "",
},
"t-zero" => AppMetadata {
title: "T-0".to_string(),
description: "Documentary series on decentralization, Bitcoin, and the ungovernable future".to_string(),
icon: "/assets/img/app-icons/t-zero.png".to_string(),
repo: "https://teeminuszero.net".to_string(),
tier: "",
},
_ => AppMetadata {
title: app_id.to_string(),
description: format!("{} application", app_id),
@@ -7,12 +7,8 @@
/// Registries images may be pulled from with an explicit host part.
/// (git.tx1138.com was removed 2026-07-10: the host is retired and must
/// never be pulled through again.)
pub const TRUSTED_REGISTRIES: &[&str] = &[
"docker.io",
"ghcr.io",
"localhost",
"146.59.87.168:3000",
];
pub const TRUSTED_REGISTRIES: &[&str] =
&["docker.io", "ghcr.io", "localhost", "146.59.87.168:3000"];
/// Validate a container image reference.
///
+14 -8
View File
@@ -727,14 +727,12 @@ pub async fn ensure_btcpay_lnd_connection_secret(secrets_dir: &std::path::Path)
Ok(s) => s,
Err(_) => return Ok(()), // LND not installed/provisioned yet
};
let thumbprint =
cert_sha256_thumbprint(&pem).context("computing LND tls.cert thumbprint")?;
let thumbprint = cert_sha256_thumbprint(&pem).context("computing LND tls.cert thumbprint")?;
let target = secrets_dir.join(BTCPAY_LND_CONNECTION_SECRET);
// Fast path (no sudo): existing secret already pins the current cert.
if let Ok(existing) = fs::read_to_string(&target).await {
if !existing.trim().is_empty()
&& existing.contains(&format!("certthumbprint={thumbprint}"))
if !existing.trim().is_empty() && existing.contains(&format!("certthumbprint={thumbprint}"))
{
return Ok(());
}
@@ -782,7 +780,9 @@ mod tests {
conf_path: tmp.path().join("lnd/lnd.conf"),
};
let out = ensure_config(&paths, "secret", "bitcoin-knots").await.unwrap();
let out = ensure_config(&paths, "secret", "bitcoin-knots")
.await
.unwrap();
assert_eq!(out, EnsureOutcome::Written);
let conf = fs::read_to_string(&paths.conf_path).await.unwrap();
assert!(conf.contains("bitcoin.active=true"));
@@ -801,11 +801,15 @@ mod tests {
};
assert_eq!(
ensure_config(&paths, "first", "bitcoin-knots").await.unwrap(),
ensure_config(&paths, "first", "bitcoin-knots")
.await
.unwrap(),
EnsureOutcome::Written
);
assert_eq!(
ensure_config(&paths, "second", "bitcoin-knots").await.unwrap(),
ensure_config(&paths, "second", "bitcoin-knots")
.await
.unwrap(),
EnsureOutcome::Written
);
let conf = fs::read_to_string(&paths.conf_path).await.unwrap();
@@ -854,7 +858,9 @@ mod tests {
.unwrap();
assert_eq!(
ensure_config(&paths, "repaired", "bitcoin-knots").await.unwrap(),
ensure_config(&paths, "repaired", "bitcoin-knots")
.await
.unwrap(),
EnsureOutcome::Written
);
let conf = fs::read_to_string(&paths.conf_path).await.unwrap();
@@ -50,6 +50,18 @@ use crate::update::host_sudo;
const UI_APP_IDS: &[&str] = &["bitcoin-ui", "electrs-ui", "lnd-ui"];
const ARCHIVAL_BITCOIN_DISK_GB: u64 = 1000;
/// Apps expected to exist from first boot on every node — the ONLY apps the
/// boot reconciler may install from nothing. Every other app needs
/// installation evidence (an existing container, or the was-running snapshot
/// handled by the caller's desired-state recovery). Without this gate,
/// "manifest loaded" counted as "installed" — and since the catalog + ISO ship
/// manifests for EVERY app, a fresh node mass-installed the entire catalog
/// (framework node 2026-07-14: portainer/vaultwarden/searxng/strfry/mempool/
/// fedimint appeared uninvited within an hour of first boot).
fn is_required_baseline_app(app_id: &str) -> bool {
matches!(app_id, "filebrowser" | "fedimint-clientd")
}
fn is_restart_sensitive_app(app_id: &str) -> bool {
matches!(
app_id,
@@ -1456,9 +1468,7 @@ impl ProdContainerOrchestrator {
);
}
}
for (port, a, b) in
host_port_collisions(state.manifests.values().map(|lm| &lm.manifest))
{
for (port, a, b) in host_port_collisions(state.manifests.values().map(|lm| &lm.manifest)) {
tracing::error!(
port,
app_a = %a,
@@ -1654,7 +1664,10 @@ impl ProdContainerOrchestrator {
// and left the unit down for minutes (.228 mempool frontend, gate
// 2026-07-09). Skip this cycle; the worker owns the outcome.
if crate::app_ops::lifecycle_op_in_flight(&app_id) {
report.record(&app_id, ReconcileAction::Left("lifecycle-op-in-flight".into()));
report.record(
&app_id,
ReconcileAction::Left("lifecycle-op-in-flight".into()),
);
crate::crash_recovery::pending_boot_start_done(&app_id);
crate::crash_recovery::pending_boot_start_done(&container_name);
continue;
@@ -1761,10 +1774,9 @@ impl ProdContainerOrchestrator {
{
let state = self.state.read().await;
for (app, dep) in degraded_running_apps(
&report,
state.manifests.values().map(|lm| &lm.manifest),
) {
for (app, dep) in
degraded_running_apps(&report, state.manifests.values().map(|lm| &lm.manifest))
{
tracing::error!(
app_id = %app,
dependency = %dep,
@@ -2197,20 +2209,25 @@ impl ProdContainerOrchestrator {
return Ok(ReconcileAction::Started);
}
// By this point `app_id` is neither user-stopped nor
// user-uninstalled (both checked earlier in this fn) and its
// manifest is still loaded — i.e. it's a genuinely-installed
// app whose container is simply gone (crash, lost record,
// wedged teardown cleared by reboot). It must self-heal
// regardless of whether it happens to be one of the hardcoded
// "required baseline" apps: an app the user installed and
// never removed should come back on its own, the same as
// baseline services always have. `is_required_baseline_app`
// used to gate this and left every other installed-but-absent
// app (e.g. a stack's backend containers) stuck forever.
// Container absent. A loaded manifest is NOT installation
// evidence — the catalog and the ISO ship manifests for every
// app, installed or not. In ExistingOnly (boot) mode only two
// things may create a container from nothing:
// 1. required-baseline apps (first-boot bootstrap), here;
// 2. desired-state recovery for apps whose container was
// running at the last snapshot — the caller matches
// Left("absent") against the snapshot and recreates
// (this is what heals a stack backend that vanished:
// the indeedhub/immich cases).
// Everything else stays absent. Installing on manifest
// presence alone mass-installed the whole catalog on a fresh
// node (framework, 2026-07-14).
if mode == ReconcileMode::ExistingOnly {
self.install_fresh(lm).await?;
return Ok(ReconcileAction::Installed);
if is_required_baseline_app(&app_id) {
self.install_fresh(lm).await?;
return Ok(ReconcileAction::Installed);
}
return Ok(ReconcileAction::Left("absent".to_string()));
}
self.install_fresh(lm).await?;
Ok(ReconcileAction::Installed)
@@ -3167,8 +3184,7 @@ impl ProdContainerOrchestrator {
// `optional` secret_env — btcpay must still start when LND is
// absent or the derivation fails, so log-and-continue.
if let Err(e) =
crate::container::lnd::ensure_btcpay_lnd_connection_secret(&self.secrets_dir)
.await
crate::container::lnd::ensure_btcpay_lnd_connection_secret(&self.secrets_dir).await
{
tracing::warn!(error = %e, "btcpay-lnd-connection secret not generated; btcpay will run without the internal LND node");
}
@@ -3243,20 +3259,17 @@ impl ProdContainerOrchestrator {
manifest.app.container.secret_env_refs = Vec::new();
manifest.app.container.secret_env_hash = None;
} else {
let hash =
archipelago_container::manifest::secret_env_content_hash(&secret_bearing);
let hash = archipelago_container::manifest::secret_env_content_hash(&secret_bearing);
let app_id = manifest.app.id.clone();
manifest.app.container.secret_env_refs = secret_bearing
.into_iter()
.map(|(key, value)| archipelago_container::manifest::SecretEnvRef {
secret_name: format!(
"archy-env-{}-{}",
app_id,
key.to_ascii_lowercase()
),
env_key: key,
value,
})
.map(
|(key, value)| archipelago_container::manifest::SecretEnvRef {
secret_name: format!("archy-env-{}-{}", app_id, key.to_ascii_lowercase()),
env_key: key,
value,
},
)
.collect();
manifest.app.container.secret_env_hash = Some(hash.clone());
@@ -3264,12 +3277,7 @@ impl ProdContainerOrchestrator {
// the steady-state reconcile free: podman is only consulted when
// the resolved content actually changed (or on first touch after
// boot). Mock runtimes no-op via the trait default.
let cached = self
.env_secret_cache
.lock()
.await
.get(&app_id)
.cloned();
let cached = self.env_secret_cache.lock().await.get(&app_id).cloned();
if cached.as_deref() != Some(hash.as_str()) {
self.runtime
.ensure_env_secrets(&manifest.app.container.secret_env_refs)
@@ -3909,7 +3917,10 @@ impl ContainerOrchestrator for ProdContainerOrchestrator {
async fn start(&self, app_id: &str) -> Result<()> {
if let Some(members) = self.mempool_umbrella_members(app_id).await {
tracing::info!(app_id, "starting legacy umbrella id via split-stack members");
tracing::info!(
app_id,
"starting legacy umbrella id via split-stack members"
);
for (i, member) in members.iter().enumerate() {
if i > 0 {
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
@@ -3952,7 +3963,10 @@ impl ContainerOrchestrator for ProdContainerOrchestrator {
async fn stop(&self, app_id: &str) -> Result<()> {
if let Some(members) = self.mempool_umbrella_members(app_id).await {
tracing::info!(app_id, "stopping legacy umbrella id via split-stack members");
tracing::info!(
app_id,
"stopping legacy umbrella id via split-stack members"
);
for member in members.iter().rev() {
Box::pin(self.stop(member))
.await
@@ -4019,7 +4033,10 @@ impl ContainerOrchestrator for ProdContainerOrchestrator {
async fn restart(&self, app_id: &str) -> Result<()> {
if let Some(members) = self.mempool_umbrella_members(app_id).await {
tracing::info!(app_id, "restarting legacy umbrella id via split-stack members");
tracing::info!(
app_id,
"restarting legacy umbrella id via split-stack members"
);
for (i, member) in members.iter().enumerate() {
if i > 0 {
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
@@ -4222,9 +4239,8 @@ fn command_argv_drifted(
// therefore Config.Cmd) has them as spaces. Normalize both sides so the
// same script never reads as drift over line breaks alone (bitcoin-knots
// and fedimint-gateway on .228, 2026-07-08).
let norm = |v: &[String]| -> Vec<String> {
v.iter().map(|s| s.replace(['\r', '\n'], " ")).collect()
};
let norm =
|v: &[String]| -> Vec<String> { v.iter().map(|s| s.replace(['\r', '\n'], " ")).collect() };
let current_cmd = norm(current_cmd);
let expected_args = norm(expected_args);
let Some(expected_entry) = expected_entry else {
@@ -5491,14 +5507,20 @@ app:
("bitcoin-knots", ReconcileAction::Installed),
("lnd", ReconcileAction::NoOp),
]);
assert_eq!(cascade_pairs_for_report(&r, &none), vec![("bitcoin-knots", "lnd")]);
assert_eq!(
cascade_pairs_for_report(&r, &none),
vec![("bitcoin-knots", "lnd")]
);
// Backend merely started from stopped also moves the IP → cascade.
let r = report(vec![
("bitcoin-core", ReconcileAction::Started),
("lnd", ReconcileAction::NoOp),
]);
assert_eq!(cascade_pairs_for_report(&r, &none), vec![("bitcoin-core", "lnd")]);
assert_eq!(
cascade_pairs_for_report(&r, &none),
vec![("bitcoin-core", "lnd")]
);
// Backend untouched → no cascade.
let r = report(vec![
@@ -5773,13 +5795,13 @@ app:
#[tokio::test]
async fn reconcile_existing_self_heals_missing_optional_installed_app() {
// A non-baseline app (gitea) whose manifest is still loaded (i.e.
// genuinely installed, not user-uninstalled — see the
// durable-user-uninstalled-marker test above for that case) must
// self-heal the same as a required baseline app when its container
// is fully gone. Leaving any installed-but-absent app stuck forever
// regressed a real node (indeedhub's backend containers never came
// back after going absent) — self-heal is no longer baseline-only.
// A non-baseline app (gitea) self-heals ONLY with installation
// evidence: its container was running at the last periodic snapshot
// (desired-state recovery). Manifest presence alone must NOT install
// — the catalog ships manifests for every app, and treating them as
// installed mass-installed the catalog on a fresh node (2026-07-14).
// The indeedhub/immich vanished-container cases are exactly the
// snapshot-covered scenario exercised here.
let rt = Arc::new(MockRuntime::default());
let mut orch = orch_with(rt.clone()).await;
orch.set_disk_gb_for_test(500);
@@ -5788,6 +5810,8 @@ app:
PathBuf::from("/tmp/gitea"),
)
.await;
// Installation evidence: gitea was running at the last snapshot.
crate::crash_recovery::save_container_snapshot_for_test(&orch.data_dir, &["gitea"]).await;
let report = orch.reconcile_existing().await;
@@ -5997,7 +6021,9 @@ app:
let calls = rt.calls();
for name in ["archy-mempool-db", "mempool-api", "archy-mempool-web"] {
assert!(
calls.iter().any(|c| c == &format!("start_container:{name}")),
calls
.iter()
.any(|c| c == &format!("start_container:{name}")),
"{name} not started: {calls:?}"
);
}
+4 -1
View File
@@ -1258,7 +1258,10 @@ app:
assert!(u.read_only_root);
assert!(u.no_new_privileges);
assert_eq!(u.cap_add, vec!["NET_BIND_SERVICE"]);
assert_eq!(u.ports, vec![(8332, 8332, "tcp".to_string(), String::new())]);
assert_eq!(
u.ports,
vec![(8332, 8332, "tcp".to_string(), String::new())]
);
assert_eq!(u.environment, vec!["BITCOIN_NETWORK=mainnet"]);
assert_eq!(u.bind_mounts.len(), 1);
assert_eq!(