status_v1.rs (36723B)
1 //! Passive, latest-value Myc lifecycle and detailed-status publication. 2 3 use core::fmt; 4 use std::{error::Error, sync::Arc, time::Duration}; 5 6 use radroots_service_host::{ 7 BoundedMetricsSnapshot, BuildInfo as HostBuildInfo, 8 BuildInfoEnvironment as HostBuildInfoEnvironment, BuildMode as HostBuildMode, 9 CachedServiceState, CachedServiceStatePublisher, CachedServiceStateReader, CommonMetricGroup, 10 ConfigurationIdentity as HostConfigurationIdentity, 11 ConfigurationSource as HostConfigurationSource, ContractVersions as HostContractVersions, 12 InstanceId, IntegrityState as HostIntegrityState, MetricDescriptor, MetricKind, MetricLabel, 13 MetricLabelKey, MetricName, MetricSample, MetricValue, 14 PersistenceHealth as HostPersistenceHealth, PersistenceSummary as HostPersistenceSummary, 15 Readiness as HostReadiness, ReasonCode as HostReasonCode, ReasonCodes as HostReasonCodes, 16 ServiceId, ServiceOperationalState as HostServiceOperationalState, 17 ServicePhase as HostServicePhase, ServiceStatus, ServiceStatusDetail, 18 Sha256Digest as HostSha256Digest, StatusContractError, StatusEncodingError, StatusModelError, 19 UptimeMillis as HostUptimeMillis, cached_service_state, 20 }; 21 use serde::Serialize; 22 23 /// Exact version of the passive Myc status-cache contract. 24 pub const MYC_STATUS_CACHE_CONTRACT_VERSION: u32 = 1; 25 26 /// Maximum encoded byte length of one detailed Myc status response. 27 pub const MYC_DETAILED_STATUS_MAX_UTF8_BYTES: usize = 28 radroots_service_host::SERVICE_STATUS_MAX_UTF8_BYTES; 29 30 /// Number of stable reason codes admitted by detailed Myc status. 31 pub const MYC_STATUS_REASON_CODE_COUNT: usize = 12; 32 33 /// One closed stable source-free status reason code. 34 #[derive(Clone, Copy, Debug, Hash, PartialEq, Eq, PartialOrd, Ord, Serialize)] 35 #[serde(rename_all = "snake_case")] 36 pub enum MycStatusReasonCode { 37 IdentityUnavailable, 38 DatabaseSchemaMismatch, 39 DatabaseReadOnly, 40 DatabaseLowDisk, 41 RequiredRelayUnavailable, 42 SubscriberNotActive, 43 SignerProviderUnavailable, 44 OutboxInvariantFailed, 45 PublicationBacklogExceeded, 46 AdminListenerFailed, 47 OperationsListenerFailed, 48 ShutdownInProgress, 49 } 50 51 impl MycStatusReasonCode { 52 pub fn new(value: impl AsRef<str>) -> Result<Self, MycStatusError> { 53 match value.as_ref() { 54 "identity_unavailable" => Ok(Self::IdentityUnavailable), 55 "database_schema_mismatch" => Ok(Self::DatabaseSchemaMismatch), 56 "database_read_only" => Ok(Self::DatabaseReadOnly), 57 "database_low_disk" => Ok(Self::DatabaseLowDisk), 58 "required_relay_unavailable" => Ok(Self::RequiredRelayUnavailable), 59 "subscriber_not_active" => Ok(Self::SubscriberNotActive), 60 "signer_provider_unavailable" => Ok(Self::SignerProviderUnavailable), 61 "outbox_invariant_failed" => Ok(Self::OutboxInvariantFailed), 62 "publication_backlog_exceeded" => Ok(Self::PublicationBacklogExceeded), 63 "admin_listener_failed" => Ok(Self::AdminListenerFailed), 64 "operations_listener_failed" => Ok(Self::OperationsListenerFailed), 65 "shutdown_in_progress" => Ok(Self::ShutdownInProgress), 66 _ => Err(MycStatusError::new(MycStatusErrorKind::InvalidReasonCode)), 67 } 68 } 69 70 #[must_use] 71 pub const fn as_str(self) -> &'static str { 72 match self { 73 Self::IdentityUnavailable => "identity_unavailable", 74 Self::DatabaseSchemaMismatch => "database_schema_mismatch", 75 Self::DatabaseReadOnly => "database_read_only", 76 Self::DatabaseLowDisk => "database_low_disk", 77 Self::RequiredRelayUnavailable => "required_relay_unavailable", 78 Self::SubscriberNotActive => "subscriber_not_active", 79 Self::SignerProviderUnavailable => "signer_provider_unavailable", 80 Self::OutboxInvariantFailed => "outbox_invariant_failed", 81 Self::PublicationBacklogExceeded => "publication_backlog_exceeded", 82 Self::AdminListenerFailed => "admin_listener_failed", 83 Self::OperationsListenerFailed => "operations_listener_failed", 84 Self::ShutdownInProgress => "shutdown_in_progress", 85 } 86 } 87 } 88 89 /// Canonically ordered, unique, bounded status reasons. 90 #[derive(Clone, Debug, Default, PartialEq, Eq, Serialize)] 91 #[serde(transparent)] 92 pub struct MycStatusReasonCodes(Vec<MycStatusReasonCode>); 93 94 impl MycStatusReasonCodes { 95 #[must_use] 96 pub const fn empty() -> Self { 97 Self(Vec::new()) 98 } 99 100 pub fn new( 101 values: impl IntoIterator<Item = MycStatusReasonCode>, 102 ) -> Result<Self, MycStatusError> { 103 let mut bounded = Vec::with_capacity(MYC_STATUS_REASON_CODE_COUNT); 104 for value in values.into_iter().take(MYC_STATUS_REASON_CODE_COUNT + 1) { 105 if bounded.len() == MYC_STATUS_REASON_CODE_COUNT { 106 return Err(MycStatusError::new(MycStatusErrorKind::TooManyReasonCodes)); 107 } 108 bounded.push(value); 109 } 110 bounded.sort_unstable(); 111 bounded.dedup(); 112 Ok(Self(bounded)) 113 } 114 115 #[must_use] 116 pub fn as_slice(&self) -> &[MycStatusReasonCode] { 117 &self.0 118 } 119 120 fn into_host(self) -> Result<HostReasonCodes, MycStatusError> { 121 let values = self 122 .0 123 .into_iter() 124 .map(|value| HostReasonCode::new(value.as_str()).map_err(map_contract_error)) 125 .collect::<Result<Vec<_>, _>>()?; 126 HostReasonCodes::new(values).map_err(map_contract_error) 127 } 128 } 129 130 /// Closed common lifecycle phase used by the Myc public boundary. 131 #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] 132 #[serde(rename_all = "snake_case")] 133 pub enum MycServicePhase { 134 Starting, 135 Ready, 136 Degraded, 137 Unready, 138 Stopping, 139 Failed, 140 } 141 142 impl MycServicePhase { 143 const fn into_host(self) -> HostServicePhase { 144 match self { 145 Self::Starting => HostServicePhase::Starting, 146 Self::Ready => HostServicePhase::Ready, 147 Self::Degraded => HostServicePhase::Degraded, 148 Self::Unready => HostServicePhase::Unready, 149 Self::Stopping => HostServicePhase::Stopping, 150 Self::Failed => HostServicePhase::Failed, 151 } 152 } 153 154 const fn from_host(value: HostServicePhase) -> Self { 155 match value { 156 HostServicePhase::Starting => Self::Starting, 157 HostServicePhase::Ready => Self::Ready, 158 HostServicePhase::Degraded => Self::Degraded, 159 HostServicePhase::Unready => Self::Unready, 160 HostServicePhase::Stopping => Self::Stopping, 161 HostServicePhase::Failed => Self::Failed, 162 } 163 } 164 } 165 166 /// Build-metadata admission mode for Myc status identity. 167 #[derive(Clone, Copy, Debug, PartialEq, Eq)] 168 pub enum MycStatusBuildMode { 169 Development, 170 Release, 171 } 172 173 /// Complete deterministic build identity retained behind the Myc boundary. 174 pub struct MycStatusBuildInfoV1 { 175 inner: HostBuildInfo, 176 } 177 178 impl MycStatusBuildInfoV1 { 179 /// Validates the complete build/source-lock identity with fixed Myc contracts. 180 pub fn new( 181 mode: MycStatusBuildMode, 182 service_version: Option<&str>, 183 service_commit: Option<&str>, 184 lib_revision: Option<&str>, 185 rust_version: Option<&str>, 186 target: Option<&str>, 187 feature_profile: Option<&str>, 188 ) -> Result<Self, MycStatusError> { 189 let contract_versions = 190 HostContractVersions::new(1, crate::MYC_STATE_SCHEMA_VERSION, 1, 1, 1) 191 .map_err(|_| MycStatusError::new(MycStatusErrorKind::InvalidBuildInfo))?; 192 HostBuildInfo::from_compile_time( 193 match mode { 194 MycStatusBuildMode::Development => HostBuildMode::Development, 195 MycStatusBuildMode::Release => HostBuildMode::Release, 196 }, 197 HostBuildInfoEnvironment { 198 service_version, 199 service_commit, 200 lib_revision, 201 rust_version, 202 target, 203 feature_profile, 204 contract_versions, 205 }, 206 ) 207 .map(|inner| Self { inner }) 208 .map_err(|_| MycStatusError::new(MycStatusErrorKind::InvalidBuildInfo)) 209 } 210 } 211 212 impl fmt::Debug for MycStatusBuildInfoV1 { 213 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { 214 formatter.write_str("MycStatusBuildInfoV1([redacted])") 215 } 216 } 217 218 /// Exact configuration-source vocabulary exposed by detailed status. 219 #[derive(Clone, Copy, Debug, PartialEq, Eq)] 220 pub enum MycStatusConfigurationSource { 221 ExplicitConfig, 222 DerivedRepoLocal, 223 } 224 225 /// Safe configuration identity retained behind the Myc boundary. 226 pub struct MycStatusConfigurationIdentityV1 { 227 inner: HostConfigurationIdentity, 228 } 229 230 impl MycStatusConfigurationIdentityV1 { 231 pub fn new( 232 digest: impl AsRef<str>, 233 source: MycStatusConfigurationSource, 234 ) -> Result<Self, MycStatusError> { 235 let service = ServiceId::new("myc") 236 .map_err(|_| MycStatusError::new(MycStatusErrorKind::InvalidConfiguration))?; 237 let digest = HostSha256Digest::new(digest) 238 .map_err(|_| MycStatusError::new(MycStatusErrorKind::InvalidConfiguration))?; 239 HostConfigurationIdentity::for_service( 240 &service, 241 digest, 242 match source { 243 MycStatusConfigurationSource::ExplicitConfig => { 244 HostConfigurationSource::ExplicitConfig 245 } 246 MycStatusConfigurationSource::DerivedRepoLocal => { 247 HostConfigurationSource::DerivedRepoLocal 248 } 249 }, 250 ) 251 .map(|inner| Self { inner }) 252 .map_err(|_| MycStatusError::new(MycStatusErrorKind::InvalidConfiguration)) 253 } 254 } 255 256 impl fmt::Debug for MycStatusConfigurationIdentityV1 { 257 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { 258 formatter.write_str("MycStatusConfigurationIdentityV1([redacted])") 259 } 260 } 261 262 #[derive(Clone, Copy, Debug, PartialEq, Eq)] 263 pub enum MycPersistenceHealthV1 { 264 Ready, 265 ReadOnly, 266 RepairRequired, 267 Unavailable, 268 } 269 270 #[derive(Clone, Copy, Debug, PartialEq, Eq)] 271 pub enum MycIntegrityStateV1 { 272 Verified, 273 VerificationRequired, 274 Failed, 275 } 276 277 /// Validated persistence summary retained behind the Myc boundary. 278 pub struct MycPersistenceStatusV1 { 279 inner: HostPersistenceSummary, 280 } 281 282 impl MycPersistenceStatusV1 { 283 pub fn new( 284 health: MycPersistenceHealthV1, 285 schema_version: u32, 286 generation: u64, 287 integrity: MycIntegrityStateV1, 288 reason_codes: MycStatusReasonCodes, 289 ) -> Result<Self, MycStatusError> { 290 let reason_codes = reason_codes.into_host()?; 291 HostPersistenceSummary::new( 292 match health { 293 MycPersistenceHealthV1::Ready => HostPersistenceHealth::Ready, 294 MycPersistenceHealthV1::ReadOnly => HostPersistenceHealth::ReadOnly, 295 MycPersistenceHealthV1::RepairRequired => HostPersistenceHealth::RepairRequired, 296 MycPersistenceHealthV1::Unavailable => HostPersistenceHealth::Unavailable, 297 }, 298 schema_version, 299 generation, 300 match integrity { 301 MycIntegrityStateV1::Verified => HostIntegrityState::Verified, 302 MycIntegrityStateV1::VerificationRequired => { 303 HostIntegrityState::VerificationRequired 304 } 305 MycIntegrityStateV1::Failed => HostIntegrityState::Failed, 306 }, 307 reason_codes, 308 ) 309 .map(|inner| Self { inner }) 310 .map_err(|_| MycStatusError::new(MycStatusErrorKind::InvalidPersistence)) 311 } 312 } 313 314 impl fmt::Debug for MycPersistenceStatusV1 { 315 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { 316 formatter.write_str("MycPersistenceStatusV1([redacted])") 317 } 318 } 319 320 /// One validated Unix timestamp used only for the oldest pending outbox item. 321 #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize)] 322 #[serde(transparent)] 323 pub struct MycStatusUnixSeconds(u64); 324 325 impl MycStatusUnixSeconds { 326 /// Constructs a timestamp representable by SQLite and the frozen wire contract. 327 pub fn new(value: u64) -> Result<Self, MycStatusError> { 328 if value > i64::MAX as u64 { 329 return Err(MycStatusError::new(MycStatusErrorKind::InvalidTime)); 330 } 331 Ok(Self(value)) 332 } 333 334 /// Returns exact whole Unix seconds. 335 #[must_use] 336 pub const fn get(self) -> u64 { 337 self.0 338 } 339 } 340 341 /// Passive availability of one configured Myc identity role. 342 #[derive(Clone, Debug, PartialEq, Eq, Serialize)] 343 pub struct MycIdentityHealthV1 { 344 configured: bool, 345 available: bool, 346 reason_codes: MycStatusReasonCodes, 347 } 348 349 impl MycIdentityHealthV1 { 350 /// Constructs one role observation, rejecting availability without configuration. 351 pub fn new( 352 configured: bool, 353 available: bool, 354 reason_codes: MycStatusReasonCodes, 355 ) -> Result<Self, MycStatusError> { 356 if available && !configured { 357 return Err(MycStatusError::new( 358 MycStatusErrorKind::InvalidIdentityHealth, 359 )); 360 } 361 Ok(Self { 362 configured, 363 available, 364 reason_codes, 365 }) 366 } 367 368 #[must_use] 369 pub const fn is_configured(&self) -> bool { 370 self.configured 371 } 372 373 #[must_use] 374 pub const fn is_available(&self) -> bool { 375 self.available 376 } 377 378 #[must_use] 379 pub const fn reason_codes(&self) -> &MycStatusReasonCodes { 380 &self.reason_codes 381 } 382 } 383 384 /// Complete provider projection for the three fixed Myc identity roles. 385 #[derive(Clone, Debug, PartialEq, Eq, Serialize)] 386 pub struct MycProviderStatusV1 { 387 health: MycProviderHealthV1, 388 transport: MycIdentityHealthV1, 389 user: MycIdentityHealthV1, 390 discovery: MycIdentityHealthV1, 391 reason_codes: MycStatusReasonCodes, 392 } 393 394 #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] 395 #[serde(rename_all = "snake_case")] 396 pub enum MycProviderHealthV1 { 397 Ready, 398 Degraded, 399 Unavailable, 400 } 401 402 impl MycProviderStatusV1 { 403 /// Derives aggregate provider health from the exact role inventory. 404 pub fn new( 405 transport: MycIdentityHealthV1, 406 user: MycIdentityHealthV1, 407 discovery: MycIdentityHealthV1, 408 reason_codes: MycStatusReasonCodes, 409 ) -> Result<Self, MycStatusError> { 410 if !transport.configured || !user.configured { 411 return Err(MycStatusError::new( 412 MycStatusErrorKind::InvalidProviderState, 413 )); 414 } 415 let health = if !transport.available || !user.available { 416 MycProviderHealthV1::Unavailable 417 } else if discovery.configured && !discovery.available { 418 MycProviderHealthV1::Degraded 419 } else { 420 MycProviderHealthV1::Ready 421 }; 422 Ok(Self { 423 health, 424 transport, 425 user, 426 discovery, 427 reason_codes, 428 }) 429 } 430 431 #[must_use] 432 pub const fn health(&self) -> MycProviderHealthV1 { 433 self.health 434 } 435 436 #[must_use] 437 pub const fn transport(&self) -> &MycIdentityHealthV1 { 438 &self.transport 439 } 440 441 #[must_use] 442 pub const fn user(&self) -> &MycIdentityHealthV1 { 443 &self.user 444 } 445 446 #[must_use] 447 pub const fn discovery(&self) -> &MycIdentityHealthV1 { 448 &self.discovery 449 } 450 451 #[must_use] 452 pub const fn reason_codes(&self) -> &MycStatusReasonCodes { 453 &self.reason_codes 454 } 455 } 456 457 /// Passive relay-transport projection for detailed status. 458 #[derive(Clone, Debug, PartialEq, Eq, Serialize)] 459 pub struct MycRelayTransportStatusV1 { 460 health: MycTransportHealthV1, 461 required_relays_ready: bool, 462 connected_relay_count: u64, 463 reason_codes: MycStatusReasonCodes, 464 } 465 466 #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] 467 #[serde(rename_all = "snake_case")] 468 pub enum MycTransportHealthV1 { 469 Ready, 470 Degraded, 471 Unavailable, 472 } 473 474 impl MycRelayTransportStatusV1 { 475 /// Constructs one transport observation, rejecting contradictory ready state. 476 pub fn new( 477 health: MycTransportHealthV1, 478 required_relays_ready: bool, 479 connected_relay_count: u64, 480 reason_codes: MycStatusReasonCodes, 481 ) -> Result<Self, MycStatusError> { 482 if health == MycTransportHealthV1::Ready && !required_relays_ready { 483 return Err(MycStatusError::new( 484 MycStatusErrorKind::InvalidTransportState, 485 )); 486 } 487 Ok(Self { 488 health, 489 required_relays_ready, 490 connected_relay_count, 491 reason_codes, 492 }) 493 } 494 495 #[must_use] 496 pub const fn health(&self) -> MycTransportHealthV1 { 497 self.health 498 } 499 500 #[must_use] 501 pub const fn required_relays_ready(&self) -> bool { 502 self.required_relays_ready 503 } 504 505 #[must_use] 506 pub const fn connected_relay_count(&self) -> u64 { 507 self.connected_relay_count 508 } 509 510 #[must_use] 511 pub const fn reason_codes(&self) -> &MycStatusReasonCodes { 512 &self.reason_codes 513 } 514 } 515 516 /// Exact closed connection-count vocabulary rendered as the safe-count map. 517 #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize)] 518 pub struct MycConnectionCountsV1 { 519 pending: u64, 520 active: u64, 521 denied: u64, 522 expired: u64, 523 } 524 525 impl MycConnectionCountsV1 { 526 #[must_use] 527 pub const fn new(pending: u64, active: u64, denied: u64, expired: u64) -> Self { 528 Self { 529 pending, 530 active, 531 denied, 532 expired, 533 } 534 } 535 536 #[must_use] 537 pub const fn pending(self) -> u64 { 538 self.pending 539 } 540 541 #[must_use] 542 pub const fn active(self) -> u64 { 543 self.active 544 } 545 546 #[must_use] 547 pub const fn denied(self) -> u64 { 548 self.denied 549 } 550 551 #[must_use] 552 pub const fn expired(self) -> u64 { 553 self.expired 554 } 555 } 556 557 /// Passive outbox summary derived by an owning runtime task before publication. 558 #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize)] 559 pub struct MycOutboxStatusV1 { 560 pending: u64, 561 unknown: u64, 562 #[serde(skip_serializing_if = "Option::is_none")] 563 oldest_pending_at_utc: Option<MycStatusUnixSeconds>, 564 } 565 566 impl MycOutboxStatusV1 { 567 #[must_use] 568 pub const fn new( 569 pending: u64, 570 unknown: u64, 571 oldest_pending_at_utc: Option<MycStatusUnixSeconds>, 572 ) -> Self { 573 Self { 574 pending, 575 unknown, 576 oldest_pending_at_utc, 577 } 578 } 579 580 #[must_use] 581 pub const fn pending(self) -> u64 { 582 self.pending 583 } 584 585 #[must_use] 586 pub const fn unknown(self) -> u64 { 587 self.unknown 588 } 589 590 #[must_use] 591 pub const fn oldest_pending_at_utc(self) -> Option<MycStatusUnixSeconds> { 592 self.oldest_pending_at_utc 593 } 594 } 595 596 #[derive(Serialize)] 597 struct MycStatusDetailV1 { 598 transport: MycIdentityHealthV1, 599 user: MycIdentityHealthV1, 600 discovery: MycIdentityHealthV1, 601 connection_counts: MycConnectionCountsV1, 602 outbox: MycOutboxStatusV1, 603 } 604 605 impl ServiceStatusDetail for MycStatusDetailV1 { 606 type Provider = MycProviderStatusV1; 607 type Transport = MycRelayTransportStatusV1; 608 609 const FIELD_NAME: &'static str = "myc"; 610 } 611 612 /// Validated common fields shared by one detailed status publication. 613 /// 614 /// Construction and publication perform validation and bounded encoding only. 615 /// They do not query SQLite, providers, relays, DNS, credentials, or the clock. 616 pub struct MycStatusCommonV1 { 617 operational: HostServiceOperationalState, 618 uptime: HostUptimeMillis, 619 build: HostBuildInfo, 620 configuration: HostConfigurationIdentity, 621 persistence: HostPersistenceSummary, 622 } 623 624 impl MycStatusCommonV1 { 625 /// Validates the common lifecycle and detailed-status envelope fields. 626 pub fn new( 627 phase: MycServicePhase, 628 ready: bool, 629 reason_codes: MycStatusReasonCodes, 630 uptime_millis: u64, 631 build: MycStatusBuildInfoV1, 632 configuration: MycStatusConfigurationIdentityV1, 633 persistence: MycPersistenceStatusV1, 634 ) -> Result<Self, MycStatusError> { 635 let operational = HostServiceOperationalState::new( 636 phase.into_host(), 637 if ready { 638 HostReadiness::READY 639 } else { 640 HostReadiness::NOT_READY 641 }, 642 reason_codes.into_host()?, 643 ) 644 .map_err(|_| MycStatusError::new(MycStatusErrorKind::InvalidLifecycle))?; 645 let uptime = HostUptimeMillis::from_duration(Duration::from_millis(uptime_millis)) 646 .map_err(|_| MycStatusError::new(MycStatusErrorKind::InvalidTime))?; 647 Ok(Self { 648 operational, 649 uptime, 650 build: build.inner, 651 configuration: configuration.inner, 652 persistence: persistence.inner, 653 }) 654 } 655 } 656 657 impl fmt::Debug for MycStatusCommonV1 { 658 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { 659 formatter.write_str("MycStatusCommonV1([redacted])") 660 } 661 } 662 663 /// One complete, already-observed status publication input. 664 pub struct MycStatusObservationV1 { 665 common: MycStatusCommonV1, 666 provider: MycProviderStatusV1, 667 transport: MycRelayTransportStatusV1, 668 connection_counts: MycConnectionCountsV1, 669 outbox: MycOutboxStatusV1, 670 } 671 672 impl MycStatusObservationV1 { 673 #[must_use] 674 pub fn new( 675 common: MycStatusCommonV1, 676 provider: MycProviderStatusV1, 677 transport: MycRelayTransportStatusV1, 678 connection_counts: MycConnectionCountsV1, 679 outbox: MycOutboxStatusV1, 680 ) -> Self { 681 Self { 682 common, 683 provider, 684 transport, 685 connection_counts, 686 outbox, 687 } 688 } 689 690 fn into_cached(self, instance: &InstanceId) -> Result<PreparedMycStatus, MycStatusError> { 691 let operational = self.common.operational.clone(); 692 let operations_metrics = bounded_operations_metrics(&operational)?; 693 let detail = MycStatusDetailV1 { 694 transport: self.provider.transport.clone(), 695 user: self.provider.user.clone(), 696 discovery: self.provider.discovery.clone(), 697 connection_counts: self.connection_counts, 698 outbox: self.outbox, 699 }; 700 let service = ServiceId::new("myc") 701 .map_err(|_| MycStatusError::new(MycStatusErrorKind::InvalidModel))?; 702 let status = ServiceStatus::new( 703 service, 704 instance.clone(), 705 self.common.operational, 706 self.common.uptime, 707 self.common.build, 708 self.common.configuration, 709 self.common.persistence, 710 self.provider, 711 self.transport, 712 detail, 713 ) 714 .map_err(map_model_error)?; 715 let json = status.to_bounded_json().map_err(map_encoding_error)?; 716 Ok(PreparedMycStatus { 717 detail: CachedServiceState::new( 718 operational.clone(), 719 MycCachedStatus { 720 json: json.into_boxed_slice(), 721 }, 722 ), 723 operations: CachedServiceState::new(operational, operations_metrics), 724 }) 725 } 726 } 727 728 impl fmt::Debug for MycStatusObservationV1 { 729 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { 730 formatter.write_str("MycStatusObservationV1([redacted])") 731 } 732 } 733 734 struct MycCachedStatus { 735 json: Box<[u8]>, 736 } 737 738 struct PreparedMycStatus { 739 detail: CachedServiceState<MycCachedStatus>, 740 operations: CachedServiceState<BoundedMetricsSnapshot>, 741 } 742 743 fn bounded_operations_metrics( 744 operational: &HostServiceOperationalState, 745 ) -> Result<BoundedMetricsSnapshot, MycStatusError> { 746 let phase_name = MetricName::new("radroots_myc_service_phase").map_err(map_metrics_error)?; 747 let ready_name = MetricName::new("radroots_myc_service_ready").map_err(map_metrics_error)?; 748 let descriptors = [ 749 MetricDescriptor::new( 750 CommonMetricGroup::Phase, 751 phase_name.clone(), 752 "Current cached Myc service phase.", 753 MetricKind::Gauge, 754 [MetricLabelKey::Phase], 755 ) 756 .map_err(map_metrics_error)?, 757 MetricDescriptor::new( 758 CommonMetricGroup::Phase, 759 ready_name.clone(), 760 "Current cached Myc readiness bit.", 761 MetricKind::Gauge, 762 [], 763 ) 764 .map_err(map_metrics_error)?, 765 ]; 766 let samples = [ 767 MetricSample::new( 768 phase_name, 769 MetricValue::Gauge(1), 770 [MetricLabel::phase(operational.phase())], 771 ) 772 .map_err(map_metrics_error)?, 773 MetricSample::new( 774 ready_name, 775 MetricValue::Gauge(i64::from(operational.readiness().is_ready())), 776 [], 777 ) 778 .map_err(map_metrics_error)?, 779 ]; 780 BoundedMetricsSnapshot::new(descriptors, samples).map_err(map_metrics_error) 781 } 782 783 fn map_metrics_error(_: radroots_service_host::MetricsContractError) -> MycStatusError { 784 MycStatusError::new(MycStatusErrorKind::InvalidModel) 785 } 786 787 impl fmt::Debug for MycCachedStatus { 788 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { 789 formatter 790 .debug_struct("MycCachedStatus") 791 .field("json_utf8_bytes", &self.json.len()) 792 .finish() 793 } 794 } 795 796 /// Sole publication authority for one process-local Myc status cache. 797 /// 798 /// This type deliberately does not implement `Clone`. A successful publish 799 /// atomically replaces the one retained snapshot. A failed encoding or illegal 800 /// lifecycle transition leaves the previous snapshot unchanged. 801 pub struct MycStatusPublisher { 802 instance: InstanceId, 803 inner: CachedServiceStatePublisher<MycCachedStatus>, 804 operations: CachedServiceStatePublisher<BoundedMetricsSnapshot>, 805 } 806 807 impl MycStatusPublisher { 808 /// Encodes one observation, publishes its passive operations projection, 809 /// and then atomically replaces the detailed-status snapshot. 810 pub fn publish(&mut self, next: MycStatusObservationV1) -> Result<(), MycStatusError> { 811 let next = next.into_cached(&self.instance)?; 812 self.operations 813 .publish(next.operations) 814 .map_err(map_contract_error)?; 815 self.inner.publish(next.detail).map_err(map_contract_error) 816 } 817 818 /// Creates another passive reader without sharing publication authority. 819 #[must_use] 820 pub fn subscribe(&self) -> MycStatusReader { 821 MycStatusReader { 822 inner: self.inner.subscribe(), 823 operations: self.operations.subscribe(), 824 } 825 } 826 } 827 828 impl fmt::Debug for MycStatusPublisher { 829 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { 830 formatter.write_str("MycStatusPublisher([sealed])") 831 } 832 } 833 834 /// Cloneable passive reader of the latest Myc lifecycle and detailed status. 835 pub struct MycStatusReader { 836 inner: CachedServiceStateReader<MycCachedStatus>, 837 operations: CachedServiceStateReader<BoundedMetricsSnapshot>, 838 } 839 840 impl Clone for MycStatusReader { 841 fn clone(&self) -> Self { 842 Self { 843 inner: self.inner.clone(), 844 operations: self.operations.clone(), 845 } 846 } 847 } 848 849 impl MycStatusReader { 850 /// Returns the latest immutable snapshot without awaiting or probing. 851 #[must_use] 852 pub fn snapshot(&self) -> MycStatusSnapshot { 853 MycStatusSnapshot { 854 inner: self.inner.snapshot(), 855 } 856 } 857 858 /// Waits for a later publication and returns the newest retained value. 859 pub async fn changed(&mut self) -> Result<MycStatusSnapshot, MycStatusError> { 860 self.inner 861 .changed() 862 .await 863 .map(|inner| MycStatusSnapshot { inner }) 864 .map_err(|_| MycStatusError::new(MycStatusErrorKind::PublisherDropped)) 865 } 866 867 pub(crate) fn operations_cache(&self) -> CachedServiceStateReader<BoundedMetricsSnapshot> { 868 self.operations.clone() 869 } 870 } 871 872 impl fmt::Debug for MycStatusReader { 873 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { 874 formatter.write_str("MycStatusReader([passive])") 875 } 876 } 877 878 /// One immutable point-in-time status snapshot backed by the retained cache `Arc`. 879 pub struct MycStatusSnapshot { 880 inner: Arc<CachedServiceState<MycCachedStatus>>, 881 } 882 883 impl MycStatusSnapshot { 884 #[must_use] 885 pub fn phase(&self) -> MycServicePhase { 886 MycServicePhase::from_host(self.inner.operational().phase()) 887 } 888 889 #[must_use] 890 pub fn is_ready(&self) -> bool { 891 self.inner.operational().readiness().is_ready() 892 } 893 894 /// Returns the already-bounded canonical detailed-status JSON bytes. 895 #[must_use] 896 pub fn detailed_status_json(&self) -> &[u8] { 897 &self.inner.metrics().json 898 } 899 } 900 901 impl fmt::Debug for MycStatusSnapshot { 902 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { 903 formatter 904 .debug_struct("MycStatusSnapshot") 905 .field("phase", &self.phase()) 906 .field("ready", &self.is_ready()) 907 .field("json_utf8_bytes", &self.detailed_status_json().len()) 908 .finish() 909 } 910 } 911 912 /// Creates the single-writer, one-latest-value Myc status cache. 913 pub fn myc_status_cache( 914 instance: InstanceId, 915 initial: MycStatusObservationV1, 916 ) -> Result<(MycStatusPublisher, MycStatusReader), MycStatusError> { 917 let initial = initial.into_cached(&instance)?; 918 let (inner, reader) = cached_service_state(initial.detail); 919 let (operations, operations_reader) = cached_service_state(initial.operations); 920 Ok(( 921 MycStatusPublisher { 922 instance, 923 inner, 924 operations, 925 }, 926 MycStatusReader { 927 inner: reader, 928 operations: operations_reader, 929 }, 930 )) 931 } 932 933 /// Stable source-free status failure category. 934 #[derive(Clone, Copy, Debug, PartialEq, Eq)] 935 pub enum MycStatusErrorKind { 936 InvalidReasonCode, 937 TooManyReasonCodes, 938 InvalidLifecycle, 939 InvalidBuildInfo, 940 InvalidConfiguration, 941 InvalidPersistence, 942 InvalidIdentityHealth, 943 InvalidProviderState, 944 InvalidTransportState, 945 InvalidTime, 946 InvalidModel, 947 Encoding, 948 ResponseTooLarge, 949 InvalidTransition, 950 PublisherDropped, 951 } 952 953 impl MycStatusErrorKind { 954 #[must_use] 955 pub const fn code(self) -> &'static str { 956 match self { 957 Self::InvalidReasonCode => "status_reason_code_invalid", 958 Self::TooManyReasonCodes => "status_reason_count_exceeded", 959 Self::InvalidLifecycle => "status_lifecycle_invalid", 960 Self::InvalidBuildInfo => "status_build_info_invalid", 961 Self::InvalidConfiguration => "status_configuration_invalid", 962 Self::InvalidPersistence => "status_persistence_invalid", 963 Self::InvalidIdentityHealth => "status_identity_health_invalid", 964 Self::InvalidProviderState => "status_provider_state_invalid", 965 Self::InvalidTransportState => "status_transport_state_invalid", 966 Self::InvalidTime => "status_time_invalid", 967 Self::InvalidModel => "status_model_invalid", 968 Self::Encoding => "status_encoding_failed", 969 Self::ResponseTooLarge => "status_response_too_large", 970 Self::InvalidTransition => "status_transition_invalid", 971 Self::PublisherDropped => "status_publisher_dropped", 972 } 973 } 974 975 const fn message(self) -> &'static str { 976 match self { 977 Self::InvalidReasonCode => "Myc status reason code is invalid", 978 Self::TooManyReasonCodes => "Myc status has too many reason codes", 979 Self::InvalidLifecycle => "Myc lifecycle status is invalid", 980 Self::InvalidBuildInfo => "Myc status build identity is invalid", 981 Self::InvalidConfiguration => "Myc status configuration identity is invalid", 982 Self::InvalidPersistence => "Myc persistence status is invalid", 983 Self::InvalidIdentityHealth => "Myc identity health is invalid", 984 Self::InvalidProviderState => "Myc provider status is invalid", 985 Self::InvalidTransportState => "Myc transport status is invalid", 986 Self::InvalidTime => "Myc status time is invalid", 987 Self::InvalidModel => "Myc detailed status is invalid", 988 Self::Encoding => "Myc detailed status encoding failed", 989 Self::ResponseTooLarge => "Myc detailed status exceeds its byte limit", 990 Self::InvalidTransition => "Myc lifecycle transition is invalid", 991 Self::PublisherDropped => "Myc status publisher is unavailable", 992 } 993 } 994 } 995 996 /// One redacted source-free status failure. 997 #[derive(Clone, Copy, PartialEq, Eq)] 998 pub struct MycStatusError { 999 kind: MycStatusErrorKind, 1000 } 1001 1002 impl MycStatusError { 1003 const fn new(kind: MycStatusErrorKind) -> Self { 1004 Self { kind } 1005 } 1006 1007 #[must_use] 1008 pub const fn kind(self) -> MycStatusErrorKind { 1009 self.kind 1010 } 1011 1012 #[must_use] 1013 pub const fn code(self) -> &'static str { 1014 self.kind.code() 1015 } 1016 } 1017 1018 impl fmt::Debug for MycStatusError { 1019 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { 1020 formatter 1021 .debug_struct("MycStatusError") 1022 .field("kind", &self.kind) 1023 .finish() 1024 } 1025 } 1026 1027 impl fmt::Display for MycStatusError { 1028 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { 1029 formatter.write_str(self.kind.message()) 1030 } 1031 } 1032 1033 impl Error for MycStatusError {} 1034 1035 const fn map_model_error(_error: StatusModelError) -> MycStatusError { 1036 MycStatusError::new(MycStatusErrorKind::InvalidModel) 1037 } 1038 1039 const fn map_encoding_error(error: StatusEncodingError) -> MycStatusError { 1040 match error { 1041 StatusEncodingError::EncodingFailed => MycStatusError::new(MycStatusErrorKind::Encoding), 1042 StatusEncodingError::ResponseTooLarge => { 1043 MycStatusError::new(MycStatusErrorKind::ResponseTooLarge) 1044 } 1045 } 1046 } 1047 1048 const fn map_contract_error(_error: StatusContractError) -> MycStatusError { 1049 MycStatusError::new(MycStatusErrorKind::InvalidTransition) 1050 } 1051 1052 #[cfg(test)] 1053 mod tests { 1054 use super::*; 1055 1056 #[test] 1057 fn invalid_status_inputs_fail_with_safe_source_free_errors() { 1058 assert_eq!( 1059 MycIdentityHealthV1::new(false, true, MycStatusReasonCodes::empty()), 1060 Err(MycStatusError::new( 1061 MycStatusErrorKind::InvalidIdentityHealth 1062 )) 1063 ); 1064 let unavailable = 1065 MycIdentityHealthV1::new(true, false, MycStatusReasonCodes::empty()).unwrap(); 1066 assert_eq!( 1067 MycProviderStatusV1::new( 1068 MycIdentityHealthV1::new(false, false, MycStatusReasonCodes::empty()).unwrap(), 1069 unavailable, 1070 MycIdentityHealthV1::new(false, false, MycStatusReasonCodes::empty()).unwrap(), 1071 MycStatusReasonCodes::empty(), 1072 ), 1073 Err(MycStatusError::new( 1074 MycStatusErrorKind::InvalidProviderState 1075 )) 1076 ); 1077 assert_eq!( 1078 MycRelayTransportStatusV1::new( 1079 MycTransportHealthV1::Ready, 1080 false, 1081 0, 1082 MycStatusReasonCodes::empty(), 1083 ), 1084 Err(MycStatusError::new( 1085 MycStatusErrorKind::InvalidTransportState 1086 )) 1087 ); 1088 assert_eq!( 1089 MycStatusUnixSeconds::new(i64::MAX as u64 + 1), 1090 Err(MycStatusError::new(MycStatusErrorKind::InvalidTime)) 1091 ); 1092 for kind in [ 1093 MycStatusErrorKind::InvalidReasonCode, 1094 MycStatusErrorKind::TooManyReasonCodes, 1095 MycStatusErrorKind::InvalidLifecycle, 1096 MycStatusErrorKind::InvalidBuildInfo, 1097 MycStatusErrorKind::InvalidConfiguration, 1098 MycStatusErrorKind::InvalidPersistence, 1099 MycStatusErrorKind::InvalidIdentityHealth, 1100 MycStatusErrorKind::InvalidProviderState, 1101 MycStatusErrorKind::InvalidTransportState, 1102 MycStatusErrorKind::InvalidTime, 1103 MycStatusErrorKind::InvalidModel, 1104 MycStatusErrorKind::Encoding, 1105 MycStatusErrorKind::ResponseTooLarge, 1106 MycStatusErrorKind::InvalidTransition, 1107 MycStatusErrorKind::PublisherDropped, 1108 ] { 1109 let error = MycStatusError::new(kind); 1110 assert!(!error.code().is_empty()); 1111 assert!(Error::source(&error).is_none()); 1112 assert!(!format!("{error} {error:?}").contains("source")); 1113 } 1114 } 1115 }