use crate::{
cli::{
clap::{parse_matches, string_option, value_arg},
defaults::local_network,
globals::internal_network_arg,
help::print_help_or_version,
},
output, version_text,
};
use canic_host::build_provenance::{BuildProvenanceRequest, build_provenance_envelope};
use canic_host::canister_build::{
CanisterBuildProfile, build_current_workspace_canister_artifact, copy_icp_wasm_output,
print_current_workspace_build_context_once,
};
use canic_host::evidence_envelope::{CommandProvenanceV1, command_path_for_root};
use canic_host::{
install_root::{current_canic_project_root, discover_project_canic_config_choices},
release_set::{
configured_fleet_name, configured_role_lifecycle, matching_fleet_config_paths,
workspace_root,
},
};
use clap::Command as ClapCommand;
use std::{
env,
ffi::OsString,
path::{Path, PathBuf},
time::{SystemTime, UNIX_EPOCH},
};
use thiserror::Error as ThisError;
const BUILD_HELP_AFTER: &str = "\
Examples:
canic build demo app
canic build demo app --provenance artifacts/app-provenance.json
canic --network local build demo root
canic build --profile fast --workspace backend --icp-root . --config backend/fleets/demo/canic.toml demo root
The selected fleet must have a matching canic.toml, and the selected role must
be attached to topology before an artifact build is allowed.
The command writes .icp/local/canisters/<role>/<role>.wasm and .wasm.gz.
Use --provenance <path> to additionally write a stable EvidenceEnvelopeV1
containing canic.build_provenance.v1.";
#[derive(Debug, ThisError)]
pub enum BuildCommandError {
#[error("{0}")]
Usage(String),
#[error("no Canic fleet configs found under fleets; run canic fleet create <name>")]
NoConfigChoices,
#[error("unknown fleet {0}; run canic fleet list to inspect config-defined fleets")]
UnknownFleet(String),
#[error(
"multiple configs declare fleet {0}; use distinct [fleet].name values before selecting it"
)]
DuplicateFleet(String),
#[error(transparent)]
Build(#[from] Box<dyn std::error::Error>),
#[error(transparent)]
Io(#[from] std::io::Error),
#[error(transparent)]
Json(#[from] serde_json::Error),
}
#[derive(Clone, Debug, Eq, PartialEq)]
struct BuildOptions {
fleet: String,
role: String,
network: String,
profile: Option<CanisterBuildProfile>,
workspace: Option<String>,
icp_root: Option<String>,
config: Option<String>,
provenance: Option<PathBuf>,
}
impl BuildOptions {
fn parse<I>(args: I) -> Result<Self, BuildCommandError>
where
I: IntoIterator<Item = OsString>,
{
let matches =
parse_matches(build_command(), args).map_err(|_| BuildCommandError::Usage(usage()))?;
Ok(Self {
fleet: string_option(&matches, "fleet").expect("clap requires fleet"),
role: string_option(&matches, "role").expect("clap requires role"),
network: string_option(&matches, "network").unwrap_or_else(local_network),
profile: string_option(&matches, "profile")
.as_deref()
.map(parse_profile)
.transpose()?,
workspace: string_option(&matches, "workspace"),
icp_root: string_option(&matches, "icp-root"),
config: string_option(&matches, "config"),
provenance: string_option(&matches, "provenance").map(PathBuf::from),
})
}
}
pub fn run<I>(args: I) -> Result<(), BuildCommandError>
where
I: IntoIterator<Item = OsString>,
{
let args = args.into_iter().collect::<Vec<_>>();
if print_help_or_version(&args, usage, version_text()) {
return Ok(());
}
let options = BuildOptions::parse(args)?;
let _guard = BuildEnvGuard::apply(&options)?;
let profile = options
.profile
.unwrap_or_else(CanisterBuildProfile::current);
print_current_workspace_build_context_once(profile)?;
validate_attached_role(&options)?;
let output = build_current_workspace_canister_artifact(&options.role, profile)?;
copy_icp_wasm_output(&options.role, &output)?;
write_build_provenance_if_requested(&options, profile, output.clone())?;
println!("{}", output.wasm_gz_path.display());
Ok(())
}
fn build_command() -> ClapCommand {
ClapCommand::new("build")
.bin_name("canic build")
.about("Build one Canic canister artifact")
.disable_help_flag(true)
.override_usage("canic build [OPTIONS] <fleet> <role>")
.arg(
value_arg("fleet")
.value_name("fleet")
.required(true)
.help("Config-defined fleet name to build from"),
)
.arg(
value_arg("role")
.value_name("role")
.required(true)
.help("Config-defined canister role to build"),
)
.arg(
value_arg("workspace")
.long("workspace")
.value_name("dir")
.num_args(1)
.help("Cargo workspace root; inferred from the current directory when omitted"),
)
.arg(
value_arg("icp-root")
.long("icp-root")
.value_name("dir")
.num_args(1)
.help("ICP project root for .icp artifacts; inferred when omitted"),
)
.arg(
value_arg("config")
.long("config")
.value_name("file")
.num_args(1)
.help("Canic config path; inferred from the workspace when omitted"),
)
.arg(
value_arg("profile")
.long("profile")
.value_name("debug|fast|release")
.num_args(1)
.help("Canister wasm build profile; defaults to CANIC_WASM_PROFILE or release"),
)
.arg(
value_arg("provenance")
.long("provenance")
.value_name("file")
.num_args(1)
.help("Write an EvidenceEnvelopeV1 build provenance artifact to this file"),
)
.arg(internal_network_arg())
.after_help(BUILD_HELP_AFTER)
}
fn usage() -> String {
let mut command = build_command();
command.render_help().to_string()
}
fn validate_attached_role(options: &BuildOptions) -> Result<(), BuildCommandError> {
let config_path = resolve_build_config_path(options)?;
let roles = configured_role_lifecycle(&config_path)?;
let Some(row) = roles.iter().find(|row| row.role == options.role) else {
return Err(BuildCommandError::Usage(format!(
"role {}.{} is not declared in {}",
options.fleet,
options.role,
config_path.display()
)));
};
if !row.attached {
return Err(BuildCommandError::Usage(format!(
"role {}.{} is declared but not attached to topology; run `canic fleet role attach {} {} --subnet <subnet>` before building an artifact",
options.fleet, options.role, options.fleet, options.role
)));
}
Ok(())
}
fn write_build_provenance_if_requested(
options: &BuildOptions,
profile: CanisterBuildProfile,
output: canic_host::canister_build::CanisterArtifactBuildOutput,
) -> Result<(), BuildCommandError> {
let Some(path) = &options.provenance else {
return Ok(());
};
let workspace_root = workspace_root()?;
let config_path = resolve_build_config_path(options)?;
let request = BuildProvenanceRequest {
fleet: options.fleet.clone(),
role: options.role.clone(),
network: options.network.clone(),
profile,
workspace_root: workspace_root.clone(),
config_path,
output,
command: build_command_provenance(options, &workspace_root),
generated_at: current_build_generated_at()?,
canic_version: env!("CARGO_PKG_VERSION").to_string(),
};
let envelope = build_provenance_envelope(&request)?;
output::write_pretty_json_file::<_, BuildCommandError>(path, &envelope)?;
Ok(())
}
fn build_command_provenance(options: &BuildOptions, workspace_root: &Path) -> CommandProvenanceV1 {
let mut argv_normalized = vec![
"canic".to_string(),
"build".to_string(),
options.fleet.clone(),
options.role.clone(),
];
if let Some(profile) = options.profile {
argv_normalized.push("--profile".to_string());
argv_normalized.push(profile.target_dir_name().to_string());
}
if let Some(workspace) = &options.workspace {
push_path_arg(
&mut argv_normalized,
"--workspace",
workspace,
workspace_root,
);
}
if let Some(icp_root) = &options.icp_root {
push_path_arg(&mut argv_normalized, "--icp-root", icp_root, workspace_root);
}
if let Some(config) = &options.config {
push_path_arg(&mut argv_normalized, "--config", config, workspace_root);
}
if options.network != local_network() {
argv_normalized.push("--network".to_string());
argv_normalized.push(options.network.clone());
}
if let Some(provenance) = &options.provenance {
argv_normalized.push("--provenance".to_string());
argv_normalized.push(command_path_for_root(provenance, workspace_root));
}
CommandProvenanceV1 {
name: "canic build".to_string(),
argv_normalized,
argv_redactions: Vec::new(),
format: "provenance".to_string(),
}
}
fn push_path_arg(argv_normalized: &mut Vec<String>, name: &str, path: &str, root: &Path) {
argv_normalized.push(name.to_string());
argv_normalized.push(command_path_for_root(Path::new(path), root));
}
fn current_build_generated_at() -> Result<String, Box<dyn std::error::Error>> {
Ok(format!(
"unix:{}",
SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs()
))
}
fn resolve_build_config_path(options: &BuildOptions) -> Result<PathBuf, BuildCommandError> {
if let Some(config) = &options.config {
let path = normalize_build_path(config);
validate_config_fleet(&path, &options.fleet)?;
return Ok(path);
}
let project_root = options.workspace.as_ref().map_or_else(
|| current_canic_project_root().map_err(BuildCommandError::from),
|workspace| Ok(normalize_build_path(workspace)),
)?;
let choices = discover_project_canic_config_choices(&project_root)?;
if choices.is_empty() {
return Err(BuildCommandError::NoConfigChoices);
}
let matches = matching_fleet_config_paths(&choices, &options.fleet);
match matches.as_slice() {
[path] => Ok(path.clone()),
[] => Err(BuildCommandError::UnknownFleet(options.fleet.clone())),
_ => Err(BuildCommandError::DuplicateFleet(options.fleet.clone())),
}
}
fn validate_config_fleet(
config_path: &Path,
expected_fleet: &str,
) -> Result<(), BuildCommandError> {
let actual_fleet = configured_fleet_name(config_path)?;
if actual_fleet != expected_fleet {
return Err(BuildCommandError::Usage(format!(
"selected config declares fleet {actual_fleet:?}, not {expected_fleet:?}"
)));
}
Ok(())
}
fn normalize_build_path(path: &str) -> PathBuf {
let path = PathBuf::from(path);
if path.is_absolute() {
path
} else {
env::current_dir()
.expect("current directory must be available")
.join(path)
}
}
fn parse_profile(value: &str) -> Result<CanisterBuildProfile, BuildCommandError> {
match value {
"debug" => Ok(CanisterBuildProfile::Debug),
"fast" => Ok(CanisterBuildProfile::Fast),
"release" => Ok(CanisterBuildProfile::Release),
_ => Err(BuildCommandError::Usage(format!(
"invalid build profile: {value}\n\n{}",
usage()
))),
}
}
struct BuildEnvGuard {
previous_network: Option<OsString>,
previous_workspace: Option<OsString>,
previous_icp_root: Option<OsString>,
previous_config: Option<OsString>,
}
impl BuildEnvGuard {
fn apply(options: &BuildOptions) -> Result<Self, BuildCommandError> {
let guard = Self {
previous_network: env::var_os("ICP_ENVIRONMENT"),
previous_workspace: env::var_os("CANIC_WORKSPACE_ROOT"),
previous_icp_root: env::var_os("CANIC_ICP_ROOT"),
previous_config: env::var_os("CANIC_CONFIG_PATH"),
};
let config_path = resolve_build_config_path(options)?;
set_env("ICP_ENVIRONMENT", &options.network);
set_optional_env("CANIC_WORKSPACE_ROOT", options.workspace.as_deref());
set_optional_env("CANIC_ICP_ROOT", options.icp_root.as_deref());
set_env("CANIC_CONFIG_PATH", config_path);
Ok(guard)
}
}
impl Drop for BuildEnvGuard {
fn drop(&mut self) {
restore_env("ICP_ENVIRONMENT", self.previous_network.take());
restore_env("CANIC_WORKSPACE_ROOT", self.previous_workspace.take());
restore_env("CANIC_ICP_ROOT", self.previous_icp_root.take());
restore_env("CANIC_CONFIG_PATH", self.previous_config.take());
}
}
fn set_optional_env(key: &str, value: Option<&str>) {
if let Some(value) = value {
set_env(key, value);
}
}
fn set_env<K, V>(key: K, value: V)
where
K: AsRef<std::ffi::OsStr>,
V: AsRef<std::ffi::OsStr>,
{
unsafe {
env::set_var(key, value);
}
}
fn restore_env(key: &str, value: Option<OsString>) {
unsafe {
match value {
Some(value) => env::set_var(key, value),
None => env::remove_var(key),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::test_support::temp_dir;
use std::fs;
#[test]
fn build_parses_required_fleet_and_role() {
let options = BuildOptions::parse([OsString::from("demo"), OsString::from("app")])
.expect("parse build options");
assert_eq!(options.fleet, "demo");
assert_eq!(options.role, "app");
assert_eq!(options.network, "local");
assert_eq!(options.profile, None);
assert_eq!(options.workspace, None);
assert_eq!(options.icp_root, None);
assert_eq!(options.config, None);
assert_eq!(options.provenance, None);
}
#[test]
fn build_accepts_internal_network() {
let options = BuildOptions::parse([
OsString::from("demo"),
OsString::from("app"),
OsString::from("--__canic-network"),
OsString::from("localnet"),
])
.expect("parse build options");
assert_eq!(options.network, "localnet");
}
#[test]
fn build_accepts_explicit_context_paths() {
let options = BuildOptions::parse([
OsString::from("--workspace"),
OsString::from("backend"),
OsString::from("--icp-root"),
OsString::from("."),
OsString::from("--config"),
OsString::from("backend/src/canisters/canic.toml"),
OsString::from("--profile"),
OsString::from("fast"),
OsString::from("--provenance"),
OsString::from("artifacts/root-provenance.json"),
OsString::from("demo"),
OsString::from("root"),
])
.expect("parse build options");
assert_eq!(options.fleet, "demo");
assert_eq!(options.role, "root");
assert_eq!(options.profile, Some(CanisterBuildProfile::Fast));
assert_eq!(options.workspace.as_deref(), Some("backend"));
assert_eq!(options.icp_root.as_deref(), Some("."));
assert_eq!(
options.config.as_deref(),
Some("backend/src/canisters/canic.toml")
);
assert_eq!(
options.provenance.as_deref(),
Some(Path::new("artifacts/root-provenance.json"))
);
}
#[test]
fn build_requires_role() {
std::assert_matches!(
BuildOptions::parse([OsString::from("demo")]),
Err(BuildCommandError::Usage(_))
);
}
#[test]
fn build_rejects_invalid_profile() {
std::assert_matches!(
BuildOptions::parse([
OsString::from("--profile"),
OsString::from("tiny"),
OsString::from("demo"),
OsString::from("app")
]),
Err(BuildCommandError::Usage(_))
);
}
#[test]
fn build_rejects_old_role_only_shape() {
std::assert_matches!(
BuildOptions::parse([OsString::from("app")]),
Err(BuildCommandError::Usage(_))
);
}
#[test]
fn build_usage_lists_fleet_and_role() {
let text = usage();
assert!(text.contains("Usage: canic build [OPTIONS] <fleet> <role>"));
assert!(text.contains("canic build demo app"));
assert!(text.contains("--provenance <file>"));
assert!(text.contains("be attached to topology"));
}
#[test]
fn build_command_provenance_redacts_paths_outside_workspace() {
let root = temp_dir("canic-cli-build-provenance-command");
fs::create_dir_all(&root).expect("create root");
let outside = temp_dir("canic-cli-build-provenance-outside");
fs::create_dir_all(&outside).expect("create outside");
let mut options = build_options(&root, "demo", "app");
options.provenance = Some(outside.join("build-provenance.json"));
let provenance = build_command_provenance(&options, &root);
fs::remove_dir_all(root).expect("remove root");
fs::remove_dir_all(outside).expect("remove outside");
assert!(
provenance
.argv_normalized
.contains(&"<redacted:absolute-outside-root>".to_string())
);
}
#[test]
fn build_resolves_config_from_selected_fleet() {
let root = temp_dir("canic-cli-build-config");
let config_path = write_build_config(&root, true);
let options = build_options(&root, "demo", "app");
let resolved = resolve_build_config_path(&options).expect("resolve build config");
fs::remove_dir_all(root).expect("remove temp root");
assert_eq!(resolved, config_path);
}
#[test]
fn build_preflight_rejects_declared_only_role() {
let root = temp_dir("canic-cli-build-declared-only");
write_build_config(&root, false);
let options = build_options(&root, "demo", "app");
let err = validate_attached_role(&options)
.expect_err("declared-only role should fail")
.to_string();
fs::remove_dir_all(root).expect("remove temp root");
assert!(err.contains("declared but not attached"));
assert!(err.contains("canic fleet role attach demo app --subnet <subnet>"));
}
#[test]
fn build_preflight_accepts_attached_role() {
let root = temp_dir("canic-cli-build-attached");
write_build_config(&root, true);
let options = build_options(&root, "demo", "app");
validate_attached_role(&options).expect("attached role should pass");
fs::remove_dir_all(root).expect("remove temp root");
}
#[test]
fn explicit_build_config_must_match_selected_fleet() {
let root = temp_dir("canic-cli-build-fleet-mismatch");
let config_path = write_build_config(&root, true);
let mut options = build_options(&root, "other", "app");
options.config = Some(config_path.display().to_string());
let err = resolve_build_config_path(&options)
.expect_err("fleet mismatch should fail")
.to_string();
fs::remove_dir_all(root).expect("remove temp root");
assert!(err.contains("not \"other\""));
}
fn build_options(root: &std::path::Path, fleet: &str, role: &str) -> BuildOptions {
BuildOptions {
fleet: fleet.to_string(),
role: role.to_string(),
network: "local".to_string(),
profile: None,
workspace: Some(root.display().to_string()),
icp_root: None,
config: None,
provenance: None,
}
}
fn write_build_config(root: &std::path::Path, attach_app: bool) -> PathBuf {
let fleet_dir = root.join("fleets/demo");
fs::create_dir_all(&fleet_dir).expect("create fleet dir");
fs::write(root.join("Cargo.toml"), "[workspace]\nmembers = []\n")
.expect("write workspace manifest");
let mut config = r#"
controllers = []
app_index = []
[fleet]
name = "demo"
[roles.root]
kind = "root"
package = "root"
[roles.app]
kind = "canister"
package = "app"
[subnets.prime.canisters.root]
kind = "root"
"#
.to_string();
if attach_app {
config.push_str(
r#"
[subnets.prime.canisters.app]
kind = "singleton"
"#,
);
}
let config_path = fleet_dir.join("canic.toml");
fs::write(&config_path, config).expect("write canic config");
config_path
}
}