diff --git a/core/archipelago/src/bootstrap.rs b/core/archipelago/src/bootstrap.rs index 0b521061..68277957 100644 --- a/core/archipelago/src/bootstrap.rs +++ b/core/archipelago/src/bootstrap.rs @@ -237,6 +237,17 @@ pub async fn ensure_runtime_assets_ready() { { warn!("File Browser credential helper installation failed: {error:#}"); } + // The supervised updater rejects any maintenance helper whose source hash + // differs from this binary. Promote its embedded copy after older OTA assets + // and before crash recovery/reconciliation can resume an existing journal. + if let Err(error) = write_root_if_needed( + "/opt/archipelago/scripts/indeehub-maintenance-controller.py", + include_str!("../../../scripts/indeehub-maintenance-controller.py"), + ) + .await + { + warn!("IndeeHub maintenance helper installation failed: {error:#}"); + } if let Err(error) = run_npm_bridge_bootstrap().await { warn!("NPM public routing bootstrap needs attention: {error:#}"); } diff --git a/core/archipelago/src/container/registration_pin.rs b/core/archipelago/src/container/registration_pin.rs index 7cc02766..bb13c2ea 100644 --- a/core/archipelago/src/container/registration_pin.rs +++ b/core/archipelago/src/container/registration_pin.rs @@ -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 = 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()); diff --git a/core/archipelago/src/container/supervised_runtime.rs b/core/archipelago/src/container/supervised_runtime.rs index ab7d5d8c..08db1aa4 100644 --- a/core/archipelago/src/container/supervised_runtime.rs +++ b/core/archipelago/src/container/supervised_runtime.rs @@ -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::>()?; + 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, diff --git a/core/archipelago/src/container/supervised_update.rs b/core/archipelago/src/container/supervised_update.rs index a3f0ee59..a5084e6b 100644 --- a/core/archipelago/src/container/supervised_update.rs +++ b/core/archipelago/src/container/supervised_update.rs @@ -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 { .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 diff --git a/core/archipelago/src/main.rs b/core/archipelago/src/main.rs index b0d9e3f0..8067eb5d 100644 --- a/core/archipelago/src/main.rs +++ b/core/archipelago/src/main.rs @@ -48,8 +48,8 @@ mod content_lightning; mod content_onchain; mod content_onchain_plan; mod content_onchain_seller; -mod content_payment_admission; mod content_owned; +mod content_payment_admission; mod content_purchase; mod content_purchase_executor; mod content_server; @@ -124,6 +124,34 @@ async fn main() -> Result<()> { return ceremony::run(); } + if std::env::args().nth(1).as_deref() == Some("prepare-indeehub-update") { + let args: Vec<_> = std::env::args_os().skip(2).collect(); + anyhow::ensure!( + args.len() == 1, + "Usage: archipelago prepare-indeehub-update DATA_DIR" + ); + container::supervised_runtime::preserve_reviewed_indeehub_originals(std::path::Path::new( + &args[0], + )) + .await?; + println!("Seven original IndeeHub recipes preserved; no services or catalog changed"); + return Ok(()); + } + + if std::env::args().nth(1).as_deref() == Some("prepare-indeehub-registration") { + let args: Vec<_> = std::env::args_os().skip(2).collect(); + anyhow::ensure!(args.len() == 3, + "Usage: archipelago prepare-indeehub-registration DATA_DIR MANIFEST_JSON PRIVATE_OUTPUT_JSON"); + container::registration_pin::prepare_indeehub_manifest( + std::path::Path::new(&args[0]), + std::path::Path::new(&args[1]), + std::path::Path::new(&args[2]), + ) + .await?; + println!("IndeeHub manifest prepared; registration and publication remain disabled"); + return Ok(()); + } + // Plain CLI flags must never boot the daemon (a stray `--version` used to // start a second instance next to the systemd one). Handled before any // tracing/state init so stdout stays clean. @@ -145,6 +173,10 @@ async fn main() -> Result<()> { println!(); println!("Commands:"); println!(" ceremony Release-root signing ceremony"); + println!(" prepare-indeehub-registration DATA_DIR MANIFEST_JSON PRIVATE_OUTPUT_JSON"); + println!( + " prepare-indeehub-update DATA_DIR Preserve reviewed original managed recipes" + ); println!(); println!("Options:"); println!(" -V, --version Print version and exit"); diff --git a/docs/managed-update-recovery-implementation.md b/docs/managed-update-recovery-implementation.md index d2bced68..e10d03d8 100644 --- a/docs/managed-update-recovery-implementation.md +++ b/docs/managed-update-recovery-implementation.md @@ -128,3 +128,41 @@ xattrs; changed restored bytes/xattrs and unreadable archives are rejected. Evid `/tmp/archy-20261007-volume-restore.log`. Repeatable fixture: `tests/regression/test_indeehub_maintenance_volumes.py`. Full seven-member application cutover and final backend build remain separate gates. + +## Explicit legacy preparation commands + +The candidate adds two offline administration commands; neither starts the daemon, +changes a catalog, enables registration/publication, or starts/stops an app: + +- `archipelago prepare-indeehub-registration DATA_DIR MANIFEST_JSON PRIVATE_OUTPUT_JSON` + validates a private opted-in API manifest with an immutable image and both feature + flags disabled, loads the existing node identity, invokes the existing installer + pin provisioner, and writes a private resolved manifest. Retry preserves the same + audience and identity; missing identity is an error, never identity generation. +- `archipelago prepare-indeehub-update DATA_DIR` validates the exact seven-member + installed reviewed plan, original unit hashes, local target images and registration + pins under the lifecycle lock. It preserves observed original unit recipes before + a newer catalog can drift-reconcile them. This records original installation + evidence, not a fabricated completed update. Explicit uninstall removes those + recipes through the existing path. Prepare the complete reviewed plan first and + retain the current catalog until this command succeeds for all seven members. + +Binary startup now promotes its exact embedded maintenance controller before +recovery/reconciliation, including when an older dashboard payload is installed. +The updater still checks the on-disk helper hash against the binary. These source +changes are undergoing full isolated backend qualification; they have not been +applied to Yaya. The full adapter fixture is prepared in a separate outbound-isolated +QEMU copy-on-write VM, using public base images, the verified tracked baseline +catalog and newly generated fixture credentials; no live app metadata/data is copied. + +Preparation qualification: the final combined isolated backend suite passes +**2,003 tests, zero failures, five ignored**. Installer preparation preserves the +existing identity/audience on retry and refuses absent identity or enabled feature +flags. Original-recipe tests cover all-member preflight, retry, foreign edit +preservation and explicit uninstall. An initial run had2,002 passes and one failure +in the new fixture's assertion that the journal directory was absent; the shared +readiness check creates an empty directory. The corrected test requires no journal +files, which verifies the intended absence of a fabricated completed transaction. +Evidence: `/tmp/archy-20261007-indeehub-final-backend-recheck.log`; initial failed +fixture evidence remains in `/tmp/archy-20261007-indeehub-final-backend.log`. +Optimized artifact build and full real VM adapter acceptance remain pending.