canic_core/config/schema/
subnet.rs

1use crate::{
2    cdk::types::{Cycles, TC},
3    config::schema::{ConfigSchemaError, NAME_MAX_BYTES, Validate},
4    ids::CanisterRole,
5};
6use candid::Principal;
7use serde::{Deserialize, Serialize};
8use std::collections::{BTreeMap, BTreeSet};
9
10mod defaults {
11    use super::Cycles;
12
13    pub const fn initial_cycles() -> Cycles {
14        Cycles::new(5_000_000_000_000)
15    }
16}
17
18fn validate_role_len(role: &CanisterRole, context: &str) -> Result<(), ConfigSchemaError> {
19    if role.as_ref().len() > NAME_MAX_BYTES {
20        return Err(ConfigSchemaError::ValidationError(format!(
21            "{context} '{role}' exceeds {NAME_MAX_BYTES} bytes",
22        )));
23    }
24
25    Ok(())
26}
27
28///
29/// SubnetConfig
30///
31
32#[derive(Clone, Debug, Default, Deserialize, Serialize)]
33#[serde(deny_unknown_fields)]
34pub struct SubnetConfig {
35    #[serde(default)]
36    pub canisters: BTreeMap<CanisterRole, CanisterConfig>,
37
38    #[serde(default)]
39    pub auto_create: BTreeSet<CanisterRole>,
40
41    #[serde(default)]
42    pub subnet_directory: BTreeSet<CanisterRole>,
43
44    #[serde(default)]
45    pub pool: CanisterPool,
46}
47
48impl SubnetConfig {
49    /// Returns the directory canisters for this subnet.
50    #[must_use]
51    pub fn directory_canisters(&self) -> Vec<CanisterRole> {
52        self.subnet_directory.iter().cloned().collect()
53    }
54
55    /// Get a canister configuration by role.
56    #[must_use]
57    pub fn get_canister(&self, role: &CanisterRole) -> Option<CanisterConfig> {
58        self.canisters.get(role).cloned()
59    }
60}
61
62impl Validate for SubnetConfig {
63    fn validate(&self) -> Result<(), ConfigSchemaError> {
64        // --- 1. Validate directory entries ---
65        for canister_role in &self.subnet_directory {
66            validate_role_len(canister_role, "subnet directory canister")?;
67            let canister_cfg = self.canisters.get(canister_role).ok_or_else(|| {
68                ConfigSchemaError::ValidationError(format!(
69                    "subnet directory canister '{canister_role}' is not defined in subnet",
70                ))
71            })?;
72
73            if canister_cfg.cardinality != CanisterCardinality::Single {
74                return Err(ConfigSchemaError::ValidationError(format!(
75                    "subnet directory canister '{canister_role}' must have cardinality = \"single\"",
76                )));
77            }
78        }
79
80        // --- 2. Validate auto-create entries ---
81        for canister_role in &self.auto_create {
82            validate_role_len(canister_role, "auto-create canister")?;
83            if !self.canisters.contains_key(canister_role) {
84                return Err(ConfigSchemaError::ValidationError(format!(
85                    "auto-create canister '{canister_role}' is not defined in subnet",
86                )));
87            }
88        }
89
90        // --- 3. Validate canister configurations ---
91        for (parent_role, cfg) in &self.canisters {
92            validate_role_len(parent_role, "canister")?;
93            if cfg.randomness.enabled && cfg.randomness.reseed_interval_secs == 0 {
94                return Err(ConfigSchemaError::ValidationError(format!(
95                    "canister '{parent_role}' randomness reseed_interval_secs must be > 0",
96                )));
97            }
98
99            // Sharding pools
100            if let Some(sharding) = &cfg.sharding {
101                for (pool_name, pool) in &sharding.pools {
102                    if pool_name.len() > NAME_MAX_BYTES {
103                        return Err(ConfigSchemaError::ValidationError(format!(
104                            "canister '{parent_role}' sharding pool '{pool_name}' name exceeds {NAME_MAX_BYTES} bytes",
105                        )));
106                    }
107
108                    if pool.canister_role.as_ref().len() > NAME_MAX_BYTES {
109                        return Err(ConfigSchemaError::ValidationError(format!(
110                            "canister '{parent_role}' sharding pool '{pool_name}' canister role '{role}' exceeds {NAME_MAX_BYTES} bytes",
111                            role = pool.canister_role
112                        )));
113                    }
114
115                    if !self.canisters.contains_key(&pool.canister_role) {
116                        return Err(ConfigSchemaError::ValidationError(format!(
117                            "canister '{parent_role}' sharding pool '{pool_name}' references unknown canister role '{role}'",
118                            role = pool.canister_role
119                        )));
120                    }
121
122                    if pool.policy.capacity == 0 {
123                        return Err(ConfigSchemaError::ValidationError(format!(
124                            "canister '{parent_role}' sharding pool '{pool_name}' has zero capacity; must be > 0",
125                        )));
126                    }
127
128                    if pool.policy.max_shards == 0 {
129                        return Err(ConfigSchemaError::ValidationError(format!(
130                            "canister '{parent_role}' sharding pool '{pool_name}' has max_shards of 0; must be > 0",
131                        )));
132                    }
133                }
134            }
135
136            // Scaling pools
137            if let Some(scaling) = &cfg.scaling {
138                for (pool_name, pool) in &scaling.pools {
139                    if pool_name.len() > NAME_MAX_BYTES {
140                        return Err(ConfigSchemaError::ValidationError(format!(
141                            "canister '{parent_role}' scaling pool '{pool_name}' name exceeds {NAME_MAX_BYTES} bytes",
142                        )));
143                    }
144
145                    if pool.canister_role.as_ref().len() > NAME_MAX_BYTES {
146                        return Err(ConfigSchemaError::ValidationError(format!(
147                            "canister '{parent_role}' scaling pool '{pool_name}' canister role '{role}' exceeds {NAME_MAX_BYTES} bytes",
148                            role = pool.canister_role
149                        )));
150                    }
151
152                    if !self.canisters.contains_key(&pool.canister_role) {
153                        return Err(ConfigSchemaError::ValidationError(format!(
154                            "canister '{parent_role}' scaling pool '{pool_name}' references unknown canister role '{role}'",
155                            role = pool.canister_role
156                        )));
157                    }
158
159                    if pool.policy.max_workers != 0
160                        && pool.policy.max_workers < pool.policy.min_workers
161                    {
162                        return Err(ConfigSchemaError::ValidationError(format!(
163                            "canister '{parent_role}' scaling pool '{pool_name}' has max_workers < min_workers (min {}, max {})",
164                            pool.policy.min_workers, pool.policy.max_workers
165                        )));
166                    }
167                }
168            }
169        }
170
171        Ok(())
172    }
173}
174
175///
176/// PoolImport
177/// Per-environment import lists for canister pools.
178///
179
180#[derive(Clone, Debug, Default, Deserialize, Serialize)]
181#[serde(deny_unknown_fields)]
182pub struct PoolImport {
183    /// Optional count of canisters to import immediately before queuing the rest.
184    #[serde(default)]
185    pub initial: Option<u16>,
186    #[serde(default)]
187    pub local: Vec<Principal>,
188    #[serde(default)]
189    pub ic: Vec<Principal>,
190}
191
192///
193/// CanisterPool
194/// defaults to a minimum size of 0
195///
196#[derive(Clone, Debug, Default, Deserialize, Serialize)]
197#[serde(deny_unknown_fields)]
198pub struct CanisterPool {
199    pub minimum_size: u8,
200    #[serde(default)]
201    pub import: PoolImport,
202}
203
204///
205/// CanisterConfig
206///
207
208#[derive(Clone, Debug, Deserialize, Serialize)]
209#[serde(deny_unknown_fields)]
210pub struct CanisterConfig {
211    /// Required cardinality for this canister role.
212    pub cardinality: CanisterCardinality,
213
214    #[serde(
215        default = "defaults::initial_cycles",
216        deserialize_with = "Cycles::from_config"
217    )]
218    pub initial_cycles: Cycles,
219
220    #[serde(default)]
221    pub topup: Option<CanisterTopup>,
222
223    #[serde(default)]
224    pub randomness: RandomnessConfig,
225
226    #[serde(default)]
227    pub scaling: Option<ScalingConfig>,
228
229    #[serde(default)]
230    pub sharding: Option<ShardingConfig>,
231}
232
233///
234/// CanisterCardinality
235/// Indicates whether a canister role may have one or many instances.
236///
237
238#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
239#[serde(rename_all = "lowercase")]
240pub enum CanisterCardinality {
241    Single,
242    Many,
243}
244
245///
246/// CanisterTopup
247///
248
249#[derive(Clone, Debug, Deserialize, Serialize)]
250#[serde(deny_unknown_fields)]
251pub struct CanisterTopup {
252    #[serde(default, deserialize_with = "Cycles::from_config")]
253    pub threshold: Cycles,
254
255    #[serde(default, deserialize_with = "Cycles::from_config")]
256    pub amount: Cycles,
257}
258
259impl Default for CanisterTopup {
260    fn default() -> Self {
261        Self {
262            threshold: Cycles::new(10 * TC),
263            amount: Cycles::new(5 * TC),
264        }
265    }
266}
267
268///
269/// RandomnessConfig
270///
271
272#[derive(Clone, Copy, Debug, Deserialize, Serialize)]
273#[serde(deny_unknown_fields, default)]
274pub struct RandomnessConfig {
275    pub enabled: bool,
276    pub reseed_interval_secs: u64,
277    pub source: RandomnessSource,
278}
279
280impl Default for RandomnessConfig {
281    fn default() -> Self {
282        Self {
283            enabled: true,
284            reseed_interval_secs: 3600,
285            source: RandomnessSource::Ic,
286        }
287    }
288}
289
290///
291/// RandomnessSource
292///
293
294#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
295#[serde(rename_all = "snake_case")]
296pub enum RandomnessSource {
297    #[default]
298    Ic,
299    Time,
300}
301
302///
303/// ScalingConfig
304/// (stateless, scaling)
305///
306/// * Organizes canisters into **worker groups** (e.g. "oracle").
307/// * Workers are interchangeable and handle transient tasks (no tenant assignment).
308/// * Scaling is about throughput, not capacity.
309/// * Hence: `WorkerManager → pools → WorkerSpec → WorkerPolicy`.
310///
311
312#[derive(Clone, Debug, Default, Deserialize, Serialize)]
313#[serde(deny_unknown_fields)]
314pub struct ScalingConfig {
315    #[serde(default)]
316    pub pools: BTreeMap<String, ScalePool>,
317}
318
319///
320/// ScalePool
321/// One stateless worker group (e.g. "oracle").
322///
323
324#[derive(Clone, Debug, Deserialize, Serialize)]
325#[serde(deny_unknown_fields)]
326pub struct ScalePool {
327    pub canister_role: CanisterRole,
328
329    #[serde(default)]
330    pub policy: ScalePoolPolicy,
331}
332
333///
334/// ScalePoolPolicy
335///
336
337#[derive(Clone, Debug, Deserialize, Serialize)]
338#[serde(deny_unknown_fields, default)]
339pub struct ScalePoolPolicy {
340    /// Minimum number of worker canisters to keep alive
341    pub min_workers: u32,
342
343    /// Maximum number of worker canisters to allow
344    pub max_workers: u32,
345}
346
347impl Default for ScalePoolPolicy {
348    fn default() -> Self {
349        Self {
350            min_workers: 1,
351            max_workers: 32,
352        }
353    }
354}
355
356///
357/// ShardingConfig
358/// (stateful, partitioned)
359///
360/// * Organizes canisters into named **pools**.
361/// * Each pool manages a set of **shards**, and each shard owns a partition of state.
362/// * Tenants are assigned to shards via HRW and stay there.
363/// * Hence: `ShardManager → pools → ShardPoolSpec → ShardPoolPolicy`.
364///
365
366#[derive(Clone, Debug, Default, Deserialize, Serialize)]
367#[serde(deny_unknown_fields)]
368pub struct ShardingConfig {
369    #[serde(default)]
370    pub pools: BTreeMap<String, ShardPool>,
371}
372
373///
374/// ShardPool
375///
376
377#[derive(Clone, Debug, Deserialize, Serialize)]
378#[serde(deny_unknown_fields)]
379pub struct ShardPool {
380    pub canister_role: CanisterRole,
381
382    #[serde(default)]
383    pub policy: ShardPoolPolicy,
384}
385
386///
387/// ShardPoolPolicy
388///
389
390#[derive(Clone, Debug, Deserialize, Serialize)]
391#[serde(deny_unknown_fields, default)]
392pub struct ShardPoolPolicy {
393    pub capacity: u32,
394    pub max_shards: u32,
395}
396
397impl Default for ShardPoolPolicy {
398    fn default() -> Self {
399        Self {
400            capacity: 1_000,
401            max_shards: 4,
402        }
403    }
404}
405
406///
407/// TESTS
408///
409
410#[cfg(test)]
411mod tests {
412    use super::*;
413    use std::collections::{BTreeMap, BTreeSet};
414
415    fn base_canister_config(cardinality: CanisterCardinality) -> CanisterConfig {
416        CanisterConfig {
417            cardinality,
418            initial_cycles: defaults::initial_cycles(),
419            topup: None,
420            randomness: RandomnessConfig::default(),
421            scaling: None,
422            sharding: None,
423        }
424    }
425
426    #[test]
427    fn randomness_defaults_to_ic() {
428        let cfg = RandomnessConfig::default();
429
430        assert!(cfg.enabled);
431        assert_eq!(cfg.reseed_interval_secs, 3600);
432        assert_eq!(cfg.source, RandomnessSource::Ic);
433    }
434
435    #[test]
436    fn randomness_source_parses_ic_and_time() {
437        let cfg: RandomnessConfig = toml::from_str("source = \"ic\"").unwrap();
438        assert_eq!(cfg.source, RandomnessSource::Ic);
439
440        let cfg: RandomnessConfig = toml::from_str("source = \"time\"").unwrap();
441        assert_eq!(cfg.source, RandomnessSource::Time);
442    }
443
444    #[test]
445    fn auto_create_entries_must_exist_in_subnet() {
446        let mut auto_create = BTreeSet::new();
447        auto_create.insert(CanisterRole::from("missing_auto_canister"));
448
449        let subnet = SubnetConfig {
450            auto_create,
451            ..Default::default()
452        };
453
454        subnet
455            .validate()
456            .expect_err("expected missing auto-create role to fail");
457    }
458
459    #[test]
460    fn sharding_pool_references_must_exist_in_subnet() {
461        let managing_role: CanisterRole = "shard_hub".into();
462        let mut canisters = BTreeMap::new();
463
464        let mut sharding = ShardingConfig::default();
465        sharding.pools.insert(
466            "primary".into(),
467            ShardPool {
468                canister_role: CanisterRole::from("missing_shard_worker"),
469                policy: ShardPoolPolicy::default(),
470            },
471        );
472
473        let manager_cfg = CanisterConfig {
474            sharding: Some(sharding),
475            ..base_canister_config(CanisterCardinality::Single)
476        };
477
478        canisters.insert(managing_role, manager_cfg);
479
480        let subnet = SubnetConfig {
481            canisters,
482            ..Default::default()
483        };
484
485        subnet
486            .validate()
487            .expect_err("expected missing worker role to fail");
488    }
489
490    #[test]
491    fn sharding_pool_policy_requires_positive_capacity_and_shards() {
492        let managing_role: CanisterRole = "shard_hub".into();
493        let mut canisters = BTreeMap::new();
494
495        let mut sharding = ShardingConfig::default();
496        sharding.pools.insert(
497            "primary".into(),
498            ShardPool {
499                canister_role: managing_role.clone(),
500                policy: ShardPoolPolicy {
501                    capacity: 0,
502                    max_shards: 0,
503                },
504            },
505        );
506
507        canisters.insert(
508            managing_role,
509            CanisterConfig {
510                sharding: Some(sharding),
511                ..base_canister_config(CanisterCardinality::Single)
512            },
513        );
514
515        let subnet = SubnetConfig {
516            canisters,
517            ..Default::default()
518        };
519
520        subnet
521            .validate()
522            .expect_err("expected invalid sharding policy to fail");
523    }
524
525    #[test]
526    fn canister_role_name_must_fit_bound() {
527        let long_role = "a".repeat(NAME_MAX_BYTES + 1);
528        let mut canisters = BTreeMap::new();
529        canisters.insert(
530            CanisterRole::from(long_role),
531            base_canister_config(CanisterCardinality::Single),
532        );
533
534        let subnet = SubnetConfig {
535            canisters,
536            ..Default::default()
537        };
538
539        subnet
540            .validate()
541            .expect_err("expected canister role length to fail");
542    }
543
544    #[test]
545    fn sharding_pool_name_must_fit_bound() {
546        let managing_role: CanisterRole = "shard_hub".into();
547        let mut canisters = BTreeMap::new();
548
549        let mut sharding = ShardingConfig::default();
550        sharding.pools.insert(
551            "a".repeat(NAME_MAX_BYTES + 1),
552            ShardPool {
553                canister_role: managing_role.clone(),
554                policy: ShardPoolPolicy::default(),
555            },
556        );
557
558        canisters.insert(
559            managing_role,
560            CanisterConfig {
561                sharding: Some(sharding),
562                ..base_canister_config(CanisterCardinality::Single)
563            },
564        );
565
566        let subnet = SubnetConfig {
567            canisters,
568            ..Default::default()
569        };
570
571        subnet
572            .validate()
573            .expect_err("expected sharding pool name length to fail");
574    }
575
576    #[test]
577    fn scaling_pool_policy_requires_max_ge_min_when_bounded() {
578        let mut canisters = BTreeMap::new();
579        let mut pools = BTreeMap::new();
580        pools.insert(
581            "worker".into(),
582            ScalePool {
583                canister_role: CanisterRole::from("worker"),
584                policy: ScalePoolPolicy {
585                    min_workers: 5,
586                    max_workers: 3,
587                },
588            },
589        );
590
591        canisters.insert(
592            CanisterRole::from("worker"),
593            base_canister_config(CanisterCardinality::Single),
594        );
595
596        let manager_cfg = CanisterConfig {
597            scaling: Some(ScalingConfig { pools }),
598            ..base_canister_config(CanisterCardinality::Single)
599        };
600
601        canisters.insert(CanisterRole::from("manager"), manager_cfg);
602
603        let subnet = SubnetConfig {
604            canisters,
605            ..Default::default()
606        };
607
608        subnet
609            .validate()
610            .expect_err("expected invalid scaling policy to fail");
611    }
612
613    #[test]
614    fn scaling_pool_name_must_fit_bound() {
615        let mut canisters = BTreeMap::new();
616        let mut pools = BTreeMap::new();
617        pools.insert(
618            "a".repeat(NAME_MAX_BYTES + 1),
619            ScalePool {
620                canister_role: CanisterRole::from("worker"),
621                policy: ScalePoolPolicy::default(),
622            },
623        );
624
625        canisters.insert(
626            CanisterRole::from("worker"),
627            base_canister_config(CanisterCardinality::Single),
628        );
629
630        let manager_cfg = CanisterConfig {
631            scaling: Some(ScalingConfig { pools }),
632            ..base_canister_config(CanisterCardinality::Single)
633        };
634
635        canisters.insert(CanisterRole::from("manager"), manager_cfg);
636
637        let subnet = SubnetConfig {
638            canisters,
639            ..Default::default()
640        };
641
642        subnet
643            .validate()
644            .expect_err("expected scaling pool name length to fail");
645    }
646
647    #[test]
648    fn randomness_interval_requires_positive_value() {
649        let mut canisters = BTreeMap::new();
650
651        let cfg = CanisterConfig {
652            randomness: RandomnessConfig {
653                enabled: true,
654                reseed_interval_secs: 0,
655                ..Default::default()
656            },
657            ..base_canister_config(CanisterCardinality::Single)
658        };
659
660        canisters.insert(CanisterRole::from("app"), cfg);
661
662        let subnet = SubnetConfig {
663            canisters,
664            ..Default::default()
665        };
666
667        subnet
668            .validate()
669            .expect_err("expected invalid randomness interval to fail");
670    }
671}