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<()> {
|
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.run_pre_start_hooks(&manifest.app.id).await?;
|
||||||
self.ensure_bind_mount_sockets(manifest).await?;
|
self.ensure_bind_mount_sockets(manifest).await?;
|
||||||
self.ensure_bind_mount_dirs(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<()> {
|
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(|| {
|
let resolved = lm.manifest.app.container.resolve().ok_or_else(|| {
|
||||||
anyhow::anyhow!(
|
anyhow::anyhow!(
|
||||||
"manifest for {} has invalid container source (neither image nor build)",
|
"manifest for {} has invalid container source (neither image nor build)",
|
||||||
|
|||||||
@@ -434,6 +434,58 @@ impl<B: DrainBarrier> Supervisor for SystemdSupervisor<B> {
|
|||||||
== target.name,
|
== target.name,
|
||||||
"Original unit or reviewed immutable target changed before update"
|
"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())
|
Ok(plan.prepared.clone())
|
||||||
}
|
}
|
||||||
async fn target_hooks(
|
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<()> {
|
fn publish_installed(guard: &Guard, record: &Journal) -> Result<()> {
|
||||||
anyhow::ensure!(
|
anyhow::ensure!(
|
||||||
record.phase == Phase::Committed,
|
matches!(record.phase, Phase::Committed | Phase::Restored),
|
||||||
"Only committed units may be published"
|
"Only verified terminal units may be published"
|
||||||
);
|
);
|
||||||
let dir = guard.directory().join("installed-units");
|
let dir = guard.directory().join("installed-units");
|
||||||
match std::fs::DirBuilder::new().mode(0o700).create(&dir) {
|
match std::fs::DirBuilder::new().mode(0o700).create(&dir) {
|
||||||
@@ -550,7 +550,11 @@ fn publish_installed(guard: &Guard, record: &Journal) -> Result<()> {
|
|||||||
schema: 1,
|
schema: 1,
|
||||||
operation: record.id.clone(),
|
operation: record.id.clone(),
|
||||||
name: member.original.name.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,
|
mode: member.original.file_mode,
|
||||||
};
|
};
|
||||||
let temporary = dir.join(format!(".{}.tmp", uuid::Uuid::new_v4()));
|
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;
|
record.phase = Phase::Restored;
|
||||||
save(guard, record)?;
|
save(guard, record)?;
|
||||||
|
publish_installed(guard, record)?;
|
||||||
supervisor
|
supervisor
|
||||||
.release_barrier(&record.id, Completion::Restored)
|
.release_barrier(&record.id, Completion::Restored)
|
||||||
.await?;
|
.await?;
|
||||||
|
for member in &record.members {
|
||||||
|
guard.release_hold(&member.original.name, &record.id)?;
|
||||||
|
}
|
||||||
record.cleanup_done = true;
|
record.cleanup_done = true;
|
||||||
save(guard, record)
|
save(guard, record)
|
||||||
}
|
}
|
||||||
@@ -931,9 +939,13 @@ pub(crate) async fn recover(guard: &Guard, supervisor: &impl Supervisor) -> Resu
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
Phase::Restored => {
|
Phase::Restored => {
|
||||||
|
publish_installed(guard, &record)?;
|
||||||
supervisor
|
supervisor
|
||||||
.release_barrier(&record.id, Completion::Restored)
|
.release_barrier(&record.id, Completion::Restored)
|
||||||
.await?;
|
.await?;
|
||||||
|
for member in &record.members {
|
||||||
|
guard.release_hold(&member.original.name, &record.id)?;
|
||||||
|
}
|
||||||
} // Never release a newer owner.
|
} // Never release a newer owner.
|
||||||
_ => restore(guard, &mut record, supervisor).await?,
|
_ => restore(guard, &mut record, supervisor).await?,
|
||||||
}
|
}
|
||||||
@@ -1300,7 +1312,11 @@ mod tests {
|
|||||||
assert_eq!(restored.image, "e".repeat(64));
|
assert_eq!(restored.image, "e".repeat(64));
|
||||||
assert_eq!(restored.config_sha256, runtime.original.config_sha256);
|
assert_eq!(restored.config_sha256, runtime.original.config_sha256);
|
||||||
assert_ne!(restored.id, format!("{:064x}", 1));
|
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);
|
assert_eq!(records(&guard).unwrap()[0].phase, Phase::Restored);
|
||||||
}
|
}
|
||||||
#[tokio::test]
|
#[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