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");