Preserve intact originals when pre-target Indee drain refuses
This commit is contained in:
@@ -39,6 +39,10 @@ struct Member {
|
|||||||
pinned_original_body: String,
|
pinned_original_body: String,
|
||||||
original_tag: String,
|
original_tag: String,
|
||||||
recovery_image: Option<RecoveryImage>,
|
recovery_image: Option<RecoveryImage>,
|
||||||
|
/// A durable pre-target recovery decision. None is not yet decided; false
|
||||||
|
/// means recreate only a stopped/missing original, true preserves its ID.
|
||||||
|
#[serde(default)]
|
||||||
|
preserve_original: Option<bool>,
|
||||||
}
|
}
|
||||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
enum Phase {
|
enum Phase {
|
||||||
@@ -423,7 +427,7 @@ fn root(guard: &Guard) -> Result<PathBuf> {
|
|||||||
}
|
}
|
||||||
fn validate(record: &Journal) -> Result<()> {
|
fn validate(record: &Journal) -> Result<()> {
|
||||||
anyhow::ensure!(
|
anyhow::ensure!(
|
||||||
record.schema == 1
|
matches!(record.schema, 1 | 2)
|
||||||
&& simple(&record.package)
|
&& simple(&record.package)
|
||||||
&& uuid::Uuid::parse_str(&record.id)?.to_string() == record.id
|
&& uuid::Uuid::parse_str(&record.id)?.to_string() == record.id
|
||||||
&& !record.members.is_empty()
|
&& !record.members.is_empty()
|
||||||
@@ -461,6 +465,13 @@ fn validate(record: &Journal) -> Result<()> {
|
|||||||
|| member.recovery_image.is_some(),
|
|| member.recovery_image.is_some(),
|
||||||
"Destructive update lacks a durable writable-layer recovery image"
|
"Destructive update lacks a durable writable-layer recovery image"
|
||||||
);
|
);
|
||||||
|
anyhow::ensure!(
|
||||||
|
member.preserve_original.is_none()
|
||||||
|
|| (record.schema == 2
|
||||||
|
&& !record.target_startup_began
|
||||||
|
&& matches!(record.phase, Phase::Restoring | Phase::Restored)),
|
||||||
|
"Preserved-original recovery cannot follow target startup"
|
||||||
|
);
|
||||||
let restore_image = member
|
let restore_image = member
|
||||||
.recovery_image
|
.recovery_image
|
||||||
.as_ref()
|
.as_ref()
|
||||||
@@ -626,7 +637,11 @@ fn publish_installed(guard: &Guard, record: &Journal) -> Result<()> {
|
|||||||
operation: record.id.clone(),
|
operation: record.id.clone(),
|
||||||
name: member.original.name.clone(),
|
name: member.original.name.clone(),
|
||||||
body: if record.phase == Phase::Restored {
|
body: if record.phase == Phase::Restored {
|
||||||
member.pinned_original_body.clone()
|
if member.preserve_original == Some(true) {
|
||||||
|
member.original.body.clone()
|
||||||
|
} else {
|
||||||
|
member.pinned_original_body.clone()
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
member.target_body.clone()
|
member.target_body.clone()
|
||||||
},
|
},
|
||||||
@@ -750,10 +765,11 @@ pub(crate) async fn execute(
|
|||||||
pinned_original_body,
|
pinned_original_body,
|
||||||
original_tag: format!("localhost/archy-update-recovery:{id}-{index}"),
|
original_tag: format!("localhost/archy-update-recovery:{id}-{index}"),
|
||||||
recovery_image: None,
|
recovery_image: None,
|
||||||
|
preserve_original: None,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
let mut record = Journal {
|
let mut record = Journal {
|
||||||
schema: 1,
|
schema: 2,
|
||||||
id,
|
id,
|
||||||
package: package.into(),
|
package: package.into(),
|
||||||
phase: Phase::Prepared,
|
phase: Phase::Prepared,
|
||||||
@@ -868,7 +884,139 @@ async fn apply(guard: &Guard, record: &mut Journal, supervisor: &impl Supervisor
|
|||||||
record.cleanup_done = true;
|
record.cleanup_done = true;
|
||||||
save(guard, record)
|
save(guard, record)
|
||||||
}
|
}
|
||||||
|
/// Observe without mutation. A live original is never stopped because a drain
|
||||||
|
/// failed; a live same-operation recovery may be adopted after a lost start ACK.
|
||||||
|
async fn pre_target_state(member: &Member, supervisor: &impl Supervisor) -> Result<(bool, bool)> {
|
||||||
|
supervisor.validate_original_file(&member.original).await?;
|
||||||
|
let body = supervisor.read(&member.original.name).await?;
|
||||||
|
anyhow::ensure!(
|
||||||
|
[
|
||||||
|
&member.original.body,
|
||||||
|
&member.target_body,
|
||||||
|
&member.pinned_original_body
|
||||||
|
]
|
||||||
|
.contains(&&body),
|
||||||
|
"Foreign unit edit requires explicit recovery"
|
||||||
|
);
|
||||||
|
let observed = supervisor.observed(&member.original.name).await?;
|
||||||
|
let intact = observed.as_ref().is_some_and(|current| {
|
||||||
|
body == member.original.body
|
||||||
|
&& current.id == member.original.container_id
|
||||||
|
&& current.image == member.original.image
|
||||||
|
&& current.running == member.original.running
|
||||||
|
&& current.config_sha256 == member.original.config_sha256
|
||||||
|
});
|
||||||
|
if member.preserve_original == Some(true) {
|
||||||
|
anyhow::ensure!(
|
||||||
|
intact,
|
||||||
|
"Preserved original changed; retain recovery hold for inspection"
|
||||||
|
);
|
||||||
|
return Ok((true, false));
|
||||||
|
}
|
||||||
|
if member.preserve_original.is_none() && intact {
|
||||||
|
return Ok((true, false));
|
||||||
|
}
|
||||||
|
let recovery = &member
|
||||||
|
.recovery_image
|
||||||
|
.as_ref()
|
||||||
|
.context("Missing recovery image")?
|
||||||
|
.image;
|
||||||
|
if let Some(current) = observed {
|
||||||
|
let own_recovery = current.image == *recovery
|
||||||
|
&& current.config_sha256 == member.original.config_sha256
|
||||||
|
&& body == member.pinned_original_body;
|
||||||
|
if current.running {
|
||||||
|
anyhow::ensure!(
|
||||||
|
member.original.running,
|
||||||
|
"Originally stopped member unexpectedly started; preserve stop intent and recovery hold"
|
||||||
|
);
|
||||||
|
anyhow::ensure!(
|
||||||
|
own_recovery,
|
||||||
|
"Unexpected live replacement; original writers must not be stopped"
|
||||||
|
);
|
||||||
|
return Ok((false, true));
|
||||||
|
}
|
||||||
|
anyhow::ensure!(
|
||||||
|
own_recovery
|
||||||
|
|| (current.id == member.original.container_id
|
||||||
|
&& current.image == member.original.image
|
||||||
|
&& current.config_sha256 == member.original.config_sha256),
|
||||||
|
"Unexpected stopped replacement; retain its data for inspection"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Ok((false, false))
|
||||||
|
}
|
||||||
|
async fn restore_before_target(
|
||||||
|
guard: &Guard,
|
||||||
|
record: &mut Journal,
|
||||||
|
supervisor: &impl Supervisor,
|
||||||
|
) -> Result<()> {
|
||||||
|
let mut choices = Vec::new();
|
||||||
|
// Validate every member before changing even one service recipe.
|
||||||
|
for member in &record.members {
|
||||||
|
choices.push(pre_target_state(member, supervisor).await?.0);
|
||||||
|
}
|
||||||
|
for (member, preserve) in record.members.iter_mut().zip(choices) {
|
||||||
|
if let Some(saved) = member.preserve_original {
|
||||||
|
anyhow::ensure!(saved == preserve, "Original recovery decision changed");
|
||||||
|
}
|
||||||
|
member.preserve_original = Some(preserve);
|
||||||
|
}
|
||||||
|
save(guard, record)?;
|
||||||
|
let mut changed = false;
|
||||||
|
for member in &record.members {
|
||||||
|
let (preserve, running_recovery) = pre_target_state(member, supervisor).await?;
|
||||||
|
if preserve || running_recovery {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
supervisor
|
||||||
|
.pin(
|
||||||
|
&member
|
||||||
|
.recovery_image
|
||||||
|
.as_ref()
|
||||||
|
.context("Missing recovery image")?
|
||||||
|
.image,
|
||||||
|
&member.original_tag,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
supervisor
|
||||||
|
.write(
|
||||||
|
&member.original,
|
||||||
|
&[
|
||||||
|
member.original.body.clone(),
|
||||||
|
member.target_body.clone(),
|
||||||
|
member.pinned_original_body.clone(),
|
||||||
|
],
|
||||||
|
&member.pinned_original_body,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
if changed {
|
||||||
|
supervisor.reload().await?;
|
||||||
|
}
|
||||||
|
for member in &record.members {
|
||||||
|
let (preserve, running_recovery) = pre_target_state(member, supervisor).await?;
|
||||||
|
if !preserve && !running_recovery && member.original.running {
|
||||||
|
supervisor.start(&member.original.name).await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Recheck preserved IDs and bodies as well as recreated runtime before
|
||||||
|
// publishing terminal installed recipes or reopening admission.
|
||||||
|
for member in &record.members {
|
||||||
|
let (preserve, running_recovery) = pre_target_state(member, supervisor).await?;
|
||||||
|
anyhow::ensure!(
|
||||||
|
preserve || running_recovery || !member.original.running,
|
||||||
|
"Original recovery did not return; retain admission hold"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
async fn restore(guard: &Guard, record: &mut Journal, supervisor: &impl Supervisor) -> Result<()> {
|
async fn restore(guard: &Guard, record: &mut Journal, supervisor: &impl Supervisor) -> Result<()> {
|
||||||
|
// Older readers must refuse journals whose per-member preservation choices
|
||||||
|
// they cannot honor. Accept old journals for migration, then upgrade before
|
||||||
|
// persisting any recovery transition or performing recovery actions.
|
||||||
|
record.schema = 2;
|
||||||
if record.phase == Phase::Prepared {
|
if record.phase == Phase::Prepared {
|
||||||
// Only image snapshots may have happened. Never stop/recreate an intact
|
// Only image snapshots may have happened. Never stop/recreate an intact
|
||||||
// app just because preflight or snapshotting failed on another member.
|
// app just because preflight or snapshotting failed on another member.
|
||||||
@@ -908,6 +1056,10 @@ async fn restore(guard: &Guard, record: &mut Journal, supervisor: &impl Supervis
|
|||||||
.begin_barrier(&record.id, &originals, true)
|
.begin_barrier(&record.id, &originals, true)
|
||||||
.await?;
|
.await?;
|
||||||
supervisor.verify_barrier(&record.id).await?;
|
supervisor.verify_barrier(&record.id).await?;
|
||||||
|
if !record.target_startup_began {
|
||||||
|
restore_before_target(guard, record, supervisor).await?;
|
||||||
|
return complete_restore(guard, record, supervisor).await;
|
||||||
|
}
|
||||||
// Refuse to overwrite a foreign edit before stopping any surviving member.
|
// Refuse to overwrite a foreign edit before stopping any surviving member.
|
||||||
for member in &record.members {
|
for member in &record.members {
|
||||||
supervisor.validate_original_file(&member.original).await?;
|
supervisor.validate_original_file(&member.original).await?;
|
||||||
@@ -978,6 +1130,13 @@ async fn restore(guard: &Guard, record: &mut Journal, supervisor: &impl Supervis
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
complete_restore(guard, record, supervisor).await
|
||||||
|
}
|
||||||
|
async fn complete_restore(
|
||||||
|
guard: &Guard,
|
||||||
|
record: &mut Journal,
|
||||||
|
supervisor: &impl Supervisor,
|
||||||
|
) -> Result<()> {
|
||||||
for member in &record.members {
|
for member in &record.members {
|
||||||
guard.hold(&member.original.name, &record.id)?;
|
guard.hold(&member.original.name, &record.id)?;
|
||||||
}
|
}
|
||||||
@@ -1014,6 +1173,16 @@ pub(crate) async fn recover(guard: &Guard, supervisor: &impl Supervisor) -> Resu
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
Phase::Restored => {
|
Phase::Restored => {
|
||||||
|
if !record.target_startup_began {
|
||||||
|
for member in &record.members {
|
||||||
|
let (preserve, running_recovery) =
|
||||||
|
pre_target_state(member, supervisor).await?;
|
||||||
|
anyhow::ensure!(
|
||||||
|
preserve || running_recovery || !member.original.running,
|
||||||
|
"Restored original changed before admission release"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
publish_installed(guard, &record)?;
|
publish_installed(guard, &record)?;
|
||||||
supervisor
|
supervisor
|
||||||
.release_barrier(&record.id, Completion::Restored)
|
.release_barrier(&record.id, Completion::Restored)
|
||||||
@@ -1124,7 +1293,8 @@ mod tests {
|
|||||||
"app:\n id: movie\n name: Movie\n version: 2.0.0\n container:\n image: {}\n",
|
"app:\n id: movie\n name: Movie\n version: 2.0.0\n container:\n image: {}\n",
|
||||||
target.reference
|
target.reference
|
||||||
))?;
|
))?;
|
||||||
let body = pin_body(&self.original.body, "movie", &target.reference)?.replace(
|
let body = pin_body(&self.original.body, &self.original.name, &target.reference)?
|
||||||
|
.replace(
|
||||||
"Pull=never",
|
"Pull=never",
|
||||||
"Environment=NEW_FEATURE=enabled\nVolume=/identity:/run/identity:ro\nPull=never",
|
"Environment=NEW_FEATURE=enabled\nVolume=/identity:/run/identity:ro\nPull=never",
|
||||||
);
|
);
|
||||||
@@ -1201,7 +1371,7 @@ mod tests {
|
|||||||
let new = self.body.lock().unwrap().contains("NEW_FEATURE=enabled");
|
let new = self.body.lock().unwrap().contains("NEW_FEATURE=enabled");
|
||||||
Ok(Some(Observed {
|
Ok(Some(Observed {
|
||||||
id: format!("{:064x}", self.generation.load(Ordering::SeqCst)),
|
id: format!("{:064x}", self.generation.load(Ordering::SeqCst)),
|
||||||
name: "movie".into(),
|
name: self.original.name.clone(),
|
||||||
image: if new {
|
image: if new {
|
||||||
"b".repeat(64)
|
"b".repeat(64)
|
||||||
} else if self.body.lock().unwrap().contains(&"e".repeat(64)) {
|
} else if self.body.lock().unwrap().contains(&"e".repeat(64)) {
|
||||||
@@ -1218,6 +1388,328 @@ mod tests {
|
|||||||
Ok(true)
|
Ok(true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
struct DrainFailureStack {
|
||||||
|
members: std::collections::BTreeMap<String, Mock>,
|
||||||
|
partial_frontend: bool,
|
||||||
|
lose_frontend_start: AtomicBool,
|
||||||
|
}
|
||||||
|
impl DrainFailureStack {
|
||||||
|
fn new(partial_frontend: bool) -> Self {
|
||||||
|
let members = ["frontend", "worker", "api", "storage"]
|
||||||
|
.into_iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(index, name)| {
|
||||||
|
let mut member = Mock::new();
|
||||||
|
member.original.name = name.into();
|
||||||
|
member.original.body = member
|
||||||
|
.original
|
||||||
|
.body
|
||||||
|
.replace("ContainerName=movie", &format!("ContainerName={name}"));
|
||||||
|
*member.body.lock().unwrap() = member.original.body.clone();
|
||||||
|
member.original.container_id = format!("{:064x}", index + 1);
|
||||||
|
member.generation.store(index + 1, Ordering::SeqCst);
|
||||||
|
(name.into(), member)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
Self {
|
||||||
|
members,
|
||||||
|
partial_frontend,
|
||||||
|
lose_frontend_start: AtomicBool::new(false),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn targets(&self) -> Vec<Target> {
|
||||||
|
self.members
|
||||||
|
.keys()
|
||||||
|
.map(|name| {
|
||||||
|
let mut target = Mock::target();
|
||||||
|
target.name = name.clone();
|
||||||
|
target
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
impl Supervisor for DrainFailureStack {
|
||||||
|
async fn begin_barrier(
|
||||||
|
&self,
|
||||||
|
operation: &str,
|
||||||
|
originals: &[Unit],
|
||||||
|
recovery: bool,
|
||||||
|
) -> Result<()> {
|
||||||
|
for member in self.members.values() {
|
||||||
|
member.begin_barrier(operation, originals, recovery).await?;
|
||||||
|
}
|
||||||
|
if !recovery {
|
||||||
|
if self.partial_frontend {
|
||||||
|
self.members["frontend"].stop("frontend").await?;
|
||||||
|
}
|
||||||
|
anyhow::bail!("Active worker refused drain; no work completion acknowledged");
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
async fn verify_barrier(&self, operation: &str) -> Result<()> {
|
||||||
|
for member in self.members.values() {
|
||||||
|
member.verify_barrier(operation).await?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
async fn release_barrier(&self, operation: &str, outcome: Completion) -> Result<()> {
|
||||||
|
for member in self.members.values() {
|
||||||
|
member.release_barrier(operation, outcome).await?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
async fn prepare_target(&self, target: &Target, original: &Unit) -> Result<PreparedTarget> {
|
||||||
|
self.members[&target.name]
|
||||||
|
.prepare_target(target, original)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
async fn target_hooks(
|
||||||
|
&self,
|
||||||
|
name: &str,
|
||||||
|
manifest: &archipelago_container::AppManifest,
|
||||||
|
) -> Result<()> {
|
||||||
|
self.members[name].target_hooks(name, manifest).await
|
||||||
|
}
|
||||||
|
async fn snapshot(
|
||||||
|
&self,
|
||||||
|
original: &Unit,
|
||||||
|
operation: &str,
|
||||||
|
tag: &str,
|
||||||
|
) -> Result<RecoveryImage> {
|
||||||
|
self.members[&original.name]
|
||||||
|
.snapshot(original, operation, tag)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
async fn capture(&self, name: &str) -> Result<Unit> {
|
||||||
|
self.members[name].capture(name).await
|
||||||
|
}
|
||||||
|
async fn validate_original_file(&self, original: &Unit) -> Result<()> {
|
||||||
|
self.members[&original.name]
|
||||||
|
.validate_original_file(original)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
async fn read(&self, name: &str) -> Result<String> {
|
||||||
|
self.members[name].read(name).await
|
||||||
|
}
|
||||||
|
async fn write(&self, original: &Unit, expected: &[String], body: &str) -> Result<()> {
|
||||||
|
self.members[&original.name]
|
||||||
|
.write(original, expected, body)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
async fn pin(&self, _image: &str, _tag: &str) -> Result<()> {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
async fn stop(&self, name: &str) -> Result<()> {
|
||||||
|
self.members[name].stop(name).await
|
||||||
|
}
|
||||||
|
async fn reload(&self) -> Result<()> {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
async fn start(&self, name: &str) -> Result<()> {
|
||||||
|
self.members[name].start(name).await?;
|
||||||
|
anyhow::ensure!(
|
||||||
|
name != "frontend" || !self.lose_frontend_start.swap(false, Ordering::SeqCst),
|
||||||
|
"Lost original start acknowledgement"
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
async fn observed(&self, name: &str) -> Result<Option<Observed>> {
|
||||||
|
self.members[name].observed(name).await
|
||||||
|
}
|
||||||
|
async fn healthy(&self, name: &str) -> Result<bool> {
|
||||||
|
self.members[name].healthy(name).await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#[tokio::test]
|
||||||
|
async fn failed_active_worker_drain_never_stops_or_recreates_intact_originals() {
|
||||||
|
let root = tempfile::tempdir().unwrap();
|
||||||
|
let guard = Guard::acquire(root.path()).unwrap();
|
||||||
|
let runtime = DrainFailureStack::new(false);
|
||||||
|
assert!(execute(&guard, "movie", &runtime.targets(), &runtime)
|
||||||
|
.await
|
||||||
|
.is_err());
|
||||||
|
let record = records(&guard).unwrap().pop().unwrap();
|
||||||
|
assert_eq!(record.phase, Phase::Restored);
|
||||||
|
assert!(!record.target_startup_began);
|
||||||
|
assert_eq!(record.schema, 2);
|
||||||
|
let mut downgraded = record.clone();
|
||||||
|
downgraded.schema = 1;
|
||||||
|
assert!(validate(&downgraded).is_err());
|
||||||
|
for member in &record.members {
|
||||||
|
assert_eq!(member.preserve_original, Some(true));
|
||||||
|
let live = &runtime.members[&member.original.name];
|
||||||
|
assert_eq!(*live.calls.lock().unwrap(), ["snapshot-original"]);
|
||||||
|
assert_eq!(
|
||||||
|
live.observed(&member.original.name)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.unwrap()
|
||||||
|
.id,
|
||||||
|
member.original.container_id
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
installed_unit(root.path(), &member.original.name)
|
||||||
|
.unwrap()
|
||||||
|
.unwrap()
|
||||||
|
.0,
|
||||||
|
member.original.body
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#[tokio::test]
|
||||||
|
async fn partial_frontend_drain_restores_only_missing_frontend_preserving_busy_writers() {
|
||||||
|
let root = tempfile::tempdir().unwrap();
|
||||||
|
let guard = Guard::acquire(root.path()).unwrap();
|
||||||
|
let runtime = DrainFailureStack::new(true);
|
||||||
|
assert!(execute(&guard, "movie", &runtime.targets(), &runtime)
|
||||||
|
.await
|
||||||
|
.is_err());
|
||||||
|
let record = records(&guard).unwrap().pop().unwrap();
|
||||||
|
assert_eq!(record.phase, Phase::Restored);
|
||||||
|
for member in &record.members {
|
||||||
|
let live = &runtime.members[&member.original.name];
|
||||||
|
if member.original.name == "frontend" {
|
||||||
|
assert_eq!(member.preserve_original, Some(false));
|
||||||
|
assert_eq!(
|
||||||
|
*live.calls.lock().unwrap(),
|
||||||
|
["snapshot-original", "stop-unit", "write", "start-unit"]
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
installed_unit(root.path(), "frontend").unwrap().unwrap().0,
|
||||||
|
member.pinned_original_body
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
assert_eq!(member.preserve_original, Some(true));
|
||||||
|
assert_eq!(*live.calls.lock().unwrap(), ["snapshot-original"]);
|
||||||
|
assert_eq!(
|
||||||
|
installed_unit(root.path(), &member.original.name)
|
||||||
|
.unwrap()
|
||||||
|
.unwrap()
|
||||||
|
.0,
|
||||||
|
member.original.body
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#[tokio::test]
|
||||||
|
async fn pre_target_restart_adopts_own_recreation_after_lost_start_ack_without_stopping_it() {
|
||||||
|
let root = tempfile::tempdir().unwrap();
|
||||||
|
let guard = Guard::acquire(root.path()).unwrap();
|
||||||
|
let runtime = DrainFailureStack::new(true);
|
||||||
|
runtime.lose_frontend_start.store(true, Ordering::SeqCst);
|
||||||
|
assert!(execute(&guard, "movie", &runtime.targets(), &runtime)
|
||||||
|
.await
|
||||||
|
.is_err());
|
||||||
|
assert_eq!(records(&guard).unwrap()[0].phase, Phase::Restoring);
|
||||||
|
let before = runtime.members["frontend"]
|
||||||
|
.observed("frontend")
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.unwrap()
|
||||||
|
.id;
|
||||||
|
for member in runtime.members.values() {
|
||||||
|
member.calls.lock().unwrap().clear();
|
||||||
|
}
|
||||||
|
recover(&guard, &runtime).await.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
runtime.members["frontend"]
|
||||||
|
.observed("frontend")
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.unwrap()
|
||||||
|
.id,
|
||||||
|
before
|
||||||
|
);
|
||||||
|
for member in runtime.members.values() {
|
||||||
|
assert!(member.calls.lock().unwrap().is_empty());
|
||||||
|
}
|
||||||
|
assert_eq!(records(&guard).unwrap()[0].phase, Phase::Restored);
|
||||||
|
}
|
||||||
|
#[tokio::test]
|
||||||
|
async fn pre_target_recovery_rejects_running_recreation_of_originally_stopped_member() {
|
||||||
|
let root = tempfile::tempdir().unwrap();
|
||||||
|
let guard = Guard::acquire(root.path()).unwrap();
|
||||||
|
let runtime = DrainFailureStack::new(true);
|
||||||
|
runtime.lose_frontend_start.store(true, Ordering::SeqCst);
|
||||||
|
assert!(execute(&guard, "movie", &runtime.targets(), &runtime)
|
||||||
|
.await
|
||||||
|
.is_err());
|
||||||
|
let mut record = records(&guard).unwrap().pop().unwrap();
|
||||||
|
// Model a restart journal whose captured operator intent was stopped,
|
||||||
|
// while an external actor has started its operation-owned recovery.
|
||||||
|
record
|
||||||
|
.members
|
||||||
|
.iter_mut()
|
||||||
|
.find(|m| m.original.name == "frontend")
|
||||||
|
.unwrap()
|
||||||
|
.original
|
||||||
|
.running = false;
|
||||||
|
save(&guard, &record).unwrap();
|
||||||
|
for member in runtime.members.values() {
|
||||||
|
member.calls.lock().unwrap().clear();
|
||||||
|
}
|
||||||
|
let error = recover(&guard, &runtime).await.unwrap_err();
|
||||||
|
assert!(error.to_string().contains("Originally stopped member"));
|
||||||
|
assert_eq!(records(&guard).unwrap()[0].phase, Phase::Restoring);
|
||||||
|
assert!(super::super::update_transaction::is_held(root.path(), "frontend").unwrap());
|
||||||
|
for member in runtime.members.values() {
|
||||||
|
assert!(member.calls.lock().unwrap().is_empty());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#[tokio::test]
|
||||||
|
async fn legacy_pre_target_journal_upgrades_before_preserving_surviving_originals() {
|
||||||
|
let root = tempfile::tempdir().unwrap();
|
||||||
|
let guard = Guard::acquire(root.path()).unwrap();
|
||||||
|
let runtime = DrainFailureStack::new(true);
|
||||||
|
runtime.lose_frontend_start.store(true, Ordering::SeqCst);
|
||||||
|
assert!(execute(&guard, "movie", &runtime.targets(), &runtime)
|
||||||
|
.await
|
||||||
|
.is_err());
|
||||||
|
let mut legacy = records(&guard).unwrap().pop().unwrap();
|
||||||
|
legacy.schema = 1;
|
||||||
|
for member in &mut legacy.members {
|
||||||
|
member.preserve_original = None;
|
||||||
|
}
|
||||||
|
save(&guard, &legacy).unwrap();
|
||||||
|
for member in runtime.members.values() {
|
||||||
|
member.calls.lock().unwrap().clear();
|
||||||
|
}
|
||||||
|
recover(&guard, &runtime).await.unwrap();
|
||||||
|
let migrated = records(&guard).unwrap().pop().unwrap();
|
||||||
|
assert_eq!(migrated.schema, 2);
|
||||||
|
assert_eq!(migrated.phase, Phase::Restored);
|
||||||
|
assert!(migrated
|
||||||
|
.members
|
||||||
|
.iter()
|
||||||
|
.all(|m| m.preserve_original.is_some()));
|
||||||
|
for member in runtime.members.values() {
|
||||||
|
assert!(member.calls.lock().unwrap().is_empty());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#[tokio::test]
|
||||||
|
async fn changed_preserved_writer_blocks_recovery_and_cannot_be_reclassified_for_recreation() {
|
||||||
|
let root = tempfile::tempdir().unwrap();
|
||||||
|
let guard = Guard::acquire(root.path()).unwrap();
|
||||||
|
let runtime = DrainFailureStack::new(true);
|
||||||
|
runtime.lose_frontend_start.store(true, Ordering::SeqCst);
|
||||||
|
assert!(execute(&guard, "movie", &runtime.targets(), &runtime)
|
||||||
|
.await
|
||||||
|
.is_err());
|
||||||
|
runtime.members["worker"]
|
||||||
|
.generation
|
||||||
|
.store(99, Ordering::SeqCst);
|
||||||
|
for member in runtime.members.values() {
|
||||||
|
member.calls.lock().unwrap().clear();
|
||||||
|
}
|
||||||
|
assert!(recover(&guard, &runtime).await.is_err());
|
||||||
|
for member in runtime.members.values() {
|
||||||
|
assert!(member.calls.lock().unwrap().is_empty());
|
||||||
|
}
|
||||||
|
assert!(super::super::update_transaction::is_held(root.path(), "worker").unwrap());
|
||||||
|
let mut record = records(&guard).unwrap().pop().unwrap();
|
||||||
|
record.target_startup_began = true;
|
||||||
|
assert!(save(&guard, &record).is_err());
|
||||||
|
}
|
||||||
#[test]
|
#[test]
|
||||||
fn administrative_original_capture_is_idempotent_and_uninstall_forgets_it() {
|
fn administrative_original_capture_is_idempotent_and_uninstall_forgets_it() {
|
||||||
let root = tempfile::tempdir().unwrap();
|
let root = tempfile::tempdir().unwrap();
|
||||||
|
|||||||
Reference in New Issue
Block a user