laminar_core/cluster/control/namespace_proof/
mod.rs1use std::sync::Arc;
2use std::time::Duration;
3
4use futures::StreamExt;
5use object_store::{ObjectStore, ObjectStoreExt};
6use serde::{Deserialize, Serialize};
7use sha2::{Digest, Sha256};
8
9use super::ClusterKv;
10use crate::checkpoint::{CheckpointParticipant, MAX_CHECKPOINT_PARTICIPANTS};
11use crate::cluster::discovery::NodeId;
12
13const NAMESPACE_PROOF_KEY: &str = "control:shared-checkpoint-namespace-proof-v1";
14const NAMESPACE_PROOF_VERSION: u8 = 1;
15const NAMESPACE_PROOF_MAX_RECORD_BYTES: usize = 512;
16const NAMESPACE_PROOF_MAX_SENTINEL_BYTES: u64 = 512;
17const NAMESPACE_PROOF_RETRY_INTERVAL: Duration = Duration::from_millis(100);
18const NAMESPACE_PROOF_READ_CONCURRENCY: usize = 16;
19
20pub const MAX_SHARED_NAMESPACE_PROOF_TIMEOUT: Duration = Duration::from_secs(60);
22
23#[derive(Clone)]
25pub struct VerifiedClusterNamespaces {
26 checkpoint: Arc<dyn ObjectStore>,
27 local: CheckpointParticipant,
28}
29
30impl std::fmt::Debug for VerifiedClusterNamespaces {
31 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
32 formatter
33 .debug_struct("VerifiedClusterNamespaces")
34 .finish_non_exhaustive()
35 }
36}
37
38impl VerifiedClusterNamespaces {
39 #[must_use]
41 pub fn checkpoint_store(&self) -> Arc<dyn ObjectStore> {
42 Arc::clone(&self.checkpoint)
43 }
44
45 #[must_use]
47 pub const fn local_participant(&self) -> CheckpointParticipant {
48 self.local
49 }
50}
51
52#[derive(Debug, thiserror::Error)]
54#[error("{message}")]
55pub struct NamespaceProofError {
56 message: String,
57}
58
59impl NamespaceProofError {
60 fn configuration(message: &'static str) -> Self {
61 Self {
62 message: message.to_string(),
63 }
64 }
65
66 fn verification(message: &str) -> Self {
67 Self {
68 message: format!("shared checkpoint namespace proof failed: {message}"),
69 }
70 }
71
72 fn timeout(timeout: Duration) -> Self {
73 Self {
74 message: format!("shared checkpoint namespace proof exceeded {timeout:?}"),
75 }
76 }
77}
78
79#[derive(Deserialize, Serialize)]
80#[serde(deny_unknown_fields)]
81struct NamespaceProofRecord {
82 version: u8,
83 node_id: u64,
84 boot_incarnation: uuid::Uuid,
85 nonce: uuid::Uuid,
86 roster_sha256: String,
87}
88
89impl NamespaceProofRecord {
90 fn validate_identity(&self, participant: CheckpointParticipant) -> Result<(), String> {
91 if self.version != NAMESPACE_PROOF_VERSION
92 || self.node_id != participant.node_id
93 || self.boot_incarnation != participant.boot_incarnation
94 || self.nonce.is_nil()
95 || self.roster_sha256.len() != 64
96 || !self
97 .roster_sha256
98 .bytes()
99 .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
100 {
101 return Err(format!(
102 "node {} published a stale or mismatched shared checkpoint namespace proof",
103 participant.node_id
104 ));
105 }
106 Ok(())
107 }
108}
109
110fn namespace_proof_roster_sha256(participants: &[CheckpointParticipant]) -> String {
111 const HEX: &[u8; 16] = b"0123456789abcdef";
112
113 let mut hash = Sha256::new();
114 hash.update(b"LAMINAR_SHARED_CHECKPOINT_NAMESPACE_ROSTER_V1\0");
115 hash.update(
116 u64::try_from(participants.len())
117 .unwrap_or(u64::MAX)
118 .to_be_bytes(),
119 );
120 for participant in participants {
121 hash.update(participant.node_id.to_be_bytes());
122 hash.update(participant.boot_incarnation.as_bytes());
123 }
124 let digest = hash.finalize();
125 let mut encoded = String::with_capacity(digest.len() * 2);
126 for byte in digest {
127 encoded.push(HEX[(byte >> 4) as usize] as char);
128 encoded.push(HEX[(byte & 0x0f) as usize] as char);
129 }
130 encoded
131}
132
133fn namespace_proof_path(node_id: u64) -> object_store::path::Path {
134 object_store::path::Path::from(format!(
135 "cluster-checkpoint-namespace-proof/v1/node={node_id}/sentinel"
136 ))
137}
138
139fn namespace_proof_sentinel(record: &NamespaceProofRecord) -> bytes::Bytes {
140 bytes::Bytes::from(format!(
141 "LAMINAR_SHARED_CHECKPOINT_NAMESPACE_V1\n{}\n{}\n{}\n{}\n",
142 record.node_id, record.boot_incarnation, record.nonce, record.roster_sha256
143 ))
144}
145
146async fn write_namespace_proof_sentinel(
147 object_store: &Arc<dyn ObjectStore>,
148 record: &NamespaceProofRecord,
149) -> Result<(), String> {
150 let payload = namespace_proof_sentinel(record);
151 if u64::try_from(payload.len()).unwrap_or(u64::MAX) > NAMESPACE_PROOF_MAX_SENTINEL_BYTES {
152 return Err("shared checkpoint namespace sentinel exceeds its fixed size bound".into());
153 }
154 object_store
155 .put(
156 &namespace_proof_path(record.node_id),
157 object_store::PutPayload::from(payload),
158 )
159 .await
160 .map(|_| ())
161 .map_err(|error| format!("write checkpoint namespace sentinel: {error}"))
162}
163
164async fn read_namespace_proof_sentinel(
165 object_store: &Arc<dyn ObjectStore>,
166 record: &NamespaceProofRecord,
167) -> Result<(), String> {
168 let result = object_store
169 .get(&namespace_proof_path(record.node_id))
170 .await
171 .map_err(|error| {
172 format!(
173 "read node {} checkpoint namespace sentinel: {error}",
174 record.node_id
175 )
176 })?;
177 if result.meta.size == 0 || result.meta.size > NAMESPACE_PROOF_MAX_SENTINEL_BYTES {
178 return Err(format!(
179 "node {} checkpoint namespace sentinel is {} bytes; maximum is {}",
180 record.node_id, result.meta.size, NAMESPACE_PROOF_MAX_SENTINEL_BYTES
181 ));
182 }
183 let bytes = result.bytes().await.map_err(|error| {
184 format!(
185 "read node {} checkpoint namespace sentinel body: {error}",
186 record.node_id
187 )
188 })?;
189 if bytes != namespace_proof_sentinel(record) {
190 return Err(format!(
191 "node {} checkpoint namespace sentinel does not match its boot proof",
192 record.node_id
193 ));
194 }
195 Ok(())
196}
197
198async fn verify_namespace_proof_visibility(
199 control: &Arc<dyn ClusterKv>,
200 checkpoint_store: &Arc<dyn ObjectStore>,
201 participants: &[CheckpointParticipant],
202 local: CheckpointParticipant,
203 roster_sha256: &str,
204) -> Result<(), String> {
205 let checks = futures::stream::iter(participants.iter().copied())
206 .map(|participant| async move {
207 let encoded = control
208 .read_from_checked(NodeId(participant.node_id), NAMESPACE_PROOF_KEY)
209 .await?
210 .ok_or_else(|| {
211 format!(
212 "node {} has not published its shared checkpoint namespace proof",
213 participant.node_id
214 )
215 })?;
216 if encoded.len() > NAMESPACE_PROOF_MAX_RECORD_BYTES {
217 return Err(format!(
218 "node {} shared checkpoint namespace proof exceeds {} bytes",
219 participant.node_id, NAMESPACE_PROOF_MAX_RECORD_BYTES
220 ));
221 }
222 let record: NamespaceProofRecord = serde_json::from_str(&encoded).map_err(|error| {
223 format!(
224 "decode node {} shared checkpoint namespace proof: {error}",
225 participant.node_id
226 )
227 })?;
228 record.validate_identity(participant)?;
229 if participant == local && record.roster_sha256 != roster_sha256 {
230 return Err(
231 "local shared checkpoint namespace proof has the wrong startup roster".into(),
232 );
233 }
234 read_namespace_proof_sentinel(checkpoint_store, &record).await?;
235 Ok::<_, String>(())
236 })
237 .buffer_unordered(NAMESPACE_PROOF_READ_CONCURRENCY);
238 let results: Vec<Result<(), String>> = checks.collect().await;
239 for result in results {
240 result?;
241 }
242 Ok(())
243}
244
245async fn wait_for_namespace_proof_visibility(
246 control: &Arc<dyn ClusterKv>,
247 checkpoint_store: &Arc<dyn ObjectStore>,
248 participants: &[CheckpointParticipant],
249 local: CheckpointParticipant,
250 roster_sha256: &str,
251 deadline: tokio::time::Instant,
252) -> Result<(), String> {
253 let mut last_failure = "no verification attempt completed".to_string();
254 loop {
255 let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
256 if remaining.is_zero() {
257 return Err(format!(
258 "shared checkpoint namespace peer visibility timed out: {last_failure}"
259 ));
260 }
261 match tokio::time::timeout(
262 remaining,
263 verify_namespace_proof_visibility(
264 control,
265 checkpoint_store,
266 participants,
267 local,
268 roster_sha256,
269 ),
270 )
271 .await
272 {
273 Ok(Ok(())) => return Ok(()),
274 Ok(Err(error)) => last_failure = error,
275 Err(_) => {
276 return Err(format!(
277 "shared checkpoint namespace peer visibility timed out: {last_failure}"
278 ));
279 }
280 }
281 tokio::time::sleep(
282 NAMESPACE_PROOF_RETRY_INTERVAL
283 .min(deadline.saturating_duration_since(tokio::time::Instant::now())),
284 )
285 .await;
286 }
287}
288
289pub async fn prove_shared_object_store_namespaces(
296 local: CheckpointParticipant,
297 participants: &[CheckpointParticipant],
298 control: Arc<dyn ClusterKv>,
299 checkpoint_store: Arc<dyn ObjectStore>,
300 timeout: Duration,
301) -> Result<VerifiedClusterNamespaces, NamespaceProofError> {
302 if participants.is_empty()
303 || participants.len() > MAX_CHECKPOINT_PARTICIPANTS
304 || participants
305 .windows(2)
306 .any(|pair| pair[0].node_id >= pair[1].node_id)
307 || participants
308 .iter()
309 .any(|participant| participant.node_id == 0 || participant.boot_incarnation.is_nil())
310 || participants
311 .iter()
312 .filter(|participant| **participant == local)
313 .count()
314 != 1
315 {
316 return Err(NamespaceProofError::configuration(
317 "shared checkpoint namespace proof requires one canonical exact startup roster",
318 ));
319 }
320 let timeout = timeout.min(MAX_SHARED_NAMESPACE_PROOF_TIMEOUT);
321 if timeout.is_zero() {
322 return Err(NamespaceProofError::configuration(
323 "shared checkpoint namespace proof timeout is zero",
324 ));
325 }
326 let roster_sha256 = namespace_proof_roster_sha256(participants);
327 let record = NamespaceProofRecord {
328 version: NAMESPACE_PROOF_VERSION,
329 node_id: local.node_id,
330 boot_incarnation: local.boot_incarnation,
331 nonce: uuid::Uuid::new_v4(),
332 roster_sha256: roster_sha256.clone(),
333 };
334 let deadline = tokio::time::Instant::now() + timeout;
335 let proof = async {
336 write_namespace_proof_sentinel(&checkpoint_store, &record).await?;
337 let encoded = serde_json::to_string(&record).map_err(|error| error.to_string())?;
338 if encoded.len() > NAMESPACE_PROOF_MAX_RECORD_BYTES {
339 return Err(
340 "local shared checkpoint namespace proof exceeds its size bound".to_string(),
341 );
342 }
343 control.write_checked(NAMESPACE_PROOF_KEY, encoded).await?;
344 wait_for_namespace_proof_visibility(
345 &control,
346 &checkpoint_store,
347 participants,
348 local,
349 &roster_sha256,
350 deadline,
351 )
352 .await
353 };
354 match tokio::time::timeout(timeout, proof).await {
355 Ok(Ok(())) => Ok(VerifiedClusterNamespaces {
356 checkpoint: checkpoint_store,
357 local,
358 }),
359 Ok(Err(error)) => Err(NamespaceProofError::verification(&error)),
360 Err(_) => Err(NamespaceProofError::timeout(timeout)),
361 }
362}
363
364#[cfg(test)]
365mod tests;