Compare commits
19
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1b0ed281b2 | ||
|
|
9c6580f5c0 | ||
|
|
83abb0485d | ||
|
|
b35409ca74 | ||
|
|
700d39c425 | ||
|
|
4272c47ee5 | ||
|
|
c7cb043485 | ||
|
|
4dfe79290e | ||
|
|
d3e3df6d24 | ||
|
|
969570e38b | ||
|
|
b73d646db5 | ||
|
|
8c37ff412c | ||
|
|
06bf359535 | ||
|
|
a4f3415f0f | ||
|
|
c9c9ebe6d4 | ||
|
|
100993445b | ||
|
|
a4f80e7ec1 | ||
|
|
4ad34d3a0a | ||
|
|
c9bae926a5 |
@@ -2,6 +2,26 @@
|
||||
|
||||
## 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)
|
||||
|
||||
- **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.
|
||||
|
||||
## v1.8.13-alpha (2026-09-12)
|
||||
|
||||
- **GitWorkshop installs reliably on fresh nodes.** The app is classified as a user-facing app while its install placeholder is being created, so it remains visible under My Apps instead of Services.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
Generated
+1
-1
@@ -104,7 +104,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "archipelago"
|
||||
version = "1.8.13-alpha"
|
||||
version = "1.8.16-alpha"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"archipelago-container",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "archipelago"
|
||||
version = "1.8.13-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;
|
||||
}
|
||||
|
||||
@@ -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(¤t, &installed).unwrap());
|
||||
let legacy = vec![(
|
||||
"mempool-web".into(),
|
||||
"r.test/old/mempool-frontend:v3.3.1-archy1".into(),
|
||||
)];
|
||||
assert!(!update_targets_need_change(¤t, &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,
|
||||
);
|
||||
|
||||
@@ -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", ®istry);
|
||||
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());
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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/
|
||||
|
||||
@@ -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 `/`.
|
||||
Regular → Executable
+4
-136
@@ -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 "$@"
|
||||
|
||||
Executable
+56
@@ -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"
|
||||
Executable
+5
@@ -0,0 +1,5 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
/patch/repair-nginx.sh
|
||||
nginx -t
|
||||
exec "$@"
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "neode-ui",
|
||||
"version": "1.8.13-alpha",
|
||||
"version": "1.8.16-alpha",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "neode-ui",
|
||||
"version": "1.8.13-alpha",
|
||||
"version": "1.8.16-alpha",
|
||||
"dependencies": {
|
||||
"@scure/bip39": "^2.2.0",
|
||||
"@types/dompurify": "^3.0.5",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "neode-ui",
|
||||
"private": true,
|
||||
"version": "1.8.13-alpha",
|
||||
"version": "1.8.16-alpha",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "./start-dev.sh",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -95,7 +95,7 @@ describe('useAppLauncherStore', () => {
|
||||
const store = useAppLauncherStore()
|
||||
store.openSession('archipelago-source')
|
||||
expect(openInApp).toHaveBeenCalledWith(
|
||||
'http://192.0.2.10/app/archipelago-source/',
|
||||
'http://192.0.2.10/app/archipelago-source/npub1w3sqdkrhn0gyuvsex32effzgnfpyde6qrrc4u467flg5e9txh4wsfn5vjg/relay.ngit.dev/archy',
|
||||
)
|
||||
expect(store.panelAppId).toBeNull()
|
||||
expect(store.isOpen).toBe(false)
|
||||
|
||||
@@ -78,12 +78,11 @@ describe('appsConfig service filtering', () => {
|
||||
|
||||
it('shows Cuprate as one My Apps entry while hiding its daemon dependency', () => {
|
||||
const entries: Array<[string, PackageDataEntry]> = [
|
||||
['cuprate-ui', makePkg('cuprate-ui', 'Cuprate UI', 'money')],
|
||||
['cuprate', makePkg('cuprate', 'Cuprate daemon', 'money')],
|
||||
['cuprate', makePkg('cuprate', 'Cuprate', 'money')],
|
||||
['archy-cuprate-ui', makePkg('archy-cuprate-ui', 'Cuprate UI companion', 'money')],
|
||||
]
|
||||
;(entries[0][1].manifest as unknown as Record<string, unknown>).interfaces = { main: { ui: 'http://localhost:18091' } }
|
||||
expect(filterEntriesForTab(entries, 'apps', 'all').map(([id]) => id)).toEqual(['cuprate-ui'])
|
||||
expect(filterEntriesForTab(entries, 'services', 'all').map(([id]) => id)).toEqual(['cuprate'])
|
||||
expect(filterEntriesForTab(entries, 'apps', 'all').map(([id]) => id)).toEqual(['cuprate'])
|
||||
expect(filterEntriesForTab(entries, 'services', 'all').map(([id]) => id)).toEqual([])
|
||||
})
|
||||
|
||||
it('falls back to packaged app icon when static icon token is not a path', () => {
|
||||
|
||||
@@ -20,6 +20,10 @@ export const isServiceContainer = sharedIsServiceContainer
|
||||
|
||||
const INTERNAL_TOOLING_NAMES = new Set([
|
||||
'buildx_buildkit_default',
|
||||
// Cuprate's dashboard is bundled as a companion of the primary cuprate
|
||||
// package; showing the generated container as a second Services entry
|
||||
// defeats the one-app presentation.
|
||||
'archy-cuprate-ui',
|
||||
])
|
||||
|
||||
export function isInternalToolingPackage(id: string, pkg?: PackageDataEntry): boolean {
|
||||
@@ -37,7 +41,7 @@ export function isServicePackage(id: string, pkg?: PackageDataEntry): boolean {
|
||||
|
||||
// Known app -> category mappings (matches App Store categorisation)
|
||||
export const APP_CATEGORY_MAP: Record<string, string> = {
|
||||
'bitcoin-core': 'money', 'bitcoin-knots': 'money', 'bitcoin-ui': 'money', 'cuprate-ui': 'money', 'electrumx': 'money', 'electrs': 'money',
|
||||
'bitcoin-core': 'money', 'bitcoin-knots': 'money', 'bitcoin-ui': 'money', 'cuprate': 'money', 'cuprate-ui': 'money', 'electrumx': 'money', 'electrs': 'money',
|
||||
'lnd': 'money', 'mempool': 'money', 'mempool-web': 'money', 'btcpay-server': 'commerce',
|
||||
'fedimint': 'money', 'fedimint-gateway': 'money',
|
||||
'indeedhub': 'media', 'jellyfin': 'media', 'photoprism': 'media', 'immich': 'media',
|
||||
|
||||
@@ -14,8 +14,10 @@
|
||||
// SERVICE_NAMES set that used to live in appsConfig.ts verbatim.
|
||||
export const SERVICE_NAMES = new Set([
|
||||
'dwn', 'archy-mempool-db', 'archy-btcpay-db', 'archy-nbxplorer', 'archy-tor',
|
||||
// Cuprate's daemon is the backend dependency of the Cuprate UI app.
|
||||
'cuprate',
|
||||
// Cuprate is presented as one user-facing app. Its companion container
|
||||
// (archy-cuprate-ui) is the implementation detail; do not classify the
|
||||
// primary package as a Service or it disappears from My Apps and loses its
|
||||
// launch button during/after install.
|
||||
// Headless backends with no user-facing UI: the Fedimint ecash client daemon,
|
||||
// the Nostr relay, and the Meshtastic LoRa daemon (its chat UI lives in the
|
||||
// built-in Mesh tab) belong in Services, not My Apps.
|
||||
|
||||
@@ -362,6 +362,44 @@ 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">
|
||||
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.8.15-alpha</span>
|
||||
<span class="text-xs text-white/40">September 13, 2026</span>
|
||||
</div>
|
||||
<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 -->
|
||||
<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.14-alpha</span>
|
||||
<span class="text-xs text-white/40">September 13, 2026</span>
|
||||
</div>
|
||||
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
|
||||
<p><strong>Cuprate gains a first-party companion dashboard.</strong> 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.</p>
|
||||
<p><strong>Bitcoin Core Tor enrollment uses the correct protocol identity.</strong> bitcoin-core is forwarded on port 8333 and resolves to its own hidden-service directory without disturbing legacy Bitcoin aliases.</p>
|
||||
<p><strong>GitWorkshop opens Archipelago’s canonical ngit repository by default.</strong> The launcher and registry promotion use the full maintainer/relay/archy coordinate, with regression coverage for Companion and browser-tab launches.</p>
|
||||
<p><strong>Release validation is stricter.</strong> The registry gate now checks the complete canonical source deep link, and the merged candidate passed the full frontend and focused backend test suites.</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- v1.8.13-alpha -->
|
||||
<div>
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
@@ -369,7 +407,9 @@ init()
|
||||
<span class="text-xs text-white/40">September 12, 2026</span>
|
||||
</div>
|
||||
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
|
||||
<p><strong>GitWorkshop installs reliably on fresh nodes.</strong> The app is classified as a user-facing app while its install placeholder is being created, so it remains visible under My Apps instead of Services. Fresh installs now use the production orchestrator to build the bundled GitWorkshop image rather than sending its local image reference through the legacy registry-pull path. Regression coverage now protects all curated app classifications.</p>
|
||||
<p><strong>GitWorkshop installs reliably on fresh nodes.</strong> The app is classified as a user-facing app while its install placeholder is being created, so it remains visible under My Apps instead of Services.</p>
|
||||
<p><strong>Fresh GitWorkshop installs build the correct image.</strong> The production orchestrator handles its bundled build context instead of sending the local image reference through the legacy registry-pull path.</p>
|
||||
<p><strong>Curated app classification is regression-tested.</strong> Every user-facing app remains in My Apps during installation, while headless services stay in Services.</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- v1.8.12-alpha -->
|
||||
|
||||
+17
-17
@@ -1,29 +1,29 @@
|
||||
{
|
||||
"changelog": [
|
||||
"**GitWorkshop installs reliably on fresh nodes.** The app is classified as a user-facing app while its install placeholder is being created, so it remains visible under My Apps instead of Services.",
|
||||
"**Fresh GitWorkshop installs build the correct image.** The production orchestrator handles its bundled build context instead of sending the local image reference through the legacy registry-pull path.",
|
||||
"**Curated app classification is regression-tested.** Every user-facing app remains in My Apps during installation, while headless services stay in Services."
|
||||
"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.13-alpha",
|
||||
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.13-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.13-alpha",
|
||||
"sha256": "832c7e75b395f94f919a21d1ad7d32d367e1ef84a0e0995b4e8fe87268aa21a4",
|
||||
"size_bytes": 64585424
|
||||
"new_version": "1.8.15-alpha",
|
||||
"sha256": "3eee71563337f20cb348529925c58b8227afec796783a69176e0b59b9e113c91",
|
||||
"size_bytes": 64571544
|
||||
},
|
||||
{
|
||||
"current_version": "1.8.13-alpha",
|
||||
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.13-alpha/archipelago-frontend-1.8.13-alpha.tar.gz",
|
||||
"name": "archipelago-frontend-1.8.13-alpha.tar.gz",
|
||||
"new_version": "1.8.13-alpha",
|
||||
"sha256": "d0159b61f84eb30013634a97177801474376dddb1189024b94f16236ce7ff538",
|
||||
"size_bytes": 97915796
|
||||
"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-12",
|
||||
"signature": "45a322e793a2fc7565cc148f14efbbcf03e0c934183590375368091368ffe49ed2a7e9962692caf54b3432c71976a6c1b0f06a48cf8c4634c7b2e573e5782a0f",
|
||||
"release_date": "2026-09-13",
|
||||
"signature": "5e13396e2f33571f136bb1a9ea0356486b3d42c6ba99ee5d33990cd565d9b1178f61eaf6a70a937a006e7de3fd388bcebd63f2daca4bb28accc1b810d8455d03",
|
||||
"signed_by": "did:key:z6Mkfu5LT8d4DjETtrkATvHh9Dvcbnr7zBCUwfau8Sw7DLWT",
|
||||
"version": "1.8.13-alpha"
|
||||
"version": "1.8.15-alpha"
|
||||
}
|
||||
|
||||
+3251
-3249
File diff suppressed because one or more lines are too long
+17
-17
@@ -1,29 +1,29 @@
|
||||
{
|
||||
"changelog": [
|
||||
"**GitWorkshop installs reliably on fresh nodes.** The app is classified as a user-facing app while its install placeholder is being created, so it remains visible under My Apps instead of Services.",
|
||||
"**Fresh GitWorkshop installs build the correct image.** The production orchestrator handles its bundled build context instead of sending the local image reference through the legacy registry-pull path.",
|
||||
"**Curated app classification is regression-tested.** Every user-facing app remains in My Apps during installation, while headless services stay in Services."
|
||||
"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.13-alpha",
|
||||
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.13-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.13-alpha",
|
||||
"sha256": "832c7e75b395f94f919a21d1ad7d32d367e1ef84a0e0995b4e8fe87268aa21a4",
|
||||
"size_bytes": 64585424
|
||||
"new_version": "1.8.15-alpha",
|
||||
"sha256": "3eee71563337f20cb348529925c58b8227afec796783a69176e0b59b9e113c91",
|
||||
"size_bytes": 64571544
|
||||
},
|
||||
{
|
||||
"current_version": "1.8.13-alpha",
|
||||
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.13-alpha/archipelago-frontend-1.8.13-alpha.tar.gz",
|
||||
"name": "archipelago-frontend-1.8.13-alpha.tar.gz",
|
||||
"new_version": "1.8.13-alpha",
|
||||
"sha256": "d0159b61f84eb30013634a97177801474376dddb1189024b94f16236ce7ff538",
|
||||
"size_bytes": 97915796
|
||||
"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-12",
|
||||
"signature": "45a322e793a2fc7565cc148f14efbbcf03e0c934183590375368091368ffe49ed2a7e9962692caf54b3432c71976a6c1b0f06a48cf8c4634c7b2e573e5782a0f",
|
||||
"release_date": "2026-09-13",
|
||||
"signature": "5e13396e2f33571f136bb1a9ea0356486b3d42c6ba99ee5d33990cd565d9b1178f61eaf6a70a937a006e7de3fd388bcebd63f2daca4bb28accc1b810d8455d03",
|
||||
"signed_by": "did:key:z6Mkfu5LT8d4DjETtrkATvHh9Dvcbnr7zBCUwfau8Sw7DLWT",
|
||||
"version": "1.8.13-alpha"
|
||||
"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"
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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}"
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -104,7 +104,7 @@ if "archipelago-source" not in apps:
|
||||
source_promotions = [item for item in promotions if item.get("id") == "archipelago-source"]
|
||||
if not source_promotions:
|
||||
raise SystemExit("registry candidate omits the Archipelago source promotion")
|
||||
expected_path = "/npub1w3sqdkrhn0gyuvsex32effzgnfpyde6qrrc4u467flg5e9txh4wsfn5vjg/archy"
|
||||
expected_path = "/npub1w3sqdkrhn0gyuvsex32effzgnfpyde6qrrc4u467flg5e9txh4wsfn5vjg/relay.ngit.dev/archy"
|
||||
if source_promotions[0].get("path") != expected_path:
|
||||
raise SystemExit("source promotion does not open the canonical Archipelago repository")
|
||||
print("registry candidate includes GitWorkshop and its source promotion")
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user