summaryrefslogtreecommitdiff
path: root/src/archive.rs
diff options
context:
space:
mode:
Diffstat (limited to 'src/archive.rs')
-rw-r--r--src/archive.rs406
1 files changed, 0 insertions, 406 deletions
diff --git a/src/archive.rs b/src/archive.rs
deleted file mode 100644
index e7e6bc5..0000000
--- a/src/archive.rs
+++ /dev/null
@@ -1,406 +0,0 @@
-// 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(())
- }
-}