Prepare legacy IndeeHub identity and original recipes before catalog selection
This commit is contained in:
@@ -237,6 +237,17 @@ pub async fn ensure_runtime_assets_ready() {
|
|||||||
{
|
{
|
||||||
warn!("File Browser credential helper installation failed: {error:#}");
|
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 {
|
if let Err(error) = run_npm_bridge_bootstrap().await {
|
||||||
warn!("NPM public routing bootstrap needs attention: {error:#}");
|
warn!("NPM public routing bootstrap needs attention: {error:#}");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -327,6 +327,99 @@ pub fn apply_environment(manifest: &mut AppManifest, pin: &RegistrationPin) -> R
|
|||||||
Ok(())
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -340,6 +433,61 @@ mod tests {
|
|||||||
(root, identity)
|
(root, identity)
|
||||||
}
|
}
|
||||||
#[tokio::test]
|
#[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() {
|
async fn audience_is_stable_across_retries_reconstruction_and_other_apps_are_distinct() {
|
||||||
let (root, identity) = fixture().await;
|
let (root, identity) = fixture().await;
|
||||||
assert!(load_existing(root.path(), "indeedhub-api", &identity).is_err());
|
assert!(load_existing(root.path(), "indeedhub-api", &identity).is_err());
|
||||||
|
|||||||
@@ -263,6 +263,63 @@ pub(crate) fn load_reviewed_plans(
|
|||||||
Ok(record.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(
|
pub(crate) async fn recover_before_reconcile(
|
||||||
data_dir: &Path,
|
data_dir: &Path,
|
||||||
guard: &super::update_transaction::Guard,
|
guard: &super::update_transaction::Guard,
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ use serde::{Deserialize, Serialize};
|
|||||||
use std::{
|
use std::{
|
||||||
future::Future,
|
future::Future,
|
||||||
io::Write,
|
io::Write,
|
||||||
os::unix::fs::{DirBuilderExt, OpenOptionsExt},
|
os::unix::fs::{DirBuilderExt, MetadataExt, OpenOptionsExt},
|
||||||
path::{Path, PathBuf},
|
path::{Path, PathBuf},
|
||||||
};
|
};
|
||||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
@@ -513,7 +513,7 @@ fn save(guard: &Guard, record: &Journal) -> Result<()> {
|
|||||||
}
|
}
|
||||||
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.
|
// Keep it until explicit uninstall or the next reviewed managed transaction.
|
||||||
#[derive(Serialize, Deserialize)]
|
#[derive(Serialize, Deserialize)]
|
||||||
#[serde(deny_unknown_fields)]
|
#[serde(deny_unknown_fields)]
|
||||||
@@ -530,6 +530,81 @@ fn installed_path(data: &Path, name: &str) -> Result<PathBuf> {
|
|||||||
.join("update-transactions/installed-units")
|
.join("update-transactions/installed-units")
|
||||||
.join(format!("{name}.json")))
|
.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<()> {
|
fn publish_installed(guard: &Guard, record: &Journal) -> Result<()> {
|
||||||
anyhow::ensure!(
|
anyhow::ensure!(
|
||||||
matches!(record.phase, Phase::Committed | Phase::Restored),
|
matches!(record.phase, Phase::Committed | Phase::Restored),
|
||||||
@@ -1144,6 +1219,51 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
#[test]
|
#[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() {
|
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 old = "[Container]\nImage=old\nEnvironment=FEATURE=old\nEnvironment=PORT=1\n[Service]\nRestart=always\n";
|
||||||
let actual = old
|
let actual = old
|
||||||
|
|||||||
@@ -48,8 +48,8 @@ mod content_lightning;
|
|||||||
mod content_onchain;
|
mod content_onchain;
|
||||||
mod content_onchain_plan;
|
mod content_onchain_plan;
|
||||||
mod content_onchain_seller;
|
mod content_onchain_seller;
|
||||||
mod content_payment_admission;
|
|
||||||
mod content_owned;
|
mod content_owned;
|
||||||
|
mod content_payment_admission;
|
||||||
mod content_purchase;
|
mod content_purchase;
|
||||||
mod content_purchase_executor;
|
mod content_purchase_executor;
|
||||||
mod content_server;
|
mod content_server;
|
||||||
@@ -124,6 +124,34 @@ async fn main() -> Result<()> {
|
|||||||
return ceremony::run();
|
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
|
// 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
|
// start a second instance next to the systemd one). Handled before any
|
||||||
// tracing/state init so stdout stays clean.
|
// tracing/state init so stdout stays clean.
|
||||||
@@ -145,6 +173,10 @@ async fn main() -> Result<()> {
|
|||||||
println!();
|
println!();
|
||||||
println!("Commands:");
|
println!("Commands:");
|
||||||
println!(" ceremony <gen|pubkey|sign|verify> Release-root signing ceremony");
|
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!();
|
||||||
println!("Options:");
|
println!("Options:");
|
||||||
println!(" -V, --version Print version and exit");
|
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:
|
`/tmp/archy-20261007-volume-restore.log`. Repeatable fixture:
|
||||||
`tests/regression/test_indeehub_maintenance_volumes.py`.
|
`tests/regression/test_indeehub_maintenance_volumes.py`.
|
||||||
Full seven-member application cutover and final backend build remain separate gates.
|
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.
|
||||||
|
|||||||
Reference in New Issue
Block a user