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