lib

Core libraries for Radroots
git clone https://radroots.dev/git/lib.git
Log | Files | Refs | README

service.rs (24272B)


      1 //! Generic, bounded detailed-status envelope.
      2 
      3 use core::{fmt, time::Duration};
      4 use std::io::{self, Write};
      5 
      6 use radroots_runtime_paths::{InstanceId, ServiceId};
      7 use serde::{Serialize, ser::SerializeMap};
      8 
      9 use crate::BuildInfo;
     10 
     11 use super::ServiceOperationalState;
     12 
     13 pub const SERVICE_STATUS_CONTRACT_VERSION: u32 = 1;
     14 pub const CONFIGURATION_SCHEMA_VERSION: u32 = 1;
     15 pub const SERVICE_STATUS_MAX_UTF8_BYTES: usize = 1_048_576;
     16 pub const STATUS_ID_MAX_BYTES: usize = 128;
     17 
     18 /// A bounded identifier matching the frozen operator-contract grammar.
     19 #[derive(Clone, Debug, Hash, PartialEq, Eq, PartialOrd, Ord, Serialize)]
     20 #[serde(transparent)]
     21 pub struct StatusId(String);
     22 
     23 impl StatusId {
     24     pub fn new(value: impl AsRef<str>) -> Result<Self, StatusModelError> {
     25         let value = value.as_ref();
     26         if !valid_status_id(value) {
     27             return Err(StatusModelError::InvalidStatusId);
     28         }
     29         Ok(Self(value.to_owned()))
     30     }
     31 
     32     #[must_use]
     33     pub fn as_str(&self) -> &str {
     34         &self.0
     35     }
     36 }
     37 
     38 /// A lowercase SHA-256 digest without a wire prefix.
     39 #[derive(Clone, Debug, Hash, PartialEq, Eq, PartialOrd, Ord, Serialize)]
     40 #[serde(transparent)]
     41 pub struct Sha256Digest(String);
     42 
     43 impl Sha256Digest {
     44     pub fn new(value: impl AsRef<str>) -> Result<Self, StatusModelError> {
     45         let value = value.as_ref();
     46         if value.len() != 64
     47             || !value
     48                 .bytes()
     49                 .all(|byte| byte.is_ascii_digit() || matches!(byte, b'a'..=b'f'))
     50         {
     51             return Err(StatusModelError::InvalidSha256Digest);
     52         }
     53         Ok(Self(value.to_owned()))
     54     }
     55 
     56     #[must_use]
     57     pub fn as_str(&self) -> &str {
     58         &self.0
     59     }
     60 }
     61 
     62 /// Source of the effective service configuration.
     63 #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
     64 #[serde(rename_all = "snake_case")]
     65 pub enum ConfigurationSource {
     66     ExplicitConfig,
     67     DerivedRepoLocal,
     68 }
     69 
     70 /// Safe identity of the effective configuration document.
     71 #[derive(Clone, Debug, PartialEq, Eq, Serialize)]
     72 pub struct ConfigurationIdentity {
     73     schema: StatusId,
     74     schema_version: u32,
     75     digest: Sha256Digest,
     76     source: ConfigurationSource,
     77 }
     78 
     79 impl ConfigurationIdentity {
     80     /// Derives the exact v1 configuration-schema identity from the validated service.
     81     pub fn for_service(
     82         service: &ServiceId,
     83         digest: Sha256Digest,
     84         source: ConfigurationSource,
     85     ) -> Result<Self, StatusModelError> {
     86         let schema = StatusId::new(format!("radroots.{service}.config"))?;
     87         Ok(Self {
     88             schema,
     89             schema_version: CONFIGURATION_SCHEMA_VERSION,
     90             digest,
     91             source,
     92         })
     93     }
     94 
     95     fn validate_for_service(&self, service: &ServiceId) -> Result<(), StatusModelError> {
     96         validate_configuration_binding(service, &self.schema, self.schema_version)
     97     }
     98 }
     99 
    100 /// Common persistence health classification.
    101 #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
    102 #[serde(rename_all = "snake_case")]
    103 pub enum PersistenceHealth {
    104     Ready,
    105     ReadOnly,
    106     RepairRequired,
    107     Unavailable,
    108 }
    109 
    110 /// Common persistence integrity state.
    111 #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
    112 #[serde(rename_all = "snake_case")]
    113 pub enum IntegrityState {
    114     Verified,
    115     VerificationRequired,
    116     Failed,
    117 }
    118 
    119 /// Shared persistence-status fields; service stores retain detailed policy.
    120 #[derive(Clone, Debug, PartialEq, Eq, Serialize)]
    121 pub struct PersistenceSummary {
    122     health: PersistenceHealth,
    123     schema_version: u32,
    124     generation: u64,
    125     integrity: IntegrityState,
    126     reason_codes: super::ReasonCodes,
    127 }
    128 
    129 impl PersistenceSummary {
    130     pub fn new(
    131         health: PersistenceHealth,
    132         schema_version: u32,
    133         generation: u64,
    134         integrity: IntegrityState,
    135         reason_codes: super::ReasonCodes,
    136     ) -> Result<Self, StatusModelError> {
    137         if schema_version == 0 {
    138             return Err(StatusModelError::InvalidSchemaVersion);
    139         }
    140         Ok(Self {
    141             health,
    142             schema_version,
    143             generation,
    144             integrity,
    145             reason_codes,
    146         })
    147     }
    148 }
    149 
    150 /// Common provider-health classification used inside service-owned summaries.
    151 #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
    152 #[serde(rename_all = "snake_case")]
    153 pub enum ProviderHealth {
    154     Ready,
    155     Degraded,
    156     Unavailable,
    157 }
    158 
    159 /// Common transport-health classification used inside service-owned summaries.
    160 #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
    161 #[serde(rename_all = "snake_case")]
    162 pub enum TransportHealth {
    163     Ready,
    164     Degraded,
    165     Unavailable,
    166 }
    167 
    168 /// Monotonic process uptime in exact whole milliseconds.
    169 #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize)]
    170 #[serde(transparent)]
    171 pub struct UptimeMillis(u64);
    172 
    173 impl UptimeMillis {
    174     pub fn from_duration(duration: Duration) -> Result<Self, StatusModelError> {
    175         u64::try_from(duration.as_millis())
    176             .map(Self)
    177             .map_err(|_| StatusModelError::UptimeOverflow)
    178     }
    179 
    180     #[must_use]
    181     pub const fn get(self) -> u64 {
    182         self.0
    183     }
    184 }
    185 
    186 /// Connects typed service detail with service-owned provider and transport summaries.
    187 pub trait ServiceStatusDetail: Serialize {
    188     type Provider: Serialize;
    189     type Transport: Serialize;
    190 
    191     /// Frozen detail field. It must equal the service identifier.
    192     const FIELD_NAME: &'static str;
    193 }
    194 
    195 /// Shared detailed-status envelope with service-owned typed detail.
    196 pub struct ServiceStatus<D>
    197 where
    198     D: ServiceStatusDetail,
    199 {
    200     service: ServiceId,
    201     instance: InstanceId,
    202     state: ServiceOperationalState,
    203     uptime: UptimeMillis,
    204     build: BuildInfo,
    205     configuration: ConfigurationIdentity,
    206     persistence: PersistenceSummary,
    207     provider: D::Provider,
    208     transport: D::Transport,
    209     detail: D,
    210 }
    211 
    212 impl<D> ServiceStatus<D>
    213 where
    214     D: ServiceStatusDetail,
    215 {
    216     #[allow(clippy::too_many_arguments)]
    217     pub fn new(
    218         service: ServiceId,
    219         instance: InstanceId,
    220         state: ServiceOperationalState,
    221         uptime: UptimeMillis,
    222         build: BuildInfo,
    223         configuration: ConfigurationIdentity,
    224         persistence: PersistenceSummary,
    225         provider: D::Provider,
    226         transport: D::Transport,
    227         detail: D,
    228     ) -> Result<Self, StatusModelError> {
    229         if D::FIELD_NAME != service.as_str() || !valid_status_detail_field(D::FIELD_NAME) {
    230             return Err(StatusModelError::InvalidDetailField);
    231         }
    232         configuration.validate_for_service(&service)?;
    233         Ok(Self {
    234             service,
    235             instance,
    236             state,
    237             uptime,
    238             build,
    239             configuration,
    240             persistence,
    241             provider,
    242             transport,
    243             detail,
    244         })
    245     }
    246 
    247     #[must_use]
    248     pub fn service(&self) -> &ServiceId {
    249         &self.service
    250     }
    251 
    252     #[must_use]
    253     pub fn instance(&self) -> &InstanceId {
    254         &self.instance
    255     }
    256 
    257     #[must_use]
    258     pub fn state(&self) -> &ServiceOperationalState {
    259         &self.state
    260     }
    261 
    262     /// Serializes without allowing the response buffer to exceed the frozen 1 MiB ceiling.
    263     pub fn to_bounded_json(&self) -> Result<Vec<u8>, StatusEncodingError> {
    264         let mut writer = BoundedWriter::new(SERVICE_STATUS_MAX_UTF8_BYTES);
    265         match serde_json::to_writer(&mut writer, self) {
    266             Ok(()) => Ok(writer.bytes),
    267             Err(_) if writer.exceeded => Err(StatusEncodingError::ResponseTooLarge),
    268             Err(_) => Err(StatusEncodingError::EncodingFailed),
    269         }
    270     }
    271 }
    272 
    273 impl<D> Serialize for ServiceStatus<D>
    274 where
    275     D: ServiceStatusDetail,
    276 {
    277     fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
    278     where
    279         S: serde::Serializer,
    280     {
    281         let mut map = serializer.serialize_map(Some(13))?;
    282         map.serialize_entry("contract_version", &SERVICE_STATUS_CONTRACT_VERSION)?;
    283         map.serialize_entry("service", &self.service)?;
    284         map.serialize_entry("instance", &self.instance)?;
    285         map.serialize_entry("phase", &self.state.phase())?;
    286         map.serialize_entry("ready", &self.state.readiness())?;
    287         map.serialize_entry("uptime_millis", &self.uptime)?;
    288         map.serialize_entry("reason_codes", self.state.reasons())?;
    289         map.serialize_entry("build_info", &self.build.status_projection())?;
    290         map.serialize_entry("configuration", &self.configuration)?;
    291         map.serialize_entry("persistence", &self.persistence)?;
    292         map.serialize_entry("provider", &self.provider)?;
    293         map.serialize_entry("transport", &self.transport)?;
    294         map.serialize_entry(D::FIELD_NAME, &self.detail)?;
    295         map.end()
    296     }
    297 }
    298 
    299 /// Validation failure for shared detailed-status models.
    300 #[derive(Clone, Copy, Debug, PartialEq, Eq)]
    301 pub enum StatusModelError {
    302     InvalidStatusId,
    303     InvalidSha256Digest,
    304     InvalidSchemaVersion,
    305     ConfigurationServiceMismatch,
    306     InvalidDetailField,
    307     UptimeOverflow,
    308 }
    309 
    310 impl fmt::Display for StatusModelError {
    311     fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
    312         formatter.write_str(match self {
    313             Self::InvalidStatusId => "status identifier is invalid",
    314             Self::InvalidSha256Digest => "status digest is invalid",
    315             Self::InvalidSchemaVersion => "status schema version is invalid",
    316             Self::ConfigurationServiceMismatch => "configuration schema does not match the service",
    317             Self::InvalidDetailField => "status detail field is invalid",
    318             Self::UptimeOverflow => "status uptime exceeds its u64 millisecond representation",
    319         })
    320     }
    321 }
    322 
    323 impl std::error::Error for StatusModelError {}
    324 
    325 /// Safe result category for bounded JSON encoding.
    326 #[derive(Clone, Copy, Debug, PartialEq, Eq)]
    327 pub enum StatusEncodingError {
    328     EncodingFailed,
    329     ResponseTooLarge,
    330 }
    331 
    332 impl fmt::Display for StatusEncodingError {
    333     fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
    334         formatter.write_str(match self {
    335             Self::EncodingFailed => "status response encoding failed",
    336             Self::ResponseTooLarge => "status response exceeds its byte limit",
    337         })
    338     }
    339 }
    340 
    341 impl std::error::Error for StatusEncodingError {}
    342 
    343 struct BoundedWriter {
    344     bytes: Vec<u8>,
    345     maximum: usize,
    346     exceeded: bool,
    347 }
    348 
    349 impl BoundedWriter {
    350     fn new(maximum: usize) -> Self {
    351         Self {
    352             bytes: Vec::with_capacity(4096),
    353             maximum,
    354             exceeded: false,
    355         }
    356     }
    357 }
    358 
    359 impl Write for BoundedWriter {
    360     fn write(&mut self, buffer: &[u8]) -> io::Result<usize> {
    361         let Some(next_length) = self.bytes.len().checked_add(buffer.len()) else {
    362             self.exceeded = true;
    363             return Err(io::Error::other("bounded status response overflow"));
    364         };
    365         if next_length > self.maximum {
    366             self.exceeded = true;
    367             return Err(io::Error::other("bounded status response exceeded"));
    368         }
    369         self.bytes.extend_from_slice(buffer);
    370         Ok(buffer.len())
    371     }
    372 
    373     fn flush(&mut self) -> io::Result<()> {
    374         Ok(())
    375     }
    376 }
    377 
    378 fn valid_status_id(value: &str) -> bool {
    379     let mut bytes = value.bytes();
    380     let Some(first) = bytes.next() else {
    381         return false;
    382     };
    383     value.len() <= STATUS_ID_MAX_BYTES
    384         && first.is_ascii_alphanumeric()
    385         && bytes
    386             .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b':' | b'-'))
    387 }
    388 
    389 fn valid_status_detail_field(value: &str) -> bool {
    390     !matches!(
    391         value,
    392         "contract_version"
    393             | "service"
    394             | "instance"
    395             | "phase"
    396             | "ready"
    397             | "uptime_millis"
    398             | "reason_codes"
    399             | "build_info"
    400             | "configuration"
    401             | "persistence"
    402             | "provider"
    403             | "transport"
    404     )
    405 }
    406 
    407 fn validate_configuration_binding(
    408     service: &ServiceId,
    409     schema: &StatusId,
    410     schema_version: u32,
    411 ) -> Result<(), StatusModelError> {
    412     if schema_version != CONFIGURATION_SCHEMA_VERSION {
    413         return Err(StatusModelError::InvalidSchemaVersion);
    414     }
    415     let expected = format!("radroots.{service}.config");
    416     if schema.as_str() != expected {
    417         return Err(StatusModelError::ConfigurationServiceMismatch);
    418     }
    419     Ok(())
    420 }
    421 
    422 #[cfg(test)]
    423 mod tests {
    424     use serde::Serialize;
    425 
    426     use crate::{
    427         BuildInfoEnvironment, BuildMode, CommonReasonCode, ContractVersions, Readiness,
    428         ServiceOperationalState, ServicePhase,
    429     };
    430 
    431     use super::*;
    432 
    433     const SERVICE_REVISION: &str = "0123456789abcdef0123456789abcdef01234567";
    434     const LIB_REVISION: &str = "89abcdef0123456789abcdef0123456789abcdef";
    435 
    436     #[derive(Serialize)]
    437     struct Provider {
    438         health: ProviderHealth,
    439         reason_codes: super::super::ReasonCodes,
    440     }
    441 
    442     #[derive(Serialize)]
    443     struct Transport {
    444         health: TransportHealth,
    445         required_relays_ready: bool,
    446         connected_relay_count: u64,
    447         reason_codes: super::super::ReasonCodes,
    448     }
    449 
    450     #[derive(Serialize)]
    451     struct Detail {
    452         active_connections: u64,
    453     }
    454 
    455     impl ServiceStatusDetail for Detail {
    456         type Provider = Provider;
    457         type Transport = Transport;
    458         const FIELD_NAME: &'static str = "myc";
    459     }
    460 
    461     struct FailingDetail;
    462 
    463     impl Serialize for FailingDetail {
    464         fn serialize<S>(&self, _serializer: S) -> Result<S::Ok, S::Error>
    465         where
    466             S: serde::Serializer,
    467         {
    468             Err(serde::ser::Error::custom("injected detail failure"))
    469         }
    470     }
    471 
    472     impl ServiceStatusDetail for FailingDetail {
    473         type Provider = Provider;
    474         type Transport = Transport;
    475         const FIELD_NAME: &'static str = "myc";
    476     }
    477 
    478     #[derive(Serialize)]
    479     struct ReservedDetail;
    480 
    481     impl ServiceStatusDetail for ReservedDetail {
    482         type Provider = Provider;
    483         type Transport = Transport;
    484         const FIELD_NAME: &'static str = "service";
    485     }
    486 
    487     #[derive(Serialize)]
    488     struct MismatchedDetail;
    489 
    490     impl ServiceStatusDetail for MismatchedDetail {
    491         type Provider = Provider;
    492         type Transport = Transport;
    493         const FIELD_NAME: &'static str = "rhi";
    494     }
    495 
    496     fn status(detail: Detail) -> ServiceStatus<Detail> {
    497         let build = BuildInfo::from_compile_time(
    498             BuildMode::Release,
    499             BuildInfoEnvironment {
    500                 service_version: Some("0.1.0-alpha"),
    501                 service_commit: Some(SERVICE_REVISION),
    502                 lib_revision: Some(LIB_REVISION),
    503                 rust_version: Some("1.97.1"),
    504                 target: Some("x86_64-unknown-linux-gnu"),
    505                 feature_profile: Some("service-host"),
    506                 contract_versions: ContractVersions::new(1, 1, 1, 1, 1).unwrap(),
    507             },
    508         )
    509         .unwrap();
    510         let state = ServiceOperationalState::new(
    511             ServicePhase::Degraded,
    512             Readiness::READY,
    513             super::super::ReasonCodes::new([CommonReasonCode::DatabaseLowDisk.into()]).unwrap(),
    514         )
    515         .unwrap();
    516         let service = ServiceId::new("myc").unwrap();
    517         let configuration = ConfigurationIdentity::for_service(
    518             &service,
    519             Sha256Digest::new("a".repeat(64)).unwrap(),
    520             ConfigurationSource::ExplicitConfig,
    521         )
    522         .unwrap();
    523         let persistence = PersistenceSummary::new(
    524             PersistenceHealth::Ready,
    525             1,
    526             7,
    527             IntegrityState::Verified,
    528             super::super::ReasonCodes::empty(),
    529         )
    530         .unwrap();
    531         ServiceStatus::new(
    532             service,
    533             InstanceId::new("default").unwrap(),
    534             state,
    535             UptimeMillis::from_duration(Duration::from_millis(120_000)).unwrap(),
    536             build,
    537             configuration,
    538             persistence,
    539             Provider {
    540                 health: ProviderHealth::Degraded,
    541                 reason_codes: super::super::ReasonCodes::empty(),
    542             },
    543             Transport {
    544                 health: TransportHealth::Ready,
    545                 required_relays_ready: true,
    546                 connected_relay_count: 2,
    547                 reason_codes: super::super::ReasonCodes::empty(),
    548             },
    549             detail,
    550         )
    551         .unwrap()
    552     }
    553 
    554     #[test]
    555     fn status_json_snapshot_matches_the_frozen_envelope() {
    556         let json = String::from_utf8(
    557             status(Detail {
    558                 active_connections: 3,
    559             })
    560             .to_bounded_json()
    561             .unwrap(),
    562         )
    563         .unwrap();
    564         assert_eq!(
    565             json,
    566             r#"{"contract_version":1,"service":"myc","instance":"default","phase":"degraded","ready":true,"uptime_millis":120000,"reason_codes":["database_low_disk"],"build_info":{"version":"0.1.0-alpha","revision":"0123456789abcdef0123456789abcdef01234567","toolchain":"1.97.1","contract_versions":{"config":1,"state":1,"admin":1,"status":1,"provider":1}},"configuration":{"schema":"radroots.myc.config","schema_version":1,"digest":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","source":"explicit_config"},"persistence":{"health":"ready","schema_version":1,"generation":7,"integrity":"verified","reason_codes":[]},"provider":{"health":"degraded","reason_codes":[]},"transport":{"health":"ready","required_relays_ready":true,"connected_relay_count":2,"reason_codes":[]},"myc":{"active_connections":3}}"#
    567         );
    568         assert!(json.len() < SERVICE_STATUS_MAX_UTF8_BYTES);
    569         for forbidden in ["secret", "credential", "private_key", "password", "path"] {
    570             assert!(!json.contains(forbidden));
    571         }
    572     }
    573 
    574     #[derive(Serialize)]
    575     struct OversizedDetail {
    576         content: String,
    577     }
    578 
    579     impl ServiceStatusDetail for OversizedDetail {
    580         type Provider = Provider;
    581         type Transport = Transport;
    582         const FIELD_NAME: &'static str = "myc";
    583     }
    584 
    585     #[test]
    586     fn bounded_encoder_rejects_oversized_detail() {
    587         let ordinary = status(Detail {
    588             active_connections: 0,
    589         });
    590         let oversized = ServiceStatus::new(
    591             ordinary.service,
    592             ordinary.instance,
    593             ordinary.state,
    594             ordinary.uptime,
    595             ordinary.build,
    596             ordinary.configuration,
    597             ordinary.persistence,
    598             ordinary.provider,
    599             ordinary.transport,
    600             OversizedDetail {
    601                 content: "x".repeat(SERVICE_STATUS_MAX_UTF8_BYTES),
    602             },
    603         )
    604         .unwrap();
    605         assert_eq!(
    606             oversized.to_bounded_json(),
    607             Err(StatusEncodingError::ResponseTooLarge)
    608         );
    609     }
    610 
    611     #[test]
    612     fn identifiers_versions_digests_and_uptime_fail_closed() {
    613         for invalid in ["", ".schema", "bad schema", "slash/name"] {
    614             assert!(StatusId::new(invalid).is_err());
    615         }
    616         assert!(StatusId::new("a".repeat(STATUS_ID_MAX_BYTES)).is_ok());
    617         assert!(StatusId::new("a".repeat(STATUS_ID_MAX_BYTES + 1)).is_err());
    618         let very_large = "a".repeat(4 * 1024 * 1024);
    619         assert_eq!(
    620             StatusId::new(&very_large),
    621             Err(StatusModelError::InvalidStatusId)
    622         );
    623         assert!(Sha256Digest::new("a".repeat(63)).is_err());
    624         assert!(Sha256Digest::new("a".repeat(64)).is_ok());
    625         assert!(Sha256Digest::new("A".repeat(64)).is_err());
    626         assert_eq!(
    627             Sha256Digest::new(&very_large),
    628             Err(StatusModelError::InvalidSha256Digest)
    629         );
    630         let myc = ServiceId::new("myc").unwrap();
    631         assert_eq!(
    632             validate_configuration_binding(
    633                 &myc,
    634                 &StatusId::new("radroots.rhi.config").unwrap(),
    635                 CONFIGURATION_SCHEMA_VERSION,
    636             ),
    637             Err(StatusModelError::ConfigurationServiceMismatch)
    638         );
    639         assert_eq!(
    640             validate_configuration_binding(
    641                 &myc,
    642                 &StatusId::new("radroots.myc.config").unwrap(),
    643                 CONFIGURATION_SCHEMA_VERSION + 1,
    644             ),
    645             Err(StatusModelError::InvalidSchemaVersion)
    646         );
    647         assert_eq!(
    648             UptimeMillis::from_duration(Duration::MAX),
    649             Err(StatusModelError::UptimeOverflow)
    650         );
    651     }
    652 
    653     #[test]
    654     fn model_accessors_reserved_fields_and_encoding_errors_are_closed() {
    655         let ordinary = status(Detail {
    656             active_connections: 1,
    657         });
    658         assert_eq!(ordinary.service().as_str(), "myc");
    659         assert_eq!(ordinary.instance().as_str(), "default");
    660         assert_eq!(ordinary.state().phase(), ServicePhase::Degraded);
    661         assert_eq!(ordinary.uptime.get(), 120_000);
    662         assert_eq!(
    663             ordinary.configuration.schema.as_str(),
    664             "radroots.myc.config"
    665         );
    666         assert_eq!(ordinary.configuration.digest.as_str(), "a".repeat(64));
    667 
    668         assert_eq!(
    669             PersistenceSummary::new(
    670                 PersistenceHealth::Unavailable,
    671                 0,
    672                 0,
    673                 IntegrityState::Failed,
    674                 super::super::ReasonCodes::empty(),
    675             ),
    676             Err(StatusModelError::InvalidSchemaVersion)
    677         );
    678         assert!(Sha256Digest::new("0".repeat(64)).is_ok());
    679         assert_eq!(
    680             Sha256Digest::new(format!("{}g", "a".repeat(63))),
    681             Err(StatusModelError::InvalidSha256Digest)
    682         );
    683 
    684         let failing = ServiceStatus::new(
    685             ordinary.service,
    686             ordinary.instance,
    687             ordinary.state,
    688             ordinary.uptime,
    689             ordinary.build,
    690             ordinary.configuration,
    691             ordinary.persistence,
    692             ordinary.provider,
    693             ordinary.transport,
    694             FailingDetail,
    695         )
    696         .unwrap();
    697         assert_eq!(
    698             failing.to_bounded_json(),
    699             Err(StatusEncodingError::EncodingFailed)
    700         );
    701 
    702         let ordinary = status(Detail {
    703             active_connections: 1,
    704         });
    705         let service = ServiceId::new("service").unwrap();
    706         let configuration = ConfigurationIdentity::for_service(
    707             &service,
    708             Sha256Digest::new("b".repeat(64)).unwrap(),
    709             ConfigurationSource::DerivedRepoLocal,
    710         )
    711         .unwrap();
    712         assert!(matches!(
    713             ServiceStatus::new(
    714                 service,
    715                 ordinary.instance,
    716                 ordinary.state,
    717                 ordinary.uptime,
    718                 ordinary.build,
    719                 configuration,
    720                 ordinary.persistence,
    721                 ordinary.provider,
    722                 ordinary.transport,
    723                 ReservedDetail,
    724             ),
    725             Err(StatusModelError::InvalidDetailField)
    726         ));
    727 
    728         let ordinary = status(Detail {
    729             active_connections: 1,
    730         });
    731         assert!(matches!(
    732             ServiceStatus::new(
    733                 ordinary.service,
    734                 ordinary.instance,
    735                 ordinary.state,
    736                 ordinary.uptime,
    737                 ordinary.build,
    738                 ordinary.configuration,
    739                 ordinary.persistence,
    740                 ordinary.provider,
    741                 ordinary.transport,
    742                 MismatchedDetail,
    743             ),
    744             Err(StatusModelError::InvalidDetailField)
    745         ));
    746 
    747         let mut writer = BoundedWriter::new(2);
    748         assert_eq!(writer.write(b"ab").unwrap(), 2);
    749         writer.flush().unwrap();
    750         assert!(writer.write(b"c").is_err());
    751         assert!(writer.exceeded);
    752 
    753         for error in [
    754             StatusModelError::InvalidStatusId,
    755             StatusModelError::InvalidSha256Digest,
    756             StatusModelError::InvalidSchemaVersion,
    757             StatusModelError::ConfigurationServiceMismatch,
    758             StatusModelError::InvalidDetailField,
    759             StatusModelError::UptimeOverflow,
    760         ] {
    761             assert!(!error.to_string().is_empty());
    762             assert!(std::error::Error::source(&error).is_none());
    763         }
    764         for error in [
    765             StatusEncodingError::EncodingFailed,
    766             StatusEncodingError::ResponseTooLarge,
    767         ] {
    768             assert!(!error.to_string().is_empty());
    769             assert!(std::error::Error::source(&error).is_none());
    770         }
    771     }
    772 }