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