feat: add backup restore workflow
This commit is contained in:
parent
0d588fb3d9
commit
619e8eee62
7 changed files with 498 additions and 6 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
331
crates/geth-node/src/backup.rs
Normal file
331
crates/geth-node/src/backup.rs
Normal 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"));
|
||||
}
|
||||
}
|
||||
|
|
@ -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")]
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
Loading…
Reference in a new issue