Merge remote-tracking branch 'origin/main' into ark-merge
This commit is contained in:
@@ -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.
|
||||
///
|
||||
|
||||
@@ -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:?}"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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!(
|
||||
|
||||
Reference in New Issue
Block a user