feat: add backup restore workflow

This commit is contained in:
Eric Wendland 2026-07-05 22:35:18 +02:00
commit 619e8eee62
7 changed files with 498 additions and 6 deletions

View file

@ -0,0 +1,331 @@
use crate::NodeError;
use geth_config::GethPaths;
use geth_crypto::AgentKey;
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
const BACKUP_FORMAT: &str = "geth.backup.v1";
const MANIFEST_FILE: &str = "manifest.json";
const HOME_DIR: &str = "home";
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct BackupManifest {
pub format: String,
pub created_at_ms: i64,
pub source_home: PathBuf,
pub includes: Vec<String>,
pub excludes: Vec<String>,
pub identity_public: BackupIdentityPublic,
pub notes: Vec<String>,
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct BackupIdentityPublic {
pub agent_id: Option<String>,
pub agent_public_key: Option<String>,
pub iroh_endpoint_id: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct BackupCreateReport {
pub backup_dir: PathBuf,
pub manifest: BackupManifest,
pub files_copied: usize,
pub bytes_copied: u64,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct BackupRestoreReport {
pub backup_dir: PathBuf,
pub target_home: PathBuf,
pub manifest: BackupManifest,
pub files_restored: usize,
pub bytes_restored: u64,
}
pub fn create_backup(
paths: &GethPaths,
backup_dir: &Path,
) -> Result<BackupCreateReport, NodeError> {
if backup_dir.starts_with(paths.home()) {
return Err(NodeError::Backup(
"backup output must be outside GETH_HOME to avoid recursive self-inclusion".to_owned(),
));
}
ensure_new_or_empty_dir(backup_dir)?;
let backup_home = backup_dir.join(HOME_DIR);
std::fs::create_dir_all(&backup_home)?;
let mut copied = CopyStats::default();
let mut includes = Vec::new();
let mut excludes = Vec::new();
copy_if_file(
paths.config_file(),
&backup_home.join("config.toml"),
&mut copied,
&mut includes,
)?;
copy_if_file(
paths.metadata_db(),
&backup_home.join("geth.sqlite"),
&mut copied,
&mut includes,
)?;
copy_if_file(
paths.home().join("geth.sqlite-wal"),
&backup_home.join("geth.sqlite-wal"),
&mut copied,
&mut includes,
)?;
copy_if_file(
paths.home().join("geth.sqlite-shm"),
&backup_home.join("geth.sqlite-shm"),
&mut copied,
&mut includes,
)?;
copy_dir_filtered(
paths.cas_dir(),
&backup_home.join("cas"),
&mut copied,
&mut includes,
&mut excludes,
)?;
if paths.identity_dir().exists() {
excludes.push("identity/*.ed25519 private geth node keys".to_owned());
}
excludes.push("run/* daemon sockets and process-local files".to_owned());
excludes.push(
"private SSH admin keys; geth records only configured public admin trust anchors"
.to_owned(),
);
let manifest = BackupManifest {
format: BACKUP_FORMAT.to_owned(),
created_at_ms: geth_store::now_ms(),
source_home: paths.home().to_path_buf(),
includes,
excludes,
identity_public: identity_public(paths),
notes: vec![
"Offline backup format for pre-deployment validation.".to_owned(),
"Restore writes to a separate target home and does not restore private geth identity keys.".to_owned(),
],
};
std::fs::write(
backup_dir.join(MANIFEST_FILE),
serde_json::to_vec_pretty(&manifest)?,
)?;
Ok(BackupCreateReport {
backup_dir: backup_dir.to_path_buf(),
manifest,
files_copied: copied.files,
bytes_copied: copied.bytes,
})
}
pub fn restore_backup(
backup_dir: &Path,
target_home: &Path,
) -> Result<BackupRestoreReport, NodeError> {
let manifest = read_manifest(backup_dir)?;
if manifest.format != BACKUP_FORMAT {
return Err(NodeError::Backup(format!(
"unsupported backup format `{}`",
manifest.format
)));
}
if target_home.exists() && target_home.read_dir()?.next().is_some() {
return Err(NodeError::Backup(format!(
"restore target `{}` must be empty or not exist",
target_home.display()
)));
}
std::fs::create_dir_all(target_home)?;
let mut copied = CopyStats::default();
copy_dir_all(backup_dir.join(HOME_DIR), target_home, &mut copied)?;
Ok(BackupRestoreReport {
backup_dir: backup_dir.to_path_buf(),
target_home: target_home.to_path_buf(),
manifest,
files_restored: copied.files,
bytes_restored: copied.bytes,
})
}
pub fn read_manifest(backup_dir: &Path) -> Result<BackupManifest, NodeError> {
let manifest = std::fs::read(backup_dir.join(MANIFEST_FILE))?;
Ok(serde_json::from_slice(&manifest)?)
}
#[derive(Default)]
struct CopyStats {
files: usize,
bytes: u64,
}
fn ensure_new_or_empty_dir(path: &Path) -> Result<(), NodeError> {
if path.exists() && path.read_dir()?.next().is_some() {
return Err(NodeError::Backup(format!(
"backup output `{}` must be empty or not exist",
path.display()
)));
}
std::fs::create_dir_all(path)?;
Ok(())
}
fn copy_if_file(
source: PathBuf,
destination: &Path,
copied: &mut CopyStats,
includes: &mut Vec<String>,
) -> Result<(), NodeError> {
if !source.is_file() {
return Ok(());
}
if let Some(parent) = destination.parent() {
std::fs::create_dir_all(parent)?;
}
let bytes = std::fs::copy(&source, destination)?;
copied.files += 1;
copied.bytes += bytes;
includes.push(destination.display().to_string());
Ok(())
}
fn copy_dir_filtered(
source: PathBuf,
destination: &Path,
copied: &mut CopyStats,
includes: &mut Vec<String>,
excludes: &mut Vec<String>,
) -> Result<(), NodeError> {
if !source.exists() {
return Ok(());
}
for entry in std::fs::read_dir(&source)? {
let entry = entry?;
let path = entry.path();
let target = destination.join(entry.file_name());
if path.is_dir() {
copy_dir_filtered(path, &target, copied, includes, excludes)?;
} else if path.is_file() {
let bytes = copy_regular_file(&path, &target)?;
copied.files += 1;
copied.bytes += bytes;
includes.push(target.display().to_string());
} else {
excludes.push(format!("non-regular path {}", path.display()));
}
}
Ok(())
}
fn copy_dir_all(
source: PathBuf,
destination: &Path,
copied: &mut CopyStats,
) -> Result<(), NodeError> {
if !source.exists() {
return Ok(());
}
for entry in std::fs::read_dir(source)? {
let entry = entry?;
let path = entry.path();
let target = destination.join(entry.file_name());
if path.is_dir() {
std::fs::create_dir_all(&target)?;
copy_dir_all(path, &target, copied)?;
} else if path.is_file() {
let bytes = copy_regular_file(&path, &target)?;
copied.files += 1;
copied.bytes += bytes;
}
}
Ok(())
}
fn copy_regular_file(source: &Path, destination: &Path) -> Result<u64, NodeError> {
if let Some(parent) = destination.parent() {
std::fs::create_dir_all(parent)?;
}
Ok(std::fs::copy(source, destination)?)
}
fn identity_public(paths: &GethPaths) -> BackupIdentityPublic {
let mut identity = BackupIdentityPublic::default();
if paths.agent_key().is_file()
&& let Ok(agent_key) = AgentKey::load(&paths.agent_key())
{
identity.agent_id = Some(agent_key.agent_id().to_string());
identity.agent_public_key = Some(agent_key.public_key_hex());
}
if paths.iroh_key().is_file()
&& let Ok(secret_key) = geth_iroh::load_secret_key(&paths.iroh_key())
{
identity.iroh_endpoint_id = Some(secret_key.public().to_string());
}
identity
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn backup_create_and_restore_exclude_private_identity_keys() {
let home = tempfile::tempdir().expect("home");
let backup = tempfile::tempdir().expect("backup");
let restore = tempfile::tempdir().expect("restore");
let restore_home = restore.path().join("restored-home");
let paths = GethPaths::from_home(home.path());
paths.ensure_base_dirs().expect("dirs");
std::fs::write(paths.config_file(), "config").expect("config");
std::fs::write(paths.metadata_db(), "db").expect("db");
std::fs::write(paths.agent_key(), [1_u8; 32]).expect("agent key");
std::fs::write(paths.iroh_key(), "00").expect("bad iroh key");
std::fs::write(paths.cas_dir().join("blobs/blob"), "blob").expect("blob");
let report = create_backup(&paths, backup.path()).expect("backup");
assert_eq!(report.files_copied, 3);
assert!(backup.path().join("manifest.json").exists());
assert!(backup.path().join("home/config.toml").exists());
assert!(backup.path().join("home/geth.sqlite").exists());
assert!(backup.path().join("home/cas/blobs/blob").exists());
assert!(!backup.path().join("home/identity/agent.ed25519").exists());
assert!(report.manifest.identity_public.agent_id.is_some());
assert!(
report
.manifest
.excludes
.iter()
.any(|entry| entry.contains("private SSH admin keys"))
);
let restore_report = restore_backup(backup.path(), &restore_home).expect("restore");
assert_eq!(restore_report.files_restored, 3);
assert!(restore_home.join("config.toml").exists());
assert!(restore_home.join("geth.sqlite").exists());
assert!(restore_home.join("cas/blobs/blob").exists());
assert!(!restore_home.join("identity/agent.ed25519").exists());
}
#[test]
fn restore_requires_empty_target_home() {
let home = tempfile::tempdir().expect("home");
let backup = tempfile::tempdir().expect("backup");
let target = tempfile::tempdir().expect("target");
let paths = GethPaths::from_home(home.path());
paths.ensure_base_dirs().expect("dirs");
std::fs::write(paths.config_file(), "config").expect("config");
create_backup(&paths, backup.path()).expect("backup");
std::fs::write(target.path().join("existing"), "data").expect("existing");
let error = restore_backup(backup.path(), target.path()).expect_err("reject target");
assert!(error.to_string().contains("must be empty"));
}
}

View file

@ -1,3 +1,4 @@
pub mod backup;
mod daemon;
mod peer_client;
mod runtime;
@ -105,6 +106,8 @@ pub enum NodeError {
Json(#[from] serde_json::Error),
#[error("io error: {0}")]
Io(#[from] std::io::Error),
#[error("backup error: {0}")]
Backup(String),
#[error("invalid resource kind: {0}")]
InvalidResourceKind(String),
#[error("owner init requires --admin-key so the owner trust anchor is recorded")]