Preserve reviewed managed unit recipes after update completion

This commit is contained in:
archipelago
2026-10-07 02:49:31 -04:00
parent 5a9aac18ea
commit 39852ab381
4 changed files with 171 additions and 0 deletions
@@ -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();