1use std::collections::{BTreeMap, BTreeSet};
4use std::path::{Component, Path, PathBuf};
5
6use lodestone_wire::{
7 Digest, Identity, IndexResourceSpec, LODESTONE_API_INDEX_RESOURCE_SPEC_FORMAT_VERSION, Locator, Resource,
8 ServiceCredentialReservation, U64,
9};
10use serde::{Deserialize, Serialize};
11
12use crate::{
13 LocalOperatorWorkflowKind, PublishedFileIdentity, WorkflowJournalError, WorkflowJournalIntent,
14 WorkflowJournalTransition,
15};
16
17pub const DEVELOPMENT_MATERIALIZE_FORMAT_VERSION: u16 = 1;
18pub const DEVELOPMENT_PKI_SUCCESSOR_FORMAT_VERSION: u16 = 1;
19pub const DEVELOPMENT_RECONCILE_FORMAT_VERSION: u16 = 1;
20const MAX_ENDPOINT_BYTES: usize = 2_048;
21const MAX_NAME_BYTES: usize = 255;
22
23#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
24#[serde(deny_unknown_fields)]
25pub struct DevelopmentMaterializeIntent {
26 pub format_version: u16,
27 pub output_root: String,
28 pub supply_root: String,
29 pub supply_package_sha256: [u8; 32],
30 pub supply_source_revision: String,
31 pub native_api_sha256: [u8; 32],
32 pub native_endpoint: String,
33 pub subscription_endpoint: String,
34 pub internal_endpoint: String,
35 pub bootstrap_endpoint: String,
36}
37
38impl crate::workflow_journal::sealed::Sealed for DevelopmentMaterializeIntent {}
39
40impl WorkflowJournalIntent for DevelopmentMaterializeIntent {
41 type Transition = DevelopmentMaterializeTransition;
42
43 const KIND: LocalOperatorWorkflowKind = LocalOperatorWorkflowKind::DevelopmentMaterialize;
44
45 fn validate(&self) -> Result<(), WorkflowJournalError> {
46 if self.format_version != DEVELOPMENT_MATERIALIZE_FORMAT_VERSION
47 || !valid_absolute_path(&self.output_root)
48 || !valid_absolute_path(&self.supply_root)
49 || self.output_root == self.supply_root
50 || !valid_sha256(&self.supply_package_sha256)
51 || !valid_git_object(&self.supply_source_revision)
52 || !valid_sha256(&self.native_api_sha256)
53 || !valid_development_endpoints(
54 &self.native_endpoint,
55 &self.subscription_endpoint,
56 &self.internal_endpoint,
57 &self.bootstrap_endpoint,
58 )
59 {
60 return Err(WorkflowJournalError::InvalidIntent);
61 }
62 Ok(())
63 }
64}
65
66#[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
67#[serde(rename_all = "snake_case")]
68pub enum DevelopmentMaterializeArtifact {
69 CaCertificate,
70 CaPrivateKey,
71 ProcessCertificate,
72 ProcessPrivateKey,
73 GatewayCertificate,
74 GatewayPrivateKey,
75 BootstrapCredential,
76 BootstrapIntent,
77 ServerConfiguration,
78 DevelopmentIndexDefinition,
79 InstanceManifest,
80}
81
82#[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
83#[serde(rename_all = "snake_case")]
84pub enum DevelopmentMaterializePrivatePlan {
85 Pki,
86}
87
88impl DevelopmentMaterializeArtifact {
89 fn is_manifest(self) -> bool {
90 self == Self::InstanceManifest
91 }
92
93 fn required_before_manifest() -> BTreeSet<Self> {
94 [
95 Self::CaCertificate,
96 Self::CaPrivateKey,
97 Self::ProcessCertificate,
98 Self::ProcessPrivateKey,
99 Self::GatewayCertificate,
100 Self::GatewayPrivateKey,
101 Self::BootstrapCredential,
102 Self::BootstrapIntent,
103 Self::ServerConfiguration,
104 Self::DevelopmentIndexDefinition,
105 ]
106 .into_iter()
107 .collect()
108 }
109}
110
111#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
112#[serde(tag = "state", rename_all = "snake_case", deny_unknown_fields)]
113pub enum DevelopmentMaterializeTransition {
114 InstanceIdentityRecorded {
115 instance_identity: String,
116 },
117 ClockRecorded {
118 unix_millis: U64,
119 },
120 PrivatePlanStaged {
121 plan: DevelopmentMaterializePrivatePlan,
122 file: PublishedFileIdentity,
123 },
124 PrivatePlanPublished {
125 plan: DevelopmentMaterializePrivatePlan,
126 file: PublishedFileIdentity,
127 },
128 ArtifactStaged {
129 artifact: DevelopmentMaterializeArtifact,
130 file: PublishedFileIdentity,
131 },
132 ArtifactPublished {
133 artifact: DevelopmentMaterializeArtifact,
134 file: PublishedFileIdentity,
135 #[serde(default, skip_serializing_if = "Option::is_none")]
136 public_sha256: Option<[u8; 32]>,
137 },
138 Verified {
139 manifest_sha256: [u8; 32],
140 },
141 Completed,
142}
143
144impl crate::workflow_journal::sealed::Sealed for DevelopmentMaterializeTransition {}
145
146impl WorkflowJournalTransition for DevelopmentMaterializeTransition {
147 fn validate(&self) -> Result<(), WorkflowJournalError> {
148 let valid = match self {
149 Self::InstanceIdentityRecorded { instance_identity } => valid_instance_identity(instance_identity),
150 Self::ClockRecorded { unix_millis } => valid_u64(unix_millis),
151 Self::PrivatePlanStaged { file, .. }
152 | Self::PrivatePlanPublished { file, .. }
153 | Self::ArtifactStaged { file, .. } => file.is_valid(),
154 Self::ArtifactPublished {
155 artifact,
156 file,
157 public_sha256,
158 } => {
159 file.is_valid()
160 && public_sha256.as_ref().is_none_or(valid_sha256)
161 && (!artifact.is_manifest() || public_sha256.as_ref().is_some_and(valid_sha256))
162 }
163 Self::Verified { manifest_sha256 } => valid_sha256(manifest_sha256),
164 Self::Completed => true,
165 };
166 if valid {
167 Ok(())
168 } else {
169 Err(WorkflowJournalError::InvalidTransition)
170 }
171 }
172}
173
174#[derive(Clone, Debug, Default, PartialEq)]
175pub struct DevelopmentMaterializeState {
176 instance_identity: Option<String>,
177 unix_millis: Option<U64>,
178 private_plans: BTreeMap<DevelopmentMaterializePrivatePlan, PublishedFileIdentity>,
179 published_private_plans: BTreeMap<DevelopmentMaterializePrivatePlan, PublishedFileIdentity>,
180 staged: BTreeMap<DevelopmentMaterializeArtifact, PublishedFileIdentity>,
181 published: BTreeMap<DevelopmentMaterializeArtifact, PublishedFileIdentity>,
182 published_manifest_sha256: Option<[u8; 32]>,
183 manifest_sha256: Option<[u8; 32]>,
184 completed: bool,
185}
186
187impl DevelopmentMaterializeState {
188 pub fn replay(transitions: &[DevelopmentMaterializeTransition]) -> Result<Self, WorkflowJournalError> {
189 let mut state = Self::default();
190 for transition in transitions {
191 transition.validate()?;
192 state.apply(transition)?;
193 }
194 Ok(state)
195 }
196
197 fn apply(&mut self, transition: &DevelopmentMaterializeTransition) -> Result<(), WorkflowJournalError> {
198 if self.completed {
199 return Err(WorkflowJournalError::Corrupt);
200 }
201 match transition {
202 DevelopmentMaterializeTransition::InstanceIdentityRecorded { instance_identity }
203 if self.instance_identity.is_none() && self.unix_millis.is_none() =>
204 {
205 self.instance_identity = Some(instance_identity.clone());
206 }
207 DevelopmentMaterializeTransition::ClockRecorded { unix_millis }
208 if self.instance_identity.is_some() && self.unix_millis.is_none() =>
209 {
210 self.unix_millis = Some(unix_millis.clone());
211 }
212 DevelopmentMaterializeTransition::PrivatePlanStaged { plan, file }
213 if self.unix_millis.is_some()
214 && self.staged.is_empty()
215 && self.published.is_empty()
216 && !self.private_plans.contains_key(plan) =>
217 {
218 self.private_plans.insert(*plan, file.clone());
219 }
220 DevelopmentMaterializeTransition::PrivatePlanPublished { plan, file }
221 if !self.published_private_plans.contains_key(plan) && self.private_plans.get(plan) == Some(file) =>
222 {
223 self.published_private_plans.insert(*plan, file.clone());
224 }
225 DevelopmentMaterializeTransition::ArtifactStaged { artifact, file }
226 if self
227 .published_private_plans
228 .contains_key(&DevelopmentMaterializePrivatePlan::Pki)
229 && !self.staged.contains_key(artifact)
230 && !self.published.contains_key(artifact)
231 && (!artifact.is_manifest()
232 || self.published.keys().copied().collect::<BTreeSet<_>>()
233 == DevelopmentMaterializeArtifact::required_before_manifest()) =>
234 {
235 self.staged.insert(*artifact, file.clone());
236 }
237 DevelopmentMaterializeTransition::ArtifactPublished {
238 artifact,
239 file,
240 public_sha256,
241 } if !self.published.contains_key(artifact) && self.staged.get(artifact) == Some(file) => {
242 self.published.insert(*artifact, file.clone());
243 if artifact.is_manifest() {
244 self.published_manifest_sha256 = *public_sha256;
245 }
246 }
247 DevelopmentMaterializeTransition::Verified { manifest_sha256 }
248 if self
249 .published
250 .contains_key(&DevelopmentMaterializeArtifact::InstanceManifest)
251 && self.published_manifest_sha256.as_ref() == Some(manifest_sha256)
252 && self.manifest_sha256.is_none() =>
253 {
254 self.manifest_sha256 = Some(*manifest_sha256);
255 }
256 DevelopmentMaterializeTransition::Completed if self.manifest_sha256.is_some() => {
257 self.completed = true;
258 }
259 _ => return Err(WorkflowJournalError::Corrupt),
260 }
261 Ok(())
262 }
263
264 #[must_use]
265 pub fn instance_identity(&self) -> Option<&str> {
266 self.instance_identity.as_deref()
267 }
268
269 #[must_use]
270 pub const fn clock(&self) -> Option<&U64> {
271 self.unix_millis.as_ref()
272 }
273
274 #[must_use]
275 pub fn staged(&self, artifact: DevelopmentMaterializeArtifact) -> Option<&PublishedFileIdentity> {
276 self.staged.get(&artifact)
277 }
278
279 #[must_use]
280 pub fn private_plan(&self, plan: DevelopmentMaterializePrivatePlan) -> Option<&PublishedFileIdentity> {
281 self.private_plans.get(&plan)
282 }
283
284 #[must_use]
285 pub fn published_private_plan(&self, plan: DevelopmentMaterializePrivatePlan) -> Option<&PublishedFileIdentity> {
286 self.published_private_plans.get(&plan)
287 }
288
289 #[must_use]
290 pub fn published(&self, artifact: DevelopmentMaterializeArtifact) -> Option<&PublishedFileIdentity> {
291 self.published.get(&artifact)
292 }
293
294 #[must_use]
295 pub const fn completed(&self) -> bool {
296 self.completed
297 }
298}
299
300#[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
301#[serde(rename_all = "snake_case")]
302pub enum DevelopmentPkiSuccessorOperation {
303 RenewLeaves,
304 RotateCa,
305}
306
307#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
308#[serde(deny_unknown_fields)]
309pub struct DevelopmentPkiSuccessorIntent {
310 pub format_version: u16,
311 pub operation: DevelopmentPkiSuccessorOperation,
312 pub instance_identity: String,
313 pub parent_manifest_path: String,
314 pub parent_manifest_sha256: [u8; 32],
315 pub successor_root: String,
316 pub parent_instance_generation: U64,
317 pub successor_instance_generation: U64,
318 pub parent_ca_generation: U64,
319 pub successor_ca_generation: U64,
320 pub parent_process_leaf_generation: U64,
321 pub successor_process_leaf_generation: U64,
322 pub parent_gateway_leaf_generation: U64,
323 pub successor_gateway_leaf_generation: U64,
324}
325
326impl crate::workflow_journal::sealed::Sealed for DevelopmentPkiSuccessorIntent {}
327
328impl WorkflowJournalIntent for DevelopmentPkiSuccessorIntent {
329 type Transition = DevelopmentPkiSuccessorTransition;
330
331 const KIND: LocalOperatorWorkflowKind = LocalOperatorWorkflowKind::DevelopmentPkiSuccessor;
332
333 fn validate(&self) -> Result<(), WorkflowJournalError> {
334 let instance_generations =
335 ordered_successor(&self.parent_instance_generation, &self.successor_instance_generation);
336 let process_generations = ordered_successor(
337 &self.parent_process_leaf_generation,
338 &self.successor_process_leaf_generation,
339 );
340 let gateway_generations = ordered_successor(
341 &self.parent_gateway_leaf_generation,
342 &self.successor_gateway_leaf_generation,
343 );
344 let ca_generations = match self.operation {
345 DevelopmentPkiSuccessorOperation::RenewLeaves => {
346 canonical_u64(&self.parent_ca_generation).filter(|value| *value > 0)
347 == canonical_u64(&self.successor_ca_generation).filter(|value| *value > 0)
348 }
349 DevelopmentPkiSuccessorOperation::RotateCa => {
350 ordered_successor(&self.parent_ca_generation, &self.successor_ca_generation)
351 }
352 };
353 let parent_manifest = Path::new(&self.parent_manifest_path);
354 let parent_root = parent_manifest.parent();
355 if self.format_version != DEVELOPMENT_PKI_SUCCESSOR_FORMAT_VERSION
356 || !valid_instance_identity(&self.instance_identity)
357 || !valid_absolute_path(&self.parent_manifest_path)
358 || !valid_sha256(&self.parent_manifest_sha256)
359 || !valid_absolute_path(&self.successor_root)
360 || parent_manifest.file_name().and_then(|name| name.to_str()) != Some("instance.json")
361 || parent_root.is_none_or(|root| paths_overlap(root, Path::new(&self.successor_root)))
362 || !instance_generations
363 || !process_generations
364 || !gateway_generations
365 || !ca_generations
366 {
367 return Err(WorkflowJournalError::InvalidIntent);
368 }
369 Ok(())
370 }
371}
372
373fn paths_overlap(left: &Path, right: &Path) -> bool {
374 left.starts_with(right) || right.starts_with(left)
375}
376
377#[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
378#[serde(rename_all = "snake_case")]
379pub enum DevelopmentPkiSuccessorArtifact {
380 CaCertificate,
381 CaPrivateKey,
382 ProcessCertificate,
383 ProcessPrivateKey,
384 GatewayCertificate,
385 GatewayPrivateKey,
386 ServerConfiguration,
387 InstanceManifest,
388}
389
390#[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
391#[serde(rename_all = "snake_case")]
392pub enum DevelopmentPkiSuccessorPrivatePlan {
393 Pki,
394}
395
396#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
397#[serde(tag = "state", rename_all = "snake_case", deny_unknown_fields)]
398pub enum DevelopmentPkiSuccessorTransition {
399 ClockRecorded {
400 unix_millis: U64,
401 },
402 PrivatePlanStaged {
403 plan: DevelopmentPkiSuccessorPrivatePlan,
404 file: PublishedFileIdentity,
405 },
406 PrivatePlanPublished {
407 plan: DevelopmentPkiSuccessorPrivatePlan,
408 file: PublishedFileIdentity,
409 },
410 ArtifactStaged {
411 artifact: DevelopmentPkiSuccessorArtifact,
412 file: PublishedFileIdentity,
413 },
414 ArtifactPublished {
415 artifact: DevelopmentPkiSuccessorArtifact,
416 file: PublishedFileIdentity,
417 #[serde(default, skip_serializing_if = "Option::is_none")]
418 public_sha256: Option<[u8; 32]>,
419 },
420 Verified {
421 manifest_sha256: [u8; 32],
422 },
423 Completed,
424}
425
426impl crate::workflow_journal::sealed::Sealed for DevelopmentPkiSuccessorTransition {}
427
428impl WorkflowJournalTransition for DevelopmentPkiSuccessorTransition {
429 fn validate(&self) -> Result<(), WorkflowJournalError> {
430 let valid = match self {
431 Self::ClockRecorded { unix_millis } => valid_u64(unix_millis),
432 Self::PrivatePlanStaged { file, .. }
433 | Self::PrivatePlanPublished { file, .. }
434 | Self::ArtifactStaged { file, .. } => file.is_valid(),
435 Self::ArtifactPublished {
436 artifact,
437 file,
438 public_sha256,
439 } => {
440 file.is_valid()
441 && public_sha256.as_ref().is_none_or(valid_sha256)
442 && (*artifact != DevelopmentPkiSuccessorArtifact::InstanceManifest
443 || public_sha256.as_ref().is_some_and(valid_sha256))
444 }
445 Self::Verified { manifest_sha256 } => valid_sha256(manifest_sha256),
446 Self::Completed => true,
447 };
448 if valid {
449 Ok(())
450 } else {
451 Err(WorkflowJournalError::InvalidTransition)
452 }
453 }
454}
455
456#[derive(Clone, Debug, Default, PartialEq)]
457pub struct DevelopmentPkiSuccessorState {
458 clock: Option<U64>,
459 private_plans: BTreeMap<DevelopmentPkiSuccessorPrivatePlan, PublishedFileIdentity>,
460 published_private_plans: BTreeMap<DevelopmentPkiSuccessorPrivatePlan, PublishedFileIdentity>,
461 staged: BTreeMap<DevelopmentPkiSuccessorArtifact, PublishedFileIdentity>,
462 published: BTreeMap<DevelopmentPkiSuccessorArtifact, PublishedFileIdentity>,
463 published_manifest_sha256: Option<[u8; 32]>,
464 manifest_sha256: Option<[u8; 32]>,
465 completed: bool,
466}
467
468impl DevelopmentPkiSuccessorState {
469 pub fn replay(
470 intent: &DevelopmentPkiSuccessorIntent,
471 transitions: &[DevelopmentPkiSuccessorTransition],
472 ) -> Result<Self, WorkflowJournalError> {
473 intent.validate()?;
474 let mut state = Self::default();
475 for transition in transitions {
476 transition.validate()?;
477 state.apply(intent.operation, transition)?;
478 }
479 Ok(state)
480 }
481
482 fn apply(
483 &mut self,
484 operation: DevelopmentPkiSuccessorOperation,
485 transition: &DevelopmentPkiSuccessorTransition,
486 ) -> Result<(), WorkflowJournalError> {
487 if self.completed {
488 return Err(WorkflowJournalError::Corrupt);
489 }
490 match transition {
491 DevelopmentPkiSuccessorTransition::ClockRecorded { unix_millis } if self.clock.is_none() => {
492 self.clock = Some(unix_millis.clone());
493 }
494 DevelopmentPkiSuccessorTransition::PrivatePlanStaged { plan, file }
495 if self.clock.is_some()
496 && self.staged.is_empty()
497 && self.published.is_empty()
498 && !self.private_plans.contains_key(plan) =>
499 {
500 self.private_plans.insert(*plan, file.clone());
501 }
502 DevelopmentPkiSuccessorTransition::PrivatePlanPublished { plan, file }
503 if !self.published_private_plans.contains_key(plan) && self.private_plans.get(plan) == Some(file) =>
504 {
505 self.published_private_plans.insert(*plan, file.clone());
506 }
507 DevelopmentPkiSuccessorTransition::ArtifactStaged { artifact, file }
508 if self.clock.is_some()
509 && self
510 .published_private_plans
511 .contains_key(&DevelopmentPkiSuccessorPrivatePlan::Pki)
512 && !self.staged.contains_key(artifact)
513 && !self.published.contains_key(artifact)
514 && (artifact_admitted_for_pki_operation(operation, *artifact)
515 || (*artifact == DevelopmentPkiSuccessorArtifact::InstanceManifest
516 && self.published.keys().copied().collect::<BTreeSet<_>>()
517 == required_pki_artifacts(operation))) =>
518 {
519 self.staged.insert(*artifact, file.clone());
520 }
521 DevelopmentPkiSuccessorTransition::ArtifactPublished {
522 artifact,
523 file,
524 public_sha256,
525 } if !self.published.contains_key(artifact) && self.staged.get(artifact) == Some(file) => {
526 self.published.insert(*artifact, file.clone());
527 if *artifact == DevelopmentPkiSuccessorArtifact::InstanceManifest {
528 self.published_manifest_sha256 = *public_sha256;
529 }
530 }
531 DevelopmentPkiSuccessorTransition::Verified { manifest_sha256 }
532 if self
533 .published
534 .contains_key(&DevelopmentPkiSuccessorArtifact::InstanceManifest)
535 && self.published_manifest_sha256.as_ref() == Some(manifest_sha256)
536 && self.manifest_sha256.is_none() =>
537 {
538 self.manifest_sha256 = Some(*manifest_sha256);
539 }
540 DevelopmentPkiSuccessorTransition::Completed if self.manifest_sha256.is_some() => {
541 self.completed = true;
542 }
543 _ => return Err(WorkflowJournalError::Corrupt),
544 }
545 Ok(())
546 }
547
548 #[must_use]
549 pub const fn clock(&self) -> Option<&U64> {
550 self.clock.as_ref()
551 }
552
553 #[must_use]
554 pub fn private_plan(&self, plan: DevelopmentPkiSuccessorPrivatePlan) -> Option<&PublishedFileIdentity> {
555 self.private_plans.get(&plan)
556 }
557
558 #[must_use]
559 pub fn published_private_plan(&self, plan: DevelopmentPkiSuccessorPrivatePlan) -> Option<&PublishedFileIdentity> {
560 self.published_private_plans.get(&plan)
561 }
562
563 #[must_use]
564 pub fn staged(&self, artifact: DevelopmentPkiSuccessorArtifact) -> Option<&PublishedFileIdentity> {
565 self.staged.get(&artifact)
566 }
567
568 #[must_use]
569 pub fn published(&self, artifact: DevelopmentPkiSuccessorArtifact) -> Option<&PublishedFileIdentity> {
570 self.published.get(&artifact)
571 }
572
573 #[must_use]
574 pub const fn completed(&self) -> bool {
575 self.completed
576 }
577}
578
579fn artifact_admitted_for_pki_operation(
580 operation: DevelopmentPkiSuccessorOperation,
581 artifact: DevelopmentPkiSuccessorArtifact,
582) -> bool {
583 artifact != DevelopmentPkiSuccessorArtifact::InstanceManifest
584 && (operation == DevelopmentPkiSuccessorOperation::RotateCa
585 || !matches!(
586 artifact,
587 DevelopmentPkiSuccessorArtifact::CaCertificate | DevelopmentPkiSuccessorArtifact::CaPrivateKey
588 ))
589}
590
591fn required_pki_artifacts(operation: DevelopmentPkiSuccessorOperation) -> BTreeSet<DevelopmentPkiSuccessorArtifact> {
592 let mut required = [
593 DevelopmentPkiSuccessorArtifact::ProcessCertificate,
594 DevelopmentPkiSuccessorArtifact::ProcessPrivateKey,
595 DevelopmentPkiSuccessorArtifact::GatewayCertificate,
596 DevelopmentPkiSuccessorArtifact::GatewayPrivateKey,
597 DevelopmentPkiSuccessorArtifact::ServerConfiguration,
598 ]
599 .into_iter()
600 .collect::<BTreeSet<_>>();
601 if operation == DevelopmentPkiSuccessorOperation::RotateCa {
602 required.insert(DevelopmentPkiSuccessorArtifact::CaCertificate);
603 required.insert(DevelopmentPkiSuccessorArtifact::CaPrivateKey);
604 }
605 required
606}
607
608#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
609#[serde(deny_unknown_fields)]
610pub struct DevelopmentReconcileIntent {
611 pub format_version: u16,
612 pub instance_root: String,
613 pub instance_manifest_sha256: [u8; 32],
614 pub index_definition_path: String,
615 pub index_definition_sha256: [u8; 32],
616 pub credential_output: String,
617 pub profile_output: String,
618 pub material_output: String,
619 pub timeout_millis: U64,
620 pub reservation_ttl_millis: U64,
621 pub credential_ttl_millis: U64,
622}
623
624impl crate::workflow_journal::sealed::Sealed for DevelopmentReconcileIntent {}
625
626impl WorkflowJournalIntent for DevelopmentReconcileIntent {
627 type Transition = DevelopmentReconcileTransition;
628
629 const KIND: LocalOperatorWorkflowKind = LocalOperatorWorkflowKind::DevelopmentReconcile;
630
631 fn validate(&self) -> Result<(), WorkflowJournalError> {
632 let paths = [
633 &self.instance_root,
634 &self.index_definition_path,
635 &self.credential_output,
636 &self.profile_output,
637 &self.material_output,
638 ];
639 let distinct_outputs = [&self.credential_output, &self.profile_output, &self.material_output]
640 .into_iter()
641 .collect::<BTreeSet<_>>()
642 .len()
643 == 3;
644 if self.format_version != DEVELOPMENT_RECONCILE_FORMAT_VERSION
645 || paths.into_iter().any(|path| !valid_absolute_path(path))
646 || !distinct_outputs
647 || !valid_sha256(&self.instance_manifest_sha256)
648 || !valid_sha256(&self.index_definition_sha256)
649 || !valid_positive_bounded_duration(&self.timeout_millis)
650 || !valid_positive_bounded_duration(&self.reservation_ttl_millis)
651 || !valid_positive_bounded_duration(&self.credential_ttl_millis)
652 || !ordered_u64(&self.reservation_ttl_millis, &self.credential_ttl_millis)
653 {
654 return Err(WorkflowJournalError::InvalidIntent);
655 }
656 Ok(())
657 }
658}
659
660#[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
661#[serde(rename_all = "snake_case")]
662pub enum DevelopmentReconcileRoleTarget {
663 Administrator,
664 Consumer,
665}
666
667#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
668#[serde(rename_all = "snake_case")]
669pub enum DevelopmentReconcileTerminalOutcome {
670 EffectDeadlineExpired,
671 Conflict,
672 ReservationExpiredNoEffect,
673}
674
675#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
676#[serde(tag = "state", rename_all = "snake_case", deny_unknown_fields)]
677pub enum DevelopmentReconcileTransition {
678 EffectDeadlineRecorded {
679 unix_millis: U64,
680 },
681 AuthorityObserved {
682 cluster_id: Identity,
683 collection_id: Identity,
684 schema_sha256: Digest,
685 logical_time: U64,
686 reservation_expires_at: U64,
687 credential_expires_at: U64,
688 },
689 CatalogPrepared {
690 locator: Locator,
691 semantic_digest: Digest,
692 expires_at: U64,
693 resource_spec: IndexResourceSpec,
694 },
695 CatalogApplied {
696 locator: Locator,
697 resource: Resource,
698 },
699 IndexActive {
700 resource: Resource,
701 index_revision: U64,
702 },
703 RolePrepared {
704 target: DevelopmentReconcileRoleTarget,
705 locator: Locator,
706 semantic_digest: Digest,
707 expires_at: U64,
708 predecessor_role_version: U64,
709 successor_role_version: U64,
710 },
711 RoleApplied {
712 target: DevelopmentReconcileRoleTarget,
713 locator: Locator,
714 target_policy_revision: U64,
715 },
716 RoleVerified {
717 target: DevelopmentReconcileRoleTarget,
718 role_version: U64,
719 observed_policy_revision: U64,
720 },
721 CredentialReserved {
722 reservation: ServiceCredentialReservation,
723 },
724 CredentialStaged {
725 file: PublishedFileIdentity,
726 },
727 CredentialActivated {
728 principal_id: Identity,
729 api_key_id: Identity,
730 generation: U64,
731 },
732 CredentialPublished {
733 file: PublishedFileIdentity,
734 },
735 ProfilePublished {
736 file: PublishedFileIdentity,
737 profile_sha256: [u8; 32],
738 },
739 ConsumerVerified {
740 observed_policy_revision: U64,
741 },
742 MaterialPublished {
743 file: PublishedFileIdentity,
744 material_sha256: [u8; 32],
745 },
746 Terminal {
747 outcome: DevelopmentReconcileTerminalOutcome,
748 },
749 Completed,
750}
751
752impl crate::workflow_journal::sealed::Sealed for DevelopmentReconcileTransition {}
753
754impl WorkflowJournalTransition for DevelopmentReconcileTransition {
755 fn validate(&self) -> Result<(), WorkflowJournalError> {
756 let valid = match self {
757 Self::EffectDeadlineRecorded { unix_millis } => valid_u64(unix_millis),
758 Self::AuthorityObserved {
759 cluster_id,
760 collection_id,
761 schema_sha256,
762 logical_time,
763 reservation_expires_at,
764 credential_expires_at,
765 } => {
766 valid_identity(cluster_id)
767 && valid_identity(collection_id)
768 && valid_digest(schema_sha256)
769 && valid_u64(logical_time)
770 && ordered_u64(logical_time, reservation_expires_at)
771 && ordered_u64(reservation_expires_at, credential_expires_at)
772 }
773 Self::CatalogPrepared {
774 locator,
775 semantic_digest,
776 expires_at,
777 resource_spec,
778 } => {
779 valid_locator(locator)
780 && valid_digest(semantic_digest)
781 && valid_u64(expires_at)
782 && resource_spec.format_version == u32::from(LODESTONE_API_INDEX_RESOURCE_SPEC_FORMAT_VERSION)
783 }
784 Self::CatalogApplied { locator, resource } => valid_locator(locator) && valid_resource(resource),
785 Self::IndexActive {
786 resource,
787 index_revision,
788 } => valid_resource(resource) && valid_u64(index_revision),
789 Self::RolePrepared {
790 locator,
791 semantic_digest,
792 expires_at,
793 predecessor_role_version,
794 successor_role_version,
795 ..
796 } => {
797 valid_locator(locator)
798 && valid_digest(semantic_digest)
799 && valid_u64(expires_at)
800 && ordered_successor(predecessor_role_version, successor_role_version)
801 }
802 Self::RoleApplied {
803 locator,
804 target_policy_revision,
805 ..
806 } => valid_locator(locator) && valid_u64(target_policy_revision),
807 Self::RoleVerified {
808 role_version,
809 observed_policy_revision,
810 ..
811 } => valid_u64(role_version) && valid_u64(observed_policy_revision),
812 Self::CredentialReserved { reservation } => valid_reservation(reservation),
813 Self::CredentialStaged { file } | Self::CredentialPublished { file } => file.is_valid(),
814 Self::CredentialActivated {
815 principal_id,
816 api_key_id,
817 generation,
818 } => valid_identity(principal_id) && valid_identity(api_key_id) && valid_u64(generation),
819 Self::ProfilePublished { file, profile_sha256 } => file.is_valid() && valid_sha256(profile_sha256),
820 Self::ConsumerVerified {
821 observed_policy_revision,
822 } => valid_u64(observed_policy_revision),
823 Self::MaterialPublished { file, material_sha256 } => file.is_valid() && valid_sha256(material_sha256),
824 Self::Terminal { .. } => true,
825 Self::Completed => true,
826 };
827 if valid {
828 Ok(())
829 } else {
830 Err(WorkflowJournalError::InvalidTransition)
831 }
832 }
833}
834
835#[derive(Clone, Debug, Default, PartialEq)]
836pub struct DevelopmentReconcileState {
837 effect_deadline: Option<U64>,
838 binding: Option<(Identity, Identity, Digest, U64, U64, U64)>,
839 catalog_preparation: Option<(Locator, Digest, U64, IndexResourceSpec)>,
840 resource: Option<Resource>,
841 index_active: bool,
842 role_preparations: BTreeMap<DevelopmentReconcileRoleTarget, (Locator, Digest, U64, U64, U64)>,
843 role_applied: BTreeMap<DevelopmentReconcileRoleTarget, U64>,
844 role_verified: BTreeMap<DevelopmentReconcileRoleTarget, (U64, U64)>,
845 reservation: Option<ServiceCredentialReservation>,
846 credential_staged: Option<PublishedFileIdentity>,
847 credential_activated: bool,
848 credential_published: Option<PublishedFileIdentity>,
849 profile_published: Option<PublishedFileIdentity>,
850 consumer_verified: Option<U64>,
851 material_published: Option<PublishedFileIdentity>,
852 terminal: Option<DevelopmentReconcileTerminalOutcome>,
853 completed: bool,
854}
855
856impl DevelopmentReconcileState {
857 pub fn replay(
858 intent: &DevelopmentReconcileIntent,
859 transitions: &[DevelopmentReconcileTransition],
860 ) -> Result<Self, WorkflowJournalError> {
861 intent.validate()?;
862 let mut state = Self::default();
863 for transition in transitions {
864 transition.validate()?;
865 state.apply(intent, transition)?;
866 }
867 Ok(state)
868 }
869
870 fn apply(
871 &mut self,
872 intent: &DevelopmentReconcileIntent,
873 transition: &DevelopmentReconcileTransition,
874 ) -> Result<(), WorkflowJournalError> {
875 if self.completed || self.terminal.is_some() {
876 return Err(WorkflowJournalError::Corrupt);
877 }
878 match transition {
879 DevelopmentReconcileTransition::EffectDeadlineRecorded { unix_millis }
880 if self.effect_deadline.is_none() =>
881 {
882 self.effect_deadline = Some(unix_millis.clone());
883 }
884 DevelopmentReconcileTransition::AuthorityObserved {
885 cluster_id,
886 collection_id,
887 schema_sha256,
888 logical_time,
889 reservation_expires_at,
890 credential_expires_at,
891 } if self.effect_deadline.is_some()
892 && self.binding.is_none()
893 && checked_add_u64(logical_time, &intent.reservation_ttl_millis)
894 == canonical_u64(reservation_expires_at)
895 && checked_add_u64(logical_time, &intent.credential_ttl_millis)
896 == canonical_u64(credential_expires_at) =>
897 {
898 self.binding = Some((
899 cluster_id.clone(),
900 collection_id.clone(),
901 schema_sha256.clone(),
902 logical_time.clone(),
903 reservation_expires_at.clone(),
904 credential_expires_at.clone(),
905 ));
906 }
907 DevelopmentReconcileTransition::CatalogPrepared {
908 locator,
909 semantic_digest,
910 expires_at,
911 resource_spec,
912 } if self.binding.is_some() && self.resource.is_none() => {
913 self.catalog_preparation = Some((
917 locator.clone(),
918 semantic_digest.clone(),
919 expires_at.clone(),
920 resource_spec.clone(),
921 ));
922 }
923 DevelopmentReconcileTransition::CatalogApplied { locator, resource }
924 if self
925 .catalog_preparation
926 .as_ref()
927 .is_some_and(|(expected, _, _, _)| expected == locator)
928 && self.resource.is_none() =>
929 {
930 self.resource = Some(resource.clone());
931 }
932 DevelopmentReconcileTransition::IndexActive { resource, .. }
933 if self.binding.is_some()
934 && self.resource.as_ref().is_none_or(|current| current == resource)
935 && !self.index_active =>
936 {
937 self.resource = Some(resource.clone());
938 self.index_active = true;
939 }
940 DevelopmentReconcileTransition::RolePrepared {
941 target,
942 locator,
943 semantic_digest,
944 expires_at,
945 predecessor_role_version,
946 successor_role_version,
947 } if self.role_target_is_next(*target) && !self.role_applied.contains_key(target) => {
948 self.role_preparations.insert(
951 *target,
952 (
953 locator.clone(),
954 semantic_digest.clone(),
955 expires_at.clone(),
956 predecessor_role_version.clone(),
957 successor_role_version.clone(),
958 ),
959 );
960 }
961 DevelopmentReconcileTransition::RoleApplied {
962 target,
963 locator,
964 target_policy_revision,
965 } if self
966 .role_preparations
967 .get(target)
968 .is_some_and(|(expected, _, _, _, _)| expected == locator)
969 && !self.role_applied.contains_key(target) =>
970 {
971 self.role_applied.insert(*target, target_policy_revision.clone());
972 }
973 DevelopmentReconcileTransition::RoleVerified {
974 target,
975 role_version,
976 observed_policy_revision,
977 } if self.role_target_is_next(*target)
978 && self.role_verification_matches(*target, role_version, observed_policy_revision) =>
979 {
980 self.role_verified
981 .insert(*target, (role_version.clone(), observed_policy_revision.clone()));
982 }
983 DevelopmentReconcileTransition::CredentialReserved { reservation }
984 if self
985 .role_verified
986 .contains_key(&DevelopmentReconcileRoleTarget::Consumer)
987 && self.reservation.is_none() =>
988 {
989 let Some((_, _, _, _, reservation_expires_at, credential_expires_at)) = self.binding.as_ref() else {
990 return Err(WorkflowJournalError::Corrupt);
991 };
992 if &reservation.reservation_expires_at != reservation_expires_at
993 || &reservation.credential_expires_at != credential_expires_at
994 {
995 return Err(WorkflowJournalError::Corrupt);
996 }
997 self.reservation = Some(reservation.clone());
998 }
999 DevelopmentReconcileTransition::CredentialStaged { file }
1000 if self.reservation.is_some() && self.credential_staged.is_none() =>
1001 {
1002 self.credential_staged = Some(file.clone());
1003 }
1004 DevelopmentReconcileTransition::CredentialActivated {
1005 principal_id,
1006 api_key_id,
1007 generation,
1008 } if self.reservation.as_ref().is_some_and(|reservation| {
1009 &reservation.principal_id == principal_id
1010 && &reservation.api_key_id == api_key_id
1011 && &reservation.generation == generation
1012 }) && self.credential_staged.is_some()
1013 && !self.credential_activated =>
1014 {
1015 self.credential_activated = true;
1016 }
1017 DevelopmentReconcileTransition::CredentialPublished { file }
1018 if self.credential_activated
1019 && self.credential_staged.as_ref() == Some(file)
1020 && self.credential_published.is_none() =>
1021 {
1022 self.credential_published = Some(file.clone());
1023 }
1024 DevelopmentReconcileTransition::ProfilePublished { file, .. }
1025 if self.credential_published.is_some() && self.profile_published.is_none() =>
1026 {
1027 self.profile_published = Some(file.clone());
1028 }
1029 DevelopmentReconcileTransition::ConsumerVerified {
1030 observed_policy_revision,
1031 } if self.profile_published.is_some() && self.consumer_verified.is_none() => {
1032 self.consumer_verified = Some(observed_policy_revision.clone());
1033 }
1034 DevelopmentReconcileTransition::MaterialPublished { file, .. }
1035 if self.consumer_verified.is_some() && self.material_published.is_none() =>
1036 {
1037 self.material_published = Some(file.clone());
1038 }
1039 DevelopmentReconcileTransition::Terminal { outcome, .. } if self.effect_deadline.is_some() => {
1040 self.terminal = Some(*outcome);
1041 }
1042 DevelopmentReconcileTransition::Completed if self.material_published.is_some() => {
1043 self.completed = true;
1044 }
1045 _ => return Err(WorkflowJournalError::Corrupt),
1046 }
1047 Ok(())
1048 }
1049
1050 fn role_target_is_next(&self, target: DevelopmentReconcileRoleTarget) -> bool {
1051 self.index_active
1052 && match target {
1053 DevelopmentReconcileRoleTarget::Administrator => !self
1054 .role_verified
1055 .contains_key(&DevelopmentReconcileRoleTarget::Administrator),
1056 DevelopmentReconcileRoleTarget::Consumer => {
1057 self.role_verified
1058 .contains_key(&DevelopmentReconcileRoleTarget::Administrator)
1059 && !self
1060 .role_verified
1061 .contains_key(&DevelopmentReconcileRoleTarget::Consumer)
1062 }
1063 }
1064 }
1065
1066 fn role_verification_matches(
1067 &self,
1068 target: DevelopmentReconcileRoleTarget,
1069 role_version: &U64,
1070 observed_policy_revision: &U64,
1071 ) -> bool {
1072 match self.role_preparations.get(&target) {
1073 Some((_, _, _, _, successor_role_version)) => {
1074 successor_role_version == role_version
1075 && self.role_applied.get(&target).is_some_and(|target_policy_revision| {
1076 ordered_or_equal_u64(target_policy_revision, observed_policy_revision)
1077 })
1078 }
1079 None => !self.role_applied.contains_key(&target),
1080 }
1081 }
1082
1083 #[must_use]
1084 pub const fn effect_deadline(&self) -> Option<&U64> {
1085 self.effect_deadline.as_ref()
1086 }
1087
1088 #[must_use]
1089 pub const fn resource(&self) -> Option<&Resource> {
1090 self.resource.as_ref()
1091 }
1092
1093 #[must_use]
1094 pub const fn authority_binding(&self) -> Option<&(Identity, Identity, Digest, U64, U64, U64)> {
1095 self.binding.as_ref()
1096 }
1097
1098 #[must_use]
1099 pub const fn catalog_preparation(&self) -> Option<&(Locator, Digest, U64, IndexResourceSpec)> {
1100 self.catalog_preparation.as_ref()
1101 }
1102
1103 #[must_use]
1104 pub const fn index_active(&self) -> bool {
1105 self.index_active
1106 }
1107
1108 #[must_use]
1109 pub fn role_preparation(
1110 &self,
1111 target: DevelopmentReconcileRoleTarget,
1112 ) -> Option<&(Locator, Digest, U64, U64, U64)> {
1113 self.role_preparations.get(&target)
1114 }
1115
1116 #[must_use]
1117 pub fn role_applied(&self, target: DevelopmentReconcileRoleTarget) -> Option<&U64> {
1118 self.role_applied.get(&target)
1119 }
1120
1121 #[must_use]
1122 pub fn role_verified(&self, target: DevelopmentReconcileRoleTarget) -> Option<&(U64, U64)> {
1123 self.role_verified.get(&target)
1124 }
1125
1126 #[must_use]
1127 pub const fn reservation(&self) -> Option<&ServiceCredentialReservation> {
1128 self.reservation.as_ref()
1129 }
1130
1131 #[must_use]
1132 pub const fn credential_staged(&self) -> Option<&PublishedFileIdentity> {
1133 self.credential_staged.as_ref()
1134 }
1135
1136 #[must_use]
1137 pub const fn credential_activated(&self) -> bool {
1138 self.credential_activated
1139 }
1140
1141 #[must_use]
1142 pub const fn credential_published(&self) -> Option<&PublishedFileIdentity> {
1143 self.credential_published.as_ref()
1144 }
1145
1146 #[must_use]
1147 pub const fn profile_published(&self) -> Option<&PublishedFileIdentity> {
1148 self.profile_published.as_ref()
1149 }
1150
1151 #[must_use]
1152 pub const fn consumer_policy_revision(&self) -> Option<&U64> {
1153 self.consumer_verified.as_ref()
1154 }
1155
1156 #[must_use]
1157 pub const fn material_published(&self) -> Option<&PublishedFileIdentity> {
1158 self.material_published.as_ref()
1159 }
1160
1161 #[must_use]
1162 pub const fn terminal(&self) -> Option<DevelopmentReconcileTerminalOutcome> {
1163 self.terminal
1164 }
1165
1166 #[must_use]
1167 pub const fn completed(&self) -> bool {
1168 self.completed
1169 }
1170}
1171
1172fn valid_absolute_path(value: &str) -> bool {
1173 if value.is_empty() || value.len() > 4_096 || value.bytes().any(|byte| byte.is_ascii_control()) {
1174 return false;
1175 }
1176 let path = Path::new(value);
1177 path.is_absolute()
1178 && path.parent().is_some()
1179 && path
1180 .components()
1181 .all(|component| matches!(component, Component::RootDir | Component::Normal(_)))
1182 && path.components().collect::<PathBuf>().as_os_str() == path.as_os_str()
1183}
1184
1185fn valid_sha256(value: &[u8; 32]) -> bool {
1186 value.iter().any(|byte| *byte != 0)
1187}
1188
1189fn valid_instance_identity(value: &str) -> bool {
1190 value.len() == 64
1191 && value
1192 .bytes()
1193 .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
1194 && value.bytes().any(|byte| byte != b'0')
1195}
1196
1197fn valid_git_object(value: &str) -> bool {
1198 matches!(value.len(), 40 | 64)
1199 && value
1200 .bytes()
1201 .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase())
1202 && value.bytes().any(|byte| byte != b'0')
1203}
1204
1205fn valid_development_endpoints(native: &str, subscription: &str, internal: &str, bootstrap: &str) -> bool {
1206 let values = [native, subscription, internal, bootstrap];
1207 if values.iter().any(|value| {
1208 value.is_empty() || value.len() > MAX_ENDPOINT_BYTES || !value.bytes().all(|byte| byte.is_ascii_graphic())
1209 }) {
1210 return false;
1211 }
1212 let Some(authority) = native.strip_prefix("lodestone+tls://") else {
1213 return false;
1214 };
1215 let (advertised_host, port_text) = if let Some(port) = authority.strip_prefix("[::1]:") {
1216 ("[::1]", port)
1217 } else if let Some(port) = authority.strip_prefix("localhost:") {
1218 ("localhost", port)
1219 } else if let Some(port) = authority.strip_prefix("127.0.0.1:") {
1220 ("127.0.0.1", port)
1221 } else {
1222 return false;
1223 };
1224 let Some(base_port) = port_text
1225 .parse::<u16>()
1226 .ok()
1227 .filter(|port| port.to_string() == port_text && (1024..=65532).contains(port))
1228 else {
1229 return false;
1230 };
1231 let bind_host = match advertised_host {
1232 "[::1]" => "[::1]",
1233 _ => "127.0.0.1",
1234 };
1235 subscription == format!("https://{advertised_host}:{}", base_port + 1)
1236 && internal == format!("{bind_host}:{}", base_port + 2)
1237 && bootstrap == format!("{bind_host}:{}", base_port + 3)
1238}
1239
1240fn valid_identity(value: &Identity) -> bool {
1241 value.value.len() == 32
1242 && value
1243 .value
1244 .bytes()
1245 .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase())
1246 && value.value.bytes().any(|byte| byte != b'0')
1247}
1248
1249fn valid_u64(value: &U64) -> bool {
1250 value
1251 .value
1252 .parse::<u64>()
1253 .ok()
1254 .is_some_and(|parsed| parsed.to_string() == value.value)
1255}
1256
1257fn canonical_u64(value: &U64) -> Option<u64> {
1258 value
1259 .value
1260 .parse::<u64>()
1261 .ok()
1262 .filter(|parsed| parsed.to_string() == value.value)
1263}
1264
1265fn ordered_successor(predecessor: &U64, successor: &U64) -> bool {
1266 canonical_u64(predecessor)
1267 .filter(|value| *value > 0)
1268 .and_then(|value| value.checked_add(1))
1269 .is_some_and(|expected| canonical_u64(successor) == Some(expected))
1270}
1271
1272fn ordered_u64(predecessor: &U64, successor: &U64) -> bool {
1273 canonical_u64(predecessor)
1274 .zip(canonical_u64(successor))
1275 .is_some_and(|(predecessor, successor)| predecessor < successor)
1276}
1277
1278fn ordered_or_equal_u64(predecessor: &U64, successor: &U64) -> bool {
1279 canonical_u64(predecessor)
1280 .zip(canonical_u64(successor))
1281 .is_some_and(|(predecessor, successor)| predecessor <= successor)
1282}
1283
1284fn checked_add_u64(left: &U64, right: &U64) -> Option<u64> {
1285 canonical_u64(left)?.checked_add(canonical_u64(right)?)
1286}
1287
1288fn valid_positive_bounded_duration(value: &U64) -> bool {
1289 canonical_u64(value).is_some_and(|value| (1..=86_400_000).contains(&value))
1290}
1291
1292fn valid_digest(value: &Digest) -> bool {
1293 value.value.len() == 64
1294 && value
1295 .value
1296 .bytes()
1297 .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase())
1298 && value.value.bytes().any(|byte| byte != b'0')
1299}
1300
1301fn valid_locator(value: &Locator) -> bool {
1302 !value.authority.is_empty()
1303 && value.authority.len() <= MAX_NAME_BYTES
1304 && value.authority.bytes().all(|byte| byte.is_ascii_graphic())
1305 && !value.value.is_empty()
1306 && value.value.len() <= 4_096
1307 && value.value.bytes().all(|byte| byte.is_ascii_graphic())
1308}
1309
1310fn valid_resource(value: &Resource) -> bool {
1311 value.kind == "index" && valid_index_name(&value.name) && valid_identity(&value.incarnation)
1312}
1313
1314fn valid_index_name(value: &str) -> bool {
1315 !value.is_empty()
1316 && value.len() <= MAX_NAME_BYTES
1317 && value
1318 .bytes()
1319 .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || matches!(byte, b'-' | b'_'))
1320}
1321
1322fn valid_reservation(value: &ServiceCredentialReservation) -> bool {
1323 valid_locator(&value.locator)
1324 && value.locator.authority == "service_credential_reservation"
1325 && valid_digest(&value.reservation_action_id)
1326 && valid_digest(&value.activation_action_id)
1327 && valid_identity(&value.principal_id)
1328 && valid_identity(&value.api_key_id)
1329 && valid_u64(&value.generation)
1330 && valid_u64(&value.reservation_expires_at)
1331 && valid_u64(&value.credential_expires_at)
1332 && value.verifier_algorithm == "sha256_identity_bound_v1"
1333 && value.verifier_algorithm_version == 1
1334 && value.required_secret_bytes == 32
1335}
1336
1337#[cfg(test)]
1338#[allow(clippy::expect_used)] mod tests {
1340 use super::*;
1341
1342 fn identity(value: u128) -> Identity {
1343 Identity {
1344 value: format!("{value:032x}"),
1345 }
1346 }
1347
1348 fn instance_identity(value: u8) -> String {
1349 format!("{value:02x}").repeat(32)
1350 }
1351
1352 fn digest(value: u8) -> Digest {
1353 Digest {
1354 value: format!("{value:02x}").repeat(32),
1355 }
1356 }
1357
1358 fn u64_value(value: u64) -> U64 {
1359 U64 {
1360 value: value.to_string(),
1361 }
1362 }
1363
1364 fn file(value: u64) -> PublishedFileIdentity {
1365 PublishedFileIdentity {
1366 device: 1,
1367 inode: value,
1368 size: 1,
1369 sha256: [u8::try_from(value).unwrap_or(1); 32],
1370 }
1371 }
1372
1373 fn reconcile_intent() -> DevelopmentReconcileIntent {
1374 DevelopmentReconcileIntent {
1375 format_version: DEVELOPMENT_RECONCILE_FORMAT_VERSION,
1376 instance_root: "/tmp/lodestone".to_owned(),
1377 instance_manifest_sha256: [1; 32],
1378 index_definition_path: "/tmp/lodestone/operator/development-index.json".to_owned(),
1379 index_definition_sha256: [2; 32],
1380 credential_output: "/tmp/output/credential.json".to_owned(),
1381 profile_output: "/tmp/output/profile.json".to_owned(),
1382 material_output: "/tmp/output/material.json".to_owned(),
1383 timeout_millis: u64_value(90),
1384 reservation_ttl_millis: u64_value(10),
1385 credential_ttl_millis: u64_value(20),
1386 }
1387 }
1388
1389 #[test]
1390 fn materialization_manifest_is_last_and_completion_requires_verification() -> Result<(), WorkflowJournalError> {
1391 let mut transitions = vec![
1392 DevelopmentMaterializeTransition::InstanceIdentityRecorded {
1393 instance_identity: instance_identity(1),
1394 },
1395 DevelopmentMaterializeTransition::ClockRecorded {
1396 unix_millis: u64_value(1),
1397 },
1398 DevelopmentMaterializeTransition::PrivatePlanStaged {
1399 plan: DevelopmentMaterializePrivatePlan::Pki,
1400 file: file(200),
1401 },
1402 DevelopmentMaterializeTransition::PrivatePlanPublished {
1403 plan: DevelopmentMaterializePrivatePlan::Pki,
1404 file: file(200),
1405 },
1406 ];
1407 for (position, artifact) in DevelopmentMaterializeArtifact::required_before_manifest()
1408 .into_iter()
1409 .enumerate()
1410 {
1411 let file = file(u64::try_from(position).unwrap_or(0) + 1);
1412 transitions.push(DevelopmentMaterializeTransition::ArtifactStaged {
1413 artifact,
1414 file: file.clone(),
1415 });
1416 transitions.push(DevelopmentMaterializeTransition::ArtifactPublished {
1417 artifact,
1418 file,
1419 public_sha256: None,
1420 });
1421 }
1422 let manifest = file(100);
1423 transitions.push(DevelopmentMaterializeTransition::ArtifactStaged {
1424 artifact: DevelopmentMaterializeArtifact::InstanceManifest,
1425 file: manifest.clone(),
1426 });
1427 transitions.push(DevelopmentMaterializeTransition::ArtifactPublished {
1428 artifact: DevelopmentMaterializeArtifact::InstanceManifest,
1429 file: manifest,
1430 public_sha256: Some([3; 32]),
1431 });
1432 assert!(DevelopmentMaterializeState::replay(&transitions).is_ok());
1433 transitions.push(DevelopmentMaterializeTransition::Verified {
1434 manifest_sha256: [3; 32],
1435 });
1436 transitions.push(DevelopmentMaterializeTransition::Completed);
1437 assert!(DevelopmentMaterializeState::replay(&transitions)?.completed());
1438
1439 let out_of_order = [
1440 DevelopmentMaterializeTransition::InstanceIdentityRecorded {
1441 instance_identity: instance_identity(1),
1442 },
1443 DevelopmentMaterializeTransition::ClockRecorded {
1444 unix_millis: u64_value(1),
1445 },
1446 DevelopmentMaterializeTransition::ArtifactPublished {
1447 artifact: DevelopmentMaterializeArtifact::InstanceManifest,
1448 file: file(1),
1449 public_sha256: Some([3; 32]),
1450 },
1451 ];
1452 assert_eq!(
1453 DevelopmentMaterializeState::replay(&out_of_order),
1454 Err(WorkflowJournalError::Corrupt)
1455 );
1456 Ok(())
1457 }
1458
1459 #[test]
1460 fn pki_successor_requires_ca_artifacts_only_for_ca_rotation() -> Result<(), WorkflowJournalError> {
1461 let intent = DevelopmentPkiSuccessorIntent {
1462 format_version: DEVELOPMENT_PKI_SUCCESSOR_FORMAT_VERSION,
1463 operation: DevelopmentPkiSuccessorOperation::RenewLeaves,
1464 instance_identity: instance_identity(1),
1465 parent_manifest_path: "/tmp/parent/instance.json".to_owned(),
1466 parent_manifest_sha256: [1; 32],
1467 successor_root: "/tmp/successor".to_owned(),
1468 parent_instance_generation: u64_value(1),
1469 successor_instance_generation: u64_value(2),
1470 parent_ca_generation: u64_value(1),
1471 successor_ca_generation: u64_value(1),
1472 parent_process_leaf_generation: u64_value(1),
1473 successor_process_leaf_generation: u64_value(2),
1474 parent_gateway_leaf_generation: u64_value(1),
1475 successor_gateway_leaf_generation: u64_value(2),
1476 };
1477 intent.validate()?;
1478 let illegal = [DevelopmentPkiSuccessorTransition::ArtifactStaged {
1479 artifact: DevelopmentPkiSuccessorArtifact::CaPrivateKey,
1480 file: file(1),
1481 }];
1482 assert_eq!(
1483 DevelopmentPkiSuccessorState::replay(&intent, &illegal),
1484 Err(WorkflowJournalError::Corrupt)
1485 );
1486
1487 let mut transitions = vec![
1488 DevelopmentPkiSuccessorTransition::ClockRecorded {
1489 unix_millis: u64_value(1),
1490 },
1491 DevelopmentPkiSuccessorTransition::PrivatePlanStaged {
1492 plan: DevelopmentPkiSuccessorPrivatePlan::Pki,
1493 file: file(90),
1494 },
1495 DevelopmentPkiSuccessorTransition::PrivatePlanPublished {
1496 plan: DevelopmentPkiSuccessorPrivatePlan::Pki,
1497 file: file(90),
1498 },
1499 ];
1500 for (position, artifact) in required_pki_artifacts(intent.operation).into_iter().enumerate() {
1501 let identity = file(u64::try_from(position).unwrap_or(0) + 1);
1502 transitions.push(DevelopmentPkiSuccessorTransition::ArtifactStaged {
1503 artifact,
1504 file: identity.clone(),
1505 });
1506 transitions.push(DevelopmentPkiSuccessorTransition::ArtifactPublished {
1507 artifact,
1508 file: identity,
1509 public_sha256: None,
1510 });
1511 }
1512 let manifest = file(100);
1513 transitions.push(DevelopmentPkiSuccessorTransition::ArtifactStaged {
1514 artifact: DevelopmentPkiSuccessorArtifact::InstanceManifest,
1515 file: manifest.clone(),
1516 });
1517 transitions.push(DevelopmentPkiSuccessorTransition::ArtifactPublished {
1518 artifact: DevelopmentPkiSuccessorArtifact::InstanceManifest,
1519 file: manifest,
1520 public_sha256: Some([9; 32]),
1521 });
1522 transitions.push(DevelopmentPkiSuccessorTransition::Verified {
1523 manifest_sha256: [9; 32],
1524 });
1525 transitions.push(DevelopmentPkiSuccessorTransition::Completed);
1526 assert!(DevelopmentPkiSuccessorState::replay(&intent, &transitions)?.completed());
1527 Ok(())
1528 }
1529
1530 #[test]
1531 fn reconciliation_enforces_administrator_scope_before_consumer_scope() -> Result<(), WorkflowJournalError> {
1532 let resource = Resource {
1533 kind: "index".to_owned(),
1534 name: "events".to_owned(),
1535 incarnation: identity(4),
1536 };
1537 let prefix = [
1538 DevelopmentReconcileTransition::EffectDeadlineRecorded {
1539 unix_millis: u64_value(100),
1540 },
1541 DevelopmentReconcileTransition::AuthorityObserved {
1542 cluster_id: identity(1),
1543 collection_id: identity(2),
1544 schema_sha256: digest(3),
1545 logical_time: u64_value(10),
1546 reservation_expires_at: u64_value(20),
1547 credential_expires_at: u64_value(30),
1548 },
1549 DevelopmentReconcileTransition::IndexActive {
1550 resource,
1551 index_revision: u64_value(1),
1552 },
1553 DevelopmentReconcileTransition::RoleVerified {
1554 target: DevelopmentReconcileRoleTarget::Consumer,
1555 role_version: u64_value(1),
1556 observed_policy_revision: u64_value(1),
1557 },
1558 ];
1559 assert_eq!(
1560 DevelopmentReconcileState::replay(&reconcile_intent(), &prefix),
1561 Err(WorkflowJournalError::Corrupt)
1562 );
1563 Ok(())
1564 }
1565
1566 #[test]
1567 fn reconciliation_terminal_outcome_is_final_but_unresolved_work_remains_resumable() {
1568 let deadline = DevelopmentReconcileTransition::EffectDeadlineRecorded {
1569 unix_millis: u64_value(100),
1570 };
1571 let terminal = DevelopmentReconcileTransition::Terminal {
1572 outcome: DevelopmentReconcileTerminalOutcome::EffectDeadlineExpired,
1573 };
1574 let state = DevelopmentReconcileState::replay(&reconcile_intent(), &[deadline.clone(), terminal]);
1575 assert_eq!(
1576 state.map(|value| value.terminal()),
1577 Ok(Some(DevelopmentReconcileTerminalOutcome::EffectDeadlineExpired))
1578 );
1579 assert_eq!(
1580 DevelopmentReconcileState::replay(
1581 &reconcile_intent(),
1582 &[
1583 deadline,
1584 DevelopmentReconcileTransition::Terminal {
1585 outcome: DevelopmentReconcileTerminalOutcome::Conflict,
1586 },
1587 DevelopmentReconcileTransition::Completed,
1588 ],
1589 ),
1590 Err(WorkflowJournalError::Corrupt)
1591 );
1592
1593 let mut resumable = vec![
1597 DevelopmentReconcileTransition::EffectDeadlineRecorded {
1598 unix_millis: u64_value(100),
1599 },
1600 DevelopmentReconcileTransition::AuthorityObserved {
1601 cluster_id: identity(1),
1602 collection_id: identity(2),
1603 schema_sha256: digest(3),
1604 logical_time: u64_value(10),
1605 reservation_expires_at: u64_value(20),
1606 credential_expires_at: u64_value(30),
1607 },
1608 DevelopmentReconcileTransition::CatalogPrepared {
1609 locator: Locator {
1610 authority: "catalog".to_owned(),
1611 value: "create-events".to_owned(),
1612 },
1613 semantic_digest: digest(4),
1614 expires_at: u64_value(40),
1615 resource_spec: IndexResourceSpec {
1616 format_version: u32::from(LODESTONE_API_INDEX_RESOURCE_SPEC_FORMAT_VERSION),
1617 revision: u64_value(1),
1618 initial_shards: 1,
1619 target_shard_bytes: u64_value(1),
1620 durability: crate::ResourceDurabilityClass::RegionalObject,
1621 storage_class: crate::ResourceStorageClass::Standard,
1622 ram_admission_weight: 1,
1623 ram_hard_max_bytes: u64_value(1),
1624 nvme_admission_weight: 1,
1625 nvme_hard_max_bytes: u64_value(1),
1626 refresh_objective_millis: 1,
1627 memtable_hard_max_bytes: u64_value(1),
1628 seal_parallelism: 1,
1629 merge_parallelism: 1,
1630 vector: lodestone_wire::VectorResourcePolicy {
1631 quality: lodestone_wire::VectorQualityObjective::Balanced,
1632 indexing: lodestone_wire::VectorIndexingObjective::Balanced,
1633 residency: None,
1634 calibration: None,
1635 },
1636 lifecycle_binding: None,
1637 encryption_required: false,
1638 resolver_capability: None,
1639 },
1640 },
1641 ];
1642 let mut replacement = resumable[2].clone();
1643 if let DevelopmentReconcileTransition::CatalogPrepared {
1644 locator,
1645 semantic_digest,
1646 ..
1647 } = &mut replacement
1648 {
1649 locator.value = "create-events-after-root-movement".to_owned();
1650 *semantic_digest = digest(5);
1651 }
1652 resumable.push(replacement);
1653 let state = DevelopmentReconcileState::replay(&reconcile_intent(), &resumable);
1654 assert!(state.is_ok_and(|value| {
1655 value.terminal().is_none()
1656 && value
1657 .catalog_preparation()
1658 .is_some_and(|(locator, semantic_digest, _, _)| {
1659 locator.value == "create-events-after-root-movement" && semantic_digest == &digest(5)
1660 })
1661 }));
1662 }
1663}