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

@ -100,6 +100,8 @@ The bootstrap implementation provides:
- `geth daemon service install|uninstall|start|stop|status|print`
- `geth status`
- `geth wait daemon|peer|sync --timeout-ms <ms>`
- `geth backup create --out <dir>`
- `geth backup restore <backup-dir> --target-home <dir>`
- `geth node id`
- `geth node list`
- `geth node enroll request --node-name <name> --capability <resource=capability> [--out <path>]`
@ -417,6 +419,21 @@ cargo run -p geth -- cas add /tmp/hello-geth.txt
cargo run -p geth -- cas list
```
## Backup And Restore
`geth backup create --out <dir>` creates an offline directory backup with a
`manifest.json` plus a `home/` payload. The backup includes `config.toml`,
`geth.sqlite` and SQLite WAL sidecars when present, and local CAS blobs. It
records public geth identity material in the manifest when available.
The backup intentionally excludes daemon runtime files, private geth identity
keys under `identity/*.ed25519`, and private SSH admin keys. SSH admin keys are
external trust anchors; geth stores public admin material and signatures, not
the private SSH keys.
`geth backup restore <backup-dir> --target-home <dir>` restores into a separate
empty target home for validation. It refuses to overwrite a non-empty target.
## Owner And Node Management
The intended owner setup is SSH-admin-rooted:

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

View file

@ -24,6 +24,7 @@ The following command families are intended to be stable automation surfaces:
- `geth daemon service install|uninstall|start|stop|status|print`
- `geth status`
- `geth node id`
- `geth backup create|restore`
- `geth node list|rename|revoke|grant|revoke-grant|endpoint-add|endpoint-revoke`
- `geth node enroll request|submit|import|list|approve|sync`
- `geth peer export|import|list|ping|auth-check`

View file

@ -134,13 +134,13 @@ it.
error.
- `[x]` Tests cover failure injection for at least one multi-table path.
- `[ ]` Add backup and restore workflow.
- `[x]` Add backup and restore workflow.
Acceptance criteria:
- `[ ]` `geth backup create` or an equivalent documented command captures
- `[x]` `geth backup create` or an equivalent documented command captures
metadata, identity public material, config, and CAS metadata expectations.
- `[ ]` Restore can write to a separate target home for validation.
- `[ ]` Backup output avoids copying private SSH admin keys.
- `[ ]` Docs explain what is and is not included.
- `[x]` Restore can write to a separate target home for validation.
- `[x]` Backup output avoids copying private SSH admin keys.
- `[x]` Docs explain what is and is not included.
- `[x]` Document database durability settings.
Acceptance criteria:
@ -339,7 +339,7 @@ Goal: prove the system works as an actual base layer before broader use.
1. `[x]` Finish Phase 0.
2. `[~]` Refactor `geth-node` into daemon subsystems.
3. `[x]` Add stable contract and golden JSON tests.
4. `[ ]` Harden store migrations and backup.
4. `[x]` Harden store migrations and backup.
5. `[ ]` Complete security-boundary test coverage.
6. `[ ]` Replace prototype private CAS cryptography.
7. `[ ]` Add fault-injection sync tests.