Preserve reviewed managed unit recipes after update completion
This commit is contained in:
@@ -342,6 +342,7 @@ impl RpcHandler {
|
|||||||
LegacyIndeeMaintenance::new(guard)?,
|
LegacyIndeeMaintenance::new(guard)?,
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
let targets = adapter.reviewed_targets(&targets).await?;
|
||||||
crate::container::supervised_update::execute(guard, package_id, &targets, &adapter)
|
crate::container::supervised_update::execute(guard, package_id, &targets, &adapter)
|
||||||
.await
|
.await
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -3055,6 +3055,7 @@ impl ProdContainerOrchestrator {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn install_fresh_with_pin(&self, lm: &LoadedManifest, pinned: bool) -> Result<()> {
|
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?;
|
self.ensure_app_secrets(&lm.manifest.app.id).await?;
|
||||||
let mut resolved_manifest = lm.manifest.clone();
|
let mut resolved_manifest = lm.manifest.clone();
|
||||||
self.resolve_dynamic_env(&mut resolved_manifest).await?;
|
self.resolve_dynamic_env(&mut resolved_manifest).await?;
|
||||||
@@ -3354,6 +3355,9 @@ impl ProdContainerOrchestrator {
|
|||||||
lm: &LoadedManifest,
|
lm: &LoadedManifest,
|
||||||
name: &str,
|
name: &str,
|
||||||
) -> Result<Option<ReconcileAction>> {
|
) -> 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
|
// Skip companion apps — bitcoin-ui / electrs-ui / lnd-ui have shipped
|
||||||
// via Quadlet since v1.7.41 (companion.rs renders the unit). Running
|
// via Quadlet since v1.7.41 (companion.rs renders the unit). Running
|
||||||
// migration for them races companion rendering: when migration ran
|
// 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)? {
|
if super::update_transaction::is_held(&self.data_dir, name)? {
|
||||||
return Ok(()); // Preserve the recovered unit instead of current catalog drift.
|
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 —
|
// Companions: same reasoning as migrate_to_quadlet_if_needed —
|
||||||
// companion.rs renders these units with a different shape, syncing
|
// companion.rs renders these units with a different shape, syncing
|
||||||
// here would clobber them.
|
// here would clobber them.
|
||||||
@@ -5363,6 +5376,7 @@ impl ContainerOrchestrator for ProdContainerOrchestrator {
|
|||||||
let _guard = lock.lock().await;
|
let _guard = lock.lock().await;
|
||||||
for name in [app_id.to_string(), format!("archy-{app_id}")] {
|
for name in [app_id.to_string(), format!("archy-{app_id}")] {
|
||||||
self.remove_quadlet_unit_if_present(&name).await?;
|
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());
|
self.state.write().await.disabled.insert(app_id.to_string());
|
||||||
super::staged_update::clear(&self.data_dir, app_id).await?;
|
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;
|
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(&self.data_dir, app_id).await?;
|
||||||
super::staged_update::clear_installed(&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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -307,6 +307,42 @@ impl<B: DrainBarrier> SystemdSupervisor<B> {
|
|||||||
barrier,
|
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> {
|
fn path(&self, name: &str) -> Result<PathBuf> {
|
||||||
anyhow::ensure!(
|
anyhow::ensure!(
|
||||||
!name.is_empty()
|
!name.is_empty()
|
||||||
|
|||||||
@@ -513,6 +513,91 @@ fn save(guard: &Guard, record: &Journal) -> Result<()> {
|
|||||||
}
|
}
|
||||||
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>> {
|
fn records(guard: &Guard) -> Result<Vec<Journal>> {
|
||||||
let dir = root(guard)?;
|
let dir = root(guard)?;
|
||||||
let mut records = Vec::new();
|
let mut records = Vec::new();
|
||||||
@@ -673,6 +758,10 @@ async fn apply(guard: &Guard, record: &mut Journal, supervisor: &impl Supervisor
|
|||||||
record.cleanup_done = false;
|
record.cleanup_done = false;
|
||||||
save(guard, record)?;
|
save(guard, record)?;
|
||||||
for member in &record.members {
|
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.start(&member.original.name).await?;
|
||||||
supervisor
|
supervisor
|
||||||
.target_hooks(&member.original.name, &member.target_manifest)
|
.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;
|
record.phase = Phase::Committed;
|
||||||
save(guard, record)?;
|
save(guard, record)?;
|
||||||
|
publish_installed(guard, record)?;
|
||||||
supervisor
|
supervisor
|
||||||
.release_barrier(&record.id, Completion::Committed)
|
.release_barrier(&record.id, Completion::Committed)
|
||||||
.await?;
|
.await?;
|
||||||
@@ -782,6 +872,10 @@ async fn restore(guard: &Guard, record: &mut Journal, supervisor: &impl Supervis
|
|||||||
supervisor.reload().await?;
|
supervisor.reload().await?;
|
||||||
for member in &record.members {
|
for member in &record.members {
|
||||||
if member.original.running {
|
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?;
|
supervisor.start(&member.original.name).await?;
|
||||||
}
|
}
|
||||||
let observed = supervisor.observed(&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 {
|
match record.phase {
|
||||||
Phase::Committed | Phase::Aborted => {
|
Phase::Committed | Phase::Aborted => {
|
||||||
|
if record.phase == Phase::Committed {
|
||||||
|
publish_installed(guard, &record)?;
|
||||||
|
}
|
||||||
let outcome = if record.phase == Phase::Committed {
|
let outcome = if record.phase == Phase::Committed {
|
||||||
Completion::Committed
|
Completion::Committed
|
||||||
} else {
|
} else {
|
||||||
@@ -1122,6 +1219,28 @@ mod tests {
|
|||||||
assert_eq!(*runtime.calls.lock().unwrap(), ["snapshot-original"]);
|
assert_eq!(*runtime.calls.lock().unwrap(), ["snapshot-original"]);
|
||||||
}
|
}
|
||||||
#[tokio::test]
|
#[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() {
|
async fn committed_restart_releases_only_its_own_hold_without_runtime_mutation() {
|
||||||
let root = tempfile::tempdir().unwrap();
|
let root = tempfile::tempdir().unwrap();
|
||||||
let guard = Guard::acquire(root.path()).unwrap();
|
let guard = Guard::acquire(root.path()).unwrap();
|
||||||
|
|||||||
Reference in New Issue
Block a user