canic/config/schema/
subnet.rs

1use crate::{
2    config::schema::{ConfigSchemaError, Validate},
3    types::{CanisterType, Cycles, TC},
4};
5use serde::{Deserialize, Serialize};
6use std::collections::{BTreeMap, BTreeSet};
7
8mod defaults {
9    use super::Cycles;
10
11    pub fn initial_cycles() -> Cycles {
12        Cycles::new(5_000_000_000_000)
13    }
14}
15
16///
17/// SubnetConfig
18///
19
20#[derive(Clone, Debug, Default, Deserialize, Serialize)]
21#[serde(deny_unknown_fields)]
22pub struct SubnetConfig {
23    #[serde(default)]
24    pub canisters: BTreeMap<CanisterType, CanisterConfig>,
25
26    #[serde(default)]
27    pub auto_create: BTreeSet<CanisterType>,
28
29    #[serde(default)]
30    pub subnet_directory: BTreeSet<CanisterType>,
31
32    #[serde(default)]
33    pub reserve: CanisterReserve,
34}
35
36impl SubnetConfig {
37    /// Returns the directory canisters for this subnet.
38    #[must_use]
39    pub fn directory_canisters(&self) -> Vec<CanisterType> {
40        self.subnet_directory.iter().cloned().collect()
41    }
42
43    /// Get a canister configuration by type.
44    #[must_use]
45    pub fn get_canister(&self, ty: &CanisterType) -> Option<CanisterConfig> {
46        self.canisters.get(ty).cloned()
47    }
48}
49
50impl Validate for SubnetConfig {
51    fn validate(&self) -> Result<(), ConfigSchemaError> {
52        // --- 1. Validate directory entries ---
53        for canister_ty in &self.subnet_directory {
54            if !self.canisters.contains_key(canister_ty) {
55                return Err(ConfigSchemaError::ValidationError(format!(
56                    "subnet directory canister '{canister_ty}' is not defined in subnet",
57                )));
58            }
59        }
60
61        // --- 2. Validate auto-create entries ---
62        for canister_ty in &self.auto_create {
63            if !self.canisters.contains_key(canister_ty) {
64                return Err(ConfigSchemaError::ValidationError(format!(
65                    "auto-create canister '{canister_ty}' is not defined in subnet",
66                )));
67            }
68        }
69
70        // --- 3. Validate canister configurations ---
71        for (parent_ty, cfg) in &self.canisters {
72            // Sharding pools
73            if let Some(sharding) = &cfg.sharding {
74                for (pool_name, pool) in &sharding.pools {
75                    if !self.canisters.contains_key(&pool.canister_type) {
76                        return Err(ConfigSchemaError::ValidationError(format!(
77                            "canister '{parent_ty}' sharding pool '{pool_name}' references unknown canister type '{ty}'",
78                            ty = pool.canister_type
79                        )));
80                    }
81
82                    if pool.policy.capacity == 0 {
83                        return Err(ConfigSchemaError::ValidationError(format!(
84                            "canister '{parent_ty}' sharding pool '{pool_name}' has zero capacity; must be > 0",
85                        )));
86                    }
87
88                    if pool.policy.max_shards == 0 {
89                        return Err(ConfigSchemaError::ValidationError(format!(
90                            "canister '{parent_ty}' sharding pool '{pool_name}' has max_shards of 0; must be > 0",
91                        )));
92                    }
93                }
94            }
95
96            // Scaling pools
97            if let Some(scaling) = &cfg.scaling {
98                for (pool_name, pool) in &scaling.pools {
99                    if !self.canisters.contains_key(&pool.canister_type) {
100                        return Err(ConfigSchemaError::ValidationError(format!(
101                            "canister '{parent_ty}' scaling pool '{pool_name}' references unknown canister type '{ty}'",
102                            ty = pool.canister_type
103                        )));
104                    }
105
106                    if pool.policy.max_workers != 0
107                        && pool.policy.max_workers < pool.policy.min_workers
108                    {
109                        return Err(ConfigSchemaError::ValidationError(format!(
110                            "canister '{parent_ty}' scaling pool '{pool_name}' has max_workers < min_workers (min {}, max {})",
111                            pool.policy.min_workers, pool.policy.max_workers
112                        )));
113                    }
114                }
115            }
116        }
117
118        Ok(())
119    }
120}
121
122///
123/// CanisterReserve
124/// defaults to a minimum size of 0
125///
126
127#[derive(Clone, Debug, Default, Deserialize, Serialize)]
128#[serde(deny_unknown_fields)]
129pub struct CanisterReserve {
130    pub minimum_size: u8,
131}
132
133///
134/// CanisterConfig
135///
136
137#[derive(Clone, Debug, Default, Deserialize, Serialize)]
138#[serde(deny_unknown_fields)]
139pub struct CanisterConfig {
140    #[serde(
141        default = "defaults::initial_cycles",
142        deserialize_with = "Cycles::from_config"
143    )]
144    pub initial_cycles: Cycles,
145
146    #[serde(default)]
147    pub topup: Option<CanisterTopup>,
148
149    #[serde(default)]
150    pub scaling: Option<ScalingConfig>,
151
152    #[serde(default)]
153    pub sharding: Option<ShardingConfig>,
154}
155
156///
157/// CanisterTopup
158///
159
160#[derive(Clone, Debug, Deserialize, Serialize)]
161#[serde(deny_unknown_fields)]
162pub struct CanisterTopup {
163    #[serde(default, deserialize_with = "Cycles::from_config")]
164    pub threshold: Cycles,
165
166    #[serde(default, deserialize_with = "Cycles::from_config")]
167    pub amount: Cycles,
168}
169
170impl Default for CanisterTopup {
171    fn default() -> Self {
172        Self {
173            threshold: Cycles::new(10 * TC),
174            amount: Cycles::new(5 * TC),
175        }
176    }
177}
178
179///
180/// ScalingConfig
181/// (stateless, scaling)
182///
183/// * Organizes canisters into **worker groups** (e.g. "oracle").
184/// * Workers are interchangeable and handle transient tasks (no tenant assignment).
185/// * Scaling is about throughput, not capacity.
186/// * Hence: `WorkerManager → pools → WorkerSpec → WorkerPolicy`.
187///
188
189#[derive(Clone, Debug, Default, Deserialize, Serialize)]
190#[serde(deny_unknown_fields)]
191pub struct ScalingConfig {
192    #[serde(default)]
193    pub pools: BTreeMap<String, ScalePool>,
194}
195
196///
197/// ScalePool
198/// One stateless worker group (e.g. "oracle").
199///
200
201#[derive(Clone, Debug, Deserialize, Serialize)]
202#[serde(deny_unknown_fields)]
203pub struct ScalePool {
204    pub canister_type: CanisterType,
205
206    #[serde(default)]
207    pub policy: ScalePoolPolicy,
208}
209
210///
211/// ScalePoolPolicy
212///
213
214#[derive(Clone, Debug, Deserialize, Serialize)]
215#[serde(deny_unknown_fields, default)]
216pub struct ScalePoolPolicy {
217    /// Minimum number of worker canisters to keep alive
218    pub min_workers: u32,
219
220    /// Maximum number of worker canisters to allow
221    pub max_workers: u32,
222}
223
224impl Default for ScalePoolPolicy {
225    fn default() -> Self {
226        Self {
227            min_workers: 1,
228            max_workers: 32,
229        }
230    }
231}
232
233///
234/// ShardingConfig
235/// (stateful, partitioned)
236///
237/// * Organizes canisters into named **pools**.
238/// * Each pool manages a set of **shards**, and each shard owns a partition of state.
239/// * Tenants are assigned to shards via HRW and stay there.
240/// * Hence: `ShardManager → pools → ShardPoolSpec → ShardPoolPolicy`.
241///
242
243#[derive(Clone, Debug, Default, Deserialize, Serialize)]
244#[serde(deny_unknown_fields)]
245pub struct ShardingConfig {
246    #[serde(default)]
247    pub pools: BTreeMap<String, ShardPool>,
248}
249
250///
251/// ShardPool
252///
253
254#[derive(Clone, Debug, Deserialize, Serialize)]
255#[serde(deny_unknown_fields)]
256pub struct ShardPool {
257    pub canister_type: CanisterType,
258
259    #[serde(default)]
260    pub policy: ShardPoolPolicy,
261}
262
263///
264/// ShardPoolPolicy
265///
266
267#[derive(Clone, Debug, Deserialize, Serialize)]
268#[serde(deny_unknown_fields, default)]
269pub struct ShardPoolPolicy {
270    pub capacity: u32,
271    pub max_shards: u32,
272}
273
274impl Default for ShardPoolPolicy {
275    fn default() -> Self {
276        Self {
277            capacity: 1_000,
278            max_shards: 4,
279        }
280    }
281}
282
283///
284/// TESTS
285///
286
287#[cfg(test)]
288mod tests {
289    use super::*;
290    use std::collections::{BTreeMap, BTreeSet};
291
292    #[test]
293    fn auto_create_entries_must_exist_in_subnet() {
294        let mut auto_create = BTreeSet::new();
295        auto_create.insert(CanisterType::from("missing_auto_canister"));
296
297        let subnet = SubnetConfig {
298            auto_create,
299            ..Default::default()
300        };
301
302        subnet
303            .validate()
304            .expect_err("expected missing auto-create type to fail");
305    }
306
307    #[test]
308    fn sharding_pool_references_must_exist_in_subnet() {
309        let managing_ty: CanisterType = "shard_hub".into();
310        let mut canisters = BTreeMap::new();
311
312        let mut sharding = ShardingConfig::default();
313        sharding.pools.insert(
314            "primary".into(),
315            ShardPool {
316                canister_type: CanisterType::from("missing_shard_worker"),
317                policy: ShardPoolPolicy::default(),
318            },
319        );
320
321        let manager_cfg = CanisterConfig {
322            sharding: Some(sharding),
323            ..Default::default()
324        };
325
326        canisters.insert(managing_ty, manager_cfg);
327
328        let subnet = SubnetConfig {
329            canisters,
330            ..Default::default()
331        };
332
333        subnet
334            .validate()
335            .expect_err("expected missing worker type to fail");
336    }
337
338    #[test]
339    fn sharding_pool_policy_requires_positive_capacity_and_shards() {
340        let managing_ty: CanisterType = "shard_hub".into();
341        let mut canisters = BTreeMap::new();
342
343        let mut sharding = ShardingConfig::default();
344        sharding.pools.insert(
345            "primary".into(),
346            ShardPool {
347                canister_type: managing_ty.clone(),
348                policy: ShardPoolPolicy {
349                    capacity: 0,
350                    max_shards: 0,
351                },
352            },
353        );
354
355        canisters.insert(
356            managing_ty,
357            CanisterConfig {
358                sharding: Some(sharding),
359                ..Default::default()
360            },
361        );
362
363        let subnet = SubnetConfig {
364            canisters,
365            ..Default::default()
366        };
367
368        subnet
369            .validate()
370            .expect_err("expected invalid sharding policy to fail");
371    }
372
373    #[test]
374    fn scaling_pool_policy_requires_max_ge_min_when_bounded() {
375        let mut canisters = BTreeMap::new();
376        let mut pools = BTreeMap::new();
377        pools.insert(
378            "worker".into(),
379            ScalePool {
380                canister_type: CanisterType::from("worker"),
381                policy: ScalePoolPolicy {
382                    min_workers: 5,
383                    max_workers: 3,
384                },
385            },
386        );
387
388        canisters.insert(CanisterType::from("worker"), CanisterConfig::default());
389
390        let manager_cfg = CanisterConfig {
391            scaling: Some(ScalingConfig { pools }),
392            ..Default::default()
393        };
394
395        canisters.insert(CanisterType::from("manager"), manager_cfg);
396
397        let subnet = SubnetConfig {
398            canisters,
399            ..Default::default()
400        };
401
402        subnet
403            .validate()
404            .expect_err("expected invalid scaling policy to fail");
405    }
406}