1use std::error::Error;
8use std::fmt::{self, Display, Formatter};
9use std::sync::Arc;
10use std::time::Duration;
11
12use lodestone_proto::{
13 CLUSTER_CONTROL_REQUEST_FORMAT_VERSION, CLUSTER_CONTROL_SESSION_FORMAT_VERSION,
14 COLLECTION_RETIREMENT_STATUS_FORMAT_VERSION, ClusterControlActionV3, ClusterControlCredentialKindV1,
15 ClusterControlRequestV3, ClusterControlResponseDispositionV3, ClusterControlResponseOutcomeV3,
16 ClusterControlSessionV3, CollectionAuthorityObservationRequestV1, CollectionAuthorityObservationV1,
17 CollectionRetirementOutcomeV1, CollectionRetirementRequestV1, MAX_CLUSTER_CONTROL_FRAME_BYTES,
18 decode_cluster_control_response_v3, encode_cluster_control_request_v3,
19};
20use rustls::pki_types::{CertificateDer, ServerName};
21use serde::Serialize;
22use tokio::io::{AsyncReadExt, AsyncWriteExt};
23use tokio::net::TcpStream;
24use tokio_rustls::TlsConnector;
25
26pub const CLUSTER_CONTROL_ALPN: &[u8] = b"lodestone-cluster-control-v3";
27const MAX_CLUSTER_CONTROL_CALL_MILLIS: u64 = 120_000;
28const MAX_CLUSTER_CONTROL_ENDPOINT_BYTES: usize = 512;
29
30pub struct ClusterControlCredential {
31 kind: ClusterControlCredentialKindV1,
32 bytes: Vec<u8>,
33}
34
35impl ClusterControlCredential {
36 pub fn new(kind: ClusterControlCredentialKindV1, bytes: Vec<u8>) -> Result<Self, ClusterControlClientError> {
37 if lodestone_proto::validate_cluster_control_credential_v1(&bytes).is_err() {
38 return Err(ClusterControlClientError::InvalidConfiguration);
39 }
40 Ok(Self { kind, bytes })
41 }
42
43 #[must_use]
44 pub const fn kind(&self) -> ClusterControlCredentialKindV1 {
45 self.kind
46 }
47}
48
49impl fmt::Debug for ClusterControlCredential {
50 fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result {
51 formatter
52 .debug_struct("ClusterControlCredential")
53 .field("kind", &self.kind)
54 .field("bytes", &"[REDACTED]")
55 .finish()
56 }
57}
58
59impl Drop for ClusterControlCredential {
60 fn drop(&mut self) {
61 self.bytes.fill(0);
62 std::hint::black_box(&mut self.bytes);
63 }
64}
65
66pub struct ClusterControlClientConfig {
67 endpoint: String,
68 server_name: String,
69 trust_certificates_der: Vec<Vec<u8>>,
70 credential: ClusterControlCredential,
71 timeout: Duration,
72}
73
74impl ClusterControlClientConfig {
75 pub fn new(
76 endpoint: String,
77 server_name: String,
78 trust_certificates_der: Vec<Vec<u8>>,
79 credential: ClusterControlCredential,
80 timeout: Duration,
81 ) -> Result<Self, ClusterControlClientError> {
82 if endpoint.is_empty()
83 || endpoint.len() > MAX_CLUSTER_CONTROL_ENDPOINT_BYTES
84 || endpoint.parse::<std::net::SocketAddr>().is_err()
85 || !matches!(ServerName::try_from(server_name.clone()), Ok(ServerName::DnsName(_)))
86 || trust_certificates_der.is_empty()
87 || timeout.is_zero()
88 || timeout > Duration::from_millis(MAX_CLUSTER_CONTROL_CALL_MILLIS)
89 {
90 return Err(ClusterControlClientError::InvalidConfiguration);
91 }
92 Ok(Self {
93 endpoint,
94 server_name,
95 trust_certificates_der,
96 credential,
97 timeout,
98 })
99 }
100}
101
102pub struct ClusterControlClient {
103 config: ClusterControlClientConfig,
104 tls: Arc<rustls::ClientConfig>,
105}
106
107impl ClusterControlClient {
108 pub fn new(config: ClusterControlClientConfig) -> Result<Self, ClusterControlClientError> {
109 let tls = build_tls(&config)?;
110 Ok(Self {
111 config,
112 tls: Arc::new(tls),
113 })
114 }
115
116 pub async fn initialize_collection(
117 &self,
118 request_id: &str,
119 body: impl Serialize,
120 ) -> Result<serde_json::Value, ClusterControlClientError> {
121 self.call(ClusterControlActionV3::InitializeCollectionAuthority, request_id, body)
122 .await
123 }
124
125 pub async fn activate_collection(
126 &self,
127 request_id: &str,
128 body: impl Serialize,
129 ) -> Result<serde_json::Value, ClusterControlClientError> {
130 self.call(ClusterControlActionV3::ActivateCollectionAuthority, request_id, body)
131 .await
132 }
133
134 pub async fn resolve_collection(
135 &self,
136 request_id: &str,
137 body: impl Serialize,
138 ) -> Result<serde_json::Value, ClusterControlClientError> {
139 self.call(ClusterControlActionV3::ResolveCollectionAuthority, request_id, body)
140 .await
141 }
142
143 pub async fn observe_collection_authority(
147 &self,
148 request_id: &str,
149 request: &CollectionAuthorityObservationRequestV1,
150 ) -> Result<CollectionAuthorityObservationV1, ClusterControlClientError> {
151 request.validate().map_err(|_| ClusterControlClientError::Codec)?;
152 let body = self
153 .call(ClusterControlActionV3::ObserveCollectionAuthority, request_id, request)
154 .await?;
155 decode_collection_authority_observation(body, request)
156 }
157
158 pub async fn inspect_collection_retirement(
159 &self,
160 request_id: &str,
161 body: impl Serialize,
162 ) -> Result<serde_json::Value, ClusterControlClientError> {
163 self.call(ClusterControlActionV3::InspectCollectionRetirement, request_id, body)
164 .await
165 }
166
167 pub async fn retire_collection(
168 &self,
169 request_id: &str,
170 body: impl Serialize,
171 ) -> Result<serde_json::Value, ClusterControlClientError> {
172 self.call(ClusterControlActionV3::RetireCollectionAuthority, request_id, body)
173 .await
174 }
175
176 pub async fn submit_collection_retirement(
180 &self,
181 request_id: &str,
182 request: &CollectionRetirementRequestV1,
183 ) -> Result<CollectionRetirementOutcomeV1, ClusterControlClientError> {
184 request.validate().map_err(|_| ClusterControlClientError::Codec)?;
185 self.call_collection_retirement(
186 ClusterControlActionV3::RetireCollectionAuthority,
187 request_id,
188 request,
189 request,
190 )
191 .await
192 }
193
194 pub async fn resolve_collection_retirement(
197 &self,
198 request_id: &str,
199 request: &CollectionRetirementRequestV1,
200 ) -> Result<CollectionRetirementOutcomeV1, ClusterControlClientError> {
201 request.validate().map_err(|_| ClusterControlClientError::Codec)?;
202 let body = CollectionRetirementResolutionBodyV1::Retire {
203 request: request.clone(),
204 };
205 self.call_collection_retirement(
206 ClusterControlActionV3::ResolveCollectionAuthority,
207 request_id,
208 body,
209 request,
210 )
211 .await
212 }
213
214 async fn call_collection_retirement(
215 &self,
216 action: ClusterControlActionV3,
217 request_id: &str,
218 body: impl Serialize,
219 request: &CollectionRetirementRequestV1,
220 ) -> Result<CollectionRetirementOutcomeV1, ClusterControlClientError> {
221 classify_collection_retirement_call(self.call(action, request_id, body).await, request)
222 }
223
224 pub async fn observe_capacity(&self, request_id: &str) -> Result<serde_json::Value, ClusterControlClientError> {
225 self.call(
226 ClusterControlActionV3::ObserveClusterCapacity,
227 request_id,
228 serde_json::json!({}),
229 )
230 .await
231 }
232
233 pub async fn observe_resource_diagnostics(
234 &self,
235 request_id: &str,
236 body: impl Serialize,
237 ) -> Result<serde_json::Value, ClusterControlClientError> {
238 self.call(ClusterControlActionV3::ObserveResourceDiagnostics, request_id, body)
239 .await
240 }
241
242 pub async fn reserve_credential_rotation(
243 &self,
244 request_id: &str,
245 body: impl Serialize,
246 ) -> Result<serde_json::Value, ClusterControlClientError> {
247 self.call(
248 ClusterControlActionV3::ReserveClusterControlCredentialRotation,
249 request_id,
250 body,
251 )
252 .await
253 }
254
255 pub async fn activate_credential_rotation(
256 &self,
257 request_id: &str,
258 body: impl Serialize,
259 ) -> Result<serde_json::Value, ClusterControlClientError> {
260 self.call(
261 ClusterControlActionV3::ActivateClusterControlCredentialRotation,
262 request_id,
263 body,
264 )
265 .await
266 }
267
268 pub async fn revoke_credential(
269 &self,
270 request_id: &str,
271 body: impl Serialize,
272 ) -> Result<serde_json::Value, ClusterControlClientError> {
273 self.call(ClusterControlActionV3::RevokeClusterControlCredential, request_id, body)
274 .await
275 }
276
277 pub async fn resolve_credential(
278 &self,
279 request_id: &str,
280 body: impl Serialize,
281 ) -> Result<serde_json::Value, ClusterControlClientError> {
282 self.call(
283 ClusterControlActionV3::ResolveClusterControlCredential,
284 request_id,
285 body,
286 )
287 .await
288 }
289
290 pub async fn list_backups(&self, request_id: &str) -> Result<serde_json::Value, ClusterControlClientError> {
291 self.call(
292 ClusterControlActionV3::ListClusterBackups,
293 request_id,
294 serde_json::json!({}),
295 )
296 .await
297 }
298
299 pub async fn get_backup(
300 &self,
301 request_id: &str,
302 body: impl Serialize,
303 ) -> Result<serde_json::Value, ClusterControlClientError> {
304 self.call(ClusterControlActionV3::GetClusterBackup, request_id, body)
305 .await
306 }
307
308 pub async fn create_backup(
309 &self,
310 request_id: &str,
311 body: impl Serialize,
312 ) -> Result<serde_json::Value, ClusterControlClientError> {
313 self.call(ClusterControlActionV3::CreateClusterBackup, request_id, body)
314 .await
315 }
316
317 pub async fn resolve_backup(
318 &self,
319 request_id: &str,
320 body: impl Serialize,
321 ) -> Result<serde_json::Value, ClusterControlClientError> {
322 self.call(ClusterControlActionV3::ResolveClusterBackup, request_id, body)
323 .await
324 }
325
326 pub async fn delete_backup(
327 &self,
328 request_id: &str,
329 body: impl Serialize,
330 ) -> Result<serde_json::Value, ClusterControlClientError> {
331 self.call(ClusterControlActionV3::DeleteClusterBackup, request_id, body)
332 .await
333 }
334
335 pub async fn resolve_backup_deletion(
336 &self,
337 request_id: &str,
338 body: impl Serialize,
339 ) -> Result<serde_json::Value, ClusterControlClientError> {
340 self.call(ClusterControlActionV3::ResolveClusterBackupDeletion, request_id, body)
341 .await
342 }
343
344 pub async fn restore_readiness(&self, request_id: &str) -> Result<serde_json::Value, ClusterControlClientError> {
345 self.call(
346 ClusterControlActionV3::GetClusterRestoreReadiness,
347 request_id,
348 serde_json::json!({}),
349 )
350 .await
351 }
352
353 pub async fn prepare_offline_recovery(
354 &self,
355 request_id: &str,
356 body: impl Serialize,
357 ) -> Result<serde_json::Value, ClusterControlClientError> {
358 self.call(ClusterControlActionV3::PrepareOfflineRecovery, request_id, body)
359 .await
360 }
361
362 pub async fn call(
363 &self,
364 action: ClusterControlActionV3,
365 request_id: &str,
366 body: impl Serialize,
367 ) -> Result<serde_json::Value, ClusterControlClientError> {
368 if !self.config.credential.kind.admits(action) {
369 return Err(ClusterControlClientError::ActionDenied);
370 }
371 let body = serde_json::to_value(body).map_err(|_| ClusterControlClientError::Codec)?;
372 if !body.is_object() {
373 return Err(ClusterControlClientError::Codec);
374 }
375 let mut correlation = [0_u8; 16];
376 getrandom::fill(&mut correlation).map_err(|_| ClusterControlClientError::RandomUnavailable)?;
377 let mut request = ClusterControlRequestV3 {
378 format_version: CLUSTER_CONTROL_REQUEST_FORMAT_VERSION,
379 session: ClusterControlSessionV3 {
380 format_version: CLUSTER_CONTROL_SESSION_FORMAT_VERSION,
381 credential_kind: self.config.credential.kind,
382 credential: self.config.credential.bytes.clone(),
383 request_id: request_id.to_owned(),
384 action,
385 request_correlation_id: correlation,
386 },
387 body,
388 };
389 let encoded = encode_cluster_control_request_v3(&request);
390 request.session.credential.fill(0);
391 std::hint::black_box(&mut request.session.credential);
392 let mut payload = encoded.map_err(|_| ClusterControlClientError::Codec)?;
393 let result = tokio::time::timeout(self.config.timeout, self.call_once(&payload, request_id, correlation)).await;
394 payload.fill(0);
395 std::hint::black_box(&mut payload);
396 result.map_err(|_| ClusterControlClientError::DeadlineExceeded)?
397 }
398
399 async fn call_once(
400 &self,
401 payload: &[u8],
402 request_id: &str,
403 correlation: [u8; 16],
404 ) -> Result<serde_json::Value, ClusterControlClientError> {
405 let tcp = TcpStream::connect(&self.config.endpoint)
406 .await
407 .map_err(|_| ClusterControlClientError::Transport)?;
408 let server_name = ServerName::try_from(self.config.server_name.clone())
409 .map_err(|_| ClusterControlClientError::InvalidConfiguration)?;
410 let connector = TlsConnector::from(Arc::clone(&self.tls));
411 let mut tls = connector
412 .connect(server_name, tcp)
413 .await
414 .map_err(|_| ClusterControlClientError::Transport)?;
415 let length = u32::try_from(payload.len()).map_err(|_| ClusterControlClientError::Codec)?;
416 tls.write_u32(length)
417 .await
418 .map_err(|_| ClusterControlClientError::Transport)?;
419 tls.write_all(payload)
420 .await
421 .map_err(|_| ClusterControlClientError::Transport)?;
422 let length = usize::try_from(tls.read_u32().await.map_err(|_| ClusterControlClientError::Transport)?)
423 .map_err(|_| ClusterControlClientError::Codec)?;
424 if length == 0 || length > MAX_CLUSTER_CONTROL_FRAME_BYTES {
425 return Err(ClusterControlClientError::Codec);
426 }
427 let mut response = vec![0; length];
428 tls.read_exact(&mut response)
429 .await
430 .map_err(|_| ClusterControlClientError::Transport)?;
431 let response = decode_cluster_control_response_v3(&response).map_err(|_| ClusterControlClientError::Codec)?;
432 if response.request_id != request_id || response.request_correlation_id != correlation {
433 return Err(ClusterControlClientError::ResponseIdentityMismatch);
434 }
435 match response.outcome {
436 ClusterControlResponseOutcomeV3::Succeeded { body } => Ok(body),
437 ClusterControlResponseOutcomeV3::Refused { code, disposition } => {
438 Err(ClusterControlClientError::Refused(ClusterControlRefusal {
439 code,
440 disposition,
441 }))
442 }
443 }
444 }
445}
446
447fn decode_collection_authority_observation(
448 body: serde_json::Value,
449 request: &CollectionAuthorityObservationRequestV1,
450) -> Result<CollectionAuthorityObservationV1, ClusterControlClientError> {
451 let observation: CollectionAuthorityObservationV1 =
452 serde_json::from_value(body).map_err(|_| ClusterControlClientError::Codec)?;
453 observation.validate().map_err(|_| ClusterControlClientError::Codec)?;
454 if observation.collection_id != request.collection_id {
455 return Err(ClusterControlClientError::ResponseIdentityMismatch);
456 }
457 Ok(observation)
458}
459
460fn classify_collection_retirement_call(
461 result: Result<serde_json::Value, ClusterControlClientError>,
462 request: &CollectionRetirementRequestV1,
463) -> Result<CollectionRetirementOutcomeV1, ClusterControlClientError> {
464 match result {
465 Ok(body) => match serde_json::from_value::<CollectionRetirementOutcomeV1>(body) {
466 Ok(outcome)
467 if outcome.validate().is_ok() && collection_retirement_outcome_matches_request(&outcome, request)? =>
468 {
469 Ok(outcome)
470 }
471 Ok(_) | Err(_) => Ok(CollectionRetirementOutcomeV1::Indeterminate {
472 format_version: COLLECTION_RETIREMENT_STATUS_FORMAT_VERSION,
473 request: Box::new(request.clone()),
474 }),
475 },
476 Err(ClusterControlClientError::Refused(refusal))
477 if refusal.disposition == ClusterControlResponseDispositionV3::RetrySameRequest =>
478 {
479 Ok(CollectionRetirementOutcomeV1::NotSubmitted {
480 format_version: COLLECTION_RETIREMENT_STATUS_FORMAT_VERSION,
481 request_digest: request
482 .canonical_request_digest()
483 .map_err(|_| ClusterControlClientError::Codec)?,
484 })
485 }
486 Err(ClusterControlClientError::Transport)
487 | Err(ClusterControlClientError::DeadlineExceeded)
488 | Err(ClusterControlClientError::ResponseIdentityMismatch)
489 | Err(ClusterControlClientError::Codec)
490 | Err(ClusterControlClientError::Refused(ClusterControlRefusal {
491 disposition: ClusterControlResponseDispositionV3::ResolveOperation,
492 ..
493 })) => Ok(CollectionRetirementOutcomeV1::Indeterminate {
494 format_version: COLLECTION_RETIREMENT_STATUS_FORMAT_VERSION,
495 request: Box::new(request.clone()),
496 }),
497 Err(error) => Err(error),
498 }
499}
500
501fn collection_retirement_outcome_matches_request(
502 outcome: &CollectionRetirementOutcomeV1,
503 request: &CollectionRetirementRequestV1,
504) -> Result<bool, ClusterControlClientError> {
505 let request_digest = request
506 .canonical_request_digest()
507 .map_err(|_| ClusterControlClientError::Codec)?;
508 let admitted_generation = request.expected_security_generation.get().checked_add(1);
509 Ok(match outcome {
510 CollectionRetirementOutcomeV1::NotSubmitted {
511 request_digest: observed,
512 ..
513 } => *observed == request_digest,
514 CollectionRetirementOutcomeV1::Blocked {
515 request_digest: observed,
516 admitted_security_generation,
517 ..
518 }
519 | CollectionRetirementOutcomeV1::InProgress {
520 request_digest: observed,
521 admitted_security_generation,
522 ..
523 } => *observed == request_digest && admitted_generation == Some(admitted_security_generation.get()),
524 CollectionRetirementOutcomeV1::Indeterminate { request: observed, .. } => **observed == *request,
525 CollectionRetirementOutcomeV1::Resolved { receipt, .. } => {
526 receipt.operation_id == request.operation_id
527 && receipt.request_digest == request_digest
528 && receipt.collection_id == request.collection_id
529 && admitted_generation == Some(receipt.admitted_security_generation.get())
530 }
531 CollectionRetirementOutcomeV1::KnownRetired {
532 request_digest: observed,
533 collection_id,
534 ..
535 } => *observed == request_digest && *collection_id == request.collection_id,
536 CollectionRetirementOutcomeV1::ExpiredUnknown {
537 request_digest: observed,
538 collection_id,
539 operation_id,
540 ..
541 } => {
542 *observed == request_digest
543 && *collection_id == request.collection_id
544 && *operation_id == request.operation_id
545 }
546 })
547}
548
549#[derive(Serialize)]
550#[serde(tag = "operation", rename_all = "snake_case")]
551enum CollectionRetirementResolutionBodyV1 {
552 Retire { request: CollectionRetirementRequestV1 },
553}
554
555fn build_tls(config: &ClusterControlClientConfig) -> Result<rustls::ClientConfig, ClusterControlClientError> {
556 let mut roots = rustls::RootCertStore::empty();
557 for certificate in &config.trust_certificates_der {
558 roots
559 .add(CertificateDer::from(certificate.clone()))
560 .map_err(|_| ClusterControlClientError::InvalidConfiguration)?;
561 }
562 let provider = Arc::new(rustls::crypto::aws_lc_rs::default_provider());
563 let mut tls = rustls::ClientConfig::builder_with_provider(provider)
564 .with_protocol_versions(&[&rustls::version::TLS13])
565 .map_err(|_| ClusterControlClientError::InvalidConfiguration)?
566 .with_root_certificates(roots)
567 .with_no_client_auth();
568 tls.alpn_protocols = vec![CLUSTER_CONTROL_ALPN.to_vec()];
569 Ok(tls)
570}
571
572#[derive(Clone, Debug, Eq, PartialEq)]
573pub struct ClusterControlRefusal {
574 pub code: String,
575 pub disposition: ClusterControlResponseDispositionV3,
576}
577
578#[derive(Debug)]
579pub enum ClusterControlClientError {
580 InvalidConfiguration,
581 ActionDenied,
582 RandomUnavailable,
583 Codec,
584 Transport,
585 DeadlineExceeded,
586 ResponseIdentityMismatch,
587 Refused(ClusterControlRefusal),
588}
589
590impl Display for ClusterControlClientError {
591 fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result {
592 formatter.write_str(match self {
593 Self::InvalidConfiguration => "cluster-control client configuration is invalid",
594 Self::ActionDenied => "cluster-control credential kind cannot invoke this action",
595 Self::RandomUnavailable => "cluster-control request identity entropy is unavailable",
596 Self::Codec => "cluster-control frame is invalid",
597 Self::Transport => "cluster-control transport failed",
598 Self::DeadlineExceeded => "cluster-control call deadline elapsed",
599 Self::ResponseIdentityMismatch => "cluster-control response identity differs from the request",
600 Self::Refused(_) => "cluster-control request was refused",
601 })
602 }
603}
604
605impl Error for ClusterControlClientError {}
606
607#[cfg(test)]
608mod tests {
609 use super::*;
610 use lodestone_proto::{
611 COLLECTION_AUTHORITY_OBSERVATION_FORMAT_VERSION, COLLECTION_AUTHORITY_OBSERVATION_REQUEST_FORMAT_VERSION,
612 COLLECTION_RETIREMENT_REQUEST_FORMAT_VERSION, CollectionAuthorityObservationStateV1, CollectionId,
613 CollectionRetirementClosurePolicyV1, CollectionSecurityGeneration, LogicalMillis, RecoveryEra,
614 };
615
616 fn retirement_request() -> Result<CollectionRetirementRequestV1, Box<dyn Error>> {
617 Ok(CollectionRetirementRequestV1 {
618 format_version: COLLECTION_RETIREMENT_REQUEST_FORMAT_VERSION,
619 operation_id: [19; 32],
620 collection_id: CollectionId::new(7)?,
621 expected_security_generation: CollectionSecurityGeneration::new(3),
622 closure_policy: CollectionRetirementClosurePolicyV1::RetireOwnedChildren,
623 })
624 }
625
626 #[test]
627 fn debug_never_exposes_cluster_credential() -> Result<(), ClusterControlClientError> {
628 let credential = ClusterControlCredential::new(
629 ClusterControlCredentialKindV1::CapacityObserver,
630 b"lcc1_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA".to_vec(),
631 )?;
632 let debug = format!("{credential:?}");
633 assert!(!debug.contains("lcc1_AAA"));
634 assert!(debug.contains("REDACTED"));
635 Ok(())
636 }
637
638 #[test]
639 fn collection_authority_observation_decoder_refuses_substitution_and_malformed_digest() -> Result<(), Box<dyn Error>>
640 {
641 let request = CollectionAuthorityObservationRequestV1 {
642 format_version: COLLECTION_AUTHORITY_OBSERVATION_REQUEST_FORMAT_VERSION,
643 collection_id: CollectionId::new(7)?,
644 };
645 let mut observation = CollectionAuthorityObservationV1 {
646 format_version: COLLECTION_AUTHORITY_OBSERVATION_FORMAT_VERSION,
647 collection_id: request.collection_id,
648 recovery_era: RecoveryEra::new(1)?,
649 visible_root_digest: [8; 32],
650 observed_at: LogicalMillis::new(9),
651 authority: CollectionAuthorityObservationStateV1::Active {
652 security_generation: CollectionSecurityGeneration::new(2),
653 },
654 semantic_digest: [0; 32],
655 };
656 observation.semantic_digest = observation.canonical_semantic_digest()?;
657 assert_eq!(
658 decode_collection_authority_observation(serde_json::to_value(observation)?, &request)?,
659 observation
660 );
661
662 let mut substituted = observation;
663 substituted.collection_id = CollectionId::new(8)?;
664 substituted.semantic_digest = substituted.canonical_semantic_digest()?;
665 assert!(matches!(
666 decode_collection_authority_observation(serde_json::to_value(substituted)?, &request),
667 Err(ClusterControlClientError::ResponseIdentityMismatch)
668 ));
669 let mut malformed = observation;
670 malformed.semantic_digest[0] ^= 1;
671 assert!(matches!(
672 decode_collection_authority_observation(serde_json::to_value(malformed)?, &request),
673 Err(ClusterControlClientError::Codec)
674 ));
675 Ok(())
676 }
677
678 #[test]
679 fn collection_retirement_call_classification_preserves_submission_authority() -> Result<(), Box<dyn Error>> {
680 let request = retirement_request()?;
681 let not_submitted = classify_collection_retirement_call(
682 Err(ClusterControlClientError::Refused(ClusterControlRefusal {
683 code: "temporarily_unavailable".to_owned(),
684 disposition: ClusterControlResponseDispositionV3::RetrySameRequest,
685 })),
686 &request,
687 )?;
688 assert!(matches!(
689 not_submitted,
690 CollectionRetirementOutcomeV1::NotSubmitted { request_digest, .. }
691 if request_digest == request.canonical_request_digest()?
692 ));
693
694 for ambiguous in [
695 ClusterControlClientError::Transport,
696 ClusterControlClientError::DeadlineExceeded,
697 ClusterControlClientError::ResponseIdentityMismatch,
698 ClusterControlClientError::Refused(ClusterControlRefusal {
699 code: "submitted_indeterminate".to_owned(),
700 disposition: ClusterControlResponseDispositionV3::ResolveOperation,
701 }),
702 ] {
703 assert!(matches!(
704 classify_collection_retirement_call(Err(ambiguous), &request)?,
705 CollectionRetirementOutcomeV1::Indeterminate { request: retained, .. }
706 if *retained == request
707 ));
708 }
709 assert!(matches!(
710 classify_collection_retirement_call(Ok(serde_json::json!({"state": "unknown"})), &request)?,
711 CollectionRetirementOutcomeV1::Indeterminate { .. }
712 ));
713 let mismatched = CollectionRetirementOutcomeV1::NotSubmitted {
714 format_version: COLLECTION_RETIREMENT_STATUS_FORMAT_VERSION,
715 request_digest: [99; 32],
716 };
717 assert!(matches!(
718 classify_collection_retirement_call(Ok(serde_json::to_value(mismatched)?), &request)?,
719 CollectionRetirementOutcomeV1::Indeterminate { request: retained, .. }
720 if *retained == request
721 ));
722 assert!(matches!(
723 classify_collection_retirement_call(
724 Err(ClusterControlClientError::Refused(ClusterControlRefusal {
725 code: "conflict".to_owned(),
726 disposition: ClusterControlResponseDispositionV3::CorrectRequest,
727 })),
728 &request,
729 ),
730 Err(ClusterControlClientError::Refused(_))
731 ));
732 Ok(())
733 }
734}