cli

Command-line interface for Radroots
git clone https://radroots.dev/git/cli.git
Log | Files | Refs | README | LICENSE

commit 5a0470046729c7dc62748707f54f89f0c2723878
parent 51b0873989d32d58e8ae7e94f35915f4a57765f4
Author: triesap <tyson@radroots.org>
Date:   Wed, 17 Jun 2026 18:16:04 -0700

cli: expose sdk backup restore

- add store backup restore parser and registry metadata
- delegate restore execution to SDK without opening destination stores first
- gate overwrite restores on approval tokens outside dry-run
- cover dry-run, restore, overwrite, and SDK error output in target tests

Diffstat:
Msrc/cli/input.rs | 17+++++++++++++++--
Msrc/cli/mod.rs | 3++-
Msrc/cli/store.rs | 16++++++++++++++--
Msrc/main.rs | 3+++
Msrc/ops/exec/core.rs | 68+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----
Msrc/ops/target.rs | 1+
Msrc/registry/mod.rs | 5++++-
Msrc/registry/store.rs | 16++++++++++++++++
Msrc/runtime/sdk.rs | 21+++++++++++++++------
Msrc/runtime/store.rs | 141++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------
Msrc/view/runtime.rs | 38++++++++++++++++++++++++++++++++++++++
Mtests/target_cli.rs | 144++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
12 files changed, 433 insertions(+), 40 deletions(-)

diff --git a/src/cli/input.rs b/src/cli/input.rs @@ -48,8 +48,8 @@ pub fn target_operation_input(command: &TargetCommand) -> OperationData { FarmLocationCommand, FarmProfileCommand, ListingAppCommand, ListingCommand, MarketCommand, MarketListingCommand, MarketProductCommand, OrderAppCommand, OrderCommand, OrderEventCommand, OrderFulfillmentCommand, OrderPaymentCommand, OrderReceiptCommand, - OrderRevisionCommand, OrderSettlementCommand, OrderStatusCommand, ValidationCommand, - ValidationReceiptCommand, + OrderRevisionCommand, OrderSettlementCommand, OrderStatusCommand, StoreBackupCommand, + StoreCommand, ValidationCommand, ValidationReceiptCommand, }; let mut input = OperationData::new(); @@ -168,6 +168,19 @@ pub fn target_operation_input(command: &TargetCommand) -> OperationData { }, MarketCommand::Refresh => {} }, + TargetCommand::Store(args) => match &args.command { + StoreCommand::Backup(backup) => match &backup.command { + StoreBackupCommand::Restore(args) => { + insert_path(&mut input, "source", &Some(args.source.clone())); + insert_path(&mut input, "destination", &args.destination); + if args.overwrite { + input.insert("overwrite".to_owned(), Value::Bool(true)); + } + } + StoreBackupCommand::Create => {} + }, + StoreCommand::Init | StoreCommand::Status(_) | StoreCommand::Export => {} + }, TargetCommand::Basket(args) => match &args.command { BasketCommand::Create(args) => { insert_string(&mut input, "basket_id", &args.basket_id); diff --git a/src/cli/mod.rs b/src/cli/mod.rs @@ -182,8 +182,9 @@ impl TargetCommand { StoreStatusCommand::Get => "store.status.get", }, StoreCommand::Export => "store.export", - StoreCommand::Backup(backup) => match backup.command { + StoreCommand::Backup(backup) => match &backup.command { StoreBackupCommand::Create => "store.backup.create", + StoreBackupCommand::Restore(_) => "store.backup.restore", }, }, Self::Sync(args) => match &args.command { diff --git a/src/cli/store.rs b/src/cli/store.rs @@ -1,4 +1,6 @@ -use clap::{Args, Subcommand}; +use std::path::PathBuf; + +use clap::{ArgAction, Args, Subcommand}; #[derive(Debug, Clone, Args)] pub struct StoreArgs { @@ -31,7 +33,17 @@ pub struct StoreBackupArgs { pub command: StoreBackupCommand, } -#[derive(Debug, Clone, Copy, Subcommand)] +#[derive(Debug, Clone, Subcommand)] pub enum StoreBackupCommand { Create, + Restore(StoreBackupRestoreArgs), +} + +#[derive(Debug, Clone, Args)] +pub struct StoreBackupRestoreArgs { + pub source: PathBuf, + #[arg(long = "destination")] + pub destination: Option<PathBuf>, + #[arg(long = "overwrite", action = ArgAction::SetTrue)] + pub overwrite: bool, } diff --git a/src/main.rs b/src/main.rs @@ -132,6 +132,9 @@ fn execute_request( TargetOperationRequest::StoreBackupCreate(request) => { execute_with(CoreOperationService::new(config, logging), request) } + TargetOperationRequest::StoreBackupRestore(request) => { + execute_with(CoreOperationService::new(config, logging), request) + } TargetOperationRequest::SignerStatusGet(request) => { execute_with(RuntimeOperationService::new(config), request) } diff --git a/src/ops/exec/core.rs b/src/ops/exec/core.rs @@ -14,9 +14,9 @@ use crate::ops::{ HealthCheckRunResult, HealthStatusGetRequest, HealthStatusGetResult, OperationAdapterError, OperationRequest, OperationRequestData, OperationRequestPayload, OperationResult, OperationResultData, OperationService, StoreBackupCreateRequest, StoreBackupCreateResult, - StoreExportRequest, StoreExportResult, StoreInitRequest, StoreInitResult, - StoreStatusGetRequest, StoreStatusGetResult, WorkspaceGetRequest, WorkspaceGetResult, - WorkspaceInitRequest, WorkspaceInitResult, + StoreBackupRestoreRequest, StoreBackupRestoreResult, StoreExportRequest, StoreExportResult, + StoreInitRequest, StoreInitResult, StoreStatusGetRequest, StoreStatusGetResult, + WorkspaceGetRequest, WorkspaceGetResult, WorkspaceInitRequest, WorkspaceInitResult, }; use crate::runtime::RuntimeError; use crate::runtime::account::{ @@ -33,8 +33,8 @@ use crate::runtime::config::{ use crate::runtime::logging::LoggingState; use crate::runtime::sdk::CliSdkAdapterError; use crate::view::runtime::{ - CommandDisposition, LocalBackupView, PublishProviderRuntimeView, PublishRelayRuntimeView, - PublishRuntimeView, + CommandDisposition, LocalBackupView, LocalRestoreView, PublishProviderRuntimeView, + PublishRelayRuntimeView, PublishRuntimeView, }; pub struct CoreOperationService<'a> { @@ -654,6 +654,36 @@ impl OperationService<StoreBackupCreateRequest> for CoreOperationService<'_> { } } +impl OperationService<StoreBackupRestoreRequest> for CoreOperationService<'_> { + type Result = StoreBackupRestoreResult; + + fn execute( + &self, + request: OperationRequest<StoreBackupRestoreRequest>, + ) -> Result<OperationResult<Self::Result>, OperationAdapterError> { + let source = required_path(&request, "source")?; + let destination = optional_path(&request, "destination"); + let overwrite = bool_input(&request, "overwrite").unwrap_or(false); + if overwrite && request.context.requires_approval_token() { + return Err(OperationAdapterError::approval_required( + request.operation_id(), + )); + } + + let view = map_sdk_adapter( + request.operation_id(), + crate::runtime::store::restore( + self.config, + source.as_path(), + destination.as_deref(), + overwrite, + request.context.dry_run, + ), + )?; + local_restore_result(request.operation_id(), &view) + } +} + fn serialized_operation_result<R, T>(value: &T) -> Result<OperationResult<R>, OperationAdapterError> where R: OperationResultData, @@ -728,6 +758,34 @@ fn local_backup_result( } } +fn local_restore_result( + operation_id: &str, + view: &LocalRestoreView, +) -> Result<OperationResult<StoreBackupRestoreResult>, OperationAdapterError> { + match view.disposition() { + CommandDisposition::Success => { + serialized_operation_result::<StoreBackupRestoreResult, _>(view) + } + disposition => Err(OperationAdapterError::from_command_disposition( + operation_id, + disposition, + view.reason.clone().unwrap_or_else(|| match disposition { + CommandDisposition::Success => "store restore succeeded".to_owned(), + CommandDisposition::NotFound => "store restore source was not found".to_owned(), + CommandDisposition::ValidationFailed => { + "store restore validation failed".to_owned() + } + CommandDisposition::Unconfigured => "store restore is unconfigured".to_owned(), + CommandDisposition::ExternalUnavailable => { + "store restore is unavailable".to_owned() + } + CommandDisposition::Unsupported => "store restore is unsupported".to_owned(), + CommandDisposition::InternalError => "store restore failed".to_owned(), + }), + )), + } +} + fn selected_config(config: &RuntimeConfig, selector: String) -> RuntimeConfig { let mut config = config.clone(); config.account.selector = Some(selector); diff --git a/src/ops/target.rs b/src/ops/target.rs @@ -188,6 +188,7 @@ target_operation_contracts! { StoreStatusGet => (StoreStatusGetRequest, StoreStatusGetResult, "store.status.get"), StoreExport => (StoreExportRequest, StoreExportResult, "store.export"), StoreBackupCreate => (StoreBackupCreateRequest, StoreBackupCreateResult, "store.backup.create"), + StoreBackupRestore => (StoreBackupRestoreRequest, StoreBackupRestoreResult, "store.backup.restore"), SyncStatusGet => (SyncStatusGetRequest, SyncStatusGetResult, "sync.status.get"), SyncPull => (SyncPullRequest, SyncPullResult, "sync.pull"), SyncPush => (SyncPushRequest, SyncPushResult, "sync.push"), diff --git a/src/registry/mod.rs b/src/registry/mod.rs @@ -117,6 +117,7 @@ pub const OPERATION_REGISTRY: &[OperationSpec] = &[ store::STORE_STATUS_GET, store::STORE_EXPORT, store::STORE_BACKUP_CREATE, + store::STORE_BACKUP_RESTORE, sync::SYNC_STATUS_GET, sync::SYNC_PULL, sync::SYNC_PUSH, @@ -295,6 +296,7 @@ mod tests { "store.status.get", "store.export", "store.backup.create", + "store.backup.restore", "sync.status.get", "sync.pull", "sync.push", @@ -365,6 +367,7 @@ mod tests { "account.selection.clear", "store.init", "store.backup.create", + "store.backup.restore", "sync.pull", "sync.push", "farm.create", @@ -410,7 +413,7 @@ mod tests { .copied() .collect::<BTreeSet<_>>(); assert_eq!(actual, expected); - assert_eq!(OPERATION_REGISTRY.len(), 78); + assert_eq!(OPERATION_REGISTRY.len(), 79); } #[test] diff --git a/src/registry/store.rs b/src/registry/store.rs @@ -63,3 +63,19 @@ pub const STORE_BACKUP_CREATE: OperationSpec = operation!( false, true ); + +pub const STORE_BACKUP_RESTORE: OperationSpec = operation!( + "store.backup.restore", + "radroots store backup restore sdk-store-backup", + "store", + "store_backup_restore", + "StoreBackupRestoreRequest", + "StoreBackupRestoreResult", + "Restore SDK canonical store backup.", + Any, + true, + Conditional, + High, + false, + true +); diff --git a/src/runtime/sdk.rs b/src/runtime/sdk.rs @@ -140,7 +140,7 @@ pub fn sdk_storage_root(config: &RuntimeConfig) -> PathBuf { config.local.root.join(SDK_STORAGE_DIR_NAME) } -fn sdk_runtime() -> Result<Runtime, RuntimeError> { +pub(crate) fn sdk_runtime() -> Result<Runtime, RuntimeError> { TokioRuntimeBuilder::new_multi_thread() .enable_all() .build() @@ -517,8 +517,8 @@ mod tests { ), &[ "session.sdk()", - "storage_status(StorageStatusRequest::default())", - "integrity(IntegrityRequest::default())", + "storage_status(StorageStatusRequest::new())", + "integrity(IntegrityRequest::new())", ], ); assert_migrated_path( @@ -532,10 +532,19 @@ mod tests { ); assert_migrated_path( "store backup preflight", - source_segment(&store, "pub fn backup_preflight(", "pub fn export("), + source_segment(&store, "pub fn backup_preflight(", "pub fn restore("), &[ - "storage_status(StorageStatusRequest::default())", - "integrity(IntegrityRequest::default())", + "storage_status(StorageStatusRequest::new())", + "integrity(IntegrityRequest::new())", + ], + ); + assert_migrated_path( + "store restore", + source_segment(&store, "pub fn restore(", "pub fn export("), + &[ + "RestoreRequest::new", + "sdk_runtime()", + "RadrootsSdk::restore", ], ); } diff --git a/src/runtime/store.rs b/src/runtime/store.rs @@ -5,9 +5,10 @@ use radroots_replica_db::export::{ReplicaDbExportManifestRs, export_manifest}; use radroots_replica_db::migrations; use radroots_replica_sync::radroots_replica_sync_status; use radroots_sdk::{ - BackupReceipt, BackupRequest, IntegrityReceipt, IntegrityRequest, SdkBackupState, - SdkEventStoreStorageStatus, SdkOutboxStorageStatus, SdkSqliteStoreStatus, SdkStorageKind, - StorageStatusReceipt, StorageStatusRequest, + BackupReceipt, BackupRequest, IntegrityReceipt, IntegrityRequest, RadrootsSdk, RestoreReceipt, + RestoreRequest, SdkBackupState, SdkEventStoreStorageStatus, SdkOutboxStorageStatus, + SdkRestoreState, SdkSqliteStoreStatus, SdkStorageKind, StorageStatusReceipt, + StorageStatusRequest, }; use radroots_sql_core::SqliteExecutor; use serde::Serialize; @@ -16,12 +17,12 @@ use serde_json::{Value, json}; use crate::cli::global::LocalExportFormatArg; use crate::runtime::RuntimeError; use crate::runtime::config::RuntimeConfig; -use crate::runtime::sdk::{CliSdkAdapterError, CliSdkSession, sdk_storage_root}; +use crate::runtime::sdk::{CliSdkAdapterError, CliSdkSession, sdk_runtime, sdk_storage_root}; use crate::runtime::sync::ensure_sync_run_table; use crate::view::runtime::{ LocalBackupView, LocalExportView, LocalInitView, LocalLegacyReplicaStatusView, - LocalReplicaCountsView, LocalReplicaSyncView, LocalStatusView, SdkEventStoreStatusView, - SdkIntegrityView, SdkOutboxStatusView, SdkSqliteStatusView, + LocalReplicaCountsView, LocalReplicaSyncView, LocalRestoreView, LocalStatusView, + SdkEventStoreStatusView, SdkIntegrityView, SdkOutboxStatusView, SdkSqliteStatusView, }; const LEGACY_REPLICA_SOURCE: &str = "legacy local replica ยท derived/migration source"; @@ -88,12 +89,8 @@ pub fn status(config: &RuntimeConfig) -> Result<LocalStatusView, CliSdkAdapterEr let sdk_existed_before_open = sdk_storage_files_exist(sdk_root.as_path()); let legacy_replica = legacy_replica_status(config)?; let session = CliSdkSession::connect(config)?; - let receipt = session.block_on( - session - .sdk() - .storage_status(StorageStatusRequest::default()), - )?; - let integrity = session.block_on(session.sdk().integrity(IntegrityRequest::default()))?; + let receipt = session.block_on(session.sdk().storage_status(StorageStatusRequest::new()))?; + let integrity = session.block_on(session.sdk().integrity(IntegrityRequest::new()))?; Ok(sdk_status_view( config, sdk_root, @@ -161,10 +158,7 @@ pub fn backup( ) -> Result<LocalBackupView, CliSdkAdapterError> { ensure_safe_sdk_backup_destination(config, output)?; let session = CliSdkSession::connect(config)?; - let receipt = session.block_on(session.sdk().backup(BackupRequest { - destination: output.to_path_buf(), - overwrite: false, - }))?; + let receipt = session.block_on(session.sdk().backup(BackupRequest::new(output)))?; sdk_backup_view(receipt) } @@ -174,12 +168,8 @@ pub fn backup_preflight( ) -> Result<LocalBackupView, CliSdkAdapterError> { ensure_safe_sdk_backup_destination(config, output)?; let session = CliSdkSession::connect(config)?; - let status = session.block_on( - session - .sdk() - .storage_status(StorageStatusRequest::default()), - )?; - let integrity = session.block_on(session.sdk().integrity(IntegrityRequest::default()))?; + let status = session.block_on(session.sdk().storage_status(StorageStatusRequest::new()))?; + let integrity = session.block_on(session.sdk().integrity(IntegrityRequest::new()))?; let manifest = sdk_backup_manifest_preview(output, &status, &integrity); Ok(LocalBackupView { state: "dry_run".to_owned(), @@ -200,6 +190,26 @@ pub fn backup_preflight( }) } +pub fn restore( + config: &RuntimeConfig, + source: &Path, + destination: Option<&Path>, + overwrite: bool, + dry_run: bool, +) -> Result<LocalRestoreView, CliSdkAdapterError> { + let destination = destination + .map(Path::to_path_buf) + .unwrap_or_else(|| sdk_storage_root(config)); + ensure_safe_sdk_restore_destination(config, &destination)?; + let request = RestoreRequest::new(source) + .with_destination(destination) + .with_overwrite(overwrite) + .with_dry_run(dry_run); + let runtime = sdk_runtime()?; + let receipt = runtime.block_on(RadrootsSdk::restore(request))?; + sdk_restore_view(receipt, overwrite, dry_run) +} + pub fn export( config: &RuntimeConfig, format: LocalExportFormatArg, @@ -480,6 +490,40 @@ fn ensure_safe_sdk_backup_destination( Ok(()) } +fn ensure_safe_sdk_restore_destination( + config: &RuntimeConfig, + destination: &Path, +) -> Result<(), RuntimeError> { + let sdk_root = sdk_storage_root(config); + let sdk_event_store_path = sdk_root.join(SDK_EVENT_STORE_FILE); + let sdk_outbox_path = sdk_root.join(SDK_OUTBOX_FILE); + let forbidden_paths = [ + config.local.root.as_path(), + config.local.replica_db_path.as_path(), + sdk_event_store_path.as_path(), + sdk_outbox_path.as_path(), + ]; + if forbidden_paths + .iter() + .any(|forbidden| destination == *forbidden) + { + return Err(RuntimeError::Config(format!( + "restore destination {} would overwrite canonical runtime roots or store files", + destination.display() + ))); + } + if config.local.replica_db_path.starts_with(destination) + || config.local.backups_dir.starts_with(destination) + || config.local.exports_dir.starts_with(destination) + { + return Err(RuntimeError::Config(format!( + "restore destination {} must not contain CLI runtime state directories", + destination.display() + ))); + } + Ok(()) +} + fn sdk_backup_view(receipt: BackupReceipt) -> Result<LocalBackupView, CliSdkAdapterError> { let event_store_file = receipt.event_store_path.as_ref().map(display_path); let outbox_file = receipt.outbox_path.as_ref().map(display_path); @@ -506,6 +550,59 @@ fn sdk_backup_view(receipt: BackupReceipt) -> Result<LocalBackupView, CliSdkAdap }) } +fn sdk_restore_view( + receipt: RestoreReceipt, + overwrite: bool, + dry_run: bool, +) -> Result<LocalRestoreView, CliSdkAdapterError> { + let destination_paths = receipt.destination_paths.as_ref(); + let restored_paths = receipt.restored_paths.as_ref(); + Ok(LocalRestoreView { + state: sdk_restore_state_label(receipt.state).to_owned(), + source: SDK_CANONICAL_SOURCE.to_owned(), + restore_kind: SDK_BACKUP_KIND.to_owned(), + canonical_store: SDK_CANONICAL_STORE.to_owned(), + backup_source: display_path(&receipt.source), + destination: receipt + .destination + .as_ref() + .map(display_path) + .unwrap_or_default(), + event_store_file: display_path(&receipt.event_store_path), + outbox_file: display_path(&receipt.outbox_path), + manifest_file: display_path(&receipt.manifest_path), + destination_event_store_file: destination_paths + .map(|paths| display_path(&paths.event_store_path)), + destination_outbox_file: destination_paths.map(|paths| display_path(&paths.outbox_path)), + restored_event_store_file: restored_paths + .map(|paths| display_path(&paths.event_store_path)), + restored_outbox_file: restored_paths.map(|paths| display_path(&paths.outbox_path)), + manifest: json_value(&receipt.manifest)?, + verification: json_value(&receipt.verification)?, + overwrite, + dry_run, + reason: if dry_run { + Some("dry run requested; SDK canonical store was not restored".to_owned()) + } else { + None + }, + actions: if dry_run { + vec!["radroots store backup restore <backup-dir>".to_owned()] + } else { + Vec::new() + }, + }) +} + +fn sdk_restore_state_label(state: SdkRestoreState) -> &'static str { + match state { + SdkRestoreState::Validated => "validated", + SdkRestoreState::DryRun => "dry_run", + SdkRestoreState::Completed => "completed", + _ => "unknown", + } +} + fn sdk_backup_manifest_preview( output: &Path, status: &StorageStatusReceipt, diff --git a/src/view/runtime.rs b/src/view/runtime.rs @@ -3472,6 +3472,44 @@ impl LocalBackupView { } #[derive(Debug, Clone, Serialize)] +pub struct LocalRestoreView { + pub state: String, + pub source: String, + pub restore_kind: String, + pub canonical_store: String, + pub backup_source: String, + pub destination: String, + pub event_store_file: String, + pub outbox_file: String, + pub manifest_file: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub destination_event_store_file: Option<String>, + #[serde(skip_serializing_if = "Option::is_none")] + pub destination_outbox_file: Option<String>, + #[serde(skip_serializing_if = "Option::is_none")] + pub restored_event_store_file: Option<String>, + #[serde(skip_serializing_if = "Option::is_none")] + pub restored_outbox_file: Option<String>, + pub manifest: serde_json::Value, + pub verification: serde_json::Value, + pub overwrite: bool, + pub dry_run: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub reason: Option<String>, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub actions: Vec<String>, +} + +impl LocalRestoreView { + pub fn disposition(&self) -> CommandDisposition { + match self.state.as_str() { + "unconfigured" => CommandDisposition::Unconfigured, + _ => CommandDisposition::Success, + } + } +} + +#[derive(Debug, Clone, Serialize)] pub struct LocalExportView { pub state: String, pub source: String, diff --git a/tests/target_cli.rs b/tests/target_cli.rs @@ -3715,7 +3715,7 @@ fn store_export_dry_run_is_structured_unsupported() { } #[test] -fn store_backup_dry_run_preflights_initialized_store_without_writing_file() { +fn store_backup_and_restore_use_sdk_canonical_store() { let sandbox = RadrootsCliSandbox::new(); let sdk_root = sandbox.root().join("data/apps/cli/replica/sdk"); @@ -3817,6 +3817,10 @@ fn store_backup_dry_run_preflights_initialized_store_without_writing_file() { ); assert_eq!(backup["result"]["backup_kind"], "sdk_canonical"); assert_eq!(backup["result"]["canonical_store"], "sdk"); + assert_eq!( + backup["result"]["manifest"]["manifest_kind"], + "storage_backup" + ); assert!( backup["result"]["size_bytes"] .as_u64() @@ -3842,6 +3846,144 @@ fn store_backup_dry_run_preflights_initialized_store_without_writing_file() { .contains("data/apps/cli/replica/sdk/event_store.sqlite") ); assert!(!event_store_file.ends_with("replica.sqlite")); + + let restore_destination = sandbox.root().join("restored-sdk-store"); + let restore_destination_arg = restore_destination.to_string_lossy().to_string(); + let restore_dry_run = sandbox.json_success(&[ + "--format", + "json", + "--dry-run", + "store", + "backup", + "restore", + backup_destination, + "--destination", + restore_destination_arg.as_str(), + ]); + + assert_eq!(restore_dry_run["operation_id"], "store.backup.restore"); + assert_eq!(restore_dry_run["dry_run"], true); + assert_eq!(restore_dry_run["result"]["state"], "dry_run"); + assert_eq!( + restore_dry_run["result"]["source"], + "SDK canonical event store and outbox" + ); + assert_eq!(restore_dry_run["result"]["restore_kind"], "sdk_canonical"); + assert_eq!(restore_dry_run["result"]["canonical_store"], "sdk"); + assert_eq!( + restore_dry_run["result"]["backup_source"], + backup_destination + ); + assert_eq!( + restore_dry_run["result"]["destination"], + restore_destination_arg + ); + assert_eq!( + restore_dry_run["result"]["manifest"]["manifest_kind"], + "storage_backup" + ); + assert_eq!( + restore_dry_run["result"]["verification"]["event_store_ok"], + true + ); + assert_eq!(restore_dry_run["result"]["verification"]["outbox_ok"], true); + assert!(!restore_destination.exists()); + + let restored = sandbox.json_success(&[ + "--format", + "json", + "store", + "backup", + "restore", + backup_destination, + "--destination", + restore_destination_arg.as_str(), + ]); + + assert_eq!(restored["operation_id"], "store.backup.restore"); + assert_eq!(restored["result"]["state"], "completed"); + assert_eq!( + restored["result"]["source"], + "SDK canonical event store and outbox" + ); + assert_eq!(restored["result"]["restore_kind"], "sdk_canonical"); + assert_eq!(restored["result"]["canonical_store"], "sdk"); + assert!(restore_destination.join("event_store.sqlite").exists()); + assert!(restore_destination.join("outbox.sqlite").exists()); + assert_eq!( + restored["result"]["restored_event_store_file"], + restore_destination + .join("event_store.sqlite") + .to_string_lossy() + .to_string() + ); + assert_eq!( + restored["result"]["restored_outbox_file"], + restore_destination + .join("outbox.sqlite") + .to_string_lossy() + .to_string() + ); + + let unapproved_overwrite = sandbox.json_output(&[ + "--format", + "json", + "store", + "backup", + "restore", + backup_destination, + "--destination", + restore_destination_arg.as_str(), + "--overwrite", + ]); + assert!(!unapproved_overwrite.0.status.success()); + assert_eq!(unapproved_overwrite.0.status.code(), Some(6)); + assert_eq!( + unapproved_overwrite.1["operation_id"], + "store.backup.restore" + ); + assert_eq!( + unapproved_overwrite.1["errors"][0]["code"], + "approval_required" + ); + + let approved_overwrite = sandbox.json_success(&[ + "--format", + "json", + "--approval-token", + "restore-ok", + "store", + "backup", + "restore", + backup_destination, + "--destination", + restore_destination_arg.as_str(), + "--overwrite", + ]); + assert_eq!(approved_overwrite["operation_id"], "store.backup.restore"); + assert_eq!(approved_overwrite["result"]["state"], "completed"); + assert_eq!(approved_overwrite["result"]["overwrite"], true); + + let missing_backup = sandbox.root().join("missing-sdk-backup"); + let missing_backup_arg = missing_backup.to_string_lossy().to_string(); + let (missing_output, missing_value) = sandbox.json_output(&[ + "--format", + "json", + "store", + "backup", + "restore", + missing_backup_arg.as_str(), + "--destination", + sandbox + .root() + .join("missing-restore-destination") + .to_string_lossy() + .as_ref(), + ]); + assert!(!missing_output.status.success()); + assert_eq!(missing_value["operation_id"], "store.backup.restore"); + assert_eq!(missing_value["errors"][0]["code"], "io"); + assert_eq!(missing_value["errors"][0]["detail"]["class"], "storage"); } #[test]