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("invalid deployment/template name {name:?}; use letters, numbers, '-' or '_'")]
13    InvalidStateName { name: String },
14
15    #[error("invalid network name {name:?}; use letters, numbers, '-' or '_'")]
16    InvalidNetworkName { name: String },
17
18    #[error(
19        "deployment state network mismatch: state is for {state_network}, requested {requested_network}"
20    )]
21    NetworkMismatch {
22        state_network: String,
23        requested_network: String,
24    },
25
26    #[error("failed to resolve ICP root from {}: {source}", path.display())]
27    ResolveIcpRoot {
28        path: PathBuf,
29        #[source]
30        source: io::Error,
31    },
32
33    #[error("failed to read deployment state {}: {source}", path.display())]
34    Read {
35        path: PathBuf,
36        #[source]
37        source: io::Error,
38    },
39
40    #[error("failed to decode deployment state {}: {source}", path.display())]
41    Decode {
42        path: PathBuf,
43        #[source]
44        source: serde_json::Error,
45    },
46
47    #[error("failed to encode deployment state {}: {source}", path.display())]
48    Encode {
49        path: PathBuf,
50        #[source]
51        source: serde_json::Error,
52    },
53
54    #[error("failed to write deployment state {}: {source}", path.display())]
55    Write {
56        path: PathBuf,
57        #[source]
58        source: io::Error,
59    },
60}
61
62///
63/// RootVerificationStatus
64///
65
66#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
67#[serde(rename_all = "snake_case")]
68pub enum RootVerificationStatus {
69    Verified,
70    NotVerified,
71}
72
73///
74/// InstallState
75///
76
77#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
78#[serde(deny_unknown_fields)]
79pub struct InstallState {
80    pub schema_version: u32,
81    pub deployment_name: String,
82    pub fleet_template: String,
83    pub created_at_unix_secs: u64,
84    pub updated_at_unix_secs: u64,
85    pub network: String,
86    pub root_target: String,
87    pub root_canister_id: String,
88    pub root_verification: RootVerificationStatus,
89    pub root_build_target: String,
90    pub workspace_root: String,
91    pub icp_root: String,
92    pub config_path: String,
93    pub release_set_manifest_path: String,
94}
95
96/// Read deployment-target install state for one project/network when present.
97pub(super) fn read_deployment_install_state(
98    icp_root: &Path,
99    network: &str,
100    deployment: &str,
101) -> Result<Option<InstallState>, InstallStateError> {
102    validate_network_name(network)?;
103    validate_state_name(deployment)?;
104    let path = deployment_install_state_path(icp_root, network, deployment);
105    let bytes = match fs::read(&path) {
106        Ok(bytes) => bytes,
107        Err(source) if source.kind() == io::ErrorKind::NotFound => return Ok(None),
108        Err(source) => {
109            return Err(InstallStateError::Read { path, source });
110        }
111    };
112    let state = serde_json::from_slice(&bytes)
113        .map_err(|source| InstallStateError::Decode { path, source })?;
114    Ok(Some(state))
115}
116
117/// Read deployment-target install state for the discovered current project.
118pub fn read_named_deployment_install_state(
119    network: &str,
120    deployment: &str,
121) -> Result<Option<InstallState>, InstallStateError> {
122    let start = PathBuf::from(".");
123    let icp_root = icp_root().map_err(|source| InstallStateError::ResolveIcpRoot {
124        path: start,
125        source,
126    })?;
127    read_deployment_install_state(&icp_root, network, deployment)
128}
129
130/// Read deployment-target install state for an explicit ICP project root.
131pub fn read_named_deployment_install_state_from_root(
132    icp_root: &Path,
133    network: &str,
134    deployment: &str,
135) -> Result<Option<InstallState>, InstallStateError> {
136    read_deployment_install_state(icp_root, network, deployment)
137}
138
139/// Return the project-local state path for one deployment target.
140#[must_use]
141pub(super) fn deployment_install_state_path(
142    icp_root: &Path,
143    network: &str,
144    deployment: &str,
145) -> PathBuf {
146    deployments_dir(icp_root, network).join(format!("{deployment}.json"))
147}
148
149// Return the directory that owns deployment-target state files.
150fn deployments_dir(icp_root: &Path, network: &str) -> PathBuf {
151    icp_root.join(".canic").join(network).join("deployments")
152}
153
154// Persist the completed install state under the project-local `.canic` directory.
155pub(super) fn write_install_state(
156    icp_root: &Path,
157    network: &str,
158    state: &InstallState,
159) -> Result<PathBuf, InstallStateError> {
160    validate_network_name(network)?;
161    validate_state_name(&state.deployment_name)?;
162    if state.network != network {
163        return Err(InstallStateError::NetworkMismatch {
164            state_network: state.network.clone(),
165            requested_network: network.to_string(),
166        });
167    }
168    let path = deployment_install_state_path(icp_root, network, &state.deployment_name);
169    let bytes = serde_json::to_vec_pretty(state).map_err(|source| InstallStateError::Encode {
170        path: path.clone(),
171        source,
172    })?;
173    write_bytes(&path, &bytes).map_err(|source| InstallStateError::Write {
174        path: path.clone(),
175        source,
176    })?;
177    Ok(path)
178}
179
180// Keep deployment and template names filesystem-safe and easy to type.
181pub(super) fn validate_state_name(name: &str) -> Result<(), InstallStateError> {
182    let valid = !name.is_empty()
183        && name
184            .bytes()
185            .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_'));
186    if valid {
187        Ok(())
188    } else {
189        Err(InstallStateError::InvalidStateName {
190            name: name.to_string(),
191        })
192    }
193}
194
195// Keep network names safe for `.canic/<network>` state paths.
196pub(super) fn validate_network_name(name: &str) -> Result<(), InstallStateError> {
197    let valid = !name.is_empty()
198        && name
199            .bytes()
200            .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_'));
201    if valid {
202        Ok(())
203    } else {
204        Err(InstallStateError::InvalidNetworkName {
205            name: name.to_string(),
206        })
207    }
208}