laminar_core/cluster/control/leader_lease/
topology_preparation.rs1use super::topology_admission::{
4 topology_assignment_error, CONTROL_TIMEOUT, MAX_ADMISSION_ATTEMPTS,
5};
6use super::{
7 read_authority_record, AssignmentSnapshotStore, AuthorityCreateOutcome, CatalogManifest,
8 LeaderLeaseStore, LeaseError, OsPath,
9};
10use crate::cluster::control::topology::{
11 ClusterTopologyValidation, TopologyAdmissionPhase, TopologyAdmissionPlan,
12 TopologyAdmissionStatus, TopologyCompatibilityRef, TopologyError, TopologyOperationId,
13 TopologyParticipantCertificate, TopologyPlanRef, TOPOLOGY_PREPARATION_PROTOCOL_VERSION,
14 TOPOLOGY_SUBMISSION_PROTOCOL_VERSION,
15};
16use crate::cluster::control::{LocalProcessAuthorityIdentity, ProcessLeaseAuthority};
17
18fn compatibility_path(reference: &TopologyCompatibilityRef) -> OsPath {
19 OsPath::from(format!(
20 "control/topology-compatibility/v1/{}.json",
21 reference.sha256
22 ))
23}
24
25impl LeaderLeaseStore {
26 pub async fn stage_topology_compatibility(
31 &self,
32 descriptor: &ClusterTopologyValidation,
33 ) -> Result<TopologyCompatibilityRef, TopologyError> {
34 let (bytes, reference) = descriptor.encode_and_reference()?;
35 tokio::time::timeout(
36 CONTROL_TIMEOUT,
37 self.stage_admission_blob(&compatibility_path(&reference), &bytes),
38 )
39 .await
40 .map_err(|_| TopologyError::Contended)??;
41 Ok(reference)
42 }
43
44 pub(super) async fn load_topology_compatibility(
45 &self,
46 reference: &TopologyCompatibilityRef,
47 ) -> Result<ClusterTopologyValidation, TopologyError> {
48 reference.validate()?;
49 let bytes = self
50 .read_admission_blob(&compatibility_path(reference), reference.encoded_len)
51 .await?;
52 let descriptor: ClusterTopologyValidation = serde_json::from_slice(&bytes)
53 .map_err(|error| TopologyError::Invalid(error.to_string()))?;
54 let (canonical, actual) = descriptor.encode_and_reference()?;
55 if actual != *reference || canonical.as_slice() != bytes.as_ref() {
56 return Err(TopologyError::Invalid(
57 "descriptor differs from its immutable canonical reference".into(),
58 ));
59 }
60 Ok(descriptor)
61 }
62
63 pub(super) async fn audit_topology_compatibility(
64 &self,
65 plan: &TopologyAdmissionPlan,
66 ) -> Result<Option<ClusterTopologyValidation>, TopologyError> {
67 let Some(reference) = &plan.compatibility else {
68 return Ok(None);
69 };
70 let descriptor = self.load_topology_compatibility(reference).await?;
71 if descriptor.parent_version != plan.expected_parent
72 || descriptor.parent_manifest != plan.parent_manifest
73 || descriptor.target_manifest != plan.target_manifest
74 {
75 return Err(TopologyError::Invalid(
76 "descriptor is not bound to the admitted catalog parent/target".into(),
77 ));
78 }
79 let parent = self.load_catalog_manifest(&plan.parent_manifest).await?;
80 let target = self.load_catalog_manifest(&plan.target_manifest).await?;
81 descriptor.validate_catalogs(&parent, &target)?;
82 Ok(Some(descriptor))
83 }
84
85 pub async fn topology_preparation_input(
90 &self,
91 operation_id: TopologyOperationId,
92 ) -> Result<
93 (
94 TopologyAdmissionStatus,
95 TopologyAdmissionPlan,
96 CatalogManifest,
97 ClusterTopologyValidation,
98 ),
99 TopologyError,
100 > {
101 tokio::time::timeout(CONTROL_TIMEOUT, async {
102 let current = self.load_record().await?.ok_or(TopologyError::Fenced)?;
103 let operation = current
104 .topology_operations
105 .iter()
106 .find(|entry| entry.operation_id == operation_id)
107 .ok_or_else(|| TopologyError::Conflict("unknown topology operation".into()))?;
108 self.audit_topology_operation(operation).await?;
109 let plan = self.load_topology_plan(&operation.plan).await?;
110 let descriptor = self
111 .audit_topology_compatibility(&plan)
112 .await?
113 .ok_or_else(|| {
114 TopologyError::Protocol(
115 "legacy plan has no participant compatibility binding".into(),
116 )
117 })?;
118 let target = self.load_catalog_manifest(&plan.target_manifest).await?;
119 Ok((operation.clone(), plan, target, descriptor))
120 })
121 .await
122 .map_err(|_| TopologyError::ReadTimedOut)?
123 }
124
125 pub(super) async fn require_topology_process(
126 &self,
127 processes: &ProcessLeaseAuthority,
128 identity: LocalProcessAuthorityIdentity,
129 ) -> Result<(), TopologyError> {
130 if !identity.is_canonical() {
131 return Err(TopologyError::Fenced);
132 }
133 let current = processes
136 .verify_current_participant_term(
137 identity.participant,
138 identity.process_term,
139 tokio::time::Instant::now() + CONTROL_TIMEOUT,
140 )
141 .await
142 .map_err(|error| TopologyError::Authority(LeaseError::Io(error.to_string())))?;
143 if !current {
144 return Err(TopologyError::Fenced);
145 }
146 Ok(())
147 }
148
149 pub(super) async fn require_topology_prepared(
150 &self,
151 operation: &TopologyAdmissionStatus,
152 plan: &TopologyAdmissionPlan,
153 processes: &ProcessLeaseAuthority,
154 ) -> Result<ClusterTopologyValidation, TopologyError> {
155 let descriptor = self
156 .audit_topology_compatibility(plan)
157 .await?
158 .ok_or_else(|| {
159 TopologyError::Protocol(
160 "new cuts require preparation protocol two and a durable candidate descriptor"
161 .into(),
162 )
163 })?;
164 let preparation = operation.preparation.as_ref().ok_or_else(|| {
165 TopologyError::Protocol("operation has no durable preparation".into())
166 })?;
167 if preparation.complete_sequence.is_none()
168 || preparation
169 .certificates
170 .iter()
171 .map(|cert| cert.participant)
172 .collect::<Vec<_>>()
173 != plan.assignment.participants
174 {
175 return Err(TopologyError::Conflict(
176 "every frozen owner/evidence process must certify the candidate before the cut"
177 .into(),
178 ));
179 }
180 for certificate in &preparation.certificates {
181 self.require_topology_process(
182 processes,
183 LocalProcessAuthorityIdentity {
184 participant: certificate.participant,
185 process_term: certificate.process_term,
186 },
187 )
188 .await?;
189 }
190 Ok(descriptor)
191 }
192
193 #[allow(clippy::too_many_arguments)] pub async fn certify_topology_participant(
202 &self,
203 assignments: &AssignmentSnapshotStore,
204 processes: &ProcessLeaseAuthority,
205 operation_id: TopologyOperationId,
206 expected_plan: &TopologyPlanRef,
207 identity: LocalProcessAuthorityIdentity,
208 protocol_version: u16,
209 compiled: &ClusterTopologyValidation,
210 ) -> Result<TopologyAdmissionStatus, TopologyError> {
211 if !matches!(
212 protocol_version,
213 TOPOLOGY_PREPARATION_PROTOCOL_VERSION | TOPOLOGY_SUBMISSION_PROTOCOL_VERSION
214 ) {
215 return Err(TopologyError::Protocol(
216 "participant lacks the exact preparation protocol".into(),
217 ));
218 }
219 let (_, compiled_ref) = compiled.encode_and_reference()?;
220 expected_plan.validate()?;
221 tokio::time::timeout(CONTROL_TIMEOUT, async {
222 for _ in 0..MAX_ADMISSION_ATTEMPTS {
223 let published = self
224 .load_published_authority_head()
225 .await?
226 .ok_or(TopologyError::Fenced)?;
227 let current = &published.record;
228 let index = current
229 .topology_operations
230 .iter()
231 .position(|entry| entry.operation_id == operation_id)
232 .ok_or_else(|| TopologyError::Conflict("unknown topology operation".into()))?;
233 let operation = ¤t.topology_operations[index];
234 if operation.plan != *expected_plan {
235 return Err(TopologyError::Conflict(
236 "certificate request payload differs".into(),
237 ));
238 }
239 if !current.lease.matches_proof(&operation.admitted_by) || !operation.is_preparing()
240 {
241 return Err(TopologyError::Fenced);
242 }
243 let plan = self.load_topology_plan(expected_plan).await?;
244 if plan.compatibility.as_ref() != Some(&compiled_ref)
245 || plan.protocol_version != protocol_version
246 {
247 return Err(TopologyError::Conflict(
248 "participant compiled a divergent candidate descriptor".into(),
249 ));
250 }
251 if plan
252 .assignment
253 .participant_incarnation(identity.participant.node_id)
254 != Some(identity.participant.boot_incarnation)
255 {
256 return Err(TopologyError::Fenced);
257 }
258 self.audit_topology_operation(operation).await?;
259 self.require_topology_process(processes, identity).await?;
260 let assignment = assignments
261 .load()
262 .await
263 .map_err(topology_assignment_error)?
264 .ok_or(TopologyError::Fenced)?;
265 if assignment.draining
266 || assignment
267 .assignment_fence()
268 .map_err(|error| TopologyError::Invalid(error.to_string()))?
269 != plan.assignment
270 {
271 return Err(TopologyError::Conflict(
272 "participant assignment or frozen roster changed".into(),
273 ));
274 }
275 self.reject_consumed_checkpoint_assignment(current, &plan.assignment)
276 .await
277 .map_err(super::topology_admission::topology_checkpoint_error)?;
278 let preparation = operation.preparation.as_ref().ok_or_else(|| {
279 TopologyError::Protocol("operation has no preparation binding".into())
280 })?;
281 if let Some(existing) = preparation
282 .certificates
283 .iter()
284 .find(|cert| cert.participant.node_id == identity.participant.node_id)
285 {
286 if existing.participant != identity.participant
287 || existing.process_term != identity.process_term
288 || existing.protocol_version != protocol_version
289 {
290 return Err(TopologyError::Fenced);
291 }
292 return Ok(operation.clone());
293 }
294 if !matches!(
295 operation.phase,
296 TopologyAdmissionPhase::Planned | TopologyAdmissionPhase::Preparing
297 ) {
298 return Err(TopologyError::Conflict(
299 "cut cannot acquire new preparation certificates".into(),
300 ));
301 }
302 let mut lease = current.lease.clone();
303 lease.seq = lease
304 .seq
305 .checked_add(1)
306 .ok_or_else(|| TopologyError::Invalid("authority sequence exhausted".into()))?;
307 let sequence = lease.seq;
308 let mut next = current.preserve_with_lease(lease);
309 let operation = &mut next.topology_operations[index];
310 operation.phase = TopologyAdmissionPhase::Preparing;
311 operation.status_sequence = sequence;
312 let preparation = operation
313 .preparation
314 .as_mut()
315 .ok_or_else(|| TopologyError::Invalid("preparation binding lost".into()))?;
316 preparation
317 .certificates
318 .push(TopologyParticipantCertificate {
319 participant: identity.participant,
320 process_term: identity.process_term,
321 protocol_version,
322 authority_sequence: sequence,
323 });
324 preparation
325 .certificates
326 .sort_by_key(|cert| cert.participant.node_id);
327 if preparation.certificates.len() == plan.assignment.participants.len() {
328 preparation.complete_sequence = Some(sequence);
329 }
330 let result = operation.clone();
331 match self
332 .create_authority_record(Some(&published), &next)
333 .await?
334 {
335 AuthorityCreateOutcome::Created | AuthorityCreateOutcome::ExistingIdentical => {
336 self.require_topology_process(processes, identity).await?;
337 return Ok(result);
338 }
339 AuthorityCreateOutcome::Contended(_) => tokio::task::yield_now().await,
340 }
341 }
342 Err(TopologyError::Contended)
343 })
344 .await
345 .map_err(|_| TopologyError::Contended)?
346 }
347
348 pub(super) async fn audit_topology_preparation(
349 &self,
350 operation: &TopologyAdmissionStatus,
351 plan: &TopologyAdmissionPlan,
352 ) -> Result<Option<ClusterTopologyValidation>, TopologyError> {
353 match (&plan.compatibility, &operation.preparation) {
354 (None, None) => return Ok(None),
355 (Some(reference), Some(preparation)) if reference == &preparation.compatibility => {}
356 _ => {
357 return Err(TopologyError::Invalid(
358 "operation changed its admitted descriptor binding".into(),
359 ))
360 }
361 }
362 let descriptor = self
363 .audit_topology_compatibility(plan)
364 .await?
365 .ok_or_else(|| TopologyError::Invalid("missing descriptor".into()))?;
366 let preparation = operation
367 .preparation
368 .as_ref()
369 .ok_or_else(|| TopologyError::Invalid("missing preparation".into()))?;
370 if preparation.certificates.iter().any(|cert| {
371 cert.protocol_version != plan.protocol_version
372 || plan
373 .assignment
374 .participant_incarnation(cert.participant.node_id)
375 != Some(cert.participant.boot_incarnation)
376 }) || preparation.complete_sequence.is_some()
377 != (preparation.certificates.len() == plan.assignment.participants.len())
378 || operation.cut.as_ref().is_some_and(|cut| {
379 cut.inventory.pipeline_identity != descriptor.parent_pipeline
380 || cut.inventory.deployment_id != descriptor.deployment_id
381 })
382 {
383 return Err(TopologyError::Invalid(
384 "preparation does not bind the frozen roster and parent pipeline".into(),
385 ));
386 }
387 for certificate in &preparation.certificates {
388 let record = read_authority_record(self.store.as_ref(), certificate.authority_sequence)
389 .await?
390 .ok_or_else(|| {
391 TopologyError::Invalid("preparation authority anchor is missing".into())
392 })?;
393 if record
394 .topology_operations
395 .iter()
396 .find(|entry| entry.operation_id == operation.operation_id)
397 .is_none_or(|anchored| {
398 anchored.plan != operation.plan
399 || anchored.admitted_by != operation.admitted_by
400 || anchored.admitted_sequence != operation.admitted_sequence
401 || anchored.status_sequence != certificate.authority_sequence
402 || anchored.phase != TopologyAdmissionPhase::Preparing
403 || anchored.preparation.as_ref().is_none_or(|prepared| {
404 prepared.compatibility != preparation.compatibility
405 || !prepared.certificates.contains(certificate)
406 })
407 })
408 {
409 return Err(TopologyError::Invalid(
410 "certificate differs from its immutable authority append".into(),
411 ));
412 }
413 }
414 Ok(Some(descriptor))
415 }
416}