pub mod bootstrap;
pub(in crate::fleet_ensure) mod funding;
#[cfg(test)]
pub(crate) mod tests;
use crate::fleet_ensure::{
model::{
FLEET_ENSURE_SCHEMA_VERSION,
capacity_import::{
CapacityImportAuthority, CapacityImportDisposition, CapacityImportPlanRecord,
CapacityImportSourceRecord,
},
},
view::capacity_import::{
CapacityImportDestinationView, CapacityImportOwnershipView, CapacityImportRootView,
CapacityImportSourceView,
},
};
use candid::Principal;
use canic_core::ids::{MAX_FLEET_CAPACITY_IMPORT_SOURCES, SubnetId};
use std::collections::BTreeSet;
use thiserror::Error;
#[derive(Clone, Debug, Eq, Error, PartialEq)]
pub enum CapacityImportPolicyError {
#[error("capacity import authority or controller set is invalid")]
InvalidAuthority,
#[error("capacity import requires a nonempty bounded set of distinct source IDs")]
InvalidSources,
#[error("source {canister} has no remaining canister-version space for a handoff")]
VersionExhausted { canister: Principal },
#[error("source {canister} must be stopped before importing its installed code")]
SourceRunning { canister: Principal },
#[error("source {canister} retains snapshots; retire them before capacity import")]
SourceSnapshots { canister: Principal },
#[error("capacity import destination is not ready or has a competing operation")]
DestinationUnavailable,
#[error("capacity import destination authority changed")]
DestinationChanged,
#[error("no unique destination Root on subnet {subnet:?}; select a Root explicitly")]
DestinationAmbiguous { subnet: SubnetId },
#[error("selected Root is not a destination on subnet {subnet:?}")]
DestinationMissing { subnet: SubnetId },
#[error("capacity import exceeds the destination's available physical capacity")]
CapacityExceeded,
#[error("source {canister} belongs to another role or its ownership is unresolved")]
SourceAssigned { canister: Principal },
#[error("source {canister} is on a different subnet")]
WrongSubnet { canister: Principal },
#[error("selected operator is not a controller of source {canister}")]
OperatorNotController { canister: Principal },
#[error("source {canister} authority changed since review")]
SourceChanged { canister: Principal },
#[error("source {canister} has no exact verified absence/retirement disposition")]
DispositionUnresolved { canister: Principal },
#[error("cycle bounds overflow or contain no effect allowance")]
InvalidCycleBounds,
#[error(
"canister {canister} requires {required_cycles} cycles for readiness and remaining debit; available {available_cycles}; shortfall {shortfall_cycles}"
)]
InsufficientCycles {
canister: Principal,
required_cycles: u128,
available_cycles: u128,
shortfall_cycles: u128,
},
#[error("source {canister} cycle change exceeds its original reviewed debit")]
ConservationUnproven { canister: Principal },
}
pub fn select_destination(
roots: &[CapacityImportRootView],
subnet: SubnetId,
explicit_root: Option<Principal>,
) -> Result<&CapacityImportRootView, CapacityImportPolicyError> {
let mut matches = roots.iter().filter(|root| {
root.subnet == subnet && explicit_root.is_none_or(|selected| selected == root.root)
});
let selected = matches
.next()
.ok_or(CapacityImportPolicyError::DestinationMissing { subnet })?;
if matches.next().is_some() {
return Err(CapacityImportPolicyError::DestinationAmbiguous { subnet });
}
Ok(selected)
}
pub fn validate_plan(plan: &CapacityImportPlanRecord) -> Result<(), CapacityImportPolicyError> {
if plan.schema_version != FLEET_ENSURE_SCHEMA_VERSION {
return Err(CapacityImportPolicyError::InvalidAuthority);
}
validate_authority(&plan.authority)?;
if plan.sources.is_empty() || plan.sources.len() > MAX_FLEET_CAPACITY_IMPORT_SOURCES {
return Err(CapacityImportPolicyError::InvalidSources);
}
let final_set = final_controllers(&plan.authority);
let mut transitional = final_set.clone();
transitional.insert(plan.authority.operator);
if !canonical_controllers(&plan.final_controllers)
|| !canonical_controllers(&plan.transitional_controllers)
|| plan
.final_controllers
.iter()
.copied()
.collect::<BTreeSet<_>>()
!= final_set
|| plan
.transitional_controllers
.iter()
.copied()
.collect::<BTreeSet<_>>()
!= transitional
{
return Err(CapacityImportPolicyError::InvalidAuthority);
}
if plan.root_budget.maximum_debit_cycles == 0
|| plan.root_budget.maximum_paid_calls == 0
|| plan.root_budget.minimum_retained_cycles == 0
{
return Err(CapacityImportPolicyError::InvalidCycleBounds);
}
require_headroom(
plan.authority.root,
plan.root_budget.observed_cycles,
plan.root_budget.minimum_retained_cycles,
plan.root_budget.maximum_debit_cycles,
)?;
source_total(
plan.root_budget.observed_cycles,
plan.root_budget.observed_reserved_cycles,
)?;
let mut seen = BTreeSet::new();
let mut maximum_debit = plan.root_budget.maximum_debit_cycles;
for source in &plan.sources {
let canister = source.binding.canister_id;
if !valid_principal(canister) || !seen.insert(canister) {
return Err(CapacityImportPolicyError::InvalidSources);
}
if source.binding.snapshots_size_bytes != 0 {
return Err(CapacityImportPolicyError::SourceSnapshots { canister });
}
source_total(source.observed_cycles, source.observed_reserved_cycles)?;
validate_source_lifecycle(plan, source)?;
if canister == plan.authority.root
|| canister == plan.authority.coordinator
|| canister == plan.authority.operator
|| plan.authority.recovery_controllers.contains(&canister)
{
return Err(CapacityImportPolicyError::SourceAssigned { canister });
}
if source.binding.subnet != plan.authority.subnet {
return Err(CapacityImportPolicyError::WrongSubnet { canister });
}
if !canonical_controllers(&source.binding.controllers) {
return Err(CapacityImportPolicyError::InvalidAuthority);
}
if requires_handoff(plan, source)
&& !source
.binding
.controllers
.contains(&plan.authority.operator)
{
return Err(CapacityImportPolicyError::OperatorNotController { canister });
}
if disposition_digest(source) == [0; 32] {
return Err(CapacityImportPolicyError::DispositionUnresolved { canister });
}
if source.minimum_ready_cycles == 0 || source.maximum_debit_cycles == 0 {
return Err(CapacityImportPolicyError::InvalidCycleBounds);
}
require_headroom(
canister,
source.observed_cycles,
source.minimum_ready_cycles,
source.maximum_debit_cycles,
)?;
maximum_debit = maximum_debit
.checked_add(source.maximum_debit_cycles)
.ok_or(CapacityImportPolicyError::InvalidCycleBounds)?;
}
funding::validate(plan)
}
#[must_use]
pub fn requires_handoff(
plan: &CapacityImportPlanRecord,
source: &CapacityImportSourceRecord,
) -> bool {
!source.binding.controllers.contains(&plan.authority.root)
}
#[must_use]
pub fn expired_absence_proves_retirement(ingress_expiry: u64, certified_at_ns: u64) -> bool {
certified_at_ns
.checked_sub(ingress_expiry)
.is_some_and(|elapsed| elapsed > 0 && elapsed <= 300_000_000_000)
}
#[must_use]
pub fn controller_version_delta(
plan: &CapacityImportPlanRecord,
source: &CapacityImportSourceRecord,
) -> u64 {
1 + u64::from(requires_handoff(plan, source))
}
fn validate_source_lifecycle(
plan: &CapacityImportPlanRecord,
source: &CapacityImportSourceRecord,
) -> Result<(), CapacityImportPolicyError> {
let canister = source.binding.canister_id;
if source.binding.module_sha256.is_some()
&& !source.binding.stopped
&& requires_handoff(plan, source)
{
return Err(CapacityImportPolicyError::SourceRunning { canister });
}
if source
.binding
.canister_version
.checked_add(
controller_version_delta(plan, source) + if source.binding.stopped { 1 } else { 3 },
)
.is_none()
{
return Err(CapacityImportPolicyError::VersionExhausted { canister });
}
Ok(())
}
pub fn admit_handoffs(
plan: &CapacityImportPlanRecord,
destination: &CapacityImportDestinationView,
sources: &[CapacityImportSourceView],
) -> Result<(), CapacityImportPolicyError> {
validate_plan(plan)?;
if plan.authority != destination.authority
|| plan.root_budget.minimum_retained_cycles != destination.minimum_retained_cycles
{
return Err(CapacityImportPolicyError::DestinationChanged);
}
if !destination.ready || destination.draining || destination.competing_operation {
return Err(CapacityImportPolicyError::DestinationUnavailable);
}
let additional = u32::try_from(plan.sources.len())
.map_err(|_| CapacityImportPolicyError::CapacityExceeded)?;
if destination
.occupied_capacity
.checked_add(additional)
.is_none_or(|total| total > destination.maximum_capacity)
{
return Err(CapacityImportPolicyError::CapacityExceeded);
}
let root_debit = source_total(
plan.root_budget.observed_cycles,
plan.root_budget.observed_reserved_cycles,
)?
.saturating_sub(source_total(
destination.controlled_cycles,
destination.reserved_cycles,
)?);
if root_debit > plan.root_budget.maximum_debit_cycles {
return Err(CapacityImportPolicyError::InvalidCycleBounds);
}
require_headroom(
plan.authority.root,
destination.controlled_cycles,
destination.minimum_retained_cycles,
plan.root_budget.maximum_debit_cycles - root_debit,
)?;
if sources.len() != plan.sources.len() {
return Err(CapacityImportPolicyError::InvalidSources);
}
let mut seen = BTreeSet::new();
for observed in sources {
let canister = observed.binding.canister_id;
if !seen.insert(canister) {
return Err(CapacityImportPolicyError::InvalidSources);
}
let reviewed = plan
.sources
.iter()
.find(|source| source.binding.canister_id == canister)
.ok_or(CapacityImportPolicyError::SourceChanged { canister })?;
if destination.assigned_canisters.contains(&canister) {
return Err(CapacityImportPolicyError::SourceAssigned { canister });
}
admit_source(
reviewed,
observed,
!requires_handoff(plan, reviewed) && !reviewed.binding.stopped,
)?;
}
Ok(())
}
pub fn admit_source_handoff(
reviewed: &CapacityImportSourceRecord,
observed: &CapacityImportSourceView,
) -> Result<(), CapacityImportPolicyError> {
admit_source(reviewed, observed, false)
}
fn admit_source(
reviewed: &CapacityImportSourceRecord,
observed: &CapacityImportSourceView,
running_versions: bool,
) -> Result<(), CapacityImportPolicyError> {
let canister = reviewed.binding.canister_id;
let version_matches = if running_versions {
observed.binding.canister_version >= reviewed.binding.canister_version
} else {
observed.binding.canister_version == reviewed.binding.canister_version
};
if !version_matches || custody(&reviewed.binding) != custody(&observed.binding) {
return Err(CapacityImportPolicyError::SourceChanged { canister });
}
if observed.ownership != CapacityImportOwnershipView::Unassigned {
return Err(CapacityImportPolicyError::SourceAssigned { canister });
}
if observed.disposition_evidence_sha256 != Some(disposition_digest(reviewed)) {
return Err(CapacityImportPolicyError::DispositionUnresolved { canister });
}
retained_source_debit(reviewed, observed.cycles, observed.reserved_cycles)?;
Ok(())
}
#[derive(Eq, PartialEq)]
struct SourceCustody<'a> {
canister: Principal,
subnet: SubnetId,
controllers: &'a [Principal],
module: Option<[u8; 32]>,
stopped: bool,
snapshots: u64,
}
fn custody(
binding: &crate::fleet_ensure::model::capacity_import::CapacityImportSourceBinding,
) -> SourceCustody<'_> {
SourceCustody {
canister: binding.canister_id,
subnet: binding.subnet,
controllers: &binding.controllers,
module: binding.module_sha256,
stopped: binding.stopped,
snapshots: binding.snapshots_size_bytes,
}
}
pub fn retained_source_debit(
reviewed: &CapacityImportSourceRecord,
retained_cycles: u128,
retained_reserved_cycles: u128,
) -> Result<u128, CapacityImportPolicyError> {
let canister = reviewed.binding.canister_id;
let total_before = source_total(reviewed.observed_cycles, reviewed.observed_reserved_cycles)?;
let total_after = source_total(retained_cycles, retained_reserved_cycles)?;
let debit = total_before.saturating_sub(total_after);
if debit > reviewed.maximum_debit_cycles {
return Err(CapacityImportPolicyError::ConservationUnproven { canister });
}
if retained_cycles < reviewed.minimum_ready_cycles {
return Err(CapacityImportPolicyError::InsufficientCycles {
canister,
required_cycles: reviewed.minimum_ready_cycles,
available_cycles: retained_cycles,
shortfall_cycles: reviewed.minimum_ready_cycles - retained_cycles,
});
}
Ok(debit)
}
fn validate_authority(
authority: &CapacityImportAuthority,
) -> Result<(), CapacityImportPolicyError> {
let principals = [authority.operator, authority.coordinator, authority.root];
let principals_valid = valid_principal(authority.subnet.into_principal())
&& principals
.iter()
.all(|principal| valid_principal(*principal));
let roles_distinct = principals.iter().collect::<BTreeSet<_>>().len() == principals.len();
let digests_present =
authority.network_root_key_sha256 != [0; 32] && authority.root_authority_sha256 != [0; 32];
let recovery_valid = authority
.recovery_controllers
.windows(2)
.all(|pair| pair[0] < pair[1])
&& authority
.recovery_controllers
.iter()
.all(|principal| valid_principal(*principal));
if !principals_valid || !roles_distinct || !digests_present || !recovery_valid {
return Err(CapacityImportPolicyError::InvalidAuthority);
}
Ok(())
}
fn final_controllers(authority: &CapacityImportAuthority) -> BTreeSet<Principal> {
authority
.recovery_controllers
.iter()
.copied()
.chain([authority.root])
.collect()
}
pub(in crate::fleet_ensure) const fn disposition_digest(
source: &CapacityImportSourceRecord,
) -> [u8; 32] {
match source.disposition {
CapacityImportDisposition::AbsenceEvidence { evidence_sha256 }
| CapacityImportDisposition::Retired { evidence_sha256 } => evidence_sha256,
}
}
fn valid_principal(principal: Principal) -> bool {
principal != Principal::anonymous() && principal != Principal::management_canister()
}
fn canonical_controllers(controllers: &[Principal]) -> bool {
!controllers.is_empty()
&& controllers.len() <= 10
&& controllers
.iter()
.all(|principal| valid_principal(*principal))
&& controllers.windows(2).all(|pair| pair[0] < pair[1])
}
fn require_headroom(
canister: Principal,
cycles: u128,
minimum: u128,
maximum_debit: u128,
) -> Result<(), CapacityImportPolicyError> {
let required = minimum
.checked_add(maximum_debit)
.ok_or(CapacityImportPolicyError::InvalidCycleBounds)?;
if cycles < required {
return Err(CapacityImportPolicyError::InsufficientCycles {
canister,
required_cycles: required,
available_cycles: cycles,
shortfall_cycles: required - cycles,
});
}
Ok(())
}
pub fn source_total(
cycles: u128,
reserved_cycles: u128,
) -> Result<u128, CapacityImportPolicyError> {
cycles
.checked_add(reserved_cycles)
.ok_or(CapacityImportPolicyError::InvalidCycleBounds)
}