1use crate::{
2 artifact_io::{
3 embed_candid_metadata, maybe_shrink_wasm_artifact, write_bytes_atomically,
4 write_gzip_artifact, write_wasm_artifact,
5 },
6 bootstrap_store::{BootstrapWasmStoreBuildOutput, build_bootstrap_wasm_store_artifact},
7 cargo_command, icp_environment_from_env,
8 release_set::{
9 canister_manifest_path, config_path, configured_role_lifecycle,
10 emit_root_release_set_manifest_if_ready, icp_root, workspace_root,
11 },
12 remove_optional_file, should_export_candid_artifacts,
13};
14use std::{
15 env, fs,
16 path::{Path, PathBuf},
17 process::Command,
18};
19use toml::Value as TomlValue;
20
21pub use crate::build_profile::CanisterBuildProfile;
22
23const ROOT_ROLE: &str = "root";
24const WASM_STORE_ROLE: &str = "wasm_store";
25const LOCAL_ARTIFACT_ROOT_RELATIVE: &str = ".icp/local/canisters";
26const WASM_TARGET: &str = "wasm32-unknown-unknown";
27
28#[derive(Clone, Debug)]
33pub struct CanisterArtifactBuildOutput {
34 pub artifact_root: PathBuf,
35 pub wasm_path: PathBuf,
36 pub wasm_gz_path: PathBuf,
37 pub did_path: PathBuf,
38 pub manifest_path: Option<PathBuf>,
39}
40
41#[derive(Clone, Debug, Eq, PartialEq)]
46pub struct WorkspaceBuildContext {
47 pub profile: String,
48 pub requested_profile: String,
49 pub network: String,
50 pub workspace_root: PathBuf,
51 pub icp_root: PathBuf,
52}
53
54impl WorkspaceBuildContext {
55 #[must_use]
56 pub fn lines(&self) -> Vec<String> {
57 let mut lines = vec![
58 "Canic build:".to_string(),
59 format!("profile: {}", self.profile),
60 format!("network: {}", self.network),
61 format!("workspace: {}", self.workspace_root.display()),
62 ];
63
64 if self.requested_profile != "unset" {
65 lines.push(format!("requested profile: {}", self.requested_profile));
66 }
67 if self.icp_root != self.workspace_root {
68 lines.push(format!("icp root: {}", self.icp_root.display()));
69 }
70
71 lines
72 }
73}
74
75pub fn print_current_workspace_build_context_once(
78 profile: CanisterBuildProfile,
79) -> Result<(), Box<dyn std::error::Error>> {
80 if let Some(context) = current_workspace_build_context_once(profile)? {
81 eprintln!("{}", context.lines().join("\n"));
82 }
83
84 Ok(())
85}
86
87pub fn current_workspace_build_context_once(
89 profile: CanisterBuildProfile,
90) -> Result<Option<WorkspaceBuildContext>, Box<dyn std::error::Error>> {
91 let workspace_root = workspace_root()?;
92 let icp_root = icp_root()?;
93 let marker_dir = icp_root.join(".icp");
94 fs::create_dir_all(&marker_dir)?;
95
96 let requested_profile = env::var("CANIC_WASM_PROFILE").unwrap_or_else(|_| "unset".to_string());
97 let network = icp_environment_from_env();
98 let marker_key = icp_ancestor_process_id()
99 .or_else(parent_process_id)
100 .unwrap_or_else(std::process::id)
101 .to_string();
102 let marker_file = marker_dir.join(format!(".canic-build-context-{marker_key}"));
103
104 if marker_file.exists() {
105 return Ok(None);
106 }
107
108 fs::write(&marker_file, [])?;
109 Ok(Some(WorkspaceBuildContext {
110 profile: profile.target_dir_name().to_string(),
111 requested_profile,
112 network,
113 workspace_root,
114 icp_root,
115 }))
116}
117
118pub fn build_current_workspace_canister_artifact(
120 canister_name: &str,
121 profile: CanisterBuildProfile,
122) -> Result<CanisterArtifactBuildOutput, Box<dyn std::error::Error>> {
123 let workspace_root = workspace_root()?;
124 let icp_root = icp_root()?;
125 build_canister_artifact(&workspace_root, &icp_root, canister_name, profile)
126}
127
128pub fn copy_icp_wasm_output(
134 canister_name: &str,
135 output: &CanisterArtifactBuildOutput,
136) -> Result<(), Box<dyn std::error::Error>> {
137 let Some(path) = env::var_os("ICP_WASM_OUTPUT_PATH").map(PathBuf::from) else {
138 return Ok(());
139 };
140
141 if !output.wasm_path.is_file() {
142 return Err(format!(
143 "missing ICP wasm output source for {canister_name}: {}",
144 output.wasm_path.display()
145 )
146 .into());
147 }
148
149 if let Some(parent) = path.parent() {
150 fs::create_dir_all(parent)?;
151 }
152 fs::copy(&output.wasm_path, Path::new(&path))?;
153 Ok(())
154}
155
156fn build_canister_artifact(
158 workspace_root: &Path,
159 icp_root: &Path,
160 canister_name: &str,
161 profile: CanisterBuildProfile,
162) -> Result<CanisterArtifactBuildOutput, Box<dyn std::error::Error>> {
163 if canister_name == WASM_STORE_ROLE {
164 return build_hidden_wasm_store_artifact(workspace_root, icp_root, profile);
165 }
166
167 validate_artifact_role_attached(workspace_root, canister_name)?;
168 let canister_manifest_path = canister_manifest_path(workspace_root, canister_name)?;
169 let canister_package_name = load_canister_package_name(&canister_manifest_path)?;
170 let artifact_root = icp_root
171 .join(LOCAL_ARTIFACT_ROOT_RELATIVE)
172 .join(canister_name);
173 let wasm_path = artifact_root.join(format!("{canister_name}.wasm"));
174 let wasm_gz_path = artifact_root.join(format!("{canister_name}.wasm.gz"));
175 let did_path = artifact_root.join(format!("{canister_name}.did"));
176 let require_embedded_release_artifacts = canister_name == ROOT_ROLE;
177
178 if require_embedded_release_artifacts {
179 build_bootstrap_wasm_store_artifact(workspace_root, icp_root, profile)?;
180 }
181
182 fs::create_dir_all(&artifact_root)?;
183 remove_stale_icp_candid_sidecars(&artifact_root)?;
184
185 let release_wasm_path = run_canister_build(
186 workspace_root,
187 icp_root,
188 &canister_manifest_path,
189 &canister_package_name,
190 profile,
191 require_embedded_release_artifacts,
192 )?;
193 write_wasm_artifact(&release_wasm_path, &wasm_path)?;
194 maybe_shrink_wasm_artifact(&wasm_path)?;
195
196 let network = icp_environment_from_env();
197 if should_export_candid_artifacts(&network) {
198 let debug_wasm_path = run_canister_build(
199 workspace_root,
200 icp_root,
201 &canister_manifest_path,
202 &canister_package_name,
203 CanisterBuildProfile::Debug,
204 require_embedded_release_artifacts,
205 )?;
206 extract_candid(&debug_wasm_path, &did_path)?;
207 embed_candid_metadata(&wasm_path, &did_path)?;
208 } else {
209 remove_optional_file(&did_path)?;
210 }
211 write_gzip_artifact(&wasm_path, &wasm_gz_path)?;
212
213 let manifest_path =
214 emit_root_release_set_manifest_if_ready(workspace_root, icp_root, &network)?;
215
216 Ok(CanisterArtifactBuildOutput {
217 artifact_root,
218 wasm_path,
219 wasm_gz_path,
220 did_path,
221 manifest_path,
222 })
223}
224
225fn validate_artifact_role_attached(
226 workspace_root: &Path,
227 canister_name: &str,
228) -> Result<(), Box<dyn std::error::Error>> {
229 let config_path = config_path(workspace_root);
230 let roles = configured_role_lifecycle(&config_path)?;
231 let Some(row) = roles.iter().find(|row| row.role == canister_name) else {
232 return Err(format!(
233 "role {canister_name} is not declared in {}; declare the role before building an artifact",
234 config_path.display()
235 )
236 .into());
237 };
238 if !row.attached {
239 return Err(format!(
240 "role {}.{} is declared but not attached to topology; run `canic fleet role attach {} {} --subnet <subnet>` before building an artifact",
241 row.fleet, row.role, row.fleet, row.role
242 )
243 .into());
244 }
245 Ok(())
246}
247
248fn load_canister_package_name(manifest_path: &Path) -> Result<String, Box<dyn std::error::Error>> {
251 let manifest_source = fs::read_to_string(manifest_path)?;
252 let manifest = toml::from_str::<TomlValue>(&manifest_source)?;
253 let package_name = manifest
254 .get("package")
255 .and_then(TomlValue::as_table)
256 .and_then(|package| package.get("name"))
257 .and_then(TomlValue::as_str)
258 .ok_or_else(|| format!("missing package.name in {}", manifest_path.display()))?;
259
260 Ok(package_name.to_string())
261}
262
263fn run_canister_build(
265 workspace_root: &Path,
266 icp_root: &Path,
267 manifest_path: &Path,
268 package_name: &str,
269 profile: CanisterBuildProfile,
270 require_embedded_release_artifacts: bool,
271) -> Result<PathBuf, Box<dyn std::error::Error>> {
272 let target_root = std::env::var_os("CARGO_TARGET_DIR")
273 .map_or_else(|| workspace_root.join("target"), PathBuf::from);
274 let mut command = cargo_command();
275 command
276 .current_dir(workspace_root)
277 .env("CARGO_TARGET_DIR", &target_root)
278 .env("CANIC_ICP_ROOT", icp_root)
279 .args([
280 "build",
281 "--manifest-path",
282 &manifest_path.display().to_string(),
283 "--target",
284 WASM_TARGET,
285 ])
286 .args(profile.cargo_args());
287
288 if require_embedded_release_artifacts {
289 command.env("CANIC_REQUIRE_EMBEDDED_RELEASE_ARTIFACTS", "1");
290 }
291
292 let output = command.output()?;
293 if !output.status.success() {
294 return Err(format!(
295 "cargo build failed for {}: {}",
296 manifest_path.display(),
297 String::from_utf8_lossy(&output.stderr)
298 )
299 .into());
300 }
301
302 Ok(target_root
303 .join(WASM_TARGET)
304 .join(profile.target_dir_name())
305 .join(format!("{}.wasm", package_name.replace('-', "_"))))
306}
307
308fn extract_candid(
310 debug_wasm_path: &Path,
311 did_path: &Path,
312) -> Result<(), Box<dyn std::error::Error>> {
313 let output = Command::new("candid-extractor")
314 .arg(debug_wasm_path)
315 .output()
316 .map_err(|err| {
317 format!(
318 "failed to run candid-extractor for {}: {err}",
319 debug_wasm_path.display()
320 )
321 })?;
322
323 if !output.status.success() {
324 return Err(format!(
325 "candid-extractor failed for {}: {}",
326 debug_wasm_path.display(),
327 String::from_utf8_lossy(&output.stderr)
328 )
329 .into());
330 }
331
332 write_bytes_atomically(did_path, &output.stdout)?;
333 Ok(())
334}
335
336fn remove_stale_icp_candid_sidecars(artifact_root: &Path) -> std::io::Result<()> {
340 for relative in [
341 "constructor.did",
342 "service.did",
343 "service.did.d.ts",
344 "service.did.js",
345 ] {
346 let path = artifact_root.join(relative);
347 match fs::remove_file(path) {
348 Ok(()) => {}
349 Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
350 Err(err) => return Err(err),
351 }
352 }
353
354 Ok(())
355}
356
357fn build_hidden_wasm_store_artifact(
359 workspace_root: &Path,
360 icp_root: &Path,
361 profile: CanisterBuildProfile,
362) -> Result<CanisterArtifactBuildOutput, Box<dyn std::error::Error>> {
363 let output = build_bootstrap_wasm_store_artifact(workspace_root, icp_root, profile)?;
364 Ok(map_bootstrap_output(output))
365}
366
367fn map_bootstrap_output(output: BootstrapWasmStoreBuildOutput) -> CanisterArtifactBuildOutput {
369 CanisterArtifactBuildOutput {
370 artifact_root: output.artifact_root,
371 wasm_path: output.wasm_path,
372 wasm_gz_path: output.wasm_gz_path,
373 did_path: output.did_path,
374 manifest_path: None,
375 }
376}
377
378fn parent_process_id() -> Option<u32> {
380 let stat = fs::read_to_string("/proc/self/stat").ok()?;
381 parse_parent_process_id(&stat)
382}
383
384fn icp_ancestor_process_id() -> Option<u32> {
386 let mut pid = parent_process_id()?;
387 loop {
388 if process_comm(pid).as_deref() == Some("icp") {
389 return Some(pid);
390 }
391
392 let parent = process_parent_id(pid)?;
393 if parent == 0 || parent == pid {
394 return None;
395 }
396 pid = parent;
397 }
398}
399
400fn process_parent_id(pid: u32) -> Option<u32> {
402 let stat = fs::read_to_string(format!("/proc/{pid}/stat")).ok()?;
403 parse_parent_process_id(&stat)
404}
405
406fn process_comm(pid: u32) -> Option<String> {
408 fs::read_to_string(format!("/proc/{pid}/comm"))
409 .ok()
410 .map(|comm| comm.trim().to_string())
411}
412
413fn parse_parent_process_id(stat: &str) -> Option<u32> {
415 let (_, suffix) = stat.rsplit_once(") ")?;
416 let mut parts = suffix.split_whitespace();
417 let _state = parts.next()?;
418 parts.next()?.parse::<u32>().ok()
419}
420
421#[cfg(test)]
422mod tests {
423 use super::{parse_parent_process_id, remove_stale_icp_candid_sidecars};
424 use crate::test_support::temp_dir;
425 use std::fs;
426
427 #[test]
428 fn parse_parent_process_id_accepts_proc_stat_shape() {
429 let stat = "12345 (build_canister_ar) S 67890 0 0 0";
430 assert_eq!(parse_parent_process_id(stat), Some(67890));
431 }
432
433 #[test]
434 fn remove_stale_icp_candid_sidecars_keeps_primary_role_did() {
435 let temp_root = temp_dir("canic-canister-build-sidecars");
436 let _ = fs::remove_dir_all(&temp_root);
437 fs::create_dir_all(&temp_root).unwrap();
438
439 for name in [
440 "constructor.did",
441 "service.did",
442 "service.did.d.ts",
443 "service.did.js",
444 "app.did",
445 ] {
446 fs::write(temp_root.join(name), "x").unwrap();
447 }
448
449 remove_stale_icp_candid_sidecars(&temp_root).unwrap();
450
451 assert!(!temp_root.join("constructor.did").exists());
452 assert!(!temp_root.join("service.did").exists());
453 assert!(!temp_root.join("service.did.d.ts").exists());
454 assert!(!temp_root.join("service.did.js").exists());
455 assert!(temp_root.join("app.did").exists());
456
457 let _ = fs::remove_dir_all(temp_root);
458 }
459}