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#[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#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
86#[serde(rename_all = "snake_case")]
87pub enum RootVerificationStatus {
88 Verified,
89 NotVerified,
90}
91
92#[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
115pub(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
134pub 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
151pub 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
164pub 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#[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
183fn deployments_dir(icp_root: &Path, environment: &str) -> PathBuf {
185 icp_root
186 .join(".canic")
187 .join(environment)
188 .join("deployments")
189}
190
191pub(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
218fn 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
240const 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
252pub(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
267pub 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}