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