//! Consistent, private snapshots for declaratively opted-in runtime migrations. use anyhow::{bail, Context, Result}; use archipelago_container::AppManifest; use std::os::unix::fs::PermissionsExt; use std::path::{Path, PathBuf}; pub fn enabled(manifest: &AppManifest) -> Result { match manifest.app.extensions.get("backup_before_runtime_change") { None => Ok(false), Some(value) => value .as_bool() .context("backup_before_runtime_change must be boolean"), } } fn relative_sources(manifest: &AppManifest, data_dir: &Path) -> Result> { let mut sources = Vec::new(); for volume in &manifest.app.volumes { if volume.options.iter().any(|v| v == "ro") || volume.volume_type == "tmpfs" { continue; } // A runtime socket is a connection, not application state. if volume.source == "/run/user/1000/podman/podman.sock" { continue; } if volume.volume_type != "bind" { bail!("runtime migration backup requires bind-mounted persistent state"); } let path = Path::new(&volume.source); let relative = path .strip_prefix(data_dir) .context("runtime 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() || relative .components() .any(|c| !matches!(c, std::path::Component::Normal(_))) { bail!("invalid runtime migration state path"); } sources.push(relative.to_path_buf()); } sources.sort(); sources.dedup(); let mut roots: Vec = Vec::new(); for source in sources { if !roots.iter().any(|root| source.starts_with(root)) { roots.push(source); } } if roots.is_empty() { bail!("runtime migration backup has no persistent state mounts"); } Ok(roots) } /// Caller must gracefully stop the app before this function, and resume the old /// service if it fails. No source files are changed or deleted by this operation. pub async fn snapshot( manifest: &AppManifest, data_dir: &Path, previous_unit: Option<&[u8]>, ) -> Result { let mut command = tokio::process::Command::new("podman"); command.args(["unshare", "tar"]); snapshot_with_command(manifest, data_dir, previous_unit, command).await } async fn snapshot_with_command( manifest: &AppManifest, data_dir: &Path, previous_unit: Option<&[u8]>, mut command: tokio::process::Command, ) -> Result { let sources = relative_sources(manifest, data_dir)?; let canonical_root = tokio::fs::canonicalize(data_dir).await?; for source in &sources { let path = data_dir.join(source); if tokio::fs::symlink_metadata(&path) .await? .file_type() .is_symlink() { bail!("runtime migration state mount is a symlink; explicit backup required"); } let canonical = tokio::fs::canonicalize(&path).await?; if !canonical.starts_with(&canonical_root) { bail!("runtime migration state path resolves outside node data directory"); } } let root = data_dir.join("migration-backups"); tokio::fs::create_dir_all(&root).await?; tokio::fs::set_permissions(&root, std::fs::Permissions::from_mode(0o700)).await?; let dir = root.join(uuid::Uuid::new_v4().to_string()); tokio::fs::create_dir(&dir).await?; tokio::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o700)).await?; if let Some(unit) = previous_unit { let path = dir.join("previous.container"); tokio::fs::write(&path, unit).await?; tokio::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).await?; tokio::fs::File::open(&path).await?.sync_all().await?; } let partial = dir.join("state.tar.partial"); let archive = dir.join("state.tar"); let output = command .args([ "--create", "--numeric-owner", "--acls", "--xattrs", "--file", ]) .arg(&partial) .arg("--directory") .arg(data_dir) .arg("--") .args(&sources) .output() .await .context("start rootless migration snapshot")?; if !output.status.success() { // No tar stderr in public logs: it can contain private filenames. let _ = tokio::fs::remove_file(&partial).await; bail!("persistent-state snapshot failed; original state was left intact"); } tokio::fs::set_permissions(&partial, std::fs::Permissions::from_mode(0o600)).await?; tokio::fs::File::open(&partial).await?.sync_all().await?; tokio::fs::rename(&partial, &archive).await?; let metadata = serde_json::json!({"app": manifest.app.id, "version": manifest.app.version, "network": manifest.app.container.network, "capabilities": manifest.app.security.capabilities, "sources": sources}); tokio::fs::write( dir.join("metadata.json"), serde_json::to_vec_pretty(&metadata)?, ) .await?; tokio::fs::File::open(&dir).await?.sync_all().await?; Ok(archive) } #[cfg(test)] mod tests { use super::*; fn portainer() -> AppManifest { AppManifest::parse(include_str!("../../../../apps/portainer/manifest.yml")).unwrap() } #[tokio::test] async fn stopped_state_archive_round_trips_database_compose_and_old_unit() { let dir = tempfile::tempdir().unwrap(); let state = dir.path().join("portainer"); tokio::fs::create_dir_all(state.join("compose")) .await .unwrap(); tokio::fs::write(state.join("portainer.db"), b"fixture database") .await .unwrap(); tokio::fs::write(state.join("compose/stack.yml"), b"services: {}\n") .await .unwrap(); let mut m = portainer(); m.app.volumes[0].source = state.display().to_string(); m.app.volumes[1].source = state.join("compose").display().to_string(); let archive = snapshot_with_command( &m, dir.path(), Some(b"old unit"), tokio::process::Command::new("tar"), ) .await .unwrap(); assert_eq!( std::fs::metadata(&archive).unwrap().permissions().mode() & 0o777, 0o600 ); assert_eq!( tokio::fs::read(archive.parent().unwrap().join("previous.container")) .await .unwrap(), b"old unit" ); let restored = tempfile::tempdir().unwrap(); assert!(tokio::process::Command::new("tar") .arg("-xf") .arg(archive) .arg("-C") .arg(restored.path()) .status() .await .unwrap() .success()); assert_eq!( tokio::fs::read(restored.path().join("portainer/portainer.db")) .await .unwrap(), b"fixture database" ); assert_eq!( tokio::fs::read(restored.path().join("portainer/compose/stack.yml")) .await .unwrap(), b"services: {}\n" ); assert_eq!( tokio::fs::read(state.join("portainer.db")).await.unwrap(), b"fixture database" ); } #[tokio::test] async fn failed_snapshot_never_publishes_archive_or_changes_original_state() { let dir = tempfile::tempdir().unwrap(); let state = dir.path().join("portainer"); tokio::fs::create_dir_all(state.join("compose")) .await .unwrap(); tokio::fs::write(state.join("portainer.db"), b"unchanged") .await .unwrap(); let mut m = portainer(); m.app.volumes[0].source = state.display().to_string(); m.app.volumes[1].source = state.join("compose").display().to_string(); assert!( snapshot_with_command(&m, dir.path(), None, tokio::process::Command::new("false")) .await .is_err() ); assert_eq!( tokio::fs::read(state.join("portainer.db")).await.unwrap(), b"unchanged" ); for entry in std::fs::read_dir(dir.path().join("migration-backups")).unwrap() { assert!(!entry.unwrap().path().join("state.tar").exists()); } } #[test] fn backup_covers_all_portainer_state_once_and_excludes_runtime_socket() { let m = portainer(); assert!(enabled(&m).unwrap()); assert_eq!( relative_sources(&m, Path::new("/var/lib/archipelago")).unwrap(), vec![PathBuf::from("portainer")] ); } #[test] fn backup_refuses_unknown_state_locations_instead_of_silently_omitting_them() { let mut m = portainer(); m.app.volumes[0].source = "/other/operator/state".into(); assert!(relative_sources(&m, Path::new("/var/lib/archipelago")).is_err()); m.app.volumes[0].source = "/var/lib/archipelago/../secret".into(); assert!(relative_sources(&m, Path::new("/var/lib/archipelago")).is_err()); } }