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