fix(catalog): gate network migration manifests on backup support
This commit is contained in:
@@ -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()
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
Reference in New Issue
Block a user