Skip to main content

canic_host/install_root/
state.rs

1use crate::release_set::icp_root;
2use serde::{Deserialize, Serialize};
3use std::{fs, path::Path, path::PathBuf};
4
5pub(super) const INSTALL_STATE_SCHEMA_VERSION: u32 = 2;
6
7///
8/// RootVerificationStatus
9///
10
11#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
12#[serde(rename_all = "snake_case")]
13pub enum RootVerificationStatus {
14    Verified,
15    NotVerified,
16}
17
18///
19/// InstallState
20///
21
22#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
23#[serde(deny_unknown_fields)]
24pub struct InstallState {
25    pub schema_version: u32,
26    pub deployment_name: String,
27    pub fleet_template: String,
28    pub created_at_unix_secs: u64,
29    pub updated_at_unix_secs: u64,
30    pub network: String,
31    pub root_target: String,
32    pub root_canister_id: String,
33    pub root_verification: RootVerificationStatus,
34    pub root_build_target: String,
35    pub workspace_root: String,
36    pub icp_root: String,
37    pub config_path: String,
38    pub release_set_manifest_path: String,
39}
40
41/// Read deployment-target install state for one project/network when present.
42pub(super) fn read_deployment_install_state(
43    icp_root: &Path,
44    network: &str,
45    deployment: &str,
46) -> Result<Option<InstallState>, Box<dyn std::error::Error>> {
47    validate_network_name(network)?;
48    validate_state_name(deployment)?;
49    let path = deployment_install_state_path(icp_root, network, deployment);
50    if !path.is_file() {
51        reject_legacy_fleet_state(icp_root, network, deployment)?;
52        return Ok(None);
53    }
54
55    let bytes = fs::read(&path)?;
56    let state: InstallState = serde_json::from_slice(&bytes)?;
57    Ok(Some(state))
58}
59
60/// Read deployment-target install state for the discovered current project.
61pub fn read_named_deployment_install_state(
62    network: &str,
63    deployment: &str,
64) -> Result<Option<InstallState>, Box<dyn std::error::Error>> {
65    let icp_root = icp_root()?;
66    read_deployment_install_state(&icp_root, network, deployment)
67}
68
69/// Read deployment-target install state for an explicit ICP project root.
70pub fn read_named_deployment_install_state_from_root(
71    icp_root: &Path,
72    network: &str,
73    deployment: &str,
74) -> Result<Option<InstallState>, Box<dyn std::error::Error>> {
75    read_deployment_install_state(icp_root, network, deployment)
76}
77
78/// Return the legacy project-local fleet state path.
79#[must_use]
80pub(super) fn legacy_fleet_install_state_path(
81    icp_root: &Path,
82    network: &str,
83    fleet: &str,
84) -> PathBuf {
85    fleets_dir(icp_root, network).join(format!("{fleet}.json"))
86}
87
88/// Return the project-local state path for one deployment target.
89#[must_use]
90pub(super) fn deployment_install_state_path(
91    icp_root: &Path,
92    network: &str,
93    deployment: &str,
94) -> PathBuf {
95    deployments_dir(icp_root, network).join(format!("{deployment}.json"))
96}
97
98// Return the directory that owns named fleet state files.
99fn fleets_dir(icp_root: &Path, network: &str) -> PathBuf {
100    icp_root.join(".canic").join(network).join("fleets")
101}
102
103// Return the directory that owns deployment-target state files.
104fn deployments_dir(icp_root: &Path, network: &str) -> PathBuf {
105    icp_root.join(".canic").join(network).join("deployments")
106}
107
108// Persist the completed install state under the project-local `.canic` directory.
109pub(super) fn write_install_state(
110    icp_root: &Path,
111    network: &str,
112    state: &InstallState,
113) -> Result<PathBuf, Box<dyn std::error::Error>> {
114    validate_network_name(network)?;
115    validate_state_name(&state.deployment_name)?;
116    if state.network != network {
117        return Err(format!(
118            "deployment state network mismatch: state is for {}, requested {network}",
119            state.network
120        )
121        .into());
122    }
123    let path = deployment_install_state_path(icp_root, network, &state.deployment_name);
124    if let Some(parent) = path.parent() {
125        fs::create_dir_all(parent)?;
126    }
127    fs::write(&path, serde_json::to_vec_pretty(state)?)?;
128    Ok(path)
129}
130
131fn reject_legacy_fleet_state(
132    icp_root: &Path,
133    network: &str,
134    deployment: &str,
135) -> Result<(), Box<dyn std::error::Error>> {
136    let path = legacy_fleet_install_state_path(icp_root, network, deployment);
137    if path.exists() {
138        return Err(format!(
139            "legacy fleet install state found: {}\n\nCanic 0.46 stores live deployment state by deployment target, not fleet template.\nCreate explicit deployment state with the fleet template that owns this target:\n  canic deploy register {deployment} --fleet-template <fleet-template> --root <principal> --allow-unverified\n\nOr reinstall using the fleet template/config that should produce this deployment:\n  canic install <fleet-template>\n\nIf the old state is obsolete, remove:\n  {}",
140            path.display(),
141            path.display()
142        )
143        .into());
144    }
145    Ok(())
146}
147
148// Keep deployment and template names filesystem-safe and easy to type.
149pub(super) fn validate_state_name(name: &str) -> Result<(), Box<dyn std::error::Error>> {
150    let valid = !name.is_empty()
151        && name
152            .bytes()
153            .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_'));
154    if valid {
155        Ok(())
156    } else {
157        Err(
158            format!("invalid deployment/template name {name:?}; use letters, numbers, '-' or '_'")
159                .into(),
160        )
161    }
162}
163
164// Keep network names safe for `.canic/<network>` state paths.
165pub(super) fn validate_network_name(name: &str) -> Result<(), Box<dyn std::error::Error>> {
166    let valid = !name.is_empty()
167        && name
168            .bytes()
169            .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_'));
170    if valid {
171        Ok(())
172    } else {
173        Err(format!("invalid network name {name:?}; use letters, numbers, '-' or '_'").into())
174    }
175}