commit a5eec5bd9ecbffcc462ac0fa9c395d380cb00b15
parent ca8e5db0964e411c0d5bb59ae6b7bdbffdc4401a
Author: triesap <tyson@radroots.org>
Date: Mon, 27 Apr 2026 10:30:54 +0000
cli: harden core dry run preflight
- preflight workspace and store initialization without creating local files
- validate account create and selection clear dry runs without account mutation
- keep runtime lifecycle dry runs on inspection paths
- verify target cli and signer runtime mode integration tests
Diffstat:
5 files changed, 161 insertions(+), 9 deletions(-)
diff --git a/src/operation_core.rs b/src/operation_core.rs
@@ -22,7 +22,8 @@ use crate::runtime::accounts::{
account_resolution_view, account_summary_view, clear_default_account,
create_or_migrate_default_account, import_public_identity, preview_account_removal,
preview_public_identity_import, remove_account, resolve_account_resolution,
- resolve_account_selector, select_account, snapshot, unresolved_account_reason,
+ resolve_account_selector, secret_backend_status, select_account, snapshot,
+ unresolved_account_reason,
};
use crate::runtime::config::RuntimeConfig;
use crate::runtime::logging::LoggingState;
@@ -47,11 +48,11 @@ impl OperationService<WorkspaceInitRequest> for CoreOperationService<'_> {
request: OperationRequest<WorkspaceInitRequest>,
) -> Result<OperationResult<Self::Result>, OperationAdapterError> {
if request.context.dry_run {
+ let local = map_runtime(crate::runtime::local::init_preflight(self.config))?;
return json_operation_result::<WorkspaceInitResult>(json!({
- "state": "dry_run",
+ "state": local.state,
"profile": self.config.paths.profile,
- "local_root": self.config.local.root.display().to_string(),
- "replica_db_path": self.config.local.replica_db_path.display().to_string(),
+ "local": local,
}));
}
@@ -195,9 +196,24 @@ impl OperationService<AccountCreateRequest> for CoreOperationService<'_> {
request: OperationRequest<AccountCreateRequest>,
) -> Result<OperationResult<Self::Result>, OperationAdapterError> {
if request.context.dry_run {
+ let secret_backend = secret_backend_status(self.config);
+ if secret_backend.state != "ready" {
+ return Err(OperationAdapterError::OperationUnavailable {
+ operation_id: request.operation_id().to_owned(),
+ message: secret_backend
+ .reason
+ .unwrap_or_else(|| "account secret backend is not available".to_owned()),
+ });
+ }
return json_operation_result::<AccountCreateResult>(json!({
"state": "dry_run",
"store_path": self.config.account.store_path.display().to_string(),
+ "secrets_dir": self.config.account.secrets_dir.display().to_string(),
+ "secret_backend": {
+ "state": secret_backend.state,
+ "active_backend": secret_backend.active_backend,
+ "used_fallback": secret_backend.used_fallback,
+ },
}));
}
@@ -390,8 +406,12 @@ impl OperationService<AccountSelectionClearRequest> for CoreOperationService<'_>
request: OperationRequest<AccountSelectionClearRequest>,
) -> Result<OperationResult<Self::Result>, OperationAdapterError> {
if request.context.dry_run {
+ let resolution = map_runtime(resolve_account_resolution(self.config))?;
+ let account_snapshot = map_runtime(snapshot(self.config))?;
return json_operation_result::<AccountSelectionClearResult>(json!({
"state": "dry_run",
+ "cleared_account": resolution.default_account.as_ref().map(account_summary_view),
+ "remaining_account_count": account_snapshot.accounts.len(),
}));
}
@@ -412,10 +432,8 @@ impl OperationService<StoreInitRequest> for CoreOperationService<'_> {
request: OperationRequest<StoreInitRequest>,
) -> Result<OperationResult<Self::Result>, OperationAdapterError> {
if request.context.dry_run {
- return json_operation_result::<StoreInitResult>(json!({
- "state": "dry_run",
- "path": self.config.local.replica_db_path.display().to_string(),
- }));
+ let view = map_runtime(crate::runtime::local::init_preflight(self.config))?;
+ return serialized_operation_result::<StoreInitResult, _>(&view);
}
let view = map_runtime(crate::runtime::local::init(self.config))?;
diff --git a/src/operation_runtime.rs b/src/operation_runtime.rs
@@ -307,6 +307,7 @@ where
target.runtime_id.as_str(),
target.instance_id.as_deref(),
action,
+ request.context.dry_run,
))?;
serialized_operation_result::<R, _>(&inspection.view)
}
diff --git a/src/runtime/local.rs b/src/runtime/local.rs
@@ -40,6 +40,33 @@ pub fn init(config: &RuntimeConfig) -> Result<LocalInitView, RuntimeError> {
})
}
+pub fn init_preflight(config: &RuntimeConfig) -> Result<LocalInitView, RuntimeError> {
+ validate_local_roots(config)?;
+ if config.local.replica_db_path.exists() {
+ let executor = SqliteExecutor::open(&config.local.replica_db_path)?;
+ let manifest = export_manifest(&executor)?;
+ return Ok(LocalInitView {
+ state: "ready".to_owned(),
+ source: LOCAL_SOURCE.to_owned(),
+ local_root: config.local.root.display().to_string(),
+ replica_db: "ready".to_owned(),
+ path: config.local.replica_db_path.display().to_string(),
+ replica_db_version: manifest.replica_db_version,
+ backup_format_version: manifest.backup_format_version,
+ });
+ }
+
+ Ok(LocalInitView {
+ state: "dry_run".to_owned(),
+ source: LOCAL_SOURCE.to_owned(),
+ local_root: config.local.root.display().to_string(),
+ replica_db: "missing".to_owned(),
+ path: config.local.replica_db_path.display().to_string(),
+ replica_db_version: String::new(),
+ backup_format_version: String::new(),
+ })
+}
+
pub fn status(config: &RuntimeConfig) -> Result<LocalStatusView, RuntimeError> {
if !config.local.replica_db_path.exists() {
return Ok(LocalStatusView {
@@ -239,6 +266,34 @@ fn ensure_local_roots(config: &RuntimeConfig) -> Result<(), RuntimeError> {
Ok(())
}
+fn validate_local_roots(config: &RuntimeConfig) -> Result<(), RuntimeError> {
+ validate_directory_target(&config.local.root)?;
+ validate_directory_target(&config.local.backups_dir)?;
+ validate_directory_target(&config.local.exports_dir)?;
+ Ok(())
+}
+
+fn validate_directory_target(path: &Path) -> Result<(), RuntimeError> {
+ let mut candidate = path.to_path_buf();
+ loop {
+ if candidate.exists() {
+ if candidate.is_dir() {
+ return Ok(());
+ }
+ return Err(RuntimeError::Config(format!(
+ "path {} is not a directory",
+ candidate.display()
+ )));
+ }
+ if !candidate.pop() {
+ return Err(RuntimeError::Config(format!(
+ "path {} has no existing parent directory",
+ path.display()
+ )));
+ }
+ }
+}
+
fn missing_backup_view(output: &Path) -> LocalBackupView {
LocalBackupView {
state: "unconfigured".to_owned(),
diff --git a/src/runtime/management.rs b/src/runtime/management.rs
@@ -160,10 +160,11 @@ pub fn inspect_action(
runtime_id: &str,
instance_id: Option<&str>,
action: RuntimeLifecycleAction,
+ dry_run: bool,
) -> Result<RuntimeInspection<RuntimeActionView>, RuntimeError> {
let mut context = load_management_context(config)?;
let target = resolve_runtime_target(&context, runtime_id, instance_id);
- if target.runtime_group == RuntimeGroup::ActiveManagedTarget {
+ if target.runtime_group == RuntimeGroup::ActiveManagedTarget && !dry_run {
return execute_action(config, &mut context, target, action);
}
let inspection = inspect_target_action(&target, action, None);
diff --git a/tests/target_cli.rs b/tests/target_cli.rs
@@ -542,6 +542,83 @@ fn store_backup_dry_run_preflights_initialized_store_without_writing_file() {
}
#[test]
+fn core_account_store_dry_runs_preflight_without_mutating_local_state() {
+ let sandbox = RadrootsCliSandbox::new();
+
+ let workspace = sandbox.json_success(&["--format", "json", "--dry-run", "workspace", "init"]);
+ let workspace_db = workspace["result"]["local"]["path"]
+ .as_str()
+ .expect("workspace db path");
+ assert_eq!(workspace["operation_id"], "workspace.init");
+ assert_eq!(workspace["dry_run"], true);
+ assert_eq!(workspace["result"]["state"], "dry_run");
+ assert_eq!(workspace["result"]["local"]["replica_db"], "missing");
+ assert!(!Path::new(workspace_db).exists());
+
+ let store = sandbox.json_success(&["--format", "json", "--dry-run", "store", "init"]);
+ let store_db = store["result"]["path"].as_str().expect("store db path");
+ assert_eq!(store["operation_id"], "store.init");
+ assert_eq!(store["dry_run"], true);
+ assert_eq!(store["result"]["state"], "dry_run");
+ assert_eq!(store["result"]["replica_db"], "missing");
+ assert!(!Path::new(store_db).exists());
+
+ let account_create =
+ sandbox.json_success(&["--format", "json", "--dry-run", "account", "create"]);
+ assert_eq!(account_create["operation_id"], "account.create");
+ assert_eq!(account_create["dry_run"], true);
+ assert_eq!(account_create["result"]["state"], "dry_run");
+ assert_eq!(account_create["result"]["secret_backend"]["state"], "ready");
+
+ let account_list = sandbox.json_success(&["--format", "json", "account", "list"]);
+ assert_eq!(account_list["result"]["count"], 0);
+
+ let created = sandbox.json_success(&["--format", "json", "account", "create"]);
+ let account_id = created["result"]["account"]["id"]
+ .as_str()
+ .expect("account id");
+ let clear = sandbox.json_success(&[
+ "--format",
+ "json",
+ "--dry-run",
+ "account",
+ "selection",
+ "clear",
+ ]);
+ assert_eq!(clear["operation_id"], "account.selection.clear");
+ assert_eq!(clear["result"]["state"], "dry_run");
+ assert_eq!(clear["result"]["cleared_account"]["id"], account_id);
+ assert_eq!(clear["result"]["remaining_account_count"], 1);
+
+ let selection = sandbox.json_success(&["--format", "json", "account", "selection", "get"]);
+ assert_eq!(
+ selection["result"]["account_resolution"]["default_account"]["id"],
+ account_id
+ );
+}
+
+#[test]
+fn runtime_lifecycle_dry_runs_inspect_without_changing_runtime_status() {
+ let sandbox = RadrootsCliSandbox::new();
+ let before = sandbox.json_success(&["--format", "json", "runtime", "status", "get"]);
+
+ for (command, operation_id, action) in [
+ ("start", "runtime.start", "start"),
+ ("stop", "runtime.stop", "stop"),
+ ("restart", "runtime.restart", "restart"),
+ ] {
+ let value = sandbox.json_success(&["--format", "json", "--dry-run", "runtime", command]);
+ assert_eq!(value["operation_id"], operation_id);
+ assert_eq!(value["dry_run"], true);
+ assert_eq!(value["result"]["action"], action);
+ assert_eq!(value["result"]["runtime_id"], "radrootsd");
+ }
+
+ let after = sandbox.json_success(&["--format", "json", "runtime", "status", "get"]);
+ assert_eq!(after["result"], before["result"]);
+}
+
+#[test]
fn required_approval_token_rejects_absent_empty_and_whitespace_values() {
let sandbox = RadrootsCliSandbox::new();
let public_identity = identity_public(61);