canic_host/release_set/fixture/
mod.rs1mod configuration;
8mod persistence;
9#[cfg(test)]
10mod tests;
11
12use canic_control_plane::api::fixture_content::FixtureContentApi;
13use canic_core::{
14 CANIC_WASM_CHUNK_BYTES,
15 bootstrap::compiled::ComponentTopology,
16 dto::fixture_provisioning::{FixtureChunkDescriptor, FixtureDescriptor, FixtureStoreError},
17 ids::{CanisterRole, ComponentTopologyDigest, ReleaseBuildId},
18};
19use serde::{Deserialize, Serialize};
20use sha2::{Digest, Sha256};
21use std::{collections::BTreeSet, io, path::PathBuf};
22use thiserror::Error;
23
24pub use configuration::{ConfiguredFixtureSources, load_configured_fixture_sources};
25pub(crate) use persistence::read_chunk;
26pub use persistence::{
27 PersistedFixtureArtifactManifest, compile_and_persist_fixture_artifact_manifest,
28 load_fixture_artifact_manifest, verify_fixture_artifacts,
29};
30
31#[derive(Clone, Debug, Eq, PartialEq)]
38pub struct FixtureSourceInput {
39 pub role: CanisterRole,
40 pub format_hash: [u8; 32],
41 pub completion_summary: [u8; 32],
42 pub chunk_paths: Vec<String>,
43}
44
45#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
52#[serde(deny_unknown_fields)]
53pub struct FixtureArtifactEntry {
54 pub role: CanisterRole,
55 pub content_id: [u8; 32],
56 pub descriptor: FixtureDescriptor,
57}
58
59#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
66#[serde(deny_unknown_fields)]
67pub struct FixtureArtifactManifest {
68 pub schema_version: u16,
69 pub release_build_id: ReleaseBuildId,
70 pub component_topology_digest: ComponentTopologyDigest,
71 pub entries: Vec<FixtureArtifactEntry>,
72}
73
74impl FixtureArtifactManifest {
75 pub fn validate(
77 &self,
78 topology: &ComponentTopology,
79 release_build_id: ReleaseBuildId,
80 ) -> Result<(), FixtureArtifactError> {
81 if self.schema_version != 1 || self.release_build_id != release_build_id {
82 return Err(FixtureArtifactError::Authority);
83 }
84 let digest = topology
85 .digest()
86 .map_err(|_| FixtureArtifactError::Authority)?;
87 if self.component_topology_digest != digest {
88 return Err(FixtureArtifactError::Authority);
89 }
90 let roles = topology_roles(topology);
91 let mut previous = None;
92 for entry in &self.entries {
93 if !roles.contains(&entry.role) || previous.is_some_and(|role| role >= &entry.role) {
94 return Err(FixtureArtifactError::Role(entry.role.clone()));
95 }
96 previous = Some(&entry.role);
97 if FixtureContentApi::content_id(&entry.descriptor)? != entry.content_id {
98 return Err(FixtureArtifactError::Content);
99 }
100 }
101 Ok(())
102 }
103}
104
105#[derive(Debug, Error)]
111pub enum FixtureArtifactError {
112 #[error("fixture release or topology authority differs")]
113 Authority,
114 #[error("fixture inputs changed during the build")]
115 ChangedInputs,
116 #[error("fixture source configuration is invalid: {0}")]
117 Configuration(String),
118 #[error("fixture artifact bytes differ from selected content")]
119 Content,
120 #[error("fixture artifact already exists with different bytes: {0}")]
121 Conflict(PathBuf),
122 #[error("finalized release cannot acquire or repair fixture artifacts")]
123 Finalized,
124 #[error("fixture source path is not a regular workspace-relative file: {0}")]
125 Path(PathBuf),
126 #[error(
127 "fixture artifact path contains a symlink at {0}; use real workspace-local files and directories, and keep .canic state independent between checkouts"
128 )]
129 Symlink(PathBuf),
130 #[error("fixture role is duplicate, unordered or outside the application topology: {0}")]
131 Role(CanisterRole),
132 #[error("fixture descriptor or payload is invalid: {0:?}")]
133 Store(FixtureStoreError),
134 #[error("fixture artifact I/O failed at {path}: {source}")]
135 Io { path: PathBuf, source: io::Error },
136 #[error("fixture manifest encoding failed: {0}")]
137 Json(#[from] serde_json::Error),
138 #[error(transparent)]
139 ReleaseBuild(#[from] crate::release_build::ReleaseBuildPlanError),
140}
141
142impl From<FixtureStoreError> for FixtureArtifactError {
143 fn from(error: FixtureStoreError) -> Self {
144 Self::Store(error)
145 }
146}
147
148fn topology_roles(topology: &ComponentTopology) -> BTreeSet<CanisterRole> {
149 topology
150 .component_specs
151 .iter()
152 .flat_map(|spec| {
153 std::iter::once(spec.component_role.clone())
154 .chain(spec.children.iter().map(|child| child.role.clone()))
155 })
156 .collect()
157}
158
159fn compile_entry(
160 root: &std::path::Path,
161 input: &FixtureSourceInput,
162) -> Result<FixtureArtifactEntry, FixtureArtifactError> {
163 if input.chunk_paths.len() > canic_core::ingress::payload::DEFAULT_UPDATE_INGRESS_MAX_BYTES / 32
164 {
165 return Err(FixtureStoreError::Bounds.into());
166 }
167 let mut descriptor = FixtureDescriptor {
170 schema_version: 1,
171 format_hash: input.format_hash,
172 encoded_length: input.chunk_paths.len() as u64,
173 chunks: vec![
174 FixtureChunkDescriptor {
175 digest: [0; 32],
176 length: 1
177 };
178 input.chunk_paths.len()
179 ],
180 completion_summary: input.completion_summary,
181 };
182 FixtureContentApi::content_id(&descriptor)?;
183 descriptor.encoded_length = 0;
184 for (chunk, path) in descriptor.chunks.iter_mut().zip(&input.chunk_paths) {
185 let bytes = persistence::read_workspace_file(root, path, CANIC_WASM_CHUNK_BYTES)?;
186 chunk.length = u32::try_from(bytes.len()).map_err(|_| FixtureStoreError::Bounds)?;
187 chunk.digest = Sha256::digest(&bytes).into();
188 descriptor.encoded_length += u64::from(chunk.length);
189 }
190 let content_id = FixtureContentApi::content_id(&descriptor)?;
191 Ok(FixtureArtifactEntry {
192 role: input.role.clone(),
193 content_id,
194 descriptor,
195 })
196}