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 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#[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 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
115pub(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
134pub 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
151pub 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
164pub 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#[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
183fn deployments_dir(icp_root: &Path, network: &str) -> PathBuf {
185 icp_root.join(".canic").join(network).join("deployments")
186}
187
188pub(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
215fn 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
237const 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
249pub(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
264pub 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}