1pub(in crate::fleet_ensure) mod automatic;
6pub(in crate::fleet_ensure) mod convergence;
7pub(in crate::fleet_ensure) mod declarations;
8pub(in crate::fleet_ensure) mod inspection;
9pub(in crate::fleet_ensure) mod publication;
10pub(in crate::fleet_ensure) mod registration;
11pub(in crate::fleet_ensure) mod registration_recovery;
12pub(in crate::fleet_ensure) mod survey;
13pub(in crate::fleet_ensure) mod terminal;
14#[cfg(test)]
15pub(in crate::fleet_ensure::ops) mod tests;
16
17use crate::{
18 fleet_ensure::{
19 model::{
20 CanisterRuntimeStatus, DesiredFleet, FleetEnsurePlan, FleetEnsurePlanScope,
21 FleetEnsureStateRecord, FleetObservation, LiveCanister,
22 infrastructure_bootstrap::{
23 BootstrapCoordinatorSelection, InfrastructureBootstrapRecord,
24 },
25 },
26 ops::{
27 EnsureStateError, canic_init,
28 capacity_import::{
29 admission::observer::{inventory, management},
30 journal::CapacityImportJournalError,
31 },
32 resolve_desired_artifacts,
33 },
34 policy::{EnsurePolicyError, infrastructure_bootstrap},
35 view::infrastructure_bootstrap::InfrastructureBootstrapObservation,
36 },
37 icp::IcpCli,
38};
39use candid::{CandidType, Nat, Principal};
40use canic_contracts::{dto::fleet_registry::FleetRegistry, ids::CanonicalNetworkId};
41use canic_core::{
42 cdk::utils::hash::{decode_hex, hex_bytes},
43 control_plane_support::ops::fleet_registry::FleetRegistryOps,
44 shared_support::fleet_admission_policy::bind_initial_fleet_admission_policy,
45};
46use sha2_host::{Digest, Sha256};
47use std::{
48 collections::{BTreeMap, BTreeSet},
49 path::Path,
50};
51use thiserror::Error;
52
53#[derive(Debug, Error)]
55pub enum InfrastructureBootstrapError {
56 #[error(
57 "bootstrap registration needs {required_cycles} cycles of execution allowance; controlled balance {available_cycles}, funding shortfall {funding_shortfall_cycles}; remaining approved execution allowance {remaining_execution_cycles}, execution-budget shortfall {execution_shortfall_cycles}; preserve the retained operation and review it with fleet bootstrap --recover <plan-sha256>"
58 )]
59 RegistrationBudget {
60 required_cycles: u128,
61 available_cycles: u128,
62 funding_shortfall_cycles: u128,
63 remaining_execution_cycles: u128,
64 execution_shortfall_cycles: u128,
65 },
66 #[error(
67 "bootstrap registration recovery requires approval of review {review_sha256}; the original operation is retained"
68 )]
69 RegistrationApproval { review_sha256: String },
70 #[error(
71 "bootstrap inspection allowance exhausted; retain original effects and balances and review continuation with canic --environment <environment> fleet recover-attempts <fleet>"
72 )]
73 InspectionBudget,
74 #[error("infrastructure bootstrap source, plan or current authority differs")]
75 Integrity,
76 #[error("bootstrap observation is unavailable: {0}")]
77 Observation(String),
78 #[error(transparent)]
79 Policy(#[from] EnsurePolicyError),
80 #[error(transparent)]
81 State(#[from] EnsureStateError),
82 #[error(transparent)]
83 Inspection(#[from] CapacityImportJournalError),
84 #[error(transparent)]
85 Encoding(#[from] serde_json::Error),
86 #[error(transparent)]
87 Io(#[from] std::io::Error),
88}
89
90pub fn seal_sources(
92 mut record: InfrastructureBootstrapRecord,
93) -> Result<InfrastructureBootstrapRecord, InfrastructureBootstrapError> {
94 verify_declarations(&record)?;
95 record.source_sha256 = source_digest(&record)?;
96 Ok(record)
97}
98
99fn verify_declarations(
100 record: &InfrastructureBootstrapRecord,
101) -> Result<(), InfrastructureBootstrapError> {
102 let invalid = || InfrastructureBootstrapError::Integrity;
103 let digest: [u8; 32] = Sha256::digest(record.declarations_toml.as_bytes()).into();
104 if record.declarations_toml.len() > 256 * 1024 || digest != record.declarations_sha256 {
105 return Err(invalid());
106 }
107 let declarations: declarations::BootstrapDeclarations =
108 toml::from_str(&record.declarations_toml).map_err(|_| invalid())?;
109 if declarations.schema_version != 1
110 || Principal::from_text(&declarations.operator).ok() != Some(record.operator)
111 || declarations.network_root_key_sha256 != hex_bytes(record.network_root_key_sha256)
112 || declarations.canisters.len() != record.sources.len()
113 || declarations.root_owned.len() != record.held_sources.len()
114 {
115 return Err(invalid());
116 }
117 let mut seen = BTreeSet::new();
118 for declaration in &declarations.canisters {
119 let binding = declaration.binding().map_err(|_| invalid())?;
120 if !seen.insert(binding.canister_id)
121 || !declaration.no_external_obligations
122 || !declaration.no_other_fleet_ownership
123 || declaration.evidence.trim().is_empty()
124 {
125 return Err(invalid());
126 }
127 let source = record
128 .sources
129 .values()
130 .find(|source| source.sample.binding.canister_id == binding.canister_id)
131 .ok_or_else(invalid)?;
132 let mut expected = source.sample.binding.clone();
133 expected.stopped = true;
134 expected.snapshots_size_bytes = 0;
135 if expected != binding || source.disposition != declaration.disposition(digest) {
136 return Err(invalid());
137 }
138 }
139 for declaration in &declarations.root_owned {
140 let binding = declaration.binding()?;
141 if !seen.insert(binding.canister) {
142 return Err(invalid());
143 }
144 let source = record
145 .held_sources
146 .values()
147 .find(|source| source.custody.canister == binding.canister)
148 .ok_or_else(invalid)?;
149 if source.custody != binding
150 || source.disposition != declaration.disposition(digest)
151 || !binding.controllers.contains(&source.root)
152 {
153 return Err(invalid());
154 }
155 }
156 Ok(())
157}
158
159fn source_digest(
160 record: &InfrastructureBootstrapRecord,
161) -> Result<[u8; 32], InfrastructureBootstrapError> {
162 let mut projection = record.clone();
163 projection.source_sha256 = [0; 32];
164 let mut hash = Sha256::new();
165 hash.update(b"canic.infrastructure-bootstrap.sources.v1\0");
166 hash.update(serde_json::to_vec(&projection)?);
167 Ok(hash.finalize().into())
168}
169
170pub fn prepare(
173 root: &Path,
174 desired: &DesiredFleet,
175 record: &InfrastructureBootstrapRecord,
176 desired_sha256: &str,
177 time: u64,
178) -> Result<FleetEnsurePlan, InfrastructureBootstrapError> {
179 prepare_with_funding(
180 root,
181 desired,
182 record,
183 desired_sha256,
184 time,
185 &BTreeMap::new(),
186 )
187}
188
189fn prepare_with_funding(
190 root: &Path,
191 desired: &DesiredFleet,
192 record: &InfrastructureBootstrapRecord,
193 desired_sha256: &str,
194 time: u64,
195 funding_minima: &BTreeMap<String, u128>,
196) -> Result<FleetEnsurePlan, InfrastructureBootstrapError> {
197 if source_digest(record)? != record.source_sha256 {
198 return Err(InfrastructureBootstrapError::Integrity);
199 }
200 verify_declarations(record)?;
201 publication::validate_seed(desired, record)?;
202 verify_ready_coordinator(root, desired, record)?;
203 let observation = original_observation(desired, record);
204 let artifacts = resolve_desired_artifacts(root, desired)?;
205 let mut plan = infrastructure_bootstrap::compile(
206 desired,
207 &artifacts,
208 record,
209 &observation,
210 desired_sha256,
211 time,
212 funding_minima,
213 )?;
214 let registration = registration::quote(root, &plan, &artifacts)?;
215 plan.conservation.maximum_execution_burn_cycles = plan
216 .conservation
217 .maximum_execution_burn_cycles
218 .checked_add(registration)
219 .ok_or(EnsurePolicyError::ArithmeticOverflow {
220 field: "bootstrap initialization and registration",
221 })?;
222 let available = plan
223 .conservation
224 .observed_controlled_cycles
225 .checked_add(plan.conservation.maximum_new_funding_cycles)
226 .ok_or(EnsurePolicyError::ArithmeticOverflow {
227 field: "bootstrap available cycles",
228 })?;
229 plan.conservation.expected_post_operation_cycles =
230 available.saturating_sub(plan.conservation.maximum_execution_burn_cycles);
231 plan.plan_sha256 = crate::fleet_ensure::policy::expected_plan_sha256(&plan);
232 publication::validate_capacity(&plan)?;
233 Ok(plan)
234}
235
236pub(in crate::fleet_ensure) fn prepare_review(
238 root: &Path,
239 desired: &DesiredFleet,
240 record: &InfrastructureBootstrapRecord,
241 desired_sha256: &str,
242 time: u64,
243) -> Result<FleetEnsurePlan, InfrastructureBootstrapError> {
244 let plan = prepare(root, desired, record, desired_sha256, time)?;
245 let shortfall = match infrastructure_bootstrap::admit_review(&plan) {
246 Ok(()) => return Ok(plan),
247 Err(EnsurePolicyError::InfrastructureBootstrap(
248 infrastructure_bootstrap::InfrastructureBootstrapError::Budget { shortfall, .. },
249 )) => shortfall,
250 Err(error) => return Err(error.into()),
251 };
252 let target = plan
255 .canisters
256 .iter()
257 .find(|target| {
258 desired.canisters.iter().any(|configured| {
259 configured.name == target.name
260 && configured.kind == crate::fleet_ensure::model::DesiredCanisterKind::Store
261 })
262 })
263 .ok_or(InfrastructureBootstrapError::Integrity)?;
264 let sample = &record
265 .sources
266 .get(&target.name)
267 .ok_or(InfrastructureBootstrapError::Integrity)?
268 .sample;
269 let deficit = target
270 .actions
271 .iter()
272 .find_map(|action| {
273 if let crate::fleet_ensure::model::EnsureAction::Fund {
274 funding_deficit_cycles,
275 ..
276 } = action
277 {
278 Some(*funding_deficit_cycles)
279 } else {
280 None
281 }
282 })
283 .unwrap_or(0);
284 let minimum = sample
285 .cycles
286 .checked_add(deficit)
287 .and_then(|amount| amount.checked_add(shortfall))
288 .ok_or(EnsurePolicyError::ArithmeticOverflow {
289 field: "bootstrap registration funding",
290 })?;
291 let funded = prepare_with_funding(
292 root,
293 desired,
294 record,
295 desired_sha256,
296 time,
297 &BTreeMap::from([(target.name.clone(), minimum)]),
298 )?;
299 infrastructure_bootstrap::admit_review(&funded)?;
300 Ok(funded)
301}
302
303pub(in crate::fleet_ensure) fn verify_plan(
305 root: &Path,
306 plan: &FleetEnsurePlan,
307) -> Result<(), InfrastructureBootstrapError> {
308 let record = plan
309 .infrastructure_bootstrap
310 .as_ref()
311 .ok_or(InfrastructureBootstrapError::Integrity)?;
312 let desired = plan
313 .reviewed_desired
314 .as_ref()
315 .ok_or(InfrastructureBootstrapError::Integrity)?
316 .desired();
317 let mut funding_minima = BTreeMap::new();
318 for target in &plan.canisters {
319 for action in &target.actions {
320 if let crate::fleet_ensure::model::EnsureAction::Fund {
321 funding_deficit_cycles,
322 ..
323 } = action
324 {
325 let sample = &record
326 .sources
327 .get(&target.name)
328 .ok_or(InfrastructureBootstrapError::Integrity)?
329 .sample;
330 let minimum = sample
331 .cycles
332 .checked_add(*funding_deficit_cycles)
333 .ok_or(InfrastructureBootstrapError::Integrity)?;
334 if funding_minima
335 .insert(target.name.clone(), minimum)
336 .is_some()
337 {
338 return Err(InfrastructureBootstrapError::Integrity);
339 }
340 }
341 }
342 }
343 let mut expected = prepare_with_funding(
344 root,
345 desired,
346 record,
347 &plan.desired_sha256,
348 plan.planned_at_time,
349 &funding_minima,
350 )?;
351 let available = expected
355 .conservation
356 .observed_controlled_cycles
357 .checked_add(expected.conservation.maximum_new_funding_cycles)
358 .ok_or(InfrastructureBootstrapError::Integrity)?;
359 expected.conservation.maximum_execution_burn_cycles =
360 plan.conservation.maximum_execution_burn_cycles;
361 expected.conservation.expected_post_operation_cycles = available
362 .checked_sub(plan.conservation.maximum_execution_burn_cycles)
363 .ok_or(InfrastructureBootstrapError::Integrity)?;
364 expected.plan_sha256 = crate::fleet_ensure::policy::expected_plan_sha256(&expected);
365 if plan.scope != FleetEnsurePlanScope::InfrastructureBootstrap || expected != *plan {
366 return Err(InfrastructureBootstrapError::Integrity);
367 }
368 Ok(())
369}
370
371pub(in crate::fleet_ensure) fn original_observation(
373 desired: &DesiredFleet,
374 record: &InfrastructureBootstrapRecord,
375) -> FleetObservation {
376 let view = InfrastructureBootstrapObservation {
377 held_sources: record
378 .held_sources
379 .iter()
380 .map(|(name, source)| (name.clone(), source.custody.clone()))
381 .collect(),
382 canisters: desired
383 .canisters
384 .iter()
385 .map(|configured| {
386 (
387 configured.name.clone(),
388 record
389 .sources
390 .get(&configured.name)
391 .map(|source| source.sample.clone()),
392 )
393 })
394 .collect(),
395 coordinator_registry: None,
396 operator_cycles: record.operator_cycles,
397 ledger_fee_cycles: record.ledger_fee_cycles,
398 };
399 fleet_observation(&view)
400}
401
402pub(in crate::fleet_ensure) fn fleet_observation(
403 view: &InfrastructureBootstrapObservation,
404) -> FleetObservation {
405 FleetObservation {
406 additional_controlled_cycles: BTreeMap::new(),
407 estate_funding_domains: BTreeMap::new(),
408 protocol_ready: BTreeMap::new(),
409 operator_cycles: view.operator_cycles,
410 ledger_fee_cycles: view.ledger_fee_cycles,
411 canisters: view
412 .canisters
413 .iter()
414 .map(|(name, sample)| {
415 (
416 name.clone(),
417 sample.as_ref().map(|sample| LiveCanister {
418 canister_version: Some(sample.binding.canister_version),
419 controllers: sample
420 .binding
421 .controllers
422 .iter()
423 .map(Principal::to_text)
424 .collect(),
425 cycles: sample.cycles,
426 module_sha256: sample.binding.module_sha256.map(hex_bytes),
427 principal: sample.binding.canister_id.to_text(),
428 reinstall_required: false,
429 root_owned_lifecycle: None,
430 status: if sample.binding.stopped {
431 CanisterRuntimeStatus::Stopped
432 } else {
433 CanisterRuntimeStatus::Running
434 },
435 }),
436 )
437 })
438 .collect(),
439 }
440}
441
442fn verify_ready_coordinator(
443 root: &Path,
444 desired: &DesiredFleet,
445 record: &InfrastructureBootstrapRecord,
446) -> Result<(), InfrastructureBootstrapError> {
447 if record.coordinator != BootstrapCoordinatorSelection::Ready {
448 return Ok(());
449 }
450 let encoded = record
451 .coordinator_registry_candid_hex
452 .as_ref()
453 .ok_or(InfrastructureBootstrapError::Integrity)?;
454 let bytes = decode_hex(encoded).map_err(|_| InfrastructureBootstrapError::Integrity)?;
455 let observed: FleetRegistry =
456 candid::decode_one(&bytes).map_err(|_| InfrastructureBootstrapError::Integrity)?;
457 let principals = desired
458 .canisters
459 .iter()
460 .filter_map(|configured| {
461 configured
462 .principal
463 .as_ref()
464 .map(|id| (configured.name.clone(), id.clone()))
465 })
466 .collect();
467 if observed != expected_registry(root, desired, &principals)? {
468 return Err(InfrastructureBootstrapError::Integrity);
469 }
470 Ok(())
471}
472
473fn expected_registry(
474 root: &Path,
475 desired: &DesiredFleet,
476 principals: &BTreeMap<String, String>,
477) -> Result<FleetRegistry, InfrastructureBootstrapError> {
478 let bootstrap = desired
479 .bootstrap
480 .as_ref()
481 .ok_or(InfrastructureBootstrapError::Integrity)?;
482 let authorities = canic_init::compile_root_authorities(root, desired, principals)
483 .map_err(|_| InfrastructureBootstrapError::Integrity)?;
484 let authority = authorities
485 .first()
486 .ok_or(InfrastructureBootstrapError::Integrity)?
487 .1
488 .binding
489 .authority
490 .clone();
491 let admission =
492 bind_initial_fleet_admission_policy(authority.binding.fleet.clone(), &bootstrap.admission)
493 .map_err(|_| InfrastructureBootstrapError::Integrity)?;
494 FleetRegistryOps::compile_genesis(
495 &bootstrap.app,
496 authority,
497 &bootstrap
498 .component_deployment_configuration
499 .component_topology,
500 admission,
501 )
502 .map_err(|_| InfrastructureBootstrapError::Integrity)
503}
504
505pub(in crate::fleet_ensure::ops) fn verify_coordinator(
507 icp: &IcpCli,
508 root: &Path,
509 desired: &DesiredFleet,
510 source: &InfrastructureBootstrapRecord,
511 state: &FleetEnsureStateRecord,
512) -> Result<(), InfrastructureBootstrapError> {
513 let principals = desired
514 .canisters
515 .iter()
516 .map(|configured| {
517 configured
518 .principal
519 .as_ref()
520 .or_else(|| state.pending_principals.get(&configured.name))
521 .or_else(|| state.principals.get(&configured.name))
522 .map(|id| (configured.name.clone(), id.clone()))
523 .ok_or(InfrastructureBootstrapError::Integrity)
524 })
525 .collect::<Result<BTreeMap<_, _>, _>>()?;
526 verify_registry(
527 icp,
528 desired,
529 source,
530 &expected_registry(root, desired, &principals)?,
531 )
532}
533
534pub(in crate::fleet_ensure::ops) fn verify_registry(
536 icp: &IcpCli,
537 desired: &DesiredFleet,
538 source: &InfrastructureBootstrapRecord,
539 expected: &FleetRegistry,
540) -> Result<(), InfrastructureBootstrapError> {
541 let agent = authenticated_agent(icp, desired, source)?;
542 let runtime = tokio::runtime::Builder::new_current_thread()
543 .enable_all()
544 .build()?;
545 let observed = runtime.block_on(inventory::registry(
546 &agent,
547 expected.authority.binding.coordinator,
548 ))?;
549 if observed != *expected {
550 return Err(InfrastructureBootstrapError::Integrity);
551 }
552 Ok(())
553}
554
555#[derive(CandidType)]
556struct LedgerAccount {
557 owner: Principal,
558 subaccount: Option<Vec<u8>>,
559}
560
561pub(in crate::fleet_ensure::ops) fn observe(
563 icp: &IcpCli,
564 desired: &DesiredFleet,
565 record: &InfrastructureBootstrapRecord,
566 state: &FleetEnsureStateRecord,
567) -> Result<InfrastructureBootstrapObservation, InfrastructureBootstrapError> {
568 let agent = authenticated_agent(icp, desired, record)?;
569 let bootstrap = desired
570 .bootstrap
571 .as_ref()
572 .ok_or(InfrastructureBootstrapError::Integrity)?;
573 let runtime = tokio::runtime::Builder::new_current_thread()
574 .enable_all()
575 .build()
576 .map_err(|error| InfrastructureBootstrapError::Observation(error.to_string()))?;
577 let mut canisters = BTreeMap::new();
578 let mut held_sources = BTreeMap::new();
579 for configured in &desired.canisters {
580 if let Some(source) = record.held_sources.get(&configured.name) {
581 let custody = runtime.block_on(declarations::observe_held(&agent, source))?;
582 held_sources.insert(configured.name.clone(), custody);
583 canisters.insert(configured.name.clone(), None);
584 continue;
585 }
586 let id = configured
587 .principal
588 .as_ref()
589 .or_else(|| state.pending_principals.get(&configured.name))
590 .or_else(|| state.principals.get(&configured.name));
591 let sample = match id {
592 Some(id) => Some(runtime.block_on(management::observe(
593 &agent,
594 Principal::from_text(id).map_err(|_| InfrastructureBootstrapError::Integrity)?,
595 ))?),
596 None if configured.name == bootstrap.coordinator
597 && record.coordinator == BootstrapCoordinatorSelection::Create =>
598 {
599 None
600 }
601 None => return Err(InfrastructureBootstrapError::Integrity),
602 };
603 canisters.insert(configured.name.clone(), sample);
604 }
605 let coordinator_registry = if record.coordinator == BootstrapCoordinatorSelection::Ready {
606 let id = canisters
607 .get(&bootstrap.coordinator)
608 .and_then(Option::as_ref)
609 .ok_or(InfrastructureBootstrapError::Integrity)?
610 .binding
611 .canister_id;
612 Some(runtime.block_on(inventory::registry(&agent, id))?)
613 } else {
614 None
615 };
616 let fee: Nat = icp
617 .canister_query_candid(&desired.cycles_ledger, "icrc1_fee", &(), None)
618 .map_err(|error| InfrastructureBootstrapError::Observation(error.to_string()))?;
619 let balance: Nat = icp
620 .canister_query_candid(
621 &desired.cycles_ledger,
622 "icrc1_balance_of",
623 &LedgerAccount {
624 owner: record.operator,
625 subaccount: None,
626 },
627 None,
628 )
629 .map_err(|error| InfrastructureBootstrapError::Observation(error.to_string()))?;
630 Ok(InfrastructureBootstrapObservation {
631 held_sources,
632 canisters,
633 coordinator_registry,
634 operator_cycles: balance
635 .0
636 .try_into()
637 .map_err(|_| InfrastructureBootstrapError::Integrity)?,
638 ledger_fee_cycles: fee
639 .0
640 .try_into()
641 .map_err(|_| InfrastructureBootstrapError::Integrity)?,
642 })
643}
644
645fn authenticated_agent(
646 icp: &IcpCli,
647 desired: &DesiredFleet,
648 record: &InfrastructureBootstrapRecord,
649) -> Result<ic_agent::Agent, InfrastructureBootstrapError> {
650 let agent = icp
651 .authenticated_agent_with_response_limit(2 * 1024 * 1024)
652 .map_err(|error| InfrastructureBootstrapError::Observation(error.to_string()))?;
653 let root_key = agent.read_root_key();
654 let key_hash: [u8; 32] = Sha256::digest(&root_key).into();
655 let bootstrap = desired
656 .bootstrap
657 .as_ref()
658 .ok_or(InfrastructureBootstrapError::Integrity)?;
659 if agent.get_principal().ok() != Some(record.operator)
660 || key_hash != record.network_root_key_sha256
661 || CanonicalNetworkId::from_der_root_trust_anchor(&root_key).ok()
662 != Some(bootstrap.canonical_network_id)
663 {
664 return Err(InfrastructureBootstrapError::Integrity);
665 }
666 Ok(agent)
667}
668
669pub(in crate::fleet_ensure) fn verify_initial(
671 plan: &FleetEnsurePlan,
672 observed: &InfrastructureBootstrapObservation,
673) -> Result<(), InfrastructureBootstrapError> {
674 let source = plan
675 .infrastructure_bootstrap
676 .as_ref()
677 .ok_or(InfrastructureBootstrapError::Integrity)?;
678 let desired = plan
679 .reviewed_desired
680 .as_ref()
681 .ok_or(InfrastructureBootstrapError::Integrity)?
682 .desired();
683 let allowance = desired
684 .maximum_observation_burn_cycles
685 .parse::<canic_contracts::cycles::Cycles>()
686 .map_err(|_| InfrastructureBootstrapError::Integrity)?
687 .to_u128();
688 let held = source
689 .held_sources
690 .iter()
691 .map(|(name, source)| (name.clone(), source.custody.clone()))
692 .collect::<BTreeMap<_, _>>();
693 if observed.held_sources != held
694 || observed.canisters.len() != desired.canisters.len()
695 || observed.operator_cycles < source.operator_cycles
696 || observed.ledger_fee_cycles != source.ledger_fee_cycles
697 {
698 return Err(InfrastructureBootstrapError::Integrity);
699 }
700 for configured in &desired.canisters {
701 let actual = observed
702 .canisters
703 .get(&configured.name)
704 .ok_or(InfrastructureBootstrapError::Integrity)?;
705 match (source.sources.get(&configured.name), actual) {
706 (None, None)
707 if configured.kind == crate::fleet_ensure::model::DesiredCanisterKind::Pool
708 && source.held_sources.contains_key(&configured.name) => {}
709 (None, None)
710 if configured.kind
711 == crate::fleet_ensure::model::DesiredCanisterKind::Coordinator
712 && source.coordinator == BootstrapCoordinatorSelection::Create => {}
713 (Some(original), Some(actual)) => {
714 let before = original
715 .sample
716 .cycles
717 .checked_add(original.sample.reserved_cycles)
718 .ok_or(InfrastructureBootstrapError::Integrity)?;
719 let after = actual
720 .cycles
721 .checked_add(actual.reserved_cycles)
722 .ok_or(InfrastructureBootstrapError::Integrity)?;
723 let mut expected_binding = original.sample.binding.clone();
724 if !expected_binding.stopped
725 && actual.binding.canister_version >= expected_binding.canister_version
726 {
727 expected_binding.canister_version = actual.binding.canister_version;
728 }
729 if expected_binding != actual.binding || before.saturating_sub(after) > allowance {
730 return Err(InfrastructureBootstrapError::Integrity);
731 }
732 }
733 _ => return Err(InfrastructureBootstrapError::Integrity),
734 }
735 }
736 let actual_registry = observed
737 .coordinator_registry
738 .as_ref()
739 .map(candid::encode_one)
740 .transpose()
741 .map_err(|_| InfrastructureBootstrapError::Integrity)?
742 .map(hex_bytes);
743 if source.coordinator_registry_candid_hex != actual_registry {
744 return Err(InfrastructureBootstrapError::Integrity);
745 }
746 Ok(())
747}
748
749pub(in crate::fleet_ensure) fn terminal_observation(
751 workspace: &Path,
752 plan: &FleetEnsurePlan,
753 state: &FleetEnsureStateRecord,
754 observed: &InfrastructureBootstrapObservation,
755) -> Result<(FleetObservation, u128), InfrastructureBootstrapError> {
756 let source = plan
757 .infrastructure_bootstrap
758 .as_ref()
759 .ok_or(InfrastructureBootstrapError::Integrity)?;
760 let held = source
761 .held_sources
762 .iter()
763 .map(|(name, source)| (name.clone(), source.custody.clone()))
764 .collect::<BTreeMap<_, _>>();
765 if observed.held_sources != held {
766 return Err(InfrastructureBootstrapError::Integrity);
767 }
768 let desired = plan
769 .reviewed_desired
770 .as_ref()
771 .ok_or(InfrastructureBootstrapError::Integrity)?
772 .desired();
773 let artifacts = resolve_desired_artifacts(workspace, desired)?;
774 let principals = desired
775 .canisters
776 .iter()
777 .filter_map(|configured| {
778 configured
779 .principal
780 .as_ref()
781 .or_else(|| state.pending_principals.get(&configured.name))
782 .or_else(|| state.principals.get(&configured.name))
783 .map(|id| (configured.name.clone(), id.clone()))
784 })
785 .collect::<BTreeMap<_, _>>();
786 let mut terminal = fleet_observation(observed);
787 terminal
788 .canisters
789 .retain(|name, _| plan.canisters.iter().any(|canister| canister.name == *name));
790 if terminal.canisters.len() != plan.canisters.len() {
791 return Err(InfrastructureBootstrapError::Integrity);
792 }
793 let mut total = 0_u128;
794 for target in &plan.canisters {
795 let configured = desired
796 .canisters
797 .iter()
798 .find(|candidate| candidate.name == target.name)
799 .ok_or(InfrastructureBootstrapError::Integrity)?;
800 let actual = observed
801 .canisters
802 .get(&target.name)
803 .and_then(Option::as_ref)
804 .ok_or(InfrastructureBootstrapError::Integrity)?;
805 let named_controllers = configured
806 .controller_canisters
807 .iter()
808 .map(|name| {
809 principals
810 .get(name)
811 .cloned()
812 .ok_or(InfrastructureBootstrapError::Integrity)
813 })
814 .collect::<Result<Vec<_>, _>>()?;
815 let mut controllers = configured
816 .controllers
817 .iter()
818 .cloned()
819 .chain(named_controllers)
820 .map(|id| Principal::from_text(id).map_err(|_| InfrastructureBootstrapError::Integrity))
821 .collect::<Result<Vec<_>, _>>()?;
822 controllers.sort_unstable();
823 controllers.dedup();
824 let minimum = configured
825 .minimum_cycles
826 .parse::<canic_contracts::cycles::Cycles>()
827 .map_err(|_| InfrastructureBootstrapError::Integrity)?
828 .to_u128();
829 if principals.get(&target.name) != Some(&actual.binding.canister_id.to_text())
830 || configured.subnet != actual.binding.subnet.to_string()
831 || actual.binding.controllers != controllers
832 || actual.binding.stopped
833 || actual.binding.snapshots_size_bytes != 0
834 || actual.cycles < minimum
835 || artifacts.wasm_sha256_by_canister.get(&target.name)
836 != actual.binding.module_sha256.map(hex_bytes).as_ref()
837 {
838 return Err(InfrastructureBootstrapError::Integrity);
839 }
840 total = total
841 .checked_add(actual.cycles)
842 .and_then(|n| n.checked_add(actual.reserved_cycles))
843 .ok_or(InfrastructureBootstrapError::Integrity)?;
844 }
845 Ok((terminal, total))
846}