1use crate::{
8 durable_io::write_bytes,
9 release_set::{
10 build_release_set_entry, config_path, configured_release_roles, load_root_package_version,
11 resolve_artifact_root, root_release_set_manifest_path, workspace_manifest_path,
12 },
13 role_contract::{declared_role_manifest_path, finding_detail},
14};
15use std::{collections::BTreeSet, fs, path::Path};
16
17use serde::{Deserialize, Serialize};
18
19#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
24pub struct RootReleaseSetManifest {
25 pub release_version: String,
26 pub entries: Vec<ReleaseSetEntry>,
27}
28
29#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
34pub struct ReleaseSetEntry {
35 pub role: String,
36 pub template_id: String,
37 pub artifact_relative_path: String,
38 pub payload_size_bytes: u64,
39 pub payload_sha256_hex: String,
40 pub chunk_size_bytes: u64,
41 pub chunk_sha256_hex: Vec<String>,
42}
43
44pub fn validate_root_release_set_manifest(
47 manifest: &RootReleaseSetManifest,
48) -> Result<(), Box<dyn std::error::Error>> {
49 if manifest.release_version.trim().is_empty() {
50 return Err("release-set manifest version must not be empty".into());
51 }
52
53 let mut roles = BTreeSet::new();
54 for entry in &manifest.entries {
55 if entry.role.trim().is_empty() {
56 return Err("release-set manifest role must not be empty".into());
57 }
58 if !roles.insert(entry.role.as_str()) {
59 return Err(format!("duplicate release-set role: {}", entry.role).into());
60 }
61
62 let expected_template_id = format!("embedded:{}", entry.role);
63 if entry.template_id != expected_template_id {
64 return Err(format!(
65 "release-set template identity mismatch for role {}: expected {}",
66 entry.role, expected_template_id
67 )
68 .into());
69 }
70 }
71
72 Ok(())
73}
74
75pub fn emit_root_release_set_manifest(
77 workspace_root: &Path,
78 icp_root: &Path,
79 network: &str,
80) -> Result<std::path::PathBuf, Box<dyn std::error::Error>> {
81 let config_path = config_path(workspace_root);
82 emit_root_release_set_manifest_with_config(workspace_root, icp_root, network, &config_path)
83}
84
85pub fn emit_root_release_set_manifest_with_config(
87 workspace_root: &Path,
88 icp_root: &Path,
89 network: &str,
90 config_path: &Path,
91) -> Result<std::path::PathBuf, Box<dyn std::error::Error>> {
92 let artifact_root = resolve_artifact_root(icp_root, network)?;
93 let manifest_path = root_release_set_manifest_path(&artifact_root)?;
94 let root_manifest_path =
95 declared_role_manifest_path(config_path, &canic_core::ids::CanisterRole::ROOT)
96 .map_err(|finding| finding_detail(&finding))?;
97 let release_version = load_root_package_version(
98 &root_manifest_path,
99 &workspace_manifest_path(workspace_root),
100 )?;
101 let entries = configured_release_roles(config_path)?
102 .into_iter()
103 .map(|role_name| build_release_set_entry(icp_root, &artifact_root, &role_name))
104 .collect::<Result<Vec<_>, _>>()?;
105 let manifest = RootReleaseSetManifest {
106 release_version,
107 entries,
108 };
109
110 validate_root_release_set_manifest(&manifest)?;
111 write_bytes(&manifest_path, &serde_json::to_vec_pretty(&manifest)?)?;
112 Ok(manifest_path)
113}
114
115pub fn emit_root_release_set_manifest_if_ready(
117 workspace_root: &Path,
118 icp_root: &Path,
119 network: &str,
120) -> Result<Option<std::path::PathBuf>, Box<dyn std::error::Error>> {
121 let config_path = config_path(workspace_root);
122 emit_root_release_set_manifest_if_ready_with_config(
123 workspace_root,
124 icp_root,
125 network,
126 &config_path,
127 )
128}
129
130pub fn emit_root_release_set_manifest_if_ready_with_config(
133 workspace_root: &Path,
134 icp_root: &Path,
135 network: &str,
136 config_path: &Path,
137) -> Result<Option<std::path::PathBuf>, Box<dyn std::error::Error>> {
138 let artifact_root = resolve_artifact_root(icp_root, network)?;
139 let roles = configured_release_roles(config_path)?;
140
141 for role_name in roles {
142 let artifact_path = artifact_root
143 .join(&role_name)
144 .join(format!("{role_name}.wasm.gz"));
145 if !artifact_path.is_file() {
146 return Ok(None);
147 }
148 }
149
150 emit_root_release_set_manifest_with_config(workspace_root, icp_root, network, config_path)
151 .map(Some)
152}
153
154pub fn load_root_release_set_manifest(
156 manifest_path: &Path,
157) -> Result<RootReleaseSetManifest, Box<dyn std::error::Error>> {
158 let source = fs::read(manifest_path)?;
159 let manifest = serde_json::from_slice(&source)?;
160 validate_root_release_set_manifest(&manifest)?;
161 Ok(manifest)
162}
163
164#[cfg(test)]
169mod tests {
170 use super::*;
171
172 fn manifest() -> RootReleaseSetManifest {
173 RootReleaseSetManifest {
174 release_version: "test-version".to_string(),
175 entries: vec![ReleaseSetEntry {
176 role: "app".to_string(),
177 template_id: "embedded:app".to_string(),
178 artifact_relative_path: ".icp/local/canisters/app/app.wasm.gz".to_string(),
179 payload_size_bytes: 128,
180 payload_sha256_hex: "00".repeat(32),
181 chunk_size_bytes: 1_048_576,
182 chunk_sha256_hex: vec!["00".repeat(32)],
183 }],
184 }
185 }
186
187 #[test]
188 fn release_set_manifest_identity_accepts_canonical_role() {
189 assert!(validate_root_release_set_manifest(&manifest()).is_ok());
190 }
191
192 #[test]
193 fn release_set_manifest_identity_rejects_empty_version_and_role() {
194 let mut missing_version = manifest();
195 missing_version.release_version.clear();
196 let mut missing_role = manifest();
197 missing_role.entries[0].role.clear();
198
199 assert!(validate_root_release_set_manifest(&missing_version).is_err());
200 assert!(validate_root_release_set_manifest(&missing_role).is_err());
201 }
202
203 #[test]
204 fn release_set_manifest_identity_rejects_duplicate_role() {
205 let mut manifest = manifest();
206 manifest.entries.push(manifest.entries[0].clone());
207
208 assert!(validate_root_release_set_manifest(&manifest).is_err());
209 }
210
211 #[test]
212 fn release_set_manifest_identity_rejects_template_role_mismatch() {
213 let mut manifest = manifest();
214 manifest.entries[0].template_id = "embedded:other".to_string();
215
216 assert!(validate_root_release_set_manifest(&manifest).is_err());
217 }
218}