Keep automatic recovery outside managed update ownership
This commit is contained in:
@@ -483,9 +483,48 @@ pub async fn save_container_snapshot(data_dir: &Path) -> Result<()> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Every automatic mutation shares lifecycle admission with managed updates.
|
||||||
|
// A stack is indivisible here: alias repair can affect a held sibling even if
|
||||||
|
// the particular stopped member has no saved unit of its own.
|
||||||
|
async fn automatic_recovery_intent_allowed(data_dir: &Path, names: &[&str]) -> bool {
|
||||||
|
if names
|
||||||
|
.iter()
|
||||||
|
.any(|name| !crate::health_monitor::automatic_recovery_allowed(data_dir, name))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
for file in [USER_STOPPED_FILE, USER_UNINSTALLED_FILE] {
|
||||||
|
let values: std::collections::HashSet<String> = match fs::read(data_dir.join(file)).await {
|
||||||
|
Ok(bytes) => match serde_json::from_slice(&bytes) {
|
||||||
|
Ok(values) => values,
|
||||||
|
Err(_) => return false,
|
||||||
|
},
|
||||||
|
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Default::default(),
|
||||||
|
Err(_) => return false,
|
||||||
|
};
|
||||||
|
if names.iter().any(|name| values.contains(*name)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn automatic_recovery_admission(
|
||||||
|
data_dir: &Path,
|
||||||
|
names: &[&str],
|
||||||
|
) -> Option<crate::container::update_transaction::Guard> {
|
||||||
|
let guard = crate::container::update_transaction::Guard::acquire(data_dir).ok()?;
|
||||||
|
automatic_recovery_intent_allowed(data_dir, names)
|
||||||
|
.await
|
||||||
|
.then_some(guard)
|
||||||
|
}
|
||||||
|
|
||||||
/// Recover containers that were running before a crash.
|
/// Recover containers that were running before a crash.
|
||||||
/// Attempts to start each container, logging success/failure.
|
/// Attempts to start each container, logging success/failure.
|
||||||
pub async fn recover_containers(containers: &[RunningContainerRecord]) -> RecoveryReport {
|
pub async fn recover_containers(
|
||||||
|
data_dir: &Path,
|
||||||
|
containers: &[RunningContainerRecord],
|
||||||
|
) -> RecoveryReport {
|
||||||
// Snapshot entries can outlive their containers (removed while we were
|
// Snapshot entries can outlive their containers (removed while we were
|
||||||
// down, or podman storage partially reset by an unclean poweroff).
|
// down, or podman storage partially reset by an unclean poweroff).
|
||||||
// `podman start` on those fails permanently, and recovery runs BEFORE the
|
// `podman start` on those fails permanently, and recovery runs BEFORE the
|
||||||
@@ -517,12 +556,22 @@ pub async fn recover_containers(containers: &[RunningContainerRecord]) -> Recove
|
|||||||
pending_boot_starts_add(containers.iter().map(|r| r.name.clone()));
|
pending_boot_starts_add(containers.iter().map(|r| r.name.clone()));
|
||||||
|
|
||||||
for (i, record) in containers.iter().enumerate() {
|
for (i, record) in containers.iter().enumerate() {
|
||||||
|
let scope = stack_recovery_specs()
|
||||||
|
.iter()
|
||||||
|
.find(|stack| stack.containers.contains(&record.name.as_str()))
|
||||||
|
.map(|stack| stack.containers.to_vec())
|
||||||
|
.unwrap_or_else(|| vec![record.name.as_str()]);
|
||||||
|
let Some(_admission) = automatic_recovery_admission(data_dir, &scope).await else {
|
||||||
|
pending_boot_start_done(&record.name);
|
||||||
|
continue;
|
||||||
|
};
|
||||||
// Skip containers that are already up — `podman start` on a running
|
// Skip containers that are already up — `podman start` on a running
|
||||||
// container produces the noisy benign conmon "Failed to create
|
// container produces the noisy benign conmon "Failed to create
|
||||||
// container" + cgroup Permission-denied journal pair (fleet log
|
// container" + cgroup Permission-denied journal pair (fleet log
|
||||||
// sweep 2026-07-22).
|
// sweep 2026-07-22).
|
||||||
if container_state(&record.name).await.as_deref() == Some("running") {
|
if container_state(&record.name).await.as_deref() == Some("running") {
|
||||||
report.recovered += 1;
|
report.recovered += 1;
|
||||||
|
pending_boot_start_done(&record.name);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
info!(
|
info!(
|
||||||
@@ -556,14 +605,11 @@ pub async fn recover_containers(containers: &[RunningContainerRecord]) -> Recove
|
|||||||
);
|
);
|
||||||
tokio::time::sleep(std::time::Duration::from_secs(10)).await;
|
tokio::time::sleep(std::time::Duration::from_secs(10)).await;
|
||||||
}
|
}
|
||||||
let mut cmd = tokio::process::Command::new("podman");
|
if !automatic_recovery_intent_allowed(data_dir, &scope).await {
|
||||||
cmd.args(["start", &record.name]);
|
break;
|
||||||
let result = command_with_timeout(
|
}
|
||||||
cmd,
|
let result =
|
||||||
Duration::from_secs(timeout_secs),
|
podman_output(&["start", &record.name], Duration::from_secs(timeout_secs)).await;
|
||||||
&format!("podman start {}", record.name),
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
|
|
||||||
match result {
|
match result {
|
||||||
Ok(output) if output.status.success() => {
|
Ok(output) if output.status.success() => {
|
||||||
@@ -677,6 +723,13 @@ async fn start_stopped_app_stacks(data_dir: &Path) -> RecoveryReport {
|
|||||||
};
|
};
|
||||||
|
|
||||||
for stack in stack_recovery_specs() {
|
for stack in stack_recovery_specs() {
|
||||||
|
let Some(_admission) = automatic_recovery_admission(data_dir, stack.containers).await
|
||||||
|
else {
|
||||||
|
for name in stack.containers {
|
||||||
|
pending_boot_start_done(name);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
};
|
||||||
if !stack_anchor_container_exists(stack).await {
|
if !stack_anchor_container_exists(stack).await {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -694,6 +747,9 @@ async fn start_stopped_app_stacks(data_dir: &Path) -> RecoveryReport {
|
|||||||
if pending.is_empty() {
|
if pending.is_empty() {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
if !automatic_recovery_intent_allowed(data_dir, stack.containers).await {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
info!("Recovering stopped {} stack containers", stack.name);
|
info!("Recovering stopped {} stack containers", stack.name);
|
||||||
repair_stack_network_aliases(stack).await;
|
repair_stack_network_aliases(stack).await;
|
||||||
pending_boot_starts_add(pending.iter().cloned());
|
pending_boot_starts_add(pending.iter().cloned());
|
||||||
@@ -720,9 +776,21 @@ async fn start_stopped_app_stacks(data_dir: &Path) -> RecoveryReport {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if !automatic_recovery_intent_allowed(data_dir, stack.containers).await {
|
||||||
|
for name in &pending {
|
||||||
|
pending_boot_start_done(name);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
repair_stack_network_aliases(stack).await;
|
repair_stack_network_aliases(stack).await;
|
||||||
wait_before_stack_container_recovery(stack, container).await;
|
wait_before_stack_container_recovery(data_dir, stack, container).await;
|
||||||
|
|
||||||
|
if !automatic_recovery_intent_allowed(data_dir, stack.containers).await {
|
||||||
|
for name in &pending {
|
||||||
|
pending_boot_start_done(name);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
report.total += 1;
|
report.total += 1;
|
||||||
if start_existing_container(container).await {
|
if start_existing_container(container).await {
|
||||||
report.recovered += 1;
|
report.recovered += 1;
|
||||||
@@ -736,13 +804,20 @@ async fn start_stopped_app_stacks(data_dir: &Path) -> RecoveryReport {
|
|||||||
report
|
report
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn wait_before_stack_container_recovery(stack: &StackRecoverySpec, container: &str) {
|
async fn wait_before_stack_container_recovery(
|
||||||
|
data_dir: &Path,
|
||||||
|
stack: &StackRecoverySpec,
|
||||||
|
container: &str,
|
||||||
|
) {
|
||||||
if stack.name != "indeedhub" || container != "indeedhub" {
|
if stack.name != "indeedhub" || container != "indeedhub" {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
for _ in 0..60 {
|
for _ in 0..60 {
|
||||||
if indeedhub_recovery_dependencies_running().await {
|
if indeedhub_recovery_dependencies_running().await {
|
||||||
|
if !automatic_recovery_intent_allowed(data_dir, stack.containers).await {
|
||||||
|
return;
|
||||||
|
}
|
||||||
repair_stack_network_aliases(stack).await;
|
repair_stack_network_aliases(stack).await;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -869,7 +944,7 @@ async fn start_stopped_containers_for(
|
|||||||
records.len(),
|
records.len(),
|
||||||
user_stopped.len()
|
user_stopped.len()
|
||||||
);
|
);
|
||||||
recover_containers(&records).await
|
recover_containers(data_dir, &records).await
|
||||||
}
|
}
|
||||||
|
|
||||||
fn should_auto_start_stopped_container(name: &str, include_stack_members: bool) -> bool {
|
fn should_auto_start_stopped_container(name: &str, include_stack_members: bool) -> bool {
|
||||||
@@ -1139,7 +1214,18 @@ async fn podman_status(args: &[&str], timeout: Duration) -> Option<std::process:
|
|||||||
.map(|output| output.status)
|
.map(|output| output.status)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
thread_local! {
|
||||||
|
static RECOVERY_PODMAN_TEST: std::cell::RefCell<Option<Box<dyn FnMut(&[&str]) -> Output>>> = std::cell::RefCell::new(None);
|
||||||
|
}
|
||||||
|
|
||||||
async fn podman_output(args: &[&str], timeout: Duration) -> Result<Output> {
|
async fn podman_output(args: &[&str], timeout: Duration) -> Result<Output> {
|
||||||
|
#[cfg(test)]
|
||||||
|
if let Some(output) =
|
||||||
|
RECOVERY_PODMAN_TEST.with(|hook| hook.borrow_mut().as_mut().map(|f| f(args)))
|
||||||
|
{
|
||||||
|
return Ok(output);
|
||||||
|
}
|
||||||
let mut cmd = tokio::process::Command::new("podman");
|
let mut cmd = tokio::process::Command::new("podman");
|
||||||
cmd.args(args);
|
cmd.args(args);
|
||||||
command_with_timeout(cmd, timeout, &format!("podman {}", args.join(" "))).await
|
command_with_timeout(cmd, timeout, &format!("podman {}", args.join(" "))).await
|
||||||
@@ -1227,6 +1313,193 @@ mod tests {
|
|||||||
use super::*;
|
use super::*;
|
||||||
use tempfile::TempDir;
|
use tempfile::TempDir;
|
||||||
|
|
||||||
|
struct RecoveryPodmanFixture;
|
||||||
|
impl Drop for RecoveryPodmanFixture {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
RECOVERY_PODMAN_TEST.with(|hook| *hook.borrow_mut() = None);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn recovery_podman_fixture(
|
||||||
|
root: &Path,
|
||||||
|
) -> (
|
||||||
|
RecoveryPodmanFixture,
|
||||||
|
std::rc::Rc<std::cell::RefCell<Vec<Vec<String>>>>,
|
||||||
|
) {
|
||||||
|
use std::os::unix::process::ExitStatusExt;
|
||||||
|
let calls = std::rc::Rc::new(std::cell::RefCell::new(Vec::new()));
|
||||||
|
let captured = calls.clone();
|
||||||
|
let data = root.to_path_buf();
|
||||||
|
let mut started = false;
|
||||||
|
RECOVERY_PODMAN_TEST.with(|hook| {
|
||||||
|
*hook.borrow_mut() = Some(Box::new(move |args| {
|
||||||
|
captured
|
||||||
|
.borrow_mut()
|
||||||
|
.push(args.iter().map(|v| v.to_string()).collect());
|
||||||
|
let mut status = 0;
|
||||||
|
let mut stdout = String::new();
|
||||||
|
match args.first().copied() {
|
||||||
|
Some("inspect") if args.get(1).is_some_and(|v| v.starts_with("immich_")) => {
|
||||||
|
if args.last() == Some(&"{{json .NetworkSettings.Networks}}") {
|
||||||
|
stdout = "{}".into();
|
||||||
|
} else {
|
||||||
|
stdout = if args[1] == "immich_redis" && !started {
|
||||||
|
"exited"
|
||||||
|
} else {
|
||||||
|
"running"
|
||||||
|
}
|
||||||
|
.into();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some("inspect") => status = 1,
|
||||||
|
Some("ps") => stdout = "immich_redis\n".into(),
|
||||||
|
Some("start") | Some("network") => {
|
||||||
|
assert!(
|
||||||
|
crate::container::update_transaction::Guard::acquire(&data).is_err(),
|
||||||
|
"mutation escaped lifecycle guard"
|
||||||
|
);
|
||||||
|
if args[0] == "start" {
|
||||||
|
started = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => panic!("unexpected recovery command: {args:?}"),
|
||||||
|
}
|
||||||
|
Output {
|
||||||
|
status: std::process::ExitStatus::from_raw(status << 8),
|
||||||
|
stdout: stdout.into_bytes(),
|
||||||
|
stderr: vec![],
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
});
|
||||||
|
(RecoveryPodmanFixture, calls)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn stack_recovery_never_mutates_saved_held_or_operator_disabled_siblings() {
|
||||||
|
for reason in [
|
||||||
|
"saved",
|
||||||
|
"broken-saved",
|
||||||
|
"held",
|
||||||
|
"broken-hold",
|
||||||
|
"stopped",
|
||||||
|
"uninstalled",
|
||||||
|
"broken-intent",
|
||||||
|
"locked",
|
||||||
|
] {
|
||||||
|
let root = TempDir::new().unwrap();
|
||||||
|
let transaction = root.path().join("update-transactions");
|
||||||
|
let mut lock = None;
|
||||||
|
match reason {
|
||||||
|
"saved" | "broken-saved" => {
|
||||||
|
let dir = transaction.join("installed-units");
|
||||||
|
std::fs::create_dir_all(&dir).unwrap();
|
||||||
|
let body = if reason == "saved" {
|
||||||
|
serde_json::json!({"schema":1,"operation":uuid::Uuid::new_v4().to_string(),"name":"immich_server","body":"[Container]\nImage=retained\n","mode":384}).to_string()
|
||||||
|
} else {
|
||||||
|
"broken".into()
|
||||||
|
};
|
||||||
|
std::fs::write(dir.join("immich_server.json"), body).unwrap();
|
||||||
|
}
|
||||||
|
"held" | "broken-hold" => {
|
||||||
|
let dir = transaction.join("holds");
|
||||||
|
std::fs::create_dir_all(&dir).unwrap();
|
||||||
|
std::fs::write(
|
||||||
|
dir.join("immich_server"),
|
||||||
|
if reason == "held" {
|
||||||
|
uuid::Uuid::new_v4().to_string()
|
||||||
|
} else {
|
||||||
|
"broken".into()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
"locked" => {
|
||||||
|
lock = Some(
|
||||||
|
crate::container::update_transaction::Guard::acquire(root.path()).unwrap(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
"stopped" => {
|
||||||
|
std::fs::write(root.path().join(USER_STOPPED_FILE), r#"["immich_server"]"#)
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
"uninstalled" => std::fs::write(
|
||||||
|
root.path().join(USER_UNINSTALLED_FILE),
|
||||||
|
r#"["immich_server"]"#,
|
||||||
|
)
|
||||||
|
.unwrap(),
|
||||||
|
_ => std::fs::write(root.path().join(USER_UNINSTALLED_FILE), "broken").unwrap(),
|
||||||
|
}
|
||||||
|
let (_fixture, calls) = recovery_podman_fixture(root.path());
|
||||||
|
let report = start_stopped_app_stacks(root.path()).await;
|
||||||
|
assert_eq!(report.recovered, 0, "{reason}");
|
||||||
|
assert!(
|
||||||
|
!calls
|
||||||
|
.borrow()
|
||||||
|
.iter()
|
||||||
|
.any(|args| matches!(args[0].as_str(), "network" | "start")),
|
||||||
|
"{reason}"
|
||||||
|
);
|
||||||
|
let snapshot = recover_containers(
|
||||||
|
root.path(),
|
||||||
|
&[RunningContainerRecord {
|
||||||
|
name: "immich_redis".into(),
|
||||||
|
image: String::new(),
|
||||||
|
}],
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(
|
||||||
|
snapshot.recovered, 0,
|
||||||
|
"snapshot sibling admission: {reason}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!calls.borrow().iter().any(|args| args[0] == "start"),
|
||||||
|
"snapshot {reason}"
|
||||||
|
);
|
||||||
|
drop(lock);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn stack_and_snapshot_recovery_hold_lifecycle_guard_for_real_mutation_path() {
|
||||||
|
let root = TempDir::new().unwrap();
|
||||||
|
let (_fixture, calls) = recovery_podman_fixture(root.path());
|
||||||
|
let report = start_stopped_app_stacks(root.path()).await;
|
||||||
|
assert_eq!(report.recovered, 1);
|
||||||
|
assert!(calls
|
||||||
|
.borrow()
|
||||||
|
.iter()
|
||||||
|
.any(|args| args == &["start", "immich_redis"]));
|
||||||
|
assert!(crate::container::update_transaction::Guard::acquire(root.path()).is_ok());
|
||||||
|
let (_positive_snapshot_fixture, positive_calls) = recovery_podman_fixture(root.path());
|
||||||
|
let positive = recover_containers(
|
||||||
|
root.path(),
|
||||||
|
&[RunningContainerRecord {
|
||||||
|
name: "immich_redis".into(),
|
||||||
|
image: String::new(),
|
||||||
|
}],
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(positive.recovered, 1);
|
||||||
|
assert!(positive_calls
|
||||||
|
.borrow()
|
||||||
|
.iter()
|
||||||
|
.any(|args| args == &["start", "immich_redis"]));
|
||||||
|
let (_snapshot_fixture, snapshot_calls) = recovery_podman_fixture(root.path());
|
||||||
|
std::fs::write(root.path().join(USER_STOPPED_FILE), r#"["immich_redis"]"#).unwrap();
|
||||||
|
let report = recover_containers(
|
||||||
|
root.path(),
|
||||||
|
&[RunningContainerRecord {
|
||||||
|
name: "immich_redis".into(),
|
||||||
|
image: String::new(),
|
||||||
|
}],
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(report.recovered, 0);
|
||||||
|
assert!(!snapshot_calls
|
||||||
|
.borrow()
|
||||||
|
.iter()
|
||||||
|
.any(|args| args[0] == "start"));
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn if_recorded_distinguishes_no_record_from_empty_record() {
|
async fn if_recorded_distinguishes_no_record_from_empty_record() {
|
||||||
let tmp = TempDir::new().unwrap();
|
let tmp = TempDir::new().unwrap();
|
||||||
|
|||||||
@@ -728,7 +728,7 @@ fn parse_health_from_status(status: &str) -> Option<String> {
|
|||||||
/// Try to recover a container. Running containers need a real restart so
|
/// Try to recover a container. Running containers need a real restart so
|
||||||
/// rootless network helpers such as pasta are recreated; `podman start` is a
|
/// rootless network helpers such as pasta are recreated; `podman start` is a
|
||||||
/// no-op for a running container with a missing host listener.
|
/// no-op for a running container with a missing host listener.
|
||||||
fn automatic_recovery_allowed(data_dir: &Path, name: &str) -> bool {
|
pub(crate) fn automatic_recovery_allowed(data_dir: &Path, name: &str) -> bool {
|
||||||
match (
|
match (
|
||||||
crate::container::supervised_update::installed_unit(data_dir, name),
|
crate::container::supervised_update::installed_unit(data_dir, name),
|
||||||
crate::container::update_transaction::is_held(data_dir, name),
|
crate::container::update_transaction::is_held(data_dir, name),
|
||||||
@@ -739,6 +739,11 @@ fn automatic_recovery_allowed(data_dir: &Path, name: &str) -> bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn restart_container(name: &str, state: &str, data_dir: &Path) -> bool {
|
async fn restart_container(name: &str, state: &str, data_dir: &Path) -> bool {
|
||||||
|
// Keep admission held across the command, so an update cannot acquire
|
||||||
|
// ownership after the policy check and race this automatic restart.
|
||||||
|
let Ok(_admission) = crate::container::update_transaction::Guard::acquire(data_dir) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
if !automatic_recovery_allowed(data_dir, name) {
|
if !automatic_recovery_allowed(data_dir, name) {
|
||||||
warn!(container = %name, "Automatic restart refused: reviewed managed runtime needs explicit recovery");
|
warn!(container = %name, "Automatic restart refused: reviewed managed runtime needs explicit recovery");
|
||||||
return false;
|
return false;
|
||||||
@@ -1229,6 +1234,13 @@ pub fn spawn_health_monitor(state: Arc<StateManager>, data_dir: PathBuf) {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn automatic_restart_refuses_competing_lifecycle_before_command() {
|
||||||
|
let root = tempfile::TempDir::new().unwrap();
|
||||||
|
let _guard = crate::container::update_transaction::Guard::acquire(root.path()).unwrap();
|
||||||
|
assert!(!restart_container("indeedhub-ffmpeg", "running", root.path()).await);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn automatic_recovery_never_restarts_saved_held_or_damaged_managed_runtime() {
|
async fn automatic_recovery_never_restarts_saved_held_or_damaged_managed_runtime() {
|
||||||
let root = tempfile::tempdir().unwrap();
|
let root = tempfile::tempdir().unwrap();
|
||||||
|
|||||||
@@ -284,7 +284,7 @@ async fn main() -> Result<()> {
|
|||||||
"🔧 Recovering {} containers from previous crash...",
|
"🔧 Recovering {} containers from previous crash...",
|
||||||
containers.len()
|
containers.len()
|
||||||
);
|
);
|
||||||
let report = crash_recovery::recover_containers(&containers).await;
|
let report = crash_recovery::recover_containers(&config.data_dir, &containers).await;
|
||||||
info!(
|
info!(
|
||||||
"🔧 Recovery complete: {}/{} containers restarted (failed: {:?})",
|
"🔧 Recovery complete: {}/{} containers restarted (failed: {:?})",
|
||||||
report.recovered, report.total, report.failed
|
report.recovered, report.total, report.failed
|
||||||
|
|||||||
@@ -586,3 +586,42 @@ executed exactly once. This source change still requires matching embedded-helpe
|
|||||||
build and actual transaction acceptance; it does not close cutover or rollback.
|
build and actual transaction acceptance; it does not close cutover or rollback.
|
||||||
The current guest was QMP-paused without reboot to serialize worker runtime and
|
The current guest was QMP-paused without reboot to serialize worker runtime and
|
||||||
backend process-fixture qualification.
|
backend process-fixture qualification.
|
||||||
|
|
||||||
|
### Actual automatic-recovery race found and contained (2026-10-08)
|
||||||
|
|
||||||
|
The 180-second helper matching fixture executable built with stable inputs:
|
||||||
|
full SHA256 `9acf7970f1e2409733e22789a6264b347b3748ba45cee4b79c9e51f6c662acd9`,
|
||||||
|
stripped VM `41a5fca8551e1d213c0d69ec24aa60534e4610282fbe3433beaa507933108761`,
|
||||||
|
helper `6fc3f978cb88dbf022dc5bc07eaf0337c6b6b79ff42b20cee7b33ed7c100b879`.
|
||||||
|
Actual manager startup and read-only API/Redis/PostgreSQL readiness passed with
|
||||||
|
all seven identities retained. A first request correctly refused stale reviewed
|
||||||
|
original-unit hashes after prior recovery, without creating a transaction. The
|
||||||
|
old synthetic plan was archived; independently verified replacement plan
|
||||||
|
`2ae10528245c5304d8107236ec91f1dda7609b85affab5aea385baf2f87cfbca` retained the
|
||||||
|
intentional API post-install exit77 hook and exact current original-unit pins.
|
||||||
|
|
||||||
|
Operation `23550e9e-cc0a-427b-9813-7aa1bfc0df5d` stopped the frontend cleanly,
|
||||||
|
then refused the legacy worker's unknown process-exit result before backup or
|
||||||
|
target startup. Podman could not observe PID death after SIGKILL; the 45-second
|
||||||
|
systemd stop budget killed conmon, producing died exit code -1, not a proven
|
||||||
|
137/143 termination. The stop safety check correctly refused this evidence.
|
||||||
|
|
||||||
|
Separately, actual manager logs prove the periodic `crash_recovery` stack path
|
||||||
|
restarted that same stopped original worker while transaction holds were active.
|
||||||
|
At 07:24:17 it logged Recovering stack container, and at 07:24:18 started original
|
||||||
|
ID `981d58a1f5613ccbf92d91c1dd404f247718ba7146448db9cdfc5b0acffd8527`.
|
||||||
|
Supervised recovery then refused the unexpected live replacement. This is a
|
||||||
|
confirmed competing recovery path, not merely a timeout inference.
|
||||||
|
|
||||||
|
The fixture manager was stopped and barred, and the guest QMP-paused on its same
|
||||||
|
boot. **Operation23550e9e remains unresolved Restoring/Recovering with holds and
|
||||||
|
journal intact.** Never rewrite it to Restored or adopt the restarted identity as
|
||||||
|
proof of successful rollback. No Yaya service or app data was changed.
|
||||||
|
|
||||||
|
Source correction applies the existing saved-unit/hold fail-closed policy to
|
||||||
|
whole stacks, holds the lifecycle lock across every alias/start mutation, reads
|
||||||
|
stopped/uninstalled intent strictly and rechecks it before later mutations.
|
||||||
|
Snapshot recovery uses the same stack-wide admission. Actual-path fake Podman
|
||||||
|
regressions exercise managed/held/corrupt/operator-disabled siblings and ensure
|
||||||
|
accepted mutation runs under the lifecycle lock. Compilation, isolated execution,
|
||||||
|
matching executable and fresh actual transaction acceptance remain required.
|
||||||
|
|||||||
Reference in New Issue
Block a user