diff --git a/apps/cuprate/manifest.yml b/apps/cuprate/manifest.yml index f41bdf41..bcbf9af2 100644 --- a/apps/cuprate/manifest.yml +++ b/apps/cuprate/manifest.yml @@ -36,11 +36,19 @@ app: data_uid: "1000:1000" dependencies: - # Monero mainnet is ~250GiB unpruned as of 2026 and growing a few GB a - # month; cuprated's pruning support is not confirmed stable yet (the - # `pruning` crate exists in the workspace but nothing in this config - # surface toggles it), so this sizes for a full unpruned chain plus - # headroom rather than assuming pruning is available. + # Monero mainnet is ~250GiB unpruned as of 2026 and growing ~60GiB/year. + # Verified against upstream main (binaries/cuprated/src/config.rs, 2026-09): + # cuprated has NO on-disk pruning setting of any kind — the `pruning` + # crate in its workspace is Monero's p2p *protocol* pruning, not a + # smaller chain — so unlike bitcoin-knots this app CANNOT self-prune + # when disk is scarce (see the DISK_GB branch in + # apps/bitcoin-knots/manifest.yml). Left running on a too-small disk it + # syncs until the filesystem fills and takes Archipelago down. The + # disk-scarce equivalent is enforced in Rust instead: install/start + # refuse, and boot reconcile skips, on any node under + # CUPRATE_MIN_DISK_GB (450GB — chain + headroom; refuses the 250GB VPS + # class, allows 500GB-class disks). If upstream ever ships a prune flag, + # replace that gate with the bitcoin-style entrypoint branch. - storage: 300Gi resources: diff --git a/core/archipelago/src/api/rpc/package/async_lifecycle.rs b/core/archipelago/src/api/rpc/package/async_lifecycle.rs index 29ea77bc..3c99a163 100644 --- a/core/archipelago/src/api/rpc/package/async_lifecycle.rs +++ b/core/archipelago/src/api/rpc/package/async_lifecycle.rs @@ -55,6 +55,7 @@ impl RpcHandler { .to_string(); super::validation::validate_app_id(&package_id)?; super::dependencies::check_bitcoin_pruning_compatibility(&package_id).await?; + super::dependencies::check_cuprate_disk_compatibility(&package_id).await?; // Reject if already in a transitional lifecycle (prevents double-click // queuing two installs on the same package). diff --git a/core/archipelago/src/api/rpc/package/dependencies.rs b/core/archipelago/src/api/rpc/package/dependencies.rs index db61187d..74e2d474 100644 --- a/core/archipelago/src/api/rpc/package/dependencies.rs +++ b/core/archipelago/src/api/rpc/package/dependencies.rs @@ -670,6 +670,54 @@ async fn detect_disk_gb() -> u64 { .unwrap_or(u64::MAX) } +/// Smallest disk (GB, total) a cuprate node can live on. Monero mainnet is +/// ~250 GiB of chain data in 2026 and grows ~60 GiB/year; cuprated's storage +/// (block blobs + fjall index + logs) needs headroom above the raw chain. +/// 450 lets a 500 GB-class disk work while refusing the 250 GB VPS class, +/// where the chain does not fit at all. +/// +/// Kept in lockstep with `prod_orchestrator::CUPRATE_MIN_DISK_GB` — same +/// duplication pattern as `ARCHIVAL_BITCOIN_DISK_GB` above. +pub(super) const CUPRATE_MIN_DISK_GB: u64 = 450; + +/// The bitcoin apps pick `-prune` automatically when disk is scarce, because +/// bitcoind supports pruning. Cuprate CANNOT: upstream has no pruning config +/// at all (the `pruning` crate in its workspace is Monero's p2p *protocol* +/// pruning, not on-disk pruning), so the disk-scarce equivalent is to refuse +/// to run cuprate at all rather than let it sync until the filesystem fills — +/// which took Archipelago itself down on nodes with too little disk. +fn cuprate_insufficient_disk_message(disk_gb: u64) -> String { + format!( + "Cuprate needs a disk of at least {} GB and this node has {} GB. \ + A Monero node cannot run pruned — upstream cuprate has no pruning \ + support — so the chain (~250 GB and growing) would fill the disk and \ + take Archipelago down with it. Attach a larger disk (or move \ + /var/lib/archipelago to one) and try again. Bitcoin apps CAN run \ + pruned on smaller disks; Monero currently cannot.", + CUPRATE_MIN_DISK_GB, disk_gb + ) +} + +/// Pure decision half of the cuprate disk gate — testable without df. +pub(super) fn cuprate_disk_gate(disk_gb: u64) -> Option { + (disk_gb < CUPRATE_MIN_DISK_GB).then(|| cuprate_insufficient_disk_message(disk_gb)) +} + +/// Install/start-time pre-check: refuse cuprate on disks too small to hold +/// the Monero chain. Mirrors `check_bitcoin_pruning_compatibility`'s +/// fail-open-on-unknown-disk behaviour (`detect_disk_gb` returns u64::MAX +/// when df fails, so an unreadable disk never blocks an install). +pub(super) async fn check_cuprate_disk_compatibility(package_id: &str) -> Result<()> { + if package_id != "cuprate" { + return Ok(()); + } + let disk_gb = detect_disk_gb().await; + if let Some(message) = cuprate_disk_gate(disk_gb) { + anyhow::bail!(message); + } + 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 { @@ -873,9 +921,9 @@ pub(super) fn configure_fedimint_lnd( #[cfg(test)] mod tests { use super::{ - bitcoin_is_warming_up, dependency_list_declares_archival_bitcoin, + bitcoin_is_warming_up, cuprate_disk_gate, dependency_list_declares_archival_bitcoin, manifest_declares_archival_bitcoin, order_present_containers, requires_unpruned_bitcoin, - startup_order, BITCOIN_WARMUP_BUDGET, + startup_order, BITCOIN_WARMUP_BUDGET, CUPRATE_MIN_DISK_GB, }; use archipelago_container::Dependency; @@ -1017,6 +1065,37 @@ mod tests { assert!(!manifest_declares_archival_bitcoin("does-not-exist")); } + #[test] + fn cuprate_disk_gate_refuses_disks_too_small_for_the_monero_chain() { + // 250 GB VPS class: the ~250 GiB chain does not fit, full stop. + assert!(cuprate_disk_gate(0).is_some()); + assert!(cuprate_disk_gate(250).is_some()); + assert!(cuprate_disk_gate(CUPRATE_MIN_DISK_GB - 1).is_some()); + assert!(cuprate_disk_gate(CUPRATE_MIN_DISK_GB).is_none()); + assert!(cuprate_disk_gate(1000).is_none()); + // df failure reads as u64::MAX — an unreadable disk must not block. + assert!(cuprate_disk_gate(u64::MAX).is_none()); + } + + #[test] + fn cuprate_disk_gate_message_names_the_fix_not_just_the_problem() { + let msg = cuprate_disk_gate(250).expect("250 GB must be refused"); + assert!(msg.contains("cannot run pruned"), "{msg}"); + assert!(msg.contains("larger disk"), "{msg}"); + assert!(msg.contains("250 GB"), "{msg}"); + } + + #[tokio::test] + async fn cuprate_disk_gate_only_applies_to_cuprate() { + // Every other package passes regardless of disk — including the + // bitcoin apps, which self-prune via their manifest entrypoint. + for package_id in ["bitcoin-knots", "bitcoin-core", "electrumx", "mempool"] { + super::check_cuprate_disk_compatibility(package_id) + .await + .expect("non-cuprate installs must not be gated here"); + } + } + mod dep_wait { use super::super::{wait_for_install_deps, DepProbe, DependencyGateError, RunningDeps}; use std::sync::atomic::{AtomicU32, Ordering}; diff --git a/core/archipelago/src/api/rpc/package/install.rs b/core/archipelago/src/api/rpc/package/install.rs index bd460de3..9ee88898 100644 --- a/core/archipelago/src/api/rpc/package/install.rs +++ b/core/archipelago/src/api/rpc/package/install.rs @@ -3,10 +3,10 @@ use super::config::{ is_readonly_compatible, is_valid_docker_image, }; use super::dependencies::{ - check_bitcoin_pruning_compatibility, configure_fedimint_lnd, detect_existing_containers, - detect_running_deps, detect_running_deps_from_package_data, log_optional_dep_info, - needs_archy_net, wait_for_install_deps, DepProbe, RunningDeps, DEP_WAIT_INTERVAL, - DEP_WAIT_MAX_ATTEMPTS, + check_bitcoin_pruning_compatibility, check_cuprate_disk_compatibility, configure_fedimint_lnd, + detect_existing_containers, detect_running_deps, detect_running_deps_from_package_data, + log_optional_dep_info, needs_archy_net, wait_for_install_deps, DepProbe, RunningDeps, + DEP_WAIT_INTERVAL, DEP_WAIT_MAX_ATTEMPTS, }; use super::progress::parse_pull_progress; use super::validation::validate_app_id; @@ -374,6 +374,7 @@ impl RpcHandler { // failing instantly. let deps = self.gate_install_deps(package_id).await?; check_bitcoin_pruning_compatibility(package_id).await?; + check_cuprate_disk_compatibility(package_id).await?; log_optional_dep_info(package_id, &deps); if matches!(package_id, "bitcoin" | "bitcoin-core" | "bitcoin-knots") { // Materialise the RPC password file before any install path diff --git a/core/archipelago/src/api/rpc/package/runtime.rs b/core/archipelago/src/api/rpc/package/runtime.rs index 292d0bda..8e0c02a1 100644 --- a/core/archipelago/src/api/rpc/package/runtime.rs +++ b/core/archipelago/src/api/rpc/package/runtime.rs @@ -60,6 +60,12 @@ impl RpcHandler { .and_then(|v| v.as_str()) .ok_or_else(|| anyhow::anyhow!("Missing package id"))?; validate_app_id(package_id)?; + // A cuprate node that starts on a too-small disk fills it and takes + // Archipelago down with it (no upstream pruning — see + // dependencies::check_cuprate_disk_compatibility). Fail the start + // before clearing user-stopped or flipping state, so the app stays + // cleanly stopped and the error carries the actionable message. + super::dependencies::check_cuprate_disk_compatibility(package_id).await?; let to_start = if self.orchestrator.is_some() && uses_single_orchestrator_app(package_id) { vec![orchestrator_app_id(package_id).to_string()] diff --git a/core/archipelago/src/container/prod_orchestrator.rs b/core/archipelago/src/container/prod_orchestrator.rs index 628db61f..4ce990fc 100644 --- a/core/archipelago/src/container/prod_orchestrator.rs +++ b/core/archipelago/src/container/prod_orchestrator.rs @@ -47,8 +47,21 @@ use crate::update::host_sudo; /// /// Keep in sync with the running fixture on .116. Centralized as a constant /// so the rule is visible in one place and unit-testable. -const UI_APP_IDS: &[&str] = &["bitcoin-ui", "electrs-ui", "lnd-ui"]; +const UI_APP_IDS: &[&str] = &["bitcoin-ui", "electrs-ui", "lnd-ui", "cuprate-ui"]; const ARCHIVAL_BITCOIN_DISK_GB: u64 = 1000; +/// Smallest disk (GB, total) a cuprate node may run on. Cuprate cannot prune +/// (upstream has no on-disk pruning — the bitcoin apps self-prune via their +/// entrypoint, cuprated has no equivalent flag), so a node too small for the +/// ~250 GiB Monero chain must not sync it at all: left running, it fills the +/// filesystem and takes Archipelago down. Install/start carry the same gate +/// (`dependencies::CUPRATE_MIN_DISK_GB`); this one covers boot reconcile, so +/// an already-installed cuprate on a shrunken/remounted disk stays down +/// instead of resuming a doomed sync. +const CUPRATE_MIN_DISK_GB: u64 = 450; + +fn requires_cuprate_disk(app_id: &str, disk_gb: u64) -> bool { + app_id == "cuprate" && disk_gb < CUPRATE_MIN_DISK_GB +} /// Apps expected to exist from first boot on every node — the ONLY apps the /// boot reconciler may install from nothing. Every other app needs @@ -1944,6 +1957,23 @@ impl ProdContainerOrchestrator { crate::crash_recovery::pending_boot_start_done(&container_name); continue; } + // Same shape as the archival-bitcoin skip above: recorded BEFORE + // ensure_running_with_mode, so the "absent" desired-state recovery + // below can never fire on this reason and undo it. + if mode == ReconcileMode::ExistingOnly && requires_cuprate_disk(&app_id, disk_gb) { + tracing::warn!( + app_id = %app_id, + disk_gb, + "cuprate needs a larger disk (no pruning support) — skipping start" + ); + report.record( + &app_id, + ReconcileAction::Left("cuprate-insufficient-disk".into()), + ); + crate::crash_recovery::pending_boot_start_done(&app_id); + crate::crash_recovery::pending_boot_start_done(&container_name); + continue; + } match self.ensure_running_with_mode(&lm, mode).await { // Desired-state recovery: the app has no container and was left // "absent" by boot reconcile, BUT it was running at the last @@ -5365,6 +5395,27 @@ app: assert_eq!(compute_container_name(&m), "archy-electrs-ui"); let m = pull_manifest("lnd-ui", "foo:1"); assert_eq!(compute_container_name(&m), "archy-lnd-ui"); + let m = pull_manifest("cuprate-ui", "foo:1"); + assert_eq!(compute_container_name(&m), "archy-cuprate-ui"); + } + + #[test] + fn cuprate_disk_gate_blocks_only_cuprate_on_small_disks() { + // 250 GB VPS class: the ~250 GiB Monero chain cannot fit and cuprate + // has no pruning — boot reconcile must leave it down. + assert!(requires_cuprate_disk("cuprate", 250)); + assert!(requires_cuprate_disk("cuprate", CUPRATE_MIN_DISK_GB - 1)); + assert!(!requires_cuprate_disk("cuprate", CUPRATE_MIN_DISK_GB)); + assert!(!requires_cuprate_disk("cuprate", 1000)); + // df failure in detect_disk_gb reads as 0 → fail closed at boot: a + // doomed sync is worse than a node that stays down until it can + // measure (same direction as the archival-bitcoin skip). + assert!(requires_cuprate_disk("cuprate", 0)); + // Nothing else is gated here: bitcoin apps self-prune, everything + // else is irrelevant to the Monero chain. + for app_id in ["bitcoin-knots", "bitcoin-core", "electrumx", "mempool"] { + assert!(!requires_cuprate_disk(app_id, 0), "{app_id}"); + } } #[test]