Skip to main content

canic_host/fleet_ensure/generate/
mod.rs

1//! Module: fleet_ensure::generate
2//!
3//! Responsibility: compile high-level Fleet policy plus explicit live identities into desired state.
4//! Does not own: artifact builds, identity invention, paid effects, or convergence.
5//! Boundary: release artifacts are local authority; every retained Principal is explicit and live-verified.
6
7#[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
71/// Exact local and live inputs for one no-effect desired-state generation.
72pub 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
83/// Exact authority used to create one durable empty-estate identity seed.
84pub 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
91/// Generated desired state plus its explicit observation summary.
92pub 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/// Typed no-effect Fleet generation failure.
100#[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
326/// Create or replay one durable no-effect seed for a literally empty estate.
327pub 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/// Generate one exact low-level desired document without issuing an IC update.
599#[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        // Retained seeds bind paid canisters to observed Principals and carry no creation fee.
1066        // Fresh seeds retain their explicit fee before any generated Principal exists.
1067        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}