diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..6313b56c --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +* text=auto eol=lf diff --git a/.github/scripts/init-docker.sh b/.github/scripts/init-docker.sh new file mode 100644 index 00000000..46dfeac9 --- /dev/null +++ b/.github/scripts/init-docker.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +# This sets up dockerd in a WSL2 container manually. You could probably do this locally if you wanted, +# but the primary purpose is that GitHub Actions' WSL2 runners aren't set up for hypervisor support, +# so Docker Desktop's automatic WSL2 integration doesn't work. +set -ex +apt-get update +DEBIAN_FRONTEND=noninteractive apt-get install -y docker.io +nohup dockerd -H tcp://127.0.0.1:2375 >/var/log/dockerd.log 2>&1 & +for i in $(seq 1 30); do + if docker -H tcp://127.0.0.1:2375 info >/dev/null 2>&1; then + echo Docker ready + { + echo DOCKER_HOST=tcp://127.0.0.1:2375 + echo ICP_CLI_DOCKER_WSL2_DISTRO="$WSL_DISTRO_NAME" + } >> $GITHUB_ENV + exit 0 + fi + sleep 1 +done +cat /var/log/dockerd.log +exit 1 diff --git a/.github/scripts/test-matrix.py b/.github/scripts/test-matrix.py index ad143232..87aab9d5 100644 --- a/.github/scripts/test-matrix.py +++ b/.github/scripts/test-matrix.py @@ -14,11 +14,15 @@ def test_names(): include = [] for test in test_names(): - # Ubuntu: run everything + # Ubuntu/Windows: run everything include.append({ "test": test, "os": "ubuntu-22.04" }) + include.append({ + "test": test, + "os": "windows-2025" + }) # macOS: only run selected tests if test in MACOS_TESTS: diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index b33079cc..baffa7b6 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -11,6 +11,14 @@ env: CARGO_NET_RETRY: 10 # Use the local .curlrc CURL_HOME: . + # Disable incremental compilation + CARGO_INCREMENTAL: 0 + # These variables should be shared with WSL + WSLENV: CARGO_INCREMENTAL:CURL_HOME/p:CARGO_NET_RETRY:GITHUB_ENV/p:GITHUB_OUTPUT/p + +defaults: + run: + shell: bash jobs: discover: @@ -30,15 +38,11 @@ jobs: matrix: # Make the os matrix match .github/scripts/test-matrix.py so that # we optimize caching - os: [ubuntu-22.04, macos-15] + os: [ubuntu-22.04, macos-15, windows-2025] steps: - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 - - name: Setup image (Linux) - if: ${{ contains(matrix.os, 'ubuntu') }} - run: ./.github/scripts/provision-linux-build.sh - - uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 with: path: | @@ -50,6 +54,10 @@ jobs: target/ key: ${{ matrix.os }}-cargo-${{ hashFiles('rust-toolchain.toml') }}-${{ hashFiles('**/Cargo.lock') }} + - name: Setup image (Linux) + if: ${{ contains(matrix.os, 'ubuntu') }} + run: ./.github/scripts/provision-linux-build.sh + - uses: t1m0thyj/unlock-keyring@e481cdc8833d4417a58f40734e8f197183317047 if: ${{ contains(matrix.os, 'ubuntu') }} @@ -68,10 +76,6 @@ jobs: steps: - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 - - name: Setup image (Linux) - if: ${{ contains(matrix.os, 'ubuntu') }} - run: ./.github/scripts/provision-linux-build.sh - - uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 with: path: | @@ -83,6 +87,21 @@ jobs: target/ key: ${{ matrix.os }}-cargo-${{ hashFiles('rust-toolchain.toml') }}-${{ hashFiles('**/Cargo.lock') }} + - name: Setup image (Linux) + if: ${{ contains(matrix.os, 'ubuntu') }} + run: ./.github/scripts/provision-linux-build.sh + + - name: Setup WSL2 (Windows) + if: ${{ contains(matrix.os, 'windows') }} + uses: Vampire/setup-wsl@6a8db447be7ed35f2f499c02c6e60ff77ef11278 # v6.0.0 + with: + distribution: Ubuntu-22.04 + + - name: Setup Docker in WSL2 (Windows) + if: ${{ contains(matrix.os, 'windows') }} + run: .github/scripts/init-docker.sh + shell: wsl-bash_Ubuntu-22.04 {0} + - uses: t1m0thyj/unlock-keyring@e481cdc8833d4417a58f40734e8f197183317047 if: ${{ contains(matrix.os, 'ubuntu') }} @@ -105,6 +124,7 @@ jobs: - name: install network launcher env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + if: ${{ !contains(matrix.os, 'windows') }} run: | VERSION=v11.0.0 OS="" @@ -142,6 +162,7 @@ jobs: echo "ICP_CLI_NETWORK_LAUNCHER_PATH=$DEST/icp-cli-network-launcher" >> "$GITHUB_ENV" - name: Run ${{ matrix.test }} + # the macos runners do not support Docker run: cargo test --test ${{ matrix.test }} -- ${{ contains(matrix.os, 'macos') && '--skip :docker:' || '' }} aggregate: diff --git a/CHANGELOG.md b/CHANGELOG.md index 46b57a80..449b98b5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,6 @@ # Unreleased +* feat: Release for Windows * feat: Add safety controls for `--set-controller` and `--remove-controller` * Warn and prompt for confirmation when removing yourself from controllers * Add `-f/--force` flag to skip confirmation prompts diff --git a/Cargo.lock b/Cargo.lock index 4ada3186..9e750596 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3166,6 +3166,8 @@ dependencies = [ "tracing", "url", "uuid", + "winreg", + "wslpath2", "zeroize", ] @@ -3197,6 +3199,7 @@ dependencies = [ "clap-markdown", "console 0.16.1", "dialoguer 0.11.0", + "dunce", "elliptic-curve", "futures", "hex", @@ -3224,11 +3227,13 @@ dependencies = [ "regex", "reqwest", "sec1", + "send_ctrlc", "serde", "serde_json", "serde_yaml", "serial_test", "sha2 0.10.9", + "shellwords", "snafu", "sysinfo", "test-tag", @@ -3238,6 +3243,7 @@ dependencies = [ "tracing-subscriber", "url", "uuid", + "wslpath2", ] [[package]] @@ -5613,6 +5619,16 @@ dependencies = [ "serde_core", ] +[[package]] +name = "send_ctrlc" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d07beb664b54f51140baf2769d12d5eb07d0e3eccee78fb95c3e76c2644a4cad" +dependencies = [ + "libc", + "windows-sys 0.61.1", +] + [[package]] name = "serde" version = "1.0.228" @@ -7262,6 +7278,16 @@ dependencies = [ "memchr", ] +[[package]] +name = "winreg" +version = "0.55.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb5a765337c50e9ec252c2069be9bf91c7df47afb103b642ba3a53bf8101be97" +dependencies = [ + "cfg-if", + "windows-sys 0.59.0", +] + [[package]] name = "wit-bindgen" version = "0.46.0" @@ -7274,6 +7300,12 @@ version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ea2f10b9bb0928dfb1b42b65e1f9e36f7f54dbdf08457afefb38afcdec4fa2bb" +[[package]] +name = "wslpath2" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2db8388b8fbbf9d67e346efc2c1cb216dde5b78981e2eae644a071c160f3acd5" + [[package]] name = "wyz" version = "0.5.1" diff --git a/Cargo.toml b/Cargo.toml index d2c17aa5..aaaac847 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -85,6 +85,8 @@ tracing = "0.1.41" tracing-subscriber = "0.3.20" url = "2.5.4" uuid = { version = "1.16.0", features = ["serde", "v4"] } +winreg = "0.55" +wslpath2 = "0.1" zeroize = "1.8.1" [workspace.dependencies.reqwest] diff --git a/README.md b/README.md index 9bd8bd48..5c764aae 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,9 @@ The [`examples/`](examples/) directory contains example projects to help you get - **Rust canisters** — [Rust](https://rustup.rs/) and `rustup target add wasm32-unknown-unknown` - **Motoko canisters** — [mops](https://cli.mops.one/) and `mops toolchain init` +**Other dependencies:** +- Docker — always required on Windows (with WSL2), and some projects may require it on other platforms. + ## Getting Help - **[Documentation](docs/index.md)** — Guides, concepts, and reference @@ -69,12 +72,13 @@ Contributions are welcome! See [CONTRIBUTING.md](.github/CONTRIBUTING.md) for gu - Rust 1.88.0+ ([rustup.rs](https://rustup.rs/)) - Platform dependencies: -| Platform | Install | -|----------|---------| -| macOS | `xcode-select --install` | -| Ubuntu/Debian | `sudo apt install build-essential pkg-config libssl-dev` | -| Fedora/RHEL | `sudo dnf install gcc pkg-config openssl-devel` | -| Arch Linux | `sudo pacman -S base-devel openssl` | +| Platform | Install | +|---------------|----------------------------------------------------------------------------------------------------------| +| macOS | `xcode-select --install` | +| Ubuntu/Debian | `sudo apt install build-essential pkg-config libssl-dev` | +| Fedora/RHEL | `sudo dnf install gcc pkg-config openssl-devel` | +| Arch Linux | `sudo pacman -S base-devel openssl` | +| Windows | VS build tools (see [Rustup's guide](https://rust-lang.github.io/rustup/installation/windows-msvc.html)) | ### Build and Test diff --git a/crates/icp-cli/Cargo.toml b/crates/icp-cli/Cargo.toml index 25e8883a..b52e087e 100644 --- a/crates/icp-cli/Cargo.toml +++ b/crates/icp-cli/Cargo.toml @@ -20,11 +20,12 @@ byte-unit.workspace = true camino-tempfile.workspace = true candid_parser = { workspace = true, features = ["assist"] } candid.workspace = true -cargo-generate = { version = "0.23.7", features = ["vendored-openssl"] } +cargo-generate = { version = "0.23.7" } clap-markdown.workspace = true clap.workspace = true console.workspace = true dialoguer.workspace = true +dunce.workspace = true elliptic-curve.workspace = true futures.workspace = true hex.workspace = true @@ -52,6 +53,7 @@ sec1.workspace = true serde_json.workspace = true serde_yaml.workspace = true serde.workspace = true +shellwords.workspace = true snafu.workspace = true sysinfo.workspace = true tiny-bip39.workspace = true @@ -59,20 +61,27 @@ tokio.workspace = true tracing-subscriber.workspace = true tracing.workspace = true url.workspace = true +wslpath2.workspace = true + +[target.'cfg(unix)'.dependencies] +cargo-generate = { version = "0.23.7", features = ["vendored-openssl"] } [dev-dependencies] assert_cmd = "2" camino-tempfile = "1" indoc.workspace = true icp = { workspace = true } -nix = { version = "0.30.1", features = ["process", "signal"] } predicates = "3" rand.workspace = true +send_ctrlc = "0.6" serde_yaml.workspace = true serial_test = { version = "3.2.0", features = ["file_locks"] } test-tag = "0.1" uuid.workspace = true +[target.'cfg(unix)'.dev-dependencies] +nix = { version = "0.30.1", features = ["process", "signal"] } + [lints] workspace = true diff --git a/crates/icp-cli/src/commands/canister/settings/update.rs b/crates/icp-cli/src/commands/canister/settings/update.rs index ebe1da3b..f6e2436b 100644 --- a/crates/icp-cli/src/commands/canister/settings/update.rs +++ b/crates/icp-cli/src/commands/canister/settings/update.rs @@ -1,13 +1,12 @@ use anyhow::bail; use byte_unit::{Byte, Unit}; use clap::{ArgAction, Args}; -use console::Term; use dialoguer::Confirm; use ic_agent::Identity; use ic_agent::export::Principal; use ic_management_canister_types::{CanisterStatusResult, EnvironmentVariable, LogVisibility}; use icp::ProjectLoadError; -use icp::context::{CanisterSelection, Context}; +use icp::context::{CanisterSelection, Context, TermWriter}; use std::collections::{HashMap, HashSet}; use std::io::Write; @@ -452,7 +451,7 @@ fn get_environment_variables( } fn maybe_warn_on_env_vars_change( - mut term: &Term, + mut term: &TermWriter, configured_settings: &icp::canister::Settings, environment_variables_opt: &EnvironmentVariableOpt, ) -> Result<(), anyhow::Error> { diff --git a/crates/icp-cli/src/logging.rs b/crates/icp-cli/src/logging.rs index f22f6cb4..722cded9 100644 --- a/crates/icp-cli/src/logging.rs +++ b/crates/icp-cli/src/logging.rs @@ -1,10 +1,4 @@ -use std::io::Write; -#[cfg(unix)] -use std::os::fd::{AsRawFd, RawFd}; -#[cfg(windows)] -use std::os::windows::io::{AsRawHandle, RawHandle}; - -use tracing::{Level, Subscriber, debug}; +use tracing::{Level, Subscriber}; use tracing_subscriber::{ Layer, filter::{Filtered, Targets}, @@ -12,42 +6,6 @@ use tracing_subscriber::{ registry::LookupSpan, }; -#[derive(Debug)] -pub(crate) struct TermWriter { - pub(crate) debug: bool, - pub(crate) writer: Box, -} - -impl Write for TermWriter { - fn write(&mut self, buf: &[u8]) -> std::io::Result { - if !self.debug { - self.writer.write(buf)?; - } - debug!("{}", String::from_utf8_lossy(buf).trim()); - Ok(buf.len()) - } - - fn flush(&mut self) -> std::io::Result<()> { - if !self.debug { - self.writer.flush()?; - } - Ok(()) - } -} - -#[cfg(unix)] -impl AsRawFd for TermWriter { - fn as_raw_fd(&self) -> RawFd { - self.writer.as_raw_fd() - } -} -#[cfg(windows)] -impl AsRawHandle for TermWriter { - fn as_raw_handle(&self) -> RawHandle { - self.writer.as_raw_handle() - } -} - type DebugLayer = Filtered< tracing_subscriber::fmt::Layer>, Targets, diff --git a/crates/icp-cli/src/main.rs b/crates/icp-cli/src/main.rs index dd819877..d33655e5 100644 --- a/crates/icp-cli/src/main.rs +++ b/crates/icp-cli/src/main.rs @@ -2,7 +2,7 @@ use anyhow::Error; use clap::{CommandFactory, Parser}; use commands::Command; use console::Term; -use icp::prelude::*; +use icp::{context::TermWriter, prelude::*}; use tracing::{Instrument, Level, debug, subscriber::set_global_default, trace_span}; use tracing_subscriber::{ Layer, Registry, @@ -11,7 +11,7 @@ use tracing_subscriber::{ }; use crate::{ - logging::{TermWriter, debug_layer}, + logging::debug_layer, telemetry::EventLayer, version::{git_sha, icp_cli_version_str}, }; @@ -109,14 +109,10 @@ async fn main() -> Result<(), Error> { } }; - // Printing for user-facing messages - let term = Term::read_write_pair( - std::io::stdin(), - TermWriter { - debug: cli.debug, - writer: Box::new(std::io::stdout()), - }, - ); + let term = TermWriter { + debug: cli.debug, + raw_term: Term::stdout(), + }; // Logging and Telemetry let (debug_layer, event_layer) = ( @@ -130,10 +126,7 @@ async fn main() -> Result<(), Error> { filter::filter_fn(|_| true) // // Only log if `debug` is set - .and(filter::filter_fn(move |_| cli.debug)) - // - // Only log if event level is debug - .and(filter::filter_fn(|md| md.level() == &Level::DEBUG)), + .and(filter::filter_fn(move |_| cli.debug)), ), ) .with( diff --git a/crates/icp-cli/src/operations/build.rs b/crates/icp-cli/src/operations/build.rs index 096150d1..277fbcfc 100644 --- a/crates/icp-cli/src/operations/build.rs +++ b/crates/icp-cli/src/operations/build.rs @@ -1,11 +1,11 @@ use std::sync::Arc; use camino_tempfile::tempdir; -use console::Term; use futures::{StreamExt, stream::FuturesOrdered}; use icp::{ Canister, canister::build::{Build, BuildError, Params}, + context::TermWriter, prelude::*, }; use snafu::{ResultExt, Snafu}; @@ -82,7 +82,7 @@ pub(crate) async fn build_many_with_progress_bar( canisters: Vec<(PathBuf, Canister)>, builder: Arc, artifacts: Arc, - term: &Term, + term: &TermWriter, debug: bool, ) -> Result<(), anyhow::Error> { let mut futs = FuturesOrdered::new(); diff --git a/crates/icp-cli/src/operations/sync.rs b/crates/icp-cli/src/operations/sync.rs index 9411b577..cc7f5f15 100644 --- a/crates/icp-cli/src/operations/sync.rs +++ b/crates/icp-cli/src/operations/sync.rs @@ -1,9 +1,9 @@ -use console::Term; use futures::{StreamExt, stream::FuturesOrdered}; use ic_agent::{Agent, export::Principal}; use icp::{ Canister, canister::sync::{Params, Synchronize, SynchronizeError}, + context::TermWriter, prelude::PathBuf, }; use snafu::prelude::*; @@ -19,7 +19,7 @@ pub struct SyncOperationError; async fn sync_canister( syncer: &Arc, agent: &Agent, - _term: &Term, + _term: &TermWriter, canister_path: PathBuf, canister_id: Principal, canister_info: &Canister, @@ -60,7 +60,7 @@ async fn sync_canister( pub(crate) async fn sync_many( syncer: Arc, agent: Agent, - term: Arc, + term: Arc, canisters: Vec<(Principal, PathBuf, Canister)>, debug: bool, ) -> Result<(), SyncOperationError> { diff --git a/crates/icp-cli/tests/build_adapter_tests.rs b/crates/icp-cli/tests/build_adapter_tests.rs index 89b2fa0f..0df31d2d 100644 --- a/crates/icp-cli/tests/build_adapter_tests.rs +++ b/crates/icp-cli/tests/build_adapter_tests.rs @@ -24,7 +24,7 @@ fn build_adapter_pre_built_path() { build: steps: - type: pre-built - path: {wasm} + path: '{wasm}' "#}; write_string( @@ -66,7 +66,7 @@ fn build_adapter_pre_built_path_invalid_checksum() { build: steps: - type: pre-built - path: {wasm} + path: '{wasm}' sha256: invalid "#}; @@ -114,7 +114,7 @@ fn build_adapter_pre_built_path_valid_checksum() { build: steps: - type: pre-built - path: {wasm} + path: '{wasm}' sha256: {actual} "#}; diff --git a/crates/icp-cli/tests/build_tests.rs b/crates/icp-cli/tests/build_tests.rs index 1a502890..24668fdf 100644 --- a/crates/icp-cli/tests/build_tests.rs +++ b/crates/icp-cli/tests/build_tests.rs @@ -27,7 +27,7 @@ fn build_adapter_script_single() { build: steps: - type: script - command: cp {path} "$ICP_WASM_OUTPUT_PATH" + command: cp '{path}' "$ICP_WASM_OUTPUT_PATH" "#}; write_string( @@ -64,7 +64,7 @@ fn build_adapter_script_multiple() { - type: script command: echo "before" - type: script - command: cp {path} "$ICP_WASM_OUTPUT_PATH" + command: cp '{path}' "$ICP_WASM_OUTPUT_PATH" - type: script command: echo "after" "#}; @@ -254,7 +254,7 @@ fn build_adapter_script_with_explicit_sh_c() { build: steps: - type: script - command: sh -c 'echo "nested shell" > {path} && cp {path} "$ICP_WASM_OUTPUT_PATH"' + command: sh -c 'echo "nested shell" > '"'"'{path}'"'"' && cp '"'"'{path}'"'"' "$ICP_WASM_OUTPUT_PATH"' "#}; write_string( @@ -373,17 +373,17 @@ fn build_multiple_canisters() { build: steps: - type: script - command: echo "building canister-a" && cp {path} "$ICP_WASM_OUTPUT_PATH" + command: echo "building canister-a" && cp '{path}' "$ICP_WASM_OUTPUT_PATH" - name: canister-b build: steps: - type: script - command: echo "building canister-b" && cp {path} "$ICP_WASM_OUTPUT_PATH" + command: echo "building canister-b" && cp '{path}' "$ICP_WASM_OUTPUT_PATH" - name: canister-c build: steps: - type: script - command: echo "building canister-c" && cp {path} "$ICP_WASM_OUTPUT_PATH" + command: echo "building canister-c" && cp '{path}' "$ICP_WASM_OUTPUT_PATH" "#}; write_string( @@ -423,17 +423,17 @@ fn build_all_canisters_in_environment() { build: steps: - type: script - command: echo "building canister-a" && cp {path} "$ICP_WASM_OUTPUT_PATH" + command: echo "building canister-a" && cp '{path}' "$ICP_WASM_OUTPUT_PATH" - name: canister-b build: steps: - type: script - command: echo "building canister-b" && cp {path} "$ICP_WASM_OUTPUT_PATH" + command: echo "building canister-b" && cp '{path}' "$ICP_WASM_OUTPUT_PATH" - name: canister-c build: steps: - type: script - command: echo "building canister-c" && cp {path} "$ICP_WASM_OUTPUT_PATH" + command: echo "building canister-c" && cp '{path}' "$ICP_WASM_OUTPUT_PATH" environments: - name: test-env diff --git a/crates/icp-cli/tests/canister_delete_tests.rs b/crates/icp-cli/tests/canister_delete_tests.rs index 265cfa2b..c6c79133 100644 --- a/crates/icp-cli/tests/canister_delete_tests.rs +++ b/crates/icp-cli/tests/canister_delete_tests.rs @@ -23,7 +23,7 @@ async fn canister_delete() { build: steps: - type: script - command: cp {wasm} "$ICP_WASM_OUTPUT_PATH" + command: cp '{wasm}' "$ICP_WASM_OUTPUT_PATH" {NETWORK_RANDOM_PORT} {ENVIRONMENT_RANDOM_PORT} diff --git a/crates/icp-cli/tests/canister_info_tests.rs b/crates/icp-cli/tests/canister_info_tests.rs index 4aaa12a1..b5595f38 100644 --- a/crates/icp-cli/tests/canister_info_tests.rs +++ b/crates/icp-cli/tests/canister_info_tests.rs @@ -23,7 +23,7 @@ async fn canister_status() { build: steps: - type: script - command: cp {wasm} "$ICP_WASM_OUTPUT_PATH" + command: cp '{wasm}' "$ICP_WASM_OUTPUT_PATH" {NETWORK_RANDOM_PORT} {ENVIRONMENT_RANDOM_PORT} diff --git a/crates/icp-cli/tests/canister_install_tests.rs b/crates/icp-cli/tests/canister_install_tests.rs index 0cf92089..f4783e97 100644 --- a/crates/icp-cli/tests/canister_install_tests.rs +++ b/crates/icp-cli/tests/canister_install_tests.rs @@ -26,7 +26,7 @@ async fn canister_install() { build: steps: - type: script - command: cp {wasm} "$ICP_WASM_OUTPUT_PATH" + command: cp '{wasm}' "$ICP_WASM_OUTPUT_PATH" {NETWORK_RANDOM_PORT} {ENVIRONMENT_RANDOM_PORT} @@ -202,6 +202,7 @@ async fn canister_install_with_wasm_flag() { .stdout(eq("(\"Hello, test!\")").trim()); } +#[cfg(unix)] // moc #[tokio::test] async fn canister_install_with_init_args_candid() { let ctx = TestContext::new(); @@ -265,6 +266,7 @@ async fn canister_install_with_init_args_candid() { .stdout(eq("(\"42\")").trim()); } +#[cfg(unix)] // moc #[tokio::test] async fn canister_install_with_init_args_hex() { let ctx = TestContext::new(); @@ -329,6 +331,7 @@ async fn canister_install_with_init_args_hex() { .stdout(eq("(\"100\")").trim()); } +#[cfg(unix)] // moc #[tokio::test] async fn canister_install_with_environment_init_args_override() { let ctx = TestContext::new(); @@ -397,6 +400,7 @@ async fn canister_install_with_environment_init_args_override() { .stdout(eq("(\"200\")").trim()); } +#[cfg(unix)] // moc #[tokio::test] async fn canister_install_with_invalid_init_args() { let ctx = TestContext::new(); @@ -469,7 +473,7 @@ async fn canister_install_with_environment_settings_override() { build: steps: - type: script - command: cp {wasm} "$ICP_WASM_OUTPUT_PATH" + command: cp '{wasm}' "$ICP_WASM_OUTPUT_PATH" settings: memory_allocation: 1073741824 diff --git a/crates/icp-cli/tests/canister_metadata_tests.rs b/crates/icp-cli/tests/canister_metadata_tests.rs index f4addb0e..2c0cd409 100644 --- a/crates/icp-cli/tests/canister_metadata_tests.rs +++ b/crates/icp-cli/tests/canister_metadata_tests.rs @@ -23,7 +23,7 @@ async fn canister_metadata() { build: steps: - type: script - command: cp {wasm} "$ICP_WASM_OUTPUT_PATH" + command: cp '{wasm}' "$ICP_WASM_OUTPUT_PATH" {NETWORK_RANDOM_PORT} {ENVIRONMENT_RANDOM_PORT} @@ -87,7 +87,7 @@ async fn canister_metadata_not_found() { build: steps: - type: script - command: cp {wasm} "$ICP_WASM_OUTPUT_PATH" + command: cp '{wasm}' "$ICP_WASM_OUTPUT_PATH" {NETWORK_RANDOM_PORT} {ENVIRONMENT_RANDOM_PORT} diff --git a/crates/icp-cli/tests/canister_settings_tests.rs b/crates/icp-cli/tests/canister_settings_tests.rs index 7e79c506..7785b532 100644 --- a/crates/icp-cli/tests/canister_settings_tests.rs +++ b/crates/icp-cli/tests/canister_settings_tests.rs @@ -34,7 +34,7 @@ async fn canister_settings_update_controllers() { build: steps: - type: script - command: cp {wasm} "$ICP_WASM_OUTPUT_PATH" + command: cp '{wasm}' "$ICP_WASM_OUTPUT_PATH" {NETWORK_RANDOM_PORT} {ENVIRONMENT_RANDOM_PORT} @@ -340,7 +340,7 @@ async fn canister_settings_update_log_visibility() { build: steps: - type: script - command: cp {wasm} "$ICP_WASM_OUTPUT_PATH" + command: cp '{wasm}' "$ICP_WASM_OUTPUT_PATH" {NETWORK_RANDOM_PORT} {ENVIRONMENT_RANDOM_PORT} @@ -657,7 +657,7 @@ async fn canister_settings_update_miscellaneous() { build: steps: - type: script - command: cp {wasm} "$ICP_WASM_OUTPUT_PATH" + command: cp '{wasm}' "$ICP_WASM_OUTPUT_PATH" {NETWORK_RANDOM_PORT} {ENVIRONMENT_RANDOM_PORT} @@ -780,7 +780,7 @@ async fn canister_settings_update_environment_variables() { build: steps: - type: script - command: cp {wasm} "$ICP_WASM_OUTPUT_PATH" + command: cp '{wasm}' "$ICP_WASM_OUTPUT_PATH" {NETWORK_RANDOM_PORT} {ENVIRONMENT_RANDOM_PORT} @@ -965,7 +965,7 @@ async fn canister_settings_sync() { build: steps: - type: script - command: cp {wasm} "$ICP_WASM_OUTPUT_PATH" + command: cp '{wasm}' "$ICP_WASM_OUTPUT_PATH" {NETWORK_RANDOM_PORT} {ENVIRONMENT_RANDOM_PORT} @@ -1034,7 +1034,7 @@ async fn canister_settings_sync() { build: steps: - type: script - command: cp {wasm} "$ICP_WASM_OUTPUT_PATH" + command: cp '{wasm}' "$ICP_WASM_OUTPUT_PATH" settings: {NETWORK_RANDOM_PORT} @@ -1047,7 +1047,7 @@ async fn canister_settings_sync() { build: steps: - type: script - command: cp {wasm} "$ICP_WASM_OUTPUT_PATH" + command: cp '{wasm}' "$ICP_WASM_OUTPUT_PATH" settings: wasm_memory_limit: ~ @@ -1061,7 +1061,7 @@ async fn canister_settings_sync() { build: steps: - type: script - command: cp {wasm} "$ICP_WASM_OUTPUT_PATH" + command: cp '{wasm}' "$ICP_WASM_OUTPUT_PATH" settings: wasm_memory_limit: 4000000000 diff --git a/crates/icp-cli/tests/canister_start_tests.rs b/crates/icp-cli/tests/canister_start_tests.rs index e7b21e8c..48861772 100644 --- a/crates/icp-cli/tests/canister_start_tests.rs +++ b/crates/icp-cli/tests/canister_start_tests.rs @@ -26,7 +26,7 @@ async fn canister_start() { build: steps: - type: script - command: cp {wasm} "$ICP_WASM_OUTPUT_PATH" + command: cp '{wasm}' "$ICP_WASM_OUTPUT_PATH" {NETWORK_RANDOM_PORT} {ENVIRONMENT_RANDOM_PORT} diff --git a/crates/icp-cli/tests/canister_status_tests.rs b/crates/icp-cli/tests/canister_status_tests.rs index 4482dca2..71437a35 100644 --- a/crates/icp-cli/tests/canister_status_tests.rs +++ b/crates/icp-cli/tests/canister_status_tests.rs @@ -26,7 +26,7 @@ async fn canister_status() { build: steps: - type: script - command: cp {wasm} "$ICP_WASM_OUTPUT_PATH" + command: cp '{wasm}' "$ICP_WASM_OUTPUT_PATH" {NETWORK_RANDOM_PORT} {ENVIRONMENT_RANDOM_PORT} diff --git a/crates/icp-cli/tests/canister_stop_tests.rs b/crates/icp-cli/tests/canister_stop_tests.rs index 1c47eb22..70d30074 100644 --- a/crates/icp-cli/tests/canister_stop_tests.rs +++ b/crates/icp-cli/tests/canister_stop_tests.rs @@ -26,7 +26,7 @@ async fn canister_stop() { build: steps: - type: script - command: cp {wasm} "$ICP_WASM_OUTPUT_PATH" + command: cp '{wasm}' "$ICP_WASM_OUTPUT_PATH" {NETWORK_RANDOM_PORT} {ENVIRONMENT_RANDOM_PORT} diff --git a/crates/icp-cli/tests/common/context.rs b/crates/icp-cli/tests/common/context.rs index ae12d871..6df9ae55 100644 --- a/crates/icp-cli/tests/common/context.rs +++ b/crates/icp-cli/tests/common/context.rs @@ -3,6 +3,7 @@ use std::{ env, ffi::OsString, fs::{self, create_dir_all}, + process::Stdio, }; use assert_cmd::Command; @@ -10,7 +11,6 @@ use camino_tempfile::{Utf8TempDir as TempDir, tempdir}; use candid::Principal; use ic_agent::Agent; use icp::{ - directories::{Access, Directories}, network::managed::{ launcher::{NetworkInstance, wait_for_launcher_status}, run::initialize_network, @@ -47,6 +47,10 @@ impl TestContext { let mock_cred_dir = home_dir.path().join("mock-keyring"); fs::create_dir(&mock_cred_dir).expect("failed to create mock keyring dir"); + // App files + let icp_home_dir = home_dir.path().join("icp"); + fs::create_dir(&icp_home_dir).expect("failed to create icp home dir"); + eprintln!("Test environment home directory: {}", home_dir.path()); // OS Path @@ -72,15 +76,21 @@ impl TestContext { // Isolate the command cmd.current_dir(self.home_path()); - cmd.env("HOME", self.home_path()); + // Isolate the whole user directory in Unix, test in normal mode + #[cfg(unix)] + cmd.env("HOME", self.home_path()).env_remove("ICP_HOME"); + // Run in portable mode on Windows, the user directory cannot be mocked + #[cfg(windows)] + cmd.env("ICP_HOME", self.home_path().join("icp")); cmd.env("PATH", self.os_path.clone()); - cmd.env_remove("ICP_HOME"); cmd.env("ICP_CLI_KEYRING_MOCK_DIR", self.mock_cred_dir.clone()); cmd } + #[cfg(unix)] pub(crate) async fn launcher_path(&self) -> PathBuf { + use icp::directories::{Access, Directories}; if let Ok(var) = env::var("ICP_CLI_NETWORK_LAUNCHER_PATH") { PathBuf::from(var) } else { @@ -112,6 +122,17 @@ impl TestContext { } } + pub(crate) async fn launcher_path_or_nothing(&self) -> PathBuf { + #[cfg(unix)] + { + self.launcher_path().await + } + #[cfg(windows)] + { + PathBuf::new() + } + } + fn build_os_path(bin_dir: &Path) -> OsString { let old_path = env::var_os("PATH").unwrap_or_default(); let mut new_path = bin_dir.as_os_str().to_owned(); @@ -174,15 +195,19 @@ impl TestContext { pub(crate) async fn start_network_in(&self, project_dir: &Path, name: &str) -> ChildGuard { let icp_path = env!("CARGO_BIN_EXE_icp"); let mut cmd = std::process::Command::new(icp_path); - cmd.current_dir(project_dir) - .env("HOME", self.home_path()) - .env_remove("ICP_HOME") - .arg("network") - .arg("start") - .arg(name); - - let launcher_path = self.launcher_path().await; - cmd.env("ICP_CLI_NETWORK_LAUNCHER_PATH", launcher_path); + cmd.current_dir(project_dir); + // isolate the whole user directory in Unix, test in normal mode + #[cfg(unix)] + cmd.env("HOME", self.home_path()).env_remove("ICP_HOME"); + // run in portable mode on Windows, the user directory cannot be mocked + #[cfg(windows)] + cmd.env("ICP_HOME", self.home_path().join("icp")); + cmd.arg("network").arg("start").arg(name); + #[cfg(unix)] + { + let launcher_path = self.launcher_path().await; + cmd.env("ICP_CLI_NETWORK_LAUNCHER_PATH", launcher_path); + } eprintln!("Running network in {project_dir}"); @@ -222,8 +247,6 @@ impl TestContext { project_dir: &Path, flags: &[&str], ) -> ChildGuard { - let launcher_path = self.launcher_path().await; - // Create network directory structure let network_dir = project_dir .join(".icp") @@ -241,26 +264,93 @@ impl TestContext { eprintln!("Starting network with custom flags"); // Spawn launcher - let mut cmd = std::process::Command::new(&launcher_path); - cmd.args(["--interface-version=1.0.0", "--status-dir"]); - cmd.arg(&launcher_dir); - cmd.args(flags); - cmd.stdout(std::process::Stdio::inherit()); - cmd.stderr(std::process::Stdio::inherit()); - - #[cfg(unix)] - { - use std::os::unix::process::CommandExt; - cmd.process_group(0); - } + let mut cmd = { + #[cfg(unix)] + { + let launcher_path = self.launcher_path().await; + let mut cmd = std::process::Command::new(&launcher_path); + cmd.args(["--interface-version=1.0.0", "--status-dir"]); + cmd.arg(&launcher_dir); + cmd.args(flags); + cmd.stdout(Stdio::inherit()); + cmd.stderr(Stdio::inherit()); + use std::os::unix::process::CommandExt; + cmd.process_group(0); + cmd + } + #[cfg(windows)] + { + let distro = std::env::var("ICP_CLI_DOCKER_WSL2_DISTRO"); + let launcher_dir_param = if let Ok(distro) = distro { + wslpath2::convert( + launcher_dir.as_str(), + Some(&distro), + wslpath2::Conversion::WindowsToWsl, + true, + ) + .expect("Failed to convert launcher dir to WSL path") + } else { + launcher_dir.to_string() + }; + let mut cmd = std::process::Command::new("docker"); + cmd.args([ + "run", + "--rm", + "--platform", + if cfg!(target_arch = "aarch64") { + "linux/arm64" + } else { + "linux/amd64" + }, + "-v", + &format!("{launcher_dir_param}:/app/status"), + "--cidfile", + network_dir.join("container-id").as_str(), + "-P", + "ghcr.io/dfinity/icp-cli-network-launcher:v11.0.0", + ]); + cmd.args(flags); + cmd.stdout(Stdio::inherit()); + cmd.stderr(Stdio::inherit()); + cmd + } + }; let watcher = wait_for_launcher_status(&launcher_dir).expect("Failed to watch launcher status"); - let child = cmd.spawn().expect("failed to spawn launcher"); + let guard = ChildGuard::spawn(&mut cmd).expect("Failed to spawn network launcher"); + let child = &guard.child; let launcher_pid = child.id(); // Wait for port file using the function from icp-network let status = watcher.await.expect("Timeout waiting for port file"); - let gateway_port = status.gateway_port; + let gateway_port = { + #[cfg(unix)] + { + status.gateway_port + } + #[cfg(windows)] + { + let container_id = fs::read_to_string(network_dir.join("container-id")) + .expect("Failed to read container ID file"); + let container_id = container_id.trim(); + let out = Command::new("docker") + .args([ + "port", + container_id, + &format!("{}/tcp", status.gateway_port), + ]) + .output() + .expect("Failed to get gateway port from docker") + .stdout; + let out = str::from_utf8(&out).unwrap().trim(); + // Output is like "0.0.0.0:32768" - extract the port + out.rsplit(':') + .next() + .expect("Invalid docker port output") + .parse::() + .expect("Failed to parse port from docker port output") + } + }; eprintln!("Gateway started on port {gateway_port}"); let instance = NetworkInstance { @@ -315,8 +405,7 @@ impl TestContext { .unwrap(), ) .expect("Gateway URL should not be already initialized"); - // Wrap child in ChildGuard - ChildGuard { child } + guard } pub(crate) fn ping_until_healthy(&self, project_dir: &Path, name: &str) { @@ -345,7 +434,7 @@ impl TestContext { .join(network_name) .join("descriptor.json"); let start_time = std::time::Instant::now(); - let timeout = 45; + let timeout = 300; eprintln!("Waiting for network descriptor at {descriptor_path} - limit {timeout}s"); let network_descriptor = loop { let elapsed = start_time.elapsed().as_secs(); @@ -425,8 +514,18 @@ impl TestContext { } pub(crate) fn docker_pull_network(&self) { + let platform = if cfg!(target_arch = "aarch64") { + "linux/arm64" + } else { + "linux/amd64" + }; Command::new("docker") - .args(["pull", "ghcr.io/dfinity/icp-cli-network-launcher:v11.0.0"]) + .args([ + "pull", + "--platform", + platform, + "ghcr.io/dfinity/icp-cli-network-launcher:v11.0.0", + ]) .assert() .success(); } diff --git a/crates/icp-cli/tests/common/mod.rs b/crates/icp-cli/tests/common/mod.rs index 41b61a83..29174145 100644 --- a/crates/icp-cli/tests/common/mod.rs +++ b/crates/icp-cli/tests/common/mod.rs @@ -1,6 +1,6 @@ #![allow(dead_code)] -use std::process::{Child, Command}; +use std::process::Command; use httptest::{Expectation, Server, matchers::*, responders::*}; @@ -8,6 +8,7 @@ pub(crate) mod clients; mod context; pub(crate) use context::TestContext; +use send_ctrlc::{InterruptibleChild, InterruptibleCommand}; #[cfg(unix)] pub(crate) const PATH_SEPARATOR: &str = ":"; @@ -79,7 +80,7 @@ pub(crate) struct TestNetwork { } pub(crate) struct ChildGuard { - child: Child, + child: InterruptibleChild, } impl ChildGuard { @@ -89,7 +90,12 @@ impl ChildGuard { use std::os::unix::process::CommandExt; cmd.process_group(0); } - let child = cmd.spawn()?; + #[cfg(windows)] + { + use std::os::windows::process::CommandExt; + cmd.creation_flags(0x00000200); // CREATE_NEW_PROCESS_GROUP + } + let child = cmd.spawn_interruptible()?; Ok(Self { child }) } @@ -105,6 +111,13 @@ impl ChildGuard { // Give the process some time to shut down gracefully std::thread::sleep(std::time::Duration::from_secs(2)); } + #[cfg(windows)] + { + use send_ctrlc::Interruptible; + _ = self.child.terminate(); // CTRL_BREAK_EVENT, required to target process group + // Give the process some time to shut down gracefully + std::thread::sleep(std::time::Duration::from_secs(2)); + } } } diff --git a/crates/icp-cli/tests/deploy_tests.rs b/crates/icp-cli/tests/deploy_tests.rs index 1e1bf573..de17cadc 100644 --- a/crates/icp-cli/tests/deploy_tests.rs +++ b/crates/icp-cli/tests/deploy_tests.rs @@ -81,7 +81,7 @@ async fn deploy() { build: steps: - type: script - command: cp {wasm} "$ICP_WASM_OUTPUT_PATH" + command: cp '{wasm}' "$ICP_WASM_OUTPUT_PATH" {NETWORK_RANDOM_PORT} {ENVIRONMENT_RANDOM_PORT} @@ -147,7 +147,7 @@ async fn deploy_twice_should_succeed() { build: steps: - type: script - command: cp {wasm} "$ICP_WASM_OUTPUT_PATH" + command: cp '{wasm}' "$ICP_WASM_OUTPUT_PATH" {NETWORK_RANDOM_PORT} {ENVIRONMENT_RANDOM_PORT} @@ -355,7 +355,7 @@ async fn deploy_prints_canister_urls() { build: steps: - type: script - command: cp {wasm} "$ICP_WASM_OUTPUT_PATH" + command: cp '{wasm}' "$ICP_WASM_OUTPUT_PATH" {NETWORK_RANDOM_PORT} {ENVIRONMENT_RANDOM_PORT} diff --git a/crates/icp-cli/tests/identity_tests.rs b/crates/icp-cli/tests/identity_tests.rs index d74ed8c4..8bc439d0 100644 --- a/crates/icp-cli/tests/identity_tests.rs +++ b/crates/icp-cli/tests/identity_tests.rs @@ -328,7 +328,7 @@ async fn identity_storage_forms() { build: steps: - type: script - command: cp {wasm} "$ICP_WASM_OUTPUT_PATH" + command: cp '{wasm}' "$ICP_WASM_OUTPUT_PATH" {NETWORK_RANDOM_PORT} {ENVIRONMENT_RANDOM_PORT} diff --git a/crates/icp-cli/tests/network_status_tests.rs b/crates/icp-cli/tests/network_status_tests.rs index b1f92cb4..3b91dee2 100644 --- a/crates/icp-cli/tests/network_status_tests.rs +++ b/crates/icp-cli/tests/network_status_tests.rs @@ -8,7 +8,7 @@ use crate::common::{NETWORK_RANDOM_PORT, TestContext}; async fn status_when_network_running() { let ctx = TestContext::new(); let project_dir = ctx.create_project_dir("icp"); - let launcher_path = ctx.launcher_path().await; + let launcher_path = ctx.launcher_path_or_nothing().await; // Project manifest write_string(&project_dir.join("icp.yaml"), NETWORK_RANDOM_PORT) @@ -47,7 +47,7 @@ async fn status_when_network_running() { async fn status_with_json() { let ctx = TestContext::new(); let project_dir = ctx.create_project_dir("icp"); - let launcher_path = ctx.launcher_path().await; + let launcher_path = ctx.launcher_path_or_nothing().await; // Project manifest write_string(&project_dir.join("icp.yaml"), NETWORK_RANDOM_PORT) @@ -96,7 +96,7 @@ async fn status_with_json() { async fn status_fixed_port() { let ctx = TestContext::new(); let project_dir = ctx.create_project_dir("icp"); - let launcher_path = ctx.launcher_path().await; + let launcher_path = ctx.launcher_path_or_nothing().await; // Project manifest with fixed port write_string( diff --git a/crates/icp-cli/tests/network_tests.rs b/crates/icp-cli/tests/network_tests.rs index 08d1b7de..9bcc6317 100644 --- a/crates/icp-cli/tests/network_tests.rs +++ b/crates/icp-cli/tests/network_tests.rs @@ -11,7 +11,6 @@ use predicates::{ }; use serde_json::Value; use serial_test::file_serial; -use sysinfo::{ProcessesToUpdate, System}; use test_tag::tag; use crate::common::{ @@ -72,7 +71,7 @@ async fn network_same_port() { .failure() .stderr(contains(format!( "Error: port 8080 is in use by the sameport-network network of the project at '{}'", - project_dir_a.canonicalize().unwrap().display() + dunce::canonicalize(&project_dir_a).unwrap().display() ))); } @@ -165,7 +164,7 @@ async fn deploy_to_other_projects_network() { build: steps: - type: script - command: cp {wasm} "$ICP_WASM_OUTPUT_PATH" + command: cp '{wasm}' "$ICP_WASM_OUTPUT_PATH" networks: - name: network-a @@ -353,12 +352,24 @@ async fn network_run_and_stop_background() { .trim() .parse() .expect("Descriptor file should contain valid JSON"); - let background_launcher_pid = descriptor - .get("child-locator") - .and_then(|cl| cl.get("pid")) - .and_then(|pid| pid.as_u64()) - .expect("Descriptor should contain launcher PID"); - let background_launcher_pid = (background_launcher_pid as usize).into(); + #[cfg(unix)] + let background_launcher_pid = { + let background_launcher_pid = descriptor + .get("child-locator") + .and_then(|cl| cl.get("pid")) + .and_then(|pid| pid.as_u64()) + .expect("Descriptor should contain launcher PID"); + (background_launcher_pid as usize).into() + }; + #[cfg(windows)] + let background_container_id = { + let background_container_id = descriptor + .get("child-locator") + .and_then(|c| c.get("id")) + .and_then(|cid| cid.as_str()) + .expect("Descriptor should contain launcher container ID"); + background_container_id.to_string() + }; // Verify network is healthy with agent.status() let agent = ic_agent::Agent::builder() @@ -373,30 +384,53 @@ async fn network_run_and_stop_background() { ); // Stop the network - ctx.icp() + let mut stop = ctx + .icp() .current_dir(&project_dir) .args(["network", "stop", "random-network"]) .assert() - .success() - .stdout(contains(format!( + .success(); + #[cfg(unix)] + { + stop = stop.stdout(contains(format!( "Stopping background network (PID: {})", background_launcher_pid - ))) - .stdout(contains("Network stopped successfully")); + ))); + } + #[cfg(windows)] + { + stop = stop.stdout(contains(format!( + "Stopping background network (container ID: {})", + &background_container_id[..12] + ))); + } + stop.stdout(contains("Network stopped successfully")); - // Verify PID file is removed + // Verify descriptor file is removed assert!( !descriptor_file_path.exists(), "Descriptor file should be removed after stopping" ); // Verify launcher process is no longer running - let mut system = System::new(); - system.refresh_processes(ProcessesToUpdate::Some(&[background_launcher_pid]), true); - assert!( - system.process(background_launcher_pid).is_none(), - "Process should no longer be running" - ); + #[cfg(unix)] + { + use sysinfo::{ProcessesToUpdate, System}; + let mut system = System::new(); + system.refresh_processes(ProcessesToUpdate::Some(&[background_launcher_pid]), true); + assert!( + system.process(background_launcher_pid).is_none(), + "Process should no longer be running" + ); + } + #[cfg(windows)] + { + let output = std::process::Command::new("docker") + .args(["inspect", &background_container_id]) + .output() + .expect("Failed to run docker inspect"); + assert!(!output.status.success(), "Container should no longer exist"); + } // Verify network is no longer reachable let status_result = agent.status().await; diff --git a/crates/icp-cli/tests/project_tests.rs b/crates/icp-cli/tests/project_tests.rs index 2644d285..d0090d91 100644 --- a/crates/icp-cli/tests/project_tests.rs +++ b/crates/icp-cli/tests/project_tests.rs @@ -25,7 +25,7 @@ fn single_canister_project() { build: steps: - type: script - command: cp {path} "$ICP_WASM_OUTPUT_PATH" + command: cp '{path}' "$ICP_WASM_OUTPUT_PATH" "#}; write_string( @@ -71,7 +71,7 @@ fn multi_canister_project() { build: steps: - type: script - command: cp {path} "$ICP_WASM_OUTPUT_PATH" + command: cp '{path}' "$ICP_WASM_OUTPUT_PATH" "#}; create_dir_all(&project_dir.join("my-canister")).expect("failed to create canister directory"); @@ -119,7 +119,7 @@ fn glob_path() { build: steps: - type: script - command: cp {path} "$ICP_WASM_OUTPUT_PATH" + command: cp '{path}' "$ICP_WASM_OUTPUT_PATH" "#}; create_dir_all(&project_dir.join("canisters/my-canister")) diff --git a/crates/icp-cli/tests/sync_tests.rs b/crates/icp-cli/tests/sync_tests.rs index 8076991c..d970058d 100644 --- a/crates/icp-cli/tests/sync_tests.rs +++ b/crates/icp-cli/tests/sync_tests.rs @@ -30,7 +30,7 @@ async fn sync_adapter_script_single() { build: steps: - type: script - command: cp {wasm} "$ICP_WASM_OUTPUT_PATH" + command: cp '{wasm}' "$ICP_WASM_OUTPUT_PATH" sync: steps: - type: script @@ -102,7 +102,7 @@ async fn sync_adapter_script_multiple() { build: steps: - type: script - command: cp {wasm} "$ICP_WASM_OUTPUT_PATH" + command: cp '{wasm}' "$ICP_WASM_OUTPUT_PATH" sync: steps: - type: script @@ -314,7 +314,7 @@ async fn sync_multiple_canisters() { build: steps: - type: script - command: cp {wasm} "$ICP_WASM_OUTPUT_PATH" + command: cp '{wasm}' "$ICP_WASM_OUTPUT_PATH" sync: steps: - type: script @@ -323,7 +323,7 @@ async fn sync_multiple_canisters() { build: steps: - type: script - command: cp {wasm} "$ICP_WASM_OUTPUT_PATH" + command: cp '{wasm}' "$ICP_WASM_OUTPUT_PATH" sync: steps: - type: script @@ -332,7 +332,7 @@ async fn sync_multiple_canisters() { build: steps: - type: script - command: cp {wasm} "$ICP_WASM_OUTPUT_PATH" + command: cp '{wasm}' "$ICP_WASM_OUTPUT_PATH" sync: steps: - type: script @@ -408,7 +408,7 @@ async fn sync_all_canisters_in_environment() { build: steps: - type: script - command: cp {wasm} "$ICP_WASM_OUTPUT_PATH" + command: cp '{wasm}' "$ICP_WASM_OUTPUT_PATH" sync: steps: - type: script @@ -417,7 +417,7 @@ async fn sync_all_canisters_in_environment() { build: steps: - type: script - command: cp {wasm} "$ICP_WASM_OUTPUT_PATH" + command: cp '{wasm}' "$ICP_WASM_OUTPUT_PATH" sync: steps: - type: script @@ -426,7 +426,7 @@ async fn sync_all_canisters_in_environment() { build: steps: - type: script - command: cp {wasm} "$ICP_WASM_OUTPUT_PATH" + command: cp '{wasm}' "$ICP_WASM_OUTPUT_PATH" sync: steps: - type: script diff --git a/crates/icp/Cargo.toml b/crates/icp/Cargo.toml index 5d257fd1..6a9d9b70 100644 --- a/crates/icp/Cargo.toml +++ b/crates/icp/Cargo.toml @@ -62,7 +62,11 @@ tokio = { workspace = true, features = ["full"] } tracing = { workspace = true } url = { workspace = true } uuid = { workspace = true } +wslpath2 = { workspace = true } zeroize = { workspace = true } +[target.'cfg(windows)'.dependencies] +winreg = { workspace = true } + [dev-dependencies] jsonschema = { workspace = true } diff --git a/crates/icp/src/canister/build/script.rs b/crates/icp/src/canister/build/script.rs index 308f93d0..33fe0ab6 100644 --- a/crates/icp/src/canister/build/script.rs +++ b/crates/icp/src/canister/build/script.rs @@ -38,7 +38,7 @@ mod tests { // Define adapter let v = Adapter { command: CommandField::Command(format!( - "echo test > {} && echo {}", + "echo test > '{}' && echo '{}'", f.path(), f.path() )), @@ -72,10 +72,10 @@ mod tests { // Define adapter let v = Adapter { command: CommandField::Commands(vec![ - format!("echo cmd-1 >> {}", f.path()), - format!("echo cmd-2 >> {}", f.path()), - format!("echo cmd-3 >> {}", f.path()), - format!("echo {}", f.path()), + format!("echo cmd-1 >> '{}'", f.path()), + format!("echo cmd-2 >> '{}'", f.path()), + format!("echo cmd-3 >> '{}'", f.path()), + format!("echo '{}'", f.path()), ]), }; diff --git a/crates/icp/src/canister/script.rs b/crates/icp/src/canister/script.rs index 28866a28..afb3b4d6 100644 --- a/crates/icp/src/canister/script.rs +++ b/crates/icp/src/canister/script.rs @@ -36,6 +36,17 @@ pub enum ScriptError { #[snafu(display("command '{command}' failed with status code {code}"))] Status { command: String, code: String }, + + #[cfg(windows)] + #[snafu(display( + "failed to locate Git for Windows (if you prefer MSYS2, set ICP_CLI_BASH_PATH to the bash.exe path)" + ))] + LocateGit, + + #[snafu(display( + "WSL bash is not supported in the Windows version of icp-cli. Use the Linux version instead." + ))] + WslBash, } pub(super) async fn execute( @@ -152,8 +163,31 @@ fn shell_command(s: &str, cwd: &Path) -> Result { } .fail(); } - + #[cfg(unix)] let mut cmd = Command::new("sh"); + #[cfg(windows)] + let mut cmd = if let Ok(bash_path) = std::env::var("ICP_CLI_BASH_PATH") { + if bash_path == r"C:\Windows\System32\bash.exe" { + return WslBashSnafu.fail(); + } + Command::new(bash_path) + } else { + use winreg::{RegKey, enums::*}; + let git_for_windows_path = if let Ok(lm_path) = RegKey::predef(HKEY_LOCAL_MACHINE) + .open_subkey(r"SOFTWARE\GitForWindows") + .and_then(|key| key.get_value::("InstallPath")) + { + lm_path + } else if let Ok(cu_path) = RegKey::predef(HKEY_CURRENT_USER) + .open_subkey(r"SOFTWARE\GitForWindows") + .and_then(|key| key.get_value::("InstallPath")) + { + cu_path + } else { + return LocateGitSnafu.fail(); + }; + Command::new(PathBuf::from(git_for_windows_path).join("bin/bash.exe")) + }; cmd.args(["-c", s]); cmd.current_dir(cwd); Ok(cmd) diff --git a/crates/icp/src/context/init.rs b/crates/icp/src/context/init.rs index 8f7bd70b..2af6719e 100644 --- a/crates/icp/src/context/init.rs +++ b/crates/icp/src/context/init.rs @@ -1,12 +1,11 @@ use std::{env::current_dir, sync::Arc}; -use console::Term; use snafu::prelude::*; use crate::canister::build::Builder; use crate::canister::recipe::handlebars::Handlebars; use crate::canister::sync::Syncer; -use crate::context::Context; +use crate::context::{Context, TermWriter}; use crate::directories::{Access as _, Directories}; use crate::prelude::*; use crate::store_artifact::ArtifactStore; @@ -33,7 +32,7 @@ pub enum ContextInitError { pub fn initialize( project_root_override: Option, - term: Term, + term: TermWriter, debug: bool, password_func: PasswordFunc, ) -> Result { @@ -42,7 +41,7 @@ pub fn initialize( // Project Root let project_root_locate = Arc::new(manifest::ProjectRootLocateImpl::new( - current_dir() + dunce::canonicalize(current_dir().context(CwdSnafu)?) .context(CwdSnafu)? .try_into() .context(Utf8PathSnafu)?, // cwd diff --git a/crates/icp/src/context/mod.rs b/crates/icp/src/context/mod.rs index cc610276..40b8c09f 100644 --- a/crates/icp/src/context/mod.rs +++ b/crates/icp/src/context/mod.rs @@ -1,5 +1,6 @@ use console::Term; -use std::sync::Arc; +use std::{io::Write, sync::Arc}; +use tracing::info; use url::Url; use crate::{ @@ -69,7 +70,7 @@ pub enum CanisterSelection { pub struct Context { /// Terminal for printing messages for the user to see - pub term: Term, + pub term: TermWriter, /// Various cli-related directories (cache, configuration, etc). pub dirs: Arc, @@ -477,7 +478,10 @@ impl Context { /// Creates a test context with all mocks pub fn mocked() -> Context { Context { - term: Term::stderr(), + term: TermWriter { + debug: false, + raw_term: Term::stderr(), + }, dirs: Arc::new(crate::directories::UnimplementedMockDirs), ids: Arc::new(crate::store_id::mock::MockInMemoryIdStore::new()), artifacts: Arc::new(crate::store_artifact::MockInMemoryArtifactStore::new()), @@ -492,6 +496,51 @@ impl Context { } } +#[derive(Debug, Clone)] +pub struct TermWriter { + pub debug: bool, + pub raw_term: Term, +} + +impl TermWriter { + pub fn write_line(&self, line: &str) -> std::io::Result<()> { + if self.debug { + info!("{line}"); + } else { + writeln!(&self.raw_term, "{line}")?; + } + Ok(()) + } +} + +impl Write for TermWriter { + fn write(&mut self, buf: &[u8]) -> std::io::Result { + (&*self).write(buf) + } + + fn flush(&mut self) -> std::io::Result<()> { + (&*self).flush() + } +} + +impl Write for &TermWriter { + fn write(&mut self, buf: &[u8]) -> std::io::Result { + if self.debug { + info!("{}", String::from_utf8_lossy(buf).trim()); + Ok(buf.len()) + } else { + (&self.raw_term).write(buf) + } + } + + fn flush(&mut self) -> std::io::Result<()> { + if !self.debug { + self.raw_term.flush()?; + } + Ok(()) + } +} + #[derive(Debug, Snafu)] pub enum GetIdentityError { #[snafu(display("failed to load identity"))] diff --git a/crates/icp/src/network/managed/docker.rs b/crates/icp/src/network/managed/docker.rs index 46dad327..1e9a1db2 100644 --- a/crates/icp/src/network/managed/docker.rs +++ b/crates/icp/src/network/managed/docker.rs @@ -17,6 +17,7 @@ use itertools::Itertools; use snafu::ResultExt; use snafu::{OptionExt, Snafu}; use tokio::select; +use wslpath2::Conversion; use crate::network::{ ManagedImageConfig, @@ -42,7 +43,19 @@ pub async fn spawn_docker_launcher( status_dir, mounts, } = image_config; - let host_status_dir = Utf8TempDir::new().context(CreateStatusDirSnafu)?; + let platform = if let Some(p) = platform { + p.clone() + } else if cfg!(target_arch = "aarch64") { + "linux/arm64".to_string() + } else { + "linux/amd64".to_string() + }; + let wsl2_distro = std::env::var("ICP_CLI_DOCKER_WSL2_DISTRO").ok(); + let wsl2_distro = wsl2_distro.as_deref(); + let wsl2_convert = cfg!(windows) && wsl2_distro.is_some(); + let host_status_tmpdir = Utf8TempDir::new().context(CreateStatusDirSnafu)?; + let host_status_dir = host_status_tmpdir.path(); + let host_status_dir_param = convert_path(wsl2_convert, wsl2_distro, host_status_tmpdir.path())?; let socket = match std::env::var("DOCKER_HOST").ok() { Some(sock) => sock, #[cfg(unix)] @@ -77,8 +90,13 @@ pub async fn spawn_docker_launcher( #[cfg(windows)] None => r"\\.\pipe\docker_engine".to_string(), }; - let docker = Docker::connect_with_local(&socket, 120, bollard::API_DEFAULT_VERSION) - .context(ConnectDockerSnafu { socket: &socket })?; + let docker = if socket.starts_with("tcp://") || socket.starts_with("http://") { + let http_addr = socket.replace("tcp://", "http://"); + Docker::connect_with_http(&http_addr, 120, bollard::API_DEFAULT_VERSION) + } else { + Docker::connect_with_local(&socket, 120, bollard::API_DEFAULT_VERSION) + } + .context(ConnectDockerSnafu { socket: &socket })?; let portmap: HashMap<_, _> = port_mapping .iter() .map(|mapping| { @@ -101,6 +119,7 @@ pub async fn spawn_docker_launcher( let (host, rest) = m.split_once(':').context(ParseMountSnafu { mount: m })?; let host = dunce::canonicalize(host).context(ProcessMountSourceSnafu { path: host })?; let host = PathBuf::try_from(host.clone()).context(BadPathSnafu)?; + let host_param = convert_path(wsl2_convert, wsl2_distro, &host)?; let (target, flags) = match rest.split_once(':') { Some((t, f)) => (t, Some(f)), None => (rest, None), @@ -114,7 +133,7 @@ pub async fn spawn_docker_launcher( .transpose()?; Ok::<_, DockerLauncherError>(Mount { target: Some(target.to_string()), - source: Some(host.to_string()), + source: Some(host_param), typ: Some(MountTypeEnum::BIND), read_only, ..<_>::default() @@ -122,7 +141,7 @@ pub async fn spawn_docker_launcher( }) .chain([Ok(Mount { target: Some(status_dir.to_string()), - source: Some(host_status_dir.path().to_string()), + source: Some(host_status_dir_param), typ: Some(MountTypeEnum::BIND), read_only: Some(false), ..<_>::default() @@ -139,7 +158,7 @@ pub async fn spawn_docker_launcher( .create_image( Some(CreateImageOptions { from_image: Some(image.clone()), - platform: platform.clone().unwrap_or_default(), + platform: platform.clone(), ..<_>::default() }), None, @@ -154,8 +173,8 @@ pub async fn spawn_docker_launcher( }; let container_resp = docker .create_container( - platform.clone().map(|p| CreateContainerOptions { - platform: p, + Some(CreateContainerOptions { + platform: platform.clone(), ..<_>::default() }), ContainerCreateBody { @@ -176,7 +195,12 @@ pub async fn spawn_docker_launcher( host_config: Some(HostConfig { port_bindings: Some(portmap), mounts: Some(mounts), - binds: Some(volumes.to_vec()), + binds: Some( + volumes + .iter() + .map(|v| convert_volume(wsl2_convert, wsl2_distro, v)) + .try_collect()?, + ), shm_size: *shm_size, ..<_>::default() }), @@ -194,7 +218,7 @@ pub async fn spawn_docker_launcher( }); let container_id = guard.container_id.as_ref().unwrap(); let docker = guard.docker.as_ref().unwrap(); - let watcher = wait_for_launcher_status(host_status_dir.path())?; + let watcher = wait_for_launcher_status(host_status_dir)?; docker .start_container(container_id, None::) .await @@ -306,8 +330,13 @@ pub async fn stop_docker_launcher( container_id: &str, rm_on_exit: bool, ) -> Result<(), StopContainerError> { - let docker = Docker::connect_with_local(socket, 120, bollard::API_DEFAULT_VERSION) - .context(ConnectSnafu { socket })?; + let docker = if socket.starts_with("tcp://") { + let http_addr = socket.replace("tcp://", "http://"); + Docker::connect_with_http(&http_addr, 120, bollard::API_DEFAULT_VERSION) + } else { + Docker::connect_with_local(socket, 120, bollard::API_DEFAULT_VERSION) + } + .context(ConnectSnafu { socket })?; stop(&docker, container_id, rm_on_exit).await } @@ -348,6 +377,54 @@ pub enum StopContainerError { }, } +fn convert_path( + convert: bool, + distro: Option<&str>, + path: &Path, +) -> Result { + if convert { + wslpath2::convert(path.as_str(), distro, Conversion::WindowsToWsl, true).map_err(|e| { + WslPathConvertSnafu { + msg: e.to_string(), + path: path.to_path_buf(), + } + .build() + }) + } else { + Ok(path.to_string()) + } +} + +fn convert_volume( + convert: bool, + distro: Option<&str>, + volume: &str, +) -> Result { + // docker's actual parsing logic, clunky as it is + let (host, rest) = if volume.chars().next().unwrap().is_ascii_alphabetic() + && volume.chars().nth(1).unwrap() == ':' + { + let split_point = volume[2..] + .find(':') + .map(|idx| idx + 2) + .context(ParseMountSnafu { mount: volume })?; + (&volume[..split_point], &volume[split_point + 1..]) + } else { + volume + .split_once(':') + .context(ParseMountSnafu { mount: volume })? + }; + let host_param = if host.contains(&['/', '\\'][..]) { + let host_path = + dunce::canonicalize(host).context(ProcessMountSourceSnafu { path: host })?; + let host_path = PathBuf::try_from(host_path.clone()).context(BadPathSnafu)?; + convert_path(convert, distro, &host_path)? + } else { + host.to_string() + }; + Ok(format!("{host_param}:{rest}")) +} + #[derive(Debug, Snafu)] pub enum DockerLauncherError { #[snafu(display("failed to connect to docker daemon at {socket} (is it running?)"))] @@ -459,6 +536,10 @@ pub enum DockerLauncherError { source: std::str::Utf8Error, display: String, }, + #[snafu(display("failed to convert path {path} to WSL2: {msg}"))] + WslPathConvertError { msg: String, path: PathBuf }, + #[snafu(display("failed to create temporary directory in WSL2"))] + WslCreateTmpDirError { source: std::io::Error }, } #[derive(Default)] diff --git a/crates/icp/src/network/managed/launcher.rs b/crates/icp/src/network/managed/launcher.rs index 8931efaa..50b95f0b 100644 --- a/crates/icp/src/network/managed/launcher.rs +++ b/crates/icp/src/network/managed/launcher.rs @@ -2,12 +2,12 @@ use async_dropper::{AsyncDrop, AsyncDropper}; use async_trait::async_trait; use camino_tempfile::Utf8TempDir; use candid::Principal; -use notify::Watcher; +use notify::{EventHandler, Watcher}; use serde::Deserialize; use snafu::prelude::*; use std::{io::ErrorKind, process::Stdio, time::Duration}; use sysinfo::{Pid, ProcessesToUpdate, Signal, System}; -use tokio::{process::Child, select, time::Instant}; +use tokio::{process::Child, select, sync::mpsc::Sender, time::Instant}; use crate::{ network::{Port, config::ChildLocator}, @@ -216,48 +216,53 @@ pub fn wait_for_single_line_file( path: &Path, ) -> Result> + use<>, WaitForFileError> { let dir = path.parent().unwrap(); - let (tx, rx) = tokio::sync::oneshot::channel(); - let mut tx = Some(tx); - let mut watcher = notify::recommended_watcher({ - let path = path.to_path_buf(); - let dir = dir.to_path_buf(); - move |res: notify::Result| match res { - Ok(event) => { - if event.kind.is_modify() { - let content_res = crate::fs::read_to_string(&path); - match content_res { - Ok(content) => { - if content.ends_with('\n') - && let Some(tx) = tx.take() - { - let _ = tx.send(Ok(content)); - } - } - Err(e) if e.kind() == ErrorKind::NotFound => {} - Err(e) => { - if let Some(tx) = tx.take() { - let _ = tx.send(Err(e.into())); - } + // notify will get here faster + let (rec_tx, mut rec_rx) = tokio::sync::mpsc::channel(10); + let mut rec_watcher = + notify::recommended_watcher(WatchRecv(rec_tx)).context(WatchSnafu { path: &dir })?; + // poll is more reliable when dealing with vfs like 9p, notably in WSL2 + let (poll_tx, mut poll_rx) = tokio::sync::mpsc::channel(10); + let mut poll_watcher = notify::PollWatcher::new( + WatchRecv(poll_tx), + notify::Config::default() + .with_poll_interval(Duration::from_millis(100)) + .with_compare_contents(true), + ) + .context(WatchSnafu { path: &dir })?; + rec_watcher + .watch(dir.as_std_path(), notify::RecursiveMode::NonRecursive) + .context(WatchSnafu { path: &dir })?; + poll_watcher + .watch(dir.as_std_path(), notify::RecursiveMode::NonRecursive) + .context(WatchSnafu { path: &dir })?; + _ = poll_watcher.poll(); + let path = path.to_path_buf(); + let dir = dir.to_path_buf(); + Ok(async move { + let _rec_watcher = rec_watcher; + let _poll_watcher = poll_watcher; + loop { + let evt = select! { + rec = rec_rx.recv() => rec, + poll = poll_rx.recv() => poll, + }; + let Some(res) = evt else { + unreachable!("watcher dropped while waiting for file"); + }; + let event = res.context(ReadEventSnafu { path: &dir })?; + if event.kind.is_modify() || event.kind.is_create() { + match crate::fs::read_to_string(&path) { + Ok(content) => { + if content.ends_with('\n') { + return Ok(content); } } - } - } - Err(e) => { - if let Some(tx) = tx.take() { - let _ = tx.send(Err(e).context(ReadEventSnafu { path: &dir })); + Err(e) if e.kind() == ErrorKind::NotFound => {} + Err(e) => return Err(e.into()), } } } }) - .context(WatchSnafu { path: &dir })?; - watcher - .watch(dir.as_std_path(), notify::RecursiveMode::NonRecursive) - .context(WatchSnafu { path: &dir })?; - Ok(async { - let _watcher = watcher; - let res = rx.await; - res.unwrap() - }) } /// Call the function before initing the external process, then await the future after the init. @@ -300,3 +305,11 @@ pub struct LauncherStatus { pub root_key: String, pub default_effective_canister_id: Option, } + +struct WatchRecv(Sender>); + +impl EventHandler for WatchRecv { + fn handle_event(&mut self, event: notify::Result) { + let _ = self.0.blocking_send(event); + } +} diff --git a/crates/icp/src/network/managed/run.rs b/crates/icp/src/network/managed/run.rs index 3e06ceaa..dd3e2c0d 100644 --- a/crates/icp/src/network/managed/run.rs +++ b/crates/icp/src/network/managed/run.rs @@ -35,7 +35,7 @@ use uuid::Uuid; use crate::{ fs::{create_dir_all, lock::LockError, remove_dir_all}, network::{ - Gateway, Managed, ManagedMode, NetworkDirectory, Port, + Gateway, Managed, ManagedImageConfig, ManagedMode, NetworkDirectory, Port, config::{ChildLocator, NetworkDescriptorGatewayPort, NetworkDescriptorModel}, directory::{ClaimPortError, SaveNetworkDescriptorError, save_network_descriptors}, managed::{ @@ -120,18 +120,22 @@ async fn run_network_launcher( verbose: bool, ) -> Result<(), RunNetworkLauncherError> { let network_root = nd.root()?; + let port = if let ManagedMode::Launcher { + gateway: Gateway { + port: Port::Fixed(port), + .. + }, + } = &config.mode + { + Some(*port) + } else { + None + }; // hold port_claim until the end of this function - let (mut guard, port, _port_claim) = network_root + let (mut guard, instance, gateway, locator, _port_claim) = network_root .with_write(async |root| -> Result<_, RunNetworkLauncherError> { - let port_lock = if let ManagedMode::Launcher { - gateway: - Gateway { - port: Port::Fixed(port), - .. - }, - } = &config.mode - { - Some(nd.port(*port)?.into_write().await?) + let port_lock = if let Some(port) = port { + Some(nd.port(port)?.into_write().await?) } else { None }; @@ -147,15 +151,24 @@ async fn run_network_launcher( } create_dir_all(&root.state_dir()).context(CreateDirAllSnafu)?; - eprintln!("Starting network"); - let (guard, instance, gateway, locator) = match &config.mode { + match &config.mode { ManagedMode::Image(image_config) => { let (guard, instance, locator) = spawn_docker_launcher(image_config).await?; let gateway = NetworkDescriptorGatewayPort { port: instance.gateway_port, fixed: false, }; - (ShutdownGuard::Container(guard), instance, gateway, locator) + Ok((ShutdownGuard::Container(guard), instance, gateway, locator, port_claim)) + } + ManagedMode::Launcher { gateway } if cfg!(windows) /* todo machine setting for unix */ => { + let image_config = transform_native_launcher_to_container(gateway.clone()); + let (guard, instance, locator) = + spawn_docker_launcher(&image_config).await?; + let gateway = NetworkDescriptorGatewayPort { + port: instance.gateway_port, + fixed: false, + }; + Ok((ShutdownGuard::Container(guard), instance, gateway, locator, port_claim)) } ManagedMode::Launcher { gateway } => { if root.state_dir().exists() { @@ -175,26 +188,36 @@ async fn run_network_launcher( &root.state_dir(), ) .await?; - if background { - // background means we're using stdio files - otherwise the launcher already prints this - eprintln!("Network started on port {}", instance.gateway_port); - } let gateway = NetworkDescriptorGatewayPort { port: instance.gateway_port, fixed: matches!(gateway.port, Port::Fixed(_)), }; - (ShutdownGuard::Process(child), instance, gateway, locator) + Ok((ShutdownGuard::Process(child), instance, gateway, locator, port_claim)) } + } + }).await??; + + if background { + // background means we're using stdio files - otherwise the launcher already prints this + eprintln!("Network started on port {}", instance.gateway_port); + } + let candid_ui_canister_id = initialize_network( + &format!("http://localhost:{}", instance.gateway_port) + .parse() + .unwrap(), + &instance.root_key, + seed_accounts, + candid_ui_wasm, + ) + .await?; + + network_root + .with_write(async |root| -> Result<_, RunNetworkLauncherError> { + let port_lock = if let Some(port) = port { + Some(nd.port(port)?.into_write().await?) + } else { + None }; - let candid_ui_canister_id = initialize_network( - &format!("http://localhost:{}", instance.gateway_port) - .parse() - .unwrap(), - &instance.root_key, - seed_accounts, - candid_ui_wasm, - ) - .await?; let descriptor = NetworkDescriptorModel { v: "1".to_string(), id: Uuid::new_v4(), @@ -208,14 +231,9 @@ async fn run_network_launcher( pocketic_instance_id: instance.pocketic_instance_id, candid_ui_canister_id, }; - - save_network_descriptors( - root, - port_lock.as_ref().map(|lock| lock.as_ref()), - &descriptor, - ) - .await?; - Ok((guard, instance.gateway_port, port_claim)) + save_network_descriptors(root, port_lock.as_ref().map(|p| p.as_ref()), &descriptor) + .await?; + Ok(()) }) .await??; if background { @@ -228,11 +246,32 @@ async fn run_network_launcher( guard.async_drop().await; let _ = nd.cleanup_project_network_descriptor().await; - let _ = nd.cleanup_port_descriptor(Some(port)).await; + let _ = nd.cleanup_port_descriptor(port).await; } Ok(()) } +fn transform_native_launcher_to_container(gateway: Gateway) -> ManagedImageConfig { + let port = match gateway.port { + Port::Fixed(port) => port, + Port::Random => 0, + }; + ManagedImageConfig { + image: "ghcr.io/dfinity/icp-cli-network-launcher:latest".to_string(), + port_mapping: vec![format!("{port}:4943")], + rm_on_exit: true, + args: vec!["--ii".to_string()], + entrypoint: None, + environment: vec![], + volumes: vec![], + platform: None, + user: None, + shm_size: None, + status_dir: "/app/status".to_string(), + mounts: vec![], + } +} + enum ShutdownGuard { Container(AsyncDropper), Process(AsyncDropper), @@ -314,13 +353,13 @@ fn safe_eprintln(msg: &str) { async fn wait_for_shutdown(guard: &mut ShutdownGuard) -> ShutdownReason { match guard { ShutdownGuard::Container(_) => { - _ = ctrl_c().await; + stop_signal().await; safe_eprintln("Received Ctrl-C, shutting down PocketIC..."); ShutdownReason::CtrlC } ShutdownGuard::Process(child) => { select!( - _ = ctrl_c() => { + _ = stop_signal() => { safe_eprintln("Received Ctrl-C, shutting down PocketIC..."); ShutdownReason::CtrlC } @@ -333,6 +372,28 @@ async fn wait_for_shutdown(guard: &mut ShutdownGuard) -> ShutdownReason { } } +#[cfg(unix)] +async fn stop_signal() { + use tokio::signal::unix::{SignalKind, signal}; + let mut sigterm = signal(SignalKind::terminate()).unwrap(); + select! { + _ = ctrl_c() => {}, + _ = sigterm.recv() => {}, + } +} + +#[cfg(windows)] +async fn stop_signal() { + use tokio::signal::windows::{ctrl_break, ctrl_close}; + let mut ctrl_break = ctrl_break().unwrap(); + let mut ctrl_close = ctrl_close().unwrap(); + select! { + _ = ctrl_c() => {}, + _ = ctrl_break.recv() => {}, + _ = ctrl_close.recv() => {}, + } +} + /// Yields immediately if the child exits. pub async fn notice_child_exit(child: &mut Child) -> ChildExitError { loop { diff --git a/crates/icp/src/network/mod.rs b/crates/icp/src/network/mod.rs index a75ae613..0fc3f38e 100644 --- a/crates/icp/src/network/mod.rs +++ b/crates/icp/src/network/mod.rs @@ -84,26 +84,39 @@ pub enum ManagedMode { impl Default for ManagedMode { fn default() -> Self { + Self::default_for_port(0) + } +} + +impl ManagedMode { + pub fn default_for_port(port: u16) -> Self { ManagedMode::Launcher { - gateway: Gateway::default(), + gateway: Gateway { + host: default_host(), + port: if port == 0 { + Port::Random + } else { + Port::Fixed(port) + }, + }, } } } #[derive(Clone, Debug, Deserialize, PartialEq, JsonSchema, Serialize)] pub struct ManagedImageConfig { - image: String, - port_mapping: Vec, - rm_on_exit: bool, - args: Vec, - entrypoint: Option>, - environment: Vec, - volumes: Vec, - platform: Option, - user: Option, - shm_size: Option, - status_dir: String, - mounts: Vec, + pub image: String, + pub port_mapping: Vec, + pub rm_on_exit: bool, + pub args: Vec, + pub entrypoint: Option>, + pub environment: Vec, + pub volumes: Vec, + pub platform: Option, + pub user: Option, + pub shm_size: Option, + pub status_dir: String, + pub mounts: Vec, } #[derive(Clone, Debug, Deserialize, PartialEq, JsonSchema, Serialize)] diff --git a/dist-workspace.toml b/dist-workspace.toml index fb7980d4..4eed3344 100644 --- a/dist-workspace.toml +++ b/dist-workspace.toml @@ -8,9 +8,15 @@ cargo-dist-version = "0.30.3" # CI backends to support ci = "github" # The installers to generate for each app -installers = ["shell"] +installers = ["shell", "powershell"] # Target platforms to build apps for (Rust target-triple syntax) -targets = ["aarch64-apple-darwin", "aarch64-unknown-linux-gnu", "x86_64-apple-darwin", "x86_64-unknown-linux-gnu"] +targets = [ + "aarch64-apple-darwin", + "aarch64-unknown-linux-gnu", + "x86_64-apple-darwin", + "x86_64-unknown-linux-gnu", + "x86_64-pc-windows-msvc", +] # Path that installers should place binaries in install-path = "CARGO_HOME" # Whether to install an updater program diff --git a/docs/containers.md b/docs/containers.md index 523b0c02..a3077dd6 100644 --- a/docs/containers.md +++ b/docs/containers.md @@ -61,3 +61,7 @@ The gateway port of the network must be bound to a host port (permitted to be 0) - `user`: string, the user to run the container as, in `user:group` format - `shm-size`: uint, size of `/dev/shm` in bytes. - `status-dir`: string, default `/app/status`, the status directory mentioned above. + +## Windows with WSL2 `dockerd` + +`icp-cli` will automatically integrate with Docker Desktop's support for WSL2; if unconfigured, it should Just Work. However, if you want to use a manually instantiated `dockerd` in a WSL2 instance serving local TCP, you can set `ICP_CLI_DOCKER_WSL2_DISTRO=` (as well as `DOCKER_HOST=tcp://:`). Remote containers are not supported since `icp-cli` makes use of bind mounts; for this purpose you can use 'connected' networks instead of 'managed'. diff --git a/docs/guides/installation.md b/docs/guides/installation.md index cd469005..f08e3ea6 100644 --- a/docs/guides/installation.md +++ b/docs/guides/installation.md @@ -8,20 +8,74 @@ Install icp-cli on macOS, Linux, or Windows (WSL). brew install dfinity/tap/icp-cli ``` +**Bash/Curl** + +```bash +# install icp-cli +curl --proto '=https' --tlsv1.2 -LsSf https://github.com/dfinity/icp-cli/releases/download/v0.1.0-beta.3/icp-cli-installer.sh | sh + +# install ic-wasm which is a dependency for many recipes +curl --proto '=https' --tlsv1.2 -LsSf https://github.com/dfinity/ic-wasm/releases/download/0.9.10/ic-wasm-installer.sh | sh +``` + +**PowerShell (Windows)** + +```ps1 +# install icp-cli +powershell -ExecutionPolicy Bypass -c "irm https://github.com/dfinity/icp-cli/releases/download/v0.30.3/cargo-dist-installer.ps1 | iex" + +# install ic-wasm which is a dependency for many recipes +powershell -ExecutionPolicy Bypass -c "irm https://github.com/dfinitiy/ic-wasm/releases/download/v0.9.11/ic-wasm-installer.ps1 | iex" +``` + +**From source:** + +Cargo is required as a pre-requisite. + +```bash +git clone https://github.com/dfinity/icp-cli.git +cd icp-cli && cargo build --release +export PATH=$(pwd)/target/release:$PATH +``` + +Verify installation: + +```bash +icp --version +``` + +## Installation Methods + +### Homebrew (macOS) + +The recommended installation method for macOS: + +```bash +brew install dfinity/tap/icp-cli +``` + To update later: ```bash brew upgrade dfinity/tap/icp-cli ``` -## Linux / Windows (WSL) +## Linux / WSL ```bash -curl --proto '=https' --tlsv1.2 -LsSf https://github.com/dfinity/icp-cli/releases/latest/download/icp-cli-installer.sh | sh +curl --proto '=https' --tlsv1.2 -LsSf https://github.com/dfinity/icp-cli/releases/download/v0.1.0-beta.3/icp-cli-installer.sh | sh ``` The installer adds icp-cli to your PATH automatically. Restart your shell or run the `source` command shown by the installer. +## Windows + +```ps1 +powershell -ExecutionPolicy Bypass -c "irm https://github.com/dfinity/icp-cli/releases/download/v0.1.0-beta.3/icp-cli-installer.ps1 | iex" +``` + +The installer adds icp-cli to your PATH automatically. Restart your shell (and if it's inside another program, e.g. the VS Code embedded shell, restart that program too). + ## Verify Installation ```bash @@ -45,6 +99,14 @@ npm install -g ic-mops mops toolchain init ``` +## Other dependencies + +### Docker/WSL2 (Windows) + +On Windows, the local network will be run in a Docker container inside WSL2. It is recommended to install [Docker Desktop](https://www.docker.com/products/docker-desktop/) with WSL2 integration, but a manually run `dockerd` instance is [also supported](docs/containers.md). + +Docker is also a dependency for projects that manually configure their network to be container-based. + ## Troubleshooting **"command not found: icp" (after curl install)**