mod client;
pub mod publication;
pub(in crate::workflow::runtime::template) use client::WasmStoreInternalClient;
pub use publication::WasmStorePublicationWorkflow;
use crate::{
dto::template::{TemplateChunkSetInfoResponse, TemplateManifestResponse},
ids::{TemplateId, TemplateReleaseKey, TemplateVersion, WasmStoreBinding},
ops::storage::{
state::subnet::SubnetStateOps,
template::{TemplateChunkedOps, TemplateManifestOps},
},
};
use canic_core::api::lifecycle::metrics::{
WasmStoreMetricOperation, WasmStoreMetricOutcome, WasmStoreMetricReason, WasmStoreMetricSource,
WasmStoreMetricsApi,
};
use canic_core::api::runtime::install::ApprovedModuleSource;
use canic_core::cdk::types::Principal;
use canic_core::control_plane_support::{
error::{InternalError, InternalErrorOrigin},
ops::ic::{IcOps, mgmt::MgmtOps},
};
use canic_core::dto::error::{Error, ErrorCode};
use std::collections::BTreeSet;
const WASM_STORE_BOOTSTRAP_BINDING: WasmStoreBinding = WasmStoreBinding::new("bootstrap");
fn release_source_label(template_id: &TemplateId, version: &TemplateVersion) -> String {
TemplateReleaseKey::new(template_id.clone(), version.clone()).to_string()
}
pub async fn resolved_approved_module_source_for_role(
role: &crate::ids::CanisterRole,
) -> Result<ApprovedModuleSource, InternalError> {
let manifest = TemplateManifestOps::approved_for_role_response(role)?;
approved_module_source_from_manifest(&manifest).await
}
pub async fn approved_module_source_from_manifest(
manifest: &TemplateManifestResponse,
) -> Result<ApprovedModuleSource, InternalError> {
match manifest.chunking_mode {
crate::ids::TemplateChunkingMode::Inline => {
record_wasm_store_metric(
WasmStoreMetricOperation::SourceResolve,
WasmStoreMetricSource::Store,
WasmStoreMetricOutcome::Failed,
WasmStoreMetricReason::UnsupportedInline,
);
Err(InternalError::workflow(
InternalErrorOrigin::Workflow,
format!(
"inline module sources are no longer supported; role '{}' source '{}' must be staged and published through a wasm_store",
manifest.role, manifest.template_id
),
))
}
crate::ids::TemplateChunkingMode::Chunked => {
if manifest.store_binding == WASM_STORE_BOOTSTRAP_BINDING {
record_wasm_store_metric(
WasmStoreMetricOperation::SourceResolve,
WasmStoreMetricSource::Bootstrap,
WasmStoreMetricOutcome::Started,
WasmStoreMetricReason::Ok,
);
let (store_pid, info) =
match resolved_bootstrap_chunk_set_for_manifest(manifest).await {
Ok(source) => source,
Err(err) => {
record_wasm_store_metric(
WasmStoreMetricOperation::SourceResolve,
WasmStoreMetricSource::Bootstrap,
WasmStoreMetricOutcome::Failed,
WasmStoreMetricReason::from_manifest_source_error(&err),
);
return Err(err);
}
};
record_wasm_store_metric(
WasmStoreMetricOperation::SourceResolve,
WasmStoreMetricSource::Bootstrap,
WasmStoreMetricOutcome::Completed,
WasmStoreMetricReason::Ok,
);
return Ok(ApprovedModuleSource::chunked(
store_pid,
release_source_label(&manifest.template_id, &manifest.version),
manifest.payload_hash.clone(),
info.chunk_hashes,
manifest.payload_size_bytes,
));
}
record_wasm_store_metric(
WasmStoreMetricOperation::SourceResolve,
WasmStoreMetricSource::Store,
WasmStoreMetricOutcome::Started,
WasmStoreMetricReason::Ok,
);
let (store_pid, info) = match resolved_store_chunk_set_for_manifest(manifest).await {
Ok(source) => source,
Err(err) => {
record_wasm_store_metric(
WasmStoreMetricOperation::SourceResolve,
WasmStoreMetricSource::Store,
WasmStoreMetricOutcome::Failed,
WasmStoreMetricReason::from_manifest_source_error(&err),
);
return Err(err);
}
};
record_wasm_store_metric(
WasmStoreMetricOperation::SourceResolve,
WasmStoreMetricSource::Store,
WasmStoreMetricOutcome::Completed,
WasmStoreMetricReason::Ok,
);
Ok(ApprovedModuleSource::chunked(
store_pid,
release_source_label(&manifest.template_id, &manifest.version),
manifest.payload_hash.clone(),
info.chunk_hashes,
manifest.payload_size_bytes,
))
}
}
}
async fn resolved_bootstrap_chunk_set_for_manifest(
manifest: &TemplateManifestResponse,
) -> Result<(Principal, TemplateChunkSetInfoResponse), InternalError> {
let store_pid = IcOps::canister_self();
let info =
TemplateChunkedOps::chunk_set_info_response(&manifest.template_id, &manifest.version)?;
if info.chunk_hashes.is_empty() {
return Err(InternalError::workflow(
InternalErrorOrigin::Workflow,
format!(
"template '{}' bootstrap chunk metadata is incomplete",
manifest.template_id
),
));
}
ensure_bootstrap_chunk_hashes_present(&manifest.template_id, &manifest.version, &info).await?;
Ok((store_pid, info))
}
async fn resolved_store_chunk_set_for_manifest(
manifest: &TemplateManifestResponse,
) -> Result<(Principal, TemplateChunkSetInfoResponse), InternalError> {
if manifest.store_binding == WASM_STORE_BOOTSTRAP_BINDING {
return Err(InternalError::workflow(
InternalErrorOrigin::Workflow,
format!(
"template '{}' uses the local bootstrap store, which is only installable through the root control-plane path",
manifest.template_id
),
));
}
let store_pid = store_pid_for_binding(&manifest.store_binding)?;
let info = WasmStoreInternalClient::new(store_pid)
.info(&manifest.template_id, &manifest.version)
.await?;
if info.chunk_hashes.is_empty() {
return Err(InternalError::workflow(
InternalErrorOrigin::Workflow,
format!(
"template '{}' chunk metadata is incomplete for store {}",
manifest.template_id, store_pid
),
));
}
Ok((store_pid, info))
}
async fn ensure_bootstrap_chunk_hashes_present(
template_id: &TemplateId,
version: &TemplateVersion,
info: &TemplateChunkSetInfoResponse,
) -> Result<(), InternalError> {
record_wasm_store_metric(
WasmStoreMetricOperation::BootstrapChunkSync,
WasmStoreMetricSource::Bootstrap,
WasmStoreMetricOutcome::Started,
WasmStoreMetricReason::Ok,
);
let store_pid = IcOps::canister_self();
let stored_hashes = MgmtOps::stored_chunks(store_pid)
.await?
.into_iter()
.collect::<BTreeSet<_>>();
if info
.chunk_hashes
.iter()
.all(|expected_hash| stored_hashes.contains(expected_hash))
{
record_wasm_store_metric(
WasmStoreMetricOperation::BootstrapChunkSync,
WasmStoreMetricSource::Bootstrap,
WasmStoreMetricOutcome::Completed,
WasmStoreMetricReason::CacheHit,
);
return Ok(());
}
for (chunk_index, expected_hash) in info.chunk_hashes.iter().cloned().enumerate() {
if stored_hashes.contains(&expected_hash) {
record_wasm_store_metric(
WasmStoreMetricOperation::ChunkUpload,
WasmStoreMetricSource::Bootstrap,
WasmStoreMetricOutcome::Skipped,
WasmStoreMetricReason::CacheHit,
);
continue;
}
record_wasm_store_metric(
WasmStoreMetricOperation::ChunkUpload,
WasmStoreMetricSource::Bootstrap,
WasmStoreMetricOutcome::Started,
WasmStoreMetricReason::CacheMiss,
);
let chunk_index = u32::try_from(chunk_index).map_err(|_| {
InternalError::workflow(
InternalErrorOrigin::Workflow,
format!("template '{template_id}' exceeds supported chunk indexing bounds"),
)
})?;
let bytes = TemplateChunkedOps::chunk_response(template_id, version, chunk_index)?.bytes;
let uploaded_hash = match MgmtOps::upload_chunk(store_pid, bytes).await {
Ok(uploaded_hash) => uploaded_hash,
Err(err) => {
record_wasm_store_metric(
WasmStoreMetricOperation::ChunkUpload,
WasmStoreMetricSource::Bootstrap,
WasmStoreMetricOutcome::Failed,
WasmStoreMetricReason::ManagementCall,
);
return Err(err);
}
};
if uploaded_hash != expected_hash {
record_wasm_store_metric(
WasmStoreMetricOperation::ChunkUpload,
WasmStoreMetricSource::Bootstrap,
WasmStoreMetricOutcome::Failed,
WasmStoreMetricReason::HashMismatch,
);
return Err(InternalError::workflow(
InternalErrorOrigin::Workflow,
format!(
"template '{template_id}' bootstrap chunk {chunk_index} uploaded hash mismatch for root {store_pid}",
),
));
}
record_wasm_store_metric(
WasmStoreMetricOperation::ChunkUpload,
WasmStoreMetricSource::Bootstrap,
WasmStoreMetricOutcome::Completed,
WasmStoreMetricReason::Ok,
);
}
record_wasm_store_metric(
WasmStoreMetricOperation::BootstrapChunkSync,
WasmStoreMetricSource::Bootstrap,
WasmStoreMetricOutcome::Completed,
WasmStoreMetricReason::CacheMiss,
);
Ok(())
}
fn record_wasm_store_metric(
operation: WasmStoreMetricOperation,
source: WasmStoreMetricSource,
outcome: WasmStoreMetricOutcome,
reason: WasmStoreMetricReason,
) {
WasmStoreMetricsApi::record(operation, source, outcome, reason);
}
trait WasmStoreManifestSourceError {
fn from_manifest_source_error(err: &InternalError) -> Self;
}
impl WasmStoreManifestSourceError for WasmStoreMetricReason {
fn from_manifest_source_error(err: &InternalError) -> Self {
match err.public_error().map(|public| public.code) {
Some(ErrorCode::WasmStoreChunkMissing) => Self::MissingChunk,
Some(ErrorCode::WasmStoreHashMismatch) => Self::HashMismatch,
Some(ErrorCode::WasmStoreManifestMissing) => Self::MissingManifest,
Some(_) => Self::StoreCall,
None => Self::InvalidState,
}
}
}
fn store_pid_for_binding(binding: &WasmStoreBinding) -> Result<Principal, InternalError> {
SubnetStateOps::wasm_store_pid(binding).ok_or_else(|| {
InternalError::public(Error::new(
ErrorCode::WasmStoreManifestMissing,
format!("wasm store binding '{binding}' is not registered"),
))
})
}
#[cfg(test)]
mod tests {
use super::{WasmStoreInternalClient, release_source_label};
use crate::ids::{TemplateId, TemplateVersion};
use canic_core::protocol;
#[test]
fn release_source_label_includes_version() {
let label = release_source_label(
&TemplateId::new("embedded:user_hub"),
&TemplateVersion::new("0.20.2"),
);
assert_eq!(label, "embedded:user_hub@0.20.2");
}
#[test]
fn wasm_store_root_update_method_classifier_matches_protocol_list() {
for method in protocol::CANIC_WASM_STORE_ROOT_UPDATE_METHODS {
assert!(
!WasmStoreInternalClient::method_requires_internal_proof(method),
"{method} must use a direct root-principal call"
);
}
assert!(!WasmStoreInternalClient::method_requires_internal_proof(
protocol::CANIC_WASM_STORE_CATALOG
));
assert!(!WasmStoreInternalClient::method_requires_internal_proof(
protocol::CANIC_WASM_STORE_STATUS
));
}
}