myc

Self-custodial remote signer for Radroots apps
git clone https://radroots.dev/git/myc.git
Log | Files | Refs | README | LICENSE

commit 3fd36213bb5863d026b35b914e51a309dd821037
parent 86bea74eba9df4e512e5bd3494a635eb7492362b
Author: triesap <tyson@radroots.org>
Date:   Thu, 26 Mar 2026 16:51:20 +0000

status: expose persistence backend and schema state

Diffstat:
Msrc/app/mod.rs | 7+++++++
Msrc/app/runtime.rs | 27++++++++++++++++++++++++++-
Msrc/lib.rs | 8+++++---
Msrc/operability/mod.rs | 260+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Mtests/operability_cli.rs | 5+++++
Mtests/operability_e2e.rs | 88++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
6 files changed, 384 insertions(+), 11 deletions(-)

diff --git a/src/app/mod.rs b/src/app/mod.rs @@ -82,7 +82,10 @@ mod tests { "filesystem" ); assert_eq!(snapshot.user_identity_source.backend.as_str(), "filesystem"); + assert_eq!(snapshot.signer_state_backend.as_str(), "json_file"); assert!(snapshot.signer_state_path.ends_with("signer-state.json")); + assert_eq!(snapshot.runtime_audit_backend.as_str(), "jsonl_file"); + assert!(snapshot.runtime_audit_path.ends_with("operations.jsonl")); assert!(!snapshot.signer_identity_id.is_empty()); assert!(!snapshot.signer_public_key_hex.is_empty()); assert!(!snapshot.user_identity_id.is_empty()); @@ -98,6 +101,7 @@ mod tests { config.paths.signer_identity_path = temp.path().join("identity.json"); config.paths.user_identity_path = temp.path().join("user.json"); config.persistence.signer_state_backend = MycSignerStateBackend::Sqlite; + config.persistence.runtime_audit_backend = crate::config::MycRuntimeAuditBackend::Sqlite; write_test_identity( &config.paths.signer_identity_path, "1111111111111111111111111111111111111111111111111111111111111111", @@ -110,6 +114,9 @@ mod tests { let app = MycApp::bootstrap(config).expect("bootstrap"); let snapshot = app.snapshot(); + assert_eq!(snapshot.signer_state_backend.as_str(), "sqlite"); assert!(snapshot.signer_state_path.ends_with("signer-state.sqlite")); + assert_eq!(snapshot.runtime_audit_backend.as_str(), "sqlite"); + assert!(snapshot.runtime_audit_path.ends_with("operations.sqlite")); } } diff --git a/src/app/runtime.rs b/src/app/runtime.rs @@ -29,6 +29,7 @@ pub struct MycRuntimePaths { pub signer_identity_path: PathBuf, pub user_identity_path: PathBuf, pub signer_state_path: PathBuf, + pub runtime_audit_path: PathBuf, } #[derive(Debug, Clone, PartialEq, Eq, Serialize)] @@ -43,7 +44,10 @@ pub struct MycStartupSnapshot { pub user_identity_path: PathBuf, pub signer_identity_source: MycIdentitySourceSpec, pub user_identity_source: MycIdentitySourceSpec, + pub signer_state_backend: MycSignerStateBackend, pub signer_state_path: PathBuf, + pub runtime_audit_backend: MycRuntimeAuditBackend, + pub runtime_audit_path: PathBuf, pub signer_identity_id: String, pub signer_public_key_hex: String, pub user_identity_id: String, @@ -153,7 +157,10 @@ impl MycRuntime { user_identity_path: self.paths.user_identity_path.clone(), signer_identity_source: self.signer.signer_identity_source().clone(), user_identity_source: self.signer.user_identity_source().clone(), + signer_state_backend: self.config.persistence.signer_state_backend, signer_state_path: self.paths.signer_state_path.clone(), + runtime_audit_backend: self.config.persistence.runtime_audit_backend, + runtime_audit_path: self.paths.runtime_audit_path.clone(), signer_identity_id: signer_public.id.into_string(), signer_public_key_hex: signer_public.public_key_hex, user_identity_id: user_public.id.into_string(), @@ -185,7 +192,10 @@ impl MycRuntime { user_identity_backend = %snapshot.user_identity_source.backend.as_str(), signer_keyring_account_id = snapshot.signer_identity_source.keyring_account_id.as_deref().unwrap_or(""), user_keyring_account_id = snapshot.user_identity_source.keyring_account_id.as_deref().unwrap_or(""), + signer_state_backend = snapshot.signer_state_backend.as_str(), signer_state_path = %snapshot.signer_state_path.display(), + runtime_audit_backend = snapshot.runtime_audit_backend.as_str(), + runtime_audit_path = %snapshot.runtime_audit_path.display(), signer_identity_id = %snapshot.signer_identity_id, signer_public_key_hex = %snapshot.signer_public_key_hex, user_identity_id = %snapshot.user_identity_id, @@ -302,8 +312,19 @@ impl MycRuntimePaths { }) } + pub(crate) fn runtime_audit_path_for_backend( + audit_dir: &Path, + backend: MycRuntimeAuditBackend, + ) -> PathBuf { + audit_dir.join(match backend { + MycRuntimeAuditBackend::JsonlFile => "operations.jsonl", + MycRuntimeAuditBackend::Sqlite => "operations.sqlite", + }) + } + fn from_config(config: &MycConfig) -> Self { let state_dir = config.paths.state_dir.clone(); + let audit_dir = Self::audit_dir_for_state_dir(&state_dir); Self { signer_identity_path: config.paths.signer_identity_path.clone(), user_identity_path: config.paths.user_identity_path.clone(), @@ -311,7 +332,11 @@ impl MycRuntimePaths { &state_dir, config.persistence.signer_state_backend, ), - audit_dir: Self::audit_dir_for_state_dir(&state_dir), + runtime_audit_path: Self::runtime_audit_path_for_backend( + &audit_dir, + config.persistence.runtime_audit_backend, + ), + audit_dir, state_dir, } } diff --git a/src/lib.rs b/src/lib.rs @@ -44,9 +44,11 @@ pub use discovery::{ pub use error::MycError; pub use operability::{ MycAuditDecisionCounts, MycCustodyStatusOutput, MycDiscoveryStatusOutput, MycMetricsSnapshot, - MycOperationOutcomeCounts, MycRelayProbe, MycRelayProbeAvailability, MycRuntimeStatus, - MycStatusFullOutput, MycStatusSummaryOutput, MycTransportStatusOutput, collect_metrics, - collect_status_full, collect_status_summary, render_metrics_text, + MycOperationOutcomeCounts, MycPersistenceStatusOutput, MycRelayProbe, + MycRelayProbeAvailability, MycRuntimeAuditPersistenceStatusOutput, MycRuntimeStatus, + MycSignerStatePersistenceStatusOutput, MycSqliteSchemaStatusOutput, MycStatusFullOutput, + MycStatusSummaryOutput, MycTransportStatusOutput, collect_metrics, collect_status_full, + collect_status_summary, render_metrics_text, }; pub use persistence::{ MycPersistenceImportJsonToSqliteOutput, MycPersistenceImportSelection, diff --git a/src/operability/mod.rs b/src/operability/mod.rs @@ -1,6 +1,7 @@ pub mod server; use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; use std::time::Duration; use radroots_identity::RadrootsIdentity; @@ -8,12 +9,13 @@ use radroots_nostr::prelude::{ RadrootsNostrClient, RadrootsNostrRelayStatus, RadrootsNostrRelayUrl, }; use radroots_nostr_signer::prelude::RadrootsNostrSignerRequestDecision; -use serde::Serialize; +use radroots_sql_core::{SqlExecutor, SqliteExecutor}; +use serde::{Deserialize, Serialize}; use tokio::task::JoinSet; use crate::app::MycRuntime; use crate::audit::{MycOperationAuditKind, MycOperationAuditOutcome}; -use crate::config::MycTransportDeliveryPolicy; +use crate::config::{MycRuntimeAuditBackend, MycSignerStateBackend, MycTransportDeliveryPolicy}; use crate::custody::MycIdentityStatusOutput; use crate::discovery::MycDiscoveryContext; use crate::error::MycError; @@ -93,12 +95,52 @@ pub struct MycCustodyStatusOutput { } #[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct MycPersistenceStatusOutput { + pub signer_state: MycSignerStatePersistenceStatusOutput, + pub runtime_audit: MycRuntimeAuditPersistenceStatusOutput, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct MycSignerStatePersistenceStatusOutput { + pub backend: MycSignerStateBackend, + pub path: PathBuf, + pub exists: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub sqlite_schema: Option<MycSqliteSchemaStatusOutput>, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct MycRuntimeAuditPersistenceStatusOutput { + pub backend: MycRuntimeAuditBackend, + pub path: PathBuf, + pub exists: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub sqlite_schema: Option<MycSqliteSchemaStatusOutput>, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct MycSqliteSchemaStatusOutput { + pub ready: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub applied_migration_count: Option<usize>, + #[serde(skip_serializing_if = "Option::is_none")] + pub latest_migration: Option<String>, + #[serde(skip_serializing_if = "Option::is_none")] + pub journal_mode: Option<String>, + #[serde(skip_serializing_if = "Option::is_none")] + pub store_version: Option<u32>, + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option<String>, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] pub struct MycStatusFullOutput { pub status: MycRuntimeStatus, pub ready: bool, pub reasons: Vec<String>, pub startup: crate::app::MycStartupSnapshot, pub custody: MycCustodyStatusOutput, + pub persistence: MycPersistenceStatusOutput, pub transport: MycTransportStatusOutput, pub discovery: MycDiscoveryStatusOutput, } @@ -110,6 +152,7 @@ pub struct MycStatusSummaryOutput { pub reasons: Vec<String>, pub instance_name: String, pub custody: MycCustodyStatusOutput, + pub persistence: MycPersistenceStatusOutput, pub transport: MycTransportStatusOutput, pub discovery: MycDiscoveryStatusOutput, } @@ -159,13 +202,39 @@ struct MycCustodyStatusEvaluation { output: MycCustodyStatusOutput, } +#[derive(Debug, Clone, PartialEq, Eq)] +struct MycPersistenceStatusEvaluation { + output: MycPersistenceStatusOutput, + reasons: Vec<String>, + status: Option<MycRuntimeStatus>, +} + +#[derive(Debug, Deserialize)] +struct MycSqliteAppliedCountRow { + applied_count: u64, +} + +#[derive(Debug, Deserialize)] +struct MycSqliteNamedRow { + name: String, +} + +#[derive(Debug, Deserialize)] +struct MycSqliteJournalModeRow { + journal_mode: String, +} + +#[derive(Debug, Deserialize)] +struct MycSqliteStoreVersionRow { + store_version: u64, +} + pub async fn collect_status_full(runtime: &MycRuntime) -> Result<MycStatusFullOutput, MycError> { let snapshot = runtime.snapshot(); let custody = collect_custody_status(runtime)?; + let persistence = collect_persistence_status(runtime); let transport = collect_transport_status(runtime).await?; let discovery = collect_discovery_status(runtime).await?; - let mut reasons = transport.reasons; - reasons.extend(discovery.reasons); let mut status = combine_runtime_status( transport.output.status, if discovery.output.enabled { @@ -174,6 +243,12 @@ pub async fn collect_status_full(runtime: &MycRuntime) -> Result<MycStatusFullOu None }, ); + let mut reasons = transport.reasons; + reasons.extend(discovery.reasons); + if let Some(persistence_status) = persistence.status { + status = worse_runtime_status(status, persistence_status); + } + reasons.extend(persistence.reasons.clone()); if custody .output .discovery_app @@ -191,6 +266,7 @@ pub async fn collect_status_full(runtime: &MycRuntime) -> Result<MycStatusFullOu reasons, startup: snapshot, custody: custody.output, + persistence: persistence.output, transport: transport.output, discovery: discovery.output, }) @@ -206,6 +282,7 @@ pub async fn collect_status_summary( reasons: full.reasons, instance_name: full.startup.instance_name, custody: full.custody, + persistence: full.persistence, transport: MycTransportStatusOutput { relay_probes: Vec::new(), ..full.transport @@ -261,6 +338,69 @@ fn collect_custody_status(runtime: &MycRuntime) -> Result<MycCustodyStatusEvalua }) } +fn collect_persistence_status(runtime: &MycRuntime) -> MycPersistenceStatusEvaluation { + let signer_state_backend = runtime.config().persistence.signer_state_backend; + let runtime_audit_backend = runtime.config().persistence.runtime_audit_backend; + let signer_state = MycSignerStatePersistenceStatusOutput { + backend: signer_state_backend, + path: runtime.paths().signer_state_path.clone(), + exists: runtime.paths().signer_state_path.exists(), + sqlite_schema: match signer_state_backend { + MycSignerStateBackend::JsonFile => None, + MycSignerStateBackend::Sqlite => Some(inspect_signer_state_sqlite_schema( + runtime.paths().signer_state_path.as_path(), + )), + }, + }; + let runtime_audit = MycRuntimeAuditPersistenceStatusOutput { + backend: runtime_audit_backend, + path: runtime.paths().runtime_audit_path.clone(), + exists: runtime.paths().runtime_audit_path.exists(), + sqlite_schema: match runtime_audit_backend { + MycRuntimeAuditBackend::JsonlFile => None, + MycRuntimeAuditBackend::Sqlite => Some(inspect_runtime_audit_sqlite_schema( + runtime.paths().runtime_audit_path.as_path(), + )), + }, + }; + + let mut reasons = Vec::new(); + if signer_state + .sqlite_schema + .as_ref() + .is_some_and(|schema| !schema.ready) + { + reasons.push(format!( + "signer-state sqlite schema at {} is not ready", + signer_state.path.display() + )); + } + if runtime_audit + .sqlite_schema + .as_ref() + .is_some_and(|schema| !schema.ready) + { + reasons.push(format!( + "runtime-audit sqlite schema at {} is not ready", + runtime_audit.path.display() + )); + } + let status = if reasons.is_empty() { + None + } else { + Some(MycRuntimeStatus::Degraded) + }; + + MycPersistenceStatusEvaluation { + output: MycPersistenceStatusOutput { + signer_state, + runtime_audit, + }, + reasons, + status, + } +} + pub fn collect_metrics(runtime: &MycRuntime) -> Result<MycMetricsSnapshot, MycError> { let manager = runtime.signer_manager()?; let signer_request_audit = manager.list_audit_records()?; @@ -785,6 +925,93 @@ fn relay_status_label(status: RadrootsNostrRelayStatus) -> String { status.to_string().to_ascii_lowercase() } +fn inspect_signer_state_sqlite_schema(path: &Path) -> MycSqliteSchemaStatusOutput { + inspect_sqlite_schema( + path, + Some("SELECT store_version FROM signer_store_metadata WHERE singleton_id = 1"), + ) +} + +fn inspect_runtime_audit_sqlite_schema(path: &Path) -> MycSqliteSchemaStatusOutput { + inspect_sqlite_schema(path, None) +} + +fn inspect_sqlite_schema( + path: &Path, + store_version_sql: Option<&str>, +) -> MycSqliteSchemaStatusOutput { + let outcome = (|| -> Result<MycSqliteSchemaStatusOutput, String> { + if !path.exists() { + return Err("sqlite persistence file is missing".to_owned()); + } + let executor = SqliteExecutor::open(path).map_err(|error| error.to_string())?; + let applied_count = query_sqlite_rows::<MycSqliteAppliedCountRow>( + &executor, + "SELECT COUNT(*) AS applied_count FROM __migrations", + )? + .into_iter() + .next() + .ok_or_else(|| "sqlite migrations query returned no rows".to_owned())? + .applied_count; + let latest_migration = query_sqlite_rows::<MycSqliteNamedRow>( + &executor, + "SELECT name FROM __migrations ORDER BY rowid DESC LIMIT 1", + )? + .into_iter() + .next() + .map(|row| row.name); + let journal_mode = + query_sqlite_rows::<MycSqliteJournalModeRow>(&executor, "PRAGMA journal_mode")? + .into_iter() + .next() + .ok_or_else(|| "sqlite journal mode query returned no rows".to_owned())? + .journal_mode; + let store_version = if let Some(sql) = store_version_sql { + query_sqlite_rows::<MycSqliteStoreVersionRow>(&executor, sql)? + .into_iter() + .next() + .map(|row| { + u32::try_from(row.store_version) + .map_err(|_| "sqlite store_version is out of range".to_owned()) + }) + .transpose()? + } else { + None + }; + + Ok(MycSqliteSchemaStatusOutput { + ready: true, + applied_migration_count: Some(applied_count as usize), + latest_migration, + journal_mode: Some(journal_mode), + store_version, + error: None, + }) + })(); + + match outcome { + Ok(output) => output, + Err(error) => MycSqliteSchemaStatusOutput { + ready: false, + applied_migration_count: None, + latest_migration: None, + journal_mode: None, + store_version: None, + error: Some(error), + }, + } +} + +fn query_sqlite_rows<T>(executor: &SqliteExecutor, sql: &str) -> Result<Vec<T>, String> +where + T: for<'de> Deserialize<'de>, +{ + let raw = executor + .query_raw(sql, "[]") + .map_err(|error| error.to_string())?; + serde_json::from_str(&raw).map_err(|error| error.to_string()) +} + fn push_counter(lines: &mut Vec<String>, name: &str, value: usize) { lines.push(format!("{name} {value}")); } @@ -918,11 +1145,14 @@ fn push_labeled_counter_pair( #[cfg(test)] mod tests { use std::collections::BTreeMap; + use std::path::PathBuf; use super::{ - MycMetricsSnapshot, MycOperationOutcomeCounts, MycRuntimeStatus, render_metrics_text, - worse_runtime_status, + MycMetricsSnapshot, MycOperationOutcomeCounts, MycRuntimeStatus, + inspect_runtime_audit_sqlite_schema, render_metrics_text, worse_runtime_status, }; + use crate::app::MycRuntimePaths; + use crate::config::MycRuntimeAuditBackend; #[test] fn runtime_status_prefers_the_worst_state() { @@ -976,4 +1206,22 @@ mod tests { r#"myc_runtime_operation_kind_total{kind="listener_response_publish",outcome="succeeded"} 1"# )); } + + #[test] + fn runtime_audit_sqlite_schema_status_reports_missing_file() { + let temp = tempfile::tempdir().expect("tempdir"); + let status = inspect_runtime_audit_sqlite_schema( + MycRuntimePaths::runtime_audit_path_for_backend( + PathBuf::from(temp.path()).as_path(), + MycRuntimeAuditBackend::Sqlite, + ) + .as_path(), + ); + + assert!(!status.ready); + assert_eq!( + status.error.as_deref(), + Some("sqlite persistence file is missing") + ); + } } diff --git a/tests/operability_cli.rs b/tests/operability_cli.rs @@ -69,6 +69,11 @@ fn status_summary_command_emits_machine_readable_json() { assert_eq!(value["ready"], false); assert_eq!(value["custody"]["signer"]["backend"], "filesystem"); assert_eq!(value["custody"]["signer"]["resolved"], true); + assert_eq!(value["persistence"]["signer_state"]["backend"], "json_file"); + assert_eq!( + value["persistence"]["runtime_audit"]["backend"], + "jsonl_file" + ); assert_eq!(value["transport"]["enabled"], false); } diff --git a/tests/operability_e2e.rs b/tests/operability_e2e.rs @@ -2,7 +2,8 @@ use std::path::{Path, PathBuf}; use std::time::Duration; use myc::{ - MycConfig, MycRuntime, MycRuntimeStatus, MycTransportDeliveryPolicy, collect_status_full, + MycConfig, MycRuntime, MycRuntimeAuditBackend, MycRuntimeStatus, MycSignerStateBackend, + MycTransportDeliveryPolicy, collect_status_full, }; use radroots_identity::RadrootsIdentity; use tokio::net::TcpListener; @@ -192,3 +193,88 @@ async fn status_is_unready_when_all_policy_cannot_be_satisfied() -> TestResult<( assert_eq!(status.transport.required_available_relays, 2); Ok(()) } + +#[tokio::test] +async fn status_reports_sqlite_persistence_schema_state() -> TestResult<()> { + let runtime = build_runtime(|config| { + config.persistence.signer_state_backend = MycSignerStateBackend::Sqlite; + config.persistence.runtime_audit_backend = MycRuntimeAuditBackend::Sqlite; + }); + + let status = collect_status_full(&runtime).await?; + + assert_eq!( + status.persistence.signer_state.backend, + MycSignerStateBackend::Sqlite + ); + assert!(status.persistence.signer_state.exists); + assert_eq!( + status + .persistence + .signer_state + .sqlite_schema + .as_ref() + .expect("signer sqlite schema") + .applied_migration_count, + Some(1) + ); + assert_eq!( + status + .persistence + .signer_state + .sqlite_schema + .as_ref() + .expect("signer sqlite schema") + .journal_mode + .as_deref(), + Some("wal") + ); + assert_eq!( + status + .persistence + .signer_state + .sqlite_schema + .as_ref() + .expect("signer sqlite schema") + .store_version, + Some(1) + ); + assert_eq!( + status.persistence.runtime_audit.backend, + MycRuntimeAuditBackend::Sqlite + ); + assert!(status.persistence.runtime_audit.exists); + assert_eq!( + status + .persistence + .runtime_audit + .sqlite_schema + .as_ref() + .expect("audit sqlite schema") + .applied_migration_count, + Some(1) + ); + assert_eq!( + status + .persistence + .runtime_audit + .sqlite_schema + .as_ref() + .expect("audit sqlite schema") + .latest_migration + .as_deref(), + Some("0000_runtime_audit_init") + ); + assert_eq!( + status + .persistence + .runtime_audit + .sqlite_schema + .as_ref() + .expect("audit sqlite schema") + .journal_mode + .as_deref(), + Some("wal") + ); + Ok(()) +}