1pub mod capacity_import;
8pub mod infrastructure_bootstrap;
9pub mod preflight;
10mod startup_funding;
11#[cfg(test)]
12mod tests;
13
14use crate::{
15 canister_protocol::query_with_candid,
16 component_topology::{
17 PlannedFleetSubnetRootTopology, PlannedFleetSubnetRootTopologyInput, PlannedFleetTopology,
18 RootComponentAdmissionInput, RootPoolCapacityError, RootPoolCapacityInput,
19 RootPoolImportCapacityError, RootPoolImportCapacityInput, plan_initial_fleet_topology,
20 validate_root_pool_capacity, validate_root_pool_import_capacity,
21 },
22 durable_io::{
23 RegularFileReadError, create_new_bytes_with_parents, read_optional_regular_bytes,
24 },
25 fleet_ensure::model::{
26 CanisterRuntimeStatus, DesiredCanister, DesiredCanisterInit, DesiredCanisterKind,
27 DesiredComponentGroupPlacement, DesiredFleet, DesiredFleetBootstrap,
28 DesiredFleetBootstrapRoot, DesiredFleetProtocol, DesiredPresence,
29 FLEET_ENSURE_SCHEMA_VERSION, MAX_FLEET_ENSURE_CANISTERS, RetainedRootStartAuthorityRecord,
30 RootManagementBinding,
31 },
32 fleet_ensure::ops::{EnsurePaths, root_owned_lifecycle, write_root_start_authority},
33 icp::{IcpCli, LocalReplicaTarget},
34 icp_config::resolve_icp_build_network_from_root,
35 network::resolve_canonical_network_id_from_root,
36 release_build::validate_finalized_release_build_manifest,
37 release_set::{
38 AppConfigSnapshot, CanicInfrastructureArtifactEntry, CanicInfrastructureRole,
39 load_persisted_application_artifact_union,
40 load_persisted_canic_infrastructure_artifact_manifest,
41 load_persisted_current_release_set_manifest,
42 },
43 subnet_catalog::MainnetCatalogClient,
44};
45use candid::{Nat, Principal};
46use canic_core::{
47 cdk::types::Cycles,
48 control_plane_support::config::ComponentDeploymentConfiguration,
49 dto::fleet_subnet_root::FleetSubnetRootAuthority,
50 dto::pool::{CanisterPoolResponse, CanisterPoolStatusRequest},
51 ids::{
52 BuildNetwork, ComponentGroupDeploymentId, ComponentSpecId, CyclesFundingBudget,
53 FleetAdmissionPolicyTemplate, FleetBinding, FleetCoordinatorBinding,
54 FleetCoordinatorRootFundingPolicy, FleetFundingProfile, FleetKey, FleetRegistryAuthority,
55 FleetSubnetCanisterPoolConfig, FleetSubnetRootBinding, FleetSubnetRootFundingAuthority,
56 FleetSubnetRootFundingPolicy, FleetSubnetRootLimits, FleetSubnetWasmStoreAuthority,
57 ReleaseBuildId, SubnetId,
58 },
59 protocol,
60 shared_support::fleet_admission_policy::compile_fleet_admission_policy_template,
61};
62use ic_query::subnet_catalog::{CatalogLoadOutcome, SubnetSpecialization};
63use serde::{Deserialize, Serialize};
64use sha2_host::{Digest, Sha256};
65use std::{
66 collections::{BTreeMap, BTreeSet},
67 path::{Path, PathBuf},
68 time::{SystemTime, UNIX_EPOCH},
69};
70use thiserror::Error as ThisError;
71
72const MAX_GENERATOR_INPUT_BYTES: usize = 1024 * 1024;
73const MAINNET_CYCLES_LEDGER: &str = "um5iw-rqaaa-aaaaq-qaaba-cai";
74const GENERATED_RETAINED_MATERIAL_CYCLE_THRESHOLD: u128 = 1_000_000;
75const GENERATED_RETAINED_MAXIMUM_OBSERVATION_BURN_CYCLES: u128 = 1_000_000_000_000;
78const GENERATED_POOL_CREATION_EXECUTION_MARGIN_CYCLES: u128 = 1_000_000_000_000;
79const GENERATED_RETAINED_MAXIMUM_UPDATE_BURN_CYCLES: u128 = 1_000_000_000_000;
80
81#[doc(hidden)]
84pub fn fresh_pool_creation_funding(readiness_floor: u128) -> Result<u128, FleetGenerateError> {
85 crate::fleet_ensure::model::creation_observation_burn(
86 GENERATED_RETAINED_MAXIMUM_OBSERVATION_BURN_CYCLES,
87 GENERATED_RETAINED_MAXIMUM_UPDATE_BURN_CYCLES,
88 )
89 .and_then(|burn| readiness_floor.checked_add(burn))
90 .and_then(|funding| funding.checked_add(GENERATED_RETAINED_MAXIMUM_UPDATE_BURN_CYCLES))
91 .ok_or_else(|| {
92 FleetGenerateError::Authority(
93 "fresh pool creation funding plus its bounded pre-import execution margin overflowed"
94 .to_string(),
95 )
96 })
97}
98
99pub struct FleetGenerateRequest<'a> {
101 pub catalog_progress:
102 Option<&'a (dyn Fn(&crate::subnet_catalog::view::CatalogAcquisitionProgress) + Sync)>,
103 pub app_config: &'a Path,
104 pub environment: &'a str,
105 pub fleet: &'a str,
106 pub icp_executable: &'a str,
107 pub signing_identity: Option<&'a str>,
108 pub release_build_id: ReleaseBuildId,
109 pub root: &'a Path,
110 pub seed: &'a Path,
111 pub source: &'a Path,
112}
113
114pub struct FreshEstateSeedRequest<'a> {
116 pub cycles_ledger: &'a str,
117 pub management_creation_fee_cycles: u128,
118 pub seed: &'a Path,
119 pub source: &'a Path,
120}
121
122pub struct GeneratedDesiredFleet {
124 pub clean_reinstall: bool,
126 pub desired: DesiredFleet,
127 pub observed_canisters: usize,
128 pub observed_controlled_cycles: u128,
129 pub release_build_id: ReleaseBuildId,
130 pub subnet_catalog: Option<crate::subnet_catalog::view::SubnetCatalogObservation>,
131 pub startup_funding: Option<crate::fleet_ensure::view::startup_funding::StartupFundingForecast>,
132}
133
134enum GenerationOutput {
135 Ordinary(Box<GeneratedDesiredFleet>),
136 Bootstrap(Box<DesiredFleet>),
137}
138
139impl GenerationOutput {
140 fn ordinary(self) -> Result<GeneratedDesiredFleet, FleetGenerateError> {
141 match self {
142 Self::Ordinary(generated) => Ok(*generated),
143 Self::Bootstrap(_) => Err(FleetGenerateError::Authority(
144 "unexpected bootstrap generation".into(),
145 )),
146 }
147 }
148}
149
150#[derive(Debug, ThisError)]
152pub enum FleetGenerateError {
153 #[error("mainnet Subnet Catalog acquisition failed: {0}")]
154 SubnetCatalog(#[from] Box<crate::subnet_catalog::acquisition::CatalogAcquisitionError>),
155
156 #[error(transparent)]
157 Frontend(#[from] crate::frontend::FrontendError),
158
159 #[error("invalid desired Fleet policy: {0}")]
160 Policy(#[from] crate::fleet_ensure::policy::EnsurePolicyError),
161 #[error("invalid protected funding policy: {0}")]
162 FundingPolicy(#[from] canic_core::control_plane_support::model::fleet_funding_policy::FleetFundingPolicyValidationError),
163
164 #[error("failed to read Fleet generator input {path}: {source}")]
165 Read {
166 path: PathBuf,
167 #[source]
168 source: std::io::Error,
169 },
170
171 #[error("failed to write Fleet generator input {path}: {source}")]
172 Write {
173 path: PathBuf,
174 #[source]
175 source: std::io::Error,
176 },
177
178 #[error("Fleet generator input is not a safe regular file: {path} ({reason})")]
179 UnsafeInput { path: PathBuf, reason: &'static str },
180
181 #[error("Fleet generator input is too large: {path} ({actual} bytes; maximum {maximum})")]
182 TooLarge {
183 actual: usize,
184 maximum: usize,
185 path: PathBuf,
186 },
187
188 #[error("failed to decode Fleet generator input {path}: {source}")]
189 Decode {
190 path: PathBuf,
191 #[source]
192 source: toml::de::Error,
193 },
194
195 #[error("unsupported Fleet generator {kind} schema {actual}; expected 1")]
196 Schema { actual: u32, kind: &'static str },
197
198 #[error("invalid Fleet generator authority: {0}")]
199 Authority(String),
200
201 #[error("existing Fleet identity seed conflicts with requested fresh-estate authority: {0}")]
202 FreshSeedConflict(String),
203
204 #[error("completed Fleet generation requires an explicit physical inventory: supply current policy and a non-fresh seed naming the Coordinator, every Root/Store and every child as pool imports; run fleet readiness with --source and --seed before building. For ordinary same-build startup, reuse the retained current desired document with fleet ensure without --reinstall")]
205 CompletedFleetRequiresExplicitInventory,
206
207 #[error("Fleet identity seed and policy topology differ: {0}")]
208 SeedTopology(String),
209
210 #[error("retained canister {canister} is unavailable: {reason}")]
211 CanisterUnavailable { canister: String, reason: String },
212
213
214
215 #[error(transparent)]
216 StoppedRootStartRequired(Box<StoppedRootStartPrerequisite>),
217
218 #[error(
219 "retained canister {canister} controller set differs: actual {actual:?}, expected {expected:?}"
220 )]
221 ControllerMismatch {
222 actual: Vec<String>,
223 canister: String,
224 expected: Vec<String>,
225 },
226
227 #[error("retained canister {canister} is on Subnet {actual}, expected {expected}")]
228 SubnetMismatch {
229 actual: String,
230 canister: String,
231 expected: String,
232 },
233
234 #[error("release authority is invalid: {0}")]
235 Release(String),
236
237 #[error(
238 "release build {release_build_id} targets {actual}, but environment {environment} requires {expected}"
239 )]
240 ReleaseBuildNetworkMismatch {
241 release_build_id: ReleaseBuildId,
242 actual: BuildNetwork,
243 environment: String,
244 expected: BuildNetwork,
245 },
246
247 #[error("Fleet admission must not contain the anonymous Principal")]
248 FleetAdmissionAnonymous,
249
250 #[error("duplicate Fleet admission Principal {principal}")]
251 FleetAdmissionDuplicate { principal: Principal },
252
253 #[error("artifact Candid sidecar is missing: {}", path.display())]
254 CandidMissing { path: PathBuf },
255
256 #[error("artifact Candid sidecar is not a regular no-follow file: {}", path.display())]
257 CandidNotRegular { path: PathBuf },
258
259 #[error("failed to read artifact Candid sidecar {}: {source}", path.display())]
260 CandidRead {
261 path: PathBuf,
262 #[source]
263 source: std::io::Error,
264 },
265
266 #[cfg(not(unix))]
267 #[error(
268 "artifact Candid sidecar cannot be read without following links on this platform: {}",
269 path.display()
270 )]
271 CandidUnsupportedPlatform { path: PathBuf },
272
273 #[error(
274 "artifact Candid sidecar digest differs from release authority at {}: expected {expected:?}, observed {observed:?}",
275 path.display()
276 )]
277 CandidDigestMismatch {
278 path: PathBuf,
279 expected: [u8; 32],
280 observed: [u8; 32],
281 },
282
283 #[error(transparent)]
284 ComponentPoolCapacity(#[from] RootPoolCapacityError),
285
286 #[error(transparent)]
287 PoolImportCapacity(#[from] RootPoolImportCapacityError),
288
289 #[error("system clock is before the Unix epoch")]
290 Clock,
291}
292
293#[derive(Debug, Eq, PartialEq, ThisError)]
295#[error(
296 "retained Root {root} is stopped after exact management verification \
297 (Subnet {subnet}, controller {controller}, module SHA-256 {module_sha256}); \
298 no protected Root query or desired-output mutation was attempted. Generator authority \
299 {authority_sha256} binds only the observed Root identities and installed modules. \
300 The authority covers {root_count} stopped Root(s). Review and apply only the same-ID Start \
301 through the current retained \
302 `canic fleet ensure {fleet}` authority, \
303 then rerun `canic fleet generate`"
304)]
305pub struct StoppedRootStartPrerequisite {
306 pub authority_sha256: String,
307 pub controller: String,
308 pub fleet: String,
309 pub module_sha256: String,
310 pub root: String,
311 pub root_count: usize,
312 pub subnet: String,
313}
314
315#[derive(Clone, Debug, Deserialize)]
316#[serde(deny_unknown_fields)]
317struct FleetSource {
318 schema_version: u32,
319 funding_profile: FleetFundingProfile,
320 operator: String,
321 #[serde(default)]
322 recovery_controllers: Vec<String>,
323 admission: AdmissionSource,
324 coordinator: CoordinatorSource,
325 fleet_subnet_roots: Vec<RootSource>,
326}
327
328#[derive(Clone, Debug, Deserialize)]
329#[serde(deny_unknown_fields)]
330struct AdmissionSource {
331 #[serde(default)]
332 identity_origin: Option<String>,
333 principals: Vec<String>,
334}
335
336#[derive(Clone, Debug, Deserialize)]
337#[serde(deny_unknown_fields)]
338struct CoordinatorSource {
339 subnet: ExplicitSubnetSource,
340 creation_funding: CyclesCreationSource,
341 root_funding: CoordinatorFundingSource,
342}
343
344#[derive(Clone, Debug, Deserialize)]
345#[serde(deny_unknown_fields)]
346struct ExplicitSubnetSource {
347 kind: String,
348 subnet: String,
349 acknowledge_fiduciary_cost: bool,
350}
351
352#[derive(Clone, Debug, Deserialize)]
353#[serde(deny_unknown_fields)]
354struct CyclesCreationSource {
355 kind: String,
356 #[serde(deserialize_with = "Cycles::from_human_config")]
357 cycles: Cycles,
358}
359
360#[derive(Clone, Debug, Deserialize)]
361#[serde(deny_unknown_fields)]
362struct CoordinatorFundingSource {
363 #[serde(deserialize_with = "Cycles::from_human_config")]
364 minimum_reserve_cycles: Cycles,
365 window_secs: u64,
366 #[serde(deserialize_with = "Cycles::from_human_config")]
367 maximum_cycles: Cycles,
368 maximum_automatic_grants: u32,
369 #[serde(deserialize_with = "Cycles::from_human_config")]
370 maximum_automatic_cycles: Cycles,
371}
372
373#[derive(Clone, Debug, Deserialize)]
374#[serde(deny_unknown_fields)]
375struct RootSource {
376 placement_subnet: String,
377 #[serde(default)]
378 acknowledge_fiduciary_cost: bool,
379 #[serde(default)]
380 component_group_placements: BTreeMap<ComponentGroupDeploymentId, Vec<u32>>,
381 component_admissions: BTreeMap<ComponentSpecId, u32>,
382 canister_pool: PoolSource,
383 root_funding: RootFundingSource,
384 limits: LimitsSource,
385 root_creation_funding: CyclesCreationSource,
386 wasm_store_creation_funding: CyclesCreationSource,
387}
388
389#[derive(Clone, Debug, Deserialize)]
390#[serde(deny_unknown_fields)]
391struct PoolSource {
392 minimum_size: u32,
393 maximum_size: u32,
394 #[serde(deserialize_with = "Cycles::from_human_config")]
395 canister_cycles: Cycles,
396 #[serde(default)]
397 imports: Vec<String>,
398}
399
400#[derive(Clone, Debug, Deserialize)]
401#[serde(deny_unknown_fields)]
402struct RootFundingSource {
403 #[serde(deserialize_with = "Cycles::from_human_config")]
404 request_threshold: Cycles,
405 #[serde(deserialize_with = "Cycles::from_human_config")]
406 target_balance: Cycles,
407 cooldown_secs: u64,
408 window_secs: u64,
409 #[serde(deserialize_with = "Cycles::from_human_config")]
410 maximum_cycles: Cycles,
411 maximum_automatic_grants: u32,
412 #[serde(deserialize_with = "Cycles::from_human_config")]
413 maximum_automatic_cycles: Cycles,
414}
415
416#[derive(Clone, Debug, Deserialize)]
417#[serde(deny_unknown_fields)]
418struct LimitsSource {
419 maximum_component_instances: u32,
420 maximum_registry_bytes: u64,
421 maximum_wasm_store_bytes: u64,
422 maximum_group_placements: u32,
423 cycles_funding: CyclesFundingSource,
424}
425
426#[derive(Clone, Debug, Deserialize)]
427#[serde(deny_unknown_fields)]
428struct CyclesFundingSource {
429 window_secs: u64,
430 #[serde(deserialize_with = "Cycles::from_human_config")]
431 maximum_cycles: Cycles,
432}
433
434#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
435#[serde(deny_unknown_fields)]
436struct EstateSeed {
437 schema_version: u32,
438 fleet_id: canic_core::ids::FleetId,
439 #[serde(default)]
440 fresh_estate: bool,
441 coordinator: String,
442 #[serde(default)]
443 treasury: Option<TreasurySeed>,
444 #[serde(default = "mainnet_cycles_ledger")]
445 cycles_ledger: String,
446 management_creation_fee_cycles: String,
447 roots: Vec<RootSeed>,
448}
449
450#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
451#[serde(deny_unknown_fields)]
452struct TreasurySeed {
453 principal: String,
454 subnet: String,
455}
456
457#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
458#[serde(deny_unknown_fields)]
459struct RootSeed {
460 placement_subnet: String,
461 root: String,
462 store: String,
463 #[serde(default)]
464 pool_imports: Vec<String>,
465}
466
467pub fn initialize_fresh_estate_seed(
469 request: &FreshEstateSeedRequest<'_>,
470) -> Result<canic_core::ids::FleetId, FleetGenerateError> {
471 let source: FleetSource = load_toml(request.source, "source")?;
472 require_schema(source.schema_version, "source")?;
473 canonical_recovery_controllers(
474 &source.recovery_controllers,
475 parse_principal("operator", &source.operator)?,
476 )?;
477 parse_principal("Cycles Ledger", request.cycles_ledger)?;
478 if let Some(existing) = read_seed(request.seed)? {
479 require_fresh_seed_authority(&existing, &source, request)?;
480 return Ok(existing.fleet_id);
481 }
482 let mut bytes = [0_u8; 32];
483 getrandom::fill(&mut bytes).map_err(|error| {
484 FleetGenerateError::Authority(format!("Fleet ID generation failed: {error}"))
485 })?;
486 let seed = fresh_seed(
487 &source,
488 canic_core::ids::FleetId::from_generated_bytes(bytes),
489 request.cycles_ledger,
490 request.management_creation_fee_cycles,
491 )?;
492 let encoded = toml::to_string_pretty(&seed).map_err(|error| {
493 FleetGenerateError::Authority(format!("fresh seed encoding failed: {error}"))
494 })?;
495 match create_new_bytes_with_parents(request.seed, encoded.as_bytes()) {
496 Ok(()) => Ok(seed.fleet_id),
497 Err(io_error) if io_error.kind() == std::io::ErrorKind::AlreadyExists => {
498 let existing = read_seed(request.seed)?.ok_or_else(|| FleetGenerateError::Read {
499 path: request.seed.to_path_buf(),
500 source: std::io::Error::new(
501 std::io::ErrorKind::NotFound,
502 "concurrent fresh seed publication disappeared",
503 ),
504 })?;
505 require_fresh_seed_authority(&existing, &source, request)?;
506 Ok(existing.fleet_id)
507 }
508 Err(source) => Err(FleetGenerateError::Write {
509 path: request.seed.to_path_buf(),
510 source,
511 }),
512 }
513}
514
515fn read_seed(path: &Path) -> Result<Option<EstateSeed>, FleetGenerateError> {
516 let bytes = match read_optional_regular_bytes(path) {
517 Ok(bytes) => bytes,
518 Err(RegularFileReadError::NotRegular) => {
519 return Err(FleetGenerateError::UnsafeInput {
520 path: path.to_path_buf(),
521 reason: "not a regular no-follow file",
522 });
523 }
524 Err(RegularFileReadError::Io(source)) => {
525 return Err(FleetGenerateError::Read {
526 path: path.to_path_buf(),
527 source,
528 });
529 }
530 #[cfg(not(unix))]
531 Err(RegularFileReadError::UnsupportedPlatform) => {
532 return Err(FleetGenerateError::UnsafeInput {
533 path: path.to_path_buf(),
534 reason: "safe no-follow reads are unsupported on this platform",
535 });
536 }
537 };
538 let Some(bytes) = bytes else {
539 return Ok(None);
540 };
541 if bytes.len() > MAX_GENERATOR_INPUT_BYTES {
542 return Err(FleetGenerateError::TooLarge {
543 actual: bytes.len(),
544 maximum: MAX_GENERATOR_INPUT_BYTES,
545 path: path.to_path_buf(),
546 });
547 }
548 toml::from_slice(&bytes)
549 .map(Some)
550 .map_err(|source| FleetGenerateError::Decode {
551 path: path.to_path_buf(),
552 source,
553 })
554}
555
556fn fresh_seed(
557 source: &FleetSource,
558 fleet_id: canic_core::ids::FleetId,
559 cycles_ledger: &str,
560 management_creation_fee_cycles: u128,
561) -> Result<EstateSeed, FleetGenerateError> {
562 if source
563 .fleet_subnet_roots
564 .iter()
565 .any(|root| !root.canister_pool.imports.is_empty())
566 {
567 return Err(FleetGenerateError::FreshSeedConflict(
568 "fresh estate policy must not import retained pool identities".to_string(),
569 ));
570 }
571 let controlled = source
572 .fleet_subnet_roots
573 .iter()
574 .try_fold(1_usize, |total, root| {
575 let pools = usize::try_from(root.canister_pool.minimum_size).map_err(|_| {
576 FleetGenerateError::FreshSeedConflict(
577 "fresh pool minimum cannot be represented on this host".to_string(),
578 )
579 })?;
580 total
581 .checked_add(2)
582 .and_then(|value| value.checked_add(pools))
583 .ok_or_else(|| {
584 FleetGenerateError::FreshSeedConflict(
585 "fresh estate role count overflowed".to_string(),
586 )
587 })
588 })?;
589 if controlled > MAX_FLEET_ENSURE_CANISTERS {
590 return Err(FleetGenerateError::FreshSeedConflict(format!(
591 "fresh estate declares {controlled} controlled roles, above limit {MAX_FLEET_ENSURE_CANISTERS}"
592 )));
593 }
594 let roots = source
595 .fleet_subnet_roots
596 .iter()
597 .enumerate()
598 .map(|(index, root)| RootSeed {
599 placement_subnet: root.placement_subnet.clone(),
600 root: format!("root-{index}"),
601 store: format!("store-{index}"),
602 pool_imports: (0..root.canister_pool.minimum_size)
603 .map(|pool| format!("root-{index}-pool-{pool}"))
604 .collect(),
605 })
606 .collect();
607 Ok(EstateSeed {
608 schema_version: 1,
609 fleet_id,
610 fresh_estate: true,
611 coordinator: "coordinator".to_string(),
612 treasury: None,
613 cycles_ledger: cycles_ledger.to_string(),
614 management_creation_fee_cycles: config_cycles(management_creation_fee_cycles),
615 roots,
616 })
617}
618
619fn require_fresh_seed_authority(
620 actual: &EstateSeed,
621 source: &FleetSource,
622 request: &FreshEstateSeedRequest<'_>,
623) -> Result<(), FleetGenerateError> {
624 let expected = fresh_seed(
625 source,
626 actual.fleet_id,
627 request.cycles_ledger,
628 request.management_creation_fee_cycles,
629 )?;
630 if actual == &expected {
631 Ok(())
632 } else {
633 Err(FleetGenerateError::FreshSeedConflict(
634 "existing seed is retained or differs from the requested ledger, fee, or topology"
635 .to_string(),
636 ))
637 }
638}
639
640#[derive(Clone)]
641struct ObservedCanister {
642 pool_status: Option<canic_core::dto::pool::CanisterPoolAssetStatus>,
643 cycles: u128,
644 module_sha256: Option<String>,
645 subnet: String,
646}
647
648#[derive(Clone, Debug, Eq, PartialEq)]
649struct WasmStoreIdentityRelationship {
650 authority: canic_core::ids::FleetRegistryAuthority,
651 placement_subnet: SubnetId,
652 fleet_subnet_root: Principal,
653 wasm_store: Principal,
654}
655
656impl From<&FleetSubnetWasmStoreAuthority> for WasmStoreIdentityRelationship {
657 fn from(value: &FleetSubnetWasmStoreAuthority) -> Self {
658 Self {
659 authority: value.authority.clone(),
660 placement_subnet: value.placement_subnet,
661 fleet_subnet_root: value.fleet_subnet_root,
662 wasm_store: value.wasm_store,
663 }
664 }
665}
666
667#[derive(Clone, Debug, Eq, PartialEq)]
668struct RootIdentityRelationship {
669 authority: canic_core::ids::FleetRegistryAuthority,
670 placement_subnet: SubnetId,
671 fleet_subnet_root: Principal,
672}
673
674impl From<&FleetSubnetRootBinding> for RootIdentityRelationship {
675 fn from(value: &FleetSubnetRootBinding) -> Self {
676 Self {
677 authority: value.authority.clone(),
678 placement_subnet: value.placement_subnet,
679 fleet_subnet_root: value.fleet_subnet_root,
680 }
681 }
682}
683
684#[derive(Clone, Debug, Eq, PartialEq)]
685struct RootDesiredPolicy {
686 component_admissions: Vec<canic_core::ids::ComponentSpecAdmission>,
687 component_topology_digest: canic_core::ids::ComponentTopologyDigest,
688 funding: FleetSubnetRootFundingAuthority,
689 installation_controller: Principal,
690 limits: FleetSubnetRootLimits,
691}
692
693impl From<&FleetSubnetRootAuthority> for RootDesiredPolicy {
694 fn from(value: &FleetSubnetRootAuthority) -> Self {
695 Self {
696 component_admissions: value.binding.component_admissions.clone(),
697 component_topology_digest: value.binding.component_topology_digest,
698 funding: value.binding.funding.clone(),
699 installation_controller: value.wasm_store_authority.installation_controller,
700 limits: value.binding.limits.clone(),
701 }
702 }
703}
704
705struct ExpectedRootEstateAuthority {
706 coordinator: FleetCoordinatorBinding,
707 root: RootIdentityRelationship,
708 store: WasmStoreIdentityRelationship,
709}
710
711struct RootEstateAuthorityRequest<'a, 'request> {
712 app: &'a canic_core::ids::AppId,
713 generation: &'a FleetGenerateRequest<'request>,
714 root_candid: &'a Path,
715 seed: &'a EstateSeed,
716 source: &'a FleetSource,
717 topology: &'a crate::component_topology::PlannedFleetTopology,
718}
719
720struct EstateObservationRequest<'a, 'request> {
721 app: &'a canic_core::ids::AppId,
722 generation: &'a FleetGenerateRequest<'request>,
723 operator: Principal,
724 root_candid: &'a Path,
725 root_wasm_sha256: &'a str,
726 seed: &'a EstateSeed,
727 source: &'a FleetSource,
728 topology: &'a crate::component_topology::PlannedFleetTopology,
729 local_replica: Option<&'a LocalReplicaTarget>,
730 catalog: Option<&'a CatalogLoadOutcome>,
731}
732
733#[derive(candid::CandidType)]
734enum RootEstateStatusRequest {
735 FleetAuthority,
736 Pool(CanisterPoolStatusRequest),
737}
738
739#[derive(candid::CandidType, Deserialize)]
740enum RootEstateStatusResponse {
741 FleetAuthority(Box<FleetSubnetRootAuthority>),
742 Pool(Box<CanisterPoolResponse>),
743}
744
745pub fn generate_desired_fleet(
747 request: &FleetGenerateRequest<'_>,
748) -> Result<GeneratedDesiredFleet, FleetGenerateError> {
749 generate(request, None, None)?.ordinary()
750}
751
752#[cfg(feature = "local-fleet")]
754pub fn generate_local_fleet(
755 request: &FleetGenerateRequest<'_>,
756 target: &LocalReplicaTarget,
757) -> Result<GeneratedDesiredFleet, FleetGenerateError> {
758 if resolve_icp_build_network_from_root(request.root, request.environment)
759 .map_err(|error| FleetGenerateError::Authority(error.to_string()))?
760 == BuildNetwork::Ic
761 {
762 return Err(FleetGenerateError::Authority(
763 "local Fleet generation requires a local network profile".into(),
764 ));
765 }
766 generate(request, Some(target), None)?.ordinary()
767}
768
769#[expect(
770 clippy::too_many_lines,
771 reason = "generation keeps one visible fail-closed authority-validation sequence"
772)]
773fn generate(
774 request: &FleetGenerateRequest<'_>,
775 local_replica: Option<&LocalReplicaTarget>,
776 initialization: Option<
777 crate::fleet_ensure::model::infrastructure_bootstrap::BootstrapCoordinatorSelection,
778 >,
779) -> Result<GenerationOutput, FleetGenerateError> {
780 let preflight::GenerationInputs {
781 mut source,
782 seed,
783 initialization,
784 clean_reinstall,
785 } = preflight::load(
786 &preflight::FleetGenerationInputsRequest {
787 root: request.root,
788 environment: request.environment,
789 fleet: request.fleet,
790 source: request.source,
791 seed: request.seed,
792 },
793 initialization,
794 )?;
795 require_cycles_creation(&source.coordinator.creation_funding, "Coordinator")?;
796 if source.coordinator.subnet.kind != "explicit" {
797 return Err(FleetGenerateError::Authority(
798 "Coordinator Subnet must be an exact explicit protected Subnet".to_string(),
799 ));
800 }
801 let operator = parse_principal("operator", &source.operator)?;
802 source.recovery_controllers =
803 canonical_recovery_controllers(&source.recovery_controllers, operator)?;
804 parse_subnet("Coordinator", &source.coordinator.subnet.subnet)?;
805 let config = AppConfigSnapshot::load(request.app_config)
806 .map_err(|error| FleetGenerateError::Authority(error.to_string()))?;
807 let deployment_configuration = ComponentDeploymentConfiguration::compile(config.model())
808 .map_err(|error| FleetGenerateError::Authority(error.to_string()))?;
809 let admission = compile_source_admission_policy(&source.admission.principals)?;
810 if let Some(origin) = &source.admission.identity_origin {
811 let local = canonical_network_id(request, &source)?
812 != canic_core::ids::CanonicalNetworkId::ic_mainnet();
813 crate::frontend::policy::validate_origin(origin, local)
814 .map_err(FleetGenerateError::Frontend)?;
815 }
816 let root_inputs = source
817 .fleet_subnet_roots
818 .iter()
819 .map(|root| {
820 Ok(PlannedFleetSubnetRootTopologyInput {
821 placement_subnet: parse_subnet("Fleet Subnet Root", &root.placement_subnet)?,
822 component_admissions: root
823 .component_admissions
824 .iter()
825 .map(
826 |(component_spec, maximum_root_instances)| RootComponentAdmissionInput {
827 component_spec: component_spec.clone(),
828 maximum_root_instances: *maximum_root_instances,
829 },
830 )
831 .collect(),
832 limits: root_limits(root),
833 })
834 })
835 .collect::<Result<Vec<_>, FleetGenerateError>>()?;
836 let topology = plan_initial_fleet_topology(config.model(), root_inputs)
837 .map_err(|error| FleetGenerateError::Authority(error.to_string()))?;
838 let capacity_roots = bind_root_generation_inputs(
839 &source.fleet_subnet_roots,
840 &seed.roots,
841 &topology.fleet_subnet_roots,
842 )?
843 .into_iter()
844 .map(|binding| RootPoolCapacityInput {
845 component_admissions: binding.planned.component_admissions.clone(),
846 pool_target_cycles: binding
847 .planned
848 .limits
849 .canister_pool
850 .canister_cycles
851 .to_u128(),
852 root: binding.seed.root.clone(),
853 })
854 .collect::<Vec<_>>();
855 validate_root_pool_capacity(config.model(), &capacity_roots)?;
856 for root in &source.fleet_subnet_roots {
857 fresh_root_pool_count(root, &deployment_configuration)?;
858 }
859 validate_source_funding(
860 &source,
861 canonical_network_id(request, &source)?
862 == canic_core::ids::CanonicalNetworkId::ic_mainnet(),
863 )?;
864
865 let (infrastructure, complete) = release_authority(request, config.component_topology())?;
866 if complete.manifest.infrastructure_artifact_manifest_sha256 != infrastructure.digest {
867 return Err(FleetGenerateError::Release(
868 "complete release set does not bind its infrastructure manifest".to_string(),
869 ));
870 }
871 let coordinator_artifact = artifact(
872 &infrastructure.manifest.entries,
873 CanicInfrastructureRole::FleetCoordinator,
874 )?;
875 let root_artifact = artifact(
876 &infrastructure.manifest.entries,
877 CanicInfrastructureRole::FleetSubnetRoot,
878 )?;
879 let store_artifact = artifact(
880 &infrastructure.manifest.entries,
881 CanicInfrastructureRole::WasmStore,
882 )?;
883 let coordinator_candid = candid_sidecar(request.root, coordinator_artifact)?;
884 let root_candid = candid_sidecar(request.root, root_artifact)?;
885 let store_candid = candid_sidecar(request.root, store_artifact)?;
886 let root_candid_path = request.root.join(&root_candid);
887 let generation_time = SystemTime::now()
888 .duration_since(UNIX_EPOCH)
889 .map_err(|_| FleetGenerateError::Clock)?
890 .as_secs();
891 let catalog =
892 validate_generation_authority(request, &source, operator, local_replica, generation_time)?;
893 let subnet_catalog = catalog
894 .as_ref()
895 .map(|outcome| crate::subnet_catalog::ops::observation(outcome, generation_time));
896 let observed = if seed.fresh_estate || initialization.is_some() {
897 BTreeMap::new()
898 } else {
899 observe_estate(&EstateObservationRequest {
900 app: config.model().app_id(),
901 generation: request,
902 operator,
903 root_candid: &root_candid_path,
904 root_wasm_sha256: &root_artifact.wasm_sha256_hex,
905 seed: &seed,
906 source: &source,
907 topology: &topology,
908 local_replica,
909 catalog: catalog.as_ref(),
910 })?
911 };
912 let treasury = seed
913 .treasury
914 .as_ref()
915 .map_or(seed.coordinator.as_str(), |treasury| {
916 treasury.principal.as_str()
917 });
918 if !seed.fresh_estate && initialization.is_none() && !observed.contains_key(treasury) {
919 return Err(FleetGenerateError::Authority(
920 "treasury must be the seeded Coordinator or another explicitly seeded controlled canister"
921 .to_string(),
922 ));
923 }
924 let ledger_fee_cycles = observe_ledger_fee(request, &seed.cycles_ledger, local_replica)?;
925 let mut desired = compile_desired(CompileDesiredRequest {
926 request,
927 source: &source,
928 seed: &seed,
929 admission,
930 app: config.model().app_id().clone(),
931 deployment_configuration,
932 topology,
933 coordinator_artifact,
934 root_artifact,
935 store_artifact,
936 coordinator_candid: &coordinator_candid,
937 root_candid: &root_candid,
938 store_candid: &store_candid,
939 observed: &observed,
940 treasury,
941 ledger_fee_cycles,
942 })?;
943 if initialization == Some(crate::fleet_ensure::model::infrastructure_bootstrap::BootstrapCoordinatorSelection::Create) {
944 desired.canisters.iter_mut().find(|canister| canister.kind == DesiredCanisterKind::Coordinator)
945 .ok_or_else(|| FleetGenerateError::Authority("bootstrap requires one Coordinator".into()))?
946 .principal = None;
947 }
948 crate::fleet_ensure::policy::validate_terminal_pool_capacity(&desired)?;
949 if initialization.is_some() && !clean_reinstall {
950 return Ok(GenerationOutput::Bootstrap(Box::new(desired)));
951 }
952 if clean_reinstall {
953 return Ok(GenerationOutput::Ordinary(Box::new(
954 GeneratedDesiredFleet {
955 clean_reinstall: true,
956 desired,
957 observed_canisters: 0,
958 observed_controlled_cycles: 0,
959 release_build_id: request.release_build_id,
960 subnet_catalog,
961 startup_funding: None,
962 },
963 )));
964 }
965 let observed_controlled_cycles = observed.values().try_fold(0_u128, |total, canister| {
966 total.checked_add(canister.cycles).ok_or_else(|| {
967 FleetGenerateError::Authority("observed controlled cycle total overflowed".to_string())
968 })
969 })?;
970 let mut startup_funding = startup_funding::forecast(config.model(), &desired, &observed)?;
971 if initialization.is_none() {
972 startup_funding.coordinator_usage = startup_funding::observe_usage(
973 request,
974 &desired,
975 &observed,
976 &coordinator_artifact.wasm_sha256_hex,
977 local_replica,
978 );
979 startup_funding::observe_children(
980 request,
981 &desired,
982 &observed,
983 &root_artifact.wasm_sha256_hex,
984 &coordinator_artifact.wasm_sha256_hex,
985 local_replica,
986 &mut startup_funding,
987 );
988 }
989 startup_funding::apply_allowances(config.model(), &desired, &mut startup_funding);
990 Ok(GenerationOutput::Ordinary(Box::new(
991 GeneratedDesiredFleet {
992 clean_reinstall,
993 desired,
994 observed_canisters: observed.len(),
995 observed_controlled_cycles,
996 release_build_id: request.release_build_id,
997 subnet_catalog,
998 startup_funding: Some(startup_funding),
999 },
1000 )))
1001}
1002
1003fn validate_generation_authority(
1004 request: &FleetGenerateRequest<'_>,
1005 source: &FleetSource,
1006 operator: Principal,
1007 local_replica: Option<&LocalReplicaTarget>,
1008 now: u64,
1009) -> Result<Option<CatalogLoadOutcome>, FleetGenerateError> {
1010 let icp = IcpCli::new(
1011 request.icp_executable,
1012 Some(request.environment.to_string()),
1013 )
1014 .with_identity(request.signing_identity)
1015 .with_cwd(request.root.to_path_buf())
1016 .with_local_replica(local_replica.cloned());
1017 let active = icp
1018 .identity_principal_text()
1019 .map_err(|error| FleetGenerateError::Authority(error.to_string()))?;
1020 if active != operator.to_text() {
1021 return Err(FleetGenerateError::Authority(format!(
1022 "active identity {active} differs from protected operator {operator}"
1023 )));
1024 }
1025 let network = resolve_icp_build_network_from_root(request.root, request.environment)
1026 .map_err(|error| FleetGenerateError::Authority(error.to_string()))?;
1027 if network != BuildNetwork::Ic {
1028 return Ok(None);
1029 }
1030 let catalog =
1031 MainnetCatalogClient::default().load(request.root, now, request.catalog_progress)?;
1032 require_fiduciary_acknowledgement(
1033 &catalog.catalog,
1034 &source.coordinator.subnet.subnet,
1035 source.coordinator.subnet.acknowledge_fiduciary_cost,
1036 "Coordinator",
1037 )?;
1038 for root in &source.fleet_subnet_roots {
1039 require_fiduciary_acknowledgement(
1040 &catalog.catalog,
1041 &root.placement_subnet,
1042 root.acknowledge_fiduciary_cost,
1043 "Fleet Subnet Root",
1044 )?;
1045 }
1046 Ok(Some(catalog))
1047}
1048
1049fn fresh_root_pool_count(
1050 source: &RootSource,
1051 configuration: &ComponentDeploymentConfiguration,
1052) -> Result<usize, FleetGenerateError> {
1053 let workloads = crate::fleet_ensure::policy::initial_workload_count(
1054 configuration,
1055 source
1056 .component_group_placements
1057 .iter()
1058 .flat_map(|(deployment, ordinals)| ordinals.iter().map(move |_| deployment.as_str())),
1059 &source.placement_subnet,
1060 )?;
1061 let count = crate::fleet_ensure::policy::terminal_pool_supply(
1062 &source.placement_subnet,
1063 workloads,
1064 source.canister_pool.minimum_size,
1065 source.canister_pool.maximum_size,
1066 )?;
1067 usize::try_from(count).map_err(|_| {
1068 FleetGenerateError::FreshSeedConflict(
1069 "complete initial pool supply cannot be represented on this host".to_string(),
1070 )
1071 })
1072}
1073
1074struct CompileDesiredRequest<'a> {
1075 request: &'a FleetGenerateRequest<'a>,
1076 source: &'a FleetSource,
1077 seed: &'a EstateSeed,
1078 admission: FleetAdmissionPolicyTemplate,
1079 app: canic_core::ids::AppId,
1080 deployment_configuration: ComponentDeploymentConfiguration,
1081 topology: crate::component_topology::PlannedFleetTopology,
1082 coordinator_artifact: &'a CanicInfrastructureArtifactEntry,
1083 root_artifact: &'a CanicInfrastructureArtifactEntry,
1084 store_artifact: &'a CanicInfrastructureArtifactEntry,
1085 coordinator_candid: &'a str,
1086 root_candid: &'a str,
1087 store_candid: &'a str,
1088 observed: &'a BTreeMap<String, ObservedCanister>,
1089 treasury: &'a str,
1090 ledger_fee_cycles: u128,
1091}
1092
1093struct RootGenerationBinding<'a> {
1094 source: &'a RootSource,
1095 seed: &'a RootSeed,
1096 planned: &'a PlannedFleetSubnetRootTopology,
1097}
1098
1099fn bind_root_generation_inputs<'a>(
1100 sources: &'a [RootSource],
1101 seeds: &'a [RootSeed],
1102 planned_roots: &'a [PlannedFleetSubnetRootTopology],
1103) -> Result<Vec<RootGenerationBinding<'a>>, FleetGenerateError> {
1104 let mut sources_by_subnet = BTreeMap::new();
1105 for source in sources {
1106 let placement = parse_subnet("Fleet policy Root", &source.placement_subnet)?;
1107 if sources_by_subnet.insert(placement, source).is_some() {
1108 return Err(FleetGenerateError::SeedTopology(format!(
1109 "Fleet policy repeats Root placement Subnet {placement}"
1110 )));
1111 }
1112 }
1113 let mut seeds_by_subnet = BTreeMap::new();
1114 for seed in seeds {
1115 let placement = parse_subnet("Fleet identity-seed Root", &seed.placement_subnet)?;
1116 if seeds_by_subnet.insert(placement, seed).is_some() {
1117 return Err(FleetGenerateError::SeedTopology(format!(
1118 "Fleet identity seed repeats Root placement Subnet {placement}"
1119 )));
1120 }
1121 }
1122 let mut planned_by_subnet = BTreeMap::new();
1123 for planned in planned_roots {
1124 if planned_by_subnet
1125 .insert(planned.placement_subnet, planned)
1126 .is_some()
1127 {
1128 return Err(FleetGenerateError::SeedTopology(format!(
1129 "compiled topology repeats Root placement Subnet {}",
1130 planned.placement_subnet
1131 )));
1132 }
1133 }
1134 let source_subnets = sources_by_subnet.keys().copied().collect::<BTreeSet<_>>();
1135 let seed_subnets = seeds_by_subnet.keys().copied().collect::<BTreeSet<_>>();
1136 let planned_subnets = planned_by_subnet.keys().copied().collect::<BTreeSet<_>>();
1137 if source_subnets != seed_subnets || source_subnets != planned_subnets {
1138 return Err(FleetGenerateError::SeedTopology(
1139 "Fleet policy, identity-seed and compiled Root placement Subnet sets differ"
1140 .to_string(),
1141 ));
1142 }
1143 Ok(planned_by_subnet
1144 .into_iter()
1145 .map(|(placement, planned)| RootGenerationBinding {
1146 source: sources_by_subnet[&placement],
1147 seed: seeds_by_subnet[&placement],
1148 planned,
1149 })
1150 .collect())
1151}
1152
1153#[expect(
1154 clippy::too_many_lines,
1155 reason = "one compiler constructs the complete canonical desired document"
1156)]
1157fn compile_desired(input: CompileDesiredRequest<'_>) -> Result<DesiredFleet, FleetGenerateError> {
1158 let mut canisters = Vec::new();
1159 canisters.push(infrastructure_canister(
1160 "coordinator",
1161 DesiredCanisterKind::Coordinator,
1162 Some(DesiredCanisterInit::Coordinator),
1163 (!input.seed.fresh_estate).then_some(input.seed.coordinator.as_str()),
1164 None,
1165 &input.source.operator,
1166 &input.source.recovery_controllers,
1167 &input.source.coordinator.subnet.subnet,
1168 input.source.coordinator.creation_funding.cycles.to_u128(),
1169 input
1170 .source
1171 .coordinator
1172 .root_funding
1173 .minimum_reserve_cycles
1174 .to_u128(),
1175 input.coordinator_artifact,
1176 ));
1177 let mut bootstrap_roots = Vec::new();
1178 let mut placements = Vec::new();
1179 let root_bindings = bind_root_generation_inputs(
1180 &input.source.fleet_subnet_roots,
1181 &input.seed.roots,
1182 &input.topology.fleet_subnet_roots,
1183 )?;
1184 for (index, binding) in root_bindings.into_iter().enumerate() {
1185 let RootGenerationBinding {
1186 source,
1187 seed,
1188 planned,
1189 } = binding;
1190 require_cycles_creation(&source.root_creation_funding, "Fleet Subnet Root")?;
1191 require_cycles_creation(&source.wasm_store_creation_funding, "Wasm Store")?;
1192 let root_name = format!("root-{index}");
1193 let store_name = format!("store-{index}");
1194 canisters.push(infrastructure_canister(
1195 &root_name,
1196 DesiredCanisterKind::Root,
1197 Some(DesiredCanisterInit::Root {
1198 root: root_name.clone(),
1199 }),
1200 (!input.seed.fresh_estate).then_some(seed.root.as_str()),
1201 Some("coordinator"),
1202 &input.source.operator,
1203 &input.source.recovery_controllers,
1204 &source.placement_subnet,
1205 source.root_creation_funding.cycles.to_u128(),
1206 source.root_funding.request_threshold.to_u128(),
1207 input.root_artifact,
1208 ));
1209 let mut store = infrastructure_canister(
1210 &store_name,
1211 DesiredCanisterKind::Store,
1212 Some(DesiredCanisterInit::Store {
1213 root: root_name.clone(),
1214 }),
1215 (!input.seed.fresh_estate).then_some(seed.store.as_str()),
1216 Some(&root_name),
1217 &input.source.operator,
1218 &input.source.recovery_controllers,
1219 &source.placement_subnet,
1220 source.wasm_store_creation_funding.cycles.to_u128(),
1221 0,
1222 input.store_artifact,
1223 );
1224 if input.seed.fresh_estate {
1225 store.controller_canisters.push(root_name.clone());
1226 } else {
1227 store.controllers.push(seed.root.clone());
1228 store.controllers.sort();
1229 }
1230 canisters.push(store);
1231 let mut pool_names = Vec::new();
1232 let pool_count = if input.seed.fresh_estate {
1233 fresh_root_pool_count(source, &input.deployment_configuration)?
1234 } else {
1235 seed.pool_imports.len()
1236 };
1237 if canisters.len().saturating_add(pool_count) > MAX_FLEET_ENSURE_CANISTERS {
1238 return Err(FleetGenerateError::Policy(
1239 crate::fleet_ensure::policy::EnsurePolicyError::TooManyCanisters {
1240 actual: canisters.len().saturating_add(pool_count),
1241 maximum: MAX_FLEET_ENSURE_CANISTERS,
1242 },
1243 ));
1244 }
1245 for pool_index in 0..pool_count {
1246 let name = format!("root-{index}-pool-{pool_index}");
1247 pool_names.push(name.clone());
1248 let readiness_floor = source.canister_pool.canister_cycles.to_u128();
1249 let creation_funding = if input.seed.fresh_estate {
1250 fresh_pool_creation_funding(readiness_floor)?
1251 } else {
1252 readiness_floor
1253 };
1254 canisters.push(DesiredCanister {
1255 canic_init: None,
1256 controller_canisters: if input.seed.fresh_estate {
1257 vec![root_name.clone()]
1258 } else {
1259 Vec::new()
1260 },
1261 controllers: if input.seed.fresh_estate {
1262 input.source.recovery_controllers.clone()
1263 } else {
1264 sorted_controllers(&seed.root, &input.source.recovery_controllers)
1265 },
1266 drain: None,
1267 initial_cycles: config_cycles(creation_funding),
1268 init_arg: None,
1269 init_candid: None,
1270 kind: DesiredCanisterKind::Pool,
1271 minimum_cycles: source.canister_pool.canister_cycles.to_config_string(),
1272 name,
1273 parent: Some(root_name.clone()),
1274 presence: DesiredPresence::Present,
1275 principal: (!input.seed.fresh_estate)
1276 .then(|| seed.pool_imports[pool_index].clone()),
1277 protocol_binding: None,
1278 replace: false,
1279 subnet: source.placement_subnet.clone(),
1280 wasm: None,
1281 });
1282 }
1283 if seed.pool_imports != source.canister_pool.imports
1284 && !source.canister_pool.imports.is_empty()
1285 {
1286 return Err(FleetGenerateError::SeedTopology(format!(
1287 "Root {} pool imports differ between policy source and identity seed",
1288 source.placement_subnet
1289 )));
1290 }
1291 bootstrap_roots.push(DesiredFleetBootstrapRoot {
1292 capacity_import_bootstrap: None,
1293 canister_pool_imports: pool_names,
1294 component_admissions: planned.component_admissions.clone(),
1295 component_topology_digest: planned.component_topology_digest,
1296 funding: root_funding(input.source.funding_profile, &source.root_funding),
1297 limits: root_limits(source),
1298 placement_subnet: planned.placement_subnet,
1299 root: root_name.clone(),
1300 store: store_name,
1301 });
1302 for (deployment, ordinals) in &source.component_group_placements {
1303 for ordinal in ordinals {
1304 placements.push(DesiredComponentGroupPlacement {
1305 deployment: deployment.to_string(),
1306 ordinal: *ordinal,
1307 root: root_name.clone(),
1308 });
1309 }
1310 }
1311 }
1312 if !input.seed.fresh_estate && input.treasury != input.seed.coordinator {
1313 let treasury_observation = input
1314 .observed
1315 .get(input.treasury)
1316 .expect("verified treasury observation");
1317 canisters.push(DesiredCanister {
1318 canic_init: None,
1319 controller_canisters: Vec::new(),
1320 controllers: sorted_controllers(
1321 &input.source.operator,
1322 &input.source.recovery_controllers,
1323 ),
1324 drain: None,
1325 initial_cycles: config_cycles(0),
1326 init_arg: None,
1327 init_candid: None,
1328 kind: DesiredCanisterKind::Auxiliary,
1329 minimum_cycles: config_cycles(0),
1330 name: "treasury".to_string(),
1331 parent: None,
1332 presence: DesiredPresence::Present,
1333 principal: Some(input.treasury.to_string()),
1334 protocol_binding: None,
1335 replace: false,
1336 subnet: treasury_observation.subnet.clone(),
1337 wasm: None,
1338 });
1339 }
1340 let app_config = relative_path(input.request.root, input.request.app_config)?;
1341 let app = input.app.clone();
1342 Ok(DesiredFleet {
1343 bootstrap: Some(DesiredFleetBootstrap {
1344 admission_identity_origin: input.source.admission.identity_origin.clone(),
1345 admission: input.admission,
1346 app,
1347 canonical_network_id: canonical_network_id(input.request, input.source)?,
1348 component_deployment_configuration: input.deployment_configuration,
1349 coordinator: "coordinator".to_string(),
1350 coordinator_subnet: parse_subnet(
1351 "Coordinator",
1352 &input.source.coordinator.subnet.subnet,
1353 )?,
1354 recovery_controllers: input
1355 .source
1356 .recovery_controllers
1357 .iter()
1358 .map(|controller| parse_principal("recovery controller", controller))
1359 .collect::<Result<Vec<_>, _>>()?,
1360 fleet_id: input.seed.fleet_id,
1361 fresh_estate: input.seed.fresh_estate,
1362 release_build_id: input.request.release_build_id,
1363 root_funding: Some(coordinator_funding(
1364 input.source.funding_profile,
1365 &input.source.coordinator.root_funding,
1366 )),
1367 roots: bootstrap_roots,
1368 }),
1369 canisters,
1370 cycles_ledger: input.seed.cycles_ledger.clone(),
1371 environment: input.request.environment.to_string(),
1372 fleet: input.request.fleet.to_string(),
1373 ledger_fee_cycles: config_cycles(input.ledger_fee_cycles),
1374 management_creation_fee_cycles: input.seed.management_creation_fee_cycles.clone(),
1376 material_cycle_threshold: config_cycles(GENERATED_RETAINED_MATERIAL_CYCLE_THRESHOLD),
1377 maximum_observation_burn_cycles: config_cycles(
1378 GENERATED_RETAINED_MAXIMUM_OBSERVATION_BURN_CYCLES,
1379 ),
1380 maximum_stalled_observations: 8,
1381 maximum_update_burn_cycles: config_cycles(GENERATED_RETAINED_MAXIMUM_UPDATE_BURN_CYCLES),
1382 operator: input.source.operator.clone(),
1383 protocol: Some(DesiredFleetProtocol {
1384 app_config,
1385 component_group_placements: placements,
1386 coordinator_candid: input.coordinator_candid.to_string(),
1387 root_candid: input.root_candid.to_string(),
1388 store_candid: input.store_candid.to_string(),
1389 }),
1390 protocol_steps: Vec::new(),
1391 schema_version: FLEET_ENSURE_SCHEMA_VERSION,
1392 treasury: if input.treasury == input.seed.coordinator {
1393 "coordinator".to_string()
1394 } else {
1395 "treasury".to_string()
1396 },
1397 })
1398}
1399
1400fn observe_ledger_fee(
1401 request: &FleetGenerateRequest<'_>,
1402 cycles_ledger: &str,
1403 local_replica: Option<&LocalReplicaTarget>,
1404) -> Result<u128, FleetGenerateError> {
1405 parse_principal("Cycles Ledger", cycles_ledger)?;
1406 let icp = IcpCli::new(
1407 request.icp_executable,
1408 Some(request.environment.to_string()),
1409 )
1410 .with_identity(request.signing_identity)
1411 .with_cwd(request.root.to_path_buf())
1412 .with_local_replica(local_replica.cloned());
1413 let value: Nat = icp
1414 .canister_query_candid(cycles_ledger, "icrc1_fee", &(), None)
1415 .map_err(|error| {
1416 FleetGenerateError::Authority(format!(
1417 "failed to observe the exact Cycles Ledger fee: {error}"
1418 ))
1419 })?;
1420 let rendered = value.to_string();
1421 u128::try_from(value.0).map_err(|_| {
1422 FleetGenerateError::Authority(format!(
1423 "live Cycles Ledger fee exceeds the supported cycle range: {rendered}"
1424 ))
1425 })
1426}
1427
1428#[expect(
1429 clippy::too_many_lines,
1430 reason = "one observation boundary validates identity, controllers, placement, and cycles together"
1431)]
1432fn observe_estate(
1433 input: &EstateObservationRequest<'_, '_>,
1434) -> Result<BTreeMap<String, ObservedCanister>, FleetGenerateError> {
1435 let EstateObservationRequest {
1436 app,
1437 generation: request,
1438 operator,
1439 root_candid,
1440 root_wasm_sha256,
1441 seed,
1442 source,
1443 topology,
1444 local_replica,
1445 catalog,
1446 } = input;
1447 let icp = IcpCli::new(
1448 request.icp_executable,
1449 Some(request.environment.to_string()),
1450 )
1451 .with_identity(request.signing_identity)
1452 .with_cwd(request.root.to_path_buf())
1453 .with_local_replica(local_replica.cloned());
1454 let mut expected = BTreeMap::new();
1455 insert_expected_canister(
1456 &mut expected,
1457 seed.coordinator.clone(),
1458 (
1459 source.coordinator.subnet.subnet.clone(),
1460 sorted_controllers(&source.operator, &source.recovery_controllers),
1461 ),
1462 "Coordinator",
1463 )?;
1464 if let Some(treasury) = &seed.treasury
1465 && treasury.principal != seed.coordinator
1466 {
1467 insert_expected_canister(
1468 &mut expected,
1469 treasury.principal.clone(),
1470 (
1471 treasury.subnet.clone(),
1472 sorted_controllers(&source.operator, &source.recovery_controllers),
1473 ),
1474 "treasury",
1475 )?;
1476 }
1477 let mut stopped_roots = Vec::new();
1478 for root in &seed.roots {
1479 insert_expected_canister(
1480 &mut expected,
1481 root.root.clone(),
1482 (
1483 root.placement_subnet.clone(),
1484 sorted_controllers(&source.operator, &source.recovery_controllers),
1485 ),
1486 "Fleet Subnet Root",
1487 )?;
1488 }
1489 let mut observed = BTreeMap::new();
1490 let root_principals = seed
1491 .roots
1492 .iter()
1493 .map(|root| root.root.as_str())
1494 .collect::<BTreeSet<_>>();
1495 let mut root_statuses = BTreeMap::new();
1496 for (canister, (expected_subnet, required_controllers)) in expected {
1497 parse_principal("retained canister", &canister)?;
1498 let status = icp.canister_status_report(&canister).map_err(|error| {
1499 FleetGenerateError::CanisterUnavailable {
1500 canister: canister.clone(),
1501 reason: error.to_string(),
1502 }
1503 })?;
1504 if status.id != canister {
1505 return Err(FleetGenerateError::SeedTopology(format!(
1506 "retained canister query for {canister} returned identity {}",
1507 status.id
1508 )));
1509 }
1510 let controllers = status
1511 .settings
1512 .map(|settings| settings.controllers)
1513 .unwrap_or_default();
1514 require_exact_controllers(&canister, controllers, required_controllers)?;
1515 let subnet = if let Some(catalog) = &catalog {
1516 catalog
1517 .catalog
1518 .resolve_canister_route(&status.id)
1519 .map_err(|error| FleetGenerateError::Authority(error.to_string()))?
1520 .subnet
1521 .to_text()
1522 } else {
1523 expected_subnet.clone()
1524 };
1525 if subnet != expected_subnet {
1526 return Err(FleetGenerateError::SubnetMismatch {
1527 actual: subnet,
1528 canister,
1529 expected: expected_subnet,
1530 });
1531 }
1532 let cycles = status
1533 .cycles
1534 .as_deref()
1535 .ok_or_else(|| FleetGenerateError::CanisterUnavailable {
1536 canister: canister.clone(),
1537 reason: "controller-authenticated native cycle balance is unavailable".to_string(),
1538 })?
1539 .replace('_', "")
1540 .parse::<u128>()
1541 .map_err(|_| FleetGenerateError::Authority("invalid live cycle balance".to_string()))?;
1542 let module_sha256 = status
1543 .module_hash
1544 .as_deref()
1545 .map(|hash| normalize_observed_module_sha256(&canister, hash))
1546 .transpose()?;
1547 if root_principals.contains(canister.as_str()) {
1548 let runtime_status = parse_observed_runtime_status(
1549 &canister,
1550 status.status.as_deref().ok_or_else(|| {
1551 FleetGenerateError::CanisterUnavailable {
1552 canister: canister.clone(),
1553 reason: "management runtime status is unavailable to the selected identity"
1554 .to_string(),
1555 }
1556 })?,
1557 )?;
1558 if module_sha256.is_none() {
1559 return Err(FleetGenerateError::CanisterUnavailable {
1560 canister,
1561 reason: "retained Root has no installed module SHA-256".to_string(),
1562 });
1563 }
1564 root_statuses.insert(status.id.clone(), runtime_status);
1565 }
1566 if observed
1567 .insert(
1568 status.id.clone(),
1569 ObservedCanister {
1570 pool_status: None,
1571 cycles,
1572 module_sha256,
1573 subnet,
1574 },
1575 )
1576 .is_some()
1577 {
1578 return Err(FleetGenerateError::SeedTopology(format!(
1579 "retained identity {} is assigned to more than one role",
1580 status.id
1581 )));
1582 }
1583 }
1584 for root in &seed.roots {
1585 match root_statuses.get(&root.root) {
1586 Some(CanisterRuntimeStatus::Running)
1587 if observed
1588 .get(&root.root)
1589 .and_then(|value| value.module_sha256.as_deref())
1590 == Some(*root_wasm_sha256) => {}
1591 Some(CanisterRuntimeStatus::Running) => {
1592 }
1595 Some(CanisterRuntimeStatus::Stopped) => {
1596 let observation = observed.get(&root.root).ok_or_else(|| {
1597 FleetGenerateError::SeedTopology(format!(
1598 "retained Root {} has no exact management observation",
1599 root.root
1600 ))
1601 })?;
1602 let module_sha256 = observation.module_sha256.clone().ok_or_else(|| {
1603 FleetGenerateError::CanisterUnavailable {
1604 canister: root.root.clone(),
1605 reason: "retained Root has no installed module SHA-256".to_string(),
1606 }
1607 })?;
1608 let root_name = retained_root_name(source, seed, topology, &root.root)?;
1609 stopped_roots.push(RootManagementBinding {
1610 controllers: sorted_controllers(
1611 &operator.to_text(),
1612 &source.recovery_controllers,
1613 ),
1614 name: root_name,
1615 module_sha256,
1616 principal: root.root.clone(),
1617 subnet: observation.subnet.clone(),
1618 });
1619 }
1620 Some(CanisterRuntimeStatus::Stopping) => {
1621 return Err(FleetGenerateError::CanisterUnavailable {
1622 canister: root.root.clone(),
1623 reason: "retained Root is stopping; wait for terminal Stopped state and rerun generation"
1624 .to_string(),
1625 });
1626 }
1627 None => {
1628 return Err(FleetGenerateError::SeedTopology(format!(
1629 "retained Root {} has no runtime-status observation",
1630 root.root
1631 )));
1632 }
1633 }
1634 }
1635 if let Some(first) = stopped_roots.first() {
1636 let authority = write_root_start_authority(
1637 &EnsurePaths::under(request.root, request.environment, request.fleet),
1638 RetainedRootStartAuthorityRecord {
1639 authority_sha256: String::new(),
1640 environment: request.environment.to_string(),
1641 fleet: request.fleet.to_string(),
1642 fleet_id: seed.fleet_id,
1643 roots: stopped_roots.clone(),
1644 schema_version: FLEET_ENSURE_SCHEMA_VERSION,
1645 },
1646 )
1647 .map_err(|error| FleetGenerateError::Authority(error.to_string()))?;
1648 return Err(FleetGenerateError::StoppedRootStartRequired(Box::new(
1649 StoppedRootStartPrerequisite {
1650 authority_sha256: authority.authority_sha256,
1651 controller: operator.to_text(),
1652 fleet: request.fleet.to_string(),
1653 module_sha256: first.module_sha256.clone(),
1654 root: first.principal.clone(),
1655 root_count: stopped_roots.len(),
1656 subnet: first.subnet.clone(),
1657 },
1658 )));
1659 }
1660 if seed.roots.iter().any(|root| {
1661 observed
1662 .get(&root.root)
1663 .and_then(|live| live.module_sha256.as_deref())
1664 != Some(*root_wasm_sha256)
1665 }) {
1666 return Ok(observed);
1667 }
1668 observe_root_owned_pool_assets(
1669 &icp,
1670 &RootEstateAuthorityRequest {
1671 app,
1672 generation: request,
1673 root_candid,
1674 seed,
1675 source,
1676 topology,
1677 },
1678 &mut observed,
1679 )?;
1680 Ok(observed)
1681}
1682
1683fn retained_root_name(
1684 source: &FleetSource,
1685 seed: &EstateSeed,
1686 topology: &PlannedFleetTopology,
1687 principal: &str,
1688) -> Result<String, FleetGenerateError> {
1689 bind_root_generation_inputs(
1690 &source.fleet_subnet_roots,
1691 &seed.roots,
1692 &topology.fleet_subnet_roots,
1693 )?
1694 .into_iter()
1695 .enumerate()
1696 .find_map(|(index, binding)| (binding.seed.root == principal).then(|| format!("root-{index}")))
1697 .ok_or_else(|| {
1698 FleetGenerateError::SeedTopology(format!(
1699 "retained Root {principal} is absent from the compiled topology"
1700 ))
1701 })
1702}
1703
1704fn parse_observed_runtime_status(
1705 canister: &str,
1706 value: &str,
1707) -> Result<CanisterRuntimeStatus, FleetGenerateError> {
1708 match value.to_ascii_lowercase().as_str() {
1709 "running" => Ok(CanisterRuntimeStatus::Running),
1710 "stopped" => Ok(CanisterRuntimeStatus::Stopped),
1711 "stopping" => Ok(CanisterRuntimeStatus::Stopping),
1712 _ => Err(FleetGenerateError::CanisterUnavailable {
1713 canister: canister.to_string(),
1714 reason: format!("unknown management runtime status {value:?}"),
1715 }),
1716 }
1717}
1718
1719fn normalize_observed_module_sha256(
1720 canister: &str,
1721 value: &str,
1722) -> Result<String, FleetGenerateError> {
1723 let normalized = value.trim_start_matches("0x").to_ascii_lowercase();
1724 if normalized.len() != 64 || !normalized.bytes().all(|byte| byte.is_ascii_hexdigit()) {
1725 return Err(FleetGenerateError::CanisterUnavailable {
1726 canister: canister.to_string(),
1727 reason: format!("invalid installed module SHA-256 {value:?}"),
1728 });
1729 }
1730 Ok(normalized)
1731}
1732
1733fn require_root_estate_authority(
1734 root: &str,
1735 actual: &FleetSubnetRootAuthority,
1736 expected: &ExpectedRootEstateAuthority,
1737) -> Result<(), FleetGenerateError> {
1738 if actual.binding.authority.binding != expected.coordinator {
1739 return Err(FleetGenerateError::SeedTopology(format!(
1740 "Root {root} live Coordinator authority differs from protected policy"
1741 )));
1742 }
1743 if RootIdentityRelationship::from(&actual.binding) != expected.root {
1744 return Err(FleetGenerateError::SeedTopology(format!(
1745 "Root {root} live identity or placement differs from the identity seed"
1746 )));
1747 }
1748 if WasmStoreIdentityRelationship::from(&actual.wasm_store_authority) != expected.store {
1749 return Err(FleetGenerateError::SeedTopology(format!(
1750 "Root {root} live Store relationship differs from the identity seed"
1751 )));
1752 }
1753 Ok(())
1754}
1755
1756fn require_root_policy_matches(
1757 root: &str,
1758 actual: &RootDesiredPolicy,
1759 expected: &RootDesiredPolicy,
1760) -> Result<(), FleetGenerateError> {
1761 if actual != expected {
1762 return Err(FleetGenerateError::SeedTopology(format!(
1763 "Root {root} installed policy differs from the current configuration"
1764 )));
1765 }
1766 Ok(())
1767}
1768
1769fn require_fiduciary_acknowledgement(
1770 catalog: &ic_query::subnet_catalog::ValidatedSubnetCatalog,
1771 subnet: &str,
1772 acknowledged: bool,
1773 role: &str,
1774) -> Result<(), FleetGenerateError> {
1775 let info = catalog.subnet_by_principal(subnet).ok_or_else(|| {
1776 FleetGenerateError::Authority(format!(
1777 "{role} Subnet {subnet} is absent from validated Registry evidence"
1778 ))
1779 })?;
1780 let fiduciary = info.subnet_specialization == SubnetSpecialization::Fiduciary;
1781 if fiduciary != acknowledged {
1782 return Err(FleetGenerateError::Authority(format!(
1783 "{role} Subnet {subnet} Fiduciary cost acknowledgement differs from validated Registry classification"
1784 )));
1785 }
1786 Ok(())
1787}
1788
1789#[expect(
1790 clippy::too_many_lines,
1791 reason = "bounded Root inventory observation validates one complete authority tuple and page stream"
1792)]
1793fn observe_root_owned_pool_assets(
1794 icp: &IcpCli,
1795 request: &RootEstateAuthorityRequest<'_, '_>,
1796 observed: &mut BTreeMap<String, ObservedCanister>,
1797) -> Result<(), FleetGenerateError> {
1798 let expected_network = canonical_network_id(request.generation, request.source)?;
1799 let expected_fleet = request.seed.fleet_id;
1800 let expected_coordinator = parse_principal("Fleet Coordinator", &request.seed.coordinator)?;
1801 let expected_coordinator_subnet = parse_subnet(
1802 "Fleet Coordinator",
1803 &request.source.coordinator.subnet.subnet,
1804 )?;
1805 for root in &request.seed.roots {
1806 let source_root = request
1807 .source
1808 .fleet_subnet_roots
1809 .iter()
1810 .find(|candidate| candidate.placement_subnet == root.placement_subnet)
1811 .ok_or_else(|| {
1812 FleetGenerateError::SeedTopology(format!(
1813 "Root {} has no policy source",
1814 root.placement_subnet
1815 ))
1816 })?;
1817 let planned = request
1818 .topology
1819 .fleet_subnet_roots
1820 .iter()
1821 .find(|candidate| candidate.placement_subnet.to_string() == root.placement_subnet)
1822 .ok_or_else(|| {
1823 FleetGenerateError::SeedTopology(format!(
1824 "Root {} has no compiled topology authority",
1825 root.placement_subnet
1826 ))
1827 })?;
1828 let expected = std::iter::once(root.store.as_str())
1829 .chain(root.pool_imports.iter().map(String::as_str))
1830 .collect::<std::collections::BTreeSet<_>>();
1831 let root_principal = parse_principal("Fleet Subnet Root", &root.root)?;
1832 let response: RootEstateStatusResponse = query_with_candid(
1833 icp,
1834 request.root_candid,
1835 root_principal,
1836 protocol::CANIC_ROOT_STATUS,
1837 &RootEstateStatusRequest::FleetAuthority,
1838 )
1839 .map_err(|error| FleetGenerateError::CanisterUnavailable {
1840 canister: root.root.clone(),
1841 reason: format!("protected Root authority observation failed: {error}"),
1842 })?;
1843 let RootEstateStatusResponse::FleetAuthority(authority) = response else {
1844 return Err(FleetGenerateError::SeedTopology(format!(
1845 "Root {} returned the wrong authority projection",
1846 root.root
1847 )));
1848 };
1849 let expected_coordinator = FleetCoordinatorBinding {
1850 fleet: FleetBinding {
1851 fleet: FleetKey {
1852 canonical_network_id: expected_network,
1853 fleet_id: expected_fleet,
1854 },
1855 app: request.app.clone(),
1856 },
1857 coordinator_subnet: expected_coordinator_subnet,
1858 coordinator: expected_coordinator,
1859 recovery_controllers: request
1860 .source
1861 .recovery_controllers
1862 .iter()
1863 .map(|controller| parse_principal("recovery controller", controller))
1864 .collect::<Result<Vec<_>, _>>()?,
1865 };
1866 let expected_registry_authority = FleetRegistryAuthority {
1867 binding: expected_coordinator.clone(),
1868 epoch: authority.binding.authority.epoch,
1869 };
1870 let expected_authority = ExpectedRootEstateAuthority {
1871 coordinator: expected_coordinator,
1872 root: RootIdentityRelationship {
1873 authority: expected_registry_authority.clone(),
1874 placement_subnet: planned.placement_subnet,
1875 fleet_subnet_root: root_principal,
1876 },
1877 store: WasmStoreIdentityRelationship {
1878 authority: expected_registry_authority,
1879 placement_subnet: planned.placement_subnet,
1880 fleet_subnet_root: root_principal,
1881 wasm_store: parse_principal("Wasm Store", &root.store)?,
1882 },
1883 };
1884 require_root_estate_authority(&root.root, &authority, &expected_authority)?;
1885 let expected_policy = RootDesiredPolicy {
1886 component_admissions: planned.component_admissions.clone(),
1887 component_topology_digest: planned.component_topology_digest,
1888 funding: root_funding(request.source.funding_profile, &source_root.root_funding),
1889 installation_controller: parse_principal("operator", &request.source.operator)?,
1890 limits: planned.limits.clone(),
1891 };
1892 require_root_policy_matches(
1893 &root.root,
1894 &RootDesiredPolicy::from(authority.as_ref()),
1895 &expected_policy,
1896 )?;
1897 let mut found = BTreeMap::new();
1898 let mut start_after = None;
1899 loop {
1900 let status_request = RootEstateStatusRequest::Pool(CanisterPoolStatusRequest {
1901 start_after,
1902 limit: 256,
1903 });
1904 let page = {
1905 let response: RootEstateStatusResponse = query_with_candid(
1906 icp,
1907 request.root_candid,
1908 root_principal,
1909 protocol::CANIC_ROOT_STATUS,
1910 &status_request,
1911 )
1912 .map_err(|error| FleetGenerateError::CanisterUnavailable {
1913 canister: root.root.clone(),
1914 reason: format!("protected Root pool observation failed: {error}"),
1915 })?;
1916 let RootEstateStatusResponse::Pool(page) = response else {
1917 return Err(FleetGenerateError::SeedTopology(format!(
1918 "Root {} returned the wrong pool projection",
1919 root.root
1920 )));
1921 };
1922 *page
1923 };
1924 if page.config != authority.binding.limits.canister_pool {
1925 return Err(FleetGenerateError::SeedTopology(format!(
1926 "Root {} live pool policy conflicts with its retained authority",
1927 root.root
1928 )));
1929 }
1930 for asset in page.entries {
1931 let principal = asset.canister_id.to_text();
1932 if !expected.contains(principal.as_str()) {
1933 return Err(FleetGenerateError::SeedTopology(format!(
1934 "Root {} retains unseeded controlled pool asset {principal}",
1935 root.root
1936 )));
1937 }
1938 if principal == root.store
1939 && root_owned_lifecycle(DesiredCanisterKind::Store, &asset.status).is_none()
1940 {
1941 return Err(FleetGenerateError::SeedTopology(format!(
1942 "Root {} does not retain {} as its exact Store",
1943 root.root, root.store
1944 )));
1945 }
1946 if principal != root.store
1947 && root_owned_lifecycle(DesiredCanisterKind::Pool, &asset.status).is_none()
1948 {
1949 return Err(FleetGenerateError::SeedTopology(format!(
1950 "Root {} pool identity {principal} is outside the retained bootstrap/workload lifecycle",
1951 root.root
1952 )));
1953 }
1954 if found.insert(principal, asset).is_some() {
1955 return Err(FleetGenerateError::SeedTopology(format!(
1956 "Root {} repeats a pool identity",
1957 root.root
1958 )));
1959 }
1960 }
1961 let next = page.next_start_after;
1962 if next.is_none() {
1963 break;
1964 }
1965 if next == start_after {
1966 return Err(FleetGenerateError::SeedTopology(format!(
1967 "Root {} pool cursor did not advance",
1968 root.root
1969 )));
1970 }
1971 start_after = next;
1972 }
1973 for principal in expected {
1974 let asset = found.get(principal).ok_or_else(|| {
1975 FleetGenerateError::SeedTopology(format!(
1976 "Root {} does not retain seeded identity {principal}",
1977 root.root
1978 ))
1979 })?;
1980 let cycles = asset.cycles.to_u128();
1981 if let Some(entry) = observed.get_mut(principal) {
1982 if entry.cycles != cycles {
1983 return Err(FleetGenerateError::Authority(format!(
1984 "Root-owned and direct cycle observations differ for {principal}"
1985 )));
1986 }
1987 entry.pool_status = Some(asset.status.clone());
1988 } else {
1989 observed.insert(
1990 principal.to_string(),
1991 ObservedCanister {
1992 pool_status: Some(asset.status.clone()),
1993 cycles,
1994 module_sha256: None,
1995 subnet: root.placement_subnet.clone(),
1996 },
1997 );
1998 }
1999 }
2000 }
2001 Ok(())
2002}
2003
2004fn validate_identity_seed(
2005 source: &FleetSource,
2006 seed: &EstateSeed,
2007) -> Result<(), FleetGenerateError> {
2008 validate_identity_seed_selection(source, seed, false)
2009}
2010
2011fn validate_identity_seed_selection(
2012 source: &FleetSource,
2013 seed: &EstateSeed,
2014 create_coordinator: bool,
2015) -> Result<(), FleetGenerateError> {
2016 let fee = Cycles::from_human_config_str(&seed.management_creation_fee_cycles)
2017 .map_err(|_| {
2018 FleetGenerateError::SeedTopology(
2019 "estate seed management creation fee must use B, T, or Q units".to_string(),
2020 )
2021 })?
2022 .to_u128();
2023 if seed.fresh_estate {
2024 let expected = fresh_seed(source, seed.fleet_id, &seed.cycles_ledger, fee)?;
2025 if seed == &expected {
2026 return Ok(());
2027 }
2028 return Err(FleetGenerateError::FreshSeedConflict(
2029 "fresh estate seed differs from current protected topology".to_string(),
2030 ));
2031 }
2032 let mut identities = BTreeSet::new();
2033 if !create_coordinator {
2034 insert_seed_identity(&mut identities, "Coordinator", &seed.coordinator)?;
2035 }
2036 if let Some(treasury) = &seed.treasury {
2037 parse_subnet("treasury", &treasury.subnet)?;
2038 if treasury.principal != seed.coordinator {
2039 insert_seed_identity(&mut identities, "treasury", &treasury.principal)?;
2040 } else if treasury.subnet != source.coordinator.subnet.subnet {
2041 return Err(FleetGenerateError::SeedTopology(
2042 "Coordinator treasury Subnet differs from Coordinator placement".to_string(),
2043 ));
2044 }
2045 }
2046 let source_roots = source
2047 .fleet_subnet_roots
2048 .iter()
2049 .map(|root| root.placement_subnet.as_str())
2050 .collect::<BTreeSet<_>>();
2051 let seed_roots = seed
2052 .roots
2053 .iter()
2054 .map(|root| root.placement_subnet.as_str())
2055 .collect::<BTreeSet<_>>();
2056 if source_roots.len() != source.fleet_subnet_roots.len()
2057 || seed_roots.len() != seed.roots.len()
2058 || source_roots != seed_roots
2059 {
2060 return Err(FleetGenerateError::SeedTopology(
2061 "Root placement Subnet sets differ or contain duplicates".to_string(),
2062 ));
2063 }
2064 for root in &seed.roots {
2065 let source_root = source
2066 .fleet_subnet_roots
2067 .iter()
2068 .find(|candidate| candidate.placement_subnet == root.placement_subnet)
2069 .expect("validated Root placement");
2070 validate_root_pool_import_capacity(&RootPoolImportCapacityInput {
2071 import_count: root.pool_imports.len(),
2072 maximum_size: source_root.canister_pool.maximum_size,
2073 root: root.root.clone(),
2074 })?;
2075 insert_seed_identity(&mut identities, "Fleet Subnet Root", &root.root)?;
2076 insert_seed_identity(&mut identities, "Wasm Store", &root.store)?;
2077 for pool in &root.pool_imports {
2078 insert_seed_identity(&mut identities, "pool import", pool)?;
2079 }
2080 if !source_root.canister_pool.imports.is_empty()
2081 && source_root.canister_pool.imports != root.pool_imports
2082 {
2083 return Err(FleetGenerateError::SeedTopology(format!(
2084 "Root {} pool imports differ between policy source and identity seed",
2085 root.placement_subnet
2086 )));
2087 }
2088 }
2089 Ok(())
2090}
2091
2092fn insert_seed_identity(
2093 identities: &mut BTreeSet<String>,
2094 role: &str,
2095 principal: &str,
2096) -> Result<(), FleetGenerateError> {
2097 parse_principal(role, principal)?;
2098 if !identities.insert(principal.to_string()) {
2099 return Err(FleetGenerateError::SeedTopology(format!(
2100 "Principal {principal} is assigned to more than one retained role"
2101 )));
2102 }
2103 Ok(())
2104}
2105
2106fn insert_expected_canister(
2107 expected: &mut BTreeMap<String, (String, Vec<String>)>,
2108 principal: String,
2109 authority: (String, Vec<String>),
2110 role: &str,
2111) -> Result<(), FleetGenerateError> {
2112 if expected.insert(principal.clone(), authority).is_some() {
2113 return Err(FleetGenerateError::SeedTopology(format!(
2114 "{role} Principal {principal} is assigned more than once"
2115 )));
2116 }
2117 Ok(())
2118}
2119
2120fn require_exact_controllers(
2121 canister: &str,
2122 mut actual: Vec<String>,
2123 mut expected: Vec<String>,
2124) -> Result<(), FleetGenerateError> {
2125 actual.sort();
2126 expected.sort();
2127 if actual != expected {
2128 return Err(FleetGenerateError::ControllerMismatch {
2129 actual,
2130 canister: canister.to_string(),
2131 expected,
2132 });
2133 }
2134 Ok(())
2135}
2136
2137fn sorted_controllers(primary: &str, recovery: &[String]) -> Vec<String> {
2138 let mut controllers = recovery.to_vec();
2139 controllers.push(primary.to_string());
2140 controllers.sort();
2141 controllers.dedup();
2142 controllers
2143}
2144
2145fn canonical_recovery_controllers(
2146 configured: &[String],
2147 operator: Principal,
2148) -> Result<Vec<String>, FleetGenerateError> {
2149 if configured.len() > 8 {
2151 return Err(FleetGenerateError::Authority(
2152 "at most eight recovery controllers are supported".to_string(),
2153 ));
2154 }
2155 let mut controllers = configured
2156 .iter()
2157 .map(|value| parse_principal("recovery controller", value))
2158 .collect::<Result<Vec<_>, _>>()?;
2159 if controllers
2160 .iter()
2161 .any(|controller| *controller == Principal::anonymous() || *controller == operator)
2162 {
2163 return Err(FleetGenerateError::Authority(
2164 "recovery controllers must be non-anonymous and distinct from the operator".to_string(),
2165 ));
2166 }
2167 controllers.sort_unstable();
2168 if controllers.windows(2).any(|pair| pair[0] == pair[1]) {
2169 return Err(FleetGenerateError::Authority(
2170 "recovery controllers must be unique".to_string(),
2171 ));
2172 }
2173 let mut rendered = controllers
2174 .into_iter()
2175 .map(|controller| controller.to_text())
2176 .collect::<Vec<_>>();
2177 rendered.sort();
2178 Ok(rendered)
2179}
2180
2181fn release_authority(
2182 request: &FleetGenerateRequest<'_>,
2183 component_topology: &canic_core::control_plane_support::config::ComponentTopology,
2184) -> Result<
2185 (
2186 crate::release_set::PersistedCanicInfrastructureArtifactManifest,
2187 crate::release_set::PersistedCurrentReleaseSetManifest,
2188 ),
2189 FleetGenerateError,
2190> {
2191 let complete =
2192 load_persisted_current_release_set_manifest(request.root, request.release_build_id)
2193 .map_err(|error| FleetGenerateError::Release(error.to_string()))?;
2194 let expected_network = resolve_icp_build_network_from_root(request.root, request.environment)
2195 .map_err(|error| FleetGenerateError::Release(error.to_string()))?;
2196 require_release_build_network(
2197 request.release_build_id,
2198 complete.manifest.build_network,
2199 request.environment,
2200 expected_network,
2201 )?;
2202 validate_finalized_release_build_manifest(
2203 request.root,
2204 request.release_build_id,
2205 &complete.path,
2206 )
2207 .map_err(|error| FleetGenerateError::Release(error.to_string()))?;
2208 let infrastructure = load_persisted_canic_infrastructure_artifact_manifest(
2209 request.root,
2210 request.release_build_id,
2211 )
2212 .map_err(|error| FleetGenerateError::Release(error.to_string()))?;
2213 let application = load_persisted_application_artifact_union(
2214 request.root,
2215 component_topology,
2216 request.release_build_id,
2217 )
2218 .map_err(|error| FleetGenerateError::Release(error.to_string()))?;
2219 if complete.manifest.application_artifact_union_sha256 != application.digest {
2220 return Err(FleetGenerateError::Release(
2221 "complete release set does not bind its application artifact union".to_string(),
2222 ));
2223 }
2224 complete
2225 .manifest
2226 .verify_fixtures(request.root, component_topology)
2227 .map_err(|error| FleetGenerateError::Release(error.to_string()))?;
2228 Ok((infrastructure, complete))
2229}
2230
2231fn require_release_build_network(
2232 release_build_id: ReleaseBuildId,
2233 actual: BuildNetwork,
2234 environment: &str,
2235 expected: BuildNetwork,
2236) -> Result<(), FleetGenerateError> {
2237 if actual == expected {
2238 return Ok(());
2239 }
2240 Err(FleetGenerateError::ReleaseBuildNetworkMismatch {
2241 release_build_id,
2242 actual,
2243 environment: environment.to_string(),
2244 expected,
2245 })
2246}
2247
2248#[expect(
2249 clippy::too_many_arguments,
2250 reason = "single constructor makes every authority-bearing desired canister field explicit"
2251)]
2252fn infrastructure_canister(
2253 name: &str,
2254 kind: DesiredCanisterKind,
2255 canic_init: Option<DesiredCanisterInit>,
2256 principal: Option<&str>,
2257 parent: Option<&str>,
2258 operator: &str,
2259 recovery_controllers: &[String],
2260 subnet: &str,
2261 initial_cycles: u128,
2262 minimum_cycles: u128,
2263 artifact: &CanicInfrastructureArtifactEntry,
2264) -> DesiredCanister {
2265 DesiredCanister {
2266 canic_init,
2267 controller_canisters: Vec::new(),
2268 controllers: sorted_controllers(operator, recovery_controllers),
2269 drain: None,
2270 initial_cycles: config_cycles(initial_cycles),
2271 init_arg: None,
2272 init_candid: None,
2273 kind,
2274 minimum_cycles: config_cycles(minimum_cycles),
2275 name: name.to_string(),
2276 parent: parent.map(str::to_string),
2277 presence: DesiredPresence::Present,
2278 principal: principal.map(str::to_string),
2279 protocol_binding: None,
2280 replace: false,
2281 subnet: subnet.to_string(),
2282 wasm: Some(artifact.wasm_relative_path.clone()),
2283 }
2284}
2285
2286fn config_cycles(cycles: u128) -> String {
2287 Cycles::new(cycles).to_config_string()
2288}
2289
2290fn validate_source_funding(
2291 source: &FleetSource,
2292 ic_mainnet: bool,
2293) -> Result<(), FleetGenerateError> {
2294 use canic_core::control_plane_support::model::fleet_funding_policy::{
2295 validate_coordinator_root_funding_policy, validate_fleet_root_funding_capacity,
2296 validate_fleet_subnet_root_funding_authority,
2297 };
2298 let coordinator = coordinator_funding(source.funding_profile, &source.coordinator.root_funding);
2299 let roots = source
2300 .fleet_subnet_roots
2301 .iter()
2302 .map(|root| root_funding(source.funding_profile, &root.root_funding))
2303 .collect::<Vec<_>>();
2304 validate_coordinator_root_funding_policy(&coordinator)?;
2305 for root in &roots {
2306 validate_fleet_subnet_root_funding_authority(root, ic_mainnet)?;
2307 }
2308 validate_fleet_root_funding_capacity(&coordinator, &roots)?;
2309 Ok(())
2310}
2311
2312fn coordinator_funding(
2313 profile: FleetFundingProfile,
2314 source: &CoordinatorFundingSource,
2315) -> FleetCoordinatorRootFundingPolicy {
2316 FleetCoordinatorRootFundingPolicy {
2317 funding_profile: profile,
2318 minimum_reserve_cycles: source.minimum_reserve_cycles.clone(),
2319 budget: CyclesFundingBudget {
2320 window_secs: source.window_secs,
2321 maximum_cycles: source.maximum_cycles.clone(),
2322 },
2323 maximum_automatic_grants: source.maximum_automatic_grants,
2324 maximum_automatic_cycles: source.maximum_automatic_cycles.clone(),
2325 }
2326}
2327
2328fn root_funding(
2329 profile: FleetFundingProfile,
2330 source: &RootFundingSource,
2331) -> FleetSubnetRootFundingAuthority {
2332 FleetSubnetRootFundingAuthority {
2333 root_funding: FleetSubnetRootFundingPolicy {
2334 funding_profile: profile,
2335 request_threshold: source.request_threshold.clone(),
2336 target_balance: source.target_balance.clone(),
2337 cooldown_secs: source.cooldown_secs,
2338 budget: CyclesFundingBudget {
2339 window_secs: source.window_secs,
2340 maximum_cycles: source.maximum_cycles.clone(),
2341 },
2342 maximum_automatic_grants: source.maximum_automatic_grants,
2343 maximum_automatic_cycles: source.maximum_automatic_cycles.clone(),
2344 },
2345 icp_refill: None,
2346 }
2347}
2348
2349fn root_limits(source: &RootSource) -> FleetSubnetRootLimits {
2350 FleetSubnetRootLimits {
2351 maximum_component_instances: source.limits.maximum_component_instances,
2352 maximum_registry_bytes: source.limits.maximum_registry_bytes,
2353 maximum_wasm_store_bytes: source.limits.maximum_wasm_store_bytes,
2354 canister_pool: FleetSubnetCanisterPoolConfig {
2355 minimum_size: source.canister_pool.minimum_size,
2356 maximum_size: source.canister_pool.maximum_size,
2357 canister_cycles: source.canister_pool.canister_cycles.clone(),
2358 creation_execution_margin: Cycles::new(GENERATED_POOL_CREATION_EXECUTION_MARGIN_CYCLES),
2359 },
2360 cycles_funding: CyclesFundingBudget {
2361 window_secs: source.limits.cycles_funding.window_secs,
2362 maximum_cycles: source.limits.cycles_funding.maximum_cycles.clone(),
2363 },
2364 maximum_group_placements: source.limits.maximum_group_placements,
2365 }
2366}
2367
2368fn artifact(
2369 entries: &[CanicInfrastructureArtifactEntry],
2370 role: CanicInfrastructureRole,
2371) -> Result<&CanicInfrastructureArtifactEntry, FleetGenerateError> {
2372 entries
2373 .iter()
2374 .find(|entry| entry.role == role)
2375 .ok_or_else(|| FleetGenerateError::Release(format!("missing {role:?} artifact")))
2376}
2377
2378fn candid_sidecar(
2379 root: &Path,
2380 artifact: &CanicInfrastructureArtifactEntry,
2381) -> Result<String, FleetGenerateError> {
2382 let wasm = root.join(&artifact.wasm_relative_path);
2383 let path = wasm.with_extension("did");
2384 let bytes = match read_optional_regular_bytes(&path) {
2385 Ok(Some(bytes)) => bytes,
2386 Ok(None) => {
2387 return Err(FleetGenerateError::CandidMissing { path });
2388 }
2389 Err(RegularFileReadError::NotRegular) => {
2390 return Err(FleetGenerateError::CandidNotRegular { path });
2391 }
2392 Err(RegularFileReadError::Io(error)) => {
2393 return Err(FleetGenerateError::CandidRead {
2394 path,
2395 source: error,
2396 });
2397 }
2398 #[cfg(not(unix))]
2399 Err(RegularFileReadError::UnsupportedPlatform) => {
2400 return Err(FleetGenerateError::CandidUnsupportedPlatform { path });
2401 }
2402 };
2403 let actual: [u8; 32] = Sha256::digest(bytes).into();
2404 if actual != artifact.candid_sha256 {
2405 return Err(FleetGenerateError::CandidDigestMismatch {
2406 path,
2407 expected: artifact.candid_sha256,
2408 observed: actual,
2409 });
2410 }
2411 relative_path(root, &path)
2412}
2413
2414fn relative_path(root: &Path, path: &Path) -> Result<String, FleetGenerateError> {
2415 path.strip_prefix(root)
2416 .map_err(|_| {
2417 FleetGenerateError::Authority(format!("path is outside workspace: {}", path.display()))
2418 })?
2419 .to_str()
2420 .map(str::to_string)
2421 .ok_or_else(|| {
2422 FleetGenerateError::Authority(format!("path is not UTF-8: {}", path.display()))
2423 })
2424}
2425
2426fn canonical_network_id(
2427 request: &FleetGenerateRequest<'_>,
2428 _source: &FleetSource,
2429) -> Result<canic_core::ids::CanonicalNetworkId, FleetGenerateError> {
2430 resolve_canonical_network_id_from_root(request.root, request.environment)
2431 .map_err(|error| FleetGenerateError::Authority(error.to_string()))
2432}
2433
2434fn load_toml<T: for<'de> Deserialize<'de>>(
2435 path: &Path,
2436 _kind: &'static str,
2437) -> Result<T, FleetGenerateError> {
2438 let bytes = match read_optional_regular_bytes(path) {
2439 Ok(Some(bytes)) => bytes,
2440 Ok(None) => {
2441 return Err(FleetGenerateError::Read {
2442 path: path.to_path_buf(),
2443 source: std::io::Error::new(std::io::ErrorKind::NotFound, "input is missing"),
2444 });
2445 }
2446 Err(RegularFileReadError::Io(source)) => {
2447 return Err(FleetGenerateError::Read {
2448 path: path.to_path_buf(),
2449 source,
2450 });
2451 }
2452 Err(RegularFileReadError::NotRegular) => {
2453 return Err(FleetGenerateError::UnsafeInput {
2454 path: path.to_path_buf(),
2455 reason: "path is not a regular no-follow file",
2456 });
2457 }
2458 #[cfg(not(unix))]
2459 Err(RegularFileReadError::UnsupportedPlatform) => {
2460 return Err(FleetGenerateError::UnsafeInput {
2461 path: path.to_path_buf(),
2462 reason: "no-follow reads are unsupported on this platform",
2463 });
2464 }
2465 };
2466 if bytes.len() > MAX_GENERATOR_INPUT_BYTES {
2467 return Err(FleetGenerateError::TooLarge {
2468 actual: bytes.len(),
2469 maximum: MAX_GENERATOR_INPUT_BYTES,
2470 path: path.to_path_buf(),
2471 });
2472 }
2473 toml::from_slice(&bytes).map_err(|source| FleetGenerateError::Decode {
2474 path: path.to_path_buf(),
2475 source,
2476 })
2477}
2478
2479const fn require_schema(actual: u32, kind: &'static str) -> Result<(), FleetGenerateError> {
2480 if actual == 1 {
2481 Ok(())
2482 } else {
2483 Err(FleetGenerateError::Schema { actual, kind })
2484 }
2485}
2486
2487fn require_cycles_creation(
2488 source: &CyclesCreationSource,
2489 owner: &str,
2490) -> Result<(), FleetGenerateError> {
2491 if source.kind == "cycles" && source.cycles.to_u128() > 0 {
2492 Ok(())
2493 } else {
2494 Err(FleetGenerateError::Authority(format!(
2495 "{owner} fresh creation must use a positive exact cycles amount"
2496 )))
2497 }
2498}
2499
2500fn parse_principal(field: &str, value: &str) -> Result<Principal, FleetGenerateError> {
2501 Principal::from_text(value).map_err(|error| {
2502 FleetGenerateError::Authority(format!("invalid {field} Principal: {error}"))
2503 })
2504}
2505
2506fn compile_source_admission_policy(
2507 values: &[String],
2508) -> Result<FleetAdmissionPolicyTemplate, FleetGenerateError> {
2509 let mut principals = values
2510 .iter()
2511 .map(|value| parse_principal("Fleet admission", value))
2512 .collect::<Result<Vec<_>, _>>()?;
2513 if principals
2514 .iter()
2515 .any(|principal| principal == &Principal::anonymous())
2516 {
2517 return Err(FleetGenerateError::FleetAdmissionAnonymous);
2518 }
2519 principals.sort_unstable();
2520 if let Some(duplicate) = principals
2521 .windows(2)
2522 .find(|items| items[0] == items[1])
2523 .map(|items| items[0])
2524 {
2525 return Err(FleetGenerateError::FleetAdmissionDuplicate {
2526 principal: duplicate,
2527 });
2528 }
2529 compile_fleet_admission_policy_template(principals, Vec::new())
2530 .map_err(|error| FleetGenerateError::Authority(error.to_string()))
2531}
2532
2533fn parse_subnet(field: &str, value: &str) -> Result<SubnetId, FleetGenerateError> {
2534 parse_principal(field, value).map(SubnetId::from_principal)
2535}
2536
2537fn mainnet_cycles_ledger() -> String {
2538 MAINNET_CYCLES_LEDGER.to_string()
2539}