fix(cuprate): refuse to run on disks too small for the Monero chain
Cuprate has no pruning — verified against upstream main
(binaries/cuprated/src/config.rs): the 'pruning' crate is Monero's p2p
protocol pruning, not on-disk. Unlike the bitcoin apps, which branch on
DISK_GB in their entrypoint and self-prune, a disk-constrained cuprate
can only sync until the filesystem fills and take Archipelago down.
Translate the bitcoin disk-awareness into the only form cuprate can
honor — refuse rather than prune:
- install (sync + async RPC paths) and package.start fail with an
actionable message below CUPRATE_MIN_DISK_GB (450 GB total: chain
~250 GiB + headroom; allows 500 GB-class, refuses the 250 GB VPS)
- boot reconcile skips an already-installed cuprate on a shrunken disk,
recorded as Left("cuprate-insufficient-disk") before ensure_running
so desired-state recovery can never undo it (same shape as
requires-archival-bitcoin)
- df failure fail-opens at install (never block on an unreadable disk),
fail-closes at boot (never start a doomed sync)
prod_orchestrator also registers cuprate-ui in UI_APP_IDS (its
companion commit follows).
This commit is contained in:
@@ -36,11 +36,19 @@ app:
|
|||||||
data_uid: "1000:1000"
|
data_uid: "1000:1000"
|
||||||
|
|
||||||
dependencies:
|
dependencies:
|
||||||
# Monero mainnet is ~250GiB unpruned as of 2026 and growing a few GB a
|
# Monero mainnet is ~250GiB unpruned as of 2026 and growing ~60GiB/year.
|
||||||
# month; cuprated's pruning support is not confirmed stable yet (the
|
# Verified against upstream main (binaries/cuprated/src/config.rs, 2026-09):
|
||||||
# `pruning` crate exists in the workspace but nothing in this config
|
# cuprated has NO on-disk pruning setting of any kind — the `pruning`
|
||||||
# surface toggles it), so this sizes for a full unpruned chain plus
|
# crate in its workspace is Monero's p2p *protocol* pruning, not a
|
||||||
# headroom rather than assuming pruning is available.
|
# 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
|
- storage: 300Gi
|
||||||
|
|
||||||
resources:
|
resources:
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ impl RpcHandler {
|
|||||||
.to_string();
|
.to_string();
|
||||||
super::validation::validate_app_id(&package_id)?;
|
super::validation::validate_app_id(&package_id)?;
|
||||||
super::dependencies::check_bitcoin_pruning_compatibility(&package_id).await?;
|
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
|
// Reject if already in a transitional lifecycle (prevents double-click
|
||||||
// queuing two installs on the same package).
|
// queuing two installs on the same package).
|
||||||
|
|||||||
@@ -670,6 +670,54 @@ async fn detect_disk_gb() -> u64 {
|
|||||||
.unwrap_or(u64::MAX)
|
.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<String> {
|
||||||
|
(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.
|
/// Log informational messages about optional dependencies.
|
||||||
pub(super) fn log_optional_dep_info(package_id: &str, deps: &RunningDeps) {
|
pub(super) fn log_optional_dep_info(package_id: &str, deps: &RunningDeps) {
|
||||||
if matches!(package_id, "btcpay-server" | "btcpayserver") && !deps.has_lnd {
|
if matches!(package_id, "btcpay-server" | "btcpayserver") && !deps.has_lnd {
|
||||||
@@ -873,9 +921,9 @@ pub(super) fn configure_fedimint_lnd(
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{
|
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,
|
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;
|
use archipelago_container::Dependency;
|
||||||
|
|
||||||
@@ -1017,6 +1065,37 @@ mod tests {
|
|||||||
assert!(!manifest_declares_archival_bitcoin("does-not-exist"));
|
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 {
|
mod dep_wait {
|
||||||
use super::super::{wait_for_install_deps, DepProbe, DependencyGateError, RunningDeps};
|
use super::super::{wait_for_install_deps, DepProbe, DependencyGateError, RunningDeps};
|
||||||
use std::sync::atomic::{AtomicU32, Ordering};
|
use std::sync::atomic::{AtomicU32, Ordering};
|
||||||
|
|||||||
@@ -3,10 +3,10 @@ use super::config::{
|
|||||||
is_readonly_compatible, is_valid_docker_image,
|
is_readonly_compatible, is_valid_docker_image,
|
||||||
};
|
};
|
||||||
use super::dependencies::{
|
use super::dependencies::{
|
||||||
check_bitcoin_pruning_compatibility, configure_fedimint_lnd, detect_existing_containers,
|
check_bitcoin_pruning_compatibility, check_cuprate_disk_compatibility, configure_fedimint_lnd,
|
||||||
detect_running_deps, detect_running_deps_from_package_data, log_optional_dep_info,
|
detect_existing_containers, detect_running_deps, detect_running_deps_from_package_data,
|
||||||
needs_archy_net, wait_for_install_deps, DepProbe, RunningDeps, DEP_WAIT_INTERVAL,
|
log_optional_dep_info, needs_archy_net, wait_for_install_deps, DepProbe, RunningDeps,
|
||||||
DEP_WAIT_MAX_ATTEMPTS,
|
DEP_WAIT_INTERVAL, DEP_WAIT_MAX_ATTEMPTS,
|
||||||
};
|
};
|
||||||
use super::progress::parse_pull_progress;
|
use super::progress::parse_pull_progress;
|
||||||
use super::validation::validate_app_id;
|
use super::validation::validate_app_id;
|
||||||
@@ -306,6 +306,7 @@ impl RpcHandler {
|
|||||||
// failing instantly.
|
// failing instantly.
|
||||||
let deps = self.gate_install_deps(package_id).await?;
|
let deps = self.gate_install_deps(package_id).await?;
|
||||||
check_bitcoin_pruning_compatibility(package_id).await?;
|
check_bitcoin_pruning_compatibility(package_id).await?;
|
||||||
|
check_cuprate_disk_compatibility(package_id).await?;
|
||||||
log_optional_dep_info(package_id, &deps);
|
log_optional_dep_info(package_id, &deps);
|
||||||
if matches!(package_id, "bitcoin" | "bitcoin-core" | "bitcoin-knots") {
|
if matches!(package_id, "bitcoin" | "bitcoin-core" | "bitcoin-knots") {
|
||||||
// Materialise the RPC password file before any install path
|
// Materialise the RPC password file before any install path
|
||||||
|
|||||||
@@ -60,6 +60,12 @@ impl RpcHandler {
|
|||||||
.and_then(|v| v.as_str())
|
.and_then(|v| v.as_str())
|
||||||
.ok_or_else(|| anyhow::anyhow!("Missing package id"))?;
|
.ok_or_else(|| anyhow::anyhow!("Missing package id"))?;
|
||||||
validate_app_id(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) {
|
let to_start = if self.orchestrator.is_some() && uses_single_orchestrator_app(package_id) {
|
||||||
vec![orchestrator_app_id(package_id).to_string()]
|
vec![orchestrator_app_id(package_id).to_string()]
|
||||||
|
|||||||
@@ -47,8 +47,21 @@ use crate::update::host_sudo;
|
|||||||
///
|
///
|
||||||
/// Keep in sync with the running fixture on .116. Centralized as a constant
|
/// Keep in sync with the running fixture on .116. Centralized as a constant
|
||||||
/// so the rule is visible in one place and unit-testable.
|
/// 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;
|
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
|
/// Apps expected to exist from first boot on every node — the ONLY apps the
|
||||||
/// boot reconciler may install from nothing. Every other app needs
|
/// 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);
|
crate::crash_recovery::pending_boot_start_done(&container_name);
|
||||||
continue;
|
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 {
|
match self.ensure_running_with_mode(&lm, mode).await {
|
||||||
// Desired-state recovery: the app has no container and was left
|
// Desired-state recovery: the app has no container and was left
|
||||||
// "absent" by boot reconcile, BUT it was running at the last
|
// "absent" by boot reconcile, BUT it was running at the last
|
||||||
@@ -5312,6 +5342,27 @@ app:
|
|||||||
assert_eq!(compute_container_name(&m), "archy-electrs-ui");
|
assert_eq!(compute_container_name(&m), "archy-electrs-ui");
|
||||||
let m = pull_manifest("lnd-ui", "foo:1");
|
let m = pull_manifest("lnd-ui", "foo:1");
|
||||||
assert_eq!(compute_container_name(&m), "archy-lnd-ui");
|
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]
|
#[test]
|
||||||
|
|||||||
Reference in New Issue
Block a user