Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1b0ed281b2 | ||
|
|
9c6580f5c0 | ||
|
|
83abb0485d | ||
|
|
b35409ca74 | ||
|
|
700d39c425 | ||
|
|
4272c47ee5 | ||
|
|
c7cb043485 | ||
|
|
4dfe79290e | ||
|
|
d3e3df6d24 |
@@ -2,10 +2,18 @@
|
|||||||
|
|
||||||
## Unreleased
|
## 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)
|
## 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.
|
- 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.
|
- 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)
|
## v1.8.14-alpha (2026-09-13)
|
||||||
|
|
||||||
|
|||||||
@@ -378,13 +378,13 @@
|
|||||||
{
|
{
|
||||||
"id": "mempool",
|
"id": "mempool",
|
||||||
"title": "Mempool Explorer",
|
"title": "Mempool Explorer",
|
||||||
"version": "3.0.0",
|
"version": "3.3.1-archy1",
|
||||||
"description": "Bitcoin mempool and blockchain explorer. Real-time transaction and block visualization.",
|
"description": "Bitcoin mempool and blockchain explorer. Real-time transaction and block visualization.",
|
||||||
"icon": "/assets/img/app-icons/mempool.webp",
|
"icon": "/assets/img/app-icons/mempool.webp",
|
||||||
"author": "Mempool",
|
"author": "Mempool",
|
||||||
"category": "money",
|
"category": "money",
|
||||||
"tier": "core",
|
"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",
|
"repoUrl": "https://github.com/mempool/mempool",
|
||||||
"requires": [
|
"requires": [
|
||||||
"bitcoin-knots",
|
"bitcoin-knots",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
app:
|
app:
|
||||||
id: archy-mempool-web
|
id: archy-mempool-web
|
||||||
name: 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
|
# 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:
|
# 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.
|
# container.image names our mirror, not the project it was mirrored from.
|
||||||
@@ -12,7 +12,7 @@ app:
|
|||||||
container_name: mempool
|
container_name: mempool
|
||||||
|
|
||||||
container:
|
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
|
pull_policy: if-not-present
|
||||||
network: archy-net
|
network: archy-net
|
||||||
|
|
||||||
@@ -45,7 +45,9 @@ app:
|
|||||||
# first, but nginx binds 0.0.0.0:8080 (IPv4) only -> localhost probe gets
|
# first, but nginx binds 0.0.0.0:8080 (IPv4) only -> localhost probe gets
|
||||||
# "connection refused" -> perpetual unhealthy -> health_monitor restart loop.
|
# "connection refused" -> perpetual unhealthy -> health_monitor restart loop.
|
||||||
endpoint: http://127.0.0.1:8080
|
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
|
interval: 30s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 3
|
retries: 3
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
app:
|
app:
|
||||||
id: mempool
|
id: mempool
|
||||||
name: Mempool Explorer
|
name: Mempool Explorer
|
||||||
version: 3.0.0
|
version: 3.3.1-archy1
|
||||||
# Where this app comes from, so scripts/check-upstream-releases.py can
|
# 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:
|
# 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.
|
# 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.
|
description: Bitcoin mempool and blockchain explorer. Real-time transaction and block visualization.
|
||||||
|
|
||||||
container:
|
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://...
|
image_signature: cosign://...
|
||||||
pull_policy: if-not-present
|
pull_policy: if-not-present
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -67,13 +67,13 @@
|
|||||||
{
|
{
|
||||||
"id": "mempool",
|
"id": "mempool",
|
||||||
"title": "Mempool Explorer",
|
"title": "Mempool Explorer",
|
||||||
"version": "3.0.0",
|
"version": "3.3.1-archy1",
|
||||||
"description": "Bitcoin mempool and blockchain explorer. Real-time transaction and block visualization.",
|
"description": "Bitcoin mempool and blockchain explorer. Real-time transaction and block visualization.",
|
||||||
"icon": "/assets/img/app-icons/mempool.webp",
|
"icon": "/assets/img/app-icons/mempool.webp",
|
||||||
"author": "Mempool",
|
"author": "Mempool",
|
||||||
"category": "money",
|
"category": "money",
|
||||||
"tier": "core",
|
"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",
|
"repoUrl": "https://github.com/mempool/mempool",
|
||||||
"requires": [
|
"requires": [
|
||||||
"bitcoin-knots",
|
"bitcoin-knots",
|
||||||
|
|||||||
Generated
+1
-1
@@ -104,7 +104,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "archipelago"
|
name = "archipelago"
|
||||||
version = "1.8.15-alpha"
|
version = "1.8.16-alpha"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"archipelago-container",
|
"archipelago-container",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "archipelago"
|
name = "archipelago"
|
||||||
version = "1.8.15-alpha"
|
version = "1.8.16-alpha"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
license.workspace = true
|
license.workspace = true
|
||||||
description = "Archipelago Bitcoin Node OS - Native backend"
|
description = "Archipelago Bitcoin Node OS - Native backend"
|
||||||
|
|||||||
@@ -330,7 +330,7 @@ impl RpcHandler {
|
|||||||
let package_id_spawn = package_id.clone();
|
let package_id_spawn = package_id.clone();
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
match handler.handle_package_update(params).await {
|
match handler.handle_package_update(params).await {
|
||||||
Ok(_) => {
|
Ok(result) => {
|
||||||
info!("package.update {}: complete", package_id_spawn);
|
info!("package.update {}: complete", package_id_spawn);
|
||||||
// Same reasoning as install: the merge_preserving_transitional
|
// Same reasoning as install: the merge_preserving_transitional
|
||||||
// helper treats Updating as RPC-owned, so we MUST write the
|
// helper treats Updating as RPC-owned, so we MUST write the
|
||||||
@@ -345,7 +345,11 @@ impl RpcHandler {
|
|||||||
set_package_state(
|
set_package_state(
|
||||||
&handler.state_manager,
|
&handler.state_manager,
|
||||||
&package_id_spawn,
|
&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;
|
.await;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ use tracing::{error, info, warn};
|
|||||||
const PODMAN_UPDATE_PULL_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(600);
|
const PODMAN_UPDATE_PULL_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(600);
|
||||||
|
|
||||||
impl RpcHandler {
|
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.
|
/// This is a manual operation — the user clicks "Update" in the UI.
|
||||||
pub(in crate::api::rpc) async fn handle_package_update(
|
pub(in crate::api::rpc) async fn handle_package_update(
|
||||||
&self,
|
&self,
|
||||||
@@ -32,6 +32,21 @@ impl RpcHandler {
|
|||||||
.ok_or_else(|| anyhow::anyhow!("Missing package id"))?;
|
.ok_or_else(|| anyhow::anyhow!("Missing package id"))?;
|
||||||
validate_app_id(package_id)?;
|
validate_app_id(package_id)?;
|
||||||
|
|
||||||
|
// 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
|
// Resolve the target image. Prefer the remote app catalog (decoupled
|
||||||
// from the binary OTA), falling back to the image-versions.sh pin. This
|
// from the binary OTA), falling back to the image-versions.sh pin. This
|
||||||
// is OPTIONAL for orchestrator-managed apps: the orchestrator resolves
|
// 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)
|
let pinned = crate::container::app_catalog::catalog_primary_image(package_id)
|
||||||
.or_else(|| image_versions::pinned_image_for_app(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`
|
// Note: the `already updating` guard lives in `spawn_package_update`
|
||||||
// (the async wrapper that dispatch actually routes to). By the time
|
// (the async wrapper that dispatch actually routes to). By the time
|
||||||
// this inner function runs, the wrapper has already flipped state to
|
// 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() {
|
if let Some(orchestrator) = self.orchestrator.as_ref() {
|
||||||
match orchestrator.upgrade(orchestrator_app_id).await {
|
match orchestrator.upgrade(orchestrator_app_id).await {
|
||||||
Ok(()) => {
|
Ok(()) => {
|
||||||
|
if let Some(targets) = &targets {
|
||||||
|
verify_update_targets(
|
||||||
|
targets,
|
||||||
|
&inspect_update_images(package_id).await?,
|
||||||
|
)?;
|
||||||
|
}
|
||||||
self.set_install_phase(package_id, InstallPhase::WaitingHealthy)
|
self.set_install_phase(package_id, InstallPhase::WaitingHealthy)
|
||||||
.await;
|
.await;
|
||||||
if let Ok(health) = orchestrator.health(orchestrator_app_id).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
|
// 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
|
// Get all containers for this app
|
||||||
let containers = get_containers_for_app(package_id).await?;
|
let containers = get_containers_for_app(package_id).await?;
|
||||||
@@ -324,15 +362,22 @@ impl RpcHandler {
|
|||||||
.await;
|
.await;
|
||||||
if let Ok(o) = status {
|
if let Ok(o) = status {
|
||||||
let state = String::from_utf8_lossy(&o.stdout).trim().to_string();
|
let state = String::from_utf8_lossy(&o.stdout).trim().to_string();
|
||||||
if state == "exited" {
|
anyhow::ensure!(
|
||||||
warn!(
|
o.status.success() && state == "running",
|
||||||
"Update {}: container {} exited after recreate",
|
"Update {}: container {} is not running after recreate",
|
||||||
package_id, name
|
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(())
|
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 {
|
fn should_try_orchestrator_update(package_id: &str, orchestrator_available: bool) -> bool {
|
||||||
orchestrator_available && !uses_legacy_update_flow(package_id)
|
orchestrator_available && !uses_legacy_update_flow(package_id)
|
||||||
}
|
}
|
||||||
@@ -526,7 +663,10 @@ fn orchestrator_update_app_id(package_id: &str) -> &str {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn uses_legacy_update_flow(package_id: &str) -> bool {
|
fn uses_legacy_update_flow(package_id: &str) -> bool {
|
||||||
matches!(
|
// 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,
|
package_id,
|
||||||
// Multi-container stacks still updated via the stack-aware path.
|
// Multi-container stacks still updated via the stack-aware path.
|
||||||
"immich" | "penpot" | "penpot-frontend" | "indeedhub"
|
"immich" | "penpot" | "penpot-frontend" | "indeedhub"
|
||||||
@@ -554,7 +694,12 @@ fn candidate_app_ids_for_container(container_name: &str) -> Vec<String> {
|
|||||||
"archy-bitcoin-ui" => push("bitcoin-ui"),
|
"archy-bitcoin-ui" => push("bitcoin-ui"),
|
||||||
"archy-lnd-ui" => push("lnd-ui"),
|
"archy-lnd-ui" => push("lnd-ui"),
|
||||||
"archy-electrs-ui" => push("electrs-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("archy-mempool-web");
|
||||||
push("mempool");
|
push("mempool");
|
||||||
}
|
}
|
||||||
@@ -572,27 +717,89 @@ fn candidate_app_ids_for_container(container_name: &str) -> Vec<String> {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::{
|
use super::{
|
||||||
candidate_app_ids_for_container, orchestrator_update_app_id,
|
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]
|
#[test]
|
||||||
fn legacy_flow_for_stack_apps() {
|
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");
|
assert!(uses_legacy_update_flow(app), "{app} should stay legacy");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn orchestrator_flow_for_single_apps() {
|
fn orchestrator_flow_for_single_apps() {
|
||||||
for app in [
|
for app in ["lnd", "bitcoin-core", "searxng", "grafana", "fedimint"] {
|
||||||
"lnd",
|
|
||||||
"bitcoin-core",
|
|
||||||
"searxng",
|
|
||||||
"grafana",
|
|
||||||
"btcpay-server",
|
|
||||||
"mempool",
|
|
||||||
"fedimint",
|
|
||||||
] {
|
|
||||||
assert!(
|
assert!(
|
||||||
!uses_legacy_update_flow(app),
|
!uses_legacy_update_flow(app),
|
||||||
"{app} should be orchestrator-first"
|
"{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) {
|
if let Some(catalog_image) = catalog_primary_image(app_id) {
|
||||||
// Catalog covers this app with a concrete image -> authoritative.
|
// 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,
|
&catalog_image,
|
||||||
running_image,
|
running_image,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -100,6 +100,12 @@ fn parse_image_versions(content: &str) -> HashMap<String, String> {
|
|||||||
|
|
||||||
// Match VAR="value" or VAR=value
|
// Match VAR="value" or VAR=value
|
||||||
if let Some((key, val)) = parse_assignment(line) {
|
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);
|
let expanded = val.replace("$ARCHY_REGISTRY", ®istry);
|
||||||
if key == "ARCHY_REGISTRY" {
|
if key == "ARCHY_REGISTRY" {
|
||||||
registry = expanded.clone();
|
registry = expanded.clone();
|
||||||
@@ -205,46 +211,69 @@ 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> {
|
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) {
|
if is_floating_tag(&pinned_version) {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
if matches!(
|
||||||
let running_version = extract_version_from_image(running_image);
|
compare_image_versions(pinned, running_image),
|
||||||
if pinned_version == running_version {
|
Some(std::cmp::Ordering::Less | std::cmp::Ordering::Equal)
|
||||||
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 p < r {
|
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
Some(pinned_version)
|
||||||
}
|
}
|
||||||
|
|
||||||
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.
|
/// Numeric components of a version tag, for ordering comparisons only.
|
||||||
@@ -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]
|
#[test]
|
||||||
fn test_parse_image_versions() {
|
fn test_parse_image_versions() {
|
||||||
let content = r#"
|
let content = r#"
|
||||||
@@ -445,6 +525,22 @@ NOT_AN_IMAGE="something"
|
|||||||
assert!(!parsed.contains_key("ARCHY_REGISTRY"));
|
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]
|
#[test]
|
||||||
fn test_image_var_mapping() {
|
fn test_image_var_mapping() {
|
||||||
assert_eq!(image_var_for_app("lnd"), Some("LND_IMAGE"));
|
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 lock = self.app_lock(app_id).await;
|
||||||
let _guard = lock.lock().await;
|
let _guard = lock.lock().await;
|
||||||
let name = compute_container_name(&lm.manifest);
|
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.stop_container(&name).await;
|
||||||
let _ = self.runtime.remove_container(&name).await;
|
let _ = self.runtime.remove_container(&name).await;
|
||||||
self.install_fresh(&lm).await
|
self.install_fresh(&lm).await
|
||||||
@@ -5076,6 +5097,7 @@ mod tests {
|
|||||||
calls: StdMutex<Vec<String>>,
|
calls: StdMutex<Vec<String>>,
|
||||||
/// container_name -> ContainerState. Absence = "doesn't exist".
|
/// container_name -> ContainerState. Absence = "doesn't exist".
|
||||||
containers: StdMutex<HashMap<String, ContainerState>>,
|
containers: StdMutex<HashMap<String, ContainerState>>,
|
||||||
|
running_images: StdMutex<HashMap<String, String>>,
|
||||||
/// container_name -> Podman health status.
|
/// container_name -> Podman health status.
|
||||||
health: StdMutex<HashMap<String, String>>,
|
health: StdMutex<HashMap<String, String>>,
|
||||||
/// image_ref -> present. Absence = "not present in local storage".
|
/// image_ref -> present. Absence = "not present in local storage".
|
||||||
@@ -5200,7 +5222,13 @@ mod tests {
|
|||||||
health,
|
health,
|
||||||
exit_code: None,
|
exit_code: None,
|
||||||
started_at: 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(),
|
created: "now".to_string(),
|
||||||
ports: vec![],
|
ports: vec![],
|
||||||
lan_address: None,
|
lan_address: None,
|
||||||
@@ -6771,6 +6799,41 @@ app:
|
|||||||
assert_eq!(ids, vec!["bitcoin-knots", "bitcoin-ui"]);
|
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]
|
#[tokio::test]
|
||||||
async fn upgrade_removes_and_reinstalls() {
|
async fn upgrade_removes_and_reinstalls() {
|
||||||
let rt = Arc::new(MockRuntime::default());
|
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
|
/// Reload after every successful refresh, including unchanged bytes: the cache
|
||||||
/// orchestrator's manifest map so registry-shipped manifest changes take
|
/// may have been written before a previous reload failed. Auto-updates only run
|
||||||
/// effect now instead of at the next service restart.
|
/// when the catalog and the orchestrator's manifests are ready together.
|
||||||
async fn reload_manifests_if_changed(
|
async fn reload_catalog_manifests(
|
||||||
refresh: crate::container::app_catalog::CatalogRefresh,
|
_refresh: crate::container::app_catalog::CatalogRefresh,
|
||||||
orchestrator: &Option<std::sync::Arc<dyn crate::container::traits::ContainerOrchestrator>>,
|
orchestrator: &Option<std::sync::Arc<dyn crate::container::traits::ContainerOrchestrator>>,
|
||||||
) {
|
) -> bool {
|
||||||
if !refresh.changed {
|
let Some(orch) = orchestrator else {
|
||||||
return;
|
return false;
|
||||||
}
|
};
|
||||||
let Some(orch) = orchestrator else { return };
|
|
||||||
match orch.reload_manifests().await {
|
match orch.reload_manifests().await {
|
||||||
Ok(n) => info!("Update scheduler: catalog changed, reloaded {n} manifest(s)"),
|
Ok(n) => {
|
||||||
Err(e) => warn!("Update scheduler: manifest reload after catalog change failed: {e}"),
|
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"
|
// Refresh the app catalog once at startup so per-app "update available"
|
||||||
// badges appear without waiting for the first hourly tick.
|
// badges appear without waiting for the first hourly tick.
|
||||||
match crate::container::app_catalog::refresh_catalog(&data_dir).await {
|
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!(
|
Err(e) => debug!(
|
||||||
"Update scheduler: initial app-catalog refresh failed: {}",
|
"Update scheduler: initial app-catalog refresh failed: {}",
|
||||||
e
|
e
|
||||||
@@ -2204,14 +2211,22 @@ pub async fn run_update_scheduler(
|
|||||||
// previously cached catalog stays in place (origin-always-wins).
|
// previously cached catalog stays in place (origin-always-wins).
|
||||||
// A changed catalog also reloads the orchestrator's manifest overlay so
|
// A changed catalog also reloads the orchestrator's manifest overlay so
|
||||||
// catalog-shipped manifest fixes apply without a service restart.
|
// catalog-shipped manifest fixes apply without a service restart.
|
||||||
match crate::container::app_catalog::refresh_catalog(&data_dir).await {
|
let catalog_ready = 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: app-catalog refresh failed: {}", e),
|
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
|
// Per-app updates require fresh, loaded manifests; a failed refresh
|
||||||
// regardless of the binary-OTA schedule below; opt-in + pin-respecting.
|
// may still show cached badges but must not trigger container changes.
|
||||||
|
if catalog_ready {
|
||||||
apply_per_app_auto_updates(&orchestrator).await;
|
apply_per_app_auto_updates(&orchestrator).await;
|
||||||
|
}
|
||||||
|
|
||||||
let state = match load_state(&data_dir).await {
|
let state = match load_state(&data_dir).await {
|
||||||
Ok(s) => s,
|
Ok(s) => s,
|
||||||
|
|||||||
@@ -1,14 +1,13 @@
|
|||||||
# Archipelago mempool frontend — adds a resilient nginx backend proxy.
|
# Archipelago mempool frontend — adds a resilient nginx backend proxy.
|
||||||
#
|
#
|
||||||
# The only delta vs the upstream image is /patch/entrypoint.sh, which rewrites
|
# Keep the upstream startup logic; repair its rendered proxy configuration.
|
||||||
# the generated nginx-mempool.conf to use `resolver` + a variable proxy_pass so
|
# Publish this derived image under an Archipelago-specific tag, never the
|
||||||
# the frontend re-resolves the backend (mempool-api) via DNS on every request.
|
# upstream version tag that the registry mirror can overwrite.
|
||||||
# Without this, nginx pins the backend IP at startup and serves 502 / "offline"
|
ARG BASE=source.archipelago-foundation.org/lfg2025/mempool-frontend@sha256:d63498a109622475c913db4e3199d893f2440a451450e542923d2e55a38407a0
|
||||||
# after any backend restart (podman reassigns the IP). See the script header.
|
|
||||||
ARG BASE=source.archipelago-foundation.org/lfg2025/mempool-frontend:v3.0.0
|
|
||||||
FROM ${BASE}
|
FROM ${BASE}
|
||||||
|
|
||||||
# --chmod keeps the exec bit (build runs as USER 1000, plain COPY lands root:0644
|
# --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 /
|
# → "not executable"). Base USER/ENTRYPOINT/CMD (1000 / /patch/entrypoint.sh /
|
||||||
# nginx -g "daemon off;") are inherited unchanged.
|
# 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
|
#!/bin/sh
|
||||||
__MEMPOOL_BACKEND_MAINNET_HTTP_HOST__=${BACKEND_MAINNET_HTTP_HOST:=127.0.0.1}
|
set -eu
|
||||||
__MEMPOOL_BACKEND_MAINNET_HTTP_PORT__=${BACKEND_MAINNET_HTTP_PORT:=8999}
|
# Preserve the pinned upstream entrypoint (including new runtime options).
|
||||||
__MEMPOOL_FRONTEND_HTTP_PORT__=${FRONTEND_HTTP_PORT:=8080}
|
# Apply our DNS repair only after it has rendered the nginx configuration.
|
||||||
|
exec /patch/upstream-entrypoint.sh /patch/start-nginx.sh "$@"
|
||||||
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 "$@"
|
|
||||||
|
|||||||
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",
|
"name": "neode-ui",
|
||||||
"version": "1.8.15-alpha",
|
"version": "1.8.16-alpha",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "neode-ui",
|
"name": "neode-ui",
|
||||||
"version": "1.8.15-alpha",
|
"version": "1.8.16-alpha",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@scure/bip39": "^2.2.0",
|
"@scure/bip39": "^2.2.0",
|
||||||
"@types/dompurify": "^3.0.5",
|
"@types/dompurify": "^3.0.5",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "neode-ui",
|
"name": "neode-ui",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.8.15-alpha",
|
"version": "1.8.16-alpha",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "./start-dev.sh",
|
"start": "./start-dev.sh",
|
||||||
|
|||||||
@@ -378,13 +378,13 @@
|
|||||||
{
|
{
|
||||||
"id": "mempool",
|
"id": "mempool",
|
||||||
"title": "Mempool Explorer",
|
"title": "Mempool Explorer",
|
||||||
"version": "3.0.0",
|
"version": "3.3.1-archy1",
|
||||||
"description": "Bitcoin mempool and blockchain explorer. Real-time transaction and block visualization.",
|
"description": "Bitcoin mempool and blockchain explorer. Real-time transaction and block visualization.",
|
||||||
"icon": "/assets/img/app-icons/mempool.webp",
|
"icon": "/assets/img/app-icons/mempool.webp",
|
||||||
"author": "Mempool",
|
"author": "Mempool",
|
||||||
"category": "money",
|
"category": "money",
|
||||||
"tier": "core",
|
"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",
|
"repoUrl": "https://github.com/mempool/mempool",
|
||||||
"requires": [
|
"requires": [
|
||||||
"bitcoin-knots",
|
"bitcoin-knots",
|
||||||
|
|||||||
@@ -362,6 +362,19 @@ init()
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="overflow-y-auto flex-1 min-h-0 space-y-6 pr-1">
|
<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 -->
|
<!-- v1.8.15-alpha -->
|
||||||
<div>
|
<div>
|
||||||
<div class="flex items-center gap-2 mb-3">
|
<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">
|
<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>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>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>
|
||||||
</div>
|
</div>
|
||||||
<!-- v1.8.14-alpha -->
|
<!-- v1.8.14-alpha -->
|
||||||
|
|||||||
+16
-17
@@ -1,30 +1,29 @@
|
|||||||
{
|
{
|
||||||
"changelog": [
|
"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.",
|
"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.",
|
||||||
"**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.",
|
"Added regression coverage for Cuprate install and installed-state grouping.",
|
||||||
"**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 was rerun on the corrected tree before OTA and ISO publication."
|
||||||
"**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."
|
|
||||||
],
|
],
|
||||||
"components": [
|
"components": [
|
||||||
{
|
{
|
||||||
"current_version": "1.8.14-alpha",
|
"current_version": "1.8.15-alpha",
|
||||||
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.14-alpha/archipelago",
|
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.15-alpha/archipelago",
|
||||||
"name": "archipelago",
|
"name": "archipelago",
|
||||||
"new_version": "1.8.14-alpha",
|
"new_version": "1.8.15-alpha",
|
||||||
"sha256": "3d8e5e7c79a261649e89c4f5ba8d90db9057ebbb002919a812ca82f664b315bf",
|
"sha256": "3eee71563337f20cb348529925c58b8227afec796783a69176e0b59b9e113c91",
|
||||||
"size_bytes": 64568360
|
"size_bytes": 64571544
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"current_version": "1.8.14-alpha",
|
"current_version": "1.8.15-alpha",
|
||||||
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.14-alpha/archipelago-frontend-1.8.14-alpha.tar.gz",
|
"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.14-alpha.tar.gz",
|
"name": "archipelago-frontend-1.8.15-alpha.tar.gz",
|
||||||
"new_version": "1.8.14-alpha",
|
"new_version": "1.8.15-alpha",
|
||||||
"sha256": "e1c490e52571bf9238435e5986792c6bd602fd7e398783546f7592aa921d3386",
|
"sha256": "86a32ef3334b03c197e47d9d28f4435c6f2fa7c4ccacc839a8fc4a0a749495dc",
|
||||||
"size_bytes": 98792963
|
"size_bytes": 98797600
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"release_date": "2026-09-13",
|
"release_date": "2026-09-13",
|
||||||
"signature": "dacfdd2707e415af8a42306a63658a7d41cdfeba29d43802d465d18f00a747ecbfa54ea4ee8ed1eedf94d4f30371453fd9c9d475af9d6a62eac4e2c8e783b405",
|
"signature": "5e13396e2f33571f136bb1a9ea0356486b3d42c6ba99ee5d33990cd565d9b1178f61eaf6a70a937a006e7de3fd388bcebd63f2daca4bb28accc1b810d8455d03",
|
||||||
"signed_by": "did:key:z6Mkfu5LT8d4DjETtrkATvHh9Dvcbnr7zBCUwfau8Sw7DLWT",
|
"signed_by": "did:key:z6Mkfu5LT8d4DjETtrkATvHh9Dvcbnr7zBCUwfau8Sw7DLWT",
|
||||||
"version": "1.8.14-alpha"
|
"version": "1.8.15-alpha"
|
||||||
}
|
}
|
||||||
|
|||||||
+3197
-3195
File diff suppressed because one or more lines are too long
+16
-17
@@ -1,30 +1,29 @@
|
|||||||
{
|
{
|
||||||
"changelog": [
|
"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.",
|
"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.",
|
||||||
"**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.",
|
"Added regression coverage for Cuprate install and installed-state grouping.",
|
||||||
"**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 was rerun on the corrected tree before OTA and ISO publication."
|
||||||
"**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."
|
|
||||||
],
|
],
|
||||||
"components": [
|
"components": [
|
||||||
{
|
{
|
||||||
"current_version": "1.8.14-alpha",
|
"current_version": "1.8.15-alpha",
|
||||||
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.14-alpha/archipelago",
|
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.15-alpha/archipelago",
|
||||||
"name": "archipelago",
|
"name": "archipelago",
|
||||||
"new_version": "1.8.14-alpha",
|
"new_version": "1.8.15-alpha",
|
||||||
"sha256": "3d8e5e7c79a261649e89c4f5ba8d90db9057ebbb002919a812ca82f664b315bf",
|
"sha256": "3eee71563337f20cb348529925c58b8227afec796783a69176e0b59b9e113c91",
|
||||||
"size_bytes": 64568360
|
"size_bytes": 64571544
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"current_version": "1.8.14-alpha",
|
"current_version": "1.8.15-alpha",
|
||||||
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.14-alpha/archipelago-frontend-1.8.14-alpha.tar.gz",
|
"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.14-alpha.tar.gz",
|
"name": "archipelago-frontend-1.8.15-alpha.tar.gz",
|
||||||
"new_version": "1.8.14-alpha",
|
"new_version": "1.8.15-alpha",
|
||||||
"sha256": "e1c490e52571bf9238435e5986792c6bd602fd7e398783546f7592aa921d3386",
|
"sha256": "86a32ef3334b03c197e47d9d28f4435c6f2fa7c4ccacc839a8fc4a0a749495dc",
|
||||||
"size_bytes": 98792963
|
"size_bytes": 98797600
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"release_date": "2026-09-13",
|
"release_date": "2026-09-13",
|
||||||
"signature": "dacfdd2707e415af8a42306a63658a7d41cdfeba29d43802d465d18f00a747ecbfa54ea4ee8ed1eedf94d4f30371453fd9c9d475af9d6a62eac4e2c8e783b405",
|
"signature": "5e13396e2f33571f136bb1a9ea0356486b3d42c6ba99ee5d33990cd565d9b1178f61eaf6a70a937a006e7de3fd388bcebd63f2daca4bb28accc1b810d8455d03",
|
||||||
"signed_by": "did:key:z6Mkfu5LT8d4DjETtrkATvHh9Dvcbnr7zBCUwfau8Sw7DLWT",
|
"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"
|
||||||
|
}
|
||||||
@@ -33,7 +33,8 @@ ELECTRUMX_IMAGE="$ARCHY_REGISTRY/electrumx:v1.18.0"
|
|||||||
|
|
||||||
# Mempool stack
|
# Mempool stack
|
||||||
MEMPOOL_BACKEND_IMAGE="$ARCHY_REGISTRY/mempool-backend:v3.3.1"
|
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"
|
MARIADB_IMAGE="$ARCHY_REGISTRY/mariadb:11.4.10"
|
||||||
|
|
||||||
# BTCPay
|
# BTCPay
|
||||||
|
|||||||
@@ -53,24 +53,33 @@ if [ -x "$PROJECT_ROOT/core/target/release/archipelago" ]; then
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
remote_url=$(git -C "$PROJECT_ROOT" remote get-url "$REMOTE")
|
remote_url=$(git -C "$PROJECT_ROOT" remote get-url "$REMOTE")
|
||||||
# https is accepted as well as http. Requiring http:// meant the only remote
|
# Remote URLs are public metadata: ngit can include them in repository
|
||||||
# whose credential actually works for git push (the https one) was rejected,
|
# announcements. Keep credentials in Git's credential helper, never in URLs.
|
||||||
# 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.
|
|
||||||
case "$remote_url" in
|
case "$remote_url" in
|
||||||
http://*@*|https://*@*) ;;
|
http://*@*|https://*@*) fail "$REMOTE embeds credentials; move them to a Git credential helper and remove them from the remote URL" ;;
|
||||||
*) fail "$REMOTE must be an authenticated http(s):// Gitea remote URL for API uploads" ;;
|
http://*|https://*) ;;
|
||||||
|
*) fail "$REMOTE must be an http(s):// Gitea remote URL for API uploads" ;;
|
||||||
esac
|
esac
|
||||||
|
|
||||||
scheme=${remote_url%%://*}
|
scheme=${remote_url%%://*}
|
||||||
rest=${remote_url#*://}
|
host_path=${remote_url#*://}
|
||||||
auth=${rest%%@*}
|
|
||||||
host_path=${rest#*@}
|
|
||||||
host=${host_path%%/*}
|
host=${host_path%%/*}
|
||||||
repo_path=${host_path#*/}
|
repo_path=${host_path#*/}
|
||||||
repo_path=${repo_path%.git}
|
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"
|
api="$scheme://$host/api/v1/repos/$repo_path"
|
||||||
release_url="$api/releases/tags/v${VERSION}"
|
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)
|
||||||
@@ -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.
|
# 3600s leaves headroom; a warm target/ finishes in a fraction of it.
|
||||||
stage "cargo-test-weekly" timeout 3600 env CARGO_INCREMENTAL=0 \
|
stage "cargo-test-weekly" timeout 3600 env CARGO_INCREMENTAL=0 \
|
||||||
cargo test --manifest-path core/Cargo.toml -p archipelago -- \
|
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 ─────────────────────────────────────────
|
# ── Stage 4: live node smoke ─────────────────────────────────────────
|
||||||
if [[ $LIVE -eq 1 ]]; then
|
if [[ $LIVE -eq 1 ]]; then
|
||||||
|
|||||||
Reference in New Issue
Block a user