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

@ -368,6 +368,10 @@ pub enum Command {
#[command(subcommand)]
command: WaitCommand,
},
Backup {
#[command(subcommand)]
command: BackupCommand,
},
Node {
#[command(subcommand)]
command: NodeCommand,
@ -511,6 +515,19 @@ pub enum WaitCommand {
},
}
#[derive(Debug, Subcommand)]
pub enum BackupCommand {
Create {
#[arg(long, value_name = "DIR")]
out: PathBuf,
},
Restore {
backup_dir: PathBuf,
#[arg(long, value_name = "DIR")]
target_home: PathBuf,
},
}
#[derive(Debug, Subcommand)]
pub enum NodeCommand {
Id,
@ -1320,6 +1337,10 @@ async fn run_inner(cli: Cli) -> Result<()> {
std::process::exit(1);
}
}
Command::Backup { command } => {
run_backup_command(&paths, command, cli.json || cli.jsonl)
.context("run backup command")?;
}
Command::Ssh {
command:
SshCommand::Proxy {
@ -2219,6 +2240,7 @@ fn request_for_command(command: Command) -> Result<ControlRequest> {
Command::Guide { .. }
| Command::Init { .. }
| Command::Daemon { .. }
| Command::Backup { .. }
| Command::Wait { .. } => {
bail!("command is handled directly")
}
@ -2234,6 +2256,66 @@ struct WaitReport {
reason: String,
}
fn run_backup_command(paths: &GethPaths, command: BackupCommand, json: bool) -> Result<()> {
match command {
BackupCommand::Create { out } => {
let report = geth_node::backup::create_backup(paths, &out)?;
if json {
println!(
"{}",
serde_json::to_string_pretty(&serde_json::json!({
"type": "backup-created",
"backup_dir": report.backup_dir,
"files_copied": report.files_copied,
"bytes_copied": report.bytes_copied,
"manifest": report.manifest,
}))?
);
} else {
println!("backup: {}", report.backup_dir.display());
println!("files_copied: {}", report.files_copied);
println!("bytes_copied: {}", report.bytes_copied);
println!(
"manifest: {}",
report.backup_dir.join("manifest.json").display()
);
println!(
"note: private geth identity keys and private SSH admin keys are not copied"
);
}
}
BackupCommand::Restore {
backup_dir,
target_home,
} => {
let report = geth_node::backup::restore_backup(&backup_dir, &target_home)?;
if json {
println!(
"{}",
serde_json::to_string_pretty(&serde_json::json!({
"type": "backup-restored",
"backup_dir": report.backup_dir,
"target_home": report.target_home,
"files_restored": report.files_restored,
"bytes_restored": report.bytes_restored,
"manifest": report.manifest,
}))?
);
} else {
println!("restored: {}", report.target_home.display());
println!("backup: {}", report.backup_dir.display());
println!("files_restored: {}", report.files_restored);
println!("bytes_restored: {}", report.bytes_restored);
println!(
"note: validate with GETH_HOME={} geth status after starting a daemon for the restored home",
report.target_home.display()
);
}
}
}
Ok(())
}
async fn run_wait_command(paths: &GethPaths, command: WaitCommand) -> Result<WaitReport> {
match command {
WaitCommand::Daemon {

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")]

View file

@ -149,6 +149,64 @@ fn geth_init_in_temp_home() {
);
}
#[test]
fn backup_create_and_restore_use_separate_home_without_private_identity_keys() {
let home = tempfile::tempdir().expect("tempdir");
let backup = tempfile::tempdir().expect("backup");
let restore = tempfile::tempdir().expect("restore");
let restore_home = restore.path().join("restored-home");
let init = run_geth(home.path(), &["init"]);
assert!(
init.status.success(),
"stderr: {}",
String::from_utf8_lossy(&init.stderr)
);
let create = run_geth(
home.path(),
&[
"--json",
"backup",
"create",
"--out",
backup.path().to_str().expect("backup path"),
],
);
let create_json = command_json(&create);
assert_eq!(create_json["type"], "backup-created");
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/identity/agent.ed25519").exists());
assert!(
create_json["manifest"]["excludes"]
.as_array()
.expect("excludes")
.iter()
.any(|entry| entry
.as_str()
.expect("exclude")
.contains("private SSH admin keys"))
);
let restore_output = run_geth(
home.path(),
&[
"--json",
"backup",
"restore",
backup.path().to_str().expect("backup path"),
"--target-home",
restore_home.to_str().expect("restore path"),
],
);
let restore_json = command_json(&restore_output);
assert_eq!(restore_json["type"], "backup-restored");
assert!(restore_home.join("config.toml").exists());
assert!(restore_home.join("geth.sqlite").exists());
assert!(!restore_home.join("identity/agent.ed25519").exists());
}
#[test]
fn cli_help_documents_owner_init_keys() {
let home = tempfile::tempdir().expect("tempdir");