state_config.rs (23757B)
1 //! Offline append-only configuration-binding lifecycle. 2 3 use core::fmt; 4 use std::{ 5 collections::{BTreeMap, BTreeSet}, 6 error::Error, 7 }; 8 9 use radroots_service_sqlite::{ 10 MigrationAppliedAtUnixSeconds, MigrationBuildIdentity, ServiceSqliteTransaction, 11 ServiceSqliteTransactionError, ServiceSqliteTransactionErrorKind, 12 }; 13 use serde_json::Value; 14 use sqlx::{QueryBuilder, Row, Sqlite}; 15 16 use crate::{ 17 MYC_PROVIDER_CONTRACT_VERSION, MycConfigDocumentV1, MycStateRepository, 18 state_repository::{ 19 PersistedMetadata, RepositoryOperationError, read_latest_config_binding, 20 require_expected_metadata, 21 }, 22 }; 23 24 /// Maximum number of immutable configuration generations retained by one instance. 25 pub const MYC_CONFIG_BINDING_MAX_GENERATIONS: u16 = 1024; 26 27 const READ_LATEST_HEADER_SQL: &str = r#"SELECT generation, applied_at_unix_s 28 FROM myc_config_bindings 29 ORDER BY generation DESC 30 LIMIT 1"#; 31 32 const RELAY_HAS_NONTERMINAL_JOB_SQL: &str = r#"SELECT EXISTS ( 33 SELECT 1 34 FROM delivery_targets AS target 35 JOIN delivery_jobs AS job ON job.job_id = target.job_id 36 WHERE target.relay_id = ? AND job.status IN ('pending', 'active') 37 LIMIT 1 38 ) AS is_blocked"#; 39 40 const REVOKE_ACTIVE_CONNECTIONS_SQL: &str = r#"UPDATE connections 41 SET status = 'expired', updated_at_unix_ms = MAX(updated_at_unix_ms, ?), 42 authorized_until_unix_ms = NULL 43 WHERE status = 'active'"#; 44 45 const DENY_PENDING_CONNECTIONS_SQL: &str = r#"UPDATE connections 46 SET status = 'denied', updated_at_unix_ms = MAX(updated_at_unix_ms, ?), 47 authorized_until_unix_ms = NULL 48 WHERE status = 'pending'"#; 49 50 const EXPIRE_ALL_PENDING_CHALLENGES_SQL: &str = r#"UPDATE connection_auth_challenges 51 SET state = 'expired', 52 resolved_at_unix_ms = MAX(issued_at_unix_ms, ?) 53 WHERE state = 'pending'"#; 54 55 const INSERT_CONFIG_BINDING_SQL: &str = r#"INSERT INTO myc_config_bindings ( 56 generation, normalized_config_sha256, transport_public_key, user_public_key, 57 discovery_public_key, config_contract_version, state_contract_version, 58 operator_contract_version, status_contract_version, applied_at_unix_s, 59 service_version, service_commit, lib_revision, rust_version, target, 60 feature_profile, provider_contract_version 61 ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"#; 62 63 /// Stable offline configuration-application failure classes. 64 #[derive(Clone, Copy, Debug, PartialEq, Eq)] 65 pub enum MycConfigApplyErrorKind { 66 InvalidMode, 67 InvalidInput, 68 Binding, 69 PolicyConflict, 70 ResourceExhausted, 71 Transaction, 72 CommitOutcomeUnknown, 73 } 74 75 impl MycConfigApplyErrorKind { 76 /// Returns the stable machine-readable failure code. 77 #[must_use] 78 pub const fn code(self) -> &'static str { 79 match self { 80 Self::InvalidMode => "config_apply_mode_invalid", 81 Self::InvalidInput => "config_apply_input_invalid", 82 Self::Binding => "config_apply_binding_invalid", 83 Self::PolicyConflict => "config_apply_policy_conflict", 84 Self::ResourceExhausted => "resource_exhausted", 85 Self::Transaction => "config_apply_transaction_failed", 86 Self::CommitOutcomeUnknown => "config_apply_commit_outcome_unknown", 87 } 88 } 89 } 90 91 /// Source-free offline configuration-application failure. 92 #[derive(Clone, Copy, PartialEq, Eq)] 93 pub struct MycConfigApplyError { 94 kind: MycConfigApplyErrorKind, 95 } 96 97 impl MycConfigApplyError { 98 const fn new(kind: MycConfigApplyErrorKind) -> Self { 99 Self { kind } 100 } 101 102 /// Returns the stable failure class. 103 #[must_use] 104 pub const fn kind(self) -> MycConfigApplyErrorKind { 105 self.kind 106 } 107 108 /// Returns the stable machine-readable failure code. 109 #[must_use] 110 pub const fn code(self) -> &'static str { 111 self.kind.code() 112 } 113 } 114 115 impl fmt::Display for MycConfigApplyError { 116 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { 117 formatter.write_str(match self.kind { 118 MycConfigApplyErrorKind::InvalidMode => { 119 "Myc configuration apply requires an offline writable state host" 120 } 121 MycConfigApplyErrorKind::InvalidInput => "Myc configuration apply evidence is invalid", 122 MycConfigApplyErrorKind::Binding => "Myc configuration history binding is invalid", 123 MycConfigApplyErrorKind::PolicyConflict => { 124 "Myc configuration change conflicts with retained work" 125 } 126 MycConfigApplyErrorKind::ResourceExhausted => { 127 "Myc configuration history capacity is exhausted" 128 } 129 MycConfigApplyErrorKind::Transaction => "Myc configuration apply transaction failed", 130 MycConfigApplyErrorKind::CommitOutcomeUnknown => { 131 "Myc configuration apply commit outcome is unknown" 132 } 133 }) 134 } 135 } 136 137 impl fmt::Debug for MycConfigApplyError { 138 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { 139 formatter 140 .debug_struct("MycConfigApplyError") 141 .field("kind", &self.kind) 142 .finish() 143 } 144 } 145 146 impl Error for MycConfigApplyError {} 147 148 /// Committed immutable configuration-generation evidence. 149 #[derive(Clone, Copy, PartialEq, Eq)] 150 pub struct MycConfigApplyOutcome { 151 generation: u16, 152 revoked_connections: u64, 153 revoked_challenges: u64, 154 } 155 156 impl MycConfigApplyOutcome { 157 /// Returns the committed consecutive configuration generation. 158 #[must_use] 159 pub const fn generation(self) -> u16 { 160 self.generation 161 } 162 163 /// Returns the number of connection records revoked by the apply. 164 #[must_use] 165 pub const fn revoked_connection_count(self) -> u64 { 166 self.revoked_connections 167 } 168 169 /// Returns the number of pending challenges revoked by the apply. 170 #[must_use] 171 pub const fn revoked_challenge_count(self) -> u64 { 172 self.revoked_challenges 173 } 174 } 175 176 impl fmt::Debug for MycConfigApplyOutcome { 177 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { 178 formatter 179 .debug_struct("MycConfigApplyOutcome") 180 .field("generation", &self.generation) 181 .field("revoked_connections", &self.revoked_connections) 182 .field("revoked_challenges", &self.revoked_challenges) 183 .finish() 184 } 185 } 186 187 impl MycStateRepository<'_> { 188 #[cfg(any(target_os = "linux", target_os = "macos"))] 189 pub(crate) async fn current_configuration_generation( 190 &self, 191 ) -> Result<u16, MycConfigApplyError> { 192 self.host() 193 .transaction(|transaction| { 194 Box::pin(async move { 195 read_latest_header(transaction) 196 .await 197 .map(|(generation, _)| generation) 198 }) 199 }) 200 .await 201 .map_err(map_apply_transaction_error) 202 } 203 204 /// Atomically applies one complete candidate configuration while offline. 205 /// 206 /// The current document must match the latest durable binding. The candidate 207 /// is already structurally and semantically admitted by its sealed type. 208 /// Unsafe relay changes are rejected while nonterminal jobs retain the relay. 209 pub async fn apply_configuration( 210 &self, 211 current: &MycConfigDocumentV1, 212 candidate: &MycConfigDocumentV1, 213 applied_at: MigrationAppliedAtUnixSeconds, 214 build: &MigrationBuildIdentity, 215 ) -> Result<MycConfigApplyOutcome, MycConfigApplyError> { 216 if !self.is_writable() { 217 return Err(MycConfigApplyError::new( 218 MycConfigApplyErrorKind::InvalidMode, 219 )); 220 } 221 if current.profile() != candidate.profile() 222 || candidate.provider_contract().bindings().is_empty() 223 || !valid_build(candidate, build) 224 { 225 return Err(MycConfigApplyError::new( 226 MycConfigApplyErrorKind::InvalidInput, 227 )); 228 } 229 let current_binding = PersistedMetadata::from_configuration(current) 230 .map_err(|_| MycConfigApplyError::new(MycConfigApplyErrorKind::InvalidInput))?; 231 if current_binding != PersistedMetadata::from(self.expected()) { 232 return Err(MycConfigApplyError::new(MycConfigApplyErrorKind::Binding)); 233 } 234 let candidate_binding = PersistedMetadata::from_configuration(candidate) 235 .map_err(|_| MycConfigApplyError::new(MycConfigApplyErrorKind::InvalidInput))?; 236 let exact_replay = candidate_binding == current_binding; 237 let changed_relays = changed_existing_relays(current, candidate)?; 238 let candidate_permissions = permission_ceiling(candidate)?; 239 let permissions_narrowed = permission_ceiling(current)? 240 .iter() 241 .any(|permission| !candidate_permissions.contains(permission)); 242 let identities_changed = identities_changed(¤t_binding, &candidate_binding); 243 let applied_at_unix_s = applied_at.get(); 244 let applied_at_unix_ms = 245 i64::try_from(applied_at_unix_s.saturating_mul(1000)).unwrap_or(i64::MAX); 246 let build = build.clone(); 247 248 self.host() 249 .transaction(move |transaction| { 250 Box::pin(async move { 251 require_expected_metadata(transaction, ¤t_binding).await?; 252 let (generation, latest_applied_at) = read_latest_header(transaction).await?; 253 if exact_replay { 254 return Ok(MycConfigApplyOutcome { 255 generation, 256 revoked_connections: 0, 257 revoked_challenges: 0, 258 }); 259 } 260 if generation >= MYC_CONFIG_BINDING_MAX_GENERATIONS { 261 return Err(ConfigOperationError::ResourceExhausted); 262 } 263 if applied_at_unix_s < latest_applied_at { 264 return Err(ConfigOperationError::InvalidInput); 265 } 266 for relay_id in &changed_relays { 267 if relay_has_nonterminal_job(transaction, relay_id).await? { 268 return Err(ConfigOperationError::PolicyConflict); 269 } 270 } 271 let (revoked_connections, revoked_challenges) = if identities_changed { 272 revoke_for_identity_change(transaction, applied_at_unix_ms).await? 273 } else if permissions_narrowed { 274 revoke_for_permission_narrowing( 275 transaction, 276 applied_at_unix_ms, 277 &candidate_permissions, 278 ) 279 .await? 280 } else { 281 (0, 0) 282 }; 283 let next_generation = generation + 1; 284 insert_binding( 285 transaction, 286 next_generation, 287 &candidate_binding, 288 applied_at_unix_s, 289 &build, 290 ) 291 .await?; 292 match read_latest_config_binding(transaction).await? { 293 Some(actual) if actual == candidate_binding => {} 294 Some(_) | None => return Err(ConfigOperationError::Binding), 295 } 296 let (actual_generation, actual_applied_at) = 297 read_latest_header(transaction).await?; 298 if actual_generation != next_generation 299 || actual_applied_at != applied_at_unix_s 300 { 301 return Err(ConfigOperationError::Binding); 302 } 303 Ok(MycConfigApplyOutcome { 304 generation: next_generation, 305 revoked_connections, 306 revoked_challenges, 307 }) 308 }) 309 }) 310 .await 311 .map_err(map_apply_transaction_error) 312 } 313 } 314 315 #[derive(Clone, Copy, Debug, PartialEq, Eq)] 316 enum ConfigOperationError { 317 InvalidInput, 318 Binding, 319 PolicyConflict, 320 ResourceExhausted, 321 Storage, 322 } 323 324 impl From<RepositoryOperationError> for ConfigOperationError { 325 fn from(error: RepositoryOperationError) -> Self { 326 match error { 327 RepositoryOperationError::Binding => Self::Binding, 328 RepositoryOperationError::Storage => Self::Storage, 329 } 330 } 331 } 332 333 fn valid_build(configuration: &MycConfigDocumentV1, build: &MigrationBuildIdentity) -> bool { 334 build.config_contract_version() == configuration.schema_version() 335 && build.state_contract_version() == crate::MYC_STATE_SCHEMA_VERSION 336 && build.admin_contract_version() == crate::MYC_OPERATOR_CONTRACT_VERSION 337 && build.status_contract_version() == crate::MYC_SIGNER_STATUS_CONTRACT_VERSION 338 && build.provider_contract_version() == MYC_PROVIDER_CONTRACT_VERSION 339 } 340 341 fn identities_changed(current: &PersistedMetadata, candidate: &PersistedMetadata) -> bool { 342 current.transport_public_key != candidate.transport_public_key 343 || current.user_public_key != candidate.user_public_key 344 || current.discovery_public_key != candidate.discovery_public_key 345 } 346 347 #[derive(PartialEq, Eq)] 348 struct RelayBinding<'a> { 349 url: &'a str, 350 read: bool, 351 write: bool, 352 required: bool, 353 authentication: &'a str, 354 } 355 356 fn relay_bindings( 357 configuration: &MycConfigDocumentV1, 358 ) -> Result<BTreeMap<&str, RelayBinding<'_>>, MycConfigApplyError> { 359 configuration 360 .normalized() 361 .pointer("/relays") 362 .and_then(Value::as_array) 363 .ok_or_else(|| MycConfigApplyError::new(MycConfigApplyErrorKind::InvalidInput))? 364 .iter() 365 .map(|relay| { 366 let id = relay 367 .pointer("/id") 368 .and_then(Value::as_str) 369 .ok_or_else(|| MycConfigApplyError::new(MycConfigApplyErrorKind::InvalidInput))?; 370 let value = RelayBinding { 371 url: relay 372 .pointer("/url") 373 .and_then(Value::as_str) 374 .ok_or_else(|| { 375 MycConfigApplyError::new(MycConfigApplyErrorKind::InvalidInput) 376 })?, 377 read: relay 378 .pointer("/read") 379 .and_then(Value::as_bool) 380 .ok_or_else(|| { 381 MycConfigApplyError::new(MycConfigApplyErrorKind::InvalidInput) 382 })?, 383 write: relay 384 .pointer("/write") 385 .and_then(Value::as_bool) 386 .ok_or_else(|| { 387 MycConfigApplyError::new(MycConfigApplyErrorKind::InvalidInput) 388 })?, 389 required: relay 390 .pointer("/required") 391 .and_then(Value::as_bool) 392 .ok_or_else(|| { 393 MycConfigApplyError::new(MycConfigApplyErrorKind::InvalidInput) 394 })?, 395 authentication: relay 396 .pointer("/authentication") 397 .and_then(Value::as_str) 398 .ok_or_else(|| { 399 MycConfigApplyError::new(MycConfigApplyErrorKind::InvalidInput) 400 })?, 401 }; 402 Ok((id, value)) 403 }) 404 .collect() 405 } 406 407 fn changed_existing_relays( 408 current: &MycConfigDocumentV1, 409 candidate: &MycConfigDocumentV1, 410 ) -> Result<Vec<Box<str>>, MycConfigApplyError> { 411 let current = relay_bindings(current)?; 412 let candidate = relay_bindings(candidate)?; 413 Ok(current 414 .into_iter() 415 .filter(|(id, binding)| candidate.get(id).is_none_or(|next| next != binding)) 416 .map(|(id, _)| id.into()) 417 .collect()) 418 } 419 420 fn permission_ceiling( 421 configuration: &MycConfigDocumentV1, 422 ) -> Result<BTreeSet<Box<str>>, MycConfigApplyError> { 423 configuration 424 .normalized() 425 .pointer("/policy/permission_ceiling") 426 .and_then(Value::as_array) 427 .ok_or_else(|| MycConfigApplyError::new(MycConfigApplyErrorKind::InvalidInput))? 428 .iter() 429 .map(|permission| { 430 permission 431 .as_str() 432 .map(Into::into) 433 .ok_or_else(|| MycConfigApplyError::new(MycConfigApplyErrorKind::InvalidInput)) 434 }) 435 .collect() 436 } 437 438 async fn read_latest_header( 439 transaction: &mut ServiceSqliteTransaction<'_>, 440 ) -> Result<(u16, u64), ConfigOperationError> { 441 let rows = sqlx::query(READ_LATEST_HEADER_SQL) 442 .fetch_all(&mut *transaction) 443 .await 444 .map_err(|_| ConfigOperationError::Storage)?; 445 let [row] = rows.as_slice() else { 446 return Err(ConfigOperationError::Binding); 447 }; 448 let generation = row 449 .try_get::<i64, _>("generation") 450 .ok() 451 .and_then(|value| u16::try_from(value).ok()) 452 .filter(|value| (1..=MYC_CONFIG_BINDING_MAX_GENERATIONS).contains(value)) 453 .ok_or(ConfigOperationError::Binding)?; 454 let applied_at = row 455 .try_get::<i64, _>("applied_at_unix_s") 456 .ok() 457 .and_then(|value| u64::try_from(value).ok()) 458 .ok_or(ConfigOperationError::Binding)?; 459 Ok((generation, applied_at)) 460 } 461 462 async fn relay_has_nonterminal_job( 463 transaction: &mut ServiceSqliteTransaction<'_>, 464 relay_id: &str, 465 ) -> Result<bool, ConfigOperationError> { 466 let row = sqlx::query(RELAY_HAS_NONTERMINAL_JOB_SQL) 467 .bind(relay_id) 468 .fetch_one(&mut *transaction) 469 .await 470 .map_err(|_| ConfigOperationError::Storage)?; 471 match row.try_get::<i64, _>("is_blocked") { 472 Ok(0) => Ok(false), 473 Ok(1) => Ok(true), 474 _ => Err(ConfigOperationError::Binding), 475 } 476 } 477 478 async fn revoke_for_identity_change( 479 transaction: &mut ServiceSqliteTransaction<'_>, 480 observed_at_unix_ms: i64, 481 ) -> Result<(u64, u64), ConfigOperationError> { 482 let active = sqlx::query(REVOKE_ACTIVE_CONNECTIONS_SQL) 483 .bind(observed_at_unix_ms) 484 .execute(&mut *transaction) 485 .await 486 .map_err(|_| ConfigOperationError::Storage)? 487 .rows_affected(); 488 let pending = sqlx::query(DENY_PENDING_CONNECTIONS_SQL) 489 .bind(observed_at_unix_ms) 490 .execute(&mut *transaction) 491 .await 492 .map_err(|_| ConfigOperationError::Storage)? 493 .rows_affected(); 494 let challenges = sqlx::query(EXPIRE_ALL_PENDING_CHALLENGES_SQL) 495 .bind(observed_at_unix_ms) 496 .execute(&mut *transaction) 497 .await 498 .map_err(|_| ConfigOperationError::Storage)? 499 .rows_affected(); 500 Ok((active.saturating_add(pending), challenges)) 501 } 502 503 async fn revoke_for_permission_narrowing( 504 transaction: &mut ServiceSqliteTransaction<'_>, 505 observed_at_unix_ms: i64, 506 permissions: &BTreeSet<Box<str>>, 507 ) -> Result<(u64, u64), ConfigOperationError> { 508 let connections = 509 update_affected_connections(transaction, observed_at_unix_ms, permissions).await?; 510 let challenges = 511 update_affected_challenges(transaction, observed_at_unix_ms, permissions).await?; 512 Ok((connections, challenges)) 513 } 514 515 async fn update_affected_connections( 516 transaction: &mut ServiceSqliteTransaction<'_>, 517 observed_at_unix_ms: i64, 518 permissions: &BTreeSet<Box<str>>, 519 ) -> Result<u64, ConfigOperationError> { 520 let mut query = QueryBuilder::<Sqlite>::new( 521 "UPDATE connections SET status = 'expired', updated_at_unix_ms = \ 522 MAX(updated_at_unix_ms, ", 523 ); 524 query.push_bind(observed_at_unix_ms).push( 525 "), authorized_until_unix_ms = NULL WHERE status = 'active' AND EXISTS (\ 526 SELECT 1 FROM connection_permissions AS permission \ 527 WHERE permission.connection_id = connections.connection_id \ 528 AND permission.permission_scope = 'granted'", 529 ); 530 push_not_in(&mut query, permissions); 531 query.push(")"); 532 query 533 .build() 534 .execute(&mut *transaction) 535 .await 536 .map(|result| result.rows_affected()) 537 .map_err(|_| ConfigOperationError::Storage) 538 } 539 540 async fn update_affected_challenges( 541 transaction: &mut ServiceSqliteTransaction<'_>, 542 observed_at_unix_ms: i64, 543 permissions: &BTreeSet<Box<str>>, 544 ) -> Result<u64, ConfigOperationError> { 545 let mut query = QueryBuilder::<Sqlite>::new( 546 "UPDATE connection_auth_challenges SET state = 'expired', \ 547 resolved_at_unix_ms = MAX(issued_at_unix_ms, ", 548 ); 549 query.push_bind(observed_at_unix_ms).push( 550 ") WHERE state = 'pending' AND EXISTS (\ 551 SELECT 1 FROM connection_permissions AS permission \ 552 WHERE permission.connection_id = connection_auth_challenges.connection_id \ 553 AND permission.permission_scope = 'requested'", 554 ); 555 push_not_in(&mut query, permissions); 556 query.push(")"); 557 query 558 .build() 559 .execute(&mut *transaction) 560 .await 561 .map(|result| result.rows_affected()) 562 .map_err(|_| ConfigOperationError::Storage) 563 } 564 565 fn push_not_in(query: &mut QueryBuilder<Sqlite>, permissions: &BTreeSet<Box<str>>) { 566 if permissions.is_empty() { 567 return; 568 } 569 query.push(" AND permission.permission_code NOT IN ("); 570 let mut separated = query.separated(", "); 571 for permission in permissions { 572 separated.push_bind(permission.as_ref()); 573 } 574 separated.push_unseparated(")"); 575 } 576 577 async fn insert_binding( 578 transaction: &mut ServiceSqliteTransaction<'_>, 579 generation: u16, 580 binding: &PersistedMetadata, 581 applied_at_unix_s: u64, 582 build: &MigrationBuildIdentity, 583 ) -> Result<(), ConfigOperationError> { 584 let result = sqlx::query(INSERT_CONFIG_BINDING_SQL) 585 .bind(i64::from(generation)) 586 .bind(binding.normalized_config_sha256.as_slice()) 587 .bind(binding.transport_public_key.as_ref()) 588 .bind(binding.user_public_key.as_ref()) 589 .bind(binding.discovery_public_key.as_deref()) 590 .bind(i64::from(binding.config_contract_version)) 591 .bind(i64::from(binding.state_contract_version)) 592 .bind(i64::from(binding.operator_contract_version)) 593 .bind(i64::from(binding.status_contract_version)) 594 .bind(i64::try_from(applied_at_unix_s).map_err(|_| ConfigOperationError::InvalidInput)?) 595 .bind(build.service_version()) 596 .bind(build.service_commit()) 597 .bind(build.lib_revision()) 598 .bind(build.rust_version()) 599 .bind(build.target()) 600 .bind(build.feature_profile()) 601 .bind(i64::from(build.provider_contract_version())) 602 .execute(&mut *transaction) 603 .await 604 .map_err(|_| ConfigOperationError::Storage)?; 605 (result.rows_affected() == 1) 606 .then_some(()) 607 .ok_or(ConfigOperationError::Storage) 608 } 609 610 fn map_apply_transaction_error( 611 error: ServiceSqliteTransactionError<ConfigOperationError>, 612 ) -> MycConfigApplyError { 613 if error.kind() == ServiceSqliteTransactionErrorKind::CommitOutcomeUnknown { 614 return MycConfigApplyError::new(MycConfigApplyErrorKind::CommitOutcomeUnknown); 615 } 616 let kind = match error.operation_error() { 617 Some(ConfigOperationError::InvalidInput) => MycConfigApplyErrorKind::InvalidInput, 618 Some(ConfigOperationError::Binding) => MycConfigApplyErrorKind::Binding, 619 Some(ConfigOperationError::PolicyConflict) => MycConfigApplyErrorKind::PolicyConflict, 620 Some(ConfigOperationError::ResourceExhausted) => MycConfigApplyErrorKind::ResourceExhausted, 621 Some(ConfigOperationError::Storage) | None => MycConfigApplyErrorKind::Transaction, 622 }; 623 MycConfigApplyError::new(kind) 624 }