Skip to content
Open
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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ bump. Currently experimental: project bundling, project dependencies

# Unreleased

* feat: `script` build steps now receive `ICP_CLI_ENVIRONMENT`, the name of the environment the canisters are being built for, so a build can vary by environment the way a sync step already could.

# v1.3.0

* feat: a canister environment variable's value can now be read from a file, by writing `var: { path: <file> }` in place of `var: value`. The path resolves against the canister's directory — including in an environment override, matching `init_args` — and surrounding whitespace is trimmed off the file's contents. The file is read when the project is loaded, so a missing file fails before anything is deployed. `icp project bundle` writes the value into the bundled manifest inline, rejecting a file outside the project as it does for other manifest file references.
Expand All @@ -16,6 +18,7 @@ bump. Currently experimental: project bundling, project dependencies

## Experimental

* feat(bundle): `icp project bundle` takes `-e/--environment`, naming the environment its canisters are built for — it reaches build scripts as `ICP_CLI_ENVIRONMENT`. It defaults to `ic`, unlike the rest of the CLI, because a bundle is built to be deployed somewhere else; `ICP_ENVIRONMENT` overrides that default as it does elsewhere. Which canisters are bundled is unaffected.
* feat(bundle): `icp project bundle` now works on projects that declare `dependencies:`, which it previously refused outright. The bundle mirrors the workspace instead of flattening it: the root project's `icp.yaml` sits at the archive root, each dependency instance gets its own `icp.yaml` at the directory it occupies in the workspace, and the `dependencies:` declarations are preserved, each pointing at the directory its dependency occupies in the archive (the same path a plainly vendored layout already used). A shared (diamond) dependency is still a single instance, canister names stay as each project wrote them, and canister discovery (`PUBLIC_CANISTER_ID:<alias>:<canister>`) works in the extracted bundle exactly as it did in the source workspace.
* Every dependency must resolve to a directory inside the workspace root; one that resolves outside it (including through a symlink) is rejected, because the archive could not contain it. As a result, a vendored member that depends on a sibling cannot be bundled as a standalone project (e.g. via `ICP_PROJECT_ROOT`) — bundle the workspace root instead.
* Projects with script sync steps still cannot be bundled, and the restriction now covers every project in the workspace.
Expand Down
16 changes: 13 additions & 3 deletions crates/icp-cli/src/commands/build.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,10 @@ use icp::context::{Context, EnvironmentSelection};

use tracing::info;

use crate::{operations::build::build_many_with_progress_bar, options::EnvironmentOpt};
use crate::{
operations::build::build_many_with_progress_bar,
options::{EnvironmentOpt, arg_struct_change_help},
};

/// Build canisters
#[derive(Debug, Args)]
Expand All @@ -13,12 +16,18 @@ pub(crate) struct BuildArgs {
pub(crate) canisters: Vec<String>,

#[command(flatten)]
pub(crate) environment: EnvironmentOpt,
pub(crate) environment: BuildEnvironmentOpt,
}

arg_struct_change_help!(
EnvironmentOpt => BuildEnvironmentOpt,
arg = "environment",
help = "Override the environment to build for. By default, the local environment is used."

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Drop the trailing period — clap strips it from doc comments, so every other --environment renders without one, and it shows up in the generated cli.md. Same on deploy.rs:104. Regenerate cli.md after.

);

pub(crate) async fn exec(ctx: &Context, args: &BuildArgs) -> Result<(), anyhow::Error> {
// Get environment selection
let environment_selection: EnvironmentSelection = args.environment.clone().into();
let environment_selection: EnvironmentSelection = args.environment.0.clone().into();

// Load target environment
let env = ctx.get_environment(&environment_selection).await?;
Expand Down Expand Up @@ -48,6 +57,7 @@ pub(crate) async fn exec(ctx: &Context, args: &BuildArgs) -> Result<(), anyhow::

build_many_with_progress_bar(
canisters_to_build,
environment_selection.name(),
ctx.builder.clone(),
ctx.artifacts.clone(),
&ctx.dirs.package_cache()?,
Expand Down
14 changes: 11 additions & 3 deletions crates/icp-cli/src/commands/deploy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ use std::collections::{BTreeMap, BTreeSet, HashSet};
use std::time::Duration;
use tracing::info;

use crate::options::EnvironmentOpt;
use crate::{
commands::{args::ArgsOpt, canister::create},
operations::{
Expand All @@ -29,7 +30,7 @@ use crate::{
settings::{sync_controller_dependents, sync_settings_many},
sync::sync_many,
},
options::{EnvironmentOpt, IdentityOpt},
options::{IdentityOpt, arg_struct_change_help},
progress::{ProgressManager, ProgressManagerSettings},
};

Expand Down Expand Up @@ -85,7 +86,7 @@ pub(crate) struct DeployArgs {
pub(crate) identity: IdentityOpt,

#[command(flatten)]
pub(crate) environment: EnvironmentOpt,
pub(crate) environment: DeployEnvironmentOpt,

/// Output command results as JSON
#[arg(long)]
Expand All @@ -97,8 +98,14 @@ pub(crate) struct DeployArgs {
pub(crate) args_opt: ArgsOpt,
}

arg_struct_change_help!(
EnvironmentOpt => DeployEnvironmentOpt,
arg = "environment",
help = "Override the environment to build for and deploy to. By default, the local environment is used."
);

pub(crate) async fn exec(ctx: &Context, args: &DeployArgs) -> Result<(), anyhow::Error> {
let environment_selection: EnvironmentSelection = args.environment.clone().into();
let environment_selection: EnvironmentSelection = args.environment.0.clone().into();
let identity_selection: IdentitySelection = args.identity.clone().into();

let env = ctx.get_environment(&environment_selection).await?;
Expand Down Expand Up @@ -175,6 +182,7 @@ pub(crate) async fn exec(ctx: &Context, args: &DeployArgs) -> Result<(), anyhow:

build_many_with_progress_bar(
canisters_to_build,
environment_selection.name(),
ctx.builder.clone(),
ctx.artifacts.clone(),
&ctx.dirs.package_cache()?,
Expand Down
6 changes: 6 additions & 0 deletions crates/icp-cli/src/commands/project/bundle.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,11 @@ pub(crate) struct BundleArgs {
/// Output path for the bundle archive (e.g. bundle.tar.gz)
#[arg(long, short)]
pub(crate) output: PathBuf,

/// Environment the canisters are built for. Bundles are made to be deployed
/// elsewhere, so this defaults to `ic` rather than the usual `local`.
#[arg(long, short = 'e', env = "ICP_ENVIRONMENT", default_value = IC)]
pub(crate) environment: String,
}

pub(crate) async fn exec(ctx: &Context, args: &BundleArgs) -> Result<(), anyhow::Error> {
Expand All @@ -28,6 +33,7 @@ pub(crate) async fn exec(ctx: &Context, args: &BundleArgs) -> Result<(), anyhow:
create_bundle(
&project.dir,
canisters,
&args.environment,
ctx.builder.clone(),
ctx.artifacts.clone(),
&ctx.dirs.package_cache()?,
Expand Down
4 changes: 4 additions & 0 deletions crates/icp-cli/src/operations/build.rs
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ struct BuildFailure {
pub(crate) async fn build(
canister_path: &Path,
canister: &Canister,
environment: &str,
pb: &mut MultiStepProgressBar,
builder: Arc<dyn Build>,
artifacts: Arc<dyn icp::store_artifact::Access>,
Expand All @@ -69,6 +70,7 @@ pub(crate) async fn build(
&Params {
path: canister_path.to_owned(),
output: wasm_output_path.to_owned(),
environment: environment.to_owned(),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Build output can now vary by environment, but the artifact store still has one slot per canister (artifacts.save(&canister.name, …) 18 lines down, .icp/cache/artifacts/<name>). That was safe under the reproducibility rule this PR removes from build-deploy-sync.md. Now icp canister install <c> without --wasm serves whatever was built last — so deploy -e staging then install -e local installs the staging wasm, and project bundle (defaults to ic) silently clobbers the local artifacts for every canister.

Key the artifact dir by environment, or keep a caveat in the docs in place of the deleted bullet.

},
Comment thread
adamspofford-dfinity marked this conversation as resolved.
Some(tx),
pkg_cache,
Expand Down Expand Up @@ -96,6 +98,7 @@ pub(crate) async fn build(

pub(crate) async fn build_many_with_progress_bar(
canisters: Vec<(PathBuf, Canister)>,
environment: &str,
builder: Arc<dyn Build>,
artifacts: Arc<dyn icp::store_artifact::Access>,
pkg_cache: &PackageCache,
Expand All @@ -112,6 +115,7 @@ pub(crate) async fn build_many_with_progress_bar(
let build_result = build(
&canister_path,
&canister,
environment,
&mut pb,
builder,
artifacts,
Expand Down
2 changes: 2 additions & 0 deletions crates/icp-cli/src/operations/bundle.rs
Original file line number Diff line number Diff line change
Expand Up @@ -324,6 +324,7 @@ struct Instance {
pub(crate) async fn create_bundle(
project_dir: &Path,
canisters: Vec<(PathBuf, Canister)>,
environment: &str,
builder: Arc<dyn Build>,
artifacts: Arc<dyn store_artifact::Access>,
pkg_cache: &PackageCache,
Expand All @@ -348,6 +349,7 @@ pub(crate) async fn create_bundle(

build_many_with_progress_bar(
canisters.clone(),
environment,
builder,
artifacts.clone(),
pkg_cache,
Expand Down
33 changes: 33 additions & 0 deletions crates/icp-cli/src/options.rs
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,39 @@ impl From<EnvironmentOpt> for EnvironmentSelection {
}
}

macro_rules! arg_struct_change_help {
($orig_name:ident => $struct_name:ident, arg = $arg_name:literal, help = $new_help:literal) => {
#[derive(Debug)]
pub(crate) struct $struct_name(pub(crate) $orig_name);

impl clap::Args for $struct_name {
fn augment_args(cmd: clap::Command) -> clap::Command {
<$orig_name as clap::Args>::augment_args(cmd)
.mut_arg($arg_name, |a| a.help($new_help))
}
fn augment_args_for_update(cmd: clap::Command) -> clap::Command {
<$orig_name as clap::Args>::augment_args_for_update(cmd)
.mut_arg($arg_name, |a| a.help($new_help))
}
}

impl clap::FromArgMatches for $struct_name {
fn from_arg_matches(matches: &clap::ArgMatches) -> Result<Self, clap::Error> {
let inner = <$orig_name as clap::FromArgMatches>::from_arg_matches(matches)?;
Ok($struct_name(inner))
}

fn update_from_arg_matches(
&mut self,
matches: &clap::ArgMatches,
) -> Result<(), clap::Error> {
<$orig_name as clap::FromArgMatches>::update_from_arg_matches(&mut self.0, matches)
}
}
};
}
pub(crate) use arg_struct_change_help;

fn parse_root_key(input: &str) -> Result<RootKeySpec, String> {
RootKeySpec::try_from(input.to_string())
}
Expand Down
58 changes: 57 additions & 1 deletion crates/icp-cli/tests/build_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ use indoc::{formatdoc, indoc};
use predicates::{prelude::PredicateBooleanExt, str::contains};

use crate::common::TestContext;
use icp::fs::write_string;
use icp::fs::{read_to_string, write_string};

mod common;

Expand Down Expand Up @@ -83,6 +83,62 @@ fn build_adapter_script_multiple() {
.success();
}

#[test]
fn build_exposes_environment_name() {
let ctx = TestContext::new();

// Setup project
let project_dir = ctx.create_project_dir("icp");
let recorded = project_dir.join("environment.txt");

// Project manifest: the build step records the environment it was built for
let pm = formatdoc! {r#"
canisters:
- name: my-canister
build:
steps:
- type: script
commands:
- echo "$ICP_CLI_ENVIRONMENT" > '{recorded}'
- touch "$ICP_WASM_OUTPUT_PATH"
Comment thread
adamspofford-dfinity marked this conversation as resolved.

environments:
- name: test-env
canisters:
- my-canister
"#};

write_string(
&project_dir.join("icp.yaml"), // path
&pm, // contents
)
.expect("failed to write project manifest");

// No --environment: the default environment's name
ctx.icp()
.current_dir(&project_dir)
.args(["build", "my-canister"])
.assert()
.success();

assert_eq!(
read_to_string(&recorded).expect("failed to read recorded environment"),
"local\n"
);

// Explicit --environment
ctx.icp()
.current_dir(&project_dir)
.args(["build", "--environment", "test-env"])
.assert()
.success();

assert_eq!(
read_to_string(&recorded).expect("failed to read recorded environment"),
"test-env\n"
);
}

#[test]
fn build_adapter_display_failing_build_output() {
let ctx = TestContext::new();
Expand Down
56 changes: 55 additions & 1 deletion crates/icp-cli/tests/bundle_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ use std::{
use camino::Utf8Component;
use flate2::bufread::GzDecoder;
use icp::{
fs::{create_dir_all, write, write_string},
fs::{create_dir_all, read_to_string, write, write_string},
prelude::*,
};
use indoc::formatdoc;
Expand Down Expand Up @@ -817,6 +817,60 @@ fn bundle_rejects_source_outside_project() {
/// exist when bundling validates the sync sources, before the build. Validation
/// must resolve sync paths lexically (no canonicalization) so a not-yet-built
/// directory is accepted; the build then creates it before it is archived.
/// The environment reaching build steps as `ICP_CLI_ENVIRONMENT` defaults to `ic`

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test landed between the doc comment and the fn it documents — the "resolve sync paths lexically" comment now describes bundle_builds_for_ic_by_default, and bundle_accepts_synced_dir_created_by_build_step has none. Move the new test above the comment block.

/// for a bundle, rather than the `local` the rest of the CLI defaults to.
#[test]
fn bundle_builds_for_ic_by_default() {
let ctx = TestContext::new();
let project_dir = ctx.create_project_dir("icp");
let wasm_src = ctx.make_asset("example_icp_mo.wasm");
let recorded = project_dir.join("environment.txt");

let pm = formatdoc! {r#"
canisters:
- name: my-canister
build:
steps:
- type: script
commands:
- echo "$ICP_CLI_ENVIRONMENT" > '{recorded}'
- cp '{wasm_src}' "$ICP_WASM_OUTPUT_PATH"
"#};

write_string(&project_dir.join("icp.yaml"), &pm).expect("failed to write project manifest");

let bundle_path = project_dir.join("bundle.tar.gz");
ctx.icp()
.current_dir(&project_dir)
.args(["project", "bundle", "--output", bundle_path.as_str()])
.assert()
.success();

assert_eq!(
read_to_string(&recorded).expect("failed to read recorded environment"),
"ic\n"
);

// An explicit --environment overrides the default.
ctx.icp()
.current_dir(&project_dir)
.args([
"project",
"bundle",
"--output",
bundle_path.as_str(),
"--environment",
"staging",
])
.assert()
.success();

assert_eq!(
read_to_string(&recorded).expect("failed to read recorded environment"),
"staging\n"
);
}

#[test]
fn bundle_accepts_synced_dir_created_by_build_step() {
let ctx = TestContext::new();
Expand Down
Loading
Loading