Skip to main content

canic_host/install_root/
state.rs

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