Retain verified restored units and validate original installer identity bindings
This commit is contained in:
@@ -3283,6 +3283,16 @@ impl ProdContainerOrchestrator {
|
||||
}
|
||||
|
||||
async fn prepare_for_start(&self, manifest: &AppManifest) -> Result<()> {
|
||||
if super::supervised_update::installed_unit(
|
||||
&self.data_dir,
|
||||
&compute_container_name(manifest),
|
||||
)?
|
||||
.is_some()
|
||||
{
|
||||
// Already-reviewed managed configuration is authoritative. Applying
|
||||
// today's catalog files/mount preparation could mutate restored data.
|
||||
return Ok(());
|
||||
}
|
||||
self.run_pre_start_hooks(&manifest.app.id).await?;
|
||||
self.ensure_bind_mount_sockets(manifest).await?;
|
||||
self.ensure_bind_mount_dirs(manifest).await?;
|
||||
@@ -3585,6 +3595,17 @@ impl ProdContainerOrchestrator {
|
||||
}
|
||||
|
||||
async fn ensure_resolved_source_available(&self, lm: &LoadedManifest) -> Result<()> {
|
||||
if let Some((body, _)) = super::supervised_update::installed_unit(
|
||||
&self.data_dir,
|
||||
&compute_container_name(&lm.manifest),
|
||||
)? {
|
||||
let image = body
|
||||
.lines()
|
||||
.find_map(|line| line.trim().strip_prefix("Image="))
|
||||
.context("Saved managed image missing")?;
|
||||
anyhow::ensure!(self.runtime.image_exists(image).await?, "Saved managed image is unavailable; refusing to pull or recreate from a changed catalog");
|
||||
return Ok(());
|
||||
}
|
||||
let resolved = lm.manifest.app.container.resolve().ok_or_else(|| {
|
||||
anyhow::anyhow!(
|
||||
"manifest for {} has invalid container source (neither image nor build)",
|
||||
|
||||
@@ -434,6 +434,58 @@ impl<B: DrainBarrier> Supervisor for SystemdSupervisor<B> {
|
||||
== target.name,
|
||||
"Original unit or reviewed immutable target changed before update"
|
||||
);
|
||||
if plan
|
||||
.prepared
|
||||
.manifest
|
||||
.app
|
||||
.container
|
||||
.media_registration_identity
|
||||
{
|
||||
let identity =
|
||||
crate::identity::NodeIdentity::load_existing(&self.data_dir.join("identity"))
|
||||
.await?;
|
||||
let pin = super::registration_pin::load_existing(
|
||||
&self.data_dir,
|
||||
&plan.prepared.manifest.app.id,
|
||||
&identity,
|
||||
)?;
|
||||
let mut expected = plan.prepared.manifest.clone();
|
||||
super::registration_pin::apply_environment(&mut expected, &pin)?;
|
||||
for entry in expected
|
||||
.app
|
||||
.environment
|
||||
.iter()
|
||||
.filter(|entry| entry.starts_with("ARCHIPELAGO_REGISTRATION_"))
|
||||
{
|
||||
let key = entry
|
||||
.split_once('=')
|
||||
.context("Malformed registration binding")?
|
||||
.0;
|
||||
let in_manifest: Vec<_> = plan
|
||||
.prepared
|
||||
.manifest
|
||||
.app
|
||||
.environment
|
||||
.iter()
|
||||
.filter(|value| value.split_once('=').is_some_and(|(name, _)| name == key))
|
||||
.collect();
|
||||
let in_unit: Vec<_> = plan
|
||||
.prepared
|
||||
.body
|
||||
.lines()
|
||||
.filter_map(|line| line.trim().strip_prefix("Environment="))
|
||||
.map(|value| value.trim_matches('"'))
|
||||
.filter(|value| value.split_once('=').is_some_and(|(name, _)| name == key))
|
||||
.collect();
|
||||
anyhow::ensure!(
|
||||
in_manifest.len() == 1
|
||||
&& in_manifest[0] == entry
|
||||
&& in_unit.len() == 1
|
||||
&& in_unit[0] == entry,
|
||||
"Reviewed registration identity does not match the existing installer pin"
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok(plan.prepared.clone())
|
||||
}
|
||||
async fn target_hooks(
|
||||
|
||||
@@ -532,8 +532,8 @@ fn installed_path(data: &Path, name: &str) -> Result<PathBuf> {
|
||||
}
|
||||
fn publish_installed(guard: &Guard, record: &Journal) -> Result<()> {
|
||||
anyhow::ensure!(
|
||||
record.phase == Phase::Committed,
|
||||
"Only committed units may be published"
|
||||
matches!(record.phase, Phase::Committed | Phase::Restored),
|
||||
"Only verified terminal units may be published"
|
||||
);
|
||||
let dir = guard.directory().join("installed-units");
|
||||
match std::fs::DirBuilder::new().mode(0o700).create(&dir) {
|
||||
@@ -550,7 +550,11 @@ fn publish_installed(guard: &Guard, record: &Journal) -> Result<()> {
|
||||
schema: 1,
|
||||
operation: record.id.clone(),
|
||||
name: member.original.name.clone(),
|
||||
body: member.target_body.clone(),
|
||||
body: if record.phase == Phase::Restored {
|
||||
member.pinned_original_body.clone()
|
||||
} else {
|
||||
member.target_body.clone()
|
||||
},
|
||||
mode: member.original.file_mode,
|
||||
};
|
||||
let temporary = dir.join(format!(".{}.tmp", uuid::Uuid::new_v4()));
|
||||
@@ -904,9 +908,13 @@ async fn restore(guard: &Guard, record: &mut Journal, supervisor: &impl Supervis
|
||||
}
|
||||
record.phase = Phase::Restored;
|
||||
save(guard, record)?;
|
||||
publish_installed(guard, record)?;
|
||||
supervisor
|
||||
.release_barrier(&record.id, Completion::Restored)
|
||||
.await?;
|
||||
for member in &record.members {
|
||||
guard.release_hold(&member.original.name, &record.id)?;
|
||||
}
|
||||
record.cleanup_done = true;
|
||||
save(guard, record)
|
||||
}
|
||||
@@ -931,9 +939,13 @@ pub(crate) async fn recover(guard: &Guard, supervisor: &impl Supervisor) -> Resu
|
||||
}
|
||||
}
|
||||
Phase::Restored => {
|
||||
publish_installed(guard, &record)?;
|
||||
supervisor
|
||||
.release_barrier(&record.id, Completion::Restored)
|
||||
.await?;
|
||||
for member in &record.members {
|
||||
guard.release_hold(&member.original.name, &record.id)?;
|
||||
}
|
||||
} // Never release a newer owner.
|
||||
_ => restore(guard, &mut record, supervisor).await?,
|
||||
}
|
||||
@@ -1300,7 +1312,11 @@ mod tests {
|
||||
assert_eq!(restored.image, "e".repeat(64));
|
||||
assert_eq!(restored.config_sha256, runtime.original.config_sha256);
|
||||
assert_ne!(restored.id, format!("{:064x}", 1));
|
||||
assert!(super::super::update_transaction::is_held(root.path(), "movie").unwrap());
|
||||
assert!(!super::super::update_transaction::is_held(root.path(), "movie").unwrap());
|
||||
assert_eq!(
|
||||
installed_unit(root.path(), "movie").unwrap().unwrap().0,
|
||||
*runtime.body.lock().unwrap()
|
||||
);
|
||||
assert_eq!(records(&guard).unwrap()[0].phase, Phase::Restored);
|
||||
}
|
||||
#[tokio::test]
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
# Managed update runtime recovery
|
||||
|
||||
Status: isolated source implementation; Rust and real Podman/systemd qualification
|
||||
pending. No live update, snapshot, stop, backup or rollback has been performed by
|
||||
this work. Active deployed source is unchanged.
|
||||
|
||||
The managed path captures original source Quadlet bytes, mode, immutable image,
|
||||
container identity, launch configuration and running intent. It requires an
|
||||
original-hash-bound reviewed forward plan and verifies every planned image against
|
||||
the already prepared catalog image. New manifest configuration/hooks are applied
|
||||
on the forward path; rollback uses the captured original recipe and a private,
|
||||
local-only writable-layer image. AutoRemove rollback recreates containers and does
|
||||
not claim to restore their original IDs. Stopped supervised stacks currently fail
|
||||
before mutation; the retained-container and separate stopped-stage paths cover
|
||||
only their respective supported cases.
|
||||
|
||||
The legacy IndeeHub controller runs under the same inherited lifecycle lock and
|
||||
operation-owned reconciliation holds. Original writable layers are captured
|
||||
before any destructive stop. The controller fences ingress, drains supported
|
||||
legacy work, takes coherent quiescent volume/database backups and retains the
|
||||
fence through cutover or recovery. Its exact source hash must match the separately
|
||||
installed script; a backend binary alone does not install the controller.
|
||||
|
||||
Completed updates and verified runtime restorations publish exact unit recipes
|
||||
before releasing holds. Routine drift reconciliation validates those recipes;
|
||||
it does not regenerate them from a newer catalog. Catalog-driven pre-start file
|
||||
and mount mutations are skipped for these pinned installations. Missing saved
|
||||
units/images are explicit recovery failures, never permission to reconstruct a
|
||||
different runtime. Explicit uninstall removes the installed recipe, and completed
|
||||
old journals cannot recreate it. A new reviewed transaction replaces the recipe.
|
||||
|
||||
Opted-in API registration environments must match an already provisioned pin
|
||||
and the existing node identity in both manifest and exact Quadlet. Administrative
|
||||
plan preparation must use the existing installer provisioning code. Execution
|
||||
never invents a node identity or accepts a browser-supplied unit or hook.
|
||||
|
||||
Runtime restoration does not establish database compatibility. The controller's
|
||||
restored-release verifier compares original table schemas and row commitments,
|
||||
permitting only the exact reviewed additive migration prefix and empty new
|
||||
application tables. Any other data/schema change keeps ingress closed. This is
|
||||
not automatic database rollback or a promise that arbitrary migrations are
|
||||
reversible.
|
||||
|
||||
Qualification required before integration/activation:
|
||||
|
||||
- Isolated backend compile and injectable lifecycle/fault tests, including lost
|
||||
replies, daemon interruption, foreign units/holds and preflight failures.
|
||||
- Disposable real Podman/systemd and PostgreSQL execution of the controller and
|
||||
adapter. Sixteen pure controller tests currently pass; SQL/runtime behavior is
|
||||
not yet qualified.
|
||||
- Final seven-member private plan with installer-resolved identity environment,
|
||||
verified local images and source-unit provenance; review required changes and
|
||||
retained operator configuration without exposing secret values.
|
||||
- Disk-capacity and recovery-image retention checks, backup integrity and a
|
||||
documented recovery path for missing runtime artifacts.
|
||||
- Actual-node controlled deployment and acceptance, preserving persistent data.
|
||||
Reference in New Issue
Block a user