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