Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
84 changes: 81 additions & 3 deletions crates/icp-cli/src/commands/deploy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@ use icp::{
use icp_canister_interfaces::candid_ui::MAINNET_CANDID_UI_CID;
use itertools::Itertools;
use serde::Serialize;
use std::collections::{BTreeMap, BTreeSet, HashSet};
use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
use std::sync::Arc;
use std::time::Duration;
use tracing::info;

Expand All @@ -26,6 +27,7 @@ use crate::{
build::build_many_with_progress_bar,
candid_compat::check_candid_compatibility_many,
create::{CreateFunding, CreateOperation, CreateTarget},
customize,
install::{install_many, resolve_install_mode_and_status},
proxy_management,
settings::{sync_controller_dependents, sync_settings_many},
Expand Down Expand Up @@ -84,6 +86,12 @@ pub(crate) struct DeployArgs {
#[arg(long, short)]
pub(crate) yes: bool,

/// Prompt for the init argument fields and environment variables the
/// project's `icp_customize.yaml` declares, instead of deploying with the
/// manifest's values as written.
#[arg(long)]
pub(crate) customize: bool,

#[command(flatten)]
pub(crate) identity: IdentityOpt,

Expand Down Expand Up @@ -179,6 +187,63 @@ pub(crate) async fn exec(ctx: &Context, args: &DeployArgs) -> Result<(), anyhow:
)
.await?;

// Customization is opt-in: without `--customize`, the project's
// `icp_customize.yaml` is inert and every canister deploys with the
// manifest's `init_args` and `environment_variables` as written. `--yes` is
// unrelated — it suppresses confirmations, not the answers this collects.
//
// The prompts run before the build so the user fills them in upfront,
// uninterrupted by build output.
//
// A workspace declares its customizations once, in the root project's file:
// options address a member's canister through its store key, so the file is
// read from the workspace root even for a member-scoped deploy, and options
// outside this deploy's scope are dropped by `prompt_customizations`.
let customizations: Arc<customize::Customizations> = if !args.customize {
Arc::new(customize::Customizations::default())
} else {
let project = ctx.project.load().await.map_err(|e| anyhow!(e))?;
let customize_path = project.dir.join(customize::CUSTOMIZE_FILE);
let workspace_canisters: Vec<&str> = project.canisters.keys().map(String::as_str).collect();
// Independent of whether the root declares customizations of its own: a
// vendored member's file goes unread either way, and a root with no file
// is exactly the case where its options would vanish unnoticed.
customize::warn_unread_member_customize_files(&project.dir, &workspace_canisters);

let manifest = customize::load_customize_manifest(&project.dir)
.map_err(|e| anyhow!(e))?
// `--customize` asked for prompts this project does not declare.
// Deploying with the manifest's args regardless would ignore the flag.
.ok_or_else(|| {
anyhow!(
"`--customize` was passed, but there is no `{}` at '{}'",
customize::CUSTOMIZE_FILE,
project.dir
)
})?;

// Validate against the whole workspace, not just this deploy's
// canisters: a mistyped project path must not pass as an option for
// something else's canister.
customize::validate_canister_refs(&manifest, &workspace_canisters, &customize_path)
.map_err(|e| anyhow!(e))?;

let init_args: HashMap<String, Option<icp::InitArgs>> = cnames
.iter()
.map(|name| {
let ia = env
.get_canister_info(name)
.ok()
.and_then(|(_, info)| info.init_args.clone());
(name.clone(), ia)
})
.collect();
Arc::new(
customize::prompt_customizations(&manifest, &cnames, &init_args, &customize_path)
.map_err(|e| anyhow!(e))?,
)
};

// Build the selected canisters
info!("Building canisters:");

Expand Down Expand Up @@ -298,6 +363,7 @@ pub(crate) async fn exec(ctx: &Context, args: &DeployArgs) -> Result<(), anyhow:
let env_canisters = &env.canisters;
let target_canisters = try_join_all(cnames.iter().map(|name| {
let environment_selection = environment_selection.clone();
let customizations = customizations.clone();
async move {
let cid = ctx
.get_canister_id_for_env(
Expand All @@ -309,7 +375,12 @@ pub(crate) async fn exec(ctx: &Context, args: &DeployArgs) -> Result<(), anyhow:
let (_, info) = env_canisters
.get(name)
.ok_or_else(|| anyhow!("Canister id exists but no canister info"))?;
Ok::<_, anyhow::Error>((cid, info.clone()))
let mut info = info.clone();
// Ahead of both `set_binding_env_vars_many` and `sync_settings_many`,
// which each write these settings out: the answers travel with the
// canister rather than being applied twice.
customizations.overlay_env_vars(name, &mut info.settings);
Ok::<_, anyhow::Error>((cid, info))
}
}))
.await?;
Expand Down Expand Up @@ -345,6 +416,7 @@ pub(crate) async fn exec(ctx: &Context, args: &DeployArgs) -> Result<(), anyhow:
let canisters = try_join_all(cnames.iter().map(|name| {
let environment_selection = environment_selection.clone();
let agent = agent.clone();
let customizations = customizations.clone();
async move {
let cid = ctx
.get_canister_id_for_env(
Expand All @@ -361,9 +433,15 @@ pub(crate) async fn exec(ctx: &Context, args: &DeployArgs) -> Result<(), anyhow:
let (_canister_path, canister_info) =
env.get_canister_info(name).map_err(|e| anyhow!(e))?;

// CLI --args/--args-file take priority over manifest init_args
// Priority: CLI --args/--args-file > icp_customize.yaml prompts > manifest init_args
let init_args_bytes = if args.args_opt.is_some() {
args.args_opt.resolve_bytes()?
} else if let Some(customized) = customizations.init_args.get(name) {
Some(
customized
.to_bytes()
.map_err(|e| anyhow!("failed to serialize customized init args: {e}"))?,
)
} else {
canister_info
.init_args
Expand Down
59 changes: 58 additions & 1 deletion crates/icp-cli/src/operations/bundle.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,13 @@ use icp::{
use snafu::{OptionExt, ResultExt, Snafu};
use tar::Builder;

use crate::operations::build::{BuildManyError, build_many_with_progress_bar};
use crate::operations::{
build::{BuildManyError, build_many_with_progress_bar},
customize::{
CUSTOMIZE_FILE, CustomizeManifest, UnknownCanisterError, validate_canister_refs,
warn_unread_member_customize_files,
},
};

#[derive(Debug, Snafu)]
pub enum BundleError {
Expand Down Expand Up @@ -125,6 +131,18 @@ pub enum BundleError {
source: fs::IoError,
},

#[snafu(display("failed to read '{path}'"))]
ReadCustomize { path: PathBuf, source: fs::IoError },

#[snafu(display("failed to parse '{path}'"))]
ParseCustomize {
path: PathBuf,
source: serde_yaml::Error,
},

#[snafu(transparent)]
CustomizeCanister { source: UnknownCanisterError },

#[snafu(display("failed to serialize bundle manifest"))]
SerializeManifest { source: serde_yaml::Error },

Expand Down Expand Up @@ -414,9 +432,42 @@ pub(crate) async fn create_bundle(

let app_manifest = prepare_app_manifest(project_dir, &canonical_project_dir)?;

// A workspace declares its customizations once, in the root project's file,
// whose options address a member's canister by store key. Both the file and
// the store keys land in the archive unchanged — an instance sits at its
// workspace-relative directory — so the bundle's prompts resolve after
// extraction exactly as they do here. Check them now rather than leaving a
// typo to surface on whoever deploys the bundle.
let customize_path = project_dir.join(CUSTOMIZE_FILE);
let customize_bytes = match fs::read(&customize_path) {
Ok(bytes) => Some(bytes),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => None,
Err(source) => {
return Err(BundleError::ReadCustomize {
path: customize_path,
source,
});
Comment on lines +441 to +449
}
};
let canister_names: Vec<&str> = canisters
.iter()
.map(|(_, canister)| canister.name.as_str())
.collect();
// Independent of whether the root declares customizations of its own: a
// vendored member's file is left out of the archive either way.
warn_unread_member_customize_files(project_dir, &canister_names);
if let Some(bytes) = &customize_bytes {
let manifest: CustomizeManifest =
serde_yaml::from_slice(bytes).context(ParseCustomizeSnafu {
path: &customize_path,
})?;
validate_canister_refs(&manifest, &canister_names, &customize_path)?;
}

write_archive(
output,
&manifests,
customize_bytes.as_deref(),
&bundle_artifacts,
&init_args_files,
app_manifest.as_ref(),
Expand Down Expand Up @@ -1132,6 +1183,7 @@ impl<W: Write> ArchiveWriter<W> {
fn write_archive(
output: &Path,
manifests: &[InstanceManifest],
customize_bytes: Option<&[u8]>,
artifacts: &BundleArtifacts,
init_args_files: &[InitArgsFile],
app_manifest: Option<&AppManifest>,
Expand All @@ -1142,6 +1194,7 @@ fn write_archive(
.chain(app_manifest.iter().flat_map(|app| {
std::iter::once(APP_MANIFEST).chain(app.images.iter().map(|i| i.archive_path.as_str()))
}))
.chain(customize_bytes.map(|_| CUSTOMIZE_FILE))
.chain(artifacts.wasms.iter().map(|nb| nb.archive_path.as_str()))
.chain(init_args_files.iter().map(|f| f.archive_path.as_str()))
.chain(
Expand Down Expand Up @@ -1185,6 +1238,10 @@ fn write_archive(
}
}

if let Some(customize_bytes) = customize_bytes {
archive.bytes(CUSTOMIZE_FILE, customize_bytes)?;
}

for nb in &artifacts.wasms {
archive.bytes(&nb.archive_path, &nb.bytes)?;
}
Expand Down
Loading
Loading