Prepare legacy IndeeHub identity and original recipes before catalog selection

This commit is contained in:
archipelago
2026-10-07 16:02:28 -04:00
parent f81cc4ecdb
commit b9c75b2141
6 changed files with 409 additions and 3 deletions
@@ -327,6 +327,99 @@ pub fn apply_environment(manifest: &mut AppManifest, pin: &RegistrationPin) -> R
Ok(())
}
/// Offline preparation for the first supervised legacy IndeeHub migration.
/// This invokes the same installer pin implementation, never starts the daemon,
/// creates a node identity, activates a catalog, or enables publication.
pub(crate) async fn prepare_indeehub_manifest(
data_dir: &Path,
manifest_path: &Path,
output_path: &Path,
) -> Result<()> {
let input = OpenOptions::new()
.read(true)
.custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK | libc::O_CLOEXEC)
.open(manifest_path)?;
let metadata = input.metadata()?;
anyhow::ensure!(
metadata.is_file()
&& metadata.len() <= 128 * 1024
&& metadata.uid() == unsafe { libc::geteuid() }
&& metadata.mode() & 0o077 == 0,
"Preparation requires a private node-owned manifest"
);
let mut bytes = Vec::new();
input.take(128 * 1024 + 1).read_to_end(&mut bytes)?;
anyhow::ensure!(
bytes.len() <= 128 * 1024,
"Preparation manifest is too large"
);
let mut manifest: AppManifest = serde_json::from_slice(&bytes)?;
manifest.validate()?;
anyhow::ensure!(
manifest.app.id == "indeedhub-api" && manifest.app.container.media_registration_identity,
"Preparation only supports the opted-in IndeeHub API"
);
anyhow::ensure!(
manifest
.app
.container
.image
.as_ref()
.is_some_and(|image| image
.rsplit_once("@sha256:")
.is_some_and(
|(_, hash)| hash.len() == 64 && hash.bytes().all(|b| b.is_ascii_hexdigit())
)),
"Preparation requires the reviewed immutable API image"
);
for key in [
"ARCHIPELAGO_REGISTRATION_ENABLED",
"ARCHIPELAGO_PUBLICATION_ENABLED",
] {
let entries: Vec<_> = manifest
.app
.environment
.iter()
.filter(|entry| entry.split_once('=').is_some_and(|(name, _)| name == key))
.collect();
anyhow::ensure!(
entries.len() == 1 && entries[0] == &format!("{key}=false"),
"Preparation must retain disabled registration and publication"
);
}
let parent = output_path
.parent()
.context("Preparation output directory missing")?;
let output_guard = private_root(parent)?;
lock(&output_guard)?;
let identity = crate::identity::NodeIdentity::load_existing(&data_dir.join("identity")).await?;
// Validate all manifest environment restrictions before creating installer state.
let mut validated = manifest.clone();
apply_environment(
&mut validated,
&RegistrationPin {
version: 1,
app_id: manifest.app.id.clone(),
node_public_key: identity.pubkey_hex(),
node_did: identity.did_key()?,
app_audience: uuid::Uuid::nil().to_string(),
},
)?;
let existing_output: Option<serde_json::Value> = read(output_path)?;
let pin = ensure_for_installation(data_dir, &manifest.app.id, &identity)?;
apply_environment(&mut manifest, &pin)?;
let resolved = serde_json::to_value(&manifest)?;
if let Some(existing) = existing_output {
anyhow::ensure!(
existing == resolved,
"Existing prepared manifest differs; preserve it for review"
);
} else {
persist(parent, output_path, &resolved)?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
@@ -340,6 +433,61 @@ mod tests {
(root, identity)
}
#[tokio::test]
async fn offline_preparation_preserves_identity_and_disabled_flags_on_retry() {
let (root, identity) = fixture().await;
let staging = root.path().join("staging");
std::fs::create_dir(&staging).unwrap();
std::fs::set_permissions(&staging, std::fs::Permissions::from_mode(0o700)).unwrap();
let input = staging.join("manifest.json");
let output = staging.join("resolved.json");
let manifest=AppManifest::parse(&format!("app:\n id: indeedhub-api\n name: Fixture\n version: '1'\n container:\n image: localhost/fixture@sha256:{}\n media_registration_identity: true\n environment:\n - ARCHIPELAGO_REGISTRATION_ENABLED=false\n - ARCHIPELAGO_PUBLICATION_ENABLED=false\n", "a".repeat(64))).unwrap();
std::fs::write(&input, serde_json::to_vec(&manifest).unwrap()).unwrap();
std::fs::set_permissions(&input, std::fs::Permissions::from_mode(0o600)).unwrap();
let key = std::fs::read(root.path().join("identity/node_key")).unwrap();
prepare_indeehub_manifest(root.path(), &input, &output)
.await
.unwrap();
let first = std::fs::read(&output).unwrap();
prepare_indeehub_manifest(root.path(), &input, &output)
.await
.unwrap();
assert_eq!(std::fs::read(&output).unwrap(), first);
assert_eq!(
std::fs::read(root.path().join("identity/node_key")).unwrap(),
key
);
assert_eq!(output.metadata().unwrap().mode() & 0o777, 0o600);
let resolved: AppManifest = serde_json::from_slice(&first).unwrap();
let pin = load_existing(root.path(), "indeedhub-api", &identity).unwrap();
assert!(resolved.app.environment.contains(&format!(
"ARCHIPELAGO_REGISTRATION_AUDIENCE={}",
pin.app_audience
)));
assert!(resolved
.app
.environment
.contains(&"ARCHIPELAGO_PUBLICATION_ENABLED=false".to_string()));
let absent = tempfile::tempdir().unwrap();
assert!(
prepare_indeehub_manifest(absent.path(), &input, &staging.join("missing.json"))
.await
.is_err()
);
assert!(!absent.path().join("identity").exists());
let mut enabled = manifest.clone();
enabled
.app
.environment
.push("ARCHIPELAGO_PUBLICATION_ENABLED=true".into());
std::fs::write(&input, serde_json::to_vec(&enabled).unwrap()).unwrap();
assert!(
prepare_indeehub_manifest(root.path(), &input, &staging.join("enabled.json"))
.await
.is_err()
);
assert!(!staging.join("enabled.json").exists());
}
#[tokio::test]
async fn audience_is_stable_across_retries_reconstruction_and_other_apps_are_distinct() {
let (root, identity) = fixture().await;
assert!(load_existing(root.path(), "indeedhub-api", &identity).is_err());
@@ -263,6 +263,63 @@ pub(crate) fn load_reviewed_plans(
Ok(record.plans)
}
/// Called by the explicit offline administration command before catalog
/// selection. It validates the complete reviewed plan against current originals
/// and installer pins, then preserves their exact recipes under the lifecycle lock.
pub(crate) async fn preserve_reviewed_indeehub_originals(data: &Path) -> Result<()> {
let guard = super::update_transaction::Guard::acquire(data)?;
guard.require_clear()?;
anyhow::ensure!(
guard.held_names()?.is_empty(),
"Existing lifecycle holds require recovery"
);
let plans = load_reviewed_plans(data, "indeedhub")?;
let names = [
"indeedhub-postgres",
"indeedhub-redis",
"indeedhub-minio",
"indeedhub-relay",
"indeedhub-api",
"indeedhub-ffmpeg",
"indeedhub",
];
anyhow::ensure!(
plans.len() == names.len() && names.iter().all(|name| plans.contains_key(*name)),
"Preparation requires the exact seven-member IndeeHub plan"
);
let refs: Vec<_> = names
.iter()
.map(|name| {
Ok((
name.to_string(),
plans[*name]
.prepared
.manifest
.app
.container
.image
.clone()
.context("Reviewed target image is missing")?,
))
})
.collect::<Result<_>>()?;
let targets = Podman::targets(&refs, false).await?;
let adapter = SystemdSupervisor::new(
data.to_path_buf(),
plans,
LegacyIndeeMaintenance::new(&guard)?,
)
.await?;
let targets = adapter.reviewed_targets(&targets).await?;
let mut originals = Vec::new();
for target in targets {
let original = adapter.capture(&target.name).await?;
adapter.prepare_target(&target, &original).await?;
originals.push(original);
}
supervised_update::preserve_observed_originals(&guard, &originals)
}
pub(crate) async fn recover_before_reconcile(
data_dir: &Path,
guard: &super::update_transaction::Guard,
@@ -6,7 +6,7 @@ use serde::{Deserialize, Serialize};
use std::{
future::Future,
io::Write,
os::unix::fs::{DirBuilderExt, OpenOptionsExt},
os::unix::fs::{DirBuilderExt, MetadataExt, OpenOptionsExt},
path::{Path, PathBuf},
};
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
@@ -513,7 +513,7 @@ fn save(guard: &Guard, record: &Journal) -> Result<()> {
}
result
}
// The committed unit is installation evidence, not a cache of today's catalog.
// A captured original or terminal 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)]
@@ -530,6 +530,81 @@ fn installed_path(data: &Path, name: &str) -> Result<PathBuf> {
.join("update-transactions/installed-units")
.join(format!("{name}.json")))
}
/// Administrative first-upgrade preparation. Preserve freshly observed exact
/// recipes before selecting a new catalog, so drift reconciliation cannot edit
/// the legacy source beneath its reviewed original-hash-bound migration plan.
/// No service is stopped/started and no completed update journal is invented.
pub(crate) fn preserve_observed_originals(guard: &Guard, originals: &[Unit]) -> Result<()> {
guard.require_clear()?;
let data = guard
.directory()
.parent()
.context("Missing node data directory")?;
let mut names = std::collections::HashSet::new();
for original in originals {
anyhow::ensure!(
simple(&original.name)
&& names.insert(&original.name)
&& original.running
&& digest(&original.image)
&& digest(&original.container_id)
&& original.file_mode & !0o777 == 0
&& original.file_mode & 0o022 == 0,
"Invalid observed managed original"
);
// Validate recipe shape without changing its image spelling or bytes.
pin_body(
&original.body,
&original.name,
&format!("sha256:{}", original.image),
)?;
if let Some((body, mode)) = installed_unit(data, &original.name)? {
anyhow::ensure!(
body == original.body && mode == original.file_mode,
"Existing installed recipe differs; retain it for explicit recovery"
);
}
}
anyhow::ensure!(!originals.is_empty(), "No observed originals supplied");
let dir = guard.directory().join("installed-units");
match std::fs::DirBuilder::new().mode(0o700).create(&dir) {
Ok(()) => {}
Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {}
Err(error) => return Err(error.into()),
}
let metadata = std::fs::symlink_metadata(&dir)?;
anyhow::ensure!(
metadata.is_dir()
&& !metadata.file_type().is_symlink()
&& metadata.uid() == unsafe { libc::geteuid() }
&& metadata.mode() & 0o077 == 0,
"Original recipe directory is not private and node-owned"
);
let preparation = uuid::Uuid::new_v4().to_string();
for original in originals {
let path = dir.join(format!("{}.json", original.name));
if path.exists() {
continue;
}
let saved = InstalledUnit {
schema: 1,
operation: preparation.clone(),
name: original.name.clone(),
body: original.body.clone(),
mode: original.file_mode,
};
let mut temporary = tempfile::NamedTempFile::new_in(&dir)?;
temporary.write_all(&serde_json::to_vec(&saved)?)?;
temporary.as_file().sync_all()?;
temporary
.persist_noclobber(path)
.map_err(|error| anyhow::anyhow!("Cannot preserve original recipe: {}", error.error))?;
}
std::fs::File::open(&dir)?.sync_all()?;
std::fs::File::open(guard.directory())?.sync_all()?;
Ok(())
}
fn publish_installed(guard: &Guard, record: &Journal) -> Result<()> {
anyhow::ensure!(
matches!(record.phase, Phase::Committed | Phase::Restored),
@@ -1144,6 +1219,51 @@ mod tests {
}
}
#[test]
fn administrative_original_capture_is_idempotent_and_uninstall_forgets_it() {
let root = tempfile::tempdir().unwrap();
let guard = Guard::acquire(root.path()).unwrap();
let original = Mock::new().original;
preserve_observed_originals(&guard, std::slice::from_ref(&original)).unwrap();
let first = std::fs::read(installed_path(root.path(), "movie").unwrap()).unwrap();
preserve_observed_originals(&guard, std::slice::from_ref(&original)).unwrap();
assert_eq!(
std::fs::read(installed_path(root.path(), "movie").unwrap()).unwrap(),
first
);
assert_eq!(
installed_unit(root.path(), "movie").unwrap(),
Some((original.body, 0o600))
);
assert!(guard.held_names().unwrap().is_empty());
assert_eq!(
std::fs::read_dir(guard.directory().join("supervised"))
.unwrap()
.count(),
0
);
forget_installed(root.path(), "movie").unwrap();
assert!(installed_unit(root.path(), "movie").unwrap().is_none());
}
#[test]
fn original_capture_checks_all_members_before_writing_and_preserves_foreign_recipe() {
let root = tempfile::tempdir().unwrap();
let guard = Guard::acquire(root.path()).unwrap();
let original = Mock::new().original;
let mut stopped = original.clone();
stopped.name = "other".into();
stopped.running = false;
assert!(preserve_observed_originals(&guard, &[original.clone(), stopped]).is_err());
assert!(!guard.directory().join("installed-units").exists());
preserve_observed_originals(&guard, std::slice::from_ref(&original)).unwrap();
let mut changed = original.clone();
changed.body.push_str("# foreign operator edit\n");
assert!(preserve_observed_originals(&guard, &[changed]).is_err());
assert_eq!(
installed_unit(root.path(), "movie").unwrap(),
Some((original.body, 0o600))
);
}
#[test]
fn reviewed_migration_keeps_unrelated_operator_values_and_applies_required_new_fields() {
let old = "[Container]\nImage=old\nEnvironment=FEATURE=old\nEnvironment=PORT=1\n[Service]\nRestart=always\n";
let actual = old