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#[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 #[must_use]
51 pub fn directory_canisters(&self) -> Vec<CanisterRole> {
52 self.subnet_directory.iter().cloned().collect()
53 }
54
55 #[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 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 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 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 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 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#[derive(Clone, Debug, Default, Deserialize, Serialize)]
181#[serde(deny_unknown_fields)]
182pub struct PoolImport {
183 #[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#[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#[derive(Clone, Debug, Deserialize, Serialize)]
209#[serde(deny_unknown_fields)]
210pub struct CanisterConfig {
211 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#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
239#[serde(rename_all = "lowercase")]
240pub enum CanisterCardinality {
241 Single,
242 Many,
243}
244
245#[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#[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#[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#[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#[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#[derive(Clone, Debug, Deserialize, Serialize)]
338#[serde(deny_unknown_fields, default)]
339pub struct ScalePoolPolicy {
340 pub min_workers: u32,
342
343 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#[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#[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#[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#[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}