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
+11
View File
@@ -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:#}");
}
@@ -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
+33 -1
View File
@@ -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 <gen|pubkey|sign|verify> 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");
@@ -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.