diff options
| author | Doan Luu Duc Tai <doan@luuduct.ai> | 2026-10-30 14:00:58 -0500 |
|---|---|---|
| committer | Doan Luu Duc Tai <doan@luuduct.ai> | 2026-10-30 14:00:58 -0500 |
| commit | 19a0280d7dabd4ca637d8070cc7d3691a0e85bd1 (patch) | |
| tree | de4591ed54ef860f04dd88bb3aae51dee51237a7 /src/archive.rs | |
| parent | 006a1e94075e90fccfe248cd66bf7d0586a86279 (diff) | |
Manage the portable stack with Rust and rootless Podman
Diffstat (limited to 'src/archive.rs')
| -rw-r--r-- | src/archive.rs | 406 |
1 files changed, 406 insertions, 0 deletions
diff --git a/src/archive.rs b/src/archive.rs new file mode 100644 index 0000000..e7e6bc5 --- /dev/null +++ b/src/archive.rs @@ -0,0 +1,406 @@ +// Copyright (C) 2026 Doan Luu Duc Tai <doan@luuduct.ai> +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see <https://www.gnu.org/licenses/>. + +use std::collections::BTreeSet; +use std::fs; +use std::path::Path; +use std::process::Command; + +use anyhow::{Context, Result, ensure}; +use serde_json::Value; + +use crate::project::{Mode, Project, Settings, field}; +use crate::{files, process}; + +pub(crate) const RECIPIENT: &str = "3346AA3150F8F16828BB0D14F8C66DF77AFECEA6"; +const KEY: &str = "web/.well-known/openpgpkey/luuduct.ai/hu/t76q8xqkeassgy1kr38re1ppj6cpc1dp"; + +#[derive(Debug)] +struct Paused<'a> { + project: &'a Project, + ids: Vec<String>, +} + +impl Paused<'_> { + fn resume(&mut self) -> Result<()> { + let mut failure = None; + for id in self.ids.iter().rev() { + if let Err(error) = + process::capture(self.project.command("podman")?.args(["unpause", id])) + { + process::error(format_args!("Could not resume container {id}: {error:#}"))?; + failure = Some(error); + } + } + self.ids.clear(); + if let Some(error) = failure { + return Err(error); + } + Ok(()) + } +} + +impl Drop for Paused<'_> { + fn drop(&mut self) { + if let Err(error) = self.resume() { + drop(process::error(format_args!( + "Container resume failed: {error:#}" + ))); + } + } +} + +pub(crate) fn containers(project: &Project, settings: &Settings) -> Result<Vec<Value>> { + let records = project.inspect_containers(settings)?; + let actual: BTreeSet<_> = records + .iter() + .map(|record| { + field(record, &["Config", "Labels", "io.stack.service"])? + .as_str() + .context("Missing service label") + }) + .collect::<Result<_>>()?; + let configuration = project.configuration(settings)?; + let expected: BTreeSet<_> = configuration.services.keys().map(String::as_str).collect(); + ensure!( + actual == expected, + "Running services differ from configuration; refusing an incomplete backup" + ); + Ok(records) +} + +fn snapshot(project: &Project, records: &[Value], stage: &Path) -> Result<()> { + let mut guard = Paused { + project, + ids: Vec::new(), + }; + for record in records { + if field(record, &["State", "Running"])?.as_bool() == Some(true) + && field(record, &["State", "Paused"])?.as_bool() == Some(false) + { + let id = field(record, &["Id"])? + .as_str() + .context("Missing container ID")?; + process::capture(project.command("podman")?.args(["pause", id]))?; + guard.ids.push(id.to_owned()); + } + } + let result = snapshot_files(project, records, stage); + let resume = guard.resume(); + result?; + resume +} + +fn snapshot_files(project: &Project, records: &[Value], stage: &Path) -> Result<()> { + files::copy_source(&project.root, stage)?; + files::copy_tree(&project.root.join(".env"), &stage.join(".env"))?; + if project.root.join(".git").is_dir() { + files::copy_tree(&project.root.join(".git"), &stage.join(".git"))?; + } + let data = project.root.join("var/data"); + for item in fs::read_dir(&data)? { + let entry = item?; + if entry.file_name() != "caddy" { + files::copy_tree( + &entry.path(), + &stage.join("var/data").join(entry.file_name()), + )?; + } + } + let caddy = records + .iter() + .find(|record| { + field(record, &["Config", "Labels", "io.stack.service"]) + .ok() + .and_then(Value::as_str) + == Some("caddy") + }) + .context("Caddy is not present")?; + let id = field(caddy, &["Id"])?.as_str().context("Caddy has no ID")?; + let target = stage.join("var/data/caddy"); + fs::create_dir_all(&target)?; + for name in ["data", "config"] { + process::capture( + project + .command("podman")? + .args(["cp", &format!("{id}:/{name}")]) + .arg(&target), + )?; + } + fs::create_dir_all(stage.join("bin"))?; + let executable = [ + project.root.join("bin/stack"), + project + .root + .join("target/x86_64-unknown-linux-musl/release/stack"), + ] + .into_iter() + .find(|path| path.is_file()) + .context("Build a release locally before exporting so the backup includes a portable binary")?; + fs::copy(executable, stage.join("bin/stack"))?; + Ok(()) +} + +pub(crate) fn backup( + project: &Project, + destination: &Path, + with_images: bool, + recipient: &str, + public_key: Option<&Path>, +) -> Result<()> { + ensure!( + recipient.len() == 40 && recipient.bytes().all(|byte| byte.is_ascii_hexdigit()), + "A full 40-character recipient fingerprint is required" + ); + files::private_dir(destination)?; + let settings = project.settings()?; + let records = containers(project, &settings)?; + let temp = tempfile::Builder::new() + .prefix(".export-") + .tempdir_in(destination)?; + let home = temp.path().join("gnupg"); + files::private_dir(&home)?; + let key = public_key.map_or_else(|| project.root.join(KEY), Path::to_path_buf); + let operation = backup_inner( + project, + destination, + with_images, + &Encryption { + recipient, + key: &key, + home: &home, + }, + &records, + temp.path(), + ); + drop(process::capture( + Command::new("gpgconf") + .arg("--homedir") + .arg(&home) + .args(["--kill", "all"]), + )); + operation +} + +#[derive(Debug)] +struct Encryption<'a> { + recipient: &'a str, + key: &'a Path, + home: &'a Path, +} + +fn backup_inner( + project: &Project, + destination: &Path, + with_images: bool, + encryption: &Encryption<'_>, + records: &[Value], + temp: &Path, +) -> Result<()> { + let Encryption { + recipient, + key, + home, + } = encryption; + process::capture( + Command::new("gpg") + .args(["--no-options", "--batch", "--homedir"]) + .arg(home) + .args(["--import"]) + .arg(key), + )?; + let listed = process::text( + Command::new("gpg") + .args(["--no-options", "--batch", "--homedir"]) + .arg(home) + .args(["--with-colons", "--list-keys", recipient]), + )?; + ensure!( + listed + .lines() + .filter(|line| line.starts_with("fpr:")) + .any(|line| line + .split(':') + .nth(9) + .is_some_and(|found| found.eq_ignore_ascii_case(recipient))), + "Public key fingerprint does not match the requested backup recipient" + ); + let stage = temp.join("stack"); + files::private_dir(&stage)?; + snapshot(project, records, &stage)?; + for entry in fs::read_dir(stage.join("var/data/repos"))? { + let repo = entry?.path(); + if repo.is_dir() { + process::capture( + Command::new("git") + .arg("--git-dir") + .arg(repo) + .args(["fsck", "--full"]), + )?; + } + } + if with_images { + crate::images::save(project, records, &stage.join("var"))?; + } + files::manifest(&stage, "backup")?; + let plain = temp.join("stack.tar.gz"); + files::pack(&stage, &plain)?; + let name = format!( + "stack-{}.tar.gz.gpg", + chrono::Utc::now().format("%Y%m%dT%H%M%SZ") + ); + let encrypted = temp.join(&name); + process::run( + Command::new("gpg") + .args(["--no-options", "--batch", "--homedir"]) + .arg(home) + .args([ + "--rfc4880", + "--trust-model", + "always", + "--recipient", + recipient, + "--cipher-algo", + "AES256", + "--compress-algo", + "none", + "--set-filename", + "stack.tar.gz", + "--output", + ]) + .arg(&encrypted) + .arg("--encrypt") + .arg(plain), + )?; + let output = destination.join(&name); + ensure!( + !output.exists(), + "An export with this timestamp already exists" + ); + fs::rename(&encrypted, &output)?; + let digest = format!("{} {name}\n", files::hash_file(&output)?); + files::write( + &destination.join(format!("{name}.sha256")), + digest.as_bytes(), + 0o600, + )?; + process::line(format_args!("Encrypted export: {}", output.display())) +} + +pub(crate) fn restore(archive: &Path, destination: &Path) -> Result<()> { + ensure!(!destination.exists(), "Restore destination must not exist"); + let parent = destination + .parent() + .context("Restore destination has no parent")?; + fs::create_dir_all(parent)?; + let temp = tempfile::Builder::new() + .prefix(".restore-") + .tempdir_in(parent)?; + let decrypted = temp.path().join("stack.tar.gz"); + let source = if archive.extension().is_some_and(|suffix| suffix == "gpg") { + process::run( + Command::new("gpg") + .args(["--no-options", "--output"]) + .arg(&decrypted) + .arg("--decrypt") + .arg(archive), + )?; + &decrypted + } else { + archive + }; + let stage = temp.path().join("stack"); + files::extract(source, &stage)?; + let manifest = files::verify(&stage)?; + ensure!( + manifest.kind == "backup" || manifest.kind == "release", + "Unknown archive kind" + ); + let project = Project::at(&stage)?; + let mut settings = project.settings()?; + settings.mode = Mode::Local; + settings.project = format!( + "stack-{}", + files::hash_bytes(destination.as_os_str().as_encoded_bytes()) + .chars() + .take(10) + .collect::<String>() + ); + project.save(&settings)?; + if stage.join(".git").is_dir() { + process::capture( + Command::new("git") + .arg("-C") + .arg(&stage) + .args(["remote", "set-url", "origin"]) + .arg(destination.join("var/data/repos/stack")), + )?; + } + fs::rename(&stage, destination)?; + process::line(format_args!( + "Restored and verified {}. No services started.", + destination.display() + )) +} + +#[cfg(test)] +mod tests { + use std::fs; + + use anyhow::Result; + + use crate::files; + use crate::project::{Mode, Project}; + + #[test] + fn restore_is_local_and_refuses_an_existing_destination() -> Result<()> { + let workspace = tempfile::tempdir()?; + let source = workspace.path().join("source"); + files::private_dir(&source)?; + fs::write(source.join(".env"), "SEARXNG_SECRET=private\n")?; + let project = Project::at(&source)?; + let mut settings = project.settings()?; + settings.mode = Mode::Production; + project.save(&settings)?; + files::manifest(&source, "backup")?; + let archive = workspace.path().join("backup.tar.gz"); + files::pack(&source, &archive)?; + let destination = workspace.path().join("restored"); + super::restore(&archive, &destination)?; + assert_eq!(Project::at(&destination)?.settings()?.mode, Mode::Local); + assert_eq!( + fs::read_to_string(destination.join(".env"))?, + "SEARXNG_SECRET=private\n" + ); + assert!(super::restore(&archive, &destination).is_err()); + Ok(()) + } + + #[test] + fn corrupt_restore_never_publishes_a_destination() -> Result<()> { + let workspace = tempfile::tempdir()?; + let source = workspace.path().join("source"); + files::private_dir(&source)?; + fs::write(source.join(".env"), "original")?; + files::manifest(&source, "backup")?; + fs::write(source.join(".env"), "tampered")?; + let archive = workspace.path().join("backup.tar.gz"); + files::pack(&source, &archive)?; + let destination = workspace.path().join("restored"); + assert!(super::restore(&archive, &destination).is_err()); + assert!(!destination.exists()); + Ok(()) + } +} |
