fix(containers): preserve graceful shutdown through Quadlet and prepare 1.8.21
This commit is contained in:
@@ -2,6 +2,12 @@
|
|||||||
|
|
||||||
## Unreleased
|
## Unreleased
|
||||||
|
|
||||||
|
## v1.8.21-alpha (2026-09-30)
|
||||||
|
|
||||||
|
- Fixed Bitcoin and other containers being forcibly stopped after ten seconds during managed updates and restarts.
|
||||||
|
- Existing installations now receive the same graceful shutdown allowance as new containers, without restarting apps just to apply this setting.
|
||||||
|
- Includes the Cashu payment, optional Bitcoin pruning, Lightning readiness, and explorer improvements from 1.8.20.
|
||||||
|
|
||||||
## v1.8.20-alpha (2026-09-29)
|
## v1.8.20-alpha (2026-09-29)
|
||||||
|
|
||||||
- Fixed Cashu file payments rejected despite a shared mint, and preserved the payment amount when mint fees reduce change.
|
- Fixed Cashu file payments rejected despite a shared mint, and preserved the payment amount when mint fees reduce change.
|
||||||
|
|||||||
Generated
+1
-1
@@ -104,7 +104,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "archipelago"
|
name = "archipelago"
|
||||||
version = "1.8.20-alpha"
|
version = "1.8.21-alpha"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"archipelago-container",
|
"archipelago-container",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "archipelago"
|
name = "archipelago"
|
||||||
version = "1.8.20-alpha"
|
version = "1.8.21-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"
|
||||||
|
|||||||
@@ -184,6 +184,7 @@ pub struct QuadletUnit {
|
|||||||
pub no_new_privileges: bool,
|
pub no_new_privileges: bool,
|
||||||
pub cpu_quota: Option<u32>,
|
pub cpu_quota: Option<u32>,
|
||||||
pub restart_policy: RestartPolicy,
|
pub restart_policy: RestartPolicy,
|
||||||
|
pub stop_grace_secs: Option<u64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl QuadletUnit {
|
impl QuadletUnit {
|
||||||
@@ -216,6 +217,10 @@ impl QuadletUnit {
|
|||||||
let _ = writeln!(s, "[Container]");
|
let _ = writeln!(s, "[Container]");
|
||||||
let _ = writeln!(s, "ContainerName={}", self.name);
|
let _ = writeln!(s, "ContainerName={}", self.name);
|
||||||
let _ = writeln!(s, "Image={}", self.image);
|
let _ = writeln!(s, "Image={}", self.image);
|
||||||
|
let grace = self
|
||||||
|
.stop_grace_secs
|
||||||
|
.unwrap_or_else(|| archipelago_container::runtime::stop_grace_secs_for(&self.name));
|
||||||
|
let _ = writeln!(s, "StopTimeout={grace}");
|
||||||
// Pull=never: companions are pre-pulled or built. A missing image
|
// Pull=never: companions are pre-pulled or built. A missing image
|
||||||
// must surface as a unit start failure, not a silent retry storm.
|
// must surface as a unit start failure, not a silent retry storm.
|
||||||
let _ = writeln!(s, "Pull=never");
|
let _ = writeln!(s, "Pull=never");
|
||||||
@@ -350,6 +355,15 @@ impl QuadletUnit {
|
|||||||
// the unit stuck in deactivating. Health/status remains app-level state,
|
// the unit stuck in deactivating. Health/status remains app-level state,
|
||||||
// not a systemd start gate.
|
// not a systemd start gate.
|
||||||
let _ = writeln!(s, "TimeoutStartSec=0");
|
let _ = writeln!(s, "TimeoutStartSec=0");
|
||||||
|
let _ = writeln!(s, "TimeoutStopSec={}", grace.saturating_add(15));
|
||||||
|
// Stop explicitly before Quadlet's generated `podman rm -f`. The
|
||||||
|
// existing container may still carry Podman's old 10-second default;
|
||||||
|
// StopTimeout alone only protects containers created after migration.
|
||||||
|
let _ = writeln!(s, "ExecStop=");
|
||||||
|
let _ = writeln!(
|
||||||
|
s,
|
||||||
|
"ExecStop=/usr/bin/podman stop --ignore --time={grace} --cidfile=%t/%N.cid"
|
||||||
|
);
|
||||||
// Restart policy + 10s backoff. RestartSec keeps a crash-loop
|
// Restart policy + 10s backoff. RestartSec keeps a crash-loop
|
||||||
// from saturating the journal. Companions: Always. Backends:
|
// from saturating the journal. Companions: Always. Backends:
|
||||||
// OnFailure (clean stops stay stopped).
|
// OnFailure (clean stops stay stopped).
|
||||||
@@ -525,6 +539,9 @@ impl QuadletUnit {
|
|||||||
// Always, not OnFailure: with quadlet's `--rm`, OnFailure left a
|
// Always, not OnFailure: with quadlet's `--rm`, OnFailure left a
|
||||||
// cleanly-exited app deleted and unrestarted. See RestartPolicy.
|
// cleanly-exited app deleted and unrestarted. See RestartPolicy.
|
||||||
restart_policy: RestartPolicy::Always,
|
restart_policy: RestartPolicy::Always,
|
||||||
|
stop_grace_secs: Some(super::prod_orchestrator::resolve_stop_grace_secs(
|
||||||
|
manifest, name,
|
||||||
|
)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -792,7 +809,11 @@ pub async fn stop_service(service: &str) -> Result<()> {
|
|||||||
/// corruption — so the orchestrator passes the per-app grace here. Never waits
|
/// corruption — so the orchestrator passes the per-app grace here. Never waits
|
||||||
/// less than `QUADLET_STOP_TIMEOUT`.
|
/// less than `QUADLET_STOP_TIMEOUT`.
|
||||||
pub async fn stop_service_with_timeout(service: &str, timeout: Duration) -> Result<()> {
|
pub async fn stop_service_with_timeout(service: &str, timeout: Duration) -> Result<()> {
|
||||||
let timeout = timeout.max(QUADLET_STOP_TIMEOUT);
|
let name = service.strip_suffix(".service").unwrap_or(service);
|
||||||
|
let body = fs::read_to_string(unit_dir().await?.join(format!("{name}.container")))
|
||||||
|
.await
|
||||||
|
.unwrap_or_default();
|
||||||
|
let timeout = timeout.max(stop_wait_timeout(name, &body));
|
||||||
match systemctl_user_status(&["stop", service], timeout).await {
|
match systemctl_user_status(&["stop", service], timeout).await {
|
||||||
Ok(status) if status.success() => Ok(()),
|
Ok(status) if status.success() => Ok(()),
|
||||||
Ok(status) => Err(anyhow!("systemctl --user stop {service} exited {status}")),
|
Ok(status) => Err(anyhow!("systemctl --user stop {service} exited {status}")),
|
||||||
@@ -813,6 +834,20 @@ pub async fn stop_service_with_timeout(service: &str, timeout: Duration) -> Resu
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The command waiter must outlive both the container grace and systemd's
|
||||||
|
/// stop deadline. Restart/repair callers must not kill Bitcoin at 45 seconds.
|
||||||
|
fn stop_wait_timeout(name: &str, unit_body: &str) -> Duration {
|
||||||
|
Duration::from_secs(stop_grace_from_unit(name, unit_body).saturating_add(30))
|
||||||
|
.max(QUADLET_STOP_TIMEOUT)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn stop_grace_from_unit(name: &str, unit_body: &str) -> u64 {
|
||||||
|
directive_values(unit_body, "StopTimeout=")
|
||||||
|
.last()
|
||||||
|
.and_then(|value| value.parse::<u64>().ok())
|
||||||
|
.unwrap_or_else(|| archipelago_container::runtime::stop_grace_secs_for(name))
|
||||||
|
}
|
||||||
|
|
||||||
async fn systemctl_user_status(
|
async fn systemctl_user_status(
|
||||||
args: &[&str],
|
args: &[&str],
|
||||||
timeout: Duration,
|
timeout: Duration,
|
||||||
@@ -939,6 +974,10 @@ fn directive_values(unit_body: &str, prefix: &str) -> Vec<String> {
|
|||||||
/// that systemd no longer knows about.
|
/// that systemd no longer knows about.
|
||||||
pub async fn disable_remove(unit_name: &str, dir: &Path) -> Result<()> {
|
pub async fn disable_remove(unit_name: &str, dir: &Path) -> Result<()> {
|
||||||
let svc = format!("{unit_name}.service");
|
let svc = format!("{unit_name}.service");
|
||||||
|
let path = dir.join(format!("{unit_name}.container"));
|
||||||
|
let body = fs::read_to_string(&path).await.unwrap_or_default();
|
||||||
|
let timeout = stop_wait_timeout(unit_name, &body);
|
||||||
|
let grace = stop_grace_from_unit(unit_name, &body).to_string();
|
||||||
// Stop first; ignore failure (unit may already be down). BOUNDED — on
|
// Stop first; ignore failure (unit may already be down). BOUNDED — on
|
||||||
// rootless podman a generated unit can wedge in "deactivating" while
|
// rootless podman a generated unit can wedge in "deactivating" while
|
||||||
// `podman rm -f` hangs underneath it, and an unbounded `systemctl stop`
|
// `podman rm -f` hangs underneath it, and an unbounded `systemctl stop`
|
||||||
@@ -946,13 +985,12 @@ pub async fn disable_remove(unit_name: &str, dir: &Path) -> Result<()> {
|
|||||||
// the package entry is stranded in `Removing` (a ghost in My Apps that also
|
// the package entry is stranded in `Removing` (a ghost in My Apps that also
|
||||||
// blocks reinstall). If the graceful stop times out, escalate to
|
// blocks reinstall). If the graceful stop times out, escalate to
|
||||||
// SIGKILL + reset-failed so teardown always proceeds.
|
// SIGKILL + reset-failed so teardown always proceeds.
|
||||||
if systemctl_user_status(&["stop", &svc], QUADLET_STOP_TIMEOUT)
|
if systemctl_user_status(&["stop", &svc], timeout)
|
||||||
.await
|
.await
|
||||||
.is_err()
|
.is_err()
|
||||||
{
|
{
|
||||||
let _ = kill_and_reset_service(&svc).await;
|
let _ = kill_and_reset_service(&svc).await;
|
||||||
}
|
}
|
||||||
let path = dir.join(format!("{unit_name}.container"));
|
|
||||||
if fs::try_exists(&path).await.unwrap_or(false) {
|
if fs::try_exists(&path).await.unwrap_or(false) {
|
||||||
match fs::remove_file(&path).await {
|
match fs::remove_file(&path).await {
|
||||||
Ok(()) => {}
|
Ok(()) => {}
|
||||||
@@ -965,9 +1003,9 @@ pub async fn disable_remove(unit_name: &str, dir: &Path) -> Result<()> {
|
|||||||
// Bounded so a hung podman store can't re-introduce the stall this function
|
// Bounded so a hung podman store can't re-introduce the stall this function
|
||||||
// exists to avoid.
|
// exists to avoid.
|
||||||
let _ = tokio::time::timeout(
|
let _ = tokio::time::timeout(
|
||||||
QUADLET_STOP_TIMEOUT,
|
timeout,
|
||||||
Command::new("podman")
|
Command::new("podman")
|
||||||
.args(["rm", "-f", unit_name])
|
.args(["rm", "-f", "--ignore", "--time", &grace, unit_name])
|
||||||
.status(),
|
.status(),
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
@@ -992,6 +1030,118 @@ mod tests {
|
|||||||
use super::*;
|
use super::*;
|
||||||
use tempfile::tempdir;
|
use tempfile::tempdir;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn shutdown_grace_covers_container_systemd_and_caller() {
|
||||||
|
for (name, grace) in [
|
||||||
|
("bitcoin-core", 600),
|
||||||
|
("bitcoin-knots", 600),
|
||||||
|
("lnd", 330),
|
||||||
|
("electrumx", 300),
|
||||||
|
("other", 30),
|
||||||
|
] {
|
||||||
|
let unit = QuadletUnit {
|
||||||
|
name: name.into(),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let body = unit.render();
|
||||||
|
assert!(body.contains(&format!("StopTimeout={grace}\n")));
|
||||||
|
assert!(body.contains(&format!("TimeoutStopSec={}\n", grace + 15)));
|
||||||
|
assert!(body.contains(&format!("podman stop --ignore --time={grace} --cidfile=")));
|
||||||
|
assert_eq!(
|
||||||
|
stop_wait_timeout(name, &body),
|
||||||
|
Duration::from_secs(grace + 30)
|
||||||
|
);
|
||||||
|
// Legacy units have no StopTimeout directive yet.
|
||||||
|
assert_eq!(stop_wait_timeout(name, ""), Duration::from_secs(grace + 30));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn custom_stop_grace_survives_render_and_restart_budget() {
|
||||||
|
let manifest: AppManifest = serde_yaml::from_str(
|
||||||
|
r#"
|
||||||
|
app:
|
||||||
|
id: custom-db
|
||||||
|
name: Custom database
|
||||||
|
version: 1.0.0
|
||||||
|
stop_grace_secs: 900
|
||||||
|
container:
|
||||||
|
image: example/db:1
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let unit = QuadletUnit::from_manifest(&manifest, "custom-db");
|
||||||
|
assert_eq!(unit.stop_grace_secs, Some(900));
|
||||||
|
assert_eq!(
|
||||||
|
stop_wait_timeout("custom-db", &unit.render()),
|
||||||
|
Duration::from_secs(930)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
stop_wait_timeout("lnd", "StopTimeout=invalid"),
|
||||||
|
Duration::from_secs(360)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn stop_grace_migration_does_not_request_an_execution_restart() {
|
||||||
|
let unit = sample_unit();
|
||||||
|
let new = unit.render();
|
||||||
|
let old = new
|
||||||
|
.lines()
|
||||||
|
.filter(|line| {
|
||||||
|
!line.starts_with("StopTimeout=")
|
||||||
|
&& !line.starts_with("TimeoutStopSec=")
|
||||||
|
&& !line.starts_with("ExecStop=")
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("\n");
|
||||||
|
assert!(!exec_changed(&old, &new));
|
||||||
|
assert!(!publish_ports_changed(&old, &new));
|
||||||
|
assert!(!network_aliases_changed(&old, &new));
|
||||||
|
assert!(!health_cmd_changed(&old, &new));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn actual_quadlet_generator_stops_before_forced_removal() {
|
||||||
|
let generator = Path::new("/usr/lib/systemd/system-generators/podman-system-generator");
|
||||||
|
if !generator.exists() {
|
||||||
|
eprintln!(
|
||||||
|
"Quadlet generator unavailable; run this regression on the Linux release host"
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let dir = tempdir().unwrap();
|
||||||
|
let unit = QuadletUnit {
|
||||||
|
name: "grace-test".into(),
|
||||||
|
image: "localhost/test:latest".into(),
|
||||||
|
stop_grace_secs: Some(600),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
std::fs::write(dir.path().join("grace-test.container"), unit.render()).unwrap();
|
||||||
|
let output = std::process::Command::new(generator)
|
||||||
|
.args(["--user", "--dryrun"])
|
||||||
|
.env("QUADLET_UNIT_DIRS", dir.path())
|
||||||
|
.output()
|
||||||
|
.unwrap();
|
||||||
|
assert!(
|
||||||
|
output.status.success(),
|
||||||
|
"{}",
|
||||||
|
String::from_utf8_lossy(&output.stderr)
|
||||||
|
);
|
||||||
|
let generated = String::from_utf8_lossy(&output.stdout).to_string()
|
||||||
|
+ &String::from_utf8_lossy(&output.stderr);
|
||||||
|
let stop = generated
|
||||||
|
.find("ExecStop=/usr/bin/podman stop --ignore --time=600")
|
||||||
|
.unwrap();
|
||||||
|
let remove = generated.find("ExecStop=/usr/bin/podman rm ").unwrap();
|
||||||
|
assert!(
|
||||||
|
stop < remove,
|
||||||
|
"Legacy container must stop gracefully before removal"
|
||||||
|
);
|
||||||
|
assert!(generated.contains("--stop-timeout 600"));
|
||||||
|
assert!(generated.contains("TimeoutStopSec=615"));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn render_emits_secret_env_by_reference_never_value() {
|
fn render_emits_secret_env_by_reference_never_value() {
|
||||||
let u = QuadletUnit {
|
let u = QuadletUnit {
|
||||||
|
|||||||
@@ -257,3 +257,32 @@ Both catalog and OTA signatures verify against the pinned release root. Staged
|
|||||||
artifact hash/size checks and catalog drift/trust checks pass. User accepted the
|
artifact hash/size checks and catalog drift/trust checks pass. User accepted the
|
||||||
remaining Framework display check and explicitly authorized release. Publication
|
remaining Framework display check and explicitly authorized release. Publication
|
||||||
and ISO build may proceed; do not regenerate the signed manifest or artifacts.
|
and ISO build may proceed; do not regenerate the signed manifest or artifacts.
|
||||||
|
|
||||||
|
### Published OTA; ISO withheld after live shutdown defect — 2026-09-30
|
||||||
|
|
||||||
|
Signed 1.8.20 OTA/catalog published to git and ngit, with public asset hashes
|
||||||
|
verified. Catalog rollout triggered a Bitcoin command update at 08:34 UTC.
|
||||||
|
Although the orchestrator allowed a long stop, Quadlet's generated Podman removal
|
||||||
|
still used its ten-second default and killed Bitcoin. Core replayed its block
|
||||||
|
index; LND later lost its connection to the previous Bitcoin container IP.
|
||||||
|
|
||||||
|
Stopped the ISO build and queued boot check; any partial 1.8.20 ISO is invalid
|
||||||
|
and must not be published. Preparing 1.8.21 to supersede the immutable signed OTA.
|
||||||
|
Installed explicit graceful-stop systemd overrides on dev and Shorty without
|
||||||
|
restarting native services. Candidate Quadlet fix adds per-app container, systemd,
|
||||||
|
and command-wait budgets, including existing containers and uninstall fallback.
|
||||||
|
Focused 43 tests pass, including actual Quadlet generator stop-before-remove order.
|
||||||
|
Full tests, disposable slow-stop verification, build and deployment remain pending.
|
||||||
|
|
||||||
|
Disposable live regression passed: started an Alpine container with its legacy
|
||||||
|
ten-second stop setting, rewrote and reloaded its Quadlet with explicit twenty-
|
||||||
|
second graceful stop, verified the same container ID and old internal timeout
|
||||||
|
remained running, then stopped it. Its twelve-second shutdown handler completed
|
||||||
|
in 12.6 seconds, emitted the completion marker, and exited without SIGKILL/137.
|
||||||
|
Fixture had no network or wallet mounts and was removed afterward.
|
||||||
|
|
||||||
|
Core finished index loading and resumed unpruned initial sync. LND automatically
|
||||||
|
unlocked at 08:47 UTC. The existing backend-address cascade then performed a
|
||||||
|
graceful LND restart at 08:57 UTC after Bitcoin reconciliation completed; LND
|
||||||
|
automatically unlocked again and reached chain-sync waiting. No manual wallet
|
||||||
|
unlock or restart was used for this recovery.
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "neode-ui",
|
"name": "neode-ui",
|
||||||
"version": "1.8.19-alpha",
|
"version": "1.8.21-alpha",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "neode-ui",
|
"name": "neode-ui",
|
||||||
"version": "1.8.19-alpha",
|
"version": "1.8.21-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.20-alpha",
|
"version": "1.8.21-alpha",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "./start-dev.sh",
|
"start": "./start-dev.sh",
|
||||||
|
|||||||
@@ -362,6 +362,18 @@ 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.21-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.21-alpha</span>
|
||||||
|
<span class="text-xs text-white/40">September 30, 2026</span>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
|
||||||
|
<p>Fixed Bitcoin and other containers being forcibly stopped after ten seconds during managed updates and restarts.</p>
|
||||||
|
<p>Existing installations now receive the same graceful shutdown allowance as new containers, without restarting apps just to apply this setting.</p>
|
||||||
|
<p>Includes the Cashu payment, optional Bitcoin pruning, Lightning readiness, and explorer improvements from 1.8.20.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<!-- v1.8.20-alpha -->
|
<!-- v1.8.20-alpha -->
|
||||||
<div>
|
<div>
|
||||||
<div class="flex items-center gap-2 mb-3">
|
<div class="flex items-center gap-2 mb-3">
|
||||||
|
|||||||
Reference in New Issue
Block a user