Preserve reviewed managed unit recipes after update completion
This commit is contained in:
@@ -342,6 +342,7 @@ impl RpcHandler {
|
||||
LegacyIndeeMaintenance::new(guard)?,
|
||||
)
|
||||
.await?;
|
||||
let targets = adapter.reviewed_targets(&targets).await?;
|
||||
crate::container::supervised_update::execute(guard, package_id, &targets, &adapter)
|
||||
.await
|
||||
} else {
|
||||
|
||||
@@ -3055,6 +3055,7 @@ impl ProdContainerOrchestrator {
|
||||
}
|
||||
|
||||
async fn install_fresh_with_pin(&self, lm: &LoadedManifest, pinned: bool) -> Result<()> {
|
||||
anyhow::ensure!(super::supervised_update::installed_unit(&self.data_dir, &compute_container_name(&lm.manifest))?.is_none(), "Reviewed managed runtime is missing; restore its saved unit/image explicitly instead of recreating from the current catalog");
|
||||
self.ensure_app_secrets(&lm.manifest.app.id).await?;
|
||||
let mut resolved_manifest = lm.manifest.clone();
|
||||
self.resolve_dynamic_env(&mut resolved_manifest).await?;
|
||||
@@ -3354,6 +3355,9 @@ impl ProdContainerOrchestrator {
|
||||
lm: &LoadedManifest,
|
||||
name: &str,
|
||||
) -> Result<Option<ReconcileAction>> {
|
||||
if super::supervised_update::installed_unit(&self.data_dir, name)?.is_some() {
|
||||
return Ok(None); // Never remove an original runtime to migrate it from changed catalog data.
|
||||
}
|
||||
// Skip companion apps — bitcoin-ui / electrs-ui / lnd-ui have shipped
|
||||
// via Quadlet since v1.7.41 (companion.rs renders the unit). Running
|
||||
// migration for them races companion rendering: when migration ran
|
||||
@@ -3456,6 +3460,15 @@ impl ProdContainerOrchestrator {
|
||||
if super::update_transaction::is_held(&self.data_dir, name)? {
|
||||
return Ok(()); // Preserve the recovered unit instead of current catalog drift.
|
||||
}
|
||||
if let Some((body, mode)) = super::supervised_update::installed_unit(&self.data_dir, name)?
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let path = quadlet::unit_dir().await?.join(format!("{name}.container"));
|
||||
let meta = std::fs::symlink_metadata(&path)
|
||||
.context("Saved managed unit is missing; recovery required")?;
|
||||
anyhow::ensure!(meta.is_file() && meta.permissions().mode() & 0o777 == mode && std::fs::read_to_string(&path)? == body, "Reviewed managed unit changed; preserve it for explicit reconciliation instead of overwriting operator configuration");
|
||||
return Ok(());
|
||||
}
|
||||
// Companions: same reasoning as migrate_to_quadlet_if_needed —
|
||||
// companion.rs renders these units with a different shape, syncing
|
||||
// here would clobber them.
|
||||
@@ -5363,6 +5376,7 @@ impl ContainerOrchestrator for ProdContainerOrchestrator {
|
||||
let _guard = lock.lock().await;
|
||||
for name in [app_id.to_string(), format!("archy-{app_id}")] {
|
||||
self.remove_quadlet_unit_if_present(&name).await?;
|
||||
super::supervised_update::forget_installed(&self.data_dir, &name)?;
|
||||
}
|
||||
self.state.write().await.disabled.insert(app_id.to_string());
|
||||
super::staged_update::clear(&self.data_dir, app_id).await?;
|
||||
@@ -5422,6 +5436,7 @@ impl ContainerOrchestrator for ProdContainerOrchestrator {
|
||||
crate::crash_recovery::clear_installed(&self.data_dir, app_id).await;
|
||||
super::staged_update::clear(&self.data_dir, app_id).await?;
|
||||
super::staged_update::clear_installed(&self.data_dir, app_id).await?;
|
||||
super::supervised_update::forget_installed(&self.data_dir, &name)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -307,6 +307,42 @@ impl<B: DrainBarrier> SystemdSupervisor<B> {
|
||||
barrier,
|
||||
})
|
||||
}
|
||||
/// A reviewed plan may replace a mutable catalog spelling with its exact
|
||||
/// locally verified digest, but never select different image bytes.
|
||||
pub(crate) async fn reviewed_targets(&self, targets: &[Target]) -> Result<Vec<Target>> {
|
||||
let mut refs = Vec::new();
|
||||
for target in targets {
|
||||
let plan = self
|
||||
.plans
|
||||
.get(&target.name)
|
||||
.context("Missing reviewed managed target")?;
|
||||
let reference = plan
|
||||
.prepared
|
||||
.manifest
|
||||
.app
|
||||
.container
|
||||
.image
|
||||
.as_ref()
|
||||
.context("Reviewed image missing")?;
|
||||
anyhow::ensure!(
|
||||
reference.rsplit_once("@sha256:").is_some_and(
|
||||
|(_, hash)| hash.len() == 64 && hash.bytes().all(|b| b.is_ascii_hexdigit())
|
||||
),
|
||||
"Managed target must use an immutable digest"
|
||||
);
|
||||
refs.push((target.name.clone(), reference.clone()));
|
||||
}
|
||||
let resolved = Podman::targets(&refs, false).await?;
|
||||
anyhow::ensure!(
|
||||
resolved
|
||||
.iter()
|
||||
.zip(targets)
|
||||
.all(|(reviewed, catalog)| reviewed.name == catalog.name
|
||||
&& reviewed.image == catalog.image),
|
||||
"Reviewed managed image differs from prepared catalog image"
|
||||
);
|
||||
Ok(resolved)
|
||||
}
|
||||
fn path(&self, name: &str) -> Result<PathBuf> {
|
||||
anyhow::ensure!(
|
||||
!name.is_empty()
|
||||
|
||||
@@ -513,6 +513,91 @@ fn save(guard: &Guard, record: &Journal) -> Result<()> {
|
||||
}
|
||||
result
|
||||
}
|
||||
// The committed unit is installation evidence, not a cache of today's catalog.
|
||||
// Keep it until explicit uninstall or the next reviewed managed transaction.
|
||||
#[derive(Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct InstalledUnit {
|
||||
schema: u8,
|
||||
operation: String,
|
||||
name: String,
|
||||
body: String,
|
||||
mode: u32,
|
||||
}
|
||||
fn installed_path(data: &Path, name: &str) -> Result<PathBuf> {
|
||||
anyhow::ensure!(simple(name), "Invalid managed member name");
|
||||
Ok(data
|
||||
.join("update-transactions/installed-units")
|
||||
.join(format!("{name}.json")))
|
||||
}
|
||||
fn publish_installed(guard: &Guard, record: &Journal) -> Result<()> {
|
||||
anyhow::ensure!(
|
||||
record.phase == Phase::Committed,
|
||||
"Only committed units may be published"
|
||||
);
|
||||
let dir = guard.directory().join("installed-units");
|
||||
match std::fs::DirBuilder::new().mode(0o700).create(&dir) {
|
||||
Ok(()) => {}
|
||||
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {}
|
||||
Err(e) => return Err(e.into()),
|
||||
}
|
||||
anyhow::ensure!(
|
||||
std::fs::symlink_metadata(&dir)?.is_dir(),
|
||||
"Invalid installed unit directory"
|
||||
);
|
||||
for member in &record.members {
|
||||
let saved = InstalledUnit {
|
||||
schema: 1,
|
||||
operation: record.id.clone(),
|
||||
name: member.original.name.clone(),
|
||||
body: member.target_body.clone(),
|
||||
mode: member.original.file_mode,
|
||||
};
|
||||
let temporary = dir.join(format!(".{}.tmp", uuid::Uuid::new_v4()));
|
||||
let mut file = std::fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.create_new(true)
|
||||
.mode(0o600)
|
||||
.open(&temporary)?;
|
||||
file.write_all(&serde_json::to_vec(&saved)?)?;
|
||||
file.sync_all()?;
|
||||
std::fs::rename(&temporary, dir.join(format!("{}.json", saved.name)))?;
|
||||
}
|
||||
std::fs::File::open(&dir)?.sync_all()?;
|
||||
std::fs::File::open(guard.directory())?.sync_all()?;
|
||||
Ok(())
|
||||
}
|
||||
pub(crate) fn installed_unit(data: &Path, name: &str) -> Result<Option<(String, u32)>> {
|
||||
let path = installed_path(data, name)?;
|
||||
let meta = match std::fs::symlink_metadata(&path) {
|
||||
Ok(meta) => meta,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
|
||||
Err(e) => return Err(e.into()),
|
||||
};
|
||||
anyhow::ensure!(
|
||||
meta.is_file() && meta.len() <= 4 * 1024 * 1024,
|
||||
"Invalid installed managed recipe"
|
||||
);
|
||||
let saved: InstalledUnit = serde_json::from_slice(&std::fs::read(path)?)?;
|
||||
anyhow::ensure!(
|
||||
saved.schema == 1
|
||||
&& saved.name == name
|
||||
&& uuid::Uuid::parse_str(&saved.operation)?.to_string() == saved.operation
|
||||
&& saved.mode & !0o777 == 0
|
||||
&& saved.mode & 0o022 == 0,
|
||||
"Invalid installed managed recipe binding"
|
||||
);
|
||||
Ok(Some((saved.body, saved.mode)))
|
||||
}
|
||||
pub(crate) fn forget_installed(data: &Path, name: &str) -> Result<()> {
|
||||
let path = installed_path(data, name)?;
|
||||
match std::fs::remove_file(&path) {
|
||||
Ok(()) => std::fs::File::open(path.parent().unwrap())?.sync_all()?,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
|
||||
Err(e) => return Err(e.into()),
|
||||
};
|
||||
Ok(())
|
||||
}
|
||||
fn records(guard: &Guard) -> Result<Vec<Journal>> {
|
||||
let dir = root(guard)?;
|
||||
let mut records = Vec::new();
|
||||
@@ -673,6 +758,10 @@ async fn apply(guard: &Guard, record: &mut Journal, supervisor: &impl Supervisor
|
||||
record.cleanup_done = false;
|
||||
save(guard, record)?;
|
||||
for member in &record.members {
|
||||
anyhow::ensure!(
|
||||
supervisor.read(&member.original.name).await? == member.target_body,
|
||||
"Reviewed unit changed before target start; recovery required"
|
||||
);
|
||||
supervisor.start(&member.original.name).await?;
|
||||
supervisor
|
||||
.target_hooks(&member.original.name, &member.target_manifest)
|
||||
@@ -690,6 +779,7 @@ async fn apply(guard: &Guard, record: &mut Journal, supervisor: &impl Supervisor
|
||||
}
|
||||
record.phase = Phase::Committed;
|
||||
save(guard, record)?;
|
||||
publish_installed(guard, record)?;
|
||||
supervisor
|
||||
.release_barrier(&record.id, Completion::Committed)
|
||||
.await?;
|
||||
@@ -782,6 +872,10 @@ async fn restore(guard: &Guard, record: &mut Journal, supervisor: &impl Supervis
|
||||
supervisor.reload().await?;
|
||||
for member in &record.members {
|
||||
if member.original.running {
|
||||
anyhow::ensure!(
|
||||
supervisor.read(&member.original.name).await? == member.pinned_original_body,
|
||||
"Original recovery unit changed before start"
|
||||
);
|
||||
supervisor.start(&member.original.name).await?;
|
||||
}
|
||||
let observed = supervisor.observed(&member.original.name).await?;
|
||||
@@ -823,6 +917,9 @@ pub(crate) async fn recover(guard: &Guard, supervisor: &impl Supervisor) -> Resu
|
||||
}
|
||||
match record.phase {
|
||||
Phase::Committed | Phase::Aborted => {
|
||||
if record.phase == Phase::Committed {
|
||||
publish_installed(guard, &record)?;
|
||||
}
|
||||
let outcome = if record.phase == Phase::Committed {
|
||||
Completion::Committed
|
||||
} else {
|
||||
@@ -1122,6 +1219,28 @@ mod tests {
|
||||
assert_eq!(*runtime.calls.lock().unwrap(), ["snapshot-original"]);
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn committed_unit_survives_restart_until_explicit_uninstall() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let guard = Guard::acquire(root.path()).unwrap();
|
||||
let runtime = Mock::new();
|
||||
execute(&guard, "movie", &[Mock::target()], &runtime)
|
||||
.await
|
||||
.unwrap();
|
||||
let saved = installed_unit(root.path(), "movie").unwrap().unwrap();
|
||||
assert_eq!(saved.0, *runtime.body.lock().unwrap());
|
||||
assert!(saved.0.contains("OPERATOR_VALUE=retained"));
|
||||
runtime.calls.lock().unwrap().clear();
|
||||
recover(&guard, &runtime).await.unwrap();
|
||||
assert_eq!(
|
||||
installed_unit(root.path(), "movie").unwrap().unwrap(),
|
||||
saved
|
||||
);
|
||||
assert!(runtime.calls.lock().unwrap().is_empty());
|
||||
forget_installed(root.path(), "movie").unwrap();
|
||||
recover(&guard, &runtime).await.unwrap();
|
||||
assert!(installed_unit(root.path(), "movie").unwrap().is_none());
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn committed_restart_releases_only_its_own_hold_without_runtime_mutation() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let guard = Guard::acquire(root.path()).unwrap();
|
||||
|
||||
Reference in New Issue
Block a user