From f79ecd11aecff2811338a826af649459e02faeb8 Mon Sep 17 00:00:00 2001
From: archipelago
Date: Wed, 7 Oct 2026 02:34:51 -0400
Subject: [PATCH] Integrate legacy managed update maintenance and fenced
recovery before reconciliation
---
.../archipelago/src/api/rpc/package/update.rs | 34 +++-
core/archipelago/src/appgate/mod.rs | 40 ++++
core/archipelago/src/bootstrap.rs | 50 ++++-
.../src/container/supervised_runtime.rs | 183 ++++++++++++++++--
.../src/container/supervised_update.rs | 119 +++++++++---
.../src/container/update_transaction.rs | 13 +-
image-recipe/configs/nginx-archipelago.conf | 3 +
7 files changed, 399 insertions(+), 43 deletions(-)
diff --git a/core/archipelago/src/api/rpc/package/update.rs b/core/archipelago/src/api/rpc/package/update.rs
index 037f0f93..61379e10 100644
--- a/core/archipelago/src/api/rpc/package/update.rs
+++ b/core/archipelago/src/api/rpc/package/update.rs
@@ -305,7 +305,22 @@ impl RpcHandler {
guard: &crate::container::update_transaction::Guard,
) -> Result<()> {
use crate::container::update_transaction::{self, Podman};
- let targets = Podman::targets(images_to_pull).await?;
+ let mut supervised = 0;
+ for name in containers {
+ if crate::container::quadlet::unit_exists(name).await {
+ supervised += 1;
+ }
+ }
+ anyhow::ensure!(
+ supervised == 0 || supervised == containers.len(),
+ "Mixed managed/unmanaged stack requires an explicit recovery plan; originals unchanged"
+ );
+ let managed = supervised != 0;
+ anyhow::ensure!(
+ !managed || package_id == "indeedhub",
+ "This managed app has no qualified maintenance controller; originals unchanged"
+ );
+ let targets = Podman::targets(images_to_pull, !managed).await?;
let names: HashSet<_> = containers.iter().map(String::as_str).collect();
anyhow::ensure!(
targets.len() == names.len()
@@ -316,7 +331,22 @@ impl RpcHandler {
);
self.set_install_phase(package_id, InstallPhase::Preparing)
.await;
- update_transaction::execute(guard, package_id, &targets, &Podman).await
+ if managed {
+ use crate::container::supervised_runtime::{
+ load_reviewed_plans, LegacyIndeeMaintenance, SystemdSupervisor,
+ };
+ let plans = load_reviewed_plans(&self.config.data_dir, package_id)?;
+ let adapter = SystemdSupervisor::new(
+ self.config.data_dir.clone(),
+ plans,
+ LegacyIndeeMaintenance::new(guard)?,
+ )
+ .await?;
+ crate::container::supervised_update::execute(guard, package_id, &targets, &adapter)
+ .await
+ } else {
+ update_transaction::execute(guard, package_id, &targets, &Podman).await
+ }
}
async fn recreate_container_for_update(
diff --git a/core/archipelago/src/appgate/mod.rs b/core/archipelago/src/appgate/mod.rs
index d82fef6d..dbdd1eed 100644
--- a/core/archipelago/src/appgate/mod.rs
+++ b/core/archipelago/src/appgate/mod.rs
@@ -146,6 +146,16 @@ impl AppGate {
// snapshot when the port momentarily leaves the map mid-refresh.
let live = self.port_map.read().await.gated(app.port).cloned();
let app = live.as_ref().unwrap_or(app);
+ if maintenance_active(&self.data_dir, &app.app_id) {
+ return Response::builder()
+ .status(StatusCode::SERVICE_UNAVAILABLE)
+ .header(header::CACHE_CONTROL, "no-store")
+ .header(header::RETRY_AFTER, "60")
+ .body(Body::from(
+ "This app is temporarily unavailable while its update is recovered.",
+ ))
+ .unwrap();
+ }
let path = req.uri().path().to_string();
// A dashboard same-origin proxy strips `/app//` before this gate
@@ -838,6 +848,16 @@ dashboard and check {name} under My Apps.
"#,
resp
}
+fn maintenance_active(data_dir: &std::path::Path, app_id: &str) -> bool {
+ if app_id != "indeedhub" && !app_id.starts_with("indeedhub-") {
+ return false;
+ }
+ match std::fs::symlink_metadata(data_dir.join("app-maintenance/indeedhub")) {
+ Ok(_) => true,
+ Err(error) => error.kind() != std::io::ErrorKind::NotFound,
+ }
+}
+
fn not_found() -> Response {
Response::builder()
.status(StatusCode::NOT_FOUND)
@@ -1857,3 +1877,23 @@ mod tests {
)
}
}
+
+#[cfg(test)]
+mod maintenance_tests {
+ #[test]
+ fn held_indee_ingress_never_reaches_upstream_or_another_app() {
+ let root = tempfile::tempdir().unwrap();
+ assert!(!super::maintenance_active(root.path(), "indeedhub"));
+ std::fs::create_dir(root.path().join("app-maintenance")).unwrap();
+ std::fs::write(
+ root.path().join("app-maintenance/indeedhub"),
+ uuid::Uuid::new_v4().to_string(),
+ )
+ .unwrap();
+ assert!(super::maintenance_active(root.path(), "indeedhub"));
+ assert!(super::maintenance_active(root.path(), "indeedhub-api"));
+ assert!(!super::maintenance_active(root.path(), "node-demo-v4v"));
+ std::fs::remove_file(root.path().join("app-maintenance/indeedhub")).unwrap();
+ assert!(!super::maintenance_active(root.path(), "indeedhub"));
+ }
+}
diff --git a/core/archipelago/src/bootstrap.rs b/core/archipelago/src/bootstrap.rs
index 34d068cb..0b521061 100644
--- a/core/archipelago/src/bootstrap.rs
+++ b/core/archipelago/src/bootstrap.rs
@@ -83,6 +83,32 @@ const RUNTIME_ASSETS_DIR: &str = "/opt/archipelago/web-ui/archipelago-runtime";
/// Inserted into every server block of the nginx config that lacks the
/// `/api/app-catalog` proxy. Kept in sync with the canonical block in
/// image-recipe/configs/nginx-archipelago.conf.
+const INDEEHUB_MAINTENANCE_GUARD: &str =
+ "if (-f /var/lib/archipelago/app-maintenance/indeedhub) { return 503; }";
+/// Patch only recognized literal IndeeHub routes, including asset/WebSocket
+/// sublocations; unknown operator routes are not guessed by this repair.
+fn heal_indeehub_maintenance_guards(content: &str) -> String {
+ let route=regex::Regex::new(r"(?m)^([ \t]*)(location[ \t]+(?:\^~[ \t]+|=[ \t]+)?/app/indeedhub(?:/[^\s{]*)?[ \t]*\{)[ \t]*$").unwrap();
+ let mut result = String::new();
+ let mut previous = 0;
+ for capture in route.captures_iter(content) {
+ let full = capture.get(0).unwrap();
+ result.push_str(&content[previous..full.end()]);
+ if !content[full.end()..]
+ .trim_start()
+ .starts_with(INDEEHUB_MAINTENANCE_GUARD)
+ {
+ result.push_str(&format!(
+ "\n{} {}",
+ &capture[1], INDEEHUB_MAINTENANCE_GUARD
+ ));
+ }
+ previous = full.end();
+ }
+ result.push_str(&content[previous..]);
+ result
+}
+
const NGINX_APP_CATALOG_BLOCK: &str = "\n # App Store catalog proxy — backend fetches from configured registries\n # so the browser doesn't hit CORS/CSP. Without this block nginx falls\n # through to the SPA index.html and the frontend gets HTML back instead\n # of JSON.\n location ~ ^/api/(?:app-catalog|node-app-catalog)$ {\n proxy_pass http://127.0.0.1:5678;\n proxy_http_version 1.1;\n proxy_set_header Host $host;\n proxy_set_header X-Real-IP $remote_addr;\n proxy_set_header Cookie $http_cookie;\n proxy_connect_timeout 15s;\n proxy_read_timeout 30s;\n proxy_send_timeout 15s;\n error_page 502 503 = @backend_unavailable;\n error_page 504 = @backend_timeout;\n }\n\n";
const NGINX_SOURCE_PROXY_BLOCK: &str = " # GitWorkshop follows the dashboard origin so LAN, Tailscale, FIPS, Tor,\n # hostnames and reverse proxies all use the connection that already works.\n location /app/archipelago-source/ {\n proxy_pass http://127.0.0.2:8337/;\n proxy_http_version 1.1;\n proxy_set_header Host $http_host;\n proxy_set_header Cookie $http_cookie;\n proxy_set_header X-Real-IP $remote_addr;\n proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n proxy_set_header X-Forwarded-Proto $scheme;\n proxy_set_header X-Forwarded-Prefix /app/archipelago-source;\n proxy_hide_header X-Frame-Options;\n add_header X-Frame-Options \"SAMEORIGIN\" always;\n add_header X-Content-Type-Options \"nosniff\" always;\n proxy_read_timeout 300s;\n }\n";
@@ -2011,8 +2037,10 @@ async fn patch_nginx_conf(path: &str) -> Result {
let missing_source_prefix = heal_source_forwarded_prefix(&content).is_some();
let missing_nostr_signer = heal_missing_nostr_signer(&content).is_some();
let missing_rental_playback = heal_rental_playback_route(&content) != content;
+ let missing_maintenance = heal_indeehub_maintenance_guards(&content) != content;
let legacy_catalog_route = content.contains("location /api/app-catalog {");
- if !missing_rental_playback
+ if !missing_maintenance
+ && !missing_rental_playback
&& !missing_app_catalog
&& !legacy_catalog_route
&& !missing_bitcoin_status
@@ -2031,7 +2059,9 @@ async fn patch_nginx_conf(path: &str) -> Result {
return Ok(false);
}
- let mut patched = heal_rental_playback_route(&heal_node_catalog_route(&content));
+ let mut patched = heal_indeehub_maintenance_guards(&heal_rental_playback_route(
+ &heal_node_catalog_route(&content),
+ ));
if let Some(p) = heal_stale_web_search_block(&patched) {
patched = p;
@@ -2515,3 +2545,19 @@ pub async fn ensure_restart_policy() {
Err(e) => tracing::warn!(error = %e, "could not repair archipelago.service restart policy"),
}
}
+
+#[cfg(test)]
+mod indeehub_maintenance_tests {
+ #[test]
+ fn legacy_routes_are_fenced_independently_and_repair_is_idempotent() {
+ let source="server {\n location /app/indeedhub/ {\n proxy_pass http://127.0.0.1:7778;\n }\n location /app/indeedhub/ws/ {\n proxy_pass http://127.0.0.1:7778;\n }\n location /app/other/ {\n proxy_pass http://127.0.0.1:7777;\n }\n}\n";
+ let repaired = super::heal_indeehub_maintenance_guards(source);
+ assert_eq!(
+ repaired.matches(super::INDEEHUB_MAINTENANCE_GUARD).count(),
+ 2
+ );
+ assert_eq!(super::heal_indeehub_maintenance_guards(&repaired), repaired);
+ assert!(repaired.contains("location /app/other/ {\n proxy_pass"));
+ assert_eq!(repaired.matches("proxy_pass").count(), 3);
+ }
+}
diff --git a/core/archipelago/src/container/supervised_runtime.rs b/core/archipelago/src/container/supervised_runtime.rs
index 21f51705..68a79287 100644
--- a/core/archipelago/src/container/supervised_runtime.rs
+++ b/core/archipelago/src/container/supervised_runtime.rs
@@ -1,7 +1,7 @@
//! Production systemd/Podman adapter. Application write admission/drain is an
//! explicit dependency: neither process pause nor a filesystem receipt is drain.
use super::{
- supervised_update::{self, PreparedTarget, RecoveryImage, Supervisor, Unit},
+ supervised_update::{self, Completion, PreparedTarget, RecoveryImage, Supervisor, Unit},
update_transaction::{Observed, Podman, Runtime, Target},
};
use anyhow::{Context, Result};
@@ -10,24 +10,155 @@ use std::{
collections::HashMap,
future::Future,
io::Write,
- os::unix::fs::{MetadataExt, OpenOptionsExt},
+ os::unix::fs::{MetadataExt, OpenOptionsExt, PermissionsExt},
path::{Path, PathBuf},
time::Duration,
};
pub(crate) trait DrainBarrier: Sync {
- /// Persist ownership before blocking admissions. Return only after API,
- /// direct uploads and workers have drained and the coherent backup finished.
+ /// Called after the node persisted original writable recovery images.
+ /// Persist ownership before blocking admissions or stopping writers. Return
+ /// only after API/direct uploads/workers drained and coherent backup finished.
fn acquire(
&self,
operation: &str,
originals: &[Unit],
+ recovery: bool,
) -> impl Future