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