state_host.rs (18945B)
1 //! Sealed lifecycle boundary for the canonical Myc SQLite state catalog. 2 3 use core::fmt; 4 use std::{error::Error, path::Path}; 5 6 use radroots_service_sqlite::{ 7 BackupCreatedAtUnixMs, ExistingServiceDatabaseIntent, IntegrityCheckedAtUnixMs, 8 MigrationApplicationOutcome, MigrationAppliedAtUnixSeconds, MigrationBuildIdentity, OpenMode, 9 ServiceBackupManifest, ServiceSqliteApplicationId, ServiceSqliteConnectionOptions, 10 ServiceSqliteHost, ServiceSqliteInitializer, ServiceSqliteInitializerFuture, 11 ServiceSqliteIntegrityReport, ServiceSqlitePaths, initialize_database, 12 }; 13 14 use crate::{ 15 MYC_STATE_APPLICATION_ID, MYC_STATE_BASE_SCHEMA_VERSION, MYC_STATE_SCHEMA_VERSION, 16 MycConfigDocumentV1, MycRuntimeContext, MycStateMaintenanceError, MycStateMaintenanceErrorKind, 17 MycStateMetadata, MycStateRepository, myc_migration_catalog, myc_schema_catalog, 18 validate_myc_state_catalogs, 19 }; 20 21 /// Stable lifecycle mode of one opened Myc state host. 22 #[derive(Clone, Copy, Debug, PartialEq, Eq)] 23 pub enum MycStateHostMode { 24 ReadWriteExisting, 25 ReadOnlyInspection, 26 } 27 28 /// Stable source-free class for a Myc state-host lifecycle failure. 29 #[derive(Clone, Copy, Debug, PartialEq, Eq)] 30 pub enum MycStateHostErrorKind { 31 InvalidPaths, 32 InvalidEvidence, 33 Catalog, 34 Initialize, 35 ReadWriteOpen, 36 InspectionOpen, 37 Repository, 38 Close, 39 } 40 41 impl MycStateHostErrorKind { 42 /// Returns the stable machine-readable failure code. 43 #[must_use] 44 pub const fn code(self) -> &'static str { 45 match self { 46 Self::InvalidPaths => "state_paths_invalid", 47 Self::InvalidEvidence => "state_evidence_invalid", 48 Self::Catalog => "state_catalog_invalid", 49 Self::Initialize => "state_initialize_failed", 50 Self::ReadWriteOpen => "state_read_write_open_failed", 51 Self::InspectionOpen => "state_inspection_open_failed", 52 Self::Repository => "state_repository_failed", 53 Self::Close => "state_close_failed", 54 } 55 } 56 } 57 58 /// Redacted Myc state-host lifecycle failure. 59 #[derive(Clone, Copy, PartialEq, Eq)] 60 pub struct MycStateHostError { 61 kind: MycStateHostErrorKind, 62 } 63 64 impl MycStateHostError { 65 const fn new(kind: MycStateHostErrorKind) -> Self { 66 Self { kind } 67 } 68 69 /// Returns the stable failure class. 70 #[must_use] 71 pub const fn kind(self) -> MycStateHostErrorKind { 72 self.kind 73 } 74 75 /// Returns the stable machine-readable failure code. 76 #[must_use] 77 pub const fn code(self) -> &'static str { 78 self.kind.code() 79 } 80 } 81 82 impl fmt::Display for MycStateHostError { 83 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { 84 formatter.write_str(match self.kind { 85 MycStateHostErrorKind::InvalidPaths => "Myc state paths are invalid", 86 MycStateHostErrorKind::InvalidEvidence => "Myc state identity evidence is invalid", 87 MycStateHostErrorKind::Catalog => "Myc state catalogs are invalid", 88 MycStateHostErrorKind::Initialize => "Myc state initialization failed", 89 MycStateHostErrorKind::ReadWriteOpen => "Myc writable state could not be opened", 90 MycStateHostErrorKind::InspectionOpen => "Myc inspection state could not be opened", 91 MycStateHostErrorKind::Repository => "Myc state repository binding failed", 92 MycStateHostErrorKind::Close => "Myc state host could not be closed", 93 }) 94 } 95 } 96 97 impl fmt::Debug for MycStateHostError { 98 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { 99 formatter 100 .debug_struct("MycStateHostError") 101 .field("kind", &self.kind) 102 .finish() 103 } 104 } 105 106 impl Error for MycStateHostError {} 107 108 /// One opened Myc state catalog whose raw SQLite authority remains sealed. 109 /// 110 /// Callers cannot construct the wrapper or extract the shared host: 111 /// 112 /// ```compile_fail 113 /// use myc::{MycStateHost, MycStateHostMode}; 114 /// 115 /// let _ = MycStateHost { 116 /// host: todo!(), 117 /// mode: MycStateHostMode::ReadWriteExisting, 118 /// }; 119 /// ``` 120 /// 121 /// The wrapper intentionally exposes no transaction or connection escape: 122 /// 123 /// ```compile_fail 124 /// use myc::MycStateHost; 125 /// 126 /// fn bypass(host: &MycStateHost) { 127 /// let _ = host.transaction(|_| async { Ok::<_, ()>(()) }); 128 /// } 129 /// ``` 130 pub struct MycStateHost { 131 host: ServiceSqliteHost, 132 mode: MycStateHostMode, 133 metadata: MycStateMetadata, 134 } 135 136 impl MycStateHost { 137 /// Returns the lifecycle mode selected when this host was opened. 138 #[must_use] 139 pub const fn mode(&self) -> MycStateHostMode { 140 self.mode 141 } 142 143 /// Returns the immutable Myc metadata bound to this host session. 144 #[must_use] 145 pub const fn metadata(&self) -> &MycStateMetadata { 146 &self.metadata 147 } 148 149 /// Returns sealed typed repository access bound to this host and metadata. 150 #[must_use] 151 pub const fn repository(&self) -> MycStateRepository<'_> { 152 MycStateRepository::new( 153 &self.host, 154 &self.metadata, 155 matches!(self.mode, MycStateHostMode::ReadWriteExisting), 156 ) 157 } 158 159 /// Captures one governed point-in-time backup from a writable Myc host. 160 /// 161 /// The staging directory must be a new absolute path. The returned 162 /// manifest remains in memory and contains no protected identity material. 163 pub async fn capture_online_backup( 164 &self, 165 staging_directory: &Path, 166 created_at: BackupCreatedAtUnixMs, 167 ) -> Result<ServiceBackupManifest, MycStateMaintenanceError> { 168 if self.mode != MycStateHostMode::ReadWriteExisting { 169 return Err(MycStateMaintenanceError::new( 170 MycStateMaintenanceErrorKind::InvalidMode, 171 )); 172 } 173 self.host 174 .capture_online_backup(staging_directory, created_at) 175 .await 176 .map_err(MycStateMaintenanceError::from_sqlite) 177 } 178 179 /// Runs one explicit bounded integrity inspection over this host. 180 pub async fn inspect_integrity( 181 &self, 182 checked_at: IntegrityCheckedAtUnixMs, 183 ) -> Result<ServiceSqliteIntegrityReport, MycStateMaintenanceError> { 184 self.host 185 .inspect_integrity(checked_at) 186 .await 187 .map_err(MycStateMaintenanceError::from_sqlite) 188 } 189 190 /// Drains the shared host and explicitly releases retained authority. 191 pub async fn close(&self) -> Result<(), MycStateHostError> { 192 self.host 193 .close() 194 .await 195 .map_err(|_| MycStateHostError::new(MycStateHostErrorKind::Close)) 196 } 197 } 198 199 impl fmt::Debug for MycStateHost { 200 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { 201 formatter 202 .debug_struct("MycStateHost") 203 .field("mode", &self.mode) 204 .field("state", &"[sealed]") 205 .finish() 206 } 207 } 208 209 /// Creates a missing Myc catalog exactly once and releases initialization authority. 210 /// 211 /// This function never opens an existing database as initialization. The caller 212 /// injects the shared metadata evidence; the Myc metadata-binding layer owns 213 /// its exact application and configuration bindings. 214 pub async fn initialize_myc_state( 215 runtime: &MycRuntimeContext, 216 metadata: &MycStateMetadata, 217 applied_at: MigrationAppliedAtUnixSeconds, 218 build: &MigrationBuildIdentity, 219 ) -> Result<(), MycStateHostError> { 220 let paths = state_paths(runtime)?; 221 require_metadata(runtime, metadata)?; 222 require_migration_build(metadata, build)?; 223 let (migrations, schema) = catalogs()?; 224 provision_state_directory(runtime)?; 225 let authority = initialize_database( 226 &paths, 227 OpenMode::Initialize, 228 metadata.initial_database_metadata(), 229 &schema, 230 initialize_empty_catalog, 231 ) 232 .await 233 .map_err(|_| MycStateHostError::new(MycStateHostErrorKind::Initialize))?; 234 let identity = metadata.database_identity(); 235 let (host, outcome) = ServiceSqliteHost::open_initialized( 236 &paths, 237 &identity, 238 &migrations, 239 &schema, 240 ServiceSqliteConnectionOptions::reviewed(), 241 authority, 242 applied_at, 243 build, 244 &[], 245 ) 246 .await 247 .map_err(|_| MycStateHostError::new(MycStateHostErrorKind::Initialize))?; 248 let state = MycStateHost { 249 host, 250 mode: MycStateHostMode::ReadWriteExisting, 251 metadata: metadata.clone(), 252 }; 253 if !exact_initialization_outcome(outcome) { 254 return Err(close_error(&state.host, MycStateHostErrorKind::Catalog).await); 255 } 256 if state.repository().bind_or_verify().await.is_err() { 257 return Err(close_error(&state.host, MycStateHostErrorKind::Repository).await); 258 } 259 state 260 .close() 261 .await 262 .map_err(|_| MycStateHostError::new(MycStateHostErrorKind::Initialize)) 263 } 264 265 /// Opens an already initialized Myc catalog with exclusive writer authority. 266 /// 267 /// Missing state is never created. Migration time and build identity remain 268 /// explicit injected evidence even while the baseline migration catalog is 269 /// empty. 270 pub async fn open_myc_state_read_write( 271 runtime: &MycRuntimeContext, 272 metadata: &MycStateMetadata, 273 applied_at: MigrationAppliedAtUnixSeconds, 274 build: &MigrationBuildIdentity, 275 ) -> Result<MycStateHost, MycStateHostError> { 276 let paths = state_paths(runtime)?; 277 require_metadata(runtime, metadata)?; 278 require_migration_build(metadata, build)?; 279 let identity = metadata.database_identity(); 280 let (migrations, schema) = catalogs()?; 281 let (host, outcome) = ServiceSqliteHost::open_read_write_existing( 282 &paths, 283 &identity, 284 &migrations, 285 &schema, 286 ServiceSqliteConnectionOptions::reviewed(), 287 applied_at, 288 build, 289 &[], 290 ) 291 .await 292 .map_err(|_| MycStateHostError::new(MycStateHostErrorKind::ReadWriteOpen))?; 293 if !exact_existing_outcome(outcome) { 294 return Err(close_error(&host, MycStateHostErrorKind::Catalog).await); 295 } 296 let state = MycStateHost { 297 host, 298 mode: MycStateHostMode::ReadWriteExisting, 299 metadata: metadata.clone(), 300 }; 301 if state.repository().bind_or_verify().await.is_err() { 302 return Err(close_error(&state.host, MycStateHostErrorKind::Repository).await); 303 } 304 Ok(state) 305 } 306 307 /// Opens existing state from a sealed intent and discovers actual source metadata. 308 /// 309 /// The caller supplies configuration policy but no source generation or 310 /// creation-time guess. Those values are discovered from the same retained 311 /// authority that is returned in the host. 312 pub async fn open_myc_state_read_write_from_config( 313 runtime: &MycRuntimeContext, 314 configuration: &MycConfigDocumentV1, 315 applied_at: MigrationAppliedAtUnixSeconds, 316 build: &MigrationBuildIdentity, 317 ) -> Result<MycStateHost, MycStateHostError> { 318 let paths = state_paths(runtime)?; 319 let (migrations, schema) = catalogs()?; 320 let intent = existing_intent(&paths)?; 321 let (opened, outcome) = ServiceSqliteHost::open_read_write_existing_with_intent( 322 &paths, 323 &intent, 324 &migrations, 325 &schema, 326 ServiceSqliteConnectionOptions::reviewed(), 327 applied_at, 328 build, 329 &[], 330 ) 331 .await 332 .map_err(|_| MycStateHostError::new(MycStateHostErrorKind::ReadWriteOpen))?; 333 if !exact_existing_outcome(outcome) { 334 let (host, _) = opened.into_parts(); 335 return Err(close_error(&host, MycStateHostErrorKind::Catalog).await); 336 } 337 let (host, actual) = opened.into_parts(); 338 let metadata = match MycStateMetadata::from_existing_database(runtime, configuration, &actual) { 339 Ok(metadata) => metadata, 340 Err(_) => { 341 return Err(close_error(&host, MycStateHostErrorKind::InvalidEvidence).await); 342 } 343 }; 344 if require_migration_build(&metadata, build).is_err() { 345 return Err(close_error(&host, MycStateHostErrorKind::InvalidEvidence).await); 346 } 347 let state = MycStateHost { 348 host, 349 mode: MycStateHostMode::ReadWriteExisting, 350 metadata, 351 }; 352 if state.repository().bind_or_verify().await.is_err() { 353 return Err(close_error(&state.host, MycStateHostErrorKind::Repository).await); 354 } 355 Ok(state) 356 } 357 358 /// Opens an already initialized Myc catalog for immutable inspection. 359 pub async fn open_myc_state_inspection( 360 runtime: &MycRuntimeContext, 361 metadata: &MycStateMetadata, 362 ) -> Result<MycStateHost, MycStateHostError> { 363 let paths = state_paths(runtime)?; 364 require_metadata(runtime, metadata)?; 365 let identity = metadata.database_identity(); 366 let (migrations, schema) = catalogs()?; 367 let host = ServiceSqliteHost::open_read_only_inspection( 368 &paths, 369 &identity, 370 &migrations, 371 &schema, 372 ServiceSqliteConnectionOptions::reviewed(), 373 ) 374 .await 375 .map_err(|_| MycStateHostError::new(MycStateHostErrorKind::InspectionOpen))?; 376 let state = MycStateHost { 377 host, 378 mode: MycStateHostMode::ReadOnlyInspection, 379 metadata: metadata.clone(), 380 }; 381 if state.repository().verify_binding().await.is_err() { 382 return Err(close_error(&state.host, MycStateHostErrorKind::Repository).await); 383 } 384 Ok(state) 385 } 386 387 /// Opens existing inspection state from a sealed intent and actual metadata. 388 pub async fn open_myc_state_inspection_from_config( 389 runtime: &MycRuntimeContext, 390 configuration: &MycConfigDocumentV1, 391 ) -> Result<MycStateHost, MycStateHostError> { 392 let paths = state_paths(runtime)?; 393 let (migrations, schema) = catalogs()?; 394 let intent = existing_intent(&paths)?; 395 let opened = ServiceSqliteHost::open_read_only_inspection_with_intent( 396 &paths, 397 &intent, 398 &migrations, 399 &schema, 400 ServiceSqliteConnectionOptions::reviewed(), 401 ) 402 .await 403 .map_err(|_| MycStateHostError::new(MycStateHostErrorKind::InspectionOpen))?; 404 let (host, actual) = opened.into_parts(); 405 let metadata = match MycStateMetadata::from_existing_database(runtime, configuration, &actual) { 406 Ok(metadata) => metadata, 407 Err(_) => { 408 return Err(close_error(&host, MycStateHostErrorKind::InvalidEvidence).await); 409 } 410 }; 411 let state = MycStateHost { 412 host, 413 mode: MycStateHostMode::ReadOnlyInspection, 414 metadata, 415 }; 416 if state.repository().verify_binding().await.is_err() { 417 return Err(close_error(&state.host, MycStateHostErrorKind::Repository).await); 418 } 419 Ok(state) 420 } 421 422 async fn close_error( 423 host: &ServiceSqliteHost, 424 fallback: MycStateHostErrorKind, 425 ) -> MycStateHostError { 426 if host.close().await.is_err() { 427 MycStateHostError::new(MycStateHostErrorKind::Close) 428 } else { 429 MycStateHostError::new(fallback) 430 } 431 } 432 433 fn existing_intent( 434 paths: &ServiceSqlitePaths, 435 ) -> Result<ExistingServiceDatabaseIntent, MycStateHostError> { 436 let schema = core::num::NonZeroU32::new(MYC_STATE_SCHEMA_VERSION) 437 .ok_or_else(|| MycStateHostError::new(MycStateHostErrorKind::InvalidEvidence))?; 438 let application = ServiceSqliteApplicationId::new(MYC_STATE_APPLICATION_ID) 439 .map_err(|_| MycStateHostError::new(MycStateHostErrorKind::InvalidEvidence))?; 440 Ok(ExistingServiceDatabaseIntent::new( 441 paths, 442 schema, 443 application, 444 )) 445 } 446 447 pub(crate) fn state_paths( 448 runtime: &MycRuntimeContext, 449 ) -> Result<ServiceSqlitePaths, MycStateHostError> { 450 ServiceSqlitePaths::from_runtime_context(runtime.context()) 451 .map_err(|_| MycStateHostError::new(MycStateHostErrorKind::InvalidPaths)) 452 } 453 454 fn provision_state_directory(runtime: &MycRuntimeContext) -> Result<(), MycStateHostError> { 455 runtime 456 .context() 457 .state_directory_plan() 458 .and_then(|plan| plan.provision()) 459 .map_err(|_| MycStateHostError::new(MycStateHostErrorKind::Initialize)) 460 } 461 462 pub(crate) fn require_metadata( 463 runtime: &MycRuntimeContext, 464 metadata: &MycStateMetadata, 465 ) -> Result<(), MycStateHostError> { 466 let database = metadata.initial_database_metadata(); 467 let identity = metadata.database_identity(); 468 let matches = metadata.matches_runtime(runtime) 469 && database.service() == runtime.context().service() 470 && database.instance() == runtime.context().instance() 471 && database.state_schema_version().get() == MYC_STATE_BASE_SCHEMA_VERSION 472 && identity.service() == runtime.context().service() 473 && identity.instance() == runtime.context().instance() 474 && identity.supported_state_schema_version().get() == MYC_STATE_SCHEMA_VERSION; 475 matches 476 .then_some(()) 477 .ok_or_else(|| MycStateHostError::new(MycStateHostErrorKind::InvalidEvidence)) 478 } 479 480 fn require_migration_build( 481 metadata: &MycStateMetadata, 482 build: &MigrationBuildIdentity, 483 ) -> Result<(), MycStateHostError> { 484 let versions = metadata.policy_versions(); 485 let matches = build.config_contract_version() == versions.configuration() 486 && build.state_contract_version() == versions.state() 487 && build.admin_contract_version() == versions.operator() 488 && build.status_contract_version() == versions.status(); 489 matches 490 .then_some(()) 491 .ok_or_else(|| MycStateHostError::new(MycStateHostErrorKind::InvalidEvidence)) 492 } 493 494 fn exact_initialization_outcome(outcome: MigrationApplicationOutcome) -> bool { 495 outcome.initial_version() == MYC_STATE_BASE_SCHEMA_VERSION 496 && outcome.final_version() == MYC_STATE_SCHEMA_VERSION 497 && outcome.applied_count() == 11 498 } 499 500 fn exact_existing_outcome(outcome: MigrationApplicationOutcome) -> bool { 501 outcome.final_version() == MYC_STATE_SCHEMA_VERSION 502 && matches!( 503 (outcome.initial_version(), outcome.applied_count()), 504 (MYC_STATE_BASE_SCHEMA_VERSION, 11) 505 | (2, 10) 506 | (3, 9) 507 | (4, 8) 508 | (5, 7) 509 | (6, 6) 510 | (7, 5) 511 | (8, 4) 512 | (9, 3) 513 | (10, 2) 514 | (11, 1) 515 | (MYC_STATE_SCHEMA_VERSION, 0) 516 ) 517 } 518 519 pub(crate) fn catalogs() -> Result< 520 ( 521 radroots_service_sqlite::MigrationCatalog, 522 radroots_service_sqlite::SchemaCatalog, 523 ), 524 MycStateHostError, 525 > { 526 let migrations = myc_migration_catalog() 527 .map_err(|_| MycStateHostError::new(MycStateHostErrorKind::Catalog))?; 528 let schema = 529 myc_schema_catalog().map_err(|_| MycStateHostError::new(MycStateHostErrorKind::Catalog))?; 530 validate_myc_state_catalogs(&migrations, &schema) 531 .map_err(|_| MycStateHostError::new(MycStateHostErrorKind::Catalog))?; 532 Ok((migrations, schema)) 533 } 534 535 fn initialize_empty_catalog<'a>( 536 _initializer: &'a mut ServiceSqliteInitializer<'_>, 537 ) -> ServiceSqliteInitializerFuture<'a, core::convert::Infallible> { 538 Box::pin(async { Ok(()) }) 539 }