From dbe77b03add2979660d7290050f81984eddc56f9 Mon Sep 17 00:00:00 2001 From: Adam Spofford Date: Mon, 8 Dec 2025 09:20:19 -0800 Subject: [PATCH 01/59] support docker images --- Cargo.lock | 245 ++++++++++++++++++++- Cargo.toml | 2 + crates/icp/Cargo.toml | 2 + crates/icp/src/context/tests.rs | 37 ++-- crates/icp/src/lib.rs | 18 +- crates/icp/src/manifest/network.rs | 46 +++- crates/icp/src/manifest/project.rs | 6 +- crates/icp/src/network/managed/launcher.rs | 192 +++++++++++++++- crates/icp/src/network/managed/run.rs | 162 +++++++++----- crates/icp/src/network/mod.rs | 55 ++++- crates/icp/src/project.rs | 10 +- 11 files changed, 662 insertions(+), 113 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index f85dd7ff..6c6618b5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -117,6 +117,15 @@ version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + [[package]] name = "anstream" version = "0.6.20" @@ -259,6 +268,45 @@ dependencies = [ "futures-core", ] +[[package]] +name = "async-dropper" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d901072ae4dcdca2201b98beb02d31fb4b6b2472fbd0e870b12ec15b8b35b2d2" +dependencies = [ + "async-dropper-derive", + "async-dropper-simple", + "async-trait", + "futures", + "tokio", +] + +[[package]] +name = "async-dropper-derive" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a35cf17a37761f1c88b8e770b5956820fe84c12854165b6f930c604ea186e47e" +dependencies = [ + "async-trait", + "proc-macro2", + "quote", + "syn 2.0.106", + "tokio", +] + +[[package]] +name = "async-dropper-simple" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7c4748dfe8cd3d625ec68fc424fa80c134319881185866f9e173af9e5d8add8" +dependencies = [ + "async-scoped", + "async-trait", + "futures", + "rustc_version", + "tokio", +] + [[package]] name = "async-lock" version = "3.4.1" @@ -270,6 +318,17 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "async-scoped" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4042078ea593edffc452eef14e99fdb2b120caa4ad9618bcdeabc4a023b98740" +dependencies = [ + "futures", + "pin-project", + "tokio", +] + [[package]] name = "async-trait" version = "0.1.89" @@ -547,6 +606,51 @@ dependencies = [ "generic-array", ] +[[package]] +name = "bollard" +version = "0.19.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87a52479c9237eb04047ddb94788c41ca0d26eaff8b697ecfbb4c32f7fdc3b1b" +dependencies = [ + "base64 0.22.1", + "bollard-stubs", + "bytes", + "futures-core", + "futures-util", + "hex", + "http", + "http-body-util", + "hyper", + "hyper-named-pipe", + "hyper-util", + "hyperlocal", + "log", + "pin-project-lite", + "serde", + "serde_derive", + "serde_json", + "serde_repr", + "serde_urlencoded", + "thiserror 2.0.17", + "tokio", + "tokio-util", + "tower-service", + "url", + "winapi", +] + +[[package]] +name = "bollard-stubs" +version = "1.49.1-rc.28.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5731fe885755e92beff1950774068e0cae67ea6ec7587381536fca84f1779623" +dependencies = [ + "serde", + "serde_json", + "serde_repr", + "serde_with", +] + [[package]] name = "borrow-or-share" version = "0.2.2" @@ -799,7 +903,7 @@ dependencies = [ "heck", "home", "ignore", - "indexmap", + "indexmap 2.11.4", "indicatif", "liquid", "liquid-core", @@ -870,6 +974,18 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +[[package]] +name = "chrono" +version = "0.4.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "145052bdd345b87320e369255277e3fb5152762ad123a901ef5c262dd38fe8d2" +dependencies = [ + "iana-time-zone", + "num-traits", + "serde", + "windows-link 0.2.0", +] + [[package]] name = "cipher" version = "0.4.4" @@ -2404,7 +2520,7 @@ dependencies = [ "futures-core", "futures-sink", "http", - "indexmap", + "indexmap 2.11.4", "slab", "tokio", "tokio-util", @@ -2648,6 +2764,21 @@ dependencies = [ "want", ] +[[package]] +name = "hyper-named-pipe" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73b7d8abf35697b81a825e386fc151e0d503e8cb5fcb93cc8669c376dfd6f278" +dependencies = [ + "hex", + "hyper", + "hyper-util", + "pin-project-lite", + "tokio", + "tower-service", + "winapi", +] + [[package]] name = "hyper-rustls" version = "0.27.7" @@ -2689,6 +2820,45 @@ dependencies = [ "tracing", ] +[[package]] +name = "hyperlocal" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "986c5ce3b994526b3cd75578e62554abd09f0899d6206de48b3e96ab34ccc8c7" +dependencies = [ + "hex", + "http-body-util", + "hyper", + "hyper-util", + "pin-project-lite", + "tokio", + "tower-service", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.64" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33e57f83510bb73707521ebaffa789ec8caf86f9657cad665b092b581d40e9fb" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + [[package]] name = "ic-agent" version = "0.44.1" @@ -3003,8 +3173,10 @@ dependencies = [ name = "icp" version = "0.1.0" dependencies = [ + "async-dropper", "async-trait", "bip32 0.5.3", + "bollard", "camino", "camino-tempfile", "candid", @@ -3289,6 +3461,17 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", + "serde", +] + [[package]] name = "indexmap" version = "2.11.4" @@ -4505,7 +4688,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4c5cc86750666a3ed20bdaf5ca2a0344f9c67674cae0515bec2da16fbaa47db" dependencies = [ "fixedbitset", - "indexmap", + "indexmap 2.11.4", ] [[package]] @@ -4566,6 +4749,26 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5be167a7af36ee22fe3115051bc51f6e6c7054c9348e28deb4f49bd6f705a315" +[[package]] +name = "pin-project" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677f1add503faace112b9f1373e43e9e054bfdd22ff1a63c1bc485eaec6a6a8a" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e918e4ff8c4549eb882f14b3a4bc8c8bc93de829416eacf579f1207a8fbf861" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + [[package]] name = "pin-project-lite" version = "0.2.16" @@ -5371,6 +5574,18 @@ dependencies = [ "serde_json", ] +[[package]] +name = "schemars" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + [[package]] name = "schemars" version = "1.1.0" @@ -5658,13 +5873,31 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_with" +version = "3.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fa237f2807440d238e0364a218270b98f767a00d3dada77b1c53ae88940e2e7" +dependencies = [ + "base64 0.22.1", + "chrono", + "hex", + "indexmap 1.9.3", + "indexmap 2.11.4", + "schemars 0.9.0", + "schemars 1.1.0", + "serde_core", + "serde_json", + "time", +] + [[package]] name = "serde_yaml" version = "0.9.34+deprecated" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" dependencies = [ - "indexmap", + "indexmap 2.11.4", "itoa", "ryu", "serde", @@ -6383,7 +6616,7 @@ version = "0.9.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0dc8b1fb61449e27716ec0e1bdf0f6b8f3e8f6b05391e8497b8b6d7804ea6d8" dependencies = [ - "indexmap", + "indexmap 2.11.4", "serde_core", "serde_spanned", "toml_datetime", @@ -6407,7 +6640,7 @@ version = "0.23.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f3effe7c0e86fdff4f69cdd2ccc1b96f933e24811c5441d44904e8683e27184b" dependencies = [ - "indexmap", + "indexmap 2.11.4", "toml_datetime", "toml_parser", "winnow", diff --git a/Cargo.toml b/Cargo.toml index 856b1e1e..629a2466 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,9 +14,11 @@ license = "Apache-2.0" [workspace.dependencies] anyhow = "1.0.100" +async-dropper = { version = "0.3.0", features = ["tokio", "simple"] } async-trait = "0.1.88" bigdecimal = "0.4.8" bip32 = "0.5.0" +bollard = "0.19.4" byte-unit = "5.1.6" camino = { version = "1.1.9", features = ["serde1"] } camino-tempfile = "1" diff --git a/crates/icp/Cargo.toml b/crates/icp/Cargo.toml index 8c9149df..177bcea8 100644 --- a/crates/icp/Cargo.toml +++ b/crates/icp/Cargo.toml @@ -5,8 +5,10 @@ edition = { workspace = true } license = { workspace = true } [dependencies] +async-dropper = { workspace = true } async-trait = { workspace = true } bip32 = { workspace = true } +bollard = { workspace = true } camino = { workspace = true } camino-tempfile = { workspace = true } candid = { workspace = true } diff --git a/crates/icp/src/context/tests.rs b/crates/icp/src/context/tests.rs index a1c9f6fd..747f055d 100644 --- a/crates/icp/src/context/tests.rs +++ b/crates/icp/src/context/tests.rs @@ -2,7 +2,10 @@ use super::*; use crate::{ Environment, MockProjectLoader, Network, Project, identity::MockIdentityLoader, - network::{Configuration, Gateway, Managed, MockNetworkAccessor, Port, access::NetworkAccess}, + network::{ + Configuration, Gateway, Managed, ManagedMode, MockNetworkAccessor, Port, + access::NetworkAccess, + }, project::{DEFAULT_LOCAL_NETWORK_NAME, DEFAULT_LOCAL_NETWORK_URL}, store_id::{Access as IdAccess, mock::MockInMemoryIdStore}, }; @@ -594,9 +597,11 @@ async fn test_get_agent_defaults_inside_project_with_default_local() { name: DEFAULT_LOCAL_NETWORK_NAME.to_string(), configuration: Configuration::Managed { managed: Managed { - gateway: Gateway { - host: "localhost".to_string(), - port: Port::Fixed(8000), + mode: ManagedMode::Launcher { + gateway: Gateway { + host: "localhost".to_string(), + port: Port::Fixed(8000), + }, }, }, }, @@ -656,9 +661,11 @@ async fn test_get_agent_defaults_with_overridden_local_network() { name: DEFAULT_LOCAL_NETWORK_NAME.to_string(), configuration: Configuration::Managed { managed: Managed { - gateway: Gateway { - host: "localhost".to_string(), - port: Port::Fixed(9000), + mode: ManagedMode::Launcher { + gateway: Gateway { + host: "localhost".to_string(), + port: Port::Fixed(9000), + }, }, }, }, @@ -720,9 +727,11 @@ async fn test_get_agent_defaults_with_overridden_local_environment() { name: DEFAULT_LOCAL_NETWORK_NAME.to_string(), configuration: Configuration::Managed { managed: Managed { - gateway: Gateway { - host: "localhost".to_string(), - port: Port::Fixed(8000), + mode: ManagedMode::Launcher { + gateway: Gateway { + host: "localhost".to_string(), + port: Port::Fixed(8000), + }, }, }, }, @@ -732,9 +741,11 @@ async fn test_get_agent_defaults_with_overridden_local_environment() { name: "custom".to_string(), configuration: Configuration::Managed { managed: Managed { - gateway: Gateway { - host: "localhost".to_string(), - port: Port::Fixed(7000), + mode: ManagedMode::Launcher { + gateway: Gateway { + host: "localhost".to_string(), + port: Port::Fixed(7000), + }, }, }, }, diff --git a/crates/icp/src/lib.rs b/crates/icp/src/lib.rs index 8508b46c..ff6ebdf9 100644 --- a/crates/icp/src/lib.rs +++ b/crates/icp/src/lib.rs @@ -291,7 +291,7 @@ impl MockProjectLoader { use crate::{ manifest::adapter::prebuilt::{Adapter as PrebuiltAdapter, LocalSource, SourceField}, manifest::canister::{BuildStep, BuildSteps, SyncSteps}, - network::{Configuration, Connected, Gateway, Managed, Port}, + network::{Configuration, Connected, Gateway, Managed, ManagedMode, Port}, }; // Create canisters @@ -342,9 +342,11 @@ impl MockProjectLoader { name: "local".to_string(), configuration: Configuration::Managed { managed: Managed { - gateway: Gateway { - host: "localhost".to_string(), - port: Port::Fixed(8000), + mode: ManagedMode::Launcher { + gateway: Gateway { + host: "localhost".to_string(), + port: Port::Fixed(8000), + }, }, }, }, @@ -354,9 +356,11 @@ impl MockProjectLoader { name: "staging".to_string(), configuration: Configuration::Managed { managed: Managed { - gateway: Gateway { - host: "localhost".to_string(), - port: Port::Fixed(8001), + mode: ManagedMode::Launcher { + gateway: Gateway { + host: "localhost".to_string(), + port: Port::Fixed(8001), + }, }, }, }, diff --git a/crates/icp/src/manifest/network.rs b/crates/icp/src/manifest/network.rs index e7efbb48..467d7d3b 100644 --- a/crates/icp/src/manifest/network.rs +++ b/crates/icp/src/manifest/network.rs @@ -19,7 +19,27 @@ pub enum Mode { #[derive(Clone, Debug, Default, Deserialize, PartialEq, JsonSchema)] pub struct Managed { - pub gateway: Option, + #[serde(flatten)] + pub mode: ManagedMode, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, JsonSchema)] +#[serde(untagged, rename_all_fields = "kebab-case")] +pub enum ManagedMode { + Image { + image: String, + #[serde(default)] + port_mapping: Vec, + }, + Launcher { + gateway: Option, + }, +} + +impl Default for ManagedMode { + fn default() -> Self { + ManagedMode::Launcher { gateway: None } + } } #[derive(Clone, Debug, Deserialize, PartialEq, JsonSchema)] @@ -165,7 +185,9 @@ mod tests { "#}), NetworkManifest { name: "my-network".to_string(), - configuration: Mode::Managed(Managed { gateway: None }) + configuration: Mode::Managed(Managed { + mode: ManagedMode::Launcher { gateway: None } + }) }, ); } @@ -182,10 +204,12 @@ mod tests { NetworkManifest { name: "my-network".to_string(), configuration: Mode::Managed(Managed { - gateway: Some(Gateway { - host: Some("localhost".to_string()), - port: None, - }) + mode: ManagedMode::Launcher { + gateway: Some(Gateway { + host: Some("localhost".to_string()), + port: None, + }) + } }) }, ); @@ -204,10 +228,12 @@ mod tests { NetworkManifest { name: "my-network".to_string(), configuration: Mode::Managed(Managed { - gateway: Some(Gateway { - host: Some("localhost".to_string()), - port: Some(8000) - }) + mode: ManagedMode::Launcher { + gateway: Some(Gateway { + host: Some("localhost".to_string()), + port: Some(8000) + }) + } }) }, ); diff --git a/crates/icp/src/manifest/project.rs b/crates/icp/src/manifest/project.rs index c4dee1bf..9af25f3c 100644 --- a/crates/icp/src/manifest/project.rs +++ b/crates/icp/src/manifest/project.rs @@ -33,7 +33,7 @@ mod tests { adapter::script, canister::{BuildStep, BuildSteps, Instructions}, environment::CanisterSelection, - network::{Managed, Mode}, + network::{Managed, ManagedMode, Mode}, }, }; @@ -256,7 +256,9 @@ mod tests { canisters: vec![], networks: vec![Item::Manifest(NetworkManifest { name: "my-network".to_string(), - configuration: Mode::Managed(Managed { gateway: None }) + configuration: Mode::Managed(Managed { + mode: ManagedMode::Launcher { gateway: None } + }), })], environments: vec![], }, diff --git a/crates/icp/src/network/managed/launcher.rs b/crates/icp/src/network/managed/launcher.rs index 09196760..4fef37cf 100644 --- a/crates/icp/src/network/managed/launcher.rs +++ b/crates/icp/src/network/managed/launcher.rs @@ -1,10 +1,22 @@ +use async_dropper::{AsyncDrop, AsyncDropper}; +use async_trait::async_trait; +use bollard::{ + Docker, + query_parameters::{ + CreateContainerOptions, InspectContainerOptions, RemoveContainerOptions, + StartContainerOptions, StopContainerOptions, WaitContainerOptions, + }, + secret::{ContainerCreateBody, HostConfig, Mount, MountTypeEnum, PortBinding}, +}; use camino_tempfile::Utf8TempDir; use candid::Principal; +use futures::TryStreamExt; use notify::Watcher; use serde::Deserialize; use snafu::prelude::*; -use std::{io::ErrorKind, process::Stdio}; -use tokio::process::Child; +use std::{collections::HashMap, io::ErrorKind, process::Stdio}; +use sysinfo::{Pid, ProcessesToUpdate, Signal, System}; +use tokio::{process::Child, select}; use crate::{network::Port, prelude::*}; @@ -42,7 +54,7 @@ pub async fn spawn_network_launcher( background: bool, port: &Port, state_dir: &Path, -) -> Result<(Child, NetworkInstance), SpawnNetworkLauncherError> { +) -> Result<(ChildSignalOnDrop, NetworkInstance), SpawnNetworkLauncherError> { let mut cmd = tokio::process::Command::new(network_launcher_path); cmd.args([ "--interface-version", @@ -74,6 +86,7 @@ pub async fn spawn_network_launcher( let child = cmd.spawn().context(SpawnLauncherSnafu { network_launcher_path, })?; + let child = ChildSignalOnDrop { child }; let launcher_status = watcher.await.context(WatchForStatusFileSnafu)?; Ok(( child, @@ -86,6 +99,179 @@ pub async fn spawn_network_launcher( )) } +pub fn send_sigint(pid: Pid) { + let mut system = System::new(); + system.refresh_processes(ProcessesToUpdate::Some(&[pid]), true); + if let Some(process) = system.process(pid) { + process.kill_with(Signal::Interrupt); + } +} + +pub struct ChildSignalOnDrop { + pub child: Child, +} + +impl ChildSignalOnDrop { + pub fn signal(&self) { + if let Some(id) = self.child.id() { + send_sigint((id as usize).into()); + } + } +} + +impl Drop for ChildSignalOnDrop { + fn drop(&mut self) { + self.signal(); + } +} + +pub async fn spawn_docker_launcher( + image: &str, + port_mappings: &[String], +) -> (AsyncDropper, NetworkInstance) { + let status_dir = Utf8TempDir::new().unwrap(); + #[cfg(unix)] + let docker = Docker::connect_with_socket_defaults() + .expect("failed to connect to docker socket (is it running?)"); + let portmap = port_mappings + .iter() + .map(|mapping| { + let (host_port, container_port) = mapping + .split_once(':') + .expect("invalid port mapping, must be in format :"); + ( + format!("{}/tcp", container_port), + Some(vec![PortBinding { + host_ip: None, + host_port: Some(host_port.to_string()), + }]), + ) + }) + .collect::>(); + let container_resp = docker + .create_container( + None::, + ContainerCreateBody { + image: Some(image.to_string()), + attach_stdin: Some(false), + attach_stdout: Some(true), + attach_stderr: Some(true), + host_config: Some(HostConfig { + port_bindings: Some(portmap), + mounts: Some(vec![Mount { + target: Some("/app/status".to_string()), + source: Some(status_dir.path().to_string()), + typ: Some(MountTypeEnum::BIND), + read_only: Some(false), + ..<_>::default() + }]), + ..<_>::default() + }), + ..<_>::default() + }, + ) + .await + .expect("failed to create docker container"); + let container_id = container_resp.id; + let guard = AsyncDropper::new(DockerDropGuard { + container_id: Some(container_id), + docker: Some(docker), + }); + let container_id = guard.container_id.as_ref().unwrap(); + let docker = guard.docker.as_ref().unwrap(); + let watcher = wait_for_single_line_file(&status_dir.path().join("status.json")).unwrap(); + docker + .start_container(container_id, None::) + .await + .expect("failed to start docker container"); + let mut wait_container = docker.wait_container(container_id, None::); + let status_content = select! { + content = watcher => content.unwrap(), + res = wait_container.try_next() => { + let exit = res.unwrap(); + if let Some(exit) = exit { + panic!("Docker container exited with code {} before writing status file.", exit.status_code); + } else { + panic!("Docker container exited before writing status file."); + } + }, + }; + let launcher_status: LauncherStatus = + serde_json::from_str(&status_content).expect("failed to parse launcher status file"); + assert_eq!( + launcher_status.v, "1", + "unexpected Docker launcher status version" + ); + let container_info = docker + .inspect_container(container_id, None::) + .await + .expect("failed to inspect docker container"); + let container_config_port = launcher_status.config_port; + let container_gateway_port = launcher_status.gateway_port; + let port_bindings = container_info + .network_settings + .expect("missing network settings in docker container") + .ports + .expect("missing port mappings in docker container"); + let host_config_port = container_config_port.map(|container_config_port| { + port_bindings + .get(&format!("{container_config_port}/tcp")) + .expect("missing PIC config port in docker container") + .as_ref() + .expect("missing host port binding for PIC config port in docker container") + .first() + .expect("missing host port binding for PIC config port in docker container") + .host_port + .as_ref() + .expect("missing host port for PIC config port in docker container") + .parse::() + .expect("invalid host port for PIC config port in docker container") + }); + let host_gateway_port = port_bindings + .get(&format!("{container_gateway_port}/tcp")) + .expect("missing PIC gateway port in docker container") + .as_ref() + .expect("missing host port binding for PIC gateway port in docker container") + .first() + .expect("missing host port binding for PIC gateway port in docker container") + .host_port + .as_ref() + .expect("missing host port for PIC gateway port in docker container") + .parse::() + .expect("invalid host port for PIC gateway port in docker container"); + ( + guard, + NetworkInstance { + gateway_port: host_gateway_port, + pocketic_config_port: host_config_port, + pocketic_instance_id: launcher_status.instance_id, + root_key: hex::decode(&launcher_status.root_key) + .expect("invalid root key in launcher status"), + }, + ) +} + +#[derive(Default)] +pub struct DockerDropGuard { + docker: Option, + container_id: Option, +} + +#[async_trait] +impl AsyncDrop for DockerDropGuard { + async fn async_drop(&mut self) { + if let Some(docker) = &self.docker.take() { + let container_id = self.container_id.take().unwrap(); + let _ = docker + .stop_container(&container_id, None::) + .await; + let _ = docker + .remove_container(&container_id, None::) + .await; + } + } +} + #[derive(Debug, Snafu)] pub enum WaitForFileError { #[snafu(display("failed to watch file changes at path {path}"))] diff --git a/crates/icp/src/network/managed/run.rs b/crates/icp/src/network/managed/run.rs index 232fb731..b14b3154 100644 --- a/crates/icp/src/network/managed/run.rs +++ b/crates/icp/src/network/managed/run.rs @@ -1,3 +1,4 @@ +use async_dropper::{AsyncDrop, AsyncDropper}; use candid::{Decode, Encode, Nat, Principal}; use futures::future::{join, join_all}; use ic_agent::{ @@ -22,7 +23,7 @@ use k256::SecretKey; use rand::{RngCore, rng}; use snafu::prelude::*; use std::{env::var, io::Write, process::ExitStatus, time::Duration}; -use sysinfo::{Pid, ProcessesToUpdate, Signal, System}; +use sysinfo::Pid; use tokio::{process::Child, select, signal::ctrl_c, time::sleep}; use url::Url; use uuid::Uuid; @@ -30,11 +31,13 @@ use uuid::Uuid; use crate::{ fs::{create_dir_all, lock::LockError, remove_dir_all}, network::{ - Managed, NetworkDirectory, Port, - RunNetworkError::NoLauncherPath, + Gateway, Managed, ManagedMode, NetworkDirectory, Port, config::{NetworkDescriptorGatewayPort, NetworkDescriptorModel}, directory::{ClaimPortError, SaveNetworkDescriptorError, save_network_descriptors}, - managed::launcher::{CreateHttpGatewayError, spawn_network_launcher}, + managed::launcher::{ + ChildSignalOnDrop, CreateHttpGatewayError, DockerDropGuard, spawn_docker_launcher, + spawn_network_launcher, + }, }, prelude::*, }; @@ -46,16 +49,12 @@ pub async fn run_network( seed_accounts: impl Iterator + Clone, background: bool, ) -> Result<(), RunNetworkError> { - let network_launcher_path = PathBuf::from( - var("ICP_CLI_NETWORK_LAUNCHER_PATH") - .ok() - .ok_or(NoLauncherPath)?, - ); - nd.ensure_exists()?; + let network_launcher_path = var("ICP_CLI_NETWORK_LAUNCHER_PATH").ok().map(PathBuf::from); + run_network_launcher( - &network_launcher_path, + network_launcher_path.as_deref(), config, &nd, project_root, @@ -71,9 +70,6 @@ pub enum RunNetworkError { #[snafu(transparent)] CreateDirFailed { source: crate::fs::IoError }, - #[snafu(display("ICP_CLI_NETWORK_LAUNCHER_PATH environment variable is not set"))] - NoLauncherPath, - #[snafu(transparent)] LockFileError { source: LockError }, @@ -82,7 +78,7 @@ pub enum RunNetworkError { } async fn run_network_launcher( - network_launcher_path: &Path, + network_launcher_path: Option<&Path>, config: &Managed, nd: &NetworkDirectory, project_root: &Path, @@ -91,9 +87,16 @@ async fn run_network_launcher( ) -> Result<(), RunNetworkLauncherError> { let network_root = nd.root()?; // hold port_claim until the end of this function - let (mut child, port, _port_claim) = network_root + let (mut guard, port, _port_claim) = network_root .with_write(async |root| -> Result<_, RunNetworkLauncherError> { - let port_lock = if let Port::Fixed(port) = &config.gateway.port { + let port_lock = if let ManagedMode::Launcher { + gateway: + Gateway { + port: Port::Fixed(port), + .. + }, + } = &config.mode + { Some(nd.port(*port)?.into_write().await?) } else { None @@ -102,7 +105,6 @@ async fn run_network_launcher( .as_ref() .map(|lock| lock.claim_port()) .transpose()?; - eprintln!("Network launcher path: {network_launcher_path}"); create_dir_all(&root.launcher_dir()).context(CreateDirAllSnafu)?; @@ -111,20 +113,46 @@ async fn run_network_launcher( } create_dir_all(&root.state_dir()).context(CreateDirAllSnafu)?; - let (child, instance) = spawn_network_launcher( - network_launcher_path, - &root.network_stdout_file(), - &root.network_stderr_file(), - background, - &config.gateway.port, - &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 (guard, instance, gateway) = match &config.mode { + ManagedMode::Image { + image, + port_mapping, + } => { + let (guard, instance) = spawn_docker_launcher(image, port_mapping).await; + let gateway = NetworkDescriptorGatewayPort { + port: instance.gateway_port, + fixed: false, + }; + (ShutdownGuard::Container(guard), instance, gateway) + } + ManagedMode::Launcher { gateway } => { + if root.state_dir().exists() { + remove_dir_all(&root.state_dir()).context(RemoveDirAllSnafu)?; + } + create_dir_all(&root.state_dir()).context(CreateDirAllSnafu)?; + let network_launcher_path = + network_launcher_path.context(NoNetworkLauncherPathSnafu)?; + eprintln!("Network launcher path: {network_launcher_path}"); + let (child, instance) = spawn_network_launcher( + network_launcher_path, + &root.network_stdout_file(), + &root.network_stderr_file(), + background, + &gateway.port, + &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) + } + }; seed_instance( &format!("http://localhost:{}", instance.gateway_port) .parse() @@ -133,17 +161,16 @@ async fn run_network_launcher( seed_accounts, ) .await?; - let gateway = NetworkDescriptorGatewayPort { - port: instance.gateway_port, - fixed: matches!(config.gateway.port, Port::Fixed(_)), - }; let descriptor = NetworkDescriptorModel { id: Uuid::new_v4(), project_dir: project_root.to_path_buf(), network: nd.network_name.to_owned(), network_dir: root.root_dir().to_path_buf(), gateway, - pid: Some(child.id().unwrap()), + pid: match &guard { + ShutdownGuard::Container(_) => None, + ShutdownGuard::Process(child) => Some(child.child.id().unwrap()), + }, root_key: instance.root_key, pocketic_config_port: instance.pocketic_config_port, pocketic_instance_id: instance.pocketic_instance_id, @@ -155,22 +182,20 @@ async fn run_network_launcher( &descriptor, ) .await?; - Ok((child, instance.gateway_port, port_claim)) + Ok((guard, instance.gateway_port, port_claim)) }) .await??; - if background { - // Save the PID of the main launcher process + if background && let ShutdownGuard::Process(child) = &guard { + // Save the PID of the main `pocket-ic` process // This is used by the `icp network stop` command to find and kill the process. - nd.save_background_network_runner_pid(Pid::from(child.id().unwrap() as usize)) + nd.save_background_network_runner_pid(Pid::from(child.child.id().unwrap() as usize)) .await?; eprintln!("To stop the network, run `icp network stop`"); } else { eprintln!("Press Ctrl-C to exit."); - let _ = wait_for_shutdown(&mut child).await; - let pid = child.id().unwrap() as usize; - send_sigint(pid.into()); - let _ = child.wait().await; + let _ = wait_for_shutdown(&mut guard).await; + guard.async_drop().await; let _ = nd.cleanup_project_network_descriptor().await; let _ = nd.cleanup_port_descriptor(Some(port)).await; @@ -178,16 +203,28 @@ async fn run_network_launcher( Ok(()) } -fn send_sigint(pid: Pid) { - let mut system = System::new(); - system.refresh_processes(ProcessesToUpdate::Some(&[pid]), true); - if let Some(process) = system.process(pid) { - process.kill_with(Signal::Interrupt); +enum ShutdownGuard { + Container(AsyncDropper), + Process(ChildSignalOnDrop), +} + +impl ShutdownGuard { + async fn async_drop(self) { + match self { + ShutdownGuard::Container(mut guard) => guard.async_drop().await, + ShutdownGuard::Process(mut guard) => { + guard.signal(); + guard.child.wait().await.unwrap(); + } + } } } #[derive(Debug, Snafu)] pub enum RunNetworkLauncherError { + #[snafu(display("ICP_CLI_NETWORK_LAUNCHER_PATH environment variable is not set"))] + NoNetworkLauncherPath, + #[snafu(display("failed to create dir"))] CreateDirAll { source: crate::fs::IoError }, @@ -236,17 +273,26 @@ fn safe_eprintln(msg: &str) { let _ = std::io::stderr().write_all(b"\n"); } -async fn wait_for_shutdown(child: &mut Child) -> ShutdownReason { - select!( - _ = ctrl_c() => { - safe_eprintln("Received Ctrl-C, shutting down network..."); +async fn wait_for_shutdown(guard: &mut ShutdownGuard) -> ShutdownReason { + match guard { + ShutdownGuard::Container(_) => { + _ = ctrl_c().await; + safe_eprintln("Received Ctrl-C, shutting down PocketIC..."); ShutdownReason::CtrlC } - res = notice_child_exit(child) => { - safe_eprintln(&format!("Network exited with status: {:?}", res.status)); - ShutdownReason::ChildExited + ShutdownGuard::Process(child) => { + select!( + _ = ctrl_c() => { + safe_eprintln("Received Ctrl-C, shutting down PocketIC..."); + ShutdownReason::CtrlC + } + res = notice_child_exit(&mut child.child) => { + safe_eprintln(&format!("PocketIC exited with status: {:?}", res.status)); + ShutdownReason::ChildExited + } + ) } - ) + } } /// Yields immediately if the child exits. diff --git a/crates/icp/src/network/mod.rs b/crates/icp/src/network/mod.rs index 013d8985..f1003cdd 100644 --- a/crates/icp/src/network/mod.rs +++ b/crates/icp/src/network/mod.rs @@ -71,7 +71,28 @@ impl Default for Gateway { #[derive(Clone, Debug, Default, Deserialize, PartialEq, JsonSchema, Serialize)] pub struct Managed { - pub gateway: Gateway, + #[serde(flatten)] + pub mode: ManagedMode, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, JsonSchema, Serialize)] +#[serde(untagged)] +pub enum ManagedMode { + Image { + image: String, + port_mapping: Vec, + }, + Launcher { + gateway: Gateway, + }, +} + +impl Default for ManagedMode { + fn default() -> Self { + ManagedMode::Launcher { + gateway: Gateway::default(), + } + } } #[derive(Clone, Debug, Deserialize, PartialEq, JsonSchema, Serialize)] @@ -134,16 +155,30 @@ impl From for Connected { impl From for Configuration { fn from(value: Mode) -> Self { match value { - Mode::Managed(managed) => { - let gateway: Gateway = match managed.gateway { - Some(g) => g.into(), - None => Gateway::default(), - }; - - Configuration::Managed { - managed: Managed { gateway }, + Mode::Managed(managed) => match managed.mode { + crate::manifest::network::ManagedMode::Launcher { gateway } => { + let gateway: Gateway = match gateway { + Some(g) => g.into(), + None => Gateway::default(), + }; + Configuration::Managed { + managed: Managed { + mode: ManagedMode::Launcher { gateway }, + }, + } } - } + crate::manifest::network::ManagedMode::Image { + image, + port_mapping, + } => Configuration::Managed { + managed: Managed { + mode: ManagedMode::Image { + image, + port_mapping, + }, + }, + }, + }, Mode::Connected(connected) => Configuration::Connected { connected: connected.into(), }, diff --git a/crates/icp/src/project.rs b/crates/icp/src/project.rs index 0cff193a..d3e577bd 100644 --- a/crates/icp/src/project.rs +++ b/crates/icp/src/project.rs @@ -13,7 +13,7 @@ use crate::{ load_manifest_from_path, recipe::RecipeType, }, - network::{Configuration, Connected, Gateway, Managed, Port}, + network::{Configuration, Connected, Gateway, Managed, ManagedMode, Port}, prelude::*, }; @@ -95,9 +95,11 @@ fn default_networks() -> Vec { name: DEFAULT_LOCAL_NETWORK_NAME.to_string(), configuration: Configuration::Managed { managed: Managed { - gateway: Gateway { - host: DEFAULT_LOCAL_NETWORK_HOST.to_string(), - port: Port::Fixed(DEFAULT_LOCAL_NETWORK_PORT), + mode: ManagedMode::Launcher { + gateway: Gateway { + host: DEFAULT_LOCAL_NETWORK_HOST.to_string(), + port: Port::Fixed(DEFAULT_LOCAL_NETWORK_PORT), + }, }, }, }, From a0bb0b438312ed34b1ed270ffaa896f3550e778a Mon Sep 17 00:00:00 2001 From: Adam Spofford Date: Fri, 12 Dec 2025 13:06:19 -0800 Subject: [PATCH 02/59] error handling + windows --- crates/icp/src/network/managed/docker.rs | 277 +++++++++++++++++++++ crates/icp/src/network/managed/launcher.rs | 200 +++------------ crates/icp/src/network/managed/mod.rs | 1 + crates/icp/src/network/managed/run.rs | 13 +- 4 files changed, 324 insertions(+), 167 deletions(-) create mode 100644 crates/icp/src/network/managed/docker.rs diff --git a/crates/icp/src/network/managed/docker.rs b/crates/icp/src/network/managed/docker.rs new file mode 100644 index 00000000..3b0bcae1 --- /dev/null +++ b/crates/icp/src/network/managed/docker.rs @@ -0,0 +1,277 @@ +use std::collections::HashMap; + +use async_dropper::{AsyncDrop, AsyncDropper}; +use async_trait::async_trait; +use bollard::{ + Docker, + query_parameters::{ + CreateContainerOptions, InspectContainerOptions, RemoveContainerOptions, + StartContainerOptions, StopContainerOptions, WaitContainerOptions, + }, + secret::{ContainerCreateBody, HostConfig, Mount, MountTypeEnum, PortBinding}, +}; +use camino_tempfile::Utf8TempDir; +use futures::TryStreamExt; +use itertools::Itertools; +#[cfg(unix)] +use snafu::ResultExt; +use snafu::{OptionExt, Snafu}; +use tokio::select; + +use crate::network::managed::launcher::{NetworkInstance, wait_for_launcher_status}; +use crate::prelude::*; + +pub async fn spawn_docker_launcher( + image: &str, + port_mappings: &[String], +) -> Result<(AsyncDropper, NetworkInstance), DockerLauncherError> { + let status_dir = Utf8TempDir::new().context(CreateStatusDirSnafu)?; + #[cfg(unix)] + let docker = Docker::connect_with_unix_defaults().context(ConnectDockerSnafu { + socket: "/var/run/docker.sock", + })?; + #[cfg(windows)] + let docker = Docker::connect_with_named_pipe_defaults().context(ConnectDockerSnafu { + socket: r"\\.\pipe\docker_engine", + })?; + let portmap: HashMap<_, _> = port_mappings + .iter() + .map(|mapping| { + let (host_port, container_port) = + mapping.split_once(':').context(ParsePortmapSnafu { + port_mapping: mapping, + })?; + Ok::<_, DockerLauncherError>(( + format!("{}/tcp", container_port), + Some(vec![PortBinding { + host_ip: None, + host_port: Some(host_port.to_string()), + }]), + )) + }) + .try_collect()?; + let container_resp = docker + .create_container( + None::, + ContainerCreateBody { + image: Some(image.to_string()), + attach_stdin: Some(false), + attach_stdout: Some(true), + attach_stderr: Some(true), + host_config: Some(HostConfig { + port_bindings: Some(portmap), + mounts: Some(vec![Mount { + target: Some("/app/status".to_string()), + source: Some(status_dir.path().to_string()), + typ: Some(MountTypeEnum::BIND), + read_only: Some(false), + ..<_>::default() + }]), + ..<_>::default() + }), + ..<_>::default() + }, + ) + .await + .context(CreateContainerSnafu { image_name: image })?; + let container_id = container_resp.id; + let guard = AsyncDropper::new(DockerDropGuard { + container_id: Some(container_id), + docker: Some(docker), + }); + let container_id = guard.container_id.as_ref().unwrap(); + let docker = guard.docker.as_ref().unwrap(); + let watcher = wait_for_launcher_status(status_dir.path())?; + docker + .start_container(container_id, None::) + .await + .context(StartContainerSnafu { container_id })?; + let mut wait_container = docker.wait_container(container_id, None::); + let launcher_status = select! { + content = watcher => content?, + res = wait_container.try_next() => { + let exit = res.context(WatchContainerSnafu { container_id })?; + if let Some(exit) = exit { + return ContainerExitedPrematurelySnafu { + container_id, + exit_status: exit.status_code, + }.fail(); + } else { + return RequiredFieldMissingSnafu { + field: "StatusCode", + route: "wait_container", + }.fail() + } + }, + }; + let container_info = docker + .inspect_container(container_id, None::) + .await + .context(InspectContainerSnafu { container_id })?; + let container_config_port = launcher_status.config_port; + let container_gateway_port = launcher_status.gateway_port; + let port_bindings = container_info + .network_settings + .context(RequiredFieldMissingSnafu { + field: "NetworkSettings", + route: "inspect_container", + })? + .ports + .context(RequiredFieldMissingSnafu { + field: "NetworkSettings.Ports", + route: "inspect_container", + })?; + let host_config_port = if let Some(container_config_port) = container_config_port { + if let Some(port_binding) = port_bindings + .get(&format!("{container_config_port}/tcp")) + .and_then(|pb| pb.as_ref()) + .and_then(|pb| pb.first()) + { + let host_port_str = + port_binding + .host_port + .as_ref() + .with_context(|| RequiredFieldMissingSnafu { + field: format!( + "NetworkSettings.Ports[{container_config_port}][0].HostPort" + ), + route: "inspect_container", + })?; + Some(host_port_str.parse::().context(ParsePortSnafu { + host_port: host_port_str, + container_port: container_config_port, + container_id, + })?) + } else { + None + } + } else { + None + }; + let host_gateway_port_str = port_bindings + .get(&format!("{container_gateway_port}/tcp")) + .and_then(|pb| pb.as_ref()) + .and_then(|pb| pb.first()) + .context(GatewayPortNotMappedSnafu { + container_port: container_gateway_port, + container_id, + })? + .host_port + .as_ref() + .with_context(|| RequiredFieldMissingSnafu { + field: format!("NetworkSettings.Ports[{container_gateway_port}][0].HostPort"), + route: "inspect_container", + })?; + let host_gateway_port = host_gateway_port_str + .parse::() + .context(ParsePortSnafu { + host_port: host_gateway_port_str, + container_port: container_gateway_port, + container_id, + })?; + Ok(( + guard, + NetworkInstance { + gateway_port: host_gateway_port, + pocketic_config_port: host_config_port, + pocketic_instance_id: launcher_status.instance_id, + root_key: hex::decode(&launcher_status.root_key).context(ParseRootKeySnafu { + key: &launcher_status.root_key, + })?, + }, + )) +} + +#[derive(Debug, Snafu)] +pub enum DockerLauncherError { + #[snafu(display("failed to connect to docker daemon at {socket} (is it running?)"))] + ConnectDocker { + source: bollard::errors::Error, + socket: PathBuf, + }, + #[snafu(display("failed to create docker container"))] + CreateContainer { + source: bollard::errors::Error, + image_name: String, + }, + #[snafu(display("failed to start docker container"))] + StartContainer { + source: bollard::errors::Error, + container_id: String, + }, + #[snafu(display("failed to inspect docker container"))] + InspectContainer { + source: bollard::errors::Error, + container_id: String, + }, + #[snafu(display("failed to parse root key {key}"))] + ParseRootKey { + key: String, + source: hex::FromHexError, + }, + #[snafu(display("required field {field} in docker API route {route} is missing"))] + RequiredFieldMissing { route: String, field: String }, + #[snafu(display( + "container {container_id} did not map container port {container_port}/tcp to a host port" + ))] + GatewayPortNotMapped { + container_port: u16, + container_id: String, + }, + #[snafu(display( + "failed to parse port mapping {host_port} as number for container port {container_port}/tcp in container {container_id}" + ))] + ParsePort { + source: std::num::ParseIntError, + host_port: String, + container_port: u16, + container_id: String, + }, + #[snafu(display( + "failed to parse port mapping {port_mapping}, must be in format :" + ))] + ParsePortmap { port_mapping: String }, + #[snafu(transparent)] + WaitForLauncherStatus { + source: crate::network::managed::launcher::WaitForLauncherStatusError, + }, + #[snafu(transparent)] + WatchStatusDir { + source: crate::network::managed::launcher::WaitForFileError, + }, + #[snafu(display("failed to watch docker container {container_id} for exit"))] + WatchContainer { + source: bollard::errors::Error, + container_id: String, + }, + #[snafu(display( + "docker container {container_id} exited prematurely with status {exit_status}" + ))] + ContainerExitedPrematurely { + container_id: String, + exit_status: i64, + }, + #[snafu(display("failed to create status directory"))] + CreateStatusDir { source: std::io::Error }, +} + +#[derive(Default)] +pub struct DockerDropGuard { + docker: Option, + container_id: Option, +} + +#[async_trait] +impl AsyncDrop for DockerDropGuard { + async fn async_drop(&mut self) { + if let Some(docker) = &self.docker.take() { + let container_id = self.container_id.take().unwrap(); + let _ = docker + .stop_container(&container_id, None::) + .await; + let _ = docker + .remove_container(&container_id, None::) + .await; + } + } +} diff --git a/crates/icp/src/network/managed/launcher.rs b/crates/icp/src/network/managed/launcher.rs index 4fef37cf..67416ef3 100644 --- a/crates/icp/src/network/managed/launcher.rs +++ b/crates/icp/src/network/managed/launcher.rs @@ -1,20 +1,9 @@ -use async_dropper::{AsyncDrop, AsyncDropper}; -use async_trait::async_trait; -use bollard::{ - Docker, - query_parameters::{ - CreateContainerOptions, InspectContainerOptions, RemoveContainerOptions, - StartContainerOptions, StopContainerOptions, WaitContainerOptions, - }, - secret::{ContainerCreateBody, HostConfig, Mount, MountTypeEnum, PortBinding}, -}; use camino_tempfile::Utf8TempDir; use candid::Principal; -use futures::TryStreamExt; use notify::Watcher; use serde::Deserialize; use snafu::prelude::*; -use std::{collections::HashMap, io::ErrorKind, process::Stdio}; +use std::{io::ErrorKind, process::Stdio}; use sysinfo::{Pid, ProcessesToUpdate, Signal, System}; use tokio::{process::Child, select}; @@ -45,6 +34,23 @@ pub enum SpawnNetworkLauncherError { }, #[snafu(display("failed to watch launcher status file"))] WatchForStatusFile { source: WaitForLauncherStatusError }, + #[snafu(display( + "network launcher at {network_launcher_path} exited prematurely with status {exit_status}" + ))] + LauncherExitedPrematurely { + network_launcher_path: PathBuf, + exit_status: std::process::ExitStatus, + }, + #[snafu(display("failed to watch launcher process for exit code"))] + WatchLauncher { + network_launcher_path: PathBuf, + source: std::io::Error, + }, + #[snafu(display("failed to parse root key {key}"))] + ParseRootKey { + key: String, + source: hex::FromHexError, + }, } pub async fn spawn_network_launcher( @@ -86,13 +92,28 @@ pub async fn spawn_network_launcher( let child = cmd.spawn().context(SpawnLauncherSnafu { network_launcher_path, })?; - let child = ChildSignalOnDrop { child }; - let launcher_status = watcher.await.context(WatchForStatusFileSnafu)?; + let mut guard = ChildSignalOnDrop { child }; + let child = &mut guard.child; + let launcher_status = select! { + status = watcher => status.context(WatchForStatusFileSnafu)?, + // If the child process exits before writing the status file, return an error. + res = child.wait() => { + let exit_status = res.context(WatchLauncherSnafu { + network_launcher_path, + })?; + return LauncherExitedPrematurelySnafu { + exit_status, + network_launcher_path: &network_launcher_path, + }.fail(); + }, + }; Ok(( - child, + guard, NetworkInstance { gateway_port: launcher_status.gateway_port, - root_key: hex::decode(&launcher_status.root_key).unwrap(), + root_key: hex::decode(&launcher_status.root_key).context(ParseRootKeySnafu { + key: &launcher_status.root_key, + })?, pocketic_config_port: launcher_status.config_port, pocketic_instance_id: launcher_status.instance_id, }, @@ -125,153 +146,6 @@ impl Drop for ChildSignalOnDrop { } } -pub async fn spawn_docker_launcher( - image: &str, - port_mappings: &[String], -) -> (AsyncDropper, NetworkInstance) { - let status_dir = Utf8TempDir::new().unwrap(); - #[cfg(unix)] - let docker = Docker::connect_with_socket_defaults() - .expect("failed to connect to docker socket (is it running?)"); - let portmap = port_mappings - .iter() - .map(|mapping| { - let (host_port, container_port) = mapping - .split_once(':') - .expect("invalid port mapping, must be in format :"); - ( - format!("{}/tcp", container_port), - Some(vec![PortBinding { - host_ip: None, - host_port: Some(host_port.to_string()), - }]), - ) - }) - .collect::>(); - let container_resp = docker - .create_container( - None::, - ContainerCreateBody { - image: Some(image.to_string()), - attach_stdin: Some(false), - attach_stdout: Some(true), - attach_stderr: Some(true), - host_config: Some(HostConfig { - port_bindings: Some(portmap), - mounts: Some(vec![Mount { - target: Some("/app/status".to_string()), - source: Some(status_dir.path().to_string()), - typ: Some(MountTypeEnum::BIND), - read_only: Some(false), - ..<_>::default() - }]), - ..<_>::default() - }), - ..<_>::default() - }, - ) - .await - .expect("failed to create docker container"); - let container_id = container_resp.id; - let guard = AsyncDropper::new(DockerDropGuard { - container_id: Some(container_id), - docker: Some(docker), - }); - let container_id = guard.container_id.as_ref().unwrap(); - let docker = guard.docker.as_ref().unwrap(); - let watcher = wait_for_single_line_file(&status_dir.path().join("status.json")).unwrap(); - docker - .start_container(container_id, None::) - .await - .expect("failed to start docker container"); - let mut wait_container = docker.wait_container(container_id, None::); - let status_content = select! { - content = watcher => content.unwrap(), - res = wait_container.try_next() => { - let exit = res.unwrap(); - if let Some(exit) = exit { - panic!("Docker container exited with code {} before writing status file.", exit.status_code); - } else { - panic!("Docker container exited before writing status file."); - } - }, - }; - let launcher_status: LauncherStatus = - serde_json::from_str(&status_content).expect("failed to parse launcher status file"); - assert_eq!( - launcher_status.v, "1", - "unexpected Docker launcher status version" - ); - let container_info = docker - .inspect_container(container_id, None::) - .await - .expect("failed to inspect docker container"); - let container_config_port = launcher_status.config_port; - let container_gateway_port = launcher_status.gateway_port; - let port_bindings = container_info - .network_settings - .expect("missing network settings in docker container") - .ports - .expect("missing port mappings in docker container"); - let host_config_port = container_config_port.map(|container_config_port| { - port_bindings - .get(&format!("{container_config_port}/tcp")) - .expect("missing PIC config port in docker container") - .as_ref() - .expect("missing host port binding for PIC config port in docker container") - .first() - .expect("missing host port binding for PIC config port in docker container") - .host_port - .as_ref() - .expect("missing host port for PIC config port in docker container") - .parse::() - .expect("invalid host port for PIC config port in docker container") - }); - let host_gateway_port = port_bindings - .get(&format!("{container_gateway_port}/tcp")) - .expect("missing PIC gateway port in docker container") - .as_ref() - .expect("missing host port binding for PIC gateway port in docker container") - .first() - .expect("missing host port binding for PIC gateway port in docker container") - .host_port - .as_ref() - .expect("missing host port for PIC gateway port in docker container") - .parse::() - .expect("invalid host port for PIC gateway port in docker container"); - ( - guard, - NetworkInstance { - gateway_port: host_gateway_port, - pocketic_config_port: host_config_port, - pocketic_instance_id: launcher_status.instance_id, - root_key: hex::decode(&launcher_status.root_key) - .expect("invalid root key in launcher status"), - }, - ) -} - -#[derive(Default)] -pub struct DockerDropGuard { - docker: Option, - container_id: Option, -} - -#[async_trait] -impl AsyncDrop for DockerDropGuard { - async fn async_drop(&mut self) { - if let Some(docker) = &self.docker.take() { - let container_id = self.container_id.take().unwrap(); - let _ = docker - .stop_container(&container_id, None::) - .await; - let _ = docker - .remove_container(&container_id, None::) - .await; - } - } -} - #[derive(Debug, Snafu)] pub enum WaitForFileError { #[snafu(display("failed to watch file changes at path {path}"))] diff --git a/crates/icp/src/network/managed/mod.rs b/crates/icp/src/network/managed/mod.rs index 5f253ae7..6f9a1186 100644 --- a/crates/icp/src/network/managed/mod.rs +++ b/crates/icp/src/network/managed/mod.rs @@ -1,2 +1,3 @@ +pub mod docker; pub mod launcher; pub mod run; diff --git a/crates/icp/src/network/managed/run.rs b/crates/icp/src/network/managed/run.rs index b14b3154..8628ff4c 100644 --- a/crates/icp/src/network/managed/run.rs +++ b/crates/icp/src/network/managed/run.rs @@ -34,9 +34,9 @@ use crate::{ Gateway, Managed, ManagedMode, NetworkDirectory, Port, config::{NetworkDescriptorGatewayPort, NetworkDescriptorModel}, directory::{ClaimPortError, SaveNetworkDescriptorError, save_network_descriptors}, - managed::launcher::{ - ChildSignalOnDrop, CreateHttpGatewayError, DockerDropGuard, spawn_docker_launcher, - spawn_network_launcher, + managed::{ + docker::{DockerDropGuard, spawn_docker_launcher}, + launcher::{ChildSignalOnDrop, CreateHttpGatewayError, spawn_network_launcher}, }, }, prelude::*, @@ -118,7 +118,7 @@ async fn run_network_launcher( image, port_mapping, } => { - let (guard, instance) = spawn_docker_launcher(image, port_mapping).await; + let (guard, instance) = spawn_docker_launcher(image, port_mapping).await?; let gateway = NetworkDescriptorGatewayPort { port: instance.gateway_port, fixed: false, @@ -258,6 +258,11 @@ pub enum RunNetworkLauncherError { SpawnLauncher { source: crate::network::managed::launcher::SpawnNetworkLauncherError, }, + + #[snafu(transparent)] + SpawnDockerLauncher { + source: crate::network::managed::docker::DockerLauncherError, + }, } #[derive(Debug)] From 518050c030fe643a892d0ff2fadbd19cd62d7f6b Mon Sep 17 00:00:00 2001 From: Adam Spofford Date: Fri, 12 Dec 2025 13:09:43 -0800 Subject: [PATCH 03/59] container preservation --- crates/icp/src/manifest/network.rs | 2 +- crates/icp/src/network/managed/docker.rs | 11 ++++++++--- crates/icp/src/network/managed/run.rs | 4 +++- crates/icp/src/network/mod.rs | 3 +++ 4 files changed, 15 insertions(+), 5 deletions(-) diff --git a/crates/icp/src/manifest/network.rs b/crates/icp/src/manifest/network.rs index 467d7d3b..c070f9e0 100644 --- a/crates/icp/src/manifest/network.rs +++ b/crates/icp/src/manifest/network.rs @@ -28,8 +28,8 @@ pub struct Managed { pub enum ManagedMode { Image { image: String, - #[serde(default)] port_mapping: Vec, + rm_on_exit: Option, }, Launcher { gateway: Option, diff --git a/crates/icp/src/network/managed/docker.rs b/crates/icp/src/network/managed/docker.rs index 3b0bcae1..dcb47668 100644 --- a/crates/icp/src/network/managed/docker.rs +++ b/crates/icp/src/network/managed/docker.rs @@ -24,6 +24,7 @@ use crate::prelude::*; pub async fn spawn_docker_launcher( image: &str, port_mappings: &[String], + rm_on_drop: bool, ) -> Result<(AsyncDropper, NetworkInstance), DockerLauncherError> { let status_dir = Utf8TempDir::new().context(CreateStatusDirSnafu)?; #[cfg(unix)] @@ -78,6 +79,7 @@ pub async fn spawn_docker_launcher( let guard = AsyncDropper::new(DockerDropGuard { container_id: Some(container_id), docker: Some(docker), + rm_on_drop, }); let container_id = guard.container_id.as_ref().unwrap(); let docker = guard.docker.as_ref().unwrap(); @@ -259,6 +261,7 @@ pub enum DockerLauncherError { pub struct DockerDropGuard { docker: Option, container_id: Option, + rm_on_drop: bool, } #[async_trait] @@ -269,9 +272,11 @@ impl AsyncDrop for DockerDropGuard { let _ = docker .stop_container(&container_id, None::) .await; - let _ = docker - .remove_container(&container_id, None::) - .await; + if self.rm_on_drop { + let _ = docker + .remove_container(&container_id, None::) + .await; + } } } } diff --git a/crates/icp/src/network/managed/run.rs b/crates/icp/src/network/managed/run.rs index 8628ff4c..d34861de 100644 --- a/crates/icp/src/network/managed/run.rs +++ b/crates/icp/src/network/managed/run.rs @@ -117,8 +117,10 @@ async fn run_network_launcher( ManagedMode::Image { image, port_mapping, + rm_on_exit, } => { - let (guard, instance) = spawn_docker_launcher(image, port_mapping).await?; + let (guard, instance) = + spawn_docker_launcher(image, port_mapping, *rm_on_exit).await?; let gateway = NetworkDescriptorGatewayPort { port: instance.gateway_port, fixed: false, diff --git a/crates/icp/src/network/mod.rs b/crates/icp/src/network/mod.rs index f1003cdd..3b8b480a 100644 --- a/crates/icp/src/network/mod.rs +++ b/crates/icp/src/network/mod.rs @@ -81,6 +81,7 @@ pub enum ManagedMode { Image { image: String, port_mapping: Vec, + rm_on_exit: bool, }, Launcher { gateway: Gateway, @@ -170,11 +171,13 @@ impl From for Configuration { crate::manifest::network::ManagedMode::Image { image, port_mapping, + rm_on_exit, } => Configuration::Managed { managed: Managed { mode: ManagedMode::Image { image, port_mapping, + rm_on_exit: rm_on_exit.unwrap_or(false), }, }, }, From 8a8e9e53422cbc3e91a3dd81413f492a8b3f57dd Mon Sep 17 00:00:00 2001 From: Adam Spofford Date: Fri, 12 Dec 2025 17:22:45 -0800 Subject: [PATCH 04/59] . --- crates/icp/src/network/managed/docker.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/crates/icp/src/network/managed/docker.rs b/crates/icp/src/network/managed/docker.rs index dcb47668..e5b37d53 100644 --- a/crates/icp/src/network/managed/docker.rs +++ b/crates/icp/src/network/managed/docker.rs @@ -13,7 +13,6 @@ use bollard::{ use camino_tempfile::Utf8TempDir; use futures::TryStreamExt; use itertools::Itertools; -#[cfg(unix)] use snafu::ResultExt; use snafu::{OptionExt, Snafu}; use tokio::select; From e05cc001f28c0ca4a8e300d65ceffe9fdbd01052 Mon Sep 17 00:00:00 2001 From: Adam Spofford Date: Fri, 12 Dec 2025 17:29:48 -0800 Subject: [PATCH 05/59] windows --- crates/icp-cli/artifacts/mod.rs | 2 +- crates/icp-cli/src/logging.rs | 22 +++++++++++++++------- 2 files changed, 16 insertions(+), 8 deletions(-) diff --git a/crates/icp-cli/artifacts/mod.rs b/crates/icp-cli/artifacts/mod.rs index 52ad1562..a1fb1415 100644 --- a/crates/icp-cli/artifacts/mod.rs +++ b/crates/icp-cli/artifacts/mod.rs @@ -159,7 +159,7 @@ fn generate_artifacts_code(artifact_names: &[String], cache_dir: &Path) -> Strin name )); code.push_str(&format!("pub fn {}() -> &'static [u8] {{\n", fn_name)); - code.push_str(&format!(" include_bytes!(\"{}\")\n", artifact_path_str)); + code.push_str(&format!(" include_bytes!(r\"{}\")\n", artifact_path_str)); code.push_str("}\n\n"); } diff --git a/crates/icp-cli/src/logging.rs b/crates/icp-cli/src/logging.rs index 8b723af8..f22f6cb4 100644 --- a/crates/icp-cli/src/logging.rs +++ b/crates/icp-cli/src/logging.rs @@ -1,7 +1,8 @@ -use std::{ - io::Write, - os::fd::{AsRawFd, RawFd}, -}; +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_subscriber::{ @@ -12,12 +13,12 @@ use tracing_subscriber::{ }; #[derive(Debug)] -pub(crate) struct TermWriter { +pub(crate) struct TermWriter { pub(crate) debug: bool, pub(crate) writer: Box, } -impl Write for TermWriter { +impl Write for TermWriter { fn write(&mut self, buf: &[u8]) -> std::io::Result { if !self.debug { self.writer.write(buf)?; @@ -34,11 +35,18 @@ impl Write for TermWriter { } } -impl AsRawFd for TermWriter { +#[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>, From d0219ce6588341621f6769ac8cccfa54fe426fc2 Mon Sep 17 00:00:00 2001 From: Adam Spofford Date: Fri, 12 Dec 2025 18:35:52 -0800 Subject: [PATCH 06/59] container args --- crates/icp/src/manifest/network.rs | 1 + crates/icp/src/network/managed/docker.rs | 4 ++++ crates/icp/src/network/managed/run.rs | 3 ++- crates/icp/src/network/mod.rs | 3 +++ 4 files changed, 10 insertions(+), 1 deletion(-) diff --git a/crates/icp/src/manifest/network.rs b/crates/icp/src/manifest/network.rs index c070f9e0..e5177656 100644 --- a/crates/icp/src/manifest/network.rs +++ b/crates/icp/src/manifest/network.rs @@ -30,6 +30,7 @@ pub enum ManagedMode { image: String, port_mapping: Vec, rm_on_exit: Option, + args: Option>, }, Launcher { gateway: Option, diff --git a/crates/icp/src/network/managed/docker.rs b/crates/icp/src/network/managed/docker.rs index e5b37d53..8ff89170 100644 --- a/crates/icp/src/network/managed/docker.rs +++ b/crates/icp/src/network/managed/docker.rs @@ -24,6 +24,7 @@ pub async fn spawn_docker_launcher( image: &str, port_mappings: &[String], rm_on_drop: bool, + args: &[String], ) -> Result<(AsyncDropper, NetworkInstance), DockerLauncherError> { let status_dir = Utf8TempDir::new().context(CreateStatusDirSnafu)?; #[cfg(unix)] @@ -50,11 +51,14 @@ pub async fn spawn_docker_launcher( )) }) .try_collect()?; + let mut args = args.to_vec(); + args.push("--interface-version=1.0.0".to_string()); let container_resp = docker .create_container( None::, ContainerCreateBody { image: Some(image.to_string()), + cmd: Some(args), attach_stdin: Some(false), attach_stdout: Some(true), attach_stderr: Some(true), diff --git a/crates/icp/src/network/managed/run.rs b/crates/icp/src/network/managed/run.rs index d34861de..62b6dc15 100644 --- a/crates/icp/src/network/managed/run.rs +++ b/crates/icp/src/network/managed/run.rs @@ -118,9 +118,10 @@ async fn run_network_launcher( image, port_mapping, rm_on_exit, + args, } => { let (guard, instance) = - spawn_docker_launcher(image, port_mapping, *rm_on_exit).await?; + spawn_docker_launcher(image, port_mapping, *rm_on_exit, args).await?; let gateway = NetworkDescriptorGatewayPort { port: instance.gateway_port, fixed: false, diff --git a/crates/icp/src/network/mod.rs b/crates/icp/src/network/mod.rs index 3b8b480a..d98ad602 100644 --- a/crates/icp/src/network/mod.rs +++ b/crates/icp/src/network/mod.rs @@ -82,6 +82,7 @@ pub enum ManagedMode { image: String, port_mapping: Vec, rm_on_exit: bool, + args: Vec, }, Launcher { gateway: Gateway, @@ -172,12 +173,14 @@ impl From for Configuration { image, port_mapping, rm_on_exit, + args, } => Configuration::Managed { managed: Managed { mode: ManagedMode::Image { image, port_mapping, rm_on_exit: rm_on_exit.unwrap_or(false), + args: args.unwrap_or_default(), }, }, }, From 68fb26c2d97220d0bff3f53b59e9c473c2ffcd3d Mon Sep 17 00:00:00 2001 From: Adam Spofford Date: Fri, 12 Dec 2025 17:42:14 -0800 Subject: [PATCH 07/59] remove special term --- crates/icp-cli/src/logging.rs | 1 + crates/icp-cli/src/main.rs | 17 +++++++++-------- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/crates/icp-cli/src/logging.rs b/crates/icp-cli/src/logging.rs index f22f6cb4..d357171c 100644 --- a/crates/icp-cli/src/logging.rs +++ b/crates/icp-cli/src/logging.rs @@ -12,6 +12,7 @@ use tracing_subscriber::{ registry::LookupSpan, }; +#[expect(unused)] #[derive(Debug)] pub(crate) struct TermWriter { pub(crate) debug: bool, diff --git a/crates/icp-cli/src/main.rs b/crates/icp-cli/src/main.rs index 79c3aa23..fcc8ecac 100644 --- a/crates/icp-cli/src/main.rs +++ b/crates/icp-cli/src/main.rs @@ -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}, }; @@ -105,13 +105,14 @@ 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 = Term::read_write_pair( + // std::io::stdin(), + // TermWriter { + // debug: cli.debug, + // writer: Box::new(std::io::stdout()), + // }, + // ); + let term = Term::stdout(); // Logging and Telemetry let (debug_layer, event_layer) = ( From 222f3adf3858e461fcc996dfec914c81b1287fd4 Mon Sep 17 00:00:00 2001 From: Adam Spofford Date: Fri, 12 Dec 2025 18:01:57 -0800 Subject: [PATCH 08/59] locate git bash --- Cargo.lock | 11 +++++++++ Cargo.toml | 1 + crates/icp/Cargo.toml | 1 + crates/icp/src/canister/script.rs | 37 ++++++++++++++++++++++++++++++- 4 files changed, 49 insertions(+), 1 deletion(-) diff --git a/Cargo.lock b/Cargo.lock index 6c6618b5..41ef17ee 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3203,6 +3203,7 @@ dependencies = [ "k256 0.13.4", "notify", "p256", + "pathsearch", "pem 3.0.5", "pkcs8 0.10.2", "rand 0.9.2", @@ -4575,6 +4576,16 @@ version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" +[[package]] +name = "pathsearch" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da983bc5e582ab17179c190b4b66c7d76c5943a69c6d34df2a2b6bf8a2977b05" +dependencies = [ + "anyhow", + "libc", +] + [[package]] name = "pbkdf2" version = "0.11.0" diff --git a/Cargo.toml b/Cargo.toml index 629a2466..a37e5de5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -56,6 +56,7 @@ mockall = "0.13.1" notify = "8.2.0" p256 = { version = "0.13.2", features = ["pem", "pkcs8", "std"] } pathdiff = { version = "0.2.3", features = ["camino"] } +pathsearch = "0.2.0" pem = "3.0.5" phf = { version = "0.13.1", features = ["macros"] } pkcs8 = { version = "0.10.2", features = ["encryption", "std"] } diff --git a/crates/icp/Cargo.toml b/crates/icp/Cargo.toml index 177bcea8..933d1ab0 100644 --- a/crates/icp/Cargo.toml +++ b/crates/icp/Cargo.toml @@ -34,6 +34,7 @@ itertools = { workspace = true } k256 = { workspace = true } notify = { workspace = true } p256 = { workspace = true } +pathsearch = { workspace = true } pem = { workspace = true } pkcs8 = { workspace = true } rand = { workspace = true } diff --git a/crates/icp/src/canister/script.rs b/crates/icp/src/canister/script.rs index 28866a28..89d3c404 100644 --- a/crates/icp/src/canister/script.rs +++ b/crates/icp/src/canister/script.rs @@ -36,6 +36,23 @@ pub enum ScriptError { #[snafu(display("command '{command}' failed with status code {code}"))] Status { command: String, code: String }, + + #[cfg(windows)] + #[snafu(display( + "failed to locate bash (the git at {git_path} does not appear to be Git for Windows, try running in Git Bash)" + ))] + LocateBash { git_path: PathBuf }, + + #[cfg(windows)] + #[snafu(display("failed to locate git executable in PATH (try running in Git Bash)"))] + LocateGit, + + #[cfg(windows)] + #[snafu(display("unprocessable executable path: {}", path.display()))] + BadPath { + path: std::path::PathBuf, + source: camino::FromPathBufError, + }, } pub(super) async fn execute( @@ -152,8 +169,26 @@ fn shell_command(s: &str, cwd: &Path) -> Result { } .fail(); } - + #[cfg(unix)] let mut cmd = Command::new("sh"); + #[cfg(windows)] + let mut cmd = if let Some(_) = std::env::var_os("BASH_VERSION") { + Command::new("bash") + } else { + if let Some(git_path) = pathsearch::find_executable_in_path("git") { + let git_path = + PathBuf::try_from(git_path.clone()).context(BadPathSnafu { path: git_path })?; + if let Some(cmd_path) = git_path.parent() + && cmd_path.ends_with("cmd") + { + Command::new(cmd_path.parent().unwrap().join("bin/bash.exe")) + } else { + return LocateBashSnafu { git_path }.fail(); + } + } else { + return LocateGitSnafu.fail(); + } + }; cmd.args(["-c", s]); cmd.current_dir(cwd); Ok(cmd) From 324bde4eed89d83a3931495c8ebae41398eae7d4 Mon Sep 17 00:00:00 2001 From: Adam Spofford Date: Thu, 8 Jan 2026 08:59:18 -0800 Subject: [PATCH 09/59] Change default network to docker on windows --- crates/icp/src/manifest/network.rs | 14 +++++++++++++- crates/icp/src/project.rs | 25 ++++++++++++++++++++----- 2 files changed, 33 insertions(+), 6 deletions(-) diff --git a/crates/icp/src/manifest/network.rs b/crates/icp/src/manifest/network.rs index e5177656..f19b9287 100644 --- a/crates/icp/src/manifest/network.rs +++ b/crates/icp/src/manifest/network.rs @@ -39,7 +39,19 @@ pub enum ManagedMode { impl Default for ManagedMode { fn default() -> Self { - ManagedMode::Launcher { gateway: None } + #[cfg(unix)] + { + ManagedMode::Launcher { gateway: None } + } + #[cfg(windows)] + { + ManagedMode::Image { + image: "ghcr.io/dfinity/icp-cli-network-launcher:latest", + port_mapping: vec!["0:4943".to_string()], + rm_on_exit: true, + args: None, + } + } } } diff --git a/crates/icp/src/project.rs b/crates/icp/src/project.rs index d3e577bd..4dfa806b 100644 --- a/crates/icp/src/project.rs +++ b/crates/icp/src/project.rs @@ -95,11 +95,26 @@ fn default_networks() -> Vec { name: DEFAULT_LOCAL_NETWORK_NAME.to_string(), configuration: Configuration::Managed { managed: Managed { - mode: ManagedMode::Launcher { - gateway: Gateway { - host: DEFAULT_LOCAL_NETWORK_HOST.to_string(), - port: Port::Fixed(DEFAULT_LOCAL_NETWORK_PORT), - }, + mode: { + #[cfg(unix)] + { + ManagedMode::Launcher { + gateway: Gateway { + host: DEFAULT_LOCAL_NETWORK_HOST.to_string(), + port: Port::Fixed(DEFAULT_LOCAL_NETWORK_PORT), + }, + } + } + #[cfg(windows)] + { + ManagedMode::Image { + image: "ghcr.io/dfinity/icp-cli-network-launcher:latest" + .to_string(), + port_mapping: vec![format!("{}:4943", DEFAULT_LOCAL_NETWORK_PORT)], + rm_on_exit: true, + args: None, + } + } }, }, }, From af95a265ad5440de1ada1d29f679999220571062 Mon Sep 17 00:00:00 2001 From: Adam Spofford Date: Thu, 8 Jan 2026 10:55:57 -0800 Subject: [PATCH 10/59] new fields --- Cargo.lock | 12 ------ crates/icp/src/manifest/network.rs | 12 +++++- crates/icp/src/network/mod.rs | 61 +++++++++++++++++++++++------- crates/icp/src/project.rs | 20 +++++++--- 4 files changed, 71 insertions(+), 34 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 64c95bb5..7fa87739 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5131,18 +5131,6 @@ dependencies = [ "serde_json", ] -[[package]] -name = "schemars" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" -dependencies = [ - "dyn-clone", - "ref-cast", - "serde", - "serde_json", -] - [[package]] name = "schemars" version = "1.1.0" diff --git a/crates/icp/src/manifest/network.rs b/crates/icp/src/manifest/network.rs index 712f0ed0..02430f1d 100644 --- a/crates/icp/src/manifest/network.rs +++ b/crates/icp/src/manifest/network.rs @@ -68,10 +68,18 @@ impl Default for ManagedMode { #[cfg(windows)] { ManagedMode::Image { - image: "ghcr.io/dfinity/icp-cli-network-launcher:latest", + image: "ghcr.io/dfinity/icp-cli-network-launcher:latest".to_string(), port_mapping: vec!["0:4943".to_string()], - rm_on_exit: true, + rm_on_exit: Some(true), args: None, + entrypoint: None, + environment: None, + volumes: None, + platform: None, + user: None, + shm_size: None, + status_dir: None, + mounts: None, } } } diff --git a/crates/icp/src/network/mod.rs b/crates/icp/src/network/mod.rs index a75ae613..6820dcd4 100644 --- a/crates/icp/src/network/mod.rs +++ b/crates/icp/src/network/mod.rs @@ -84,26 +84,59 @@ pub enum ManagedMode { impl Default for ManagedMode { fn default() -> Self { - ManagedMode::Launcher { - gateway: Gateway::default(), + Self::default_for_port(0) + } +} + +impl ManagedMode { + pub fn default_for_port(port: u16) -> Self { + #[cfg(unix)] + { + ManagedMode::Launcher { + gateway: Gateway { + host: default_host(), + port: if port == 0 { + Port::Random + } else { + Port::Fixed(port) + }, + }, + } + } + #[cfg(windows)] + { + ManagedMode::Image(Box::new(ManagedImageConfig { + image: "ghcr.io/dfinity/icp-cli-network-launcher:latest".to_string(), + port_mapping: vec![format!("{}:4943", port)], + rm_on_exit: true, + args: vec![], + entrypoint: None, + environment: vec![], + volumes: vec![], + platform: None, + user: None, + shm_size: None, + status_dir: "/app/status".to_string(), + mounts: vec![], + })) } } } #[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/crates/icp/src/project.rs b/crates/icp/src/project.rs index 4dfa806b..1182dc29 100644 --- a/crates/icp/src/project.rs +++ b/crates/icp/src/project.rs @@ -13,7 +13,7 @@ use crate::{ load_manifest_from_path, recipe::RecipeType, }, - network::{Configuration, Connected, Gateway, Managed, ManagedMode, Port}, + network::{Configuration, Connected, Managed, ManagedMode}, prelude::*, }; @@ -99,21 +99,29 @@ fn default_networks() -> Vec { #[cfg(unix)] { ManagedMode::Launcher { - gateway: Gateway { + gateway: crate::network::Gateway { host: DEFAULT_LOCAL_NETWORK_HOST.to_string(), - port: Port::Fixed(DEFAULT_LOCAL_NETWORK_PORT), + port: crate::network::Port::Fixed(DEFAULT_LOCAL_NETWORK_PORT), }, } } #[cfg(windows)] { - ManagedMode::Image { + ManagedMode::Image(Box::new(crate::network::ManagedImageConfig { image: "ghcr.io/dfinity/icp-cli-network-launcher:latest" .to_string(), port_mapping: vec![format!("{}:4943", DEFAULT_LOCAL_NETWORK_PORT)], rm_on_exit: true, - args: None, - } + args: vec![], + entrypoint: None, + environment: vec![], + volumes: vec![], + platform: None, + user: None, + shm_size: None, + status_dir: "/app/status".to_string(), + mounts: vec![], + })) } }, }, From d525c3013f39fd086e0c4e8c7494fca55c67ff1c Mon Sep 17 00:00:00 2001 From: Adam Spofford Date: Wed, 14 Jan 2026 11:53:19 -0800 Subject: [PATCH 11/59] include windows builds in CI --- dist-workspace.toml | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/dist-workspace.toml b/dist-workspace.toml index fb7980d4..c36a157a 100644 --- a/dist-workspace.toml +++ b/dist-workspace.toml @@ -10,7 +10,13 @@ ci = "github" # The installers to generate for each app installers = ["shell"] # 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 From cfa5d5633f7f28e0a26d0fd735868227299a58ed Mon Sep 17 00:00:00 2001 From: Adam Spofford Date: Wed, 14 Jan 2026 12:10:25 -0800 Subject: [PATCH 12/59] Revert default network change --- crates/icp/src/manifest/network.rs | 22 +----------------- crates/icp/src/network/mod.rs | 36 +++++++----------------------- 2 files changed, 9 insertions(+), 49 deletions(-) diff --git a/crates/icp/src/manifest/network.rs b/crates/icp/src/manifest/network.rs index 02430f1d..e6b6d8ba 100644 --- a/crates/icp/src/manifest/network.rs +++ b/crates/icp/src/manifest/network.rs @@ -61,27 +61,7 @@ pub enum ManagedMode { impl Default for ManagedMode { fn default() -> Self { - #[cfg(unix)] - { - ManagedMode::Launcher { gateway: None } - } - #[cfg(windows)] - { - ManagedMode::Image { - image: "ghcr.io/dfinity/icp-cli-network-launcher:latest".to_string(), - port_mapping: vec!["0:4943".to_string()], - rm_on_exit: Some(true), - args: None, - entrypoint: None, - environment: None, - volumes: None, - platform: None, - user: None, - shm_size: None, - status_dir: None, - mounts: None, - } - } + ManagedMode::Launcher { gateway: None } } } diff --git a/crates/icp/src/network/mod.rs b/crates/icp/src/network/mod.rs index 6820dcd4..0fc3f38e 100644 --- a/crates/icp/src/network/mod.rs +++ b/crates/icp/src/network/mod.rs @@ -90,35 +90,15 @@ impl Default for ManagedMode { impl ManagedMode { pub fn default_for_port(port: u16) -> Self { - #[cfg(unix)] - { - ManagedMode::Launcher { - gateway: Gateway { - host: default_host(), - port: if port == 0 { - Port::Random - } else { - Port::Fixed(port) - }, + ManagedMode::Launcher { + gateway: Gateway { + host: default_host(), + port: if port == 0 { + Port::Random + } else { + Port::Fixed(port) }, - } - } - #[cfg(windows)] - { - ManagedMode::Image(Box::new(ManagedImageConfig { - image: "ghcr.io/dfinity/icp-cli-network-launcher:latest".to_string(), - port_mapping: vec![format!("{}:4943", port)], - rm_on_exit: true, - args: vec![], - entrypoint: None, - environment: vec![], - volumes: vec![], - platform: None, - user: None, - shm_size: None, - status_dir: "/app/status".to_string(), - mounts: vec![], - })) + }, } } } From 929a23e8fc3178265fa0d7046d6f0d6e5e75dd2e Mon Sep 17 00:00:00 2001 From: Adam Spofford Date: Wed, 14 Jan 2026 12:23:05 -0800 Subject: [PATCH 13/59] Convert native networks to containers on windows --- crates/icp/src/network/managed/run.rs | 33 ++++++++++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) diff --git a/crates/icp/src/network/managed/run.rs b/crates/icp/src/network/managed/run.rs index 3e06ceaa..4cff47f2 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::{ @@ -157,6 +157,16 @@ async fn run_network_launcher( }; (ShutdownGuard::Container(guard), instance, gateway, locator) } + 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, + }; + (ShutdownGuard::Container(guard), instance, gateway, locator) + } ManagedMode::Launcher { gateway } => { if root.state_dir().exists() { remove_dir_all(&root.state_dir()).context(RemoveDirAllSnafu)?; @@ -233,6 +243,27 @@ async fn run_network_launcher( 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![], + 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), From 99d903f3c2e7874777ee0ac83bc4907085845738 Mon Sep 17 00:00:00 2001 From: Adam Spofford Date: Wed, 14 Jan 2026 14:15:55 -0800 Subject: [PATCH 14/59] Add ii config --- crates/icp/src/network/managed/run.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/icp/src/network/managed/run.rs b/crates/icp/src/network/managed/run.rs index 4cff47f2..742d49e4 100644 --- a/crates/icp/src/network/managed/run.rs +++ b/crates/icp/src/network/managed/run.rs @@ -252,7 +252,7 @@ fn transform_native_launcher_to_container(gateway: Gateway) -> ManagedImageConfi image: "ghcr.io/dfinity/icp-cli-network-launcher:latest".to_string(), port_mapping: vec![format!("{port}:4943")], rm_on_exit: true, - args: vec![], + args: vec!["--ii".to_string()], entrypoint: None, environment: vec![], volumes: vec![], From af109f284a697595a0ef44251cdd973011acc178 Mon Sep 17 00:00:00 2001 From: Adam Spofford Date: Thu, 15 Jan 2026 08:32:18 -0800 Subject: [PATCH 15/59] Wrap terminal output --- .../src/commands/canister/settings/update.rs | 5 +- crates/icp-cli/src/logging.rs | 45 +--------------- crates/icp-cli/src/main.rs | 15 ++---- crates/icp-cli/src/operations/build.rs | 4 +- crates/icp-cli/src/operations/sync.rs | 6 +-- crates/icp/src/context/init.rs | 5 +- crates/icp/src/context/mod.rs | 53 +++++++++++++++++-- 7 files changed, 65 insertions(+), 68 deletions(-) diff --git a/crates/icp-cli/src/commands/canister/settings/update.rs b/crates/icp-cli/src/commands/canister/settings/update.rs index 05a43d76..cab834a5 100644 --- a/crates/icp-cli/src/commands/canister/settings/update.rs +++ b/crates/icp-cli/src/commands/canister/settings/update.rs @@ -1,11 +1,10 @@ use anyhow::bail; use byte_unit::{Byte, Unit}; use clap::{ArgAction, Args}; -use console::Term; 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; @@ -413,7 +412,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 d357171c..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,43 +6,6 @@ use tracing_subscriber::{ registry::LookupSpan, }; -#[expect(unused)] -#[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 a9371f22..17193eea 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, @@ -109,15 +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 = Term::stdout(); + let term = TermWriter { + debug: cli.debug, + raw_term: Term::stdout(), + }; // Logging and Telemetry let (debug_layer, event_layer) = ( 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/src/context/init.rs b/crates/icp/src/context/init.rs index 8f7bd70b..19f746d8 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 { diff --git a/crates/icp/src/context/mod.rs b/crates/icp/src/context/mod.rs index c954433a..84ec9c82 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::debug; use url::Url; use crate::{ @@ -72,7 +73,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, @@ -480,7 +481,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()), @@ -495,6 +499,49 @@ 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 { + writeln!(&self.raw_term, "{}", line)?; + } + debug!("{}", 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 { + (&self.raw_term).write(buf)?; + } + debug!("{}", String::from_utf8_lossy(buf).trim()); + Ok(buf.len()) + } + + 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"))] From 633c78072b1683553a4cc2a4e294f4d27ce313ef Mon Sep 17 00:00:00 2001 From: Adam Spofford Date: Thu, 15 Jan 2026 10:14:31 -0800 Subject: [PATCH 16/59] use ctrl-break for process groups --- Cargo.lock | 11 +++++++++++ crates/icp-cli/Cargo.toml | 5 ++++- crates/icp-cli/tests/common/context.rs | 6 +++--- crates/icp-cli/tests/common/mod.rs | 19 ++++++++++++++++--- crates/icp/src/network/managed/run.rs | 26 ++++++++++++++++++++++++-- 5 files changed, 58 insertions(+), 9 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 5ec18789..ee3f26a7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3225,6 +3225,7 @@ dependencies = [ "regex", "reqwest", "sec1", + "send_ctrlc", "serde", "serde_json", "serde_yaml", @@ -5624,6 +5625,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" diff --git a/crates/icp-cli/Cargo.toml b/crates/icp-cli/Cargo.toml index 25e8883a..fc8370d9 100644 --- a/crates/icp-cli/Cargo.toml +++ b/crates/icp-cli/Cargo.toml @@ -65,14 +65,17 @@ 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/tests/common/context.rs b/crates/icp-cli/tests/common/context.rs index ae12d871..ab391c93 100644 --- a/crates/icp-cli/tests/common/context.rs +++ b/crates/icp-cli/tests/common/context.rs @@ -255,7 +255,8 @@ impl TestContext { } 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 @@ -315,8 +316,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) { 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/src/network/managed/run.rs b/crates/icp/src/network/managed/run.rs index 742d49e4..813b8fbf 100644 --- a/crates/icp/src/network/managed/run.rs +++ b/crates/icp/src/network/managed/run.rs @@ -345,13 +345,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 } @@ -364,6 +364,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 { From c0f534963e49db650c6c7cddb22008408a972481 Mon Sep 17 00:00:00 2001 From: Adam Spofford Date: Thu, 15 Jan 2026 10:31:46 -0800 Subject: [PATCH 17/59] quote filenames in templated sh calls --- crates/icp-cli/tests/build_tests.rs | 18 +++++++++--------- crates/icp-cli/tests/canister_delete_tests.rs | 2 +- crates/icp-cli/tests/canister_info_tests.rs | 2 +- crates/icp-cli/tests/canister_install_tests.rs | 8 ++++++-- .../icp-cli/tests/canister_settings_tests.rs | 16 ++++++++-------- crates/icp-cli/tests/canister_start_tests.rs | 2 +- crates/icp-cli/tests/canister_status_tests.rs | 2 +- crates/icp-cli/tests/canister_stop_tests.rs | 2 +- crates/icp-cli/tests/deploy_tests.rs | 6 +++--- crates/icp-cli/tests/identity_tests.rs | 2 +- crates/icp-cli/tests/network_tests.rs | 2 +- crates/icp-cli/tests/project_tests.rs | 6 +++--- crates/icp-cli/tests/sync_tests.rs | 16 ++++++++-------- 13 files changed, 44 insertions(+), 40 deletions(-) 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_settings_tests.rs b/crates/icp-cli/tests/canister_settings_tests.rs index 9260c882..c2c2626c 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} @@ -339,7 +339,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} @@ -656,7 +656,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} @@ -779,7 +779,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} @@ -964,7 +964,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} @@ -1033,7 +1033,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} @@ -1046,7 +1046,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: ~ @@ -1060,7 +1060,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/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_tests.rs b/crates/icp-cli/tests/network_tests.rs index bbebf43b..4a217ee6 100644 --- a/crates/icp-cli/tests/network_tests.rs +++ b/crates/icp-cli/tests/network_tests.rs @@ -165,7 +165,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 diff --git a/crates/icp-cli/tests/project_tests.rs b/crates/icp-cli/tests/project_tests.rs index fae8fa38..2fde5e32 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 From a7d87f3f5a0dea0a63e173398eea26405f618f61 Mon Sep 17 00:00:00 2001 From: Adam Spofford Date: Thu, 15 Jan 2026 10:54:36 -0800 Subject: [PATCH 18/59] dockerate start_network_with_flags --- Cargo.lock | 1 + crates/icp-cli/Cargo.toml | 1 + crates/icp-cli/tests/common/context.rs | 96 ++++++++++++++++---- crates/icp-cli/tests/network_status_tests.rs | 6 +- 4 files changed, 83 insertions(+), 21 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index ee3f26a7..09982ebc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3231,6 +3231,7 @@ dependencies = [ "serde_yaml", "serial_test", "sha2 0.10.9", + "shellwords", "snafu", "sysinfo", "test-tag", diff --git a/crates/icp-cli/Cargo.toml b/crates/icp-cli/Cargo.toml index fc8370d9..04223928 100644 --- a/crates/icp-cli/Cargo.toml +++ b/crates/icp-cli/Cargo.toml @@ -52,6 +52,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 diff --git a/crates/icp-cli/tests/common/context.rs b/crates/icp-cli/tests/common/context.rs index ab391c93..b13d6448 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; @@ -80,6 +81,7 @@ impl TestContext { cmd } + #[cfg(unix)] pub(crate) async fn launcher_path(&self) -> PathBuf { if let Ok(var) = env::var("ICP_CLI_NETWORK_LAUNCHER_PATH") { PathBuf::from(var) @@ -112,6 +114,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(); @@ -180,9 +193,11 @@ impl TestContext { .arg("network") .arg("start") .arg(name); - - let launcher_path = self.launcher_path().await; - cmd.env("ICP_CLI_NETWORK_LAUNCHER_PATH", launcher_path); + #[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 +237,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,18 +254,38 @@ 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 mut cmd = std::process::Command::new("docker"); + cmd.args([ + "run", + "--rm", + "-v", + &format!("{launcher_dir}:/app/status"), + "--cidfile", + ]); + cmd.arg(network_dir.join("container-id").as_std_path()); + cmd.args(["-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 guard = ChildGuard::spawn(&mut cmd).expect("Failed to spawn network launcher"); @@ -261,7 +294,34 @@ impl TestContext { // 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 { 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( From 879fc4f91fe78891d507939c9cfaa45b74801bdf Mon Sep 17 00:00:00 2001 From: Adam Spofford Date: Thu, 15 Jan 2026 11:14:41 -0800 Subject: [PATCH 19/59] you can't override $HOME so use portable mode --- crates/icp-cli/tests/common/context.rs | 24 +++++++++++++++--------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/crates/icp-cli/tests/common/context.rs b/crates/icp-cli/tests/common/context.rs index b13d6448..483ed081 100644 --- a/crates/icp-cli/tests/common/context.rs +++ b/crates/icp-cli/tests/common/context.rs @@ -11,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, @@ -48,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 @@ -73,9 +76,11 @@ impl TestContext { // Isolate the command cmd.current_dir(self.home_path()); - cmd.env("HOME", self.home_path()); + #[cfg(unix)] + cmd.env("HOME", self.home_path()).env_remove("ICP_HOME"); + #[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 @@ -83,6 +88,7 @@ impl TestContext { #[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 { @@ -187,12 +193,12 @@ 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); + cmd.current_dir(project_dir); + #[cfg(unix)] + cmd.env("HOME", self.home_path()).env_remove("ICP_HOME"); + #[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; From 3106c80b5e0166d26aec218b6c9a3c73d6998e6a Mon Sep 17 00:00:00 2001 From: Adam Spofford Date: Thu, 15 Jan 2026 11:59:22 -0800 Subject: [PATCH 20/59] print port for all sources --- crates/icp/src/network/managed/run.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/crates/icp/src/network/managed/run.rs b/crates/icp/src/network/managed/run.rs index 813b8fbf..09ed02d8 100644 --- a/crates/icp/src/network/managed/run.rs +++ b/crates/icp/src/network/managed/run.rs @@ -185,10 +185,6 @@ 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(_)), @@ -196,6 +192,10 @@ async fn run_network_launcher( (ShutdownGuard::Process(child), instance, gateway, locator) } }; + 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() From bb153fe45bc765d78e6361987dddf1749b8db875 Mon Sep 17 00:00:00 2001 From: Adam Spofford Date: Thu, 15 Jan 2026 12:03:01 -0800 Subject: [PATCH 21/59] remove unc prefix from expected path --- Cargo.lock | 1 + crates/icp-cli/Cargo.toml | 1 + crates/icp-cli/tests/network_tests.rs | 2 +- 3 files changed, 3 insertions(+), 1 deletion(-) diff --git a/Cargo.lock b/Cargo.lock index 09982ebc..204571dd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3198,6 +3198,7 @@ dependencies = [ "clap-markdown", "console 0.16.1", "dialoguer 0.11.0", + "dunce", "elliptic-curve", "futures", "hex", diff --git a/crates/icp-cli/Cargo.toml b/crates/icp-cli/Cargo.toml index 04223928..4d647ad7 100644 --- a/crates/icp-cli/Cargo.toml +++ b/crates/icp-cli/Cargo.toml @@ -25,6 +25,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 diff --git a/crates/icp-cli/tests/network_tests.rs b/crates/icp-cli/tests/network_tests.rs index 4a217ee6..7eb69bab 100644 --- a/crates/icp-cli/tests/network_tests.rs +++ b/crates/icp-cli/tests/network_tests.rs @@ -72,7 +72,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() ))); } From 5d7e2d2d335044f529ea483b756bfe221dfc4279 Mon Sep 17 00:00:00 2001 From: Adam Spofford Date: Thu, 15 Jan 2026 12:11:33 -0800 Subject: [PATCH 22/59] windows stops use container id --- crates/icp-cli/tests/network_tests.rs | 72 ++++++++++++++++++++------- 1 file changed, 53 insertions(+), 19 deletions(-) diff --git a/crates/icp-cli/tests/network_tests.rs b/crates/icp-cli/tests/network_tests.rs index 7eb69bab..71f6e20c 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::{ @@ -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; From 3921a421ddbeb233a18da87d11c055363f6d94e1 Mon Sep 17 00:00:00 2001 From: Adam Spofford Date: Thu, 15 Jan 2026 12:28:35 -0800 Subject: [PATCH 23/59] Add windows to e2e matrix --- .github/scripts/provision-windows-build.ps1 | 13 +++++++++++++ .github/scripts/test-matrix.py | 6 +++++- .github/workflows/test.yml | 18 +++++++++++++++++- 3 files changed, 35 insertions(+), 2 deletions(-) create mode 100644 .github/scripts/provision-windows-build.ps1 diff --git a/.github/scripts/provision-windows-build.ps1 b/.github/scripts/provision-windows-build.ps1 new file mode 100644 index 00000000..c1ebf18f --- /dev/null +++ b/.github/scripts/provision-windows-build.ps1 @@ -0,0 +1,13 @@ +#Requires -Version 7.3 +$ErrorActionPreference = 'Stop' +$PSNativeCommandUseErrorActionPreference = $true + +# enormous speedup in CI, but not mandatory +cd C:\vcpkg +git checkout 2025.12.12 +.\bootstrap-vcpkg.bat -disableMetrics +$env:VCPKG_BUILD_TYPE = 'release' +.\vcpkg install openssl:x64-windows-static-md +.\vcpkg integrate install +'OPENSSL_STATIC=1' >> $env:GITHUB_ENV +'OPENSSL_NO_VENDOR=1' >> $env:GITHUB_ENV diff --git a/.github/scripts/test-matrix.py b/.github/scripts/test-matrix.py index ad143232..82888b45 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-2022" + }) # macOS: only run selected tests if test in MACOS_TESTS: diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index b33079cc..68450b2a 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -12,6 +12,10 @@ env: # Use the local .curlrc CURL_HOME: . +defaults: + run: + shell: bash + jobs: discover: runs-on: ubuntu-latest @@ -30,7 +34,7 @@ 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-2022] steps: - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 @@ -38,6 +42,10 @@ jobs: - name: Setup image (Linux) if: ${{ contains(matrix.os, 'ubuntu') }} run: ./.github/scripts/provision-linux-build.sh + - name: Setup image (Windows) + if: ${{ contains(matrix.os, 'windows') }} + run: ./.github/scripts/provision-windows-build.ps1 + shell: pwsh - uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 with: @@ -48,6 +56,7 @@ jobs: ~/.cargo/registry/cache/ ~/.cargo/git/db/ target/ + ${{ contains(matrix.os, 'windows') && 'C:/vcpkg' || '' }} key: ${{ matrix.os }}-cargo-${{ hashFiles('rust-toolchain.toml') }}-${{ hashFiles('**/Cargo.lock') }} - uses: t1m0thyj/unlock-keyring@e481cdc8833d4417a58f40734e8f197183317047 @@ -72,6 +81,11 @@ jobs: if: ${{ contains(matrix.os, 'ubuntu') }} run: ./.github/scripts/provision-linux-build.sh + - name: Setup image (Windows) + if: ${{ contains(matrix.os, 'windows') }} + run: ./.github/scripts/provision-windows-build.ps1 + shell: pwsh + - uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 with: path: | @@ -81,6 +95,7 @@ jobs: ~/.cargo/registry/cache/ ~/.cargo/git/db/ target/ + ${{ contains(matrix.os, 'windows') && 'C:/vcpkg' || '' }} key: ${{ matrix.os }}-cargo-${{ hashFiles('rust-toolchain.toml') }}-${{ hashFiles('**/Cargo.lock') }} - uses: t1m0thyj/unlock-keyring@e481cdc8833d4417a58f40734e8f197183317047 @@ -105,6 +120,7 @@ jobs: - name: install network launcher env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + if: ${{ !contains(matrix.os, 'windows') }} run: | VERSION=v11.0.0 OS="" From ca35070cbb2ad6a73e88b495a6853c75eb91aa3b Mon Sep 17 00:00:00 2001 From: Adam Spofford Date: Thu, 15 Jan 2026 13:30:18 -0800 Subject: [PATCH 24/59] detect with winreg instead of pathsearch --- Cargo.lock | 22 +++++++++++----------- Cargo.toml | 2 +- crates/icp/Cargo.toml | 4 +++- crates/icp/src/canister/script.rs | 26 ++++++++++++++------------ 4 files changed, 29 insertions(+), 25 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 204571dd..346d4eb9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3143,7 +3143,6 @@ dependencies = [ "keyring", "notify", "p256", - "pathsearch", "pem 3.0.5", "pkcs8", "rand 0.9.2", @@ -3167,6 +3166,7 @@ dependencies = [ "tracing", "url", "uuid", + "winreg", "zeroize", ] @@ -4502,16 +4502,6 @@ version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" -[[package]] -name = "pathsearch" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da983bc5e582ab17179c190b4b66c7d76c5943a69c6d34df2a2b6bf8a2977b05" -dependencies = [ - "anyhow", - "libc", -] - [[package]] name = "pbkdf2" version = "0.12.2" @@ -7286,6 +7276,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" diff --git a/Cargo.toml b/Cargo.toml index 69b1bd23..0895a18e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -61,7 +61,6 @@ mockall = "0.13.1" notify = "8.2.0" p256 = { version = "0.13.2", features = ["pem", "pkcs8", "std"] } pathdiff = { version = "0.2.3", features = ["camino"] } -pathsearch = "0.2.0" pem = "3.0.5" phf = { version = "0.13.1", features = ["macros"] } pkcs8 = { version = "0.10.2", features = ["encryption", "std"] } @@ -86,6 +85,7 @@ tracing = "0.1.41" tracing-subscriber = "0.3.20" url = "2.5.4" uuid = { version = "1.16.0", features = ["serde", "v4"] } +winreg = "0.55" zeroize = "1.8.1" [workspace.dependencies.reqwest] diff --git a/crates/icp/Cargo.toml b/crates/icp/Cargo.toml index 0415f1f0..70b08575 100644 --- a/crates/icp/Cargo.toml +++ b/crates/icp/Cargo.toml @@ -39,7 +39,6 @@ k256 = { workspace = true } keyring = { workspace = true } notify = { workspace = true } p256 = { workspace = true } -pathsearch = { workspace = true } pem = { workspace = true } pkcs8 = { workspace = true } rand = { workspace = true } @@ -65,5 +64,8 @@ url = { workspace = true } uuid = { workspace = true } zeroize = { workspace = true } +[target.'cfg(windows)'.dependencies] +winreg = { workspace = true } + [dev-dependencies] jsonschema = { workspace = true } diff --git a/crates/icp/src/canister/script.rs b/crates/icp/src/canister/script.rs index 89d3c404..92cb52d3 100644 --- a/crates/icp/src/canister/script.rs +++ b/crates/icp/src/canister/script.rs @@ -172,22 +172,24 @@ fn shell_command(s: &str, cwd: &Path) -> Result { #[cfg(unix)] let mut cmd = Command::new("sh"); #[cfg(windows)] - let mut cmd = if let Some(_) = std::env::var_os("BASH_VERSION") { + let mut cmd = if let Some(_) = std::env::var_os("MSYSTEM") { Command::new("bash") } else { - if let Some(git_path) = pathsearch::find_executable_in_path("git") { - let git_path = - PathBuf::try_from(git_path.clone()).context(BadPathSnafu { path: git_path })?; - if let Some(cmd_path) = git_path.parent() - && cmd_path.ends_with("cmd") - { - Command::new(cmd_path.parent().unwrap().join("bin/bash.exe")) - } else { - return LocateBashSnafu { git_path }.fail(); - } + 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(git_for_windows_path.join("bin/bash.exe")) }; cmd.args(["-c", s]); cmd.current_dir(cwd); From 616170246fa5d3cb4ec518654099a55b8a82564a Mon Sep 17 00:00:00 2001 From: Adam Spofford Date: Thu, 15 Jan 2026 13:33:14 -0800 Subject: [PATCH 25/59] clean up --- crates/icp/src/canister/script.rs | 15 +-------------- 1 file changed, 1 insertion(+), 14 deletions(-) diff --git a/crates/icp/src/canister/script.rs b/crates/icp/src/canister/script.rs index 92cb52d3..d4e3ec14 100644 --- a/crates/icp/src/canister/script.rs +++ b/crates/icp/src/canister/script.rs @@ -37,22 +37,9 @@ pub enum ScriptError { #[snafu(display("command '{command}' failed with status code {code}"))] Status { command: String, code: String }, - #[cfg(windows)] - #[snafu(display( - "failed to locate bash (the git at {git_path} does not appear to be Git for Windows, try running in Git Bash)" - ))] - LocateBash { git_path: PathBuf }, - #[cfg(windows)] #[snafu(display("failed to locate git executable in PATH (try running in Git Bash)"))] LocateGit, - - #[cfg(windows)] - #[snafu(display("unprocessable executable path: {}", path.display()))] - BadPath { - path: std::path::PathBuf, - source: camino::FromPathBufError, - }, } pub(super) async fn execute( @@ -189,7 +176,7 @@ fn shell_command(s: &str, cwd: &Path) -> Result { } else { return LocateGitSnafu.fail(); }; - Command::new(git_for_windows_path.join("bin/bash.exe")) + Command::new(PathBuf::from(git_for_windows_path).join("bin/bash.exe")) }; cmd.args(["-c", s]); cmd.current_dir(cwd); From 51cb0c524a078effdbb47e474245ed42e1eebf6a Mon Sep 17 00:00:00 2001 From: Adam Spofford Date: Thu, 15 Jan 2026 13:50:07 -0800 Subject: [PATCH 26/59] Better message --- crates/icp/src/canister/script.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/icp/src/canister/script.rs b/crates/icp/src/canister/script.rs index d4e3ec14..e709fa4f 100644 --- a/crates/icp/src/canister/script.rs +++ b/crates/icp/src/canister/script.rs @@ -38,7 +38,9 @@ pub enum ScriptError { Status { command: String, code: String }, #[cfg(windows)] - #[snafu(display("failed to locate git executable in PATH (try running in Git Bash)"))] + #[snafu(display( + "failed to locate Git for Windows (try running in Git Bash or an MSYS2 shell)" + ))] LocateGit, } From 76be34fcb30489a0c1e6f05c237f02f37dc26ae9 Mon Sep 17 00:00:00 2001 From: Adam Spofford Date: Thu, 15 Jan 2026 14:07:28 -0800 Subject: [PATCH 27/59] Quote unit tests as well --- crates/icp-cli/tests/build_adapter_tests.rs | 6 +++--- crates/icp/src/canister/build/script.rs | 10 +++++----- 2 files changed, 8 insertions(+), 8 deletions(-) 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/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()), ]), }; From 77298a45f6944dc826775ff88bca5cb0f1558bb4 Mon Sep 17 00:00:00 2001 From: Adam Spofford Date: Thu, 15 Jan 2026 14:40:13 -0800 Subject: [PATCH 28/59] Never use unprefixed bash because it may be wsl bash --- crates/icp/src/canister/script.rs | 14 +++++++++++--- docs/getting-started.md | 4 ++++ 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/crates/icp/src/canister/script.rs b/crates/icp/src/canister/script.rs index e709fa4f..afb3b4d6 100644 --- a/crates/icp/src/canister/script.rs +++ b/crates/icp/src/canister/script.rs @@ -39,9 +39,14 @@ pub enum ScriptError { #[cfg(windows)] #[snafu(display( - "failed to locate Git for Windows (try running in Git Bash or an MSYS2 shell)" + "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( @@ -161,8 +166,11 @@ fn shell_command(s: &str, cwd: &Path) -> Result { #[cfg(unix)] let mut cmd = Command::new("sh"); #[cfg(windows)] - let mut cmd = if let Some(_) = std::env::var_os("MSYSTEM") { - Command::new("bash") + 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) diff --git a/docs/getting-started.md b/docs/getting-started.md index 776dffb7..df573964 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -254,6 +254,10 @@ Now that you have your first canister running, explore: - Ensure all required tools are installed and in PATH - Check language-specific prerequisites +**Build scripts fail on Windows** +- Build scripts require Git for Windows or MSYS2. If detection fails, set `ICP_CLI_BASH_PATH` to the path of your bash executable (e.g., `C:\Program Files\Git\bin\bash.exe` or `C:\msys64\usr\bin\bash.exe`) +- Note: WSL bash (`C:\Windows\System32\bash.exe`) is not supported + **Network connection fails** - Verify `icp network start` is running in another terminal - The network launcher is automatically downloaded on first use. If you experience issues, you can manually set `ICP_CLI_NETWORK_LAUNCHER_PATH` to a specific launcher binary for debugging From e03517c29032b7767c791eeea18c313ef0e52afb Mon Sep 17 00:00:00 2001 From: Adam Spofford Date: Thu, 15 Jan 2026 15:45:38 -0800 Subject: [PATCH 29/59] Cache before installing --- .github/workflows/test.yml | 34 +++++++++++++++++----------------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 68450b2a..45203720 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -39,14 +39,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 - - name: Setup image (Windows) - if: ${{ contains(matrix.os, 'windows') }} - run: ./.github/scripts/provision-windows-build.ps1 - shell: pwsh - - uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 with: path: | @@ -59,6 +51,14 @@ jobs: ${{ contains(matrix.os, 'windows') && 'C:/vcpkg' || '' }} 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 image (Windows) + if: ${{ contains(matrix.os, 'windows') }} + run: ./.github/scripts/provision-windows-build.ps1 + shell: pwsh + - uses: t1m0thyj/unlock-keyring@e481cdc8833d4417a58f40734e8f197183317047 if: ${{ contains(matrix.os, 'ubuntu') }} @@ -77,15 +77,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 - - - name: Setup image (Windows) - if: ${{ contains(matrix.os, 'windows') }} - run: ./.github/scripts/provision-windows-build.ps1 - shell: pwsh - - uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 with: path: | @@ -98,6 +89,15 @@ jobs: ${{ contains(matrix.os, 'windows') && 'C:/vcpkg' || '' }} 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 image (Windows) + if: ${{ contains(matrix.os, 'windows') }} + run: ./.github/scripts/provision-windows-build.ps1 + shell: pwsh + - uses: t1m0thyj/unlock-keyring@e481cdc8833d4417a58f40734e8f197183317047 if: ${{ contains(matrix.os, 'ubuntu') }} From 482dba0e2323ace69815edc9658807d670b46657 Mon Sep 17 00:00:00 2001 From: Adam Spofford Date: Thu, 15 Jan 2026 16:03:01 -0800 Subject: [PATCH 30/59] Add explicit arch to docker commands --- crates/icp-cli/tests/common/context.rs | 18 +++++++++++++++++- crates/icp/src/network/managed/docker.rs | 13 ++++++++++--- 2 files changed, 27 insertions(+), 4 deletions(-) diff --git a/crates/icp-cli/tests/common/context.rs b/crates/icp-cli/tests/common/context.rs index 483ed081..c1ead774 100644 --- a/crates/icp-cli/tests/common/context.rs +++ b/crates/icp-cli/tests/common/context.rs @@ -280,6 +280,12 @@ impl TestContext { cmd.args([ "run", "--rm", + "--platform", + if cfg!(target_arch = "aarch64") { + "linux/arm64" + } else { + "linux/amd64" + }, "-v", &format!("{launcher_dir}:/app/status"), "--cidfile", @@ -491,8 +497,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/src/network/managed/docker.rs b/crates/icp/src/network/managed/docker.rs index 46dad327..e7eb4337 100644 --- a/crates/icp/src/network/managed/docker.rs +++ b/crates/icp/src/network/managed/docker.rs @@ -42,6 +42,13 @@ pub async fn spawn_docker_launcher( status_dir, mounts, } = image_config; + 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 host_status_dir = Utf8TempDir::new().context(CreateStatusDirSnafu)?; let socket = match std::env::var("DOCKER_HOST").ok() { Some(sock) => sock, @@ -139,7 +146,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 +161,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 { From 61c68f84738ffb5beb295b99663ccb3fcd1d2a89 Mon Sep 17 00:00:00 2001 From: Adam Spofford Date: Thu, 15 Jan 2026 16:55:26 -0800 Subject: [PATCH 31/59] Set up docker with tcp --- .github/scripts/setup-wsl2-docker.ps1 | 26 ++++++++++++++++++++++++ .github/workflows/test.yml | 5 +++++ crates/icp/src/network/managed/docker.rs | 9 ++++++-- 3 files changed, 38 insertions(+), 2 deletions(-) create mode 100644 .github/scripts/setup-wsl2-docker.ps1 diff --git a/.github/scripts/setup-wsl2-docker.ps1 b/.github/scripts/setup-wsl2-docker.ps1 new file mode 100644 index 00000000..bdd163cb --- /dev/null +++ b/.github/scripts/setup-wsl2-docker.ps1 @@ -0,0 +1,26 @@ +#Requires -Version 7.3 +$ErrorActionPreference = "Stop" +$PSNativeCommandUseErrorActionPreference = $true + +wsl --install -d Ubuntu-22.04 --no-launch +wsl --set-default-version 2 + +ubuntu2204.exe install --root + +wsl -d Ubuntu-22.04 -u root -- bash -c @' +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 + exit 0 + fi + sleep 1 +done +cat /var/log/dockerd.log +exit 1 +'@ + +'DOCKER_HOST=tcp://localhost:2375' >> $env:GITHUB_ENV diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 45203720..dd0e1e27 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -98,6 +98,11 @@ jobs: run: ./.github/scripts/provision-windows-build.ps1 shell: pwsh + - name: Setup Docker in WSL2 (Windows) + if: ${{ contains(matrix.os, 'windows') }} + run: .github/scripts/setup-wsl2-docker.ps1 + shell: pwsh + - uses: t1m0thyj/unlock-keyring@e481cdc8833d4417a58f40734e8f197183317047 if: ${{ contains(matrix.os, 'ubuntu') }} diff --git a/crates/icp/src/network/managed/docker.rs b/crates/icp/src/network/managed/docker.rs index e7eb4337..a1554a77 100644 --- a/crates/icp/src/network/managed/docker.rs +++ b/crates/icp/src/network/managed/docker.rs @@ -84,8 +84,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| { From bd47875e30a147f4c75de2868808d6985527b464 Mon Sep 17 00:00:00 2001 From: Adam Spofford Date: Thu, 15 Jan 2026 17:09:13 -0800 Subject: [PATCH 32/59] . --- .github/scripts/setup-wsl2-docker.ps1 | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/scripts/setup-wsl2-docker.ps1 b/.github/scripts/setup-wsl2-docker.ps1 index bdd163cb..d8d7f39f 100644 --- a/.github/scripts/setup-wsl2-docker.ps1 +++ b/.github/scripts/setup-wsl2-docker.ps1 @@ -2,8 +2,9 @@ $ErrorActionPreference = "Stop" $PSNativeCommandUseErrorActionPreference = $true -wsl --install -d Ubuntu-22.04 --no-launch +wsl --update wsl --set-default-version 2 +wsl --install -d Ubuntu-22.04 --no-launch ubuntu2204.exe install --root From 4a1b0b8882afd6e2c33683e2ac518dbd6926df6f Mon Sep 17 00:00:00 2001 From: Adam Spofford Date: Thu, 15 Jan 2026 17:33:33 -0800 Subject: [PATCH 33/59] There's an action for this --- .../{setup-wsl2-docker.ps1 => init-docker.sh} | 16 ++-------------- .github/scripts/provision-windows-build.ps1 | 2 ++ .github/workflows/test.yml | 10 ++++++++-- 3 files changed, 12 insertions(+), 16 deletions(-) rename .github/scripts/{setup-wsl2-docker.ps1 => init-docker.sh} (51%) diff --git a/.github/scripts/setup-wsl2-docker.ps1 b/.github/scripts/init-docker.sh similarity index 51% rename from .github/scripts/setup-wsl2-docker.ps1 rename to .github/scripts/init-docker.sh index d8d7f39f..6825f478 100644 --- a/.github/scripts/setup-wsl2-docker.ps1 +++ b/.github/scripts/init-docker.sh @@ -1,14 +1,4 @@ -#Requires -Version 7.3 -$ErrorActionPreference = "Stop" -$PSNativeCommandUseErrorActionPreference = $true - -wsl --update -wsl --set-default-version 2 -wsl --install -d Ubuntu-22.04 --no-launch - -ubuntu2204.exe install --root - -wsl -d Ubuntu-22.04 -u root -- bash -c @' +#!/usr/bin/env bash set -ex apt-get update DEBIAN_FRONTEND=noninteractive apt-get install -y docker.io @@ -16,12 +6,10 @@ 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 >> $GITHUB_ENV exit 0 fi sleep 1 done cat /var/log/dockerd.log exit 1 -'@ - -'DOCKER_HOST=tcp://localhost:2375' >> $env:GITHUB_ENV diff --git a/.github/scripts/provision-windows-build.ps1 b/.github/scripts/provision-windows-build.ps1 index c1ebf18f..67872fc5 100644 --- a/.github/scripts/provision-windows-build.ps1 +++ b/.github/scripts/provision-windows-build.ps1 @@ -11,3 +11,5 @@ $env:VCPKG_BUILD_TYPE = 'release' .\vcpkg integrate install 'OPENSSL_STATIC=1' >> $env:GITHUB_ENV 'OPENSSL_NO_VENDOR=1' >> $env:GITHUB_ENV + +'WSLENV=GITHUB_ENV/p' >> $env:GITHUB_ENV diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index dd0e1e27..b911b000 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -98,10 +98,16 @@ jobs: run: ./.github/scripts/provision-windows-build.ps1 shell: pwsh + - 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/setup-wsl2-docker.ps1 - shell: pwsh + run: .github/scripts/init-docker.sh + shell: wsl-bash_Ubuntu-22.04 {0} - uses: t1m0thyj/unlock-keyring@e481cdc8833d4417a58f40734e8f197183317047 if: ${{ contains(matrix.os, 'ubuntu') }} From 6a2843fde1b6c752c47b0fe094d5b0728082168e Mon Sep 17 00:00:00 2001 From: Adam Spofford Date: Thu, 15 Jan 2026 17:55:32 -0800 Subject: [PATCH 34/59] Normalize lf --- .gitattributes | 1 + 1 file changed, 1 insertion(+) create mode 100644 .gitattributes diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..6313b56c --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +* text=auto eol=lf From c63a16e20c9dd9a35d21f15f6dabd51e61fdc7ef Mon Sep 17 00:00:00 2001 From: Adam Spofford Date: Thu, 15 Jan 2026 18:56:55 -0800 Subject: [PATCH 35/59] Add envvar for WSL2 docker path conversion --- .github/scripts/init-docker.sh | 5 +- Cargo.lock | 8 ++++ Cargo.toml | 1 + crates/icp-cli/Cargo.toml | 1 + crates/icp-cli/tests/common/context.rs | 19 ++++++-- crates/icp/Cargo.toml | 1 + crates/icp/src/network/managed/docker.rs | 58 ++++++++++++++++++++++-- 7 files changed, 86 insertions(+), 7 deletions(-) diff --git a/.github/scripts/init-docker.sh b/.github/scripts/init-docker.sh index 6825f478..2ba4180c 100644 --- a/.github/scripts/init-docker.sh +++ b/.github/scripts/init-docker.sh @@ -6,7 +6,10 @@ 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 >> $GITHUB_ENV + { + echo DOCKER_HOST=tcp://127.0.0.1:2375 + echo ICP_CLI_DOCKER_WSL2_MODE=1 + } >> $GITHUB_ENV exit 0 fi sleep 1 diff --git a/Cargo.lock b/Cargo.lock index 346d4eb9..9e750596 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3167,6 +3167,7 @@ dependencies = [ "url", "uuid", "winreg", + "wslpath2", "zeroize", ] @@ -3242,6 +3243,7 @@ dependencies = [ "tracing-subscriber", "url", "uuid", + "wslpath2", ] [[package]] @@ -7298,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 0895a18e..aaaac847 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -86,6 +86,7 @@ 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/crates/icp-cli/Cargo.toml b/crates/icp-cli/Cargo.toml index 4d647ad7..ea69a813 100644 --- a/crates/icp-cli/Cargo.toml +++ b/crates/icp-cli/Cargo.toml @@ -61,6 +61,7 @@ tokio.workspace = true tracing-subscriber.workspace = true tracing.workspace = true url.workspace = true +wslpath2.workspace = true [dev-dependencies] assert_cmd = "2" diff --git a/crates/icp-cli/tests/common/context.rs b/crates/icp-cli/tests/common/context.rs index c1ead774..a1ebf3b5 100644 --- a/crates/icp-cli/tests/common/context.rs +++ b/crates/icp-cli/tests/common/context.rs @@ -276,6 +276,18 @@ impl TestContext { } #[cfg(windows)] { + let convert = std::env::var("ICP_CLI_DOCKER_WSL2_MODE").unwrap_or_default() == "1"; + let launcher_dir_param = if convert { + wslpath2::convert( + launcher_dir.as_str(), + None, + 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", @@ -287,11 +299,12 @@ impl TestContext { "linux/amd64" }, "-v", - &format!("{launcher_dir}:/app/status"), + &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.arg(network_dir.join("container-id").as_std_path()); - cmd.args(["-P", "ghcr.io/dfinity/icp-cli-network-launcher:v11.0.0"]); cmd.args(flags); cmd.stdout(Stdio::inherit()); cmd.stderr(Stdio::inherit()); diff --git a/crates/icp/Cargo.toml b/crates/icp/Cargo.toml index 70b08575..6a9d9b70 100644 --- a/crates/icp/Cargo.toml +++ b/crates/icp/Cargo.toml @@ -62,6 +62,7 @@ tokio = { workspace = true, features = ["full"] } tracing = { workspace = true } url = { workspace = true } uuid = { workspace = true } +wslpath2 = { workspace = true } zeroize = { workspace = true } [target.'cfg(windows)'.dependencies] diff --git a/crates/icp/src/network/managed/docker.rs b/crates/icp/src/network/managed/docker.rs index a1554a77..43d0ba2e 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, @@ -49,7 +50,10 @@ pub async fn spawn_docker_launcher( } else { "linux/amd64".to_string() }; + let wsl2_convert = + cfg!(windows) && std::env::var("ICP_CLI_DOCKER_WSL2_MODE").is_ok_and(|v| v == "1"); let host_status_dir = Utf8TempDir::new().context(CreateStatusDirSnafu)?; + let host_status_dir_param = convert_path(wsl2_convert, host_status_dir.path())?; let socket = match std::env::var("DOCKER_HOST").ok() { Some(sock) => sock, #[cfg(unix)] @@ -113,6 +117,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, &host)?; let (target, flags) = match rest.split_once(':') { Some((t, f)) => (t, Some(f)), None => (rest, None), @@ -126,7 +131,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() @@ -134,7 +139,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() @@ -188,7 +193,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, v)) + .try_collect()?, + ), shm_size: *shm_size, ..<_>::default() }), @@ -360,6 +370,46 @@ pub enum StopContainerError { }, } +fn convert_path(convert: bool, path: &Path) -> Result { + if convert { + wslpath2::convert(path.as_str(), None, 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, 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, &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?)"))] @@ -471,6 +521,8 @@ pub enum DockerLauncherError { source: std::str::Utf8Error, display: String, }, + #[snafu(display("failed to convert path to WSL2: {msg}"))] + WslPathConvertError { msg: String, path: PathBuf }, } #[derive(Default)] From f23616d05c9f48e316f776063538c52964231124 Mon Sep 17 00:00:00 2001 From: Adam Spofford Date: Thu, 15 Jan 2026 20:15:47 -0800 Subject: [PATCH 36/59] Reproduce tcp check in stop --- crates/icp/src/network/managed/docker.rs | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/crates/icp/src/network/managed/docker.rs b/crates/icp/src/network/managed/docker.rs index 43d0ba2e..4e37321b 100644 --- a/crates/icp/src/network/managed/docker.rs +++ b/crates/icp/src/network/managed/docker.rs @@ -328,8 +328,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 } From 20232b94220208421b903e071ee2411e1fc7e3a0 Mon Sep 17 00:00:00 2001 From: Adam Spofford Date: Fri, 16 Jan 2026 12:34:01 -0800 Subject: [PATCH 37/59] Fix merged tests --- crates/icp-cli/tests/canister_metadata_tests.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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} From c541ec0ad2071f36eb293265a30ebfcddea0847c Mon Sep 17 00:00:00 2001 From: Adam Spofford Date: Fri, 16 Jan 2026 12:41:49 -0800 Subject: [PATCH 38/59] Remove 8.3 names from cwd check --- crates/icp/src/context/init.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/icp/src/context/init.rs b/crates/icp/src/context/init.rs index 19f746d8..2af6719e 100644 --- a/crates/icp/src/context/init.rs +++ b/crates/icp/src/context/init.rs @@ -41,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 From 514f74e2ec610cf1b44eaeeb24cd61cd88472a1e Mon Sep 17 00:00:00 2001 From: Adam Spofford Date: Fri, 16 Jan 2026 12:55:27 -0800 Subject: [PATCH 39/59] Update to windows-2025 to remove the need for `wsl --update` --- .github/scripts/test-matrix.py | 2 +- .github/workflows/test.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/scripts/test-matrix.py b/.github/scripts/test-matrix.py index 82888b45..87aab9d5 100644 --- a/.github/scripts/test-matrix.py +++ b/.github/scripts/test-matrix.py @@ -21,7 +21,7 @@ def test_names(): }) include.append({ "test": test, - "os": "windows-2022" + "os": "windows-2025" }) # macOS: only run selected tests diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index b911b000..7d0e2a4a 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -34,7 +34,7 @@ jobs: matrix: # Make the os matrix match .github/scripts/test-matrix.py so that # we optimize caching - os: [ubuntu-22.04, macos-15, windows-2022] + os: [ubuntu-22.04, macos-15, windows-2025] steps: - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 From 5e5c09546b1675fb19cc8be707c9197e36ff31be Mon Sep 17 00:00:00 2001 From: Adam Spofford Date: Fri, 16 Jan 2026 14:49:07 -0800 Subject: [PATCH 40/59] Shrink cache --- .github/workflows/test.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 7d0e2a4a..b4036d72 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -11,6 +11,8 @@ env: CARGO_NET_RETRY: 10 # Use the local .curlrc CURL_HOME: . + # Disable incremental compilation + CARGO_INCREMENTAL: 0 defaults: run: @@ -86,7 +88,7 @@ jobs: ~/.cargo/registry/cache/ ~/.cargo/git/db/ target/ - ${{ contains(matrix.os, 'windows') && 'C:/vcpkg' || '' }} + ${{ contains(matrix.os, 'windows') && 'C:/vcpkg/installed' || '' }} key: ${{ matrix.os }}-cargo-${{ hashFiles('rust-toolchain.toml') }}-${{ hashFiles('**/Cargo.lock') }} - name: Setup image (Linux) From 88a9ac272b07557f81959262bf96332b5eaeb731 Mon Sep 17 00:00:00 2001 From: Adam Spofford Date: Fri, 16 Jan 2026 20:43:00 -0800 Subject: [PATCH 41/59] merge broke the build --- crates/icp/src/project.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/icp/src/project.rs b/crates/icp/src/project.rs index 65b204e3..b4163b5f 100644 --- a/crates/icp/src/project.rs +++ b/crates/icp/src/project.rs @@ -13,7 +13,7 @@ use crate::{ load_manifest_from_path, recipe::RecipeType, }, - network::{Configuration, Connected, Managed, ManagedMode}, + network::{Configuration, Connected, Gateway, Managed, ManagedMode, Port}, prelude::*, }; From 27042011fbbac33a22934455c4fca23165713069 Mon Sep 17 00:00:00 2001 From: Adam Spofford Date: Sat, 17 Jan 2026 02:20:41 -0800 Subject: [PATCH 42/59] Move status dir to dockerd host when in wsl mode --- .github/scripts/init-docker.sh | 2 +- .github/workflows/test.yml | 2 ++ crates/icp/src/network/managed/docker.rs | 34 +++++++++++++++++------- 3 files changed, 27 insertions(+), 11 deletions(-) diff --git a/.github/scripts/init-docker.sh b/.github/scripts/init-docker.sh index 2ba4180c..c7d97f87 100644 --- a/.github/scripts/init-docker.sh +++ b/.github/scripts/init-docker.sh @@ -8,7 +8,7 @@ for i in $(seq 1 30); do echo Docker ready { echo DOCKER_HOST=tcp://127.0.0.1:2375 - echo ICP_CLI_DOCKER_WSL2_MODE=1 + echo ICP_CLI_DOCKER_WSL2_MODE="$DISTRO" } >> $GITHUB_ENV exit 0 fi diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index b4036d72..a801694c 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -109,6 +109,8 @@ jobs: - name: Setup Docker in WSL2 (Windows) if: ${{ contains(matrix.os, 'windows') }} run: .github/scripts/init-docker.sh + env: + DISTRO: Ubuntu-22.04 shell: wsl-bash_Ubuntu-22.04 {0} - uses: t1m0thyj/unlock-keyring@e481cdc8833d4417a58f40734e8f197183317047 diff --git a/crates/icp/src/network/managed/docker.rs b/crates/icp/src/network/managed/docker.rs index 4e37321b..a2470569 100644 --- a/crates/icp/src/network/managed/docker.rs +++ b/crates/icp/src/network/managed/docker.rs @@ -50,10 +50,16 @@ pub async fn spawn_docker_launcher( } else { "linux/amd64".to_string() }; - let wsl2_convert = - cfg!(windows) && std::env::var("ICP_CLI_DOCKER_WSL2_MODE").is_ok_and(|v| v == "1"); - let host_status_dir = Utf8TempDir::new().context(CreateStatusDirSnafu)?; - let host_status_dir_param = convert_path(wsl2_convert, host_status_dir.path())?; + let wsl2_distro = std::env::var("ICP_CLI_DOCKER_WSL2_MODE").ok(); + let wsl2_distro = wsl2_distro.as_deref(); + let wsl2_convert = cfg!(windows) && wsl2_distro.is_some(); + let host_status_dir = if wsl2_convert { + Utf8TempDir::new_in(format!(r"\\wsl$\{}\tmp", wsl2_distro.unwrap())) + .context(CreateStatusDirSnafu)? + } else { + Utf8TempDir::new().context(CreateStatusDirSnafu)? + }; + let host_status_dir_param = convert_path(wsl2_convert, wsl2_distro, host_status_dir.path())?; let socket = match std::env::var("DOCKER_HOST").ok() { Some(sock) => sock, #[cfg(unix)] @@ -117,7 +123,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, &host)?; + 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), @@ -196,7 +202,7 @@ pub async fn spawn_docker_launcher( binds: Some( volumes .iter() - .map(|v| convert_volume(wsl2_convert, v)) + .map(|v| convert_volume(wsl2_convert, wsl2_distro, v)) .try_collect()?, ), shm_size: *shm_size, @@ -375,9 +381,13 @@ pub enum StopContainerError { }, } -fn convert_path(convert: bool, path: &Path) -> Result { +fn convert_path( + convert: bool, + distro: Option<&str>, + path: &Path, +) -> Result { if convert { - wslpath2::convert(path.as_str(), None, Conversion::WindowsToWsl, true).map_err(|e| { + wslpath2::convert(path.as_str(), distro, Conversion::WindowsToWsl, true).map_err(|e| { WslPathConvertSnafu { msg: e.to_string(), path: path.to_path_buf(), @@ -389,7 +399,11 @@ fn convert_path(convert: bool, path: &Path) -> Result Result { +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() == ':' @@ -408,7 +422,7 @@ fn convert_volume(convert: bool, volume: &str) -> Result Date: Sat, 17 Jan 2026 02:56:08 -0800 Subject: [PATCH 43/59] Always use wslpath --- crates/icp/src/network/managed/docker.rs | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/crates/icp/src/network/managed/docker.rs b/crates/icp/src/network/managed/docker.rs index a2470569..d3cb0d73 100644 --- a/crates/icp/src/network/managed/docker.rs +++ b/crates/icp/src/network/managed/docker.rs @@ -53,13 +53,21 @@ pub async fn spawn_docker_launcher( let wsl2_distro = std::env::var("ICP_CLI_DOCKER_WSL2_MODE").ok(); let wsl2_distro = wsl2_distro.as_deref(); let wsl2_convert = cfg!(windows) && wsl2_distro.is_some(); - let host_status_dir = if wsl2_convert { - Utf8TempDir::new_in(format!(r"\\wsl$\{}\tmp", wsl2_distro.unwrap())) - .context(CreateStatusDirSnafu)? + let host_status_tmpdir = if wsl2_convert { + let host_tmp = wslpath2::convert("/tmp", wsl2_distro, Conversion::WslToWindows, true) + .map_err(|e| { + WslPathConvertSnafu { + msg: e.to_string(), + path: "/tmp", + } + .build() + })?; + Utf8TempDir::new_in(&host_tmp).context(WslCreateTmpDirSnafu)? } else { Utf8TempDir::new().context(CreateStatusDirSnafu)? }; - let host_status_dir_param = convert_path(wsl2_convert, wsl2_distro, host_status_dir.path())?; + 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)] @@ -222,7 +230,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 @@ -542,6 +550,8 @@ pub enum DockerLauncherError { }, #[snafu(display("failed to convert 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)] From af82b6be5f575596724dc261471df193375d66a7 Mon Sep 17 00:00:00 2001 From: Adam Spofford Date: Tue, 20 Jan 2026 08:29:35 -0800 Subject: [PATCH 44/59] better msg --- crates/icp/src/network/managed/docker.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/icp/src/network/managed/docker.rs b/crates/icp/src/network/managed/docker.rs index d3cb0d73..7759218d 100644 --- a/crates/icp/src/network/managed/docker.rs +++ b/crates/icp/src/network/managed/docker.rs @@ -548,7 +548,7 @@ pub enum DockerLauncherError { source: std::str::Utf8Error, display: String, }, - #[snafu(display("failed to convert path to WSL2: {msg}"))] + #[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 }, From 33b0d004187506672c7f7f741de2243121364f9b Mon Sep 17 00:00:00 2001 From: Adam Spofford Date: Tue, 20 Jan 2026 10:16:54 -0800 Subject: [PATCH 45/59] Include DISTRO in WSLPATH --- .github/scripts/provision-windows-build.ps1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/scripts/provision-windows-build.ps1 b/.github/scripts/provision-windows-build.ps1 index 67872fc5..95fc053d 100644 --- a/.github/scripts/provision-windows-build.ps1 +++ b/.github/scripts/provision-windows-build.ps1 @@ -12,4 +12,4 @@ $env:VCPKG_BUILD_TYPE = 'release' 'OPENSSL_STATIC=1' >> $env:GITHUB_ENV 'OPENSSL_NO_VENDOR=1' >> $env:GITHUB_ENV -'WSLENV=GITHUB_ENV/p' >> $env:GITHUB_ENV +'WSLENV=GITHUB_ENV/p:DISTRO' >> $env:GITHUB_ENV From 0a238499920d208119634d245182e662696d1a1d Mon Sep 17 00:00:00 2001 From: Adam Spofford Date: Tue, 20 Jan 2026 10:55:56 -0800 Subject: [PATCH 46/59] Add polling fallback for file watching --- crates/icp/src/network/managed/launcher.rs | 88 ++++++++++++---------- 1 file changed, 50 insertions(+), 38 deletions(-) diff --git a/crates/icp/src/network/managed/launcher.rs b/crates/icp/src/network/managed/launcher.rs index 8931efaa..5ab2b430 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,52 @@ 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 })?; + 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 +304,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); + } +} From f036f51fb7b4d151dbd95fd59b5eec0e8ba8ac0b Mon Sep 17 00:00:00 2001 From: Adam Spofford Date: Tue, 20 Jan 2026 11:25:12 -0800 Subject: [PATCH 47/59] Remove vendored-openssl from the Windows build --- .github/scripts/provision-windows-build.ps1 | 15 --------------- .github/workflows/test.yml | 13 ++----------- crates/icp-cli/Cargo.toml | 5 ++++- 3 files changed, 6 insertions(+), 27 deletions(-) delete mode 100644 .github/scripts/provision-windows-build.ps1 diff --git a/.github/scripts/provision-windows-build.ps1 b/.github/scripts/provision-windows-build.ps1 deleted file mode 100644 index 95fc053d..00000000 --- a/.github/scripts/provision-windows-build.ps1 +++ /dev/null @@ -1,15 +0,0 @@ -#Requires -Version 7.3 -$ErrorActionPreference = 'Stop' -$PSNativeCommandUseErrorActionPreference = $true - -# enormous speedup in CI, but not mandatory -cd C:\vcpkg -git checkout 2025.12.12 -.\bootstrap-vcpkg.bat -disableMetrics -$env:VCPKG_BUILD_TYPE = 'release' -.\vcpkg install openssl:x64-windows-static-md -.\vcpkg integrate install -'OPENSSL_STATIC=1' >> $env:GITHUB_ENV -'OPENSSL_NO_VENDOR=1' >> $env:GITHUB_ENV - -'WSLENV=GITHUB_ENV/p:DISTRO' >> $env:GITHUB_ENV diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index a801694c..ecd72802 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -13,6 +13,8 @@ env: 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:DISTRO defaults: run: @@ -50,16 +52,11 @@ jobs: ~/.cargo/registry/cache/ ~/.cargo/git/db/ target/ - ${{ contains(matrix.os, 'windows') && 'C:/vcpkg' || '' }} 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 image (Windows) - if: ${{ contains(matrix.os, 'windows') }} - run: ./.github/scripts/provision-windows-build.ps1 - shell: pwsh - uses: t1m0thyj/unlock-keyring@e481cdc8833d4417a58f40734e8f197183317047 if: ${{ contains(matrix.os, 'ubuntu') }} @@ -88,18 +85,12 @@ jobs: ~/.cargo/registry/cache/ ~/.cargo/git/db/ target/ - ${{ contains(matrix.os, 'windows') && 'C:/vcpkg/installed' || '' }} 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 image (Windows) - if: ${{ contains(matrix.os, 'windows') }} - run: ./.github/scripts/provision-windows-build.ps1 - shell: pwsh - - name: Setup WSL2 (Windows) if: ${{ contains(matrix.os, 'windows') }} uses: Vampire/setup-wsl@6a8db447be7ed35f2f499c02c6e60ff77ef11278 # v6.0.0 diff --git a/crates/icp-cli/Cargo.toml b/crates/icp-cli/Cargo.toml index ea69a813..b52e087e 100644 --- a/crates/icp-cli/Cargo.toml +++ b/crates/icp-cli/Cargo.toml @@ -20,7 +20,7 @@ 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 @@ -63,6 +63,9 @@ 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" From 7377125fdd0cc9e0eae59275e71d361e4d7cb3e4 Mon Sep 17 00:00:00 2001 From: Adam Spofford Date: Tue, 20 Jan 2026 11:53:33 -0800 Subject: [PATCH 48/59] Use version nonspecific name (cache loses the context) --- .github/workflows/test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index ecd72802..3cdb325c 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -101,7 +101,7 @@ jobs: if: ${{ contains(matrix.os, 'windows') }} run: .github/scripts/init-docker.sh env: - DISTRO: Ubuntu-22.04 + DISTRO: Ubuntu shell: wsl-bash_Ubuntu-22.04 {0} - uses: t1m0thyj/unlock-keyring@e481cdc8833d4417a58f40734e8f197183317047 From 7306c685e511c82b25eb88197cb912a720a1190b Mon Sep 17 00:00:00 2001 From: Adam Spofford Date: Tue, 20 Jan 2026 12:09:29 -0800 Subject: [PATCH 49/59] immediately poll --- crates/icp/src/network/managed/launcher.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/icp/src/network/managed/launcher.rs b/crates/icp/src/network/managed/launcher.rs index 5ab2b430..50b95f0b 100644 --- a/crates/icp/src/network/managed/launcher.rs +++ b/crates/icp/src/network/managed/launcher.rs @@ -235,6 +235,7 @@ pub fn wait_for_single_line_file( 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 { From 26794406c5cc5bfc79942eaf147ceea645bc6c6d Mon Sep 17 00:00:00 2001 From: Adam Spofford Date: Tue, 20 Jan 2026 12:45:26 -0800 Subject: [PATCH 50/59] wsl sets a var already --- .github/scripts/init-docker.sh | 2 +- .github/workflows/test.yml | 4 +--- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/.github/scripts/init-docker.sh b/.github/scripts/init-docker.sh index c7d97f87..f81885ff 100644 --- a/.github/scripts/init-docker.sh +++ b/.github/scripts/init-docker.sh @@ -8,7 +8,7 @@ for i in $(seq 1 30); do echo Docker ready { echo DOCKER_HOST=tcp://127.0.0.1:2375 - echo ICP_CLI_DOCKER_WSL2_MODE="$DISTRO" + echo ICP_CLI_DOCKER_WSL2_MODE="$WSL_DISTRO_NAME" } >> $GITHUB_ENV exit 0 fi diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 3cdb325c..d338f93f 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -14,7 +14,7 @@ env: # 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:DISTRO + WSLENV: CARGO_INCREMENTAL:CURL_HOME/p:CARGO_NET_RETRY:GITHUB_ENV/p:GITHUB_OUTPUT/p defaults: run: @@ -100,8 +100,6 @@ jobs: - name: Setup Docker in WSL2 (Windows) if: ${{ contains(matrix.os, 'windows') }} run: .github/scripts/init-docker.sh - env: - DISTRO: Ubuntu shell: wsl-bash_Ubuntu-22.04 {0} - uses: t1m0thyj/unlock-keyring@e481cdc8833d4417a58f40734e8f197183317047 From 5353e7b185742e748183827260616e7003d5ddbf Mon Sep 17 00:00:00 2001 From: Adam Spofford Date: Tue, 20 Jan 2026 15:26:07 -0800 Subject: [PATCH 51/59] Lock for a shorter duration --- crates/icp/src/network/managed/run.rs | 82 +++++++++++++++------------ 1 file changed, 45 insertions(+), 37 deletions(-) diff --git a/crates/icp/src/network/managed/run.rs b/crates/icp/src/network/managed/run.rs index 09ed02d8..dd3e2c0d 100644 --- a/crates/icp/src/network/managed/run.rs +++ b/crates/icp/src/network/managed/run.rs @@ -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,14 @@ 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()); @@ -165,7 +168,7 @@ async fn run_network_launcher( 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 root.state_dir().exists() { @@ -189,22 +192,32 @@ async fn run_network_launcher( 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)) } - }; - 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?; + }).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 descriptor = NetworkDescriptorModel { v: "1".to_string(), id: Uuid::new_v4(), @@ -218,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 { @@ -238,7 +246,7 @@ 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(()) } From bca70314c8e0cd96727ef54b7e37dfaf221bb50e Mon Sep 17 00:00:00 2001 From: Adam Spofford Date: Tue, 20 Jan 2026 16:21:19 -0800 Subject: [PATCH 52/59] fix manual flags check --- crates/icp-cli/tests/common/context.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/icp-cli/tests/common/context.rs b/crates/icp-cli/tests/common/context.rs index a1ebf3b5..88379821 100644 --- a/crates/icp-cli/tests/common/context.rs +++ b/crates/icp-cli/tests/common/context.rs @@ -276,11 +276,11 @@ impl TestContext { } #[cfg(windows)] { - let convert = std::env::var("ICP_CLI_DOCKER_WSL2_MODE").unwrap_or_default() == "1"; - let launcher_dir_param = if convert { + let distro = std::env::var("ICP_CLI_DOCKER_WSL2_MODE"); + let launcher_dir_param = if let Ok(distro) = distro { wslpath2::convert( launcher_dir.as_str(), - None, + Some(&distro), wslpath2::Conversion::WindowsToWsl, true, ) From 4b574a2dbb5d723fa8349aa0c1d0420f1805a46d Mon Sep 17 00:00:00 2001 From: Adam Spofford Date: Tue, 20 Jan 2026 17:20:05 -0800 Subject: [PATCH 53/59] Increase timeout to 5m --- crates/icp-cli/tests/common/context.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/icp-cli/tests/common/context.rs b/crates/icp-cli/tests/common/context.rs index 88379821..a7fe2e06 100644 --- a/crates/icp-cli/tests/common/context.rs +++ b/crates/icp-cli/tests/common/context.rs @@ -430,7 +430,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(); From 8c628d9fd6ab99b112d5cb6245d8caf4deeb9227 Mon Sep 17 00:00:00 2001 From: Adam Spofford Date: Tue, 20 Jan 2026 17:58:06 -0800 Subject: [PATCH 54/59] docs --- .github/scripts/init-docker.sh | 5 +++- .github/workflows/test.yml | 1 + CHANGELOG.md | 1 + crates/icp-cli/tests/common/context.rs | 6 ++++- crates/icp/src/network/managed/docker.rs | 2 +- docs/containers.md | 4 +++ docs/guides/installation.md | 33 +++++++++++++++++++----- 7 files changed, 42 insertions(+), 10 deletions(-) diff --git a/.github/scripts/init-docker.sh b/.github/scripts/init-docker.sh index f81885ff..46dfeac9 100644 --- a/.github/scripts/init-docker.sh +++ b/.github/scripts/init-docker.sh @@ -1,4 +1,7 @@ #!/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 @@ -8,7 +11,7 @@ for i in $(seq 1 30); do echo Docker ready { echo DOCKER_HOST=tcp://127.0.0.1:2375 - echo ICP_CLI_DOCKER_WSL2_MODE="$WSL_DISTRO_NAME" + echo ICP_CLI_DOCKER_WSL2_DISTRO="$WSL_DISTRO_NAME" } >> $GITHUB_ENV exit 0 fi diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index d338f93f..baffa7b6 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -162,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 0983ff5b..5418d71b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,6 @@ # Unreleased +* feat: Release for Windows * feat: Show `name` in `canister status` command * feat: `icp canister metadata ` now fetches metadata sections from specified canisters * fix: Validate explicit canister paths and throw an error if `canister.yaml` is not found diff --git a/crates/icp-cli/tests/common/context.rs b/crates/icp-cli/tests/common/context.rs index a7fe2e06..6df9ae55 100644 --- a/crates/icp-cli/tests/common/context.rs +++ b/crates/icp-cli/tests/common/context.rs @@ -76,8 +76,10 @@ impl TestContext { // Isolate the command cmd.current_dir(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()); @@ -194,8 +196,10 @@ impl TestContext { let icp_path = env!("CARGO_BIN_EXE_icp"); let mut cmd = std::process::Command::new(icp_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); @@ -276,7 +280,7 @@ impl TestContext { } #[cfg(windows)] { - let distro = std::env::var("ICP_CLI_DOCKER_WSL2_MODE"); + 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(), diff --git a/crates/icp/src/network/managed/docker.rs b/crates/icp/src/network/managed/docker.rs index 7759218d..75a7459a 100644 --- a/crates/icp/src/network/managed/docker.rs +++ b/crates/icp/src/network/managed/docker.rs @@ -50,7 +50,7 @@ pub async fn spawn_docker_launcher( } else { "linux/amd64".to_string() }; - let wsl2_distro = std::env::var("ICP_CLI_DOCKER_WSL2_MODE").ok(); + 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 = if wsl2_convert { 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 23cc66d3..b028145a 100644 --- a/docs/guides/installation.md +++ b/docs/guides/installation.md @@ -10,7 +10,7 @@ This guide covers all methods for installing icp-cli on your system. brew install dfinity/tap/icp-cli ``` -**Curl** +**Bash/Curl** ```bash # install icp-cli @@ -20,6 +20,16 @@ curl --proto '=https' --tlsv1.2 -LsSf https://github.com/dfinity/icp-cli/release 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. @@ -87,12 +97,13 @@ icp-cli requires Rust 1.88.0 or later (Rust 2024 edition). **Platform-specific dependencies:** -| Platform | Dependencies | -|----------|--------------| -| macOS | Xcode Command Line Tools: `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 | Dependencies | +|---------------|----------------------------------------------------------------------------------------------------------| +| macOS | Xcode Command Line Tools: `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 Steps @@ -174,6 +185,14 @@ cd .. rm -rf test-project ``` +## Other Dependencies + +### Docker/WSL2 + +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](../containers.md). + +Docker is also a dependency for projects that manually configure their network to be container-based. + ## Troubleshooting **"command not found: icp"** From c7dab5607e5296ecafa99cd820b6229fbb2912ff Mon Sep 17 00:00:00 2001 From: Adam Spofford Date: Tue, 20 Jan 2026 18:22:16 -0800 Subject: [PATCH 55/59] Update dist --- dist-workspace.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dist-workspace.toml b/dist-workspace.toml index c36a157a..4eed3344 100644 --- a/dist-workspace.toml +++ b/dist-workspace.toml @@ -8,7 +8,7 @@ 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", From 8a55ab69352c72131098df5d7491f1765f107574 Mon Sep 17 00:00:00 2001 From: Adam Spofford Date: Tue, 20 Jan 2026 18:23:33 -0800 Subject: [PATCH 56/59] remove unnecessary tmpdir handling --- crates/icp/src/network/managed/docker.rs | 14 +------------- 1 file changed, 1 insertion(+), 13 deletions(-) diff --git a/crates/icp/src/network/managed/docker.rs b/crates/icp/src/network/managed/docker.rs index 75a7459a..1e9a1db2 100644 --- a/crates/icp/src/network/managed/docker.rs +++ b/crates/icp/src/network/managed/docker.rs @@ -53,19 +53,7 @@ pub async fn spawn_docker_launcher( 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 = if wsl2_convert { - let host_tmp = wslpath2::convert("/tmp", wsl2_distro, Conversion::WslToWindows, true) - .map_err(|e| { - WslPathConvertSnafu { - msg: e.to_string(), - path: "/tmp", - } - .build() - })?; - Utf8TempDir::new_in(&host_tmp).context(WslCreateTmpDirSnafu)? - } else { - Utf8TempDir::new().context(CreateStatusDirSnafu)? - }; + 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() { From 609e118477a372def4dd7b2c55da6a7fb3ade638 Mon Sep 17 00:00:00 2001 From: Adam Spofford <93943719+adamspofford-dfinity@users.noreply.github.com> Date: Wed, 21 Jan 2026 12:57:38 -0800 Subject: [PATCH 57/59] Apply suggestion Co-authored-by: Linwei Shang --- crates/icp/src/context/mod.rs | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/crates/icp/src/context/mod.rs b/crates/icp/src/context/mod.rs index 2a7d11f9..93bbdfb9 100644 --- a/crates/icp/src/context/mod.rs +++ b/crates/icp/src/context/mod.rs @@ -524,11 +524,13 @@ impl Write for TermWriter { impl Write for &TermWriter { fn write(&mut self, buf: &[u8]) -> std::io::Result { - if !self.debug { - (&self.raw_term).write(buf)?; - } - debug!("{}", String::from_utf8_lossy(buf).trim()); - Ok(buf.len()) + let written = if !self.debug { + (&self.raw_term).write(buf)? + } else { + buf.len() + }; + debug!("{}", String::from_utf8_lossy(&buf[..written]).trim()); + Ok(written) } fn flush(&mut self) -> std::io::Result<()> { From 08a8eeb13561afcf9253124ae8aaa72579151af6 Mon Sep 17 00:00:00 2001 From: Adam Spofford Date: Wed, 21 Jan 2026 15:41:36 -0800 Subject: [PATCH 58/59] Simplify conditional term output --- crates/icp/src/context/mod.rs | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/crates/icp/src/context/mod.rs b/crates/icp/src/context/mod.rs index 93bbdfb9..40b8c09f 100644 --- a/crates/icp/src/context/mod.rs +++ b/crates/icp/src/context/mod.rs @@ -1,6 +1,6 @@ use console::Term; use std::{io::Write, sync::Arc}; -use tracing::debug; +use tracing::info; use url::Url; use crate::{ @@ -504,10 +504,11 @@ pub struct TermWriter { impl TermWriter { pub fn write_line(&self, line: &str) -> std::io::Result<()> { - if !self.debug { - writeln!(&self.raw_term, "{}", line)?; + if self.debug { + info!("{line}"); + } else { + writeln!(&self.raw_term, "{line}")?; } - debug!("{}", line); Ok(()) } } @@ -524,13 +525,12 @@ impl Write for TermWriter { impl Write for &TermWriter { fn write(&mut self, buf: &[u8]) -> std::io::Result { - let written = if !self.debug { - (&self.raw_term).write(buf)? + if self.debug { + info!("{}", String::from_utf8_lossy(buf).trim()); + Ok(buf.len()) } else { - buf.len() - }; - debug!("{}", String::from_utf8_lossy(&buf[..written]).trim()); - Ok(written) + (&self.raw_term).write(buf) + } } fn flush(&mut self) -> std::io::Result<()> { From 6d2de371485357338fc2b0df3b1465275c7ab6f8 Mon Sep 17 00:00:00 2001 From: Adam Spofford Date: Wed, 21 Jan 2026 16:47:35 -0800 Subject: [PATCH 59/59] this is redundant (and wrong) --- crates/icp-cli/src/main.rs | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/crates/icp-cli/src/main.rs b/crates/icp-cli/src/main.rs index 7ac0ba40..d33655e5 100644 --- a/crates/icp-cli/src/main.rs +++ b/crates/icp-cli/src/main.rs @@ -126,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(