Skip to main content

canic_host/
icp_config.rs

1use crate::{
2    install_root::{
3        current_canic_project_root, discover_project_canic_config_choices, project_fleet_roots,
4    },
5    release_set::{configured_fleet_name, configured_fleet_roles, icp_root},
6    workspace_discovery::discover_icp_root_from,
7};
8use std::{
9    collections::{BTreeMap, BTreeSet},
10    error::Error,
11    fmt, fs,
12    path::{Path, PathBuf},
13};
14
15const ICP_CONFIG_FILE: &str = "icp.yaml";
16pub const DEFAULT_LOCAL_GATEWAY_PORT: u16 = 8000;
17
18///
19/// IcpConfigError
20///
21
22#[derive(Debug)]
23pub enum IcpConfigError {
24    NoIcpRoot { start: PathBuf },
25    Config(String),
26    Io(std::io::Error),
27}
28
29impl fmt::Display for IcpConfigError {
30    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
31        match self {
32            Self::NoIcpRoot { start } => {
33                write!(
34                    formatter,
35                    "could not find icp.yaml from {}",
36                    start.display()
37                )
38            }
39            Self::Config(message) => write!(formatter, "{message}"),
40            Self::Io(err) => write!(formatter, "{err}"),
41        }
42    }
43}
44
45impl Error for IcpConfigError {
46    fn source(&self) -> Option<&(dyn Error + 'static)> {
47        match self {
48            Self::Io(err) => Some(err),
49            Self::Config(_) | Self::NoIcpRoot { .. } => None,
50        }
51    }
52}
53
54impl From<std::io::Error> for IcpConfigError {
55    fn from(err: std::io::Error) -> Self {
56        Self::Io(err)
57    }
58}
59
60///
61/// IcpProjectConfigReport
62///
63
64#[derive(Clone, Debug, Eq, PartialEq)]
65pub struct IcpProjectConfigReport {
66    pub path: PathBuf,
67    pub icp_root: PathBuf,
68    pub icp_yaml_present: bool,
69    pub canisters: Vec<String>,
70    pub environments: Vec<String>,
71    pub missing_canisters: Vec<String>,
72    pub missing_environments: Vec<String>,
73    pub local_network_present: bool,
74}
75
76impl IcpProjectConfigReport {
77    #[must_use]
78    pub const fn is_ready(&self) -> bool {
79        self.icp_yaml_present
80            && self.local_network_present
81            && self.missing_canisters.is_empty()
82            && self.missing_environments.is_empty()
83    }
84
85    #[must_use]
86    pub fn issues(&self) -> Vec<String> {
87        let mut issues = Vec::new();
88        if !self.icp_yaml_present {
89            issues.push(format!("missing {}", self.path.display()));
90        }
91        if !self.local_network_present {
92            issues.push("missing local network entry".to_string());
93        }
94        if !self.missing_canisters.is_empty() {
95            issues.push(format!(
96                "missing canisters: {}",
97                self.missing_canisters.join(", ")
98            ));
99        }
100        if !self.missing_environments.is_empty() {
101            issues.push(format!(
102                "missing environments: {}",
103                self.missing_environments.join(", ")
104            ));
105        }
106        issues
107    }
108}
109
110/// Return the configured local ICP gateway port, falling back to ICP's default.
111pub fn configured_local_gateway_port() -> Result<u16, IcpConfigError> {
112    let root = current_icp_root()?;
113    configured_local_gateway_port_from_root(&root)
114}
115
116/// Return the configured local ICP gateway port for one ICP project root.
117pub fn configured_local_gateway_port_from_root(root: &Path) -> Result<u16, IcpConfigError> {
118    let source = fs::read_to_string(root.join(ICP_CONFIG_FILE))?;
119    Ok(local_gateway_port_from_yaml(&source))
120}
121
122/// Inspect whether `icp.yaml` contains the entries implied by Canic fleet configs.
123pub fn inspect_canic_icp_yaml(
124    fleet_filter: Option<&str>,
125) -> Result<IcpProjectConfigReport, IcpConfigError> {
126    let root = resolve_current_canic_icp_root()?;
127    inspect_canic_icp_yaml_from_root(&root, fleet_filter)
128}
129
130/// Inspect one ICP project root without mutating its `icp.yaml`.
131pub fn inspect_canic_icp_yaml_from_root(
132    root: &Path,
133    fleet_filter: Option<&str>,
134) -> Result<IcpProjectConfigReport, IcpConfigError> {
135    let path = root.join(ICP_CONFIG_FILE);
136    let (source, icp_yaml_present) = read_optional_icp_yaml(&path)?;
137    let spec = discover_project_spec(root, fleet_filter)?;
138    let configured_canisters = top_level_named_items(&source, "canisters:");
139    let configured_environments = top_level_named_items(&source, "environments:");
140    let lines = source.lines().collect::<Vec<_>>();
141    let local_network_present = local_network_block(&lines).is_some();
142
143    let missing_canisters = spec
144        .canisters
145        .iter()
146        .filter(|name| !configured_canisters.contains(*name))
147        .cloned()
148        .collect::<Vec<_>>();
149    let missing_environments = spec
150        .environments
151        .keys()
152        .filter(|name| !configured_environments.contains(*name))
153        .cloned()
154        .collect::<Vec<_>>();
155
156    Ok(IcpProjectConfigReport {
157        path,
158        icp_root: root.to_path_buf(),
159        icp_yaml_present,
160        canisters: spec.canisters,
161        environments: spec.environments.into_keys().collect(),
162        missing_canisters,
163        missing_environments,
164        local_network_present,
165    })
166}
167
168fn read_optional_icp_yaml(path: &Path) -> Result<(String, bool), IcpConfigError> {
169    match fs::read_to_string(path) {
170        Ok(source) => Ok((source, true)),
171        Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok((String::new(), false)),
172        Err(err) => Err(err.into()),
173    }
174}
175
176fn current_icp_root() -> Result<PathBuf, IcpConfigError> {
177    let start = std::env::current_dir()?;
178    discover_icp_root_from(&start).ok_or(IcpConfigError::NoIcpRoot { start })
179}
180
181/// Resolve the ICP project root implied by the current Canic fleet layout.
182pub fn resolve_current_canic_icp_root() -> Result<PathBuf, IcpConfigError> {
183    if let Ok(path) = std::env::var("CANIC_ICP_ROOT") {
184        return PathBuf::from(path)
185            .canonicalize()
186            .map_err(IcpConfigError::from);
187    }
188
189    let search_root = current_project_search_root()?;
190    let choices = discover_project_canic_config_choices(&search_root)
191        .map_err(|err| IcpConfigError::Config(err.to_string()))?;
192    if !choices.is_empty() {
193        return Ok(search_root);
194    }
195
196    current_icp_root().or_else(|_| {
197        icp_root()
198            .map_err(|err| IcpConfigError::Config(err.to_string()))
199            .and_then(|path| path.canonicalize().map_err(IcpConfigError::from))
200    })
201}
202
203fn current_project_search_root() -> Result<PathBuf, IcpConfigError> {
204    let root = current_canic_project_root()
205        .map_err(|err| IcpConfigError::Config(err.to_string()))?
206        .canonicalize()?;
207    if !discover_project_canic_config_choices(&root)
208        .map_err(|err| IcpConfigError::Config(err.to_string()))?
209        .is_empty()
210    {
211        return Ok(root);
212    }
213
214    if let Ok(root) = icp_root() {
215        return Ok(root);
216    }
217    Ok(std::env::current_dir()?.canonicalize()?)
218}
219
220///
221/// CanicIcpSpec
222///
223
224#[derive(Clone, Debug, Eq, PartialEq)]
225struct CanicIcpSpec {
226    canisters: Vec<String>,
227    environments: BTreeMap<String, Vec<String>>,
228}
229
230fn discover_project_spec(
231    root: &Path,
232    fleet_filter: Option<&str>,
233) -> Result<CanicIcpSpec, IcpConfigError> {
234    let choices = discover_project_canic_config_choices(root)
235        .map_err(|err| IcpConfigError::Config(err.to_string()))?;
236    if choices.is_empty() {
237        return Err(IcpConfigError::Config(format!(
238            "no Canic fleet configs found under {}\nCreate fleets/<fleet>/canic.toml, then add matching entries to icp.yaml and rerun `canic status`.",
239            display_project_fleet_roots(root)
240        )));
241    }
242
243    let mut canisters = Vec::<String>::new();
244    let mut seen_canisters = BTreeSet::<String>::new();
245    let mut environments = BTreeMap::<String, Vec<String>>::new();
246    let mut matched_filter = fleet_filter.is_none();
247
248    for config_path in choices {
249        let fleet = configured_fleet_name(&config_path)
250            .map_err(|err| IcpConfigError::Config(err.to_string()))?;
251        if let Some(filter) = fleet_filter {
252            if filter != fleet {
253                continue;
254            }
255            matched_filter = true;
256        }
257
258        let roles = configured_fleet_roles(&config_path)
259            .map_err(|err| IcpConfigError::Config(err.to_string()))?;
260        for role in &roles {
261            if seen_canisters.insert(role.clone()) {
262                canisters.push(role.clone());
263            }
264        }
265        environments.insert(fleet, roles);
266    }
267
268    if let Some(fleet) = fleet_filter
269        && !matched_filter
270    {
271        return Err(IcpConfigError::Config(format!(
272            "no Canic fleet config found for {fleet}\nExpected a config under {} with `[fleet].name = \"{fleet}\"`.",
273            display_project_fleet_roots(root)
274        )));
275    }
276
277    Ok(CanicIcpSpec {
278        canisters,
279        environments,
280    })
281}
282
283fn display_project_fleet_roots(root: &Path) -> String {
284    project_fleet_roots(root)
285        .into_iter()
286        .map(|path| path.display().to_string())
287        .collect::<Vec<_>>()
288        .join(" or ")
289}
290
291fn top_level_section(lines: &[&str], header: &str) -> Option<(usize, usize)> {
292    let start = lines
293        .iter()
294        .position(|line| line_indent(line) == 0 && line.trim() == header)?;
295    let end = lines
296        .iter()
297        .enumerate()
298        .skip(start + 1)
299        .find(|(_, line)| {
300            !line.trim().is_empty() && line_indent(line) == 0 && !line.trim_start().starts_with('#')
301        })
302        .map_or(lines.len(), |(index, _)| index);
303    Some((start, end))
304}
305
306fn local_gateway_port_from_yaml(source: &str) -> u16 {
307    let lines = source.lines().collect::<Vec<_>>();
308    let Some((start, end)) = local_network_block(&lines) else {
309        return DEFAULT_LOCAL_GATEWAY_PORT;
310    };
311
312    lines[start..end]
313        .iter()
314        .find_map(|line| {
315            line.trim()
316                .strip_prefix("port:")
317                .and_then(|value| value.trim().parse::<u16>().ok())
318        })
319        .unwrap_or(DEFAULT_LOCAL_GATEWAY_PORT)
320}
321
322fn local_network_block(lines: &[&str]) -> Option<(usize, usize)> {
323    let (section_start, section_end) = top_level_section(lines, "networks:")?;
324    let start = lines[section_start + 1..section_end]
325        .iter()
326        .position(|line| line_indent(line) == 2 && line.trim() == "- name: local")?
327        + section_start
328        + 1;
329    let end = lines[start + 1..section_end]
330        .iter()
331        .position(|line| line_indent(line) == 2 && line.trim_start().starts_with("- name:"))
332        .map_or(section_end, |offset| start + 1 + offset);
333    Some((start, end))
334}
335
336fn top_level_named_items(source: &str, header: &str) -> BTreeSet<String> {
337    let lines = source.lines().collect::<Vec<_>>();
338    let Some((start, end)) = top_level_section(&lines, header) else {
339        return BTreeSet::new();
340    };
341
342    lines[start + 1..end]
343        .iter()
344        .filter_map(|line| {
345            if line_indent(line) != 2 {
346                return None;
347            }
348            line.trim()
349                .strip_prefix("- name:")
350                .map(trim_yaml_scalar)
351                .filter(|name| !name.is_empty())
352                .map(str::to_string)
353        })
354        .collect()
355}
356
357fn trim_yaml_scalar(value: &str) -> &str {
358    value.trim().trim_matches('"').trim_matches('\'')
359}
360
361fn line_indent(line: &str) -> usize {
362    line.chars().take_while(|c| *c == ' ').count()
363}
364
365#[cfg(test)]
366mod tests {
367    use super::*;
368    use crate::test_support::temp_dir;
369    use std::fmt::Write as _;
370    use std::fs;
371
372    #[test]
373    fn defaults_local_gateway_port_without_network_config() {
374        let source = "canisters: []\n";
375
376        assert_eq!(
377            local_gateway_port_from_yaml(source),
378            DEFAULT_LOCAL_GATEWAY_PORT
379        );
380    }
381
382    #[test]
383    fn reads_local_gateway_port_from_network_config() {
384        let source = "networks:\n  - name: local\n    mode: managed\n    gateway:\n      bind: 127.0.0.1\n      port: 8001\n";
385
386        assert_eq!(local_gateway_port_from_yaml(source), 8001);
387    }
388
389    #[test]
390    fn ignores_nested_networks_keys_when_reading_local_gateway_port() {
391        let source = "canisters:\n  - name: root\n    metadata:\n      networks:\n        - local\n\nnetworks:\n  - name: local\n    mode: managed\n    gateway:\n      bind: 127.0.0.1\n      port: 8010\n";
392
393        assert_eq!(local_gateway_port_from_yaml(source), 8010);
394    }
395
396    #[test]
397    fn inspects_icp_yaml_without_mutating_it() {
398        let root = temp_dir("canic-icp-read-only");
399        let config = root.join("fleets/toko/canic.toml");
400        fs::create_dir_all(config.parent().expect("config parent")).expect("create config parent");
401        fs::write(
402            &config,
403            r#"
404[fleet]
405name = "toko"
406
407[subnets.prime.canisters.root]
408kind = "root"
409
410[subnets.prime.canisters.app]
411kind = "singleton"
412"#,
413        )
414        .expect("write config");
415        let source = r"
416canisters:
417  - name: root
418
419networks:
420  - name: local
421    mode: managed
422    gateway:
423      port: 8010
424
425environments:
426  - name: toko
427    network: local
428    canisters: [root]
429";
430        fs::write(root.join("icp.yaml"), source).expect("write icp yaml");
431
432        let report = inspect_canic_icp_yaml_from_root(&root, Some("toko")).expect("inspect");
433
434        assert_eq!(report.canisters, vec!["root", "app"]);
435        assert_eq!(report.environments, vec!["toko"]);
436        assert_eq!(report.missing_canisters, vec!["app"]);
437        assert!(report.missing_environments.is_empty());
438        assert!(report.local_network_present);
439        assert!(!report.is_ready());
440        assert_eq!(
441            fs::read_to_string(root.join("icp.yaml")).expect("read icp yaml"),
442            source
443        );
444        fs::remove_dir_all(root).expect("clean temp dir");
445    }
446
447    #[test]
448    fn reports_missing_icp_yaml_as_incomplete() {
449        let root = temp_dir("canic-icp-missing-yaml");
450        let config = root.join("fleets/toko/canic.toml");
451        fs::create_dir_all(config.parent().expect("config parent")).expect("create config parent");
452        fs::write(
453            &config,
454            r#"
455[fleet]
456name = "toko"
457
458[subnets.prime.canisters.root]
459kind = "root"
460"#,
461        )
462        .expect("write config");
463
464        let report = inspect_canic_icp_yaml_from_root(&root, Some("toko")).expect("inspect");
465
466        assert!(!report.icp_yaml_present);
467        assert_eq!(report.missing_canisters, vec!["root"]);
468        assert_eq!(report.missing_environments, vec!["toko"]);
469        assert!(!report.local_network_present);
470        assert!(!report.is_ready());
471        fs::remove_dir_all(root).expect("clean temp dir");
472    }
473
474    #[test]
475    fn discovers_root_fleet_configs_for_icp_inspection() {
476        let root = temp_dir("canic-icp-inspect-root-fleets");
477        let config = root.join("fleets/toko/canic.toml");
478        fs::create_dir_all(config.parent().expect("config parent")).expect("create config parent");
479        fs::write(
480            &config,
481            r#"
482[fleet]
483name = "toko"
484
485[subnets.prime.canisters.root]
486kind = "root"
487
488[subnets.prime.canisters.app]
489kind = "singleton"
490"#,
491        )
492        .expect("write config");
493
494        let spec = discover_project_spec(&root, Some("toko")).expect("discover spec");
495
496        assert_eq!(spec.canisters, vec!["root", "app"]);
497        assert_eq!(
498            spec.environments,
499            BTreeMap::from([(
500                "toko".to_string(),
501                vec!["root".to_string(), "app".to_string()]
502            )])
503        );
504        fs::remove_dir_all(root).expect("clean temp dir");
505    }
506
507    #[test]
508    fn fleet_filter_limits_inspected_project_spec() {
509        let root = temp_dir("canic-icp-inspect-fleet-filter");
510        write_test_config(
511            &root.join("fleets/demo/canic.toml"),
512            "demo",
513            &["root", "app"],
514        );
515        write_test_config(
516            &root.join("fleets/test/canic.toml"),
517            "test",
518            &["root", "scale"],
519        );
520
521        let spec = discover_project_spec(&root, Some("test")).expect("discover spec");
522
523        assert_eq!(spec.canisters, vec!["root", "scale"]);
524        assert_eq!(
525            spec.environments,
526            BTreeMap::from([(
527                "test".to_string(),
528                vec!["root".to_string(), "scale".to_string()]
529            )])
530        );
531        fs::remove_dir_all(root).expect("clean temp dir");
532    }
533
534    #[test]
535    fn nested_commands_discover_outer_project_root_with_fleets() {
536        let root = temp_dir("canic-icp-root-nested");
537        let config = root.join("fleets/toko/canic.toml");
538        let nested = root.join("backend/src");
539        fs::create_dir_all(&nested).expect("create nested dir");
540        fs::create_dir_all(config.parent().expect("config parent")).expect("create config parent");
541        fs::write(root.join("icp.yaml"), "").expect("write icp config");
542        fs::write(&config, "[fleet]\nname = \"toko\"\n").expect("write config");
543
544        let icp_root = crate::install_root::discover_canic_project_root_from(&nested)
545            .expect("discover project root")
546            .expect("project root is present");
547
548        assert_eq!(icp_root, root.canonicalize().expect("canonical root"));
549        fs::remove_dir_all(root).expect("clean temp dir");
550    }
551
552    #[test]
553    fn outer_project_root_wins_over_nested_fleets() {
554        let root = temp_dir("canic-icp-root-outer-wins");
555        let outer_config = root.join("fleets/toko/canic.toml");
556        let nested_config = root.join("services/fleets/toko/canic.toml");
557        let nested = root.join("services/src");
558        fs::create_dir_all(outer_config.parent().expect("outer config parent"))
559            .expect("create outer config parent");
560        fs::create_dir_all(nested_config.parent().expect("nested config parent"))
561            .expect("create nested config parent");
562        fs::create_dir_all(&nested).expect("create nested dir");
563        fs::write(root.join("icp.yaml"), "").expect("write icp config");
564        fs::write(&outer_config, "[fleet]\nname = \"toko\"\n").expect("write outer config");
565        fs::write(&nested_config, "[fleet]\nname = \"toko\"\n").expect("write nested config");
566
567        let icp_root = crate::install_root::discover_canic_project_root_from(&nested)
568            .expect("discover project root")
569            .expect("project root is present");
570
571        assert_eq!(icp_root, root.canonicalize().expect("canonical root"));
572        fs::remove_dir_all(root).expect("clean temp dir");
573    }
574
575    #[test]
576    fn icp_inspection_rejects_missing_fleet_configs() {
577        let root = temp_dir("canic-icp-inspect-missing");
578        fs::create_dir_all(&root).expect("create root");
579
580        let err = discover_project_spec(&root, None).expect_err("missing configs should fail");
581        let message = err.to_string();
582
583        assert!(message.contains("no Canic fleet configs found under"));
584        assert!(message.contains("fleets/<fleet>/canic.toml"));
585        fs::remove_dir_all(root).expect("clean temp dir");
586    }
587
588    fn write_test_config(path: &Path, fleet: &str, roles: &[&str]) {
589        fs::create_dir_all(path.parent().expect("config parent")).expect("create config parent");
590        let mut source = format!("[fleet]\nname = \"{fleet}\"\n");
591        for role in roles {
592            let kind = if *role == "root" { "root" } else { "singleton" };
593            write!(
594                source,
595                "\n[subnets.prime.canisters.{role}]\nkind = \"{kind}\"\n"
596            )
597            .expect("write config source");
598        }
599        fs::write(path, source).expect("write config");
600    }
601}