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