fix(catalog): gate network migration manifests on backup support

This commit is contained in:
archipelago
2026-09-30 10:08:56 -04:00
parent eb3ccfa00b
commit 7d767c8cb0
6 changed files with 87 additions and 7 deletions
+44 -1
View File
@@ -102,6 +102,28 @@ pub struct AppCatalogEntry {
/// `docs/registry-manifest-design.md`. /// `docs/registry-manifest-design.md`.
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub manifest: Option<serde_json::Value>, pub manifest: Option<serde_json::Value>,
/// 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<CatalogManifestVariant>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CatalogManifestVariant {
pub requires: Vec<String>,
pub manifest: serde_json::Value,
}
fn selected_manifest(entry: AppCatalogEntry) -> Option<serde_json::Value> {
// 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 /// 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() load_catalog()
.apps .apps
.into_iter() .into_iter()
.filter_map(|(id, e)| e.manifest.map(|m| (id, m))) .filter_map(|(id, e)| selected_manifest(e).map(|m| (id, m)))
.collect() .collect()
} }
@@ -557,6 +579,27 @@ fn write_cache(data_dir: &Path, body: &str) -> anyhow::Result<bool> {
mod tests { mod tests {
use super::*; 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] #[test]
fn parses_and_ignores_unknown_fields() { fn parses_and_ignores_unknown_fields() {
let json = r#"{ let json = r#"{
@@ -30,6 +30,9 @@ fn relative_sources(manifest: &AppManifest, data_dir: &Path) -> Result<Vec<PathB
let relative = path let relative = path
.strip_prefix(data_dir) .strip_prefix(data_dir)
.context("network migration state must be inside the node data directory")?; .context("network migration state must be inside the node data directory")?;
if relative.starts_with("migration-backups") {
bail!("migration backup cannot include its own archive directory");
}
if relative.as_os_str().is_empty() if relative.as_os_str().is_empty()
|| relative || relative
.components() .components()
+7
View File
@@ -302,3 +302,10 @@ symlinked mount roots fail closed rather than silently producing an incomplete
backup. A failed snapshot resumes the original service and leaves migration backup. A failed snapshot resumes the original service and leaves migration
pending. Private archives are retained under `migration-backups/`; fresh installs pending. Private archives are retained under `migration-backups/`; fresh installs
and unchanged network configurations do not create migration snapshots. and unchanged network configurations do not create migration snapshots.
Catalog generation preserves the previously published base manifest for older
daemons and puts opted-in network changes in a signed `manifest_variants` entry
requiring `network-migration-backup-v1`. New runtimes select only variants whose
complete requirement list they support. Supply `BASE_CATALOG` when generating
against a different reviewed pre-migration catalog. This keeps catalog refresh
from applying a migration before the matching OTA code is installed.
+13 -5
View File
@@ -73,7 +73,11 @@ verification stays enabled and API redirects are refused.
## Upgrade and rollback ## Upgrade and rollback
The signed catalog embeds manifests and overrides installed disk copies. A disk The signed catalog embeds manifests and overrides installed disk copies.
Capability-gated manifest variants keep the previous Portainer manifest as the
base for older daemons; only daemons supporting `network-migration-backup-v1`
select the network repair. This prevents catalog refresh from triggering an
unbacked recreation before the OTA is installed. A disk
edit alone cannot deliver this fix. Publish the matching catalog with the tested edit alone cannot deliver this fix. Publish the matching catalog with the tested
runtime, then verify the generated unit, actual network mode and Source API. runtime, then verify the generated unit, actual network mode and Source API.
Expect a Portainer interruption while the snapshot and recreation run; duration Expect a Portainer interruption while the snapshot and recreation run; duration
@@ -94,11 +98,15 @@ repositories or the production Portainer database with disposable test data.
saved account/Source survive recreation; restart succeeds. saved account/Source survive recreation; restart succeeds.
- Invalid Git credentials produce a repository-authentication error, distinct - Invalid Git credentials produce a repository-authentication error, distinct
from TCP refusal. Requested branch and Compose file read from Portainer context. from TCP refusal. Requested branch and Compose file read from Portainer context.
- Final expanded backend suite: 1,575 passed, zero failed, four existing ignored - Combined backend suite including the reviewed paid-download PRs and catalog
rollout guard: 1,602 passed, zero failed, four existing ignored
tests, including stopped-state archive round trips and failure preservation. Container runtime suite: 78 passed. tests, including stopped-state archive round trips and failure preservation. Container runtime suite: 78 passed.
Five diagnostic regression tests passed. Combined tests with the merged Five diagnostic regression tests passed; catalog regeneration is idempotent
paid-download PRs remain pending. and the generated catalog has zero manifest metadata drift.
- Fresh managed Gitea and Portainer fixtures: authenticated private Source
creation, invalid-token rejection, workstation clone/push and exact branch
lookup from Portainer namespace passed.
- Still required before release: live automatic migration with the new runtime, - Still required before release: live automatic migration with the new runtime,
snapshot/rollback verification, private-repository and install-order acceptance, snapshot/rollback verification and reversed install-order acceptance,
lifecycle/reboot convergence, and signed-catalog delivery to the existing app. lifecycle/reboot convergence, and signed-catalog delivery to the existing app.
Record LFS/registry/SSH/browser checks and actual hardware/runtime coverage. Record LFS/registry/SSH/browser checks and actual hardware/runtime coverage.
+5
View File
@@ -81,6 +81,11 @@ def load_catalog(path: Path) -> dict[str, dict[str, Any]]:
if not isinstance(entry, dict): if not isinstance(entry, dict):
continue continue
manifest = entry.get("manifest") 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): if isinstance(manifest, dict) and isinstance(manifest.get("app"), dict):
# Embedded manifest: compare against the same fields the disk # Embedded manifest: compare against the same fields the disk
# manifests expose, plus the entry's own version. # manifests expose, plus the entry's own version.
+15 -1
View File
@@ -36,11 +36,15 @@ source "$ROOT/scripts/image-versions.sh"
set +a set +a
UPDATED="$(date -u +%Y-%m-%d)" OUT="$OUT" APPS_DIR="$ROOT/apps" \ 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" \ PUBLIC_CATALOG="$ROOT/app-catalog/catalog.json" \
EMBED_MANIFESTS="${EMBED_MANIFESTS:-1}" python3 - <<'PY' EMBED_MANIFESTS="${EMBED_MANIFESTS:-1}" python3 - <<'PY'
import glob import glob
import json, os import json, os
with open(os.environ["BASE_CATALOG"], encoding="utf-8") as baseline_file:
baseline_entries = json.load(baseline_file).get("apps", {})
try: try:
import yaml import yaml
except ImportError: except ImportError:
@@ -182,7 +186,17 @@ if os.environ.get("EMBED_MANIFESTS") and apps_dir:
continue continue
entry = apps.setdefault(str(app_id), {}) entry = apps.setdefault(str(app_id), {})
entry.setdefault("version", str(app.get("version", "")) or "0") 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 embedded += 1
# Multi-version support (docs/bitcoin-multi-version-design.md §3 Phase 1): # Multi-version support (docs/bitcoin-multi-version-design.md §3 Phase 1):