Skip to main content

canic_host/
canister_build.rs

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///
29/// CanisterArtifactBuildOutput
30///
31
32#[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///
42/// WorkspaceBuildContext
43///
44
45#[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
75// Print the current build context once per caller session so caller builds
76// stay readable without repeating root/profile diagnostics for every canister.
77pub 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
87// Return the current build context once per caller session.
88pub 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
118// Build one visible Canic canister artifact for the current workspace.
119pub 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
128/// Copy the uncompressed artifact to the path requested by ICP custom builds.
129///
130/// ICP CLI sets `ICP_WASM_OUTPUT_PATH` for script-backed canister builds. Normal
131/// direct `canic build <fleet> <role>` calls leave it unset and only write Canic's
132/// canonical `.icp/local/canisters/<role>/` artifacts.
133pub 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
156// Build one visible Canic canister artifact and keep the thin-root special cases.
157fn 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
248// Read the real package name from one canister manifest so downstreams are not
249// forced to mirror the reference `canister_<role>` naming scheme.
250fn 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
263// Run one wasm-target cargo build for the requested canister manifest/profile.
264fn 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
308// Extract the service `.did` from one debug wasm so Candid stays deterministic.
309fn 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
336// Remove stale ICP-generated Candid sidecars so local surface scans match the
337// extracted `<role>.did` artifact. Production `ic` builds skip Candid sidecars
338// entirely.
339fn 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
357// Route the implicit bootstrap store through the published public builder.
358fn 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
367// Normalize the bootstrap store builder output to the public canister-artifact shape.
368fn 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
378// Read the current parent process id from Linux procfs when available.
379fn parent_process_id() -> Option<u32> {
380    let stat = fs::read_to_string("/proc/self/stat").ok()?;
381    parse_parent_process_id(&stat)
382}
383
384// Walk ancestor processes until the wrapping `icp` process is found.
385fn 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
400// Read one ancestor's parent process id from procfs.
401fn 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
406// Read one process command name from procfs.
407fn 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
413// Parse Linux `/proc/<pid>/stat` enough to extract the parent process id.
414fn 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}