use crate::{durable_io::write_bytes, release_set::icp_root};
use std::{fs, io, path::Path, path::PathBuf};
use serde::{Deserialize, Serialize};
use thiserror::Error as ThisError;
pub(super) const INSTALL_STATE_SCHEMA_VERSION: u32 = 2;
#[derive(Debug, ThisError)]
pub enum InstallStateError {
#[error(
"deployment state identity mismatch: state is for {state_deployment}, requested {requested_deployment}"
)]
DeploymentMismatch {
state_deployment: String,
requested_deployment: String,
},
#[error("invalid deployment/template name {name:?}; use letters, numbers, '-' or '_'")]
InvalidStateName { name: String },
#[error("invalid network name {name:?}; use letters, numbers, '-' or '_'")]
InvalidNetworkName { name: String },
#[error(
"deployment state network mismatch: state is for {state_network}, requested {requested_network}"
)]
NetworkMismatch {
state_network: String,
requested_network: String,
},
#[error(
"unsupported deployment state schema version {state_version}; supported version is {supported_version}"
)]
SchemaVersionMismatch {
state_version: u32,
supported_version: u32,
},
#[error("failed to resolve ICP root from {}: {source}", path.display())]
ResolveIcpRoot {
path: PathBuf,
#[source]
source: io::Error,
},
#[error("failed to read deployment state {}: {source}", path.display())]
Read {
path: PathBuf,
#[source]
source: io::Error,
},
#[error("failed to decode deployment state {}: {source}", path.display())]
Decode {
path: PathBuf,
#[source]
source: serde_json::Error,
},
#[error("failed to encode deployment state {}: {source}", path.display())]
Encode {
path: PathBuf,
#[source]
source: serde_json::Error,
},
#[error("failed to write deployment state {}: {source}", path.display())]
Write {
path: PathBuf,
#[source]
source: io::Error,
},
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum RootVerificationStatus {
Verified,
NotVerified,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(deny_unknown_fields)]
pub struct InstallState {
pub schema_version: u32,
pub deployment_name: String,
pub fleet_template: String,
pub created_at_unix_secs: u64,
pub updated_at_unix_secs: u64,
pub network: String,
pub root_target: String,
pub root_canister_id: String,
pub root_verification: RootVerificationStatus,
pub root_build_target: String,
pub workspace_root: String,
pub icp_root: String,
pub config_path: String,
pub release_set_manifest_path: String,
}
pub(super) fn read_deployment_install_state(
icp_root: &Path,
network: &str,
deployment: &str,
) -> Result<Option<InstallState>, InstallStateError> {
validate_network_name(network)?;
validate_state_name(deployment)?;
let path = deployment_install_state_path(icp_root, network, deployment);
let bytes = match fs::read(&path) {
Ok(bytes) => bytes,
Err(source) if source.kind() == io::ErrorKind::NotFound => return Ok(None),
Err(source) => {
return Err(InstallStateError::Read { path, source });
}
};
decode_install_state(&bytes, &path, network, deployment).map(Some)
}
pub fn decode_install_state(
bytes: &[u8],
path: &Path,
network: &str,
deployment: &str,
) -> Result<InstallState, InstallStateError> {
validate_network_name(network)?;
validate_state_name(deployment)?;
let state = serde_json::from_slice(bytes).map_err(|source| InstallStateError::Decode {
path: path.to_path_buf(),
source,
})?;
validate_loaded_install_state(&state, network, deployment)?;
Ok(state)
}
pub fn read_named_deployment_install_state(
network: &str,
deployment: &str,
) -> Result<Option<InstallState>, InstallStateError> {
let start = PathBuf::from(".");
let icp_root = icp_root().map_err(|source| InstallStateError::ResolveIcpRoot {
path: start,
source,
})?;
read_deployment_install_state(&icp_root, network, deployment)
}
pub fn read_named_deployment_install_state_from_root(
icp_root: &Path,
network: &str,
deployment: &str,
) -> Result<Option<InstallState>, InstallStateError> {
read_deployment_install_state(icp_root, network, deployment)
}
#[must_use]
pub(super) fn deployment_install_state_path(
icp_root: &Path,
network: &str,
deployment: &str,
) -> PathBuf {
deployments_dir(icp_root, network).join(format!("{deployment}.json"))
}
fn deployments_dir(icp_root: &Path, network: &str) -> PathBuf {
icp_root.join(".canic").join(network).join("deployments")
}
pub(super) fn write_install_state(
icp_root: &Path,
network: &str,
state: &InstallState,
) -> Result<PathBuf, InstallStateError> {
validate_network_name(network)?;
validate_state_name(&state.deployment_name)?;
validate_schema_version(state.schema_version)?;
if state.network != network {
return Err(InstallStateError::NetworkMismatch {
state_network: state.network.clone(),
requested_network: network.to_string(),
});
}
let path = deployment_install_state_path(icp_root, network, &state.deployment_name);
let bytes = serde_json::to_vec_pretty(state).map_err(|source| InstallStateError::Encode {
path: path.clone(),
source,
})?;
write_bytes(&path, &bytes).map_err(|source| InstallStateError::Write {
path: path.clone(),
source,
})?;
Ok(path)
}
fn validate_loaded_install_state(
state: &InstallState,
requested_network: &str,
requested_deployment: &str,
) -> Result<(), InstallStateError> {
validate_schema_version(state.schema_version)?;
if state.deployment_name != requested_deployment {
return Err(InstallStateError::DeploymentMismatch {
state_deployment: state.deployment_name.clone(),
requested_deployment: requested_deployment.to_string(),
});
}
if state.network != requested_network {
return Err(InstallStateError::NetworkMismatch {
state_network: state.network.clone(),
requested_network: requested_network.to_string(),
});
}
Ok(())
}
const fn validate_schema_version(schema_version: u32) -> Result<(), InstallStateError> {
if schema_version == INSTALL_STATE_SCHEMA_VERSION {
Ok(())
} else {
Err(InstallStateError::SchemaVersionMismatch {
state_version: schema_version,
supported_version: INSTALL_STATE_SCHEMA_VERSION,
})
}
}
pub(super) fn validate_state_name(name: &str) -> Result<(), InstallStateError> {
let valid = !name.is_empty()
&& name
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_'));
if valid {
Ok(())
} else {
Err(InstallStateError::InvalidStateName {
name: name.to_string(),
})
}
}
pub fn validate_network_name(name: &str) -> Result<(), InstallStateError> {
let valid = !name.is_empty()
&& name
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_'));
if valid {
Ok(())
} else {
Err(InstallStateError::InvalidNetworkName {
name: name.to_string(),
})
}
}