aboutsummaryrefslogtreecommitdiff
path: root/src/archive.rs
diff options
context:
space:
mode:
authorDoan Luu Duc Tai <doan@luuduct.ai>2026-10-30 14:00:58 -0500
committerDoan Luu Duc Tai <doan@luuduct.ai>2026-10-30 14:00:58 -0500
commit19a0280d7dabd4ca637d8070cc7d3691a0e85bd1 (patch)
treede4591ed54ef860f04dd88bb3aae51dee51237a7 /src/archive.rs
parent006a1e94075e90fccfe248cd66bf7d0586a86279 (diff)
Manage the portable stack with Rust and rootless Podman
Diffstat (limited to 'src/archive.rs')
-rw-r--r--src/archive.rs406
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(())
+ }
+}