1use std::collections::BTreeSet;
18use std::fmt;
19
20use rocketmq_error::Sensitive;
21use serde::Deserialize;
22use serde::Serialize;
23
24use crate::ProtocolContractViolation;
25
26pub const RELEASE_CHECKPOINT_SCHEMA_VERSION: u16 = 1;
28
29#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
31#[serde(deny_unknown_fields, rename_all = "camelCase")]
32pub struct MaintenanceCapabilitiesResponse {
33 pub schema_version: u16,
34 pub policy_id: String,
35 pub policy_version: u64,
36 pub operations: Vec<String>,
37 pub max_checkpoint_bytes: u64,
38 pub max_store_members: u32,
39 pub max_concurrent_operations: u32,
40 #[serde(default, skip_serializing_if = "Option::is_none")]
41 pub store: Option<MaintenanceStoreCapabilities>,
42}
43
44#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
46#[serde(deny_unknown_fields, rename_all = "camelCase")]
47pub struct MaintenanceStoreCapabilities {
48 pub member_id: String,
49 pub backend: ReleaseCheckpointBackend,
50 pub storage_identity: ReleaseCheckpointStorageIdentity,
51}
52
53#[derive(Clone, Copy, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)]
55#[serde(rename_all = "snake_case")]
56pub enum ReleaseCheckpointBackend {
57 Local,
59 RocksDb,
61}
62
63#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
65#[serde(deny_unknown_fields, rename_all = "camelCase")]
66pub struct ReleaseCheckpointOffsets {
67 pub appended_offset: i64,
69 pub durable_offset: i64,
71 pub consume_queue_offset: i64,
73 pub index_offset: i64,
75}
76
77impl ReleaseCheckpointOffsets {
78 pub fn validate(self) -> Result<(), ProtocolContractViolation> {
84 if self.appended_offset < 0 || self.durable_offset < 0 || self.consume_queue_offset < 0 || self.index_offset < 0
85 {
86 return Err(ProtocolContractViolation::InvalidCheckpointOffsets {
87 reason: "checkpoint offsets cannot be negative",
88 });
89 }
90 if self.durable_offset > self.appended_offset {
91 return Err(ProtocolContractViolation::InvalidCheckpointOffsets {
92 reason: "durable_offset cannot exceed appended_offset",
93 });
94 }
95 if self.consume_queue_offset > self.durable_offset || self.index_offset > self.durable_offset {
96 return Err(ProtocolContractViolation::InvalidCheckpointOffsets {
97 reason: "derived offsets cannot exceed durable_offset",
98 });
99 }
100 Ok(())
101 }
102}
103
104#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
106#[serde(deny_unknown_fields, rename_all = "camelCase")]
107pub struct ReleaseCheckpointStorageIdentity {
108 pub volume_id: String,
110 pub wal_generation: u64,
112}
113
114impl ReleaseCheckpointStorageIdentity {
115 fn validate(&self) -> Result<(), ProtocolContractViolation> {
116 require_identifier("storageIdentity.volumeId", &self.volume_id)?;
117 if self.wal_generation == 0 {
118 return Err(ProtocolContractViolation::InvalidCheckpointField {
119 field: "storageIdentity.walGeneration",
120 reason: "must be greater than zero",
121 });
122 }
123 Ok(())
124 }
125}
126
127#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
129#[serde(deny_unknown_fields, rename_all = "camelCase")]
130pub struct ReleaseCheckpointArtifact {
131 pub schema_version: u16,
133 pub checkpoint_id: String,
135 pub checkpoint_set_id: String,
137 pub generation: u64,
139 pub barrier_id: String,
141 pub created_at_unix_millis: u64,
143 pub length_bytes: u64,
145 pub sha256: String,
147 pub uri: String,
149}
150
151impl ReleaseCheckpointArtifact {
152 pub fn validate(&self) -> Result<(), ProtocolContractViolation> {
158 if self.schema_version != RELEASE_CHECKPOINT_SCHEMA_VERSION {
159 return Err(ProtocolContractViolation::CheckpointSchemaVersion {
160 expected: RELEASE_CHECKPOINT_SCHEMA_VERSION,
161 actual: self.schema_version,
162 });
163 }
164 require_identifier("checkpointId", &self.checkpoint_id)?;
165 require_identifier("checkpointSetId", &self.checkpoint_set_id)?;
166 require_identifier("barrierId", &self.barrier_id)?;
167 if self.generation == 0 {
168 return Err(ProtocolContractViolation::InvalidCheckpointField {
169 field: "generation",
170 reason: "must be greater than zero",
171 });
172 }
173 if self.created_at_unix_millis == 0 {
174 return Err(ProtocolContractViolation::InvalidCheckpointField {
175 field: "createdAtUnixMillis",
176 reason: "must be greater than zero",
177 });
178 }
179 if self.length_bytes == 0 {
180 return Err(ProtocolContractViolation::InvalidCheckpointField {
181 field: "lengthBytes",
182 reason: "must be greater than zero",
183 });
184 }
185 require_sha256(&self.sha256)?;
186 if self.uri.trim().is_empty() || self.uri.bytes().any(|byte| matches!(byte, b'\r' | b'\n')) {
187 return Err(ProtocolContractViolation::InvalidCheckpointField {
188 field: "uri",
189 reason: "must be a non-empty single-line URI",
190 });
191 }
192 Ok(())
193 }
194}
195
196#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
198#[serde(deny_unknown_fields, rename_all = "camelCase")]
199pub struct ControllerReleaseSnapshotRequest {
200 pub checkpoint_id: String,
201 pub checkpoint_set_id: String,
202 pub generation: u64,
203 pub barrier_id: String,
204}
205
206impl ControllerReleaseSnapshotRequest {
207 pub fn validate(&self) -> Result<(), ProtocolContractViolation> {
214 require_identifier("checkpointId", &self.checkpoint_id)?;
215 require_identifier("checkpointSetId", &self.checkpoint_set_id)?;
216 require_identifier("barrierId", &self.barrier_id)?;
217 if self.generation == 0 {
218 return Err(ProtocolContractViolation::InvalidCheckpointField {
219 field: "generation",
220 reason: "must be greater than zero",
221 });
222 }
223 Ok(())
224 }
225}
226
227#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
229#[serde(deny_unknown_fields, rename_all = "camelCase")]
230pub struct ControllerReleaseSnapshotManifest {
231 pub artifact: ReleaseCheckpointArtifact,
233 pub snapshot_id: String,
235 pub last_applied_index: u64,
237 pub last_applied_term: u64,
239 pub voter_ids: Vec<u64>,
241}
242
243impl ControllerReleaseSnapshotManifest {
244 pub fn validate(&self) -> Result<(), ProtocolContractViolation> {
251 self.artifact.validate()?;
252 require_identifier("snapshotId", &self.snapshot_id)?;
253 if self.last_applied_index == 0 || self.last_applied_term == 0 {
254 return Err(ProtocolContractViolation::InvalidCheckpointField {
255 field: "lastApplied",
256 reason: "term and index must be greater than zero",
257 });
258 }
259 let voters = self.voter_ids.iter().copied().collect::<BTreeSet<_>>();
260 if voters.is_empty() || voters.len() != self.voter_ids.len() || voters.contains(&0) {
261 return Err(ProtocolContractViolation::InvalidCheckpointField {
262 field: "voterIds",
263 reason: "must contain unique non-zero Controller voters",
264 });
265 }
266 Ok(())
267 }
268}
269
270#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
272#[serde(deny_unknown_fields, rename_all = "camelCase")]
273pub struct StoreReleaseCheckpointRequest {
274 pub checkpoint_id: String,
275 pub checkpoint_set_id: String,
276 pub generation: u64,
277 pub barrier_id: String,
278 pub member_id: String,
279 pub offsets: ReleaseCheckpointOffsets,
280 pub storage_identity: ReleaseCheckpointStorageIdentity,
281}
282
283impl StoreReleaseCheckpointRequest {
284 pub fn validate(&self) -> Result<(), ProtocolContractViolation> {
291 require_identifier("checkpointId", &self.checkpoint_id)?;
292 require_identifier("checkpointSetId", &self.checkpoint_set_id)?;
293 require_identifier("barrierId", &self.barrier_id)?;
294 require_identifier("memberId", &self.member_id)?;
295 if self.generation == 0 {
296 return Err(ProtocolContractViolation::InvalidCheckpointField {
297 field: "generation",
298 reason: "must be greater than zero",
299 });
300 }
301 self.offsets.validate()?;
302 self.storage_identity.validate()
303 }
304}
305
306#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
308#[serde(deny_unknown_fields, rename_all = "camelCase")]
309pub struct StoreReleaseCheckpointManifest {
310 pub artifact: ReleaseCheckpointArtifact,
312 pub member_id: String,
314 pub backend: ReleaseCheckpointBackend,
316 pub offsets: ReleaseCheckpointOffsets,
318 pub storage_identity: ReleaseCheckpointStorageIdentity,
320 pub wal_retained: bool,
322 pub persistent_volume_retained: bool,
324}
325
326impl StoreReleaseCheckpointManifest {
327 pub fn validate(&self) -> Result<(), ProtocolContractViolation> {
334 self.artifact.validate()?;
335 require_identifier("memberId", &self.member_id)?;
336 self.offsets.validate()?;
337 self.storage_identity.validate()?;
338 if !self.wal_retained || !self.persistent_volume_retained {
339 return Err(ProtocolContractViolation::DestructiveRollback);
340 }
341 Ok(())
342 }
343}
344
345#[derive(Clone, Deserialize, Eq, PartialEq, Serialize)]
347#[serde(deny_unknown_fields, rename_all = "camelCase")]
348pub struct ReleaseCheckpointSetManifest {
349 pub schema_version: u16,
350 pub checkpoint_set_id: String,
351 pub release_id: String,
352 pub generation: u64,
353 pub barrier_id: String,
354 pub policy_version: u64,
355 pub fencing_token: u64,
356 pub created_at_unix_millis: u64,
357 pub controller: ControllerReleaseSnapshotManifest,
358 pub stores: Vec<StoreReleaseCheckpointManifest>,
359}
360
361impl fmt::Debug for ReleaseCheckpointSetManifest {
362 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
363 formatter
364 .debug_struct("ReleaseCheckpointSetManifest")
365 .field("schema_version", &self.schema_version)
366 .field("checkpoint_set_id", &self.checkpoint_set_id)
367 .field("release_id", &self.release_id)
368 .field("generation", &self.generation)
369 .field("barrier_id", &self.barrier_id)
370 .field("policy_version", &self.policy_version)
371 .field("fencing_token", &Sensitive::new(self.fencing_token))
372 .field("created_at_unix_millis", &self.created_at_unix_millis)
373 .field("controller", &self.controller)
374 .field("stores", &self.stores)
375 .finish()
376 }
377}
378
379impl ReleaseCheckpointSetManifest {
380 pub fn validate(&self) -> Result<(), ProtocolContractViolation> {
387 if self.schema_version != RELEASE_CHECKPOINT_SCHEMA_VERSION {
388 return Err(ProtocolContractViolation::CheckpointSchemaVersion {
389 expected: RELEASE_CHECKPOINT_SCHEMA_VERSION,
390 actual: self.schema_version,
391 });
392 }
393 require_identifier("checkpointSetId", &self.checkpoint_set_id)?;
394 require_identifier("releaseId", &self.release_id)?;
395 require_identifier("barrierId", &self.barrier_id)?;
396 if self.generation == 0 || self.policy_version == 0 || self.fencing_token == 0 {
397 return Err(ProtocolContractViolation::InvalidCheckpointField {
398 field: "generation/policyVersion/fencingToken",
399 reason: "must be greater than zero",
400 });
401 }
402 if self.created_at_unix_millis == 0 {
403 return Err(ProtocolContractViolation::InvalidCheckpointField {
404 field: "createdAtUnixMillis",
405 reason: "must be greater than zero",
406 });
407 }
408 self.controller.validate()?;
409 if self.stores.is_empty() {
410 return Err(ProtocolContractViolation::MissingStoreMembers);
411 }
412
413 self.validate_binding("Controller", &self.controller.artifact)?;
414 let mut members = BTreeSet::new();
415 for store in &self.stores {
416 store.validate()?;
417 self.validate_binding("Store", &store.artifact)?;
418 if !members.insert(store.member_id.as_str()) {
419 return Err(ProtocolContractViolation::DuplicateStoreMember);
420 }
421 }
422 Ok(())
423 }
424
425 fn validate_binding(
426 &self,
427 member: &'static str,
428 artifact: &ReleaseCheckpointArtifact,
429 ) -> Result<(), ProtocolContractViolation> {
430 if artifact.checkpoint_set_id != self.checkpoint_set_id
431 || artifact.generation != self.generation
432 || artifact.barrier_id != self.barrier_id
433 {
434 return Err(ProtocolContractViolation::CheckpointSetBindingMismatch { member });
435 }
436 Ok(())
437 }
438}
439
440#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
442#[serde(deny_unknown_fields, rename_all = "camelCase")]
443pub struct ReleaseCheckpointRestoreVerification {
444 pub checkpoint_id: String,
445 pub generation: u64,
446 pub verified_at_unix_millis: u64,
447 pub checksum_verified: bool,
448 pub offsets_verified: bool,
449 pub storage_identity_verified: bool,
450 pub wal_retained: bool,
451 pub persistent_volume_retained: bool,
452}
453
454impl ReleaseCheckpointRestoreVerification {
455 pub fn validate(&self) -> Result<(), ProtocolContractViolation> {
462 require_identifier("checkpointId", &self.checkpoint_id)?;
463 if self.generation == 0 || self.verified_at_unix_millis == 0 {
464 return Err(ProtocolContractViolation::InvalidCheckpointField {
465 field: "generation/verifiedAtUnixMillis",
466 reason: "must be greater than zero",
467 });
468 }
469 if !self.checksum_verified
470 || !self.offsets_verified
471 || !self.storage_identity_verified
472 || !self.wal_retained
473 || !self.persistent_volume_retained
474 {
475 return Err(ProtocolContractViolation::RestoreVerificationIncomplete);
476 }
477 Ok(())
478 }
479}
480
481fn require_identifier(field: &'static str, value: &str) -> Result<(), ProtocolContractViolation> {
482 if value.is_empty()
483 || value.len() > 256
484 || !value
485 .bytes()
486 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-' | b':' | b'/' | b'@'))
487 {
488 return Err(ProtocolContractViolation::InvalidCheckpointField {
489 field,
490 reason: "must be a canonical identifier",
491 });
492 }
493 Ok(())
494}
495
496fn require_sha256(value: &str) -> Result<(), ProtocolContractViolation> {
497 if value.len() != 64
498 || !value
499 .bytes()
500 .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
501 {
502 return Err(ProtocolContractViolation::InvalidCheckpointField {
503 field: "sha256",
504 reason: "must be 64 lowercase hexadecimal characters",
505 });
506 }
507 Ok(())
508}
509
510#[cfg(test)]
511mod tests {
512 use super::*;
513
514 fn artifact(id: &str) -> ReleaseCheckpointArtifact {
515 ReleaseCheckpointArtifact {
516 schema_version: 1,
517 checkpoint_id: id.to_string(),
518 checkpoint_set_id: "set-7".to_string(),
519 generation: 7,
520 barrier_id: "barrier-42".to_string(),
521 created_at_unix_millis: 1_800_000_000_000,
522 length_bytes: 512,
523 sha256: "a".repeat(64),
524 uri: format!("file:///checkpoints/{id}"),
525 }
526 }
527
528 fn store(member: &str) -> StoreReleaseCheckpointManifest {
529 StoreReleaseCheckpointManifest {
530 artifact: artifact(&format!("store-{member}")),
531 member_id: member.to_string(),
532 backend: ReleaseCheckpointBackend::Local,
533 offsets: ReleaseCheckpointOffsets {
534 appended_offset: 120,
535 durable_offset: 120,
536 consume_queue_offset: 100,
537 index_offset: 100,
538 },
539 storage_identity: ReleaseCheckpointStorageIdentity {
540 volume_id: format!("pvc-{member}"),
541 wal_generation: 7,
542 },
543 wal_retained: true,
544 persistent_volume_retained: true,
545 }
546 }
547
548 #[test]
549 fn release_checkpoint_set_binds_controller_stores_and_offsets() {
550 let manifest = ReleaseCheckpointSetManifest {
551 schema_version: 1,
552 checkpoint_set_id: "set-7".to_string(),
553 release_id: "release-7".to_string(),
554 generation: 7,
555 barrier_id: "barrier-42".to_string(),
556 policy_version: 3,
557 fencing_token: 42,
558 created_at_unix_millis: 1_800_000_000_000,
559 controller: ControllerReleaseSnapshotManifest {
560 artifact: artifact("controller"),
561 snapshot_id: "snapshot-99".to_string(),
562 last_applied_index: 99,
563 last_applied_term: 3,
564 voter_ids: vec![1, 2, 3],
565 },
566 stores: vec![store("broker-a"), store("broker-b")],
567 };
568
569 manifest.validate().expect("complete set should validate");
570 }
571
572 #[test]
573 fn release_checkpoint_set_rejects_barrier_drift_and_destructive_restore() {
574 let mut store = store("broker-a");
575 store.artifact.barrier_id = "other-barrier".to_string();
576 store.wal_retained = false;
577 let manifest = ReleaseCheckpointSetManifest {
578 schema_version: 1,
579 checkpoint_set_id: "set-7".to_string(),
580 release_id: "release-7".to_string(),
581 generation: 7,
582 barrier_id: "barrier-42".to_string(),
583 policy_version: 3,
584 fencing_token: 42,
585 created_at_unix_millis: 1_800_000_000_000,
586 controller: ControllerReleaseSnapshotManifest {
587 artifact: artifact("controller"),
588 snapshot_id: "snapshot-99".to_string(),
589 last_applied_index: 99,
590 last_applied_term: 3,
591 voter_ids: vec![1],
592 },
593 stores: vec![store],
594 };
595
596 assert!(manifest.validate().is_err());
597 }
598
599 #[test]
600 fn release_checkpoint_set_debug_redacts_fencing_token() {
601 let manifest = ReleaseCheckpointSetManifest {
602 schema_version: 1,
603 checkpoint_set_id: "set-7".to_string(),
604 release_id: "release-7".to_string(),
605 generation: 7,
606 barrier_id: "barrier-42".to_string(),
607 policy_version: 3,
608 fencing_token: 987_654_321,
609 created_at_unix_millis: 1_800_000_000_000,
610 controller: ControllerReleaseSnapshotManifest {
611 artifact: artifact("controller"),
612 snapshot_id: "snapshot-99".to_string(),
613 last_applied_index: 99,
614 last_applied_term: 3,
615 voter_ids: vec![1],
616 },
617 stores: vec![store("broker-a")],
618 };
619
620 let debug = format!("{manifest:?}");
621
622 assert!(debug.contains("<redacted>"));
623 assert!(!debug.contains("987654321"));
624 }
625}