Compare commits

...
31 changed files with 4080 additions and 3566 deletions
+8
View File
@@ -2,10 +2,18 @@
## Unreleased
## v1.8.16-alpha (2026-09-15)
- App updates refresh and verify the signed catalog before changing containers. A failed refresh or manifest reload cancels the update, and automatic updates wait for a successful refresh.
- Fixed repeated Mempool update offers: downstream `-archyN` patches now sort above their upstream release, and moving a published image between registry namespaces does not hide a genuine upgrade.
- Updates inspect installed component versions, refuse known downgrades, skip containers already at the target versions, and verify the resulting versions before reporting success.
- Added regression coverage for stale catalogs, matching versions, publisher namespace changes, stack component updates, and keeping running containers untouched when no upgrade is needed.
## v1.8.15-alpha (2026-09-13)
- Cuprate is presented as one user-facing app in My Apps, including its UI launch button; the generated dashboard companion is hidden as an implementation detail instead of appearing under Services.
- Added regression coverage for Cuprate install and installed-state grouping.
- Release validation was rerun on the corrected tree before OTA and ISO publication.
## v1.8.14-alpha (2026-09-13)
+2 -2
View File
@@ -378,13 +378,13 @@
{
"id": "mempool",
"title": "Mempool Explorer",
"version": "3.0.0",
"version": "3.3.1-archy1",
"description": "Bitcoin mempool and blockchain explorer. Real-time transaction and block visualization.",
"icon": "/assets/img/app-icons/mempool.webp",
"author": "Mempool",
"category": "money",
"tier": "core",
"dockerImage": "source.archipelago-foundation.org/lfg2025/mempool-frontend:v3.3.1",
"dockerImage": "source.archipelago-foundation.org/chaum/mempool-frontend:v3.3.1-archy1",
"repoUrl": "https://github.com/mempool/mempool",
"requires": [
"bitcoin-knots",
+5 -3
View File
@@ -1,7 +1,7 @@
app:
id: archy-mempool-web
name: Mempool Web
version: 3.0.1
version: 3.3.1-archy1
# Where this app comes from, so scripts/check-upstream-releases.py can
# tell us when the pin below has fallen behind. Without it nothing can:
# container.image names our mirror, not the project it was mirrored from.
@@ -12,7 +12,7 @@ app:
container_name: mempool
container:
image: source.archipelago-foundation.org/lfg2025/mempool-frontend:v3.3.1
image: source.archipelago-foundation.org/chaum/mempool-frontend:v3.3.1-archy1
pull_policy: if-not-present
network: archy-net
@@ -45,7 +45,9 @@ app:
# first, but nginx binds 0.0.0.0:8080 (IPv4) only -> localhost probe gets
# "connection refused" -> perpetual unhealthy -> health_monitor restart loop.
endpoint: http://127.0.0.1:8080
path: /
# Probe the backend through nginx: a static page can be healthy while
# every API/WebSocket request is stuck on a dead backend address.
path: /api/v1/backend-info
interval: 30s
timeout: 5s
retries: 3
+2 -2
View File
@@ -1,7 +1,7 @@
app:
id: mempool
name: Mempool Explorer
version: 3.0.0
version: 3.3.1-archy1
# Where this app comes from, so scripts/check-upstream-releases.py can
# tell us when the pin below has fallen behind. Without it nothing can:
# container.image names our mirror, not the project it was mirrored from.
@@ -11,7 +11,7 @@ app:
description: Bitcoin mempool and blockchain explorer. Real-time transaction and block visualization.
container:
image: source.archipelago-foundation.org/lfg2025/mempool-frontend:v3.3.1
image: source.archipelago-foundation.org/chaum/mempool-frontend:v3.3.1-archy1
image_signature: cosign://...
pull_policy: if-not-present
+2 -2
View File
@@ -67,13 +67,13 @@
{
"id": "mempool",
"title": "Mempool Explorer",
"version": "3.0.0",
"version": "3.3.1-archy1",
"description": "Bitcoin mempool and blockchain explorer. Real-time transaction and block visualization.",
"icon": "/assets/img/app-icons/mempool.webp",
"author": "Mempool",
"category": "money",
"tier": "core",
"dockerImage": "source.archipelago-foundation.org/lfg2025/mempool-frontend:v3.3.1",
"dockerImage": "source.archipelago-foundation.org/chaum/mempool-frontend:v3.3.1-archy1",
"repoUrl": "https://github.com/mempool/mempool",
"requires": [
"bitcoin-knots",
+1 -1
View File
@@ -104,7 +104,7 @@ dependencies = [
[[package]]
name = "archipelago"
version = "1.8.15-alpha"
version = "1.8.16-alpha"
dependencies = [
"anyhow",
"archipelago-container",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "archipelago"
version = "1.8.15-alpha"
version = "1.8.16-alpha"
edition = "2021"
license.workspace = true
description = "Archipelago Bitcoin Node OS - Native backend"
@@ -330,7 +330,7 @@ impl RpcHandler {
let package_id_spawn = package_id.clone();
tokio::spawn(async move {
match handler.handle_package_update(params).await {
Ok(_) => {
Ok(result) => {
info!("package.update {}: complete", package_id_spawn);
// Same reasoning as install: the merge_preserving_transitional
// helper treats Updating as RPC-owned, so we MUST write the
@@ -345,7 +345,11 @@ impl RpcHandler {
set_package_state(
&handler.state_manager,
&package_id_spawn,
PackageState::Running,
if result.get("status").and_then(|v| v.as_str()) == Some("up-to-date") {
pre_state.clone().unwrap_or(PackageState::Running)
} else {
PackageState::Running
},
)
.await;
}
+232 -25
View File
@@ -19,7 +19,7 @@ use tracing::{error, info, warn};
const PODMAN_UPDATE_PULL_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(600);
impl RpcHandler {
/// Update a package to the version pinned in image-versions.sh.
/// Update a package to the freshly verified catalog target.
/// This is a manual operation — the user clicks "Update" in the UI.
pub(in crate::api::rpc) async fn handle_package_update(
&self,
@@ -32,6 +32,21 @@ impl RpcHandler {
.ok_or_else(|| anyhow::anyhow!("Missing package id"))?;
validate_app_id(package_id)?;
// An Update click must not act on an hourly cache that predates the
// button. Fetch and verify first; failure leaves running containers alone.
crate::container::app_catalog::refresh_catalog(&self.config.data_dir)
.await
.context(
"Cannot check the signed app catalog; update cancelled before changing containers",
)?;
if let Some(orch) = &self.orchestrator {
// Reload even when bytes did not change: a previous reload may have
// failed after the cache was written, or another refresher wrote it.
orch.reload_manifests()
.await
.context("Cannot load current app manifests; update cancelled")?;
}
// Resolve the target image. Prefer the remote app catalog (decoupled
// from the binary OTA), falling back to the image-versions.sh pin. This
// is OPTIONAL for orchestrator-managed apps: the orchestrator resolves
@@ -42,6 +57,22 @@ impl RpcHandler {
let pinned = crate::container::app_catalog::catalog_primary_image(package_id)
.or_else(|| image_versions::pinned_image_for_app(package_id));
let targets = pinned
.as_ref()
.map(|target| self.resolve_images_to_pull(package_id, target));
if let Some(targets) = &targets {
let installed = inspect_update_images(package_id).await?;
if !update_targets_need_change(targets, &installed)? {
install_log(&format!(
"UPDATE SKIP: {} — target versions already installed",
package_id
))
.await;
self.clear_install_progress(package_id).await;
return Ok(serde_json::json!({"status": "up-to-date", "package_id": package_id}));
}
}
// Note: the `already updating` guard lives in `spawn_package_update`
// (the async wrapper that dispatch actually routes to). By the time
// this inner function runs, the wrapper has already flipped state to
@@ -80,6 +111,12 @@ impl RpcHandler {
if let Some(orchestrator) = self.orchestrator.as_ref() {
match orchestrator.upgrade(orchestrator_app_id).await {
Ok(()) => {
if let Some(targets) = &targets {
verify_update_targets(
targets,
&inspect_update_images(package_id).await?,
)?;
}
self.set_install_phase(package_id, InstallPhase::WaitingHealthy)
.await;
if let Ok(health) = orchestrator.health(orchestrator_app_id).await {
@@ -133,7 +170,8 @@ impl RpcHandler {
};
// Resolve images to pull — either a stack or single container
let images_to_pull = self.resolve_images_to_pull(package_id, &pinned);
let images_to_pull =
targets.unwrap_or_else(|| self.resolve_images_to_pull(package_id, &pinned));
// Get all containers for this app
let containers = get_containers_for_app(package_id).await?;
@@ -324,15 +362,22 @@ impl RpcHandler {
.await;
if let Ok(o) = status {
let state = String::from_utf8_lossy(&o.stdout).trim().to_string();
if state == "exited" {
warn!(
"Update {}: container {} exited after recreate",
package_id, name
);
}
anyhow::ensure!(
o.status.success() && state == "running",
"Update {}: container {} is not running after recreate",
package_id,
name
);
} else {
anyhow::bail!(
"Update {}: cannot inspect recreated container {}",
package_id,
name
);
}
}
verify_update_targets(images_to_pull, &inspect_update_images(package_id).await?)?;
Ok(())
}
@@ -514,6 +559,98 @@ impl RpcHandler {
}
}
async fn inspect_update_images(package_id: &str) -> Result<Vec<(String, String)>> {
let containers = get_containers_for_app(package_id).await?;
anyhow::ensure!(
!containers.is_empty(),
"No containers found for {}",
package_id
);
let mut command = tokio::process::Command::new("podman");
command.arg("inspect").args(&containers).kill_on_drop(true);
let output = tokio::time::timeout(std::time::Duration::from_secs(30), command.output())
.await
.context("Timed out checking installed images")??;
anyhow::ensure!(
output.status.success(),
"Cannot inspect installed images; update cancelled"
);
let inspected: Vec<serde_json::Value> = serde_json::from_slice(&output.stdout)?;
inspected
.iter()
.map(|entry| {
let name = entry
.get("Name")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Container inspection omitted Name"))?;
let image = entry
.get("ImageName")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Container inspection omitted ImageName"))?;
Ok((name.trim_start_matches('/').to_string(), image.to_string()))
})
.collect()
}
fn installed_image_for_target<'a>(
app_id: &str,
installed: &'a [(String, String)],
) -> Option<&'a str> {
installed
.iter()
.find(|(name, _)| {
candidate_app_ids_for_container(name)
.iter()
.any(|id| id == app_id)
})
.map(|(_, image)| image.as_str())
}
/// A successful recreate is not proof that it used the downloaded image.
fn verify_update_targets(
targets: &[(String, String)],
installed: &[(String, String)],
) -> Result<()> {
for (app_id, target) in targets {
let running = installed_image_for_target(app_id, installed).ok_or_else(|| {
anyhow::anyhow!("Update {}: target container missing after recreate", app_id)
})?;
anyhow::ensure!(
image_versions::extract_version_from_image(target)
== image_versions::extract_version_from_image(running)
|| image_versions::compare_image_versions(target, running)
== Some(std::cmp::Ordering::Equal),
"Update {}: recreated container did not reach target version {}",
app_id,
image_versions::extract_version_from_image(target)
);
}
Ok(())
}
/// Check every stack component, not just the version shown on its tile. A
/// newer backend must still update when its frontend version is unchanged.
/// A stale target for any component cancels before pulling or stopping anything.
fn update_targets_need_change(
targets: &[(String, String)],
installed: &[(String, String)],
) -> Result<bool> {
use std::cmp::Ordering;
let mut changed = false;
for (app_id, target) in targets {
let running = installed_image_for_target(app_id, installed);
match running.and_then(|image| image_versions::compare_image_versions(target, image)) {
Some(Ordering::Less) => anyhow::bail!(
"Catalog target for {} is older than the installed image; refusing downgrade",
app_id
),
Some(Ordering::Equal) => {}
Some(Ordering::Greater) | None => changed = true,
}
}
Ok(changed)
}
fn should_try_orchestrator_update(package_id: &str, orchestrator_available: bool) -> bool {
orchestrator_available && !uses_legacy_update_flow(package_id)
}
@@ -526,11 +663,14 @@ fn orchestrator_update_app_id(package_id: &str) -> &str {
}
fn uses_legacy_update_flow(package_id: &str) -> bool {
matches!(
package_id,
// Multi-container stacks still updated via the stack-aware path.
"immich" | "penpot" | "penpot-frontend" | "indeedhub"
)
// A primary container already at its target does not mean its backend or
// database is current. Route every mapped stack through the component flow.
!image_versions::containers_for_stack(package_id).is_empty()
|| matches!(
package_id,
// Multi-container stacks still updated via the stack-aware path.
"immich" | "penpot" | "penpot-frontend" | "indeedhub"
)
}
fn is_unknown_app_id_error(err: &anyhow::Error) -> bool {
@@ -554,7 +694,12 @@ fn candidate_app_ids_for_container(container_name: &str) -> Vec<String> {
"archy-bitcoin-ui" => push("bitcoin-ui"),
"archy-lnd-ui" => push("lnd-ui"),
"archy-electrs-ui" => push("electrs-ui"),
"mempool" => {
"mysql-mempool" => push("archy-mempool-db"),
"btcpay" | "btcpayserver" | "archy-btcpay" => push("btcpay-server"),
"homeassistant" | "archy-homeassistant" => push("home-assistant"),
"fedimintd" => push("fedimint"),
"electrs" | "mempool-electrs" => push("electrumx"),
"mempool" | "mempool-web" => {
push("archy-mempool-web");
push("mempool");
}
@@ -572,27 +717,89 @@ fn candidate_app_ids_for_container(container_name: &str) -> Vec<String> {
mod tests {
use super::{
candidate_app_ids_for_container, orchestrator_update_app_id,
should_try_orchestrator_update, uses_legacy_update_flow,
should_try_orchestrator_update, update_targets_need_change, uses_legacy_update_flow,
verify_update_targets,
};
#[test]
fn mempool_update_preflight_rejects_stale_catalog_without_reinstalling() {
let installed = vec![(
"mempool".into(),
"r.test/lfg2025/mempool-frontend:v3.3.1-archy1".into(),
)];
let stale = vec![(
"archy-mempool-web".into(),
"r.test/lfg2025/mempool-frontend:v3.3.1".into(),
)];
assert!(update_targets_need_change(&stale, &installed).is_err());
let current = vec![(
"archy-mempool-web".into(),
"r.test/chaum/mempool-frontend:v3.3.1-archy1".into(),
)];
assert!(!update_targets_need_change(&current, &installed).unwrap());
let legacy = vec![(
"mempool-web".into(),
"r.test/old/mempool-frontend:v3.3.1-archy1".into(),
)];
assert!(!update_targets_need_change(&current, &legacy).unwrap());
let newer = vec![(
"archy-mempool-web".into(),
"r.test/chaum/mempool-frontend:v3.3.1-archy2".into(),
)];
assert!(update_targets_need_change(&newer, &installed).unwrap());
}
#[test]
fn stack_update_checks_backend_even_when_frontend_matches() {
let installed = vec![
("mempool".into(), "r.test/team/web:3.3.1-archy1".into()),
("mempool-api".into(), "r.test/team/api:3.3.1".into()),
];
let mut targets = vec![
(
"archy-mempool-web".into(),
"r.test/team/web:3.3.1-archy1".into(),
),
("mempool-api".into(), "r.test/team/api:3.3.2".into()),
];
assert!(update_targets_need_change(&targets, &installed).unwrap());
targets[0].1 = "r.test/team/web:3.3.1".into();
assert!(update_targets_need_change(&targets, &installed).is_err());
}
#[test]
fn update_completion_requires_the_target_version_to_be_installed() {
let targets = vec![(
"archy-mempool-web".into(),
"r.test/chaum/mempool-frontend:v3.3.1-archy1".into(),
)];
let mut installed = vec![(
"mempool".into(),
"r.test/lfg2025/mempool-frontend:v3.3.1".into(),
)];
assert!(verify_update_targets(&targets, &installed).is_err());
assert!(verify_update_targets(&targets, &[]).is_err());
installed[0].1 = "r.test/lfg2025/mempool-frontend:v3.3.1-archy1".into();
assert!(verify_update_targets(&targets, &installed).is_ok());
}
#[test]
fn legacy_flow_for_stack_apps() {
for app in ["immich", "penpot", "indeedhub"] {
for app in [
"immich",
"penpot",
"indeedhub",
"mempool",
"btcpay-server",
"netbird",
] {
assert!(uses_legacy_update_flow(app), "{app} should stay legacy");
}
}
#[test]
fn orchestrator_flow_for_single_apps() {
for app in [
"lnd",
"bitcoin-core",
"searxng",
"grafana",
"btcpay-server",
"mempool",
"fedimint",
] {
for app in ["lnd", "bitcoin-core", "searxng", "grafana", "fedimint"] {
assert!(
!uses_legacy_update_flow(app),
"{app} should be orchestrator-first"
@@ -400,7 +400,7 @@ pub fn available_update_for_app(app_id: &str, running_image: &str) -> Option<Str
}
if let Some(catalog_image) = catalog_primary_image(app_id) {
// Catalog covers this app with a concrete image -> authoritative.
return crate::container::image_versions::available_update_for_images(
return crate::container::image_versions::available_catalog_update_for_images(
&catalog_image,
running_image,
);
+130 -34
View File
@@ -100,6 +100,12 @@ fn parse_image_versions(content: &str) -> HashMap<String, String> {
// Match VAR="value" or VAR=value
if let Some((key, val)) = parse_assignment(line) {
// Read a self-default assignment without evaluating shell code.
let default_prefix = format!("${{{key}:-");
let val = val
.strip_prefix(&default_prefix)
.and_then(|v| v.strip_suffix('}'))
.unwrap_or(val);
let expanded = val.replace("$ARCHY_REGISTRY", &registry);
if key == "ARCHY_REGISTRY" {
registry = expanded.clone();
@@ -205,48 +211,71 @@ pub fn available_update_for_app(app_id: &str, running_image: &str) -> Option<Str
}
pub fn available_update_for_images(pinned: &str, running_image: &str) -> Option<String> {
let pinned_version = extract_version_from_image(&pinned);
if image_without_registry_or_tag(pinned) != image_without_registry_or_tag(running_image) {
return None;
}
available_catalog_update_for_images(pinned, running_image)
}
/// A signed catalog binds the image to an app id, so a publisher namespace
/// migration must not hide a real upgrade. Baseline pins still require the
/// same repository via `available_update_for_images` above.
pub fn available_catalog_update_for_images(pinned: &str, running_image: &str) -> Option<String> {
let pinned_version = extract_version_from_image(pinned);
if is_floating_tag(&pinned_version) {
return None;
}
let running_version = extract_version_from_image(running_image);
if pinned_version == running_version {
return None;
}
let pinned_repo = image_without_registry_or_tag(&pinned);
let running_repo = image_without_registry_or_tag(running_image);
if pinned_repo != running_repo {
return None;
}
// Never advertise a LOWER version as an update.
//
// Everything upstream of here is a version claim that can go stale: the
// signed catalog, a legacy catalog entry with no manifest, the
// image-versions.sh baseline pin. When one lags behind what a node is
// actually running, a bare `pinned != running` check turns that staleness
// into an "Update" button that rolls the node BACKWARDS — and a rollback
// to a version withdrawn for a vulnerability is precisely the case where
// that must not happen. Observed with BTCPay: 2.4.2 installed, a stale
// 2.3.9 pin, and the UI offering "update" to the exploited release.
//
// Only suppress when both tags parse as comparable version numbers, so
// apps with opaque tags (RELEASE.2024-11-07T00-52-20Z, 14-vectorchord0.4.3)
// keep the previous behaviour rather than silently losing updates.
if let (Some(p), Some(r)) = (
parse_version_parts(&pinned_version),
parse_version_parts(&running_version),
if matches!(
compare_image_versions(pinned, running_image),
Some(std::cmp::Ordering::Less | std::cmp::Ordering::Equal)
) {
if p < r {
return None;
}
return None;
}
Some(pinned_version)
}
/// Compare explicit image tags, ignoring registry and namespace. `None` means
/// unknown ordering (including floating tags), never permission to downgrade.
/// Archipelago's `-archyN` is a downstream patch revision ABOVE the upstream
/// release, not a SemVer prerelease below it.
pub fn compare_image_versions(target: &str, running: &str) -> Option<std::cmp::Ordering> {
use std::cmp::Ordering;
let target = extract_version_from_image(target);
let running = extract_version_from_image(running);
if is_floating_tag(&target) || is_floating_tag(&running) {
return None;
}
let target = target.strip_prefix('v').unwrap_or(&target);
let running = running.strip_prefix('v').unwrap_or(&running);
if target == running {
return Some(Ordering::Equal);
}
let mut target_core = parse_version_parts(target)?;
let mut running_core = parse_version_parts(running)?;
while target_core.last() == Some(&0) {
target_core.pop();
}
while running_core.last() == Some(&0) {
running_core.pop();
}
match target_core.cmp(&running_core) {
Ordering::Equal => {
fn patch_revision(tag: &str) -> Option<u64> {
if let Some((base, revision)) = tag.rsplit_once("-archy") {
if base.chars().all(|c| c.is_ascii_digit() || c == '.') {
return revision.parse().ok();
}
}
tag.chars()
.all(|c| c.is_ascii_digit() || c == '.')
.then_some(0)
}
Some(patch_revision(target)?.cmp(&patch_revision(running)?))
}
order => Some(order),
}
}
/// Numeric components of a version tag, for ordering comparisons only.
///
/// Accepts a leading `v` and a trailing pre-release suffix (`v0.18.4-beta`),
@@ -423,6 +452,57 @@ mod tests {
);
}
#[test]
fn downstream_patch_is_newer_than_upstream_and_orders_revisions() {
let upstream = "registry.test/team/mempool-frontend:v3.3.1";
let patch1 = "registry.test/team/mempool-frontend:v3.3.1-archy1";
let patch2 = "registry.test/team/mempool-frontend:v3.3.1-archy2";
assert_eq!(available_update_for_images(upstream, patch1), None);
assert_eq!(available_update_for_images(patch1, patch2), None);
assert_eq!(
available_update_for_images(patch1, upstream),
Some("v3.3.1-archy1".into())
);
assert_eq!(
available_update_for_images(patch2, patch1),
Some("v3.3.1-archy2".into())
);
}
#[test]
fn catalog_namespace_migration_does_not_hide_patch_or_offer_reinstall() {
let old = "registry.test/lfg2025/mempool-frontend:v3.3.1";
let patched = "registry.test/chaum/mempool-frontend:v3.3.1-archy1";
assert_eq!(
available_catalog_update_for_images(patched, old),
Some("v3.3.1-archy1".into())
);
assert_eq!(
available_catalog_update_for_images(
patched,
"registry.test/lfg2025/mempool-frontend:v3.3.1-archy1"
),
None
);
assert_eq!(available_update_for_images(patched, old), None);
}
#[test]
fn equivalent_version_spelling_does_not_offer_update() {
assert_eq!(
available_update_for_images("r.test/team/app:v3.3.1", "r.test/team/app:3.3.1"),
None
);
assert_eq!(
available_update_for_images("r.test/team/app:3.3.0", "r.test/team/app:3.3"),
None
);
assert_eq!(
compare_image_versions("r.test/team/app:latest", "r.test/team/app:latest"),
None
);
}
#[test]
fn test_parse_image_versions() {
let content = r#"
@@ -445,6 +525,22 @@ NOT_AN_IMAGE="something"
assert!(!parsed.contains_key("ARCHY_REGISTRY"));
}
#[test]
fn shipped_image_pins_expand_shell_defaults_to_concrete_refs() {
let images = parse_image_versions(include_str!("../../../../scripts/image-versions.sh"));
assert_eq!(
images["MEMPOOL_WEB_IMAGE"],
"source.archipelago-foundation.org/chaum/mempool-frontend:v3.3.1-archy1"
);
assert_eq!(
images["MEMPOOL_BACKEND_IMAGE"],
"source.archipelago-foundation.org/lfg2025/mempool-backend:v3.3.1"
);
assert!(images
.values()
.all(|v| !v.contains('$') && !v.contains('}')));
}
#[test]
fn test_image_var_mapping() {
assert_eq!(image_var_for_app("lnd"), Some("LND_IMAGE"));
@@ -4667,6 +4667,27 @@ impl ContainerOrchestrator for ProdContainerOrchestrator {
let lock = self.app_lock(app_id).await;
let _guard = lock.lock().await;
let name = compute_container_name(&lm.manifest);
let mut resolved = lm.manifest.clone();
resolve_catalog_image(&mut resolved);
if resolved.app.container.build.is_none() {
if let Some(target) = &resolved.app.container.image {
if let Ok(running) = self.runtime.get_container_status(&name).await {
match crate::container::image_versions::compare_image_versions(
target,
&running.image,
) {
Some(std::cmp::Ordering::Less) => anyhow::bail!(
"Refusing to downgrade {} from {} to {} during update",
app_id,
running.image,
target
),
Some(std::cmp::Ordering::Equal) => return Ok(()),
_ => {}
}
}
}
}
let _ = self.runtime.stop_container(&name).await;
let _ = self.runtime.remove_container(&name).await;
self.install_fresh(&lm).await
@@ -5076,6 +5097,7 @@ mod tests {
calls: StdMutex<Vec<String>>,
/// container_name -> ContainerState. Absence = "doesn't exist".
containers: StdMutex<HashMap<String, ContainerState>>,
running_images: StdMutex<HashMap<String, String>>,
/// container_name -> Podman health status.
health: StdMutex<HashMap<String, String>>,
/// image_ref -> present. Absence = "not present in local storage".
@@ -5200,7 +5222,13 @@ mod tests {
health,
exit_code: None,
started_at: None,
image: "test-image".to_string(),
image: self
.running_images
.lock()
.unwrap()
.get(name)
.cloned()
.unwrap_or_else(|| "test-image".to_string()),
created: "now".to_string(),
ports: vec![],
lan_address: None,
@@ -6771,6 +6799,41 @@ app:
assert_eq!(ids, vec!["bitcoin-knots", "bitcoin-ui"]);
}
#[tokio::test]
async fn upgrade_preserves_container_when_catalog_is_stale_or_already_installed() {
for (target, should_error) in [("v3.3.1", true), ("v3.3.1-archy1", false)] {
let rt = Arc::new(MockRuntime::default());
rt.set_state("update-regression", ContainerState::Running);
rt.running_images.lock().unwrap().insert(
"update-regression".into(),
"registry.test/old/mempool-frontend:v3.3.1-archy1".into(),
);
let orch = orch_with(rt.clone()).await;
orch.insert_manifest_for_test(
pull_manifest(
"update-regression",
&format!("registry.test/new/mempool-frontend:{target}"),
),
PathBuf::from("/tmp/update-regression"),
)
.await;
assert_eq!(
orch.upgrade("update-regression").await.is_err(),
should_error
);
assert!(
!rt.calls()
.iter()
.any(|call| call.starts_with("stop_container:")
|| call.starts_with("remove_container:")
|| call.starts_with("pull_image:")
|| call.starts_with("create_container:")),
"{:?}",
rt.calls()
);
}
}
#[tokio::test]
async fn upgrade_removes_and_reinstalls() {
let rt = Arc::new(MockRuntime::default());
+35 -20
View File
@@ -2159,20 +2159,25 @@ async fn apply_per_app_auto_updates(
}
}
/// After a catalog refresh that changed the cached bytes, rebuild the
/// orchestrator's manifest map so registry-shipped manifest changes take
/// effect now instead of at the next service restart.
async fn reload_manifests_if_changed(
refresh: crate::container::app_catalog::CatalogRefresh,
/// Reload after every successful refresh, including unchanged bytes: the cache
/// may have been written before a previous reload failed. Auto-updates only run
/// when the catalog and the orchestrator's manifests are ready together.
async fn reload_catalog_manifests(
_refresh: crate::container::app_catalog::CatalogRefresh,
orchestrator: &Option<std::sync::Arc<dyn crate::container::traits::ContainerOrchestrator>>,
) {
if !refresh.changed {
return;
}
let Some(orch) = orchestrator else { return };
) -> bool {
let Some(orch) = orchestrator else {
return false;
};
match orch.reload_manifests().await {
Ok(n) => info!("Update scheduler: catalog changed, reloaded {n} manifest(s)"),
Err(e) => warn!("Update scheduler: manifest reload after catalog change failed: {e}"),
Ok(n) => {
info!("Update scheduler: refreshed catalog, reloaded {n} manifest(s)");
true
}
Err(e) => {
warn!("Update scheduler: manifest reload failed; skipping auto-updates: {e}");
false
}
}
}
@@ -2188,7 +2193,9 @@ pub async fn run_update_scheduler(
// Refresh the app catalog once at startup so per-app "update available"
// badges appear without waiting for the first hourly tick.
match crate::container::app_catalog::refresh_catalog(&data_dir).await {
Ok(refresh) => reload_manifests_if_changed(refresh, &orchestrator).await,
Ok(refresh) => {
reload_catalog_manifests(refresh, &orchestrator).await;
}
Err(e) => debug!(
"Update scheduler: initial app-catalog refresh failed: {}",
e
@@ -2204,14 +2211,22 @@ pub async fn run_update_scheduler(
// previously cached catalog stays in place (origin-always-wins).
// A changed catalog also reloads the orchestrator's manifest overlay so
// catalog-shipped manifest fixes apply without a service restart.
match crate::container::app_catalog::refresh_catalog(&data_dir).await {
Ok(refresh) => reload_manifests_if_changed(refresh, &orchestrator).await,
Err(e) => debug!("Update scheduler: app-catalog refresh failed: {}", e),
}
let catalog_ready = match crate::container::app_catalog::refresh_catalog(&data_dir).await {
Ok(refresh) => reload_catalog_manifests(refresh, &orchestrator).await,
Err(e) => {
debug!(
"Update scheduler: app-catalog refresh failed; skipping auto-updates: {}",
e
);
false
}
};
// Per-app auto-update-to-latest (multi-version support). Runs every tick
// regardless of the binary-OTA schedule below; opt-in + pin-respecting.
apply_per_app_auto_updates(&orchestrator).await;
// Per-app updates require fresh, loaded manifests; a failed refresh
// may still show cached badges but must not trigger container changes.
if catalog_ready {
apply_per_app_auto_updates(&orchestrator).await;
}
let state = match load_state(&data_dir).await {
Ok(s) => s,
+6 -7
View File
@@ -1,14 +1,13 @@
# Archipelago mempool frontend — adds a resilient nginx backend proxy.
#
# The only delta vs the upstream image is /patch/entrypoint.sh, which rewrites
# the generated nginx-mempool.conf to use `resolver` + a variable proxy_pass so
# the frontend re-resolves the backend (mempool-api) via DNS on every request.
# Without this, nginx pins the backend IP at startup and serves 502 / "offline"
# after any backend restart (podman reassigns the IP). See the script header.
ARG BASE=source.archipelago-foundation.org/lfg2025/mempool-frontend:v3.0.0
# Keep the upstream startup logic; repair its rendered proxy configuration.
# Publish this derived image under an Archipelago-specific tag, never the
# upstream version tag that the registry mirror can overwrite.
ARG BASE=source.archipelago-foundation.org/lfg2025/mempool-frontend@sha256:d63498a109622475c913db4e3199d893f2440a451450e542923d2e55a38407a0
FROM ${BASE}
# --chmod keeps the exec bit (build runs as USER 1000, plain COPY lands root:0644
# → "not executable"). Base USER/ENTRYPOINT/CMD (1000 / /patch/entrypoint.sh /
# nginx -g "daemon off;") are inherited unchanged.
COPY --chmod=0755 entrypoint.sh /patch/entrypoint.sh
RUN cp /patch/entrypoint.sh /patch/upstream-entrypoint.sh
COPY --chmod=0755 entrypoint.sh start-nginx.sh repair-nginx.sh /patch/
+29
View File
@@ -0,0 +1,29 @@
# Mempool frontend DNS recovery
The stock v3.3.1 nginx configuration resolves `mempool-api` only when workers
start. Recreating the backend can change its Podman address while the frontend
continues to serve its static page, leaving all API/WebSocket requests offline.
Build and test the derived image before publishing:
```sh
podman build --pull=never -t source.archipelago-foundation.org/chaum/mempool-frontend:v3.3.1-archy1 docker/mempool-frontend
python3 scripts/test-mempool-dns-recovery.py
```
The base is pinned by digest. The wrapper preserves upstream runtime options,
then repairs all four local API/WebSocket routes after placeholder rendering.
DNS is cached for five seconds using the container network resolver. Explicit
rewrites preserve API prefixes and query arguments; backend absence does not
prevent nginx startup. An unexpected upstream configuration fails startup
instead of silently omitting the fix.
Use an Archipelago-specific image tag. Do not replace it with a stock upstream
mirror when updating mempool. Every upstream update must rebuild this wrapper
and pass the recovery test (backend absent, changed IP, HTTP and WebSocket
mapping, repeated repair, and frontend restart).
Publish the tested image before publishing the signed app catalog. Both the
mempool umbrella image mapping and the archy-mempool-web embedded manifest must
point at the patched image. Keep scripts/image-versions.sh in sync. The frontend
health check must reach `/api/v1/backend-info` through nginx, not only `/`.
+4 -136
View File
@@ -1,137 +1,5 @@
#!/bin/sh
__MEMPOOL_BACKEND_MAINNET_HTTP_HOST__=${BACKEND_MAINNET_HTTP_HOST:=127.0.0.1}
__MEMPOOL_BACKEND_MAINNET_HTTP_PORT__=${BACKEND_MAINNET_HTTP_PORT:=8999}
__MEMPOOL_FRONTEND_HTTP_PORT__=${FRONTEND_HTTP_PORT:=8080}
CONF=/etc/nginx/conf.d/nginx-mempool.conf
# ─── archipelago patch ────────────────────────────────────────────────────
# The stock frontend writes `proxy_pass http://<backend>:8999` with a literal
# hostname and NO resolver, so nginx resolves the backend IP ONCE at worker
# start and caches it for the process lifetime. Podman reassigns the backend
# container's IP whenever it is restarted/recreated (gate, OTA, crash, reboot
# re-IPAM), after which nginx keeps proxying to the dead IP → /api hangs, the
# websocket 502s, and the mempool UI shows "offline" until nginx is reloaded.
#
# Fix: force per-request DNS re-resolution via `resolver` + a variable in
# proxy_pass. Because a variable in proxy_pass disables nginx's automatic
# location→URI rewriting, each block is rewritten to preserve its original
# path mapping exactly:
# /api/v1/ws, /ws → "/" (var + "/" replaces the whole URI)
# /api/v1 → identity (no-URI proxy_pass passes $uri unchanged)
# /api/ → /api/v1/$1 (explicit rewrite, then no-URI proxy_pass)
# Operates on the __PLACEHOLDER__ tokens so the host/port sed below fills in
# the concrete values (incl. the `set $mp_backend` line). Idempotent.
# Resolver address: podman's aardvark-dns answers on the network gateway
# (e.g. 10.89.0.1), NOT Docker's 127.0.0.11. Read it from resolv.conf so this
# works on any podman network/subnet (and still falls back for Docker).
ARCHY_RESOLVER=$(awk '/^nameserver/ { print $2; exit }' /etc/resolv.conf 2>/dev/null)
ARCHY_RESOLVER=${ARCHY_RESOLVER:-127.0.0.11}
if ! grep -q 'set \$mp_backend' "$CONF"; then
awk -v res_addr="$ARCHY_RESOLVER" '
BEGIN { res = 0 }
/^[[:space:]]*location / && res == 0 {
print "\tresolver " res_addr " valid=10s ipv6=off;"
res = 1
}
/proxy_pass http:\/\/__MEMPOOL_BACKEND_MAINNET_HTTP_HOST__:__MEMPOOL_BACKEND_MAINNET_HTTP_PORT__\/;/ {
print "\t\tset $mp_backend __MEMPOOL_BACKEND_MAINNET_HTTP_HOST__;"
print "\t\tproxy_pass http://$mp_backend:__MEMPOOL_BACKEND_MAINNET_HTTP_PORT__/;"
next
}
/proxy_pass http:\/\/__MEMPOOL_BACKEND_MAINNET_HTTP_HOST__:__MEMPOOL_BACKEND_MAINNET_HTTP_PORT__\/api\/v1\/;/ {
print "\t\tset $mp_backend __MEMPOOL_BACKEND_MAINNET_HTTP_HOST__;"
print "\t\trewrite ^/api/(.*)$ /api/v1/$1 break;"
print "\t\tproxy_pass http://$mp_backend:__MEMPOOL_BACKEND_MAINNET_HTTP_PORT__;"
next
}
/proxy_pass http:\/\/__MEMPOOL_BACKEND_MAINNET_HTTP_HOST__:__MEMPOOL_BACKEND_MAINNET_HTTP_PORT__\/api\/v1;/ {
print "\t\tset $mp_backend __MEMPOOL_BACKEND_MAINNET_HTTP_HOST__;"
print "\t\tproxy_pass http://$mp_backend:__MEMPOOL_BACKEND_MAINNET_HTTP_PORT__;"
next
}
{ print }
' "$CONF" > "$CONF.archy" && mv "$CONF.archy" "$CONF"
fi
# ─── end archipelago patch ────────────────────────────────────────────────
sed -i "s/__MEMPOOL_BACKEND_MAINNET_HTTP_HOST__/${__MEMPOOL_BACKEND_MAINNET_HTTP_HOST__}/g" /etc/nginx/conf.d/nginx-mempool.conf
sed -i "s/__MEMPOOL_BACKEND_MAINNET_HTTP_PORT__/${__MEMPOOL_BACKEND_MAINNET_HTTP_PORT__}/g" /etc/nginx/conf.d/nginx-mempool.conf
cp /etc/nginx/nginx.conf /patch/nginx.conf
sed -i "s/__MEMPOOL_FRONTEND_HTTP_PORT__/${__MEMPOOL_FRONTEND_HTTP_PORT__}/g" /patch/nginx.conf
cat /patch/nginx.conf > /etc/nginx/nginx.conf
if [ "${LIGHTNING_DETECTED_PORT}" != "" ];then
export LIGHTNING=true
fi
# Runtime overrides - read env vars defined in docker compose
__MAINNET_ENABLED__=${MAINNET_ENABLED:=true}
__TESTNET_ENABLED__=${TESTNET_ENABLED:=false}
__TESTNET4_ENABLED__=${TESTNET_ENABLED:=false}
__SIGNET_ENABLED__=${SIGNET_ENABLED:=false}
__LIQUID_ENABLED__=${LIQUID_ENABLED:=false}
__LIQUID_TESTNET_ENABLED__=${LIQUID_TESTNET_ENABLED:=false}
__ITEMS_PER_PAGE__=${ITEMS_PER_PAGE:=10}
__KEEP_BLOCKS_AMOUNT__=${KEEP_BLOCKS_AMOUNT:=8}
__NGINX_PROTOCOL__=${NGINX_PROTOCOL:=http}
__NGINX_HOSTNAME__=${NGINX_HOSTNAME:=localhost}
__NGINX_PORT__=${NGINX_PORT:=8999}
__BLOCK_WEIGHT_UNITS__=${BLOCK_WEIGHT_UNITS:=4000000}
__MEMPOOL_BLOCKS_AMOUNT__=${MEMPOOL_BLOCKS_AMOUNT:=8}
__BASE_MODULE__=${BASE_MODULE:=mempool}
__ROOT_NETWORK__=${ROOT_NETWORK:=}
__MEMPOOL_WEBSITE_URL__=${MEMPOOL_WEBSITE_URL:=https://mempool.space}
__LIQUID_WEBSITE_URL__=${LIQUID_WEBSITE_URL:=https://liquid.network}
__MINING_DASHBOARD__=${MINING_DASHBOARD:=true}
__LIGHTNING__=${LIGHTNING:=false}
__AUDIT__=${AUDIT:=false}
__MAINNET_BLOCK_AUDIT_START_HEIGHT__=${MAINNET_BLOCK_AUDIT_START_HEIGHT:=0}
__TESTNET_BLOCK_AUDIT_START_HEIGHT__=${TESTNET_BLOCK_AUDIT_START_HEIGHT:=0}
__SIGNET_BLOCK_AUDIT_START_HEIGHT__=${SIGNET_BLOCK_AUDIT_START_HEIGHT:=0}
__ACCELERATOR__=${ACCELERATOR:=false}
__ACCELERATOR_BUTTON__=${ACCELERATOR_BUTTON:=true}
__SERVICES_API__=${SERVICES_API:=https://mempool.space/api/v1/services}
__PUBLIC_ACCELERATIONS__=${PUBLIC_ACCELERATIONS:=false}
__HISTORICAL_PRICE__=${HISTORICAL_PRICE:=true}
__ADDITIONAL_CURRENCIES__=${ADDITIONAL_CURRENCIES:=false}
# Export as environment variables to be used by envsubst
export __MAINNET_ENABLED__
export __TESTNET_ENABLED__
export __TESTNET4_ENABLED__
export __SIGNET_ENABLED__
export __LIQUID_ENABLED__
export __LIQUID_TESTNET_ENABLED__
export __ITEMS_PER_PAGE__
export __KEEP_BLOCKS_AMOUNT__
export __NGINX_PROTOCOL__
export __NGINX_HOSTNAME__
export __NGINX_PORT__
export __BLOCK_WEIGHT_UNITS__
export __MEMPOOL_BLOCKS_AMOUNT__
export __BASE_MODULE__
export __ROOT_NETWORK__
export __MEMPOOL_WEBSITE_URL__
export __LIQUID_WEBSITE_URL__
export __MINING_DASHBOARD__
export __LIGHTNING__
export __AUDIT__
export __MAINNET_BLOCK_AUDIT_START_HEIGHT__
export __TESTNET_BLOCK_AUDIT_START_HEIGHT__
export __SIGNET_BLOCK_AUDIT_START_HEIGHT__
export __ACCELERATOR__
export __ACCELERATOR_BUTTON__
export __SERVICES_API__
export __PUBLIC_ACCELERATIONS__
export __HISTORICAL_PRICE__
export __ADDITIONAL_CURRENCIES__
folder=$(find /var/www/mempool -name "config.js" | xargs dirname)
echo ${folder}
envsubst < ${folder}/config.template.js > ${folder}/config.js
exec "$@"
set -eu
# Preserve the pinned upstream entrypoint (including new runtime options).
# Apply our DNS repair only after it has rendered the nginx configuration.
exec /patch/upstream-entrypoint.sh /patch/start-nginx.sh "$@"
+56
View File
@@ -0,0 +1,56 @@
#!/bin/sh
# Resolve the backend again after container IP changes. Run after upstream
# placeholder substitution, so the repair also works on an existing container.
set -eu
conf=${1:-/etc/nginx/conf.d/nginx-mempool.conf}
resolv=${2:-/etc/resolv.conf}
backend=${BACKEND_MAINNET_HTTP_HOST:-127.0.0.1}
port=${BACKEND_MAINNET_HTTP_PORT:-8999}
resolver=$(awk '/^nameserver/ { print $2; exit }' "$resolv")
[ -n "$resolver" ] || { echo 'No DNS resolver configured' >&2; exit 1; }
case "$resolver" in *:*) resolver="[$resolver]" ;; esac
case "$backend" in *[!a-zA-Z0-9._-]*|'') echo 'Invalid backend hostname' >&2; exit 1 ;; esac
case "$port" in *[!0-9]*|'') echo 'Invalid backend port' >&2; exit 1 ;; esac
tmp=$(mktemp "${conf}.archy.XXXXXX")
trap 'rm -f "$tmp"' EXIT HUP INT TERM
awk -v backend="$backend" -v port="$port" -v resolver="$resolver" '
BEGIN {
base = "http://" backend ":" port
print "# Archipelago: refresh backend DNS after container replacement."
print "resolver " resolver " valid=5s ipv6=off; # archy-dns"
print "resolver_timeout 3s; # archy-dns"
}
/# Archipelago: refresh backend DNS/ || /# archy-dns/ { next }
/^[[:space:]]*location[[:space:]]/ { location = $2 }
/^[[:space:]]*proxy_pass[[:space:]]/ && index($2, base) == 1 {
target = $2
sub(/;$/, "", target)
path = substr(target, length(base) + 1)
if (location != "/api/v1/ws" && location != "/ws" && location != "/api/v1" && location != "/api/") {
print "Unexpected backend location: " location > "/dev/stderr"
failed = 1; exit 1
}
if (path != "/" && path != "/api/v1" && path != "/api/v1/") {
print "Unexpected backend URI mapping" > "/dev/stderr"
failed = 1; exit 1
}
# Explicitly preserve prefix substitution and query arguments. A variable
# proxy_pass without a URI forwards the rewritten URI and original args.
print "\t\tset $mp_backend " backend ";"
if (path != location)
print "\t\trewrite ^" location "(.*)$ " path "$1 break;"
print "\t\tproxy_pass http://$mp_backend:" port ";"
count++
next
}
/proxy_pass http:\/\/\$mp_backend:/ { count++ }
{ print }
END {
if (failed || count != 4) {
print "Expected four backend proxies; refusing an incomplete DNS repair" > "/dev/stderr"
exit 1
}
}
' "$conf" > "$tmp"
cat "$tmp" > "$conf"
+5
View File
@@ -0,0 +1,5 @@
#!/bin/sh
set -eu
/patch/repair-nginx.sh
nginx -t
exec "$@"
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "neode-ui",
"version": "1.8.15-alpha",
"version": "1.8.16-alpha",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "neode-ui",
"version": "1.8.15-alpha",
"version": "1.8.16-alpha",
"dependencies": {
"@scure/bip39": "^2.2.0",
"@types/dompurify": "^3.0.5",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "neode-ui",
"private": true,
"version": "1.8.15-alpha",
"version": "1.8.16-alpha",
"type": "module",
"scripts": {
"start": "./start-dev.sh",
+2 -2
View File
@@ -378,13 +378,13 @@
{
"id": "mempool",
"title": "Mempool Explorer",
"version": "3.0.0",
"version": "3.3.1-archy1",
"description": "Bitcoin mempool and blockchain explorer. Real-time transaction and block visualization.",
"icon": "/assets/img/app-icons/mempool.webp",
"author": "Mempool",
"category": "money",
"tier": "core",
"dockerImage": "source.archipelago-foundation.org/lfg2025/mempool-frontend:v3.3.1",
"dockerImage": "source.archipelago-foundation.org/chaum/mempool-frontend:v3.3.1-archy1",
"repoUrl": "https://github.com/mempool/mempool",
"requires": [
"bitcoin-knots",
@@ -362,6 +362,19 @@ init()
</button>
</div>
<div class="overflow-y-auto flex-1 min-h-0 space-y-6 pr-1">
<!-- v1.8.16-alpha -->
<div>
<div class="flex items-center gap-2 mb-3">
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.8.16-alpha</span>
<span class="text-xs text-white/40">September 15, 2026</span>
</div>
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
<p>App updates refresh and verify the signed catalog before changing containers. A failed refresh or manifest reload cancels the update, and automatic updates wait for a successful refresh.</p>
<p>Fixed repeated Mempool update offers: downstream -archyN patches now sort above their upstream release, and moving a published image between registry namespaces does not hide a genuine upgrade.</p>
<p>Updates inspect installed component versions, refuse known downgrades, skip containers already at the target versions, and verify the resulting versions before reporting success.</p>
<p>Added regression coverage for stale catalogs, matching versions, publisher namespace changes, stack component updates, and keeping running containers untouched when no upgrade is needed.</p>
</div>
</div>
<!-- v1.8.15-alpha -->
<div>
<div class="flex items-center gap-2 mb-3">
@@ -371,6 +384,7 @@ init()
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
<p>Cuprate is presented as one user-facing app in My Apps, including its UI launch button; the generated dashboard companion is hidden as an implementation detail instead of appearing under Services.</p>
<p>Added regression coverage for Cuprate install and installed-state grouping.</p>
<p>Release validation was rerun on the corrected tree before OTA and ISO publication.</p>
</div>
</div>
<!-- v1.8.14-alpha -->
+16 -17
View File
@@ -1,30 +1,29 @@
{
"changelog": [
"**Cuprate gains a first-party companion dashboard.** The Monero node now has a Bitcoin-style status UI, safe app grouping, a 450 GB disk-safety gate, and a restricted RPC that is never exposed as a launch page.",
"**Bitcoin Core Tor enrollment uses the correct protocol identity.** `bitcoin-core` is forwarded on port 8333 and resolves to its own hidden-service directory without disturbing legacy Bitcoin aliases.",
"**GitWorkshop opens Archipelago’s canonical ngit repository by default.** The launcher and registry promotion use the full maintainer/relay/`archy` coordinate, with regression coverage for Companion and browser-tab launches.",
"**Release validation is stricter.** The registry gate now checks the complete canonical source deep link, and the merged candidate passed the full frontend and focused backend test suites."
"Cuprate is presented as one user-facing app in My Apps, including its UI launch button; the generated dashboard companion is hidden as an implementation detail instead of appearing under Services.",
"Added regression coverage for Cuprate install and installed-state grouping.",
"Release validation was rerun on the corrected tree before OTA and ISO publication."
],
"components": [
{
"current_version": "1.8.14-alpha",
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.14-alpha/archipelago",
"current_version": "1.8.15-alpha",
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.15-alpha/archipelago",
"name": "archipelago",
"new_version": "1.8.14-alpha",
"sha256": "3d8e5e7c79a261649e89c4f5ba8d90db9057ebbb002919a812ca82f664b315bf",
"size_bytes": 64568360
"new_version": "1.8.15-alpha",
"sha256": "3eee71563337f20cb348529925c58b8227afec796783a69176e0b59b9e113c91",
"size_bytes": 64571544
},
{
"current_version": "1.8.14-alpha",
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.14-alpha/archipelago-frontend-1.8.14-alpha.tar.gz",
"name": "archipelago-frontend-1.8.14-alpha.tar.gz",
"new_version": "1.8.14-alpha",
"sha256": "e1c490e52571bf9238435e5986792c6bd602fd7e398783546f7592aa921d3386",
"size_bytes": 98792963
"current_version": "1.8.15-alpha",
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.15-alpha/archipelago-frontend-1.8.15-alpha.tar.gz",
"name": "archipelago-frontend-1.8.15-alpha.tar.gz",
"new_version": "1.8.15-alpha",
"sha256": "86a32ef3334b03c197e47d9d28f4435c6f2fa7c4ccacc839a8fc4a0a749495dc",
"size_bytes": 98797600
}
],
"release_date": "2026-09-13",
"signature": "dacfdd2707e415af8a42306a63658a7d41cdfeba29d43802d465d18f00a747ecbfa54ea4ee8ed1eedf94d4f30371453fd9c9d475af9d6a62eac4e2c8e783b405",
"signature": "5e13396e2f33571f136bb1a9ea0356486b3d42c6ba99ee5d33990cd565d9b1178f61eaf6a70a937a006e7de3fd388bcebd63f2daca4bb28accc1b810d8455d03",
"signed_by": "did:key:z6Mkfu5LT8d4DjETtrkATvHh9Dvcbnr7zBCUwfau8Sw7DLWT",
"version": "1.8.14-alpha"
"version": "1.8.15-alpha"
}
+3251 -3249
View File
File diff suppressed because one or more lines are too long
+16 -17
View File
@@ -1,30 +1,29 @@
{
"changelog": [
"**Cuprate gains a first-party companion dashboard.** The Monero node now has a Bitcoin-style status UI, safe app grouping, a 450 GB disk-safety gate, and a restricted RPC that is never exposed as a launch page.",
"**Bitcoin Core Tor enrollment uses the correct protocol identity.** `bitcoin-core` is forwarded on port 8333 and resolves to its own hidden-service directory without disturbing legacy Bitcoin aliases.",
"**GitWorkshop opens Archipelago’s canonical ngit repository by default.** The launcher and registry promotion use the full maintainer/relay/`archy` coordinate, with regression coverage for Companion and browser-tab launches.",
"**Release validation is stricter.** The registry gate now checks the complete canonical source deep link, and the merged candidate passed the full frontend and focused backend test suites."
"Cuprate is presented as one user-facing app in My Apps, including its UI launch button; the generated dashboard companion is hidden as an implementation detail instead of appearing under Services.",
"Added regression coverage for Cuprate install and installed-state grouping.",
"Release validation was rerun on the corrected tree before OTA and ISO publication."
],
"components": [
{
"current_version": "1.8.14-alpha",
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.14-alpha/archipelago",
"current_version": "1.8.15-alpha",
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.15-alpha/archipelago",
"name": "archipelago",
"new_version": "1.8.14-alpha",
"sha256": "3d8e5e7c79a261649e89c4f5ba8d90db9057ebbb002919a812ca82f664b315bf",
"size_bytes": 64568360
"new_version": "1.8.15-alpha",
"sha256": "3eee71563337f20cb348529925c58b8227afec796783a69176e0b59b9e113c91",
"size_bytes": 64571544
},
{
"current_version": "1.8.14-alpha",
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.14-alpha/archipelago-frontend-1.8.14-alpha.tar.gz",
"name": "archipelago-frontend-1.8.14-alpha.tar.gz",
"new_version": "1.8.14-alpha",
"sha256": "e1c490e52571bf9238435e5986792c6bd602fd7e398783546f7592aa921d3386",
"size_bytes": 98792963
"current_version": "1.8.15-alpha",
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.15-alpha/archipelago-frontend-1.8.15-alpha.tar.gz",
"name": "archipelago-frontend-1.8.15-alpha.tar.gz",
"new_version": "1.8.15-alpha",
"sha256": "86a32ef3334b03c197e47d9d28f4435c6f2fa7c4ccacc839a8fc4a0a749495dc",
"size_bytes": 98797600
}
],
"release_date": "2026-09-13",
"signature": "dacfdd2707e415af8a42306a63658a7d41cdfeba29d43802d465d18f00a747ecbfa54ea4ee8ed1eedf94d4f30371453fd9c9d475af9d6a62eac4e2c8e783b405",
"signature": "5e13396e2f33571f136bb1a9ea0356486b3d42c6ba99ee5d33990cd565d9b1178f61eaf6a70a937a006e7de3fd388bcebd63f2daca4bb28accc1b810d8455d03",
"signed_by": "did:key:z6Mkfu5LT8d4DjETtrkATvHh9Dvcbnr7zBCUwfau8Sw7DLWT",
"version": "1.8.14-alpha"
"version": "1.8.15-alpha"
}
@@ -1,28 +0,0 @@
{
"changelog": [
"Cuprate is presented as one user-facing app in My Apps, including its UI launch button; the generated dashboard companion is hidden as an implementation detail instead of appearing under Services.",
"Added regression coverage for Cuprate install and installed-state grouping."
],
"components": [
{
"current_version": "1.8.15-alpha",
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.15-alpha/archipelago",
"name": "archipelago",
"new_version": "1.8.15-alpha",
"sha256": "3eee71563337f20cb348529925c58b8227afec796783a69176e0b59b9e113c91",
"size_bytes": 64571544
},
{
"current_version": "1.8.15-alpha",
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.15-alpha/archipelago-frontend-1.8.15-alpha.tar.gz",
"name": "archipelago-frontend-1.8.15-alpha.tar.gz",
"new_version": "1.8.15-alpha",
"sha256": "41ad8a2ec3f3338f66a5ffffecbbf23872a61524cd0c64d7b392e00b9b40576b",
"size_bytes": 98798913
}
],
"release_date": "2026-09-13",
"signature": "9b4c074e5014ea940e58b3a195c9da90f975f0664c93b9576db3055cff8cb09e59b2ffbdb2f48e407e9890afaf3ca03d28f257ede925387b5ffb30ffb688b00d",
"signed_by": "did:key:z6Mkfu5LT8d4DjETtrkATvHh9Dvcbnr7zBCUwfau8Sw7DLWT",
"version": "1.8.15-alpha"
}
@@ -0,0 +1,30 @@
{
"changelog": [
"App updates refresh and verify the signed catalog before changing containers. A failed refresh or manifest reload cancels the update, and automatic updates wait for a successful refresh.",
"Fixed repeated Mempool update offers: downstream `-archyN` patches now sort above their upstream release, and moving a published image between registry namespaces does not hide a genuine upgrade.",
"Updates inspect installed component versions, refuse known downgrades, skip containers already at the target versions, and verify the resulting versions before reporting success.",
"Added regression coverage for stale catalogs, matching versions, publisher namespace changes, stack component updates, and keeping running containers untouched when no upgrade is needed."
],
"components": [
{
"current_version": "1.8.16-alpha",
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.16-alpha/archipelago",
"name": "archipelago",
"new_version": "1.8.16-alpha",
"sha256": "1800f57678a0b994ab2e43a830ef06d1c96fd3cc7be47ce4e6e46b7df8a5420f",
"size_bytes": 64851944
},
{
"current_version": "1.8.16-alpha",
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.16-alpha/archipelago-frontend-1.8.16-alpha.tar.gz",
"name": "archipelago-frontend-1.8.16-alpha.tar.gz",
"new_version": "1.8.16-alpha",
"sha256": "7dd73c50a54bc530385d9e450a18cbff9c3f4ffaf289a2a7b21e5d3803116722",
"size_bytes": 98799570
}
],
"release_date": "2026-09-15",
"signature": "083b131a6b895e1ff8fb9e9a52b1ead260e2140081a0295ae6756cbbc4f8f2c30e8a8bc72822c905702e21ac90f7cb85d5cca9b5f8c10fc87f32a365da202c0d",
"signed_by": "did:key:z6Mkfu5LT8d4DjETtrkATvHh9Dvcbnr7zBCUwfau8Sw7DLWT",
"version": "1.8.16-alpha"
}
+2 -1
View File
@@ -33,7 +33,8 @@ ELECTRUMX_IMAGE="$ARCHY_REGISTRY/electrumx:v1.18.0"
# Mempool stack
MEMPOOL_BACKEND_IMAGE="$ARCHY_REGISTRY/mempool-backend:v3.3.1"
MEMPOOL_WEB_IMAGE="$ARCHY_REGISTRY/mempool-frontend:v3.3.1"
# The patched frontend is published by chaum on the same trusted registry.
MEMPOOL_WEB_IMAGE="source.archipelago-foundation.org/chaum/mempool-frontend:v3.3.1-archy1"
MARIADB_IMAGE="$ARCHY_REGISTRY/mariadb:11.4.10"
# BTCPay
+20 -11
View File
@@ -53,24 +53,33 @@ if [ -x "$PROJECT_ROOT/core/target/release/archipelago" ]; then
fi
remote_url=$(git -C "$PROJECT_ROOT" remote get-url "$REMOTE")
# https is accepted as well as http. Requiring http:// meant the only remote
# whose credential actually works for git push (the https one) was rejected,
# while the http remote it forced you to use had a dead token — so publishing
# failed on auth after the manifest had already passed every check
# (v1.7.121-alpha, 2026-08-04). The scheme is carried through to the API URL
# rather than assumed.
# Remote URLs are public metadata: ngit can include them in repository
# announcements. Keep credentials in Git's credential helper, never in URLs.
case "$remote_url" in
http://*@*|https://*@*) ;;
*) fail "$REMOTE must be an authenticated http(s):// Gitea remote URL for API uploads" ;;
http://*@*|https://*@*) fail "$REMOTE embeds credentials; move them to a Git credential helper and remove them from the remote URL" ;;
http://*|https://*) ;;
*) fail "$REMOTE must be an http(s):// Gitea remote URL for API uploads" ;;
esac
scheme=${remote_url%%://*}
rest=${remote_url#*://}
auth=${rest%%@*}
host_path=${rest#*@}
host_path=${remote_url#*://}
host=${host_path%%/*}
repo_path=${host_path#*/}
repo_path=${repo_path%.git}
credential=$(printf 'url=%s\n\n' "$remote_url" | GIT_TERMINAL_PROMPT=0 git -C "$PROJECT_ROOT" credential fill) \
|| fail "no Git credential available for $REMOTE; configure a credential helper first"
auth_user=""
auth_password=""
while IFS= read -r field; do
case "$field" in
username=*) auth_user=${field#username=} ;;
password=*) auth_password=${field#password=} ;;
esac
done <<< "$credential"
[ -n "$auth_user" ] && [ -n "$auth_password" ] \
|| fail "Git credential helper did not provide a username and password for $REMOTE"
auth="$auth_user:$auth_password"
unset credential auth_user auth_password
api="$scheme://$host/api/v1/repos/$repo_path"
release_url="$api/releases/tags/v${VERSION}"
+136
View File
@@ -0,0 +1,136 @@
#!/usr/bin/env python3
"""Exercise the built frontend against a backend that disappears and changes IP.
Uses an isolated Podman network and disposable containers, never the node stack.
Usage: python3 scripts/test-mempool-dns-recovery.py [frontend-image]
"""
import ipaddress
import json
import socket
import subprocess
import sys
import time
import urllib.error
import urllib.request
import uuid
IMAGE = sys.argv[1] if len(sys.argv) > 1 else (
"source.archipelago-foundation.org/chaum/mempool-frontend:v3.3.1-archy1"
)
BACKEND = "source.archipelago-foundation.org/lfg2025/mempool-backend:v3.3.1"
prefix = "mempool-dns-test-" + uuid.uuid4().hex[:8]
network, frontend, backend = prefix, prefix + "-web", prefix + "-api"
def podman(*args, check=True):
return subprocess.run(["podman", *args], capture_output=True, text=True,
check=check, timeout=60).stdout.strip()
def eventually(check, timeout=25):
deadline = time.monotonic() + timeout
while True:
try:
return check()
except (AssertionError, OSError, urllib.error.URLError):
if time.monotonic() >= deadline:
raise
time.sleep(1)
server = r"""
const http = require('http'), crypto = require('crypto');
const server = http.createServer((req, res) => {
res.setHeader('Content-Type', 'application/json');
res.end(JSON.stringify({url: req.url, instance: process.env.INSTANCE}));
});
server.on('upgrade', (req, socket) => {
const key = crypto.createHash('sha1')
.update(req.headers['sec-websocket-key'] + '258EAFA5-E914-47DA-95CA-C5AB0DC85B11')
.digest('base64');
socket.end('HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\n' +
'Connection: Upgrade\r\nSec-WebSocket-Accept: ' + key + '\r\n' +
'X-Upstream-Url: ' + req.url + '\r\nX-Instance: ' + process.env.INSTANCE + '\r\n\r\n');
});
server.listen(8999, '0.0.0.0');
"""
try:
podman("network", "create", network)
subnet = ipaddress.ip_network(json.loads(podman("network", "inspect", network))[0]["subnets"][0]["subnet"])
podman("run", "-d", "--name", frontend, "--network", network,
"-p", "127.0.0.1::8080", "-e", "BACKEND_MAINNET_HTTP_HOST=mempool-api",
"-e", "FRONTEND_HTTP_PORT=8080", IMAGE)
port = int(podman("port", frontend, "8080/tcp").rsplit(":", 1)[1])
url = f"http://127.0.0.1:{port}"
def static_ready():
assert urllib.request.urlopen(url, timeout=4).status == 200
eventually(static_ready)
started = podman("inspect", frontend, "--format", "{{.State.StartedAt}}")
try:
urllib.request.urlopen(url + "/api/v1/backend-info", timeout=6)
raise AssertionError("An absent backend must not appear healthy")
except urllib.error.HTTPError as error:
assert error.code == 502
print("PASS: frontend starts while backend DNS is absent", flush=True)
for instance, offset in [("first", 10), ("replacement", 11)]:
if instance == "replacement":
podman("rm", "-f", backend)
# Ensure the cached address has expired while the backend is absent.
time.sleep(6)
podman("run", "-d", "--name", backend, "--network", network,
"--network-alias", "mempool-api", "--ip", str(subnet[offset]),
"-e", "INSTANCE=" + instance, "--entrypoint", "node", BACKEND,
"-e", server)
for path, expected in [
("/api/blocks/tip/height?probe=one", "/api/v1/blocks/tip/height?probe=one"),
("/api/v1/fees/recommended?probe=two", "/api/v1/fees/recommended?probe=two"),
]:
def check_http():
with urllib.request.urlopen(url + path, timeout=4) as response:
result = json.load(response)
assert result == {"url": expected, "instance": instance}, result
eventually(check_http)
for path in ["/api/v1/ws?probe=ws", "/ws?probe=ws"]:
def check_ws():
with socket.create_connection(("127.0.0.1", port), timeout=4) as sock:
sock.sendall((f"GET {path} HTTP/1.1\r\nHost: localhost\r\n"
"Upgrade: websocket\r\nConnection: Upgrade\r\n"
"Sec-WebSocket-Version: 13\r\n"
"Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n\r\n").encode())
response = b""
while b"\r\n\r\n" not in response:
part = sock.recv(4096)
assert part, response
response += part
assert b"101 Switching Protocols" in response, response
assert b"X-Upstream-Url: /?probe=ws" in response, response
assert ("X-Instance: " + instance).encode() in response, response
eventually(check_ws)
assert podman("inspect", frontend, "--format", "{{.State.StartedAt}}") == started
print(f"PASS: {instance} backend at {subnet[offset]}: HTTP paths, query strings, both WebSocket routes; frontend never restarted", flush=True)
before = podman("exec", frontend, "cat", "/etc/nginx/conf.d/nginx-mempool.conf")
podman("exec", frontend, "/patch/repair-nginx.sh")
assert podman("exec", frontend, "cat", "/etc/nginx/conf.d/nginx-mempool.conf") == before
podman("exec", frontend, "nginx", "-t")
print("PASS: repeated repair is idempotent and nginx configuration is valid", flush=True)
podman("restart", frontend)
eventually(static_ready)
def after_restart():
with urllib.request.urlopen(url + "/api/blocks/tip/height?restart=1", timeout=4) as response:
assert json.load(response) == {
"url": "/api/v1/blocks/tip/height?restart=1", "instance": "replacement"
}
eventually(after_restart)
eventually(check_ws)
print("PASS: frontend restart preserves DNS recovery and HTTP/WebSocket routing", flush=True)
finally:
podman("rm", "-f", frontend, backend, check=False)
podman("network", "rm", network, check=False)
+1 -1
View File
@@ -168,7 +168,7 @@ stage "cargo-check" timeout 580 cargo check --manifest-path core/Cargo.toml
# 3600s leaves headroom; a warm target/ finishes in a fraction of it.
stage "cargo-test-weekly" timeout 3600 env CARGO_INCREMENTAL=0 \
cargo test --manifest-path core/Cargo.toml -p archipelago -- \
update:: lnd container::image_versions scanner drift missing_secret collision
update:: lnd container::image_versions upgrade_preserves_container scanner drift missing_secret collision
# ── Stage 4: live node smoke ─────────────────────────────────────────
if [[ $LIVE -eq 1 ]]; then