Skip to main content

canic_host/install_root/
state.rs

1use crate::release_set::icp_root;
2use serde::{Deserialize, Serialize};
3use std::{fs, path::Path, path::PathBuf};
4
5pub(super) const INSTALL_STATE_SCHEMA_VERSION: u32 = 1;
6
7///
8/// InstallState
9///
10
11#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
12pub struct InstallState {
13    pub schema_version: u32,
14    pub fleet: String,
15    pub installed_at_unix_secs: u64,
16    pub network: String,
17    pub root_target: String,
18    pub root_canister_id: String,
19    pub root_build_target: String,
20    pub workspace_root: String,
21    pub icp_root: String,
22    pub config_path: String,
23    pub release_set_manifest_path: String,
24}
25
26/// Read a named fleet install state for one project/network when present.
27pub(super) fn read_fleet_install_state(
28    icp_root: &Path,
29    network: &str,
30    fleet: &str,
31) -> Result<Option<InstallState>, Box<dyn std::error::Error>> {
32    validate_network_name(network)?;
33    validate_fleet_name(fleet)?;
34    let path = fleet_install_state_path(icp_root, network, fleet);
35    if !path.is_file() {
36        return Ok(None);
37    }
38
39    let bytes = fs::read(&path)?;
40    let state: InstallState = serde_json::from_slice(&bytes)?;
41    Ok(Some(state))
42}
43
44/// Read a named fleet state for the discovered current project.
45pub fn read_named_fleet_install_state(
46    network: &str,
47    fleet: &str,
48) -> Result<Option<InstallState>, Box<dyn std::error::Error>> {
49    let icp_root = icp_root()?;
50    read_fleet_install_state(&icp_root, network, fleet)
51}
52
53/// Read a named fleet state for an explicit ICP project root.
54pub fn read_named_fleet_install_state_from_root(
55    icp_root: &Path,
56    network: &str,
57    fleet: &str,
58) -> Result<Option<InstallState>, Box<dyn std::error::Error>> {
59    read_fleet_install_state(icp_root, network, fleet)
60}
61
62/// Return the project-local state path for one named fleet.
63#[must_use]
64pub(super) fn fleet_install_state_path(icp_root: &Path, network: &str, fleet: &str) -> PathBuf {
65    fleets_dir(icp_root, network).join(format!("{fleet}.json"))
66}
67
68// Return the directory that owns named fleet state files.
69fn fleets_dir(icp_root: &Path, network: &str) -> PathBuf {
70    icp_root.join(".canic").join(network).join("fleets")
71}
72
73// Persist the completed install state under the project-local `.canic` directory.
74pub(super) fn write_install_state(
75    icp_root: &Path,
76    network: &str,
77    state: &InstallState,
78) -> Result<PathBuf, Box<dyn std::error::Error>> {
79    validate_network_name(network)?;
80    validate_fleet_name(&state.fleet)?;
81    let path = fleet_install_state_path(icp_root, network, &state.fleet);
82    if let Some(parent) = path.parent() {
83        fs::create_dir_all(parent)?;
84    }
85    remove_conflicting_fleet_states(icp_root, network, state)?;
86    fs::write(&path, serde_json::to_vec_pretty(state)?)?;
87    Ok(path)
88}
89
90// A named ICP canister belongs to one installed fleet at a time. When a new
91// install reuses that root, older fleet state would otherwise point at the new
92// deployment and make `canic list <old-fleet>` show the wrong topology.
93fn remove_conflicting_fleet_states(
94    icp_root: &Path,
95    network: &str,
96    state: &InstallState,
97) -> Result<(), Box<dyn std::error::Error>> {
98    let dir = fleets_dir(icp_root, network);
99    if !dir.is_dir() {
100        return Ok(());
101    }
102
103    for entry in fs::read_dir(dir)? {
104        let entry = entry?;
105        let path = entry.path();
106        if !path.is_file() || path == fleet_install_state_path(icp_root, network, &state.fleet) {
107            continue;
108        }
109
110        let Ok(bytes) = fs::read(&path) else {
111            continue;
112        };
113        let Ok(existing) = serde_json::from_slice::<InstallState>(&bytes) else {
114            continue;
115        };
116        if existing.network == state.network
117            && (existing.root_target == state.root_target
118                || existing.root_canister_id == state.root_canister_id)
119        {
120            fs::remove_file(path)?;
121        }
122    }
123
124    Ok(())
125}
126
127// Keep fleet names filesystem-safe and easy to type in commands.
128pub(super) fn validate_fleet_name(name: &str) -> Result<(), Box<dyn std::error::Error>> {
129    let valid = !name.is_empty()
130        && name
131            .bytes()
132            .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_'));
133    if valid {
134        Ok(())
135    } else {
136        Err(format!("invalid fleet name {name:?}; use letters, numbers, '-' or '_'").into())
137    }
138}
139
140// Keep network names safe for `.canic/<network>` state paths.
141fn validate_network_name(name: &str) -> Result<(), Box<dyn std::error::Error>> {
142    let valid = !name.is_empty()
143        && name
144            .bytes()
145            .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_'));
146    if valid {
147        Ok(())
148    } else {
149        Err(format!("invalid network name {name:?}; use letters, numbers, '-' or '_'").into())
150    }
151}