Skip to main content

canic_host/install_root/
state.rs

1use crate::{durable_io::write_bytes, release_set::icp_root};
2use std::{fs, io, path::Path, path::PathBuf};
3
4use serde::{Deserialize, Serialize};
5use thiserror::Error as ThisError;
6
7pub(super) const INSTALL_STATE_SCHEMA_VERSION: u32 = 2;
8
9/// Typed failure while locating, validating, decoding, or persisting install state.
10#[derive(Debug, ThisError)]
11pub enum InstallStateError {
12    #[error(
13        "deployment state identity mismatch: state is for {state_deployment}, requested {requested_deployment}"
14    )]
15    DeploymentMismatch {
16        state_deployment: String,
17        requested_deployment: String,
18    },
19
20    #[error("invalid deployment/template name {name:?}; use letters, numbers, '-' or '_'")]
21    InvalidStateName { name: String },
22
23    #[error("invalid network name {name:?}; use letters, numbers, '-' or '_'")]
24    InvalidNetworkName { name: String },
25
26    #[error(
27        "deployment state network mismatch: state is for {state_network}, requested {requested_network}"
28    )]
29    NetworkMismatch {
30        state_network: String,
31        requested_network: String,
32    },
33
34    #[error(
35        "unsupported deployment state schema version {state_version}; supported version is {supported_version}"
36    )]
37    SchemaVersionMismatch {
38        state_version: u32,
39        supported_version: u32,
40    },
41
42    #[error("failed to resolve ICP root from {}: {source}", path.display())]
43    ResolveIcpRoot {
44        path: PathBuf,
45        #[source]
46        source: io::Error,
47    },
48
49    #[error("failed to read deployment state {}: {source}", path.display())]
50    Read {
51        path: PathBuf,
52        #[source]
53        source: io::Error,
54    },
55
56    #[error("failed to decode deployment state {}: {source}", path.display())]
57    Decode {
58        path: PathBuf,
59        #[source]
60        source: serde_json::Error,
61    },
62
63    #[error("failed to encode deployment state {}: {source}", path.display())]
64    Encode {
65        path: PathBuf,
66        #[source]
67        source: serde_json::Error,
68    },
69
70    #[error("failed to write deployment state {}: {source}", path.display())]
71    Write {
72        path: PathBuf,
73        #[source]
74        source: io::Error,
75    },
76}
77
78///
79/// RootVerificationStatus
80///
81
82#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
83#[serde(rename_all = "snake_case")]
84pub enum RootVerificationStatus {
85    Verified,
86    NotVerified,
87}
88
89///
90/// InstallState
91///
92
93#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
94#[serde(deny_unknown_fields)]
95pub struct InstallState {
96    pub schema_version: u32,
97    pub deployment_name: String,
98    pub fleet_template: String,
99    pub created_at_unix_secs: u64,
100    pub updated_at_unix_secs: u64,
101    pub network: String,
102    pub root_target: String,
103    pub root_canister_id: String,
104    pub root_verification: RootVerificationStatus,
105    pub root_build_target: String,
106    pub workspace_root: String,
107    pub icp_root: String,
108    pub config_path: String,
109    pub release_set_manifest_path: String,
110}
111
112/// Read deployment-target install state for one project/network when present.
113pub(super) fn read_deployment_install_state(
114    icp_root: &Path,
115    network: &str,
116    deployment: &str,
117) -> Result<Option<InstallState>, InstallStateError> {
118    validate_network_name(network)?;
119    validate_state_name(deployment)?;
120    let path = deployment_install_state_path(icp_root, network, deployment);
121    let bytes = match fs::read(&path) {
122        Ok(bytes) => bytes,
123        Err(source) if source.kind() == io::ErrorKind::NotFound => return Ok(None),
124        Err(source) => {
125            return Err(InstallStateError::Read { path, source });
126        }
127    };
128    decode_install_state(&bytes, &path, network, deployment).map(Some)
129}
130
131/// Decode and validate install state against its canonical network/deployment path.
132pub fn decode_install_state(
133    bytes: &[u8],
134    path: &Path,
135    network: &str,
136    deployment: &str,
137) -> Result<InstallState, InstallStateError> {
138    validate_network_name(network)?;
139    validate_state_name(deployment)?;
140    let state = serde_json::from_slice(bytes).map_err(|source| InstallStateError::Decode {
141        path: path.to_path_buf(),
142        source,
143    })?;
144    validate_loaded_install_state(&state, network, deployment)?;
145    Ok(state)
146}
147
148/// Read deployment-target install state for the discovered current project.
149pub fn read_named_deployment_install_state(
150    network: &str,
151    deployment: &str,
152) -> Result<Option<InstallState>, InstallStateError> {
153    let start = PathBuf::from(".");
154    let icp_root = icp_root().map_err(|source| InstallStateError::ResolveIcpRoot {
155        path: start,
156        source,
157    })?;
158    read_deployment_install_state(&icp_root, network, deployment)
159}
160
161/// Read deployment-target install state for an explicit ICP project root.
162pub fn read_named_deployment_install_state_from_root(
163    icp_root: &Path,
164    network: &str,
165    deployment: &str,
166) -> Result<Option<InstallState>, InstallStateError> {
167    read_deployment_install_state(icp_root, network, deployment)
168}
169
170/// Return the project-local state path for one deployment target.
171#[must_use]
172pub(super) fn deployment_install_state_path(
173    icp_root: &Path,
174    network: &str,
175    deployment: &str,
176) -> PathBuf {
177    deployments_dir(icp_root, network).join(format!("{deployment}.json"))
178}
179
180// Return the directory that owns deployment-target state files.
181fn deployments_dir(icp_root: &Path, network: &str) -> PathBuf {
182    icp_root.join(".canic").join(network).join("deployments")
183}
184
185// Persist the completed install state under the project-local `.canic` directory.
186pub(super) fn write_install_state(
187    icp_root: &Path,
188    network: &str,
189    state: &InstallState,
190) -> Result<PathBuf, InstallStateError> {
191    validate_network_name(network)?;
192    validate_state_name(&state.deployment_name)?;
193    validate_schema_version(state.schema_version)?;
194    if state.network != network {
195        return Err(InstallStateError::NetworkMismatch {
196            state_network: state.network.clone(),
197            requested_network: network.to_string(),
198        });
199    }
200    let path = deployment_install_state_path(icp_root, network, &state.deployment_name);
201    let bytes = serde_json::to_vec_pretty(state).map_err(|source| InstallStateError::Encode {
202        path: path.clone(),
203        source,
204    })?;
205    write_bytes(&path, &bytes).map_err(|source| InstallStateError::Write {
206        path: path.clone(),
207        source,
208    })?;
209    Ok(path)
210}
211
212// Reject state that does not belong to the requested canonical path.
213fn validate_loaded_install_state(
214    state: &InstallState,
215    requested_network: &str,
216    requested_deployment: &str,
217) -> Result<(), InstallStateError> {
218    validate_schema_version(state.schema_version)?;
219    if state.deployment_name != requested_deployment {
220        return Err(InstallStateError::DeploymentMismatch {
221            state_deployment: state.deployment_name.clone(),
222            requested_deployment: requested_deployment.to_string(),
223        });
224    }
225    if state.network != requested_network {
226        return Err(InstallStateError::NetworkMismatch {
227            state_network: state.network.clone(),
228            requested_network: requested_network.to_string(),
229        });
230    }
231    Ok(())
232}
233
234// Keep readers and writers on the one supported install-state schema.
235const fn validate_schema_version(schema_version: u32) -> Result<(), InstallStateError> {
236    if schema_version == INSTALL_STATE_SCHEMA_VERSION {
237        Ok(())
238    } else {
239        Err(InstallStateError::SchemaVersionMismatch {
240            state_version: schema_version,
241            supported_version: INSTALL_STATE_SCHEMA_VERSION,
242        })
243    }
244}
245
246// Keep deployment and template names filesystem-safe and easy to type.
247pub(super) fn validate_state_name(name: &str) -> Result<(), InstallStateError> {
248    let valid = !name.is_empty()
249        && name
250            .bytes()
251            .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_'));
252    if valid {
253        Ok(())
254    } else {
255        Err(InstallStateError::InvalidStateName {
256            name: name.to_string(),
257        })
258    }
259}
260
261// Keep network names safe for `.canic/<network>` state paths.
262pub fn validate_network_name(name: &str) -> Result<(), InstallStateError> {
263    let valid = !name.is_empty()
264        && name
265            .bytes()
266            .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_'));
267    if valid {
268        Ok(())
269    } else {
270        Err(InstallStateError::InvalidNetworkName {
271            name: name.to_string(),
272        })
273    }
274}