diff options
Diffstat (limited to 'src/archive.rs')
| -rw-r--r-- | src/archive.rs | 406 |
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(()) - } -} |
