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
34 changes: 31 additions & 3 deletions .claude/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@ cargo test # Run all tests (launcher auto-downloads on
cargo test -p icp-cli # Tests for a specific package
cargo test --test <file> -- <name> # Specific test
cargo fmt && cargo clippy # Run after changes pass tests
# `icp-project`'s core must reach nothing only a host has — see the boundary below
cargo clippy -p icp-project --no-default-features --target wasm32-unknown-unknown
./scripts/check-no-host-reach.sh # ...which a build alone does not prove
cargo test -p icp-project --no-default-features
./scripts/generate-cli-docs.sh # Regenerate CLI docs when commands change
./scripts/generate-config-schemas.sh # Regenerate schema when manifest types change
```
Expand Down Expand Up @@ -52,14 +56,38 @@ reverse:
- `network::Access` — a network's endpoints, root key and friendly domains
- `canister::wasm::Fetch` — a wasm module a manifest names by URL
- `canister::recipe::Resolve` — a recipe's Handlebars template
- `canister::build::Build` — running one build step
- `canister::sync::plugin::Run` — running one sync plugin
- `canister::sync::script::ScriptRunner` — running one sync script
- `store_id::Access` / `store_artifact::Access` — the project's `.icp` stores
- `random::Random` — a choice that cannot be made by arithmetic
- `host::Observe` — what resolution turned up, for telemetry

Host implementations of the first, the last two stores and the script runner
ship in `icp-project` itself behind the default-on `host` feature; the rest
have no implementation there at all.
Some of those have a host implementation in `icp-project` itself, behind the
default-on **`host`** feature: `HostFileSystem`, the two stores, `HostScripts`,
`HostRandom`, and `Builder`. The rest have none there at all.

`host` must be **strictly additive** — it may add implementations, never change
what the rest of the crate does; if it changed behaviour rather than adding to
it, the checks below would prove nothing about the shipped binary. CI runs
three of them on the core with default features off:

- a build for `wasm32-unknown-unknown`, which rejects a *dependency* that
reaches the host by gating that code on `cfg(unix)`/`cfg(windows)`, leaving
it with nothing to compile;
- `scripts/check-no-host-reach.sh`, which reads the imports back out of that
build's object files and rejects a call this crate itself makes into
`std::fs`, `std::process`, `std::env` and their neighbours — and, since a
dependency that reaches the host ungated compiles fine, the front doors onto
those that such a dependency offers, `camino`'s `Utf8Path::is_file` among
them. The build alone catches neither: `wasm32-unknown-unknown` ships a full
`std` whose host backends compile and fail at runtime;
- the crate's own tests, which are the only thing that exercises it with
`host` off, and so run in that configuration too.

Whole modules that are irreducibly host-side sit behind it too —
`operations::bundle` writes a `.tar.gz` and walks a directory tree for
symlinks, which no seam over `FileSystem` reproduces faithfully.

Because those are implemented across a crate boundary, their error types carry
their cause boxed and pass it through with `#[snafu(transparent)]`, which leaves
Expand Down
83 changes: 83 additions & 0 deletions .github/workflows/checks.yml
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,89 @@ jobs:
env:
RUST_BACKTRACE: 1

# `icp-project` is meant to end up runnable inside a canister, so its core
# must reach nothing that only a host has. Nothing else expresses this: a
# `--workspace` build unifies the `host` feature back on, because `icp-cli`
# enables it. So this builds that one crate, alone, for the target it would
# run on, and then reads back what the build left behind.
canister-target:
name: canister-target:required
needs: [changes, compile]
if: needs.changes.outputs.src == 'true'
runs-on: ubuntu-latest

steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2

- name: Setup image (Linux)
run: ./.github/scripts/provision-linux-build.sh

# rust-cache hashes all installed toolchains; the runner image's `stable`
# drifts as the image updates, which moves the cache key and causes misses.
# Remove it so only the rust-toolchain.toml-pinned version remains.
- name: Remove the runner's bundled Rust toolchain
run: rustup toolchain remove stable 2>/dev/null || true

- uses: actions-rust-lang/setup-rust-toolchain@150fca883cd4034361b621bd4e6a9d34e5143606 # v1.15.4
with:
target: wasm32-unknown-unknown
# `llvm-nm` reads the imports back out of the objects the build
# leaves behind; see scripts/check-no-host-reach.sh.
components: llvm-tools
cache-shared-key: ${{ runner.os }}-canister-target
cache-bin: false

# Catches a dependency that reaches the host, which fails to compile
# here. Only the library: the dev-dependencies behind the tests serve a
# host by definition, and `httptest` does not build for wasm at all.
- name: Check icp-project without a host
run: |
cargo clippy -p icp-project --no-default-features \
--target wasm32-unknown-unknown -- -D warnings
env:
RUST_BACKTRACE: 1

# Catches the crate's own code reaching the host, which compiles here:
# `wasm32-unknown-unknown` ships a full `std` whose host backends fail at
# runtime rather than at build time.
- name: Check the core reaches no host
run: ./scripts/check-no-host-reach.sh

# The tests are the only thing that exercises the crate with `host` off,
# so they run in that configuration too — on this machine's target,
# since that is where a test can run at all.
- name: Test icp-project without a host
run: cargo test -p icp-project --no-default-features
env:
RUST_BACKTRACE: 1

# The checks above prove the core reaches no host. This proves it reaches
# no *crate above it* either, which no build would catch, because a cycle
# is what cargo would report and there is no cycle to report until
# someone writes one.
- name: Check the dependency direction
run: |
fail() { echo "::error::$1"; exit 1; }
# grep exits 1 when it found nothing and 2 or more when it could not
# read a file. Only the first is a pass: a guard that quietly stops
# guarding once a path moves is worse than no guard at all.
forbid() {
local message=$1 pattern=$2
shift 2
local matches status
matches=$(grep -rn "$pattern" "$@") && status=0 || status=$?
case $status in
0) echo "$matches"; fail "$message" ;;
1) ;;
*) fail "could not search $* for '$pattern'" ;;
esac
}
forbid "icp-project and icp-sync-plugin must not depend on icp-app" \
'icp-app' crates/icp-project/Cargo.toml crates/icp-sync-plugin/Cargo.toml
forbid "icp-app must not re-export icp-project: a command reaches for the crate that owns what it needs" \
'pub use icp_project' crates/icp-app/src
echo "the boundary holds"

format:
name: fmt:required
needs: [changes, compile]
Expand Down
1 change: 0 additions & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 3 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,9 @@ tempfile = "3"
test-tag = "0.1"
time = { version = "0.3.47", features = ["formatting", "macros", "parsing"] }
tiny-bip39 = "2.0.0"
tokio = { version = "1.45.0", features = ["macros", "rt-multi-thread"] }
# The most any crate here can have in common: `rt-multi-thread` is not
# available on wasm, so the crates that need it ask for it themselves.
tokio = { version = "1.45.0", features = ["macros"] }
tracing = "0.1.41"
tracing-subscriber = "0.3.20"
url = { version = "2.5.4", features = ["serde"] }
Expand Down
1 change: 1 addition & 0 deletions crates/icp-app/src/context/init.rs
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,7 @@ pub fn initialize(
syncer,
wasm,
network: netaccess.clone(),
random: Arc::new(icp_project::random::HostRandom),
observer: telemetry_data.clone(),
},
dirs,
Expand Down
2 changes: 1 addition & 1 deletion crates/icp-cli/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ snafu.workspace = true
sysinfo.workspace = true
tiny-bip39.workspace = true
time.workspace = true
tokio.workspace = true
tokio = { workspace = true, features = ["rt-multi-thread"] }
tracing-subscriber.workspace = true
tracing.workspace = true
url.workspace = true
Expand Down
13 changes: 11 additions & 2 deletions crates/icp-cli/src/commands/canister/create.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ use clap::{ArgGroup, Args, Parser};
use ic_management_canister_types::CanisterSettings as MgmtCanisterSettings;
use icp_app::context::{Context, NetworkSelection};
use icp_app::identity::IdentitySelection;
use icp_canister_interfaces::engine_canister::engine_canister_id;
use icp_project::canister::resolve_controllers;
use icp_project::host::EnvironmentSelection;
use icp_project::parsers::{CyclesAmount, DurationAmount, MemoryAmount, parse_token_amount};
Expand Down Expand Up @@ -307,8 +308,14 @@ async fn create_canister(ctx: &Context, args: &CreateArgs) -> Result<(), anyhow:

let calls = icp_app::calls::calls(agent.clone(), args.proxy)?;

let create_operation =
CreateOperation::new(calls, args.create_target(), args.funding(), vec![]);
let create_operation = CreateOperation::new(
calls,
ctx.host.random.clone(),
args.create_target(),
args.funding(),
vec![],
engine_canister_id().map_err(|message| anyhow!(message))?,
);

let canister_settings = args.canister_settings();

Expand Down Expand Up @@ -376,9 +383,11 @@ async fn create_project_canister(ctx: &Context, args: &CreateArgs) -> Result<(),

let create_operation = CreateOperation::new(
calls.clone(),
ctx.host.random.clone(),
args.create_target(),
args.funding(),
ids.values().copied().collect(),
engine_canister_id().map_err(|message| anyhow!(message))?,
);

let (canister_settings, unresolved) =
Expand Down
4 changes: 3 additions & 1 deletion crates/icp-cli/src/commands/deploy.rs
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
use anyhow::bail;
use anyhow::{anyhow, bail};
use candid::Principal;
use clap::Args;
use clap_complete::ArgValueCandidates;
use icp_app::{context::Context, identity::IdentitySelection};
use icp_canister_interfaces::candid_ui::MAINNET_CANDID_UI_CID;
use icp_canister_interfaces::engine_canister::engine_canister_id;
use icp_project::calls::{Call, CallError, CanisterCalls};
use icp_project::operations::deploy::{DeployParams, DeployReport, deploy, resolve_targets};
use icp_project::parsers::CyclesAmount;
Expand Down Expand Up @@ -135,6 +136,7 @@ pub(crate) async fn exec(ctx: &Context, args: &DeployArgs) -> Result<(), anyhow:
subnet: args.subnet,
proxy: args.proxy,
cycles: args.cycles.get(),
engine_registry: engine_canister_id().map_err(|message| anyhow!(message))?,
no_create: args.no_create,
yes: args.yes,
args: args.args_opt.resolve_bytes()?,
Expand Down
36 changes: 19 additions & 17 deletions crates/icp-project/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -8,16 +8,16 @@ publish.workspace = true
[features]
default = ["host"]
# Implementations of this crate's seams that use the machine it is running on:
# the filesystem and the project-local `.icp` stores. Turned off for a build
# that has to run somewhere without them, such as inside a canister.
# the filesystem, the project-local `.icp` stores, subprocesses, randomness.
# Turned off for a build that has to run somewhere without them, such as inside
# a canister — which CI checks by building this crate for
# `wasm32-unknown-unknown` with default features off, reading the host calls
# back out of that build with `scripts/check-no-host-reach.sh`, and running the
# tests in the same configuration.
#
# It does not yet cover everything host-shaped here — the step runners still
# spawn subprocesses and drive a wasmtime sandbox unconditionally, `create`
# draws entropy from `rand`, and a bundle's plugin directories go through
# `tar`'s own directory walk. Each is a seam of its own to draw, and every
# dependency is still non-optional besides, so this is the boundary the feature
# claims rather than a canister target.
host = []
# Strictly additive: it may add implementations, never change what the rest of
# the crate does, or those checks prove nothing about the shipped binary.
host = ["dep:camino-tempfile", "dep:dunce", "dep:rand", "dep:tar", "tokio/io-std", "tokio/process", "tokio/signal"]
# Exposes this crate's mocks and fixtures so downstream crates can test against
# the same seams. Off in a normal build, so none of it ships.
test-util = []
Expand All @@ -26,11 +26,11 @@ test-util = []
async-trait = { workspace = true }
bigdecimal = { workspace = true }
camino = { workspace = true }
camino-tempfile = { workspace = true }
camino-tempfile = { workspace = true, optional = true }
candid = { workspace = true }
candid_parser = { workspace = true }
clap = { workspace = true, optional = true }
dunce = { workspace = true }
dunce = { workspace = true, optional = true }
flate2 = { workspace = true }
futures = { workspace = true }
glob = { workspace = true }
Expand All @@ -48,22 +48,21 @@ num-bigint = { workspace = true }
num-integer = { workspace = true }
num-traits = { workspace = true }
pathdiff = { workspace = true }
rand = { workspace = true }
rand = { workspace = true, optional = true }
schemars = { workspace = true }
semver = { workspace = true }
serde = { workspace = true }
serde_cbor = { workspace = true }
serde_json = { workspace = true }
serde_yaml = { workspace = true }
sha2 = { workspace = true }
shellwords = { workspace = true }
snafu = { workspace = true }
strum = { workspace = true }
tar = { workspace = true }
tar = { workspace = true, optional = true }
time = { workspace = true }
# `io-std` is listed for `tokio::io::stdout`/`stderr`; feature unification with other
# crates supplies it anyway, so dropping it would not fail the build.
tokio = { workspace = true, features = ["sync", "macros", "rt", "time", "io-util", "io-std", "process", "signal"] }
# Everything a canister build can have: no `process`, `signal` or `io-std`,
# which the `host` feature adds.
tokio = { workspace = true, features = ["sync", "macros", "rt", "time", "io-util"] }
tracing = { workspace = true }
url = { workspace = true }
wasmparser = { workspace = true }
Expand All @@ -72,5 +71,8 @@ wasmparser = { workspace = true }
winreg = { workspace = true }

[dev-dependencies]
# Also an optional dependency above, for `host`'s `scratch_dir`. Unconditional
# here so the tests build with `host` off, which is the configuration CI checks.
camino-tempfile = { workspace = true }
httptest = { workspace = true }
jsonschema = { workspace = true }
31 changes: 31 additions & 0 deletions crates/icp-project/src/canister/build/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,18 @@ use async_trait::async_trait;
use icp_events::StepReporter;
use snafu::prelude::*;

#[cfg(feature = "host")]
use std::sync::Arc;

#[cfg(feature = "host")]
use crate::canister::wasm;
use crate::manifest::canister::BuildStep;
use crate::prelude::*;

// Both halves of the one implementation below, which is host-side.
#[cfg(feature = "host")]
mod prebuilt;
#[cfg(feature = "host")]
mod script;

pub struct Params {
Expand All @@ -20,10 +25,30 @@ pub struct Params {

#[derive(Debug, Snafu)]
pub enum BuildError {
#[cfg(feature = "host")]
#[snafu(transparent)]
Script { source: super::script::ScriptError },
#[cfg(feature = "host")]
#[snafu(transparent)]
Prebuilt { source: prebuilt::PrebuiltError },
/// A [`Build`] this crate cannot name failed. How it runs a step is its own
/// business, so the cause is carried whole and displayed as itself.
///
/// This is the only variant a build off this machine has, and without it
/// the enum would be uninhabited there.
#[snafu(transparent)]
Other {
source: Box<dyn std::error::Error + Send + Sync + 'static>,
},
}

impl BuildError {
/// Wraps an implementation's own error for the trait boundary.
pub fn new(source: impl std::error::Error + Send + Sync + 'static) -> Self {
Self::Other {
source: Box::new(source),
}
}
}

#[async_trait]
Expand All @@ -38,17 +63,23 @@ pub trait Build: Sync + Send {

/// Runs each build step where it has to be run: a script step in a subprocess,
/// a pre-built step by asking [`wasm::Fetch`] for the module.
///
/// Only a host can run the script half, so this whole implementation is
/// host-side; somewhere without subprocesses supplies its own [`Build`].
#[cfg(feature = "host")]
pub struct Builder {
wasm: Arc<dyn wasm::Fetch>,
files: Arc<dyn crate::files::FileSystem>,
}

#[cfg(feature = "host")]
impl Builder {
pub fn new(wasm: Arc<dyn wasm::Fetch>, files: Arc<dyn crate::files::FileSystem>) -> Self {
Self { wasm, files }
}
}

#[cfg(feature = "host")]
#[async_trait]
impl Build for Builder {
async fn build(
Expand Down
1 change: 1 addition & 0 deletions crates/icp-project/src/canister/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ pub mod recipe;
pub mod sync;
pub mod visibility;

#[cfg(feature = "host")]
mod script;
pub mod wasm;

Expand Down
Loading
Loading