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:#}");
|
||||
}
|
||||
// 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
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user