Skip to main content

canic_host/install_root/
state.rs

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