From 7d767c8cb0a4d2559566888385303cb19fe71457 Mon Sep 17 00:00:00 2001 From: archipelago Date: Wed, 30 Sep 2026 10:08:56 -0400 Subject: [PATCH] fix(catalog): gate network migration manifests on backup support --- core/archipelago/src/container/app_catalog.rs | 45 ++++++++++++++++++- .../src/container/migration_backup.rs | 3 ++ docs/app-manifest-spec.md | 7 +++ docs/gitea-portainer-repair-20260930.md | 18 +++++--- scripts/check-app-catalog-drift.py | 5 +++ scripts/generate-app-catalog.sh | 16 ++++++- 6 files changed, 87 insertions(+), 7 deletions(-) diff --git a/core/archipelago/src/container/app_catalog.rs b/core/archipelago/src/container/app_catalog.rs index 557f5365..702dae60 100644 --- a/core/archipelago/src/container/app_catalog.rs +++ b/core/archipelago/src/container/app_catalog.rs @@ -102,6 +102,28 @@ pub struct AppCatalogEntry { /// `docs/registry-manifest-design.md`. #[serde(default, skip_serializing_if = "Option::is_none")] pub manifest: Option, + /// Backward-compatible catalog rollout: old daemons ignore these and keep + /// the base manifest. New daemons choose only variants they can safely apply. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub manifest_variants: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CatalogManifestVariant { + pub requires: Vec, + pub manifest: serde_json::Value, +} + +fn selected_manifest(entry: AppCatalogEntry) -> Option { + // Never let an unknown future requirement become an unsafe partial match. + for variant in entry.manifest_variants.into_iter().rev() { + if !variant.requires.is_empty() && variant.requires.iter().all(|capability| { + capability == "network-migration-backup-v1" + }) { + return Some(variant.manifest); + } + } + entry.manifest } /// One selectable version in an app's `versions[]` list. The catalog carries a @@ -234,7 +256,7 @@ pub fn catalog_manifest_values() -> Vec<(String, serde_json::Value)> { load_catalog() .apps .into_iter() - .filter_map(|(id, e)| e.manifest.map(|m| (id, m))) + .filter_map(|(id, e)| selected_manifest(e).map(|m| (id, m))) .collect() } @@ -557,6 +579,27 @@ fn write_cache(data_dir: &Path, body: &str) -> anyhow::Result { mod tests { use super::*; + #[test] + fn catalog_migration_variant_is_compatible_with_old_and_future_daemons() { + let raw = serde_json::json!({ + "version": "2.45.0", "manifest": {"app": {"id": "portainer", "container": {}}}, + "manifest_variants": [{"requires": ["network-migration-backup-v1"], + "manifest": {"app": {"id": "portainer", "container": {"network": "slirp4netns"}, "backup_on_network_change": true}}}] + }); + #[derive(Deserialize)] + struct OldEntry { manifest: serde_json::Value } + let old: OldEntry = serde_json::from_value(raw.clone()).unwrap(); + assert!(old.manifest["app"]["container"].get("network").is_none()); + let current: AppCatalogEntry = serde_json::from_value(raw.clone()).unwrap(); + let chosen = selected_manifest(current).unwrap(); + assert_eq!(chosen["app"]["container"]["network"], "slirp4netns"); + assert_eq!(chosen["app"]["backup_on_network_change"], true); + let mut future = raw; + future["manifest_variants"][0]["requires"].as_array_mut().unwrap().push(serde_json::json!("unknown-next-capability")); + let chosen = selected_manifest(serde_json::from_value(future).unwrap()).unwrap(); + assert!(chosen["app"]["container"].get("network").is_none()); + } + #[test] fn parses_and_ignores_unknown_fields() { let json = r#"{ diff --git a/core/archipelago/src/container/migration_backup.rs b/core/archipelago/src/container/migration_backup.rs index b27b467a..4dbfea81 100644 --- a/core/archipelago/src/container/migration_backup.rs +++ b/core/archipelago/src/container/migration_backup.rs @@ -30,6 +30,9 @@ fn relative_sources(manifest: &AppManifest, data_dir: &Path) -> Result dict[str, dict[str, Any]]: if not isinstance(entry, dict): continue manifest = entry.get("manifest") + for variant in reversed(entry.get("manifest_variants", [])): + requires = variant.get("requires", []) + if requires and all(cap == "network-migration-backup-v1" for cap in requires): + manifest = variant.get("manifest") + break if isinstance(manifest, dict) and isinstance(manifest.get("app"), dict): # Embedded manifest: compare against the same fields the disk # manifests expose, plus the entry's own version. diff --git a/scripts/generate-app-catalog.sh b/scripts/generate-app-catalog.sh index cfb976cc..cccc077d 100755 --- a/scripts/generate-app-catalog.sh +++ b/scripts/generate-app-catalog.sh @@ -36,11 +36,15 @@ source "$ROOT/scripts/image-versions.sh" set +a UPDATED="$(date -u +%Y-%m-%d)" OUT="$OUT" APPS_DIR="$ROOT/apps" \ +BASE_CATALOG="${BASE_CATALOG:-$ROOT/releases/app-catalog.json}" \ PUBLIC_CATALOG="$ROOT/app-catalog/catalog.json" \ EMBED_MANIFESTS="${EMBED_MANIFESTS:-1}" python3 - <<'PY' import glob import json, os +with open(os.environ["BASE_CATALOG"], encoding="utf-8") as baseline_file: + baseline_entries = json.load(baseline_file).get("apps", {}) + try: import yaml except ImportError: @@ -182,7 +186,17 @@ if os.environ.get("EMBED_MANIFESTS") and apps_dir: continue entry = apps.setdefault(str(app_id), {}) entry.setdefault("version", str(app.get("version", "")) or "0") - entry["manifest"] = _retarget_registry(data) + rendered = _retarget_registry(data) + if data["app"].get("backup_on_network_change"): + baseline = baseline_entries.get(app_id, {}).get("manifest") + if not baseline or baseline.get("app", {}).get("backup_on_network_change"): + raise SystemExit(f"{app_id}: a pre-migration BASE_CATALOG manifest is required for old-node compatibility") + entry["manifest"] = baseline + entry["manifest_variants"] = [{ + "requires": ["network-migration-backup-v1"], "manifest": rendered, + }] + else: + entry["manifest"] = rendered embedded += 1 # Multi-version support (docs/bitcoin-multi-version-design.md ยง3 Phase 1):