integration: preserve deployed 1.8.0 OTA work

This commit is contained in:
archipelago
2026-06-30 05:08:17 -04:00
parent f4f45c1a09
commit df9d3a55be
39 changed files with 1066 additions and 203 deletions
@@ -220,7 +220,9 @@ pub fn catalog_manifest_values() -> Vec<(String, serde_json::Value)> {
/// `version` field), if covered. Used to decide whether an install-time
/// selection should pin (older) or track-latest (default).
pub fn catalog_default_version(app_id: &str) -> Option<String> {
entry_for(app_id).map(|e| e.version).filter(|v| !v.is_empty())
entry_for(app_id)
.map(|e| e.version)
.filter(|v| !v.is_empty())
}
/// Curated, selectable versions for an app per the remote catalog. Empty when
@@ -261,8 +263,8 @@ pub fn catalog_image_for_version(
format!("{repo}:{version}")
}
};
let same_repo =
crate::container::image_versions::image_without_registry_or_tag(&candidate) == manifest_repo;
let same_repo = crate::container::image_versions::image_without_registry_or_tag(&candidate)
== manifest_repo;
if same_repo {
Some(candidate)
} else {
@@ -294,7 +294,7 @@ mod tests {
}
async fn wait_for_status_calls(rt: &CountingRuntime, expected: u32) -> u32 {
for _ in 0..100 {
for _ in 0..1000 {
let count = rt.status_call_count();
if count >= expected {
return count;
@@ -341,11 +341,10 @@ mod tests {
assert_eq!(wait_for_status_calls(&rt, 1).await, 1);
tokio::time::sleep(Duration::from_millis(20)).await;
wait_for_status_calls(&rt, 2).await;
let count = wait_for_status_calls(&rt, 2).await;
assert_eq!(
rt.status_call_count(),
2,
assert!(
count >= 2,
"a second reconcile pass should fire after one interval"
);
@@ -403,9 +402,7 @@ mod tests {
assert!(first >= 1, "initial pass should have touched the runtime");
tokio::time::sleep(Duration::from_millis(20)).await;
tokio::task::yield_now().await;
tokio::task::yield_now().await;
let second = rt.status_call_count();
let second = wait_for_status_calls(&rt, first + 1).await;
assert!(
second > first,
"loop should have fired a second pass after the interval"
+4 -1
View File
@@ -336,7 +336,10 @@ async fn image_created_unix(image: &str) -> Option<i64> {
if !out.status.success() {
return None;
}
String::from_utf8_lossy(&out.stdout).trim().parse::<i64>().ok()
String::from_utf8_lossy(&out.stdout)
.trim()
.parse::<i64>()
.ok()
}
/// Newest modification time (Unix seconds) across all files under `dir`,
+1 -6
View File
@@ -85,12 +85,7 @@ pub async fn run_post_install(manifest: &AppManifest, container_name: &str, data
}
}
async fn run_step(
step: &HookStep,
container: &str,
app_id: &str,
data_dir: &Path,
) -> Result<()> {
async fn run_step(step: &HookStep, container: &str, app_id: &str, data_dir: &Path) -> Result<()> {
match step {
HookStep::Exec { exec } => {
let mut args: Vec<&str> = Vec::with_capacity(exec.len() + 2);
@@ -302,9 +302,8 @@ async fn chown_for_rootless_container(uid_gid: &str, path: &str) -> Result<()> {
/// journal on every pass. Keyed by Id so a recreated container retries afresh.
fn unrepairable_ownership() -> &'static std::sync::Mutex<std::collections::HashSet<(String, String)>>
{
static SET: std::sync::OnceLock<
std::sync::Mutex<std::collections::HashSet<(String, String)>>,
> = std::sync::OnceLock::new();
static SET: std::sync::OnceLock<std::sync::Mutex<std::collections::HashSet<(String, String)>>> =
std::sync::OnceLock::new();
SET.get_or_init(|| std::sync::Mutex::new(std::collections::HashSet::new()))
}
@@ -1266,9 +1265,13 @@ impl ProdContainerOrchestrator {
.get(&app_id)
.map(|lm| lm.manifest_dir.clone())
.unwrap_or_else(|| root.join(&app_id));
state
.manifests
.insert(app_id.clone(), LoadedManifest { manifest: m, manifest_dir });
state.manifests.insert(
app_id.clone(),
LoadedManifest {
manifest: m,
manifest_dir,
},
);
overlaid += 1;
}
}
@@ -3409,7 +3412,11 @@ impl ContainerOrchestrator for ProdContainerOrchestrator {
{
tracing::debug!(container = %name, error = %err, "quadlet stop skipped/failed");
}
match self.runtime.stop_container_with_grace(&name, grace_secs).await {
match self
.runtime
.stop_container_with_grace(&name, grace_secs)
.await
{
Ok(()) => Ok(()),
Err(err) => {
let stuck_stopping = self
@@ -3919,15 +3926,17 @@ app:
// ("release", no digit) ship — the app then vanished from the
// orchestrator and a stack install half-fell-back to the legacy path.
// Fail loudly here instead.
let m = AppManifest::from_file(&mf).unwrap_or_else(|e| {
panic!("shipped manifest {} must be valid: {e}", mf.display())
});
let m = AppManifest::from_file(&mf)
.unwrap_or_else(|e| panic!("shipped manifest {} must be valid: {e}", mf.display()));
let id = m.app.id.clone();
let is_build = m.app.container.build.is_some();
let value = serde_json::to_value(&m).expect("manifest serializes to JSON");
let overlay = catalog_manifest_to_overlay(&id, value);
if is_build {
assert!(overlay.is_none(), "{id}: build-source app must defer to disk");
assert!(
overlay.is_none(),
"{id}: build-source app must defer to disk"
);
} else {
assert!(
overlay.is_some(),
@@ -4010,7 +4019,8 @@ app:
runtime,
PathBuf::from("/nonexistent-for-tests"),
);
orch.set_data_dir(PathBuf::from("/nonexistent-for-tests"));
let data_dir = tempfile::tempdir().unwrap().keep();
orch.set_data_dir(data_dir);
// Redirect the bitcoin-ui pre-start hook to a test-scoped
// tmpdir, seeded with a fake password file. Shared across
// every test in this module (OnceLock), so the hook can run
@@ -4294,22 +4304,25 @@ app:
let rt = Arc::new(MockRuntime::default());
rt.mark_image_present("archy-bitcoin-ui:local");
let orch = orch_with(rt.clone()).await;
orch.insert_manifest_for_test(
build_manifest(
"bitcoin-ui",
"/opt/archy/docker/bitcoin-ui",
"archy-bitcoin-ui:local",
),
PathBuf::from("/opt/archy/apps/bitcoin-ui"),
)
.await;
let build_context = tempfile::tempdir().unwrap();
std::fs::write(build_context.path().join("Dockerfile"), "FROM scratch\n").unwrap();
let build_context = build_context.path().to_string_lossy().into_owned();
let manifest = build_manifest("bitcoin-ui", &build_context, "archy-bitcoin-ui:local");
let fingerprint = fingerprint_build_context(Path::new(&build_context))
.expect("test build context must be fingerprintable");
let stamp_path = build_fingerprint_stamp_path(&orch.data_dir, "archy-bitcoin-ui:local");
std::fs::create_dir_all(stamp_path.parent().unwrap()).unwrap();
std::fs::write(&stamp_path, fingerprint).unwrap();
orch.insert_manifest_for_test(manifest, PathBuf::from("/opt/archy/apps/bitcoin-ui"))
.await;
orch.install("bitcoin-ui").await.unwrap();
let calls = rt.calls();
assert!(calls
.iter()
.any(|c| c == "image_exists:archy-bitcoin-ui:local"));
// Build must NOT be invoked because the image is already there.
// Build must NOT be invoked because the image is already there and its
// recorded build-context fingerprint still matches.
assert!(!calls.iter().any(|c| c.starts_with("build_image:")));
}
@@ -4370,7 +4383,7 @@ app:
let rt = Arc::new(MockRuntime::default());
let orch = orch_with(rt.clone()).await;
let data_dir = tempfile::tempdir().unwrap();
let data_dir = tempfile::tempdir_in("/var/lib/archipelago").unwrap();
let id_u = std::process::Command::new("id").arg("-u").output().unwrap();
let id_g = std::process::Command::new("id").arg("-g").output().unwrap();
let uid = String::from_utf8_lossy(&id_u.stdout).trim().to_string();
@@ -4400,7 +4413,7 @@ app:
let rt = Arc::new(MockRuntime::default());
let orch = orch_with(rt.clone()).await;
let data_dir = tempfile::tempdir().unwrap();
let data_dir = tempfile::tempdir_in("/var/lib/archipelago").unwrap();
orch.insert_manifest_for_test(
pull_manifest_with_generated_file(
"exampleapp",
@@ -4427,7 +4440,7 @@ app:
let rt = Arc::new(MockRuntime::default());
let orch = orch_with(rt.clone()).await;
let data_dir = tempfile::tempdir().unwrap();
let data_dir = tempfile::tempdir_in("/var/lib/archipelago").unwrap();
let config_path = data_dir.path().join("config.yaml");
std::fs::write(&config_path, "key: operator\n").unwrap();
@@ -4452,7 +4465,7 @@ app:
let rt = Arc::new(MockRuntime::default());
let orch = orch_with(rt.clone()).await;
let data_dir = tempfile::tempdir().unwrap();
let data_dir = tempfile::tempdir_in("/var/lib/archipelago").unwrap();
let config_path = data_dir.path().join("config.yaml");
std::fs::write(&config_path, "key: old\n").unwrap();
+4 -1
View File
@@ -1300,7 +1300,10 @@ app:
let u = QuadletUnit::from_manifest(&m, "x");
// tmpfs entry is dropped from bind_mounts; bind entry survives.
assert_eq!(u.bind_mounts.len(), 1);
assert_eq!(u.bind_mounts[0].host, PathBuf::from("/var/lib/archipelago/x"));
assert_eq!(
u.bind_mounts[0].host,
PathBuf::from("/var/lib/archipelago/x")
);
}
#[test]
+8 -2
View File
@@ -169,7 +169,10 @@ mod tests {
let hash = std::fs::read_to_string(dir.path().join("admin")).unwrap();
let pw = std::fs::read_to_string(dir.path().join("admin.pw")).unwrap();
assert!(hash.starts_with("$2"), "bcrypt hash shape");
assert!(bcrypt::verify(pw.trim(), hash.trim()).unwrap(), "pw matches hash");
assert!(
bcrypt::verify(pw.trim(), hash.trim()).unwrap(),
"pw matches hash"
);
for f in ["tok", "admin", "admin.pw"] {
let mode = std::fs::metadata(dir.path().join(f))
@@ -189,7 +192,10 @@ mod tests {
let first = std::fs::read_to_string(dir.path().join("tok")).unwrap();
ensure_generated_secrets(dir.path(), &m).unwrap();
let second = std::fs::read_to_string(dir.path().join("tok")).unwrap();
assert_eq!(first, second, "a present readable secret is never rewritten");
assert_eq!(
first, second,
"a present readable secret is never rewritten"
);
}
#[test]
@@ -172,11 +172,8 @@ mod tests {
fn with_tmp_data_dir<F: FnOnce()>(f: F) {
let mut counter = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
*counter += 1;
let dir = std::env::temp_dir().join(format!(
"archy-vc-test-{}-{}",
std::process::id(),
*counter
));
let dir =
std::env::temp_dir().join(format!("archy-vc-test-{}-{}", std::process::id(), *counter));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
std::env::set_var("ARCHIPELAGO_DATA_DIR", &dir);
@@ -244,10 +241,7 @@ mod tests {
assert!(!is_downgrade("28.4", "28.4"));
// Knots date-suffixed strings compare on major.minor only.
assert!(is_downgrade("29.3.knots20260508", "28.1.knots20251010"));
assert!(!is_downgrade(
"29.3.knots20260101",
"29.3.knots20260508"
));
assert!(!is_downgrade("29.3.knots20260101", "29.3.knots20260508"));
}
#[test]