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 environment name {name:?}; use letters, numbers, '-' or '_'")]
27    InvalidEnvironmentName { name: String },
28
29    #[error(
30        "deployment state environment mismatch: state is for {state_environment}, requested {requested_environment}"
31    )]
32    EnvironmentMismatch {
33        state_environment: String,
34        requested_environment: 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 environment: 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/environment when present.
116pub(super) fn read_deployment_install_state(
117    icp_root: &Path,
118    environment: &str,
119    deployment: &str,
120) -> Result<Option<InstallState>, InstallStateError> {
121    validate_environment_name(environment)?;
122    validate_state_name(deployment)?;
123    let path = deployment_install_state_path(icp_root, environment, 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, environment, deployment).map(Some)
132}
133
134/// Decode and validate install state against its canonical environment/deployment path.
135pub fn decode_install_state(
136    bytes: &[u8],
137    path: &Path,
138    environment: &str,
139    deployment: &str,
140) -> Result<InstallState, InstallStateError> {
141    validate_environment_name(environment)?;
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, environment, 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    environment: &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, environment, 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    environment: &str,
168    deployment: &str,
169) -> Result<Option<InstallState>, InstallStateError> {
170    read_deployment_install_state(icp_root, environment, 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    environment: &str,
178    deployment: &str,
179) -> PathBuf {
180    deployments_dir(icp_root, environment).join(format!("{deployment}.json"))
181}
182
183// Return the directory that owns deployment-target state files.
184fn deployments_dir(icp_root: &Path, environment: &str) -> PathBuf {
185    icp_root
186        .join(".canic")
187        .join(environment)
188        .join("deployments")
189}
190
191// Persist the completed install state under the project-local `.canic` directory.
192pub(super) fn write_install_state(
193    icp_root: &Path,
194    environment: &str,
195    state: &InstallState,
196) -> Result<PathBuf, InstallStateError> {
197    validate_environment_name(environment)?;
198    validate_state_name(&state.deployment_name)?;
199    validate_schema_version(state.schema_version)?;
200    if state.environment != environment {
201        return Err(InstallStateError::EnvironmentMismatch {
202            state_environment: state.environment.clone(),
203            requested_environment: environment.to_string(),
204        });
205    }
206    let path = deployment_install_state_path(icp_root, environment, &state.deployment_name);
207    let bytes = serde_json::to_vec_pretty(state).map_err(|source| InstallStateError::Encode {
208        path: path.clone(),
209        source,
210    })?;
211    write_bytes(&path, &bytes).map_err(|source| InstallStateError::Write {
212        path: path.clone(),
213        source,
214    })?;
215    Ok(path)
216}
217
218// Reject state that does not belong to the requested canonical path.
219fn validate_loaded_install_state(
220    state: &InstallState,
221    requested_environment: &str,
222    requested_deployment: &str,
223) -> Result<(), InstallStateError> {
224    validate_schema_version(state.schema_version)?;
225    if state.deployment_name != requested_deployment {
226        return Err(InstallStateError::DeploymentMismatch {
227            state_deployment: state.deployment_name.clone(),
228            requested_deployment: requested_deployment.to_string(),
229        });
230    }
231    if state.environment != requested_environment {
232        return Err(InstallStateError::EnvironmentMismatch {
233            state_environment: state.environment.clone(),
234            requested_environment: requested_environment.to_string(),
235        });
236    }
237    Ok(())
238}
239
240// Keep readers and writers on the one supported install-state schema.
241const fn validate_schema_version(schema_version: u32) -> Result<(), InstallStateError> {
242    if schema_version == INSTALL_STATE_SCHEMA_VERSION {
243        Ok(())
244    } else {
245        Err(InstallStateError::SchemaVersionMismatch {
246            state_version: schema_version,
247            supported_version: INSTALL_STATE_SCHEMA_VERSION,
248        })
249    }
250}
251
252// Keep deployment and template names filesystem-safe and easy to type.
253pub(super) fn validate_state_name(name: &str) -> Result<(), InstallStateError> {
254    let valid = !name.is_empty()
255        && name
256            .bytes()
257            .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_'));
258    if valid {
259        Ok(())
260    } else {
261        Err(InstallStateError::InvalidStateName {
262            name: name.to_string(),
263        })
264    }
265}
266
267// Keep environment names safe for `.canic/<environment>` state paths.
268pub fn validate_environment_name(name: &str) -> Result<(), InstallStateError> {
269    let valid = !name.is_empty()
270        && name
271            .bytes()
272            .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_'));
273    if valid {
274        Ok(())
275    } else {
276        Err(InstallStateError::InvalidEnvironmentName {
277            name: name.to_string(),
278        })
279    }
280}