canic-core 0.100.2

Canic — a canister orchestration and management toolkit for the Internet Computer
Documentation
//! Module: config::validation::component_spec
//!
//! Responsibility: validate Component Spec topology, placement, and refill configuration.
//! Does not own: topology workflow, placement policy execution, or schema definitions.
//! Boundary: config validation calls this before runtime installation.

use crate::{
    config::schema::{
        ComponentChildConfig, ComponentChildKind, ComponentSpecConfig, ConfigSchemaError,
        CyclesFundingPolicyConfig, MAX_COMPONENT_CHILD_ROLES, NAME_MAX_BYTES, TopupPolicy,
        Validate,
    },
    config::validation::validate_canister_role,
    ids::CanisterRole,
};
use std::collections::BTreeMap;

impl Validate for ComponentSpecConfig {
    fn validate(&self) -> Result<(), ConfigSchemaError> {
        validate_canister_role(&self.component_role, "Component role")?;
        if self.component_role.is_root() || self.component_role.is_wasm_store() {
            return Err(ConfigSchemaError::ValidationError(format!(
                "Component role '{}' is reserved infrastructure",
                self.component_role,
            )));
        }
        if self.maximum_instances == 0 {
            return Err(ConfigSchemaError::ValidationError(format!(
                "Component '{}' maximum_instances must be > 0",
                self.component_role,
            )));
        }
        if self.children.len() > MAX_COMPONENT_CHILD_ROLES {
            return Err(ConfigSchemaError::ValidationError(format!(
                "Component '{}' declares {} child roles, exceeding bound {MAX_COMPONENT_CHILD_ROLES}",
                self.component_role,
                self.children.len(),
            )));
        }
        if !self.children.is_empty() && self.limits.maximum_children == 0 {
            return Err(ConfigSchemaError::ValidationError(format!(
                "Component '{}' limits.maximum_children must be > 0 when children are declared",
                self.component_role,
            )));
        }
        if self.limits.maximum_registry_bytes == 0 {
            return Err(ConfigSchemaError::ValidationError(format!(
                "Component '{}' limits.maximum_registry_bytes must be > 0",
                self.component_role,
            )));
        }
        if self.limits.cycles_funding.window_secs == 0 {
            return Err(ConfigSchemaError::ValidationError(format!(
                "Component '{}' limits.cycles_funding.window_secs must be > 0",
                self.component_role,
            )));
        }
        if self.limits.cycles_funding.maximum_cycles.to_u128() == 0 {
            return Err(ConfigSchemaError::ValidationError(format!(
                "Component '{}' limits.cycles_funding.maximum_cycles must be > 0",
                self.component_role,
            )));
        }

        validate_cycles_funding(&self.cycles_funding, &self.component_role)?;
        validate_topup(self.topup.as_ref(), &self.component_role)?;

        for (role, child) in &self.children {
            validate_canister_role(role, "Component Child")?;
            if role == &self.component_role {
                return Err(ConfigSchemaError::ValidationError(format!(
                    "Component '{}' cannot also be its own child",
                    self.component_role,
                )));
            }
            if role.is_root() || role.is_wasm_store() {
                return Err(ConfigSchemaError::ValidationError(format!(
                    "Component Child '{role}' is reserved infrastructure",
                )));
            }
            if child.maximum_instances == 0 {
                return Err(ConfigSchemaError::ValidationError(format!(
                    "Component Child '{role}' maximum_instances must be > 0",
                )));
            }
            if child.kind == ComponentChildKind::Singleton && child.maximum_instances != 1 {
                return Err(ConfigSchemaError::ValidationError(format!(
                    "singleton Component Child '{role}' maximum_instances must equal 1",
                )));
            }
            validate_cycles_funding(&child.cycles_funding, role)?;
            validate_topup(child.topup.as_ref(), role)?;
        }

        validate_scaling(self, &self.component_role, &self.children)?;
        validate_sharding(self, &self.component_role, &self.children)?;
        validate_binding(self, &self.component_role, &self.children)?;

        Ok(())
    }
}

fn validate_cycles_funding(
    policy: &CyclesFundingPolicyConfig,
    canister: &CanisterRole,
) -> Result<(), ConfigSchemaError> {
    let max_per_request = policy.max_per_request.to_u128();
    let max_per_child = policy.max_per_child.to_u128();

    if max_per_request == 0 {
        return Err(ConfigSchemaError::ValidationError(format!(
            "canister '{canister}' cycles_funding.max_per_request must be > 0",
        )));
    }

    if max_per_child == 0 {
        return Err(ConfigSchemaError::ValidationError(format!(
            "canister '{canister}' cycles_funding.max_per_child must be > 0",
        )));
    }

    if policy.cooldown_secs == 0 {
        return Err(ConfigSchemaError::ValidationError(format!(
            "canister '{canister}' cycles_funding.cooldown_secs must be > 0",
        )));
    }

    if max_per_request > max_per_child {
        return Err(ConfigSchemaError::ValidationError(format!(
            "canister '{canister}' cycles_funding.max_per_request must be <= cycles_funding.max_per_child",
        )));
    }

    Ok(())
}

fn validate_topup(
    topup: Option<&TopupPolicy>,
    canister: &CanisterRole,
) -> Result<(), ConfigSchemaError> {
    let Some(topup) = topup else {
        return Ok(());
    };

    let threshold = topup.threshold.to_u128();
    let amount = topup.amount.to_u128();

    if amount.saturating_mul(2) > threshold {
        return Err(ConfigSchemaError::ValidationError(format!(
            "canister '{canister}' topup.amount must be <= 50% of topup.threshold (got amount={amount}, threshold={threshold})",
        )));
    }

    Ok(())
}

fn validate_sharding(
    cfg: &ComponentSpecConfig,
    role: &CanisterRole,
    children: &BTreeMap<CanisterRole, ComponentChildConfig>,
) -> Result<(), ConfigSchemaError> {
    let Some(sharding) = &cfg.sharding else {
        return Ok(());
    };

    for (pool_name, pool) in &sharding.pools {
        if pool_name.len() > NAME_MAX_BYTES {
            return Err(ConfigSchemaError::ValidationError(format!(
                "canister '{role}' sharding pool '{pool_name}' name exceeds {NAME_MAX_BYTES} bytes",
            )));
        }

        if !children.contains_key(&pool.canister_role) {
            return Err(ConfigSchemaError::ValidationError(format!(
                "Component '{role}' sharding pool '{pool_name}' references undeclared direct child '{}'",
                pool.canister_role
            )));
        }

        let target = &children[&pool.canister_role];
        if target.kind != ComponentChildKind::Shard {
            return Err(ConfigSchemaError::ValidationError(format!(
                "Component '{role}' sharding pool '{pool_name}' references child '{}' which is not kind = \"shard\"",
                pool.canister_role
            )));
        }

        if pool.policy.capacity == 0 || pool.policy.max_shards == 0 {
            return Err(ConfigSchemaError::ValidationError(format!(
                "canister '{role}' sharding pool '{pool_name}' must have positive capacity and max_shards",
            )));
        }

        if pool.policy.initial_shards > pool.policy.max_shards {
            return Err(ConfigSchemaError::ValidationError(format!(
                "canister '{role}' sharding pool '{pool_name}' has initial_shards > max_shards",
            )));
        }

        if pool.policy.max_shards > target.maximum_instances {
            return Err(ConfigSchemaError::ValidationError(format!(
                "Component '{role}' sharding pool '{pool_name}' max_shards exceeds child '{}' maximum_instances",
                pool.canister_role,
            )));
        }
    }

    Ok(())
}

fn validate_scaling(
    cfg: &ComponentSpecConfig,
    role: &CanisterRole,
    children: &BTreeMap<CanisterRole, ComponentChildConfig>,
) -> Result<(), ConfigSchemaError> {
    let Some(scaling) = &cfg.scaling else {
        return Ok(());
    };

    for (pool_name, pool) in &scaling.pools {
        if pool_name.len() > NAME_MAX_BYTES {
            return Err(ConfigSchemaError::ValidationError(format!(
                "canister '{role}' scaling pool '{pool_name}' name exceeds {NAME_MAX_BYTES} bytes",
            )));
        }

        if !children.contains_key(&pool.canister_role) {
            return Err(ConfigSchemaError::ValidationError(format!(
                "Component '{role}' scaling pool '{pool_name}' references undeclared direct child '{}'",
                pool.canister_role
            )));
        }

        let target = &children[&pool.canister_role];
        if target.kind != ComponentChildKind::Replica {
            return Err(ConfigSchemaError::ValidationError(format!(
                "Component '{role}' scaling pool '{pool_name}' references child '{}' which is not kind = \"replica\"",
                pool.canister_role
            )));
        }

        if pool.policy.max_workers != 0 && pool.policy.max_workers < pool.policy.min_workers {
            return Err(ConfigSchemaError::ValidationError(format!(
                "canister '{role}' scaling pool '{pool_name}' has max_workers < min_workers",
            )));
        }

        if pool.policy.max_workers != 0 && pool.policy.max_workers < pool.policy.initial_workers {
            return Err(ConfigSchemaError::ValidationError(format!(
                "canister '{role}' scaling pool '{pool_name}' has max_workers < initial_workers",
            )));
        }

        if pool.policy.max_workers == 0 {
            return Err(ConfigSchemaError::ValidationError(format!(
                "Component '{role}' scaling pool '{pool_name}' max_workers must be > 0",
            )));
        }

        if pool.policy.max_workers > target.maximum_instances {
            return Err(ConfigSchemaError::ValidationError(format!(
                "Component '{role}' scaling pool '{pool_name}' max_workers exceeds child '{}' maximum_instances",
                pool.canister_role,
            )));
        }
    }

    Ok(())
}

fn validate_binding(
    cfg: &ComponentSpecConfig,
    role: &CanisterRole,
    children: &BTreeMap<CanisterRole, ComponentChildConfig>,
) -> Result<(), ConfigSchemaError> {
    let Some(binding) = &cfg.binding else {
        return Ok(());
    };

    for (pool_name, pool) in &binding.pools {
        if pool_name.len() > NAME_MAX_BYTES {
            return Err(ConfigSchemaError::ValidationError(format!(
                "canister '{role}' binding pool '{pool_name}' name exceeds {NAME_MAX_BYTES} bytes",
            )));
        }

        if pool.key_name.is_empty() {
            return Err(ConfigSchemaError::ValidationError(format!(
                "canister '{role}' binding pool '{pool_name}' must define a non-empty key_name",
            )));
        }

        if pool.key_name.len() > NAME_MAX_BYTES {
            return Err(ConfigSchemaError::ValidationError(format!(
                "canister '{role}' binding pool '{pool_name}' key_name '{}' exceeds {NAME_MAX_BYTES} bytes",
                pool.key_name
            )));
        }

        if !children.contains_key(&pool.canister_role) {
            return Err(ConfigSchemaError::ValidationError(format!(
                "Component '{role}' binding pool '{pool_name}' references undeclared direct child '{}'",
                pool.canister_role
            )));
        }

        let target = &children[&pool.canister_role];
        if target.kind != ComponentChildKind::Instance {
            return Err(ConfigSchemaError::ValidationError(format!(
                "Component '{role}' binding pool '{pool_name}' references child '{}' which is not kind = \"instance\"",
                pool.canister_role
            )));
        }
    }

    Ok(())
}