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