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