diff --git a/CHANGELOG.md b/CHANGELOG.md index 0a841e21..4f8e36ba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,10 +1,12 @@ # Unreleased +* feat: `icp settings autocontainerize true`, always use a docker container for all networks +* feat: `icp identity export` to print the PEM file for the identity + # v0.1.0-beta.5 * fix: Fix error when loading network descriptors from v0.1.0-beta.3 * feat: `icp identity delete` and `icp identity rename` -* feat: `icp identity export` to print the PEM file for the identity # v0.1.0-beta.4 diff --git a/crates/icp-cli/src/commands/mod.rs b/crates/icp-cli/src/commands/mod.rs index 8a28a18f..c55d023f 100644 --- a/crates/icp-cli/src/commands/mod.rs +++ b/crates/icp-cli/src/commands/mod.rs @@ -11,6 +11,7 @@ pub(crate) mod network; pub(crate) mod new; pub(crate) mod parsers; pub(crate) mod project; +pub(crate) mod settings; pub(crate) mod sync; pub(crate) mod token; @@ -54,6 +55,9 @@ pub(crate) enum Command { #[command(subcommand)] Project(project::Command), + /// Configure user settings + Settings(settings::SettingsArgs), + /// Synchronize canisters Sync(sync::SyncArgs), diff --git a/crates/icp-cli/src/commands/network/start.rs b/crates/icp-cli/src/commands/network/start.rs index 0e8d0091..b671ceb1 100644 --- a/crates/icp-cli/src/commands/network/start.rs +++ b/crates/icp-cli/src/commands/network/start.rs @@ -4,6 +4,7 @@ use icp::prelude::*; use icp::{ identity::manifest::IdentityList, network::{Configuration, run_network}, + settings::Settings, }; use tracing::debug; @@ -95,6 +96,12 @@ pub(crate) async fn exec(ctx: &Context, args: &StartArgs) -> Result<(), anyhow:: let candid_ui_wasm = crate::artifacts::get_candid_ui_wasm(); + let settings = ctx + .dirs + .settings()? + .with_read(async |dirs| Settings::load_from(dirs)) + .await??; + let network_launcher_path = if let Ok(var) = std::env::var("ICP_CLI_NETWORK_LAUNCHER_PATH") { Some(PathBuf::from(var)) } else { @@ -124,6 +131,9 @@ pub(crate) async fn exec(ctx: &Context, args: &StartArgs) -> Result<(), anyhow:: } }; + // On Windows, always use Docker since the native launcher doesn't run there + let autocontainerize = cfg!(windows) || settings.autocontainerize; + run_network( cfg, nd, @@ -133,6 +143,7 @@ pub(crate) async fn exec(ctx: &Context, args: &StartArgs) -> Result<(), anyhow:: args.background, ctx.debug, network_launcher_path.as_deref(), + autocontainerize, ) .await?; Ok(()) diff --git a/crates/icp-cli/src/commands/settings.rs b/crates/icp-cli/src/commands/settings.rs new file mode 100644 index 00000000..e788a1f2 --- /dev/null +++ b/crates/icp-cli/src/commands/settings.rs @@ -0,0 +1,66 @@ +use clap::{Args, Subcommand}; +use icp::{context::Context, settings::Settings}; + +#[derive(Debug, Args)] +pub(crate) struct SettingsArgs { + #[command(subcommand)] + setting: Setting, +} + +#[derive(Debug, Subcommand)] +#[command( + subcommand_value_name = "SETTING", + subcommand_help_heading = "Settings", + override_usage = "icp settings [OPTIONS] [VALUE]", + disable_help_subcommand = true +)] +enum Setting { + /// Use Docker for the network launcher even when native mode is requested + Autocontainerize(AutocontainerizeArgs), +} + +#[derive(Debug, Args)] +struct AutocontainerizeArgs { + /// Set to true or false. If omitted, prints the current value. + value: Option, +} + +pub(crate) async fn exec(ctx: &Context, args: &SettingsArgs) -> Result<(), anyhow::Error> { + match &args.setting { + Setting::Autocontainerize(sub_args) => exec_autocontainerize(ctx, sub_args).await, + } +} + +async fn exec_autocontainerize( + ctx: &Context, + args: &AutocontainerizeArgs, +) -> Result<(), anyhow::Error> { + let dirs = ctx.dirs.settings()?; + + match args.value { + Some(value) => { + dirs.with_write(async |dirs| { + let mut settings = Settings::load_from(dirs.read())?; + settings.autocontainerize = value; + settings.write_to(dirs)?; + println!("Set autocontainerize to {value}"); + if cfg!(windows) { + eprintln!( + "Warning: This setting is ignored on Windows. \ + Docker is always used because the network launcher does not run natively." + ); + } + Ok(()) + }) + .await? + } + + None => { + let settings = dirs + .with_read(async |dirs| Settings::load_from(dirs)) + .await??; + println!("{}", settings.autocontainerize); + Ok(()) + } + } +} diff --git a/crates/icp-cli/src/main.rs b/crates/icp-cli/src/main.rs index 38991d19..18d590c9 100644 --- a/crates/icp-cli/src/main.rs +++ b/crates/icp-cli/src/main.rs @@ -422,6 +422,13 @@ async fn main() -> Result<(), Error> { } }, + // Settings + Command::Settings(args) => { + commands::settings::exec(&ctx, &args) + .instrument(trace_span) + .await? + } + // Sync Command::Sync(args) => { commands::sync::exec(&ctx, &args) diff --git a/crates/icp-cli/tests/common/context.rs b/crates/icp-cli/tests/common/context.rs index b0a66502..38bee7a3 100644 --- a/crates/icp-cli/tests/common/context.rs +++ b/crates/icp-cli/tests/common/context.rs @@ -79,7 +79,12 @@ impl TestContext { 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"); + cmd.env("HOME", self.home_path()) + .env_remove("ICP_HOME") + // Also set XDG directories to ensure isolation on Linux + .env("XDG_CONFIG_HOME", self.home_path().join(".config")) + .env("XDG_DATA_HOME", self.home_path().join(".local/share")) + .env("XDG_CACHE_HOME", self.home_path().join(".cache")); // Run in portable mode on Windows, the user directory cannot be mocked #[cfg(windows)] cmd.env("ICP_HOME", self.home_path().join("icp")); @@ -204,7 +209,14 @@ impl TestContext { 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"); + { + cmd.env("HOME", self.home_path()) + .env_remove("ICP_HOME") + // Also set XDG directories to ensure isolation on Linux + .env("XDG_CONFIG_HOME", self.home_path().join(".config")) + .env("XDG_DATA_HOME", self.home_path().join(".local/share")) + .env("XDG_CACHE_HOME", self.home_path().join(".cache")); + } // run in portable mode on Windows, the user directory cannot be mocked #[cfg(windows)] cmd.env("ICP_HOME", self.home_path().join("icp")); diff --git a/crates/icp-cli/tests/network_tests.rs b/crates/icp-cli/tests/network_tests.rs index b811c203..b6b4d9f6 100644 --- a/crates/icp-cli/tests/network_tests.rs +++ b/crates/icp-cli/tests/network_tests.rs @@ -7,7 +7,8 @@ use icp_canister_interfaces::{ use indoc::{formatdoc, indoc}; use predicates::{ ord::eq, - str::{PredicateStrExt, contains, is_match}, + prelude::*, + str::{contains, is_match}, }; use serde_json::Value; use serial_test::file_serial; @@ -618,3 +619,78 @@ async fn override_local_network_as_connected() { .assert() .success(); } + +/// Test that setting autocontainerize=true causes the network launcher to run in Docker +/// even when a native launcher configuration is used. +/// +/// This test is skipped on Windows because autocontainerize has no effect there +/// (Docker is always used on Windows). +#[cfg(not(windows))] +#[tag(docker)] +#[tokio::test] +async fn network_autocontainerize_uses_docker() { + let ctx = TestContext::new(); + + // Set autocontainerize to true + ctx.icp() + .args(["settings", "autocontainerize", "true"]) + .assert() + .success(); + + let project_dir = ctx.create_project_dir("autocontainerize-test"); + + // Use a native launcher configuration (not an explicit docker image) + write_string(&project_dir.join("icp.yaml"), NETWORK_RANDOM_PORT) + .expect("failed to write project manifest"); + + // Pull the docker image first + ctx.docker_pull_network(); + + // Start the network + let _guard = ctx.start_network_in(&project_dir, "random-network").await; + + // Verify the descriptor contains a container ID (not a PID) + let descriptor_file_path = project_dir + .join(".icp") + .join("cache") + .join("networks") + .join("random-network") + .join("descriptor.json"); + + let descriptor_contents = + read_to_string(&descriptor_file_path).expect("Failed to read network descriptor file"); + let descriptor: Value = descriptor_contents + .trim() + .parse() + .expect("Descriptor file should contain valid JSON"); + + // When running in Docker, the child-locator should have an "id" field (container ID) + // rather than a "pid" field + let child_locator = descriptor + .get("child-locator") + .expect("Descriptor should have child-locator"); + + assert!( + child_locator.get("id").is_some(), + "With autocontainerize=true, child-locator should have container 'id', not 'pid'. Got: {child_locator}" + ); + assert!( + child_locator.get("pid").is_none(), + "With autocontainerize=true, child-locator should not have 'pid'. Got: {child_locator}" + ); + + let container_id = child_locator + .get("id") + .and_then(|id| id.as_str()) + .expect("Container ID should be a string"); + + // Verify the container is running + let output = std::process::Command::new("docker") + .args(["inspect", container_id]) + .output() + .expect("Failed to run docker inspect"); + assert!( + output.status.success(), + "Container should be running while network is active" + ); +} diff --git a/crates/icp-cli/tests/settings_tests.rs b/crates/icp-cli/tests/settings_tests.rs new file mode 100644 index 00000000..0a51b2c2 --- /dev/null +++ b/crates/icp-cli/tests/settings_tests.rs @@ -0,0 +1,84 @@ +use predicates::{ord::eq, prelude::*}; + +mod common; +use common::TestContext; + +#[test] +fn settings_autocontainerize_default() { + let ctx = TestContext::new(); + + // Default value should be false + ctx.icp() + .args(["settings", "autocontainerize"]) + .assert() + .success() + .stdout(eq("false").trim()); +} + +#[test] +fn settings_autocontainerize_set_true() { + let ctx = TestContext::new(); + + // Set to true + ctx.icp() + .args(["settings", "autocontainerize", "true"]) + .assert() + .success() + .stdout(eq("Set autocontainerize to true").trim()); + + // Verify it's now true + ctx.icp() + .args(["settings", "autocontainerize"]) + .assert() + .success() + .stdout(eq("true").trim()); +} + +#[test] +fn settings_autocontainerize_set_false() { + let ctx = TestContext::new(); + + // Set to true first + ctx.icp() + .args(["settings", "autocontainerize", "true"]) + .assert() + .success(); + + // Set back to false + ctx.icp() + .args(["settings", "autocontainerize", "false"]) + .assert() + .success() + .stdout(eq("Set autocontainerize to false").trim()); + + // Verify it's now false + ctx.icp() + .args(["settings", "autocontainerize"]) + .assert() + .success() + .stdout(eq("false").trim()); +} + +#[test] +fn settings_autocontainerize_persists() { + let ctx = TestContext::new(); + + // Set to true + ctx.icp() + .args(["settings", "autocontainerize", "true"]) + .assert() + .success(); + + // Verify it persists across multiple reads + ctx.icp() + .args(["settings", "autocontainerize"]) + .assert() + .success() + .stdout(eq("true").trim()); + + ctx.icp() + .args(["settings", "autocontainerize"]) + .assert() + .success() + .stdout(eq("true").trim()); +} diff --git a/crates/icp/src/directories.rs b/crates/icp/src/directories.rs index 144ea7b5..27663c4c 100644 --- a/crates/icp/src/directories.rs +++ b/crates/icp/src/directories.rs @@ -9,6 +9,7 @@ use crate::{ identity::{IdentityDirectories, IdentityPaths}, package::PackageCache, prelude::*, + settings::{SettingsDirectories, SettingsPaths}, }; use directories::ProjectDirs; use snafu::prelude::*; @@ -23,6 +24,9 @@ pub trait Access: Sync + Send { /// Returns the path to the package cache for managed packages. fn package_cache(&self) -> Result; + + /// Returns the path to the user settings directory. + fn settings(&self) -> Result; } /// Inner structure holding data and cache directory paths. @@ -31,6 +35,8 @@ pub trait Access: Sync + Send { /// system conventions via the `directories` crate. #[derive(Debug, Clone)] pub struct DirectoriesInner { + /// Path to the config directory for storing user preferences. + config: PathBuf, /// Path to the data directory for storing user data. data: PathBuf, /// Path to the data directory for storing machine-specific data, @@ -51,6 +57,7 @@ impl DirectoriesInner { /// Returns `FromPathBufError` if the paths contain non-UTF-8 characters. pub fn from_dirs(dirs: ProjectDirs) -> Result { Ok(Self { + config: dirs.config_dir().to_owned().try_into()?, data: dirs.data_dir().to_owned().try_into()?, data_local: dirs.data_local_dir().to_owned().try_into()?, cache: dirs.cache_dir().to_owned().try_into()?, @@ -118,6 +125,17 @@ impl Directories { /// Implementation providing access to specific directory paths. impl Directories { + /// Returns the base config directory path. + /// + /// For standard directories, this is the system config directory. + /// For overridden directories, this is the custom path. + fn config(&self) -> PathBuf { + match self { + Self::Standard(dirs) => dirs.config.clone(), + Self::Overridden(path) => path.clone(), + } + } + /// Returns the base data directory path. /// /// For standard directories, this is the system data directory. @@ -171,6 +189,13 @@ impl Access for Directories { fn package_cache(&self) -> Result { PackageCache::new(self.data_local().join("pkg")) } + + /// Returns the path to the user settings directory. + /// + /// This directory stores user preferences and configuration. + fn settings(&self) -> Result { + SettingsPaths::new(self.config().join("settings")) + } } #[cfg(test)] @@ -192,4 +217,8 @@ impl Access for UnimplementedMockDirs { fn package_cache(&self) -> Result { unimplemented!("UnimplementedMockDirs::package_cache") } + + fn settings(&self) -> Result { + unimplemented!("UnimplementedMockDirs::settings") + } } diff --git a/crates/icp/src/lib.rs b/crates/icp/src/lib.rs index 1754dde5..2b69d97c 100644 --- a/crates/icp/src/lib.rs +++ b/crates/icp/src/lib.rs @@ -28,6 +28,7 @@ pub mod network; pub mod package; pub mod prelude; pub mod project; +pub mod settings; pub mod store_artifact; pub mod store_id; diff --git a/crates/icp/src/network/managed/run.rs b/crates/icp/src/network/managed/run.rs index b27cdb44..b5a50419 100644 --- a/crates/icp/src/network/managed/run.rs +++ b/crates/icp/src/network/managed/run.rs @@ -58,6 +58,7 @@ pub async fn run_network( background: bool, verbose: bool, network_launcher_path: Option<&Path>, + autocontainerize: bool, ) -> Result<(), RunNetworkError> { nd.ensure_exists()?; @@ -70,6 +71,7 @@ pub async fn run_network( candid_ui_wasm, background, verbose, + autocontainerize, ) .await?; Ok(()) @@ -121,6 +123,7 @@ async fn run_network_launcher( candid_ui_wasm: Option<&[u8]>, background: bool, verbose: bool, + autocontainerize: bool, ) -> Result<(), RunNetworkLauncherError> { let network_root = nd.root()?; @@ -136,7 +139,7 @@ async fn run_network_launcher( let fixed_ports = options.fixed_host_ports(); (LaunchMode::Image(options), fixed_ports) } - ManagedMode::Launcher(launcher_config) if cfg!(windows) => { + ManagedMode::Launcher(launcher_config) if autocontainerize => { let options = transform_native_launcher_to_container(launcher_config); let fixed_ports = options.fixed_host_ports(); (LaunchMode::Image(options), fixed_ports) diff --git a/crates/icp/src/settings.rs b/crates/icp/src/settings.rs new file mode 100644 index 00000000..6fb52d27 --- /dev/null +++ b/crates/icp/src/settings.rs @@ -0,0 +1,111 @@ +//! User settings management for ICP CLI. +//! +//! This module provides utilities for loading and saving user settings. +//! Settings are stored in a dedicated directory with an adjacent lock file +//! to ensure safe concurrent access. + +use serde::{Deserialize, Serialize}; +use snafu::{Snafu, ensure}; + +use crate::{ + fs::{ + json, + lock::{DirectoryStructureLock, LRead, LWrite, LockError, PathsAccess}, + }, + prelude::*, +}; + +/// Paths for user settings storage. +pub struct SettingsPaths { + dir: PathBuf, +} + +impl SettingsPaths { + /// Creates a new settings directory lock. + pub fn new(dir: PathBuf) -> Result { + DirectoryStructureLock::open_or_create(Self { dir }) + } + + /// Returns the path to the settings file. + pub fn settings_path(&self) -> PathBuf { + self.dir.join("settings.json") + } + + /// Ensures the settings directory exists and returns the path to the settings file. + pub fn ensure_settings_path(&self) -> Result { + crate::fs::create_dir_all(&self.dir)?; + Ok(self.settings_path()) + } +} + +/// Type alias for the locked settings directory structure. +pub type SettingsDirectories = DirectoryStructureLock; + +impl PathsAccess for SettingsPaths { + fn lock_file(&self) -> PathBuf { + self.dir.join(".lock") + } +} + +/// User settings for the ICP CLI. +#[derive(Serialize, Deserialize, Debug, Clone)] +#[serde(rename_all = "kebab-case")] +pub struct Settings { + /// Schema version for forwards compatibility. + pub v: u32, + + /// Use Docker for the network launcher even when native mode is requested. + #[serde(default)] + pub autocontainerize: bool, +} + +impl Default for Settings { + fn default() -> Self { + Self { + v: 1, + autocontainerize: false, + } + } +} + +impl Settings { + /// Writes the settings to the settings file. + pub fn write_to(&self, dirs: LWrite<&SettingsPaths>) -> Result<(), WriteSettingsError> { + json::save(&dirs.ensure_settings_path()?, self)?; + Ok(()) + } + + /// Loads settings from the settings file, or returns defaults if the file doesn't exist. + pub fn load_from(dirs: LRead<&SettingsPaths>) -> Result { + let settings_path = dirs.settings_path(); + + let settings: Self = json::load_or_default(&settings_path)?; + + ensure!( + settings.v == 1, + BadVersionSnafu { + path: &settings_path + } + ); + + Ok(settings) + } +} + +#[derive(Debug, Snafu)] +pub enum WriteSettingsError { + #[snafu(transparent)] + WriteJsonError { source: json::Error }, + + #[snafu(transparent)] + CreateDirectoryError { source: crate::fs::IoError }, +} + +#[derive(Debug, Snafu)] +pub enum LoadSettingsError { + #[snafu(transparent)] + LoadJsonError { source: json::Error }, + + #[snafu(display("file `{path}` was modified by an incompatible new version of icp-cli"))] + BadVersion { path: PathBuf }, +} diff --git a/docs/guides/containerized-networks.md b/docs/guides/containerized-networks.md index c62c8a20..39bc4452 100644 --- a/docs/guides/containerized-networks.md +++ b/docs/guides/containerized-networks.md @@ -183,11 +183,11 @@ When using a containerized network, the Docker image must fulfill a specific con The container must write a status file to `/app/status/status.json` (configurable via `status-dir`) when the network is ready. This file tells icp-cli how to connect to the network. **Required fields:** -| Field | Type | Description | -|----------------|--------|--------------------------------------------------| -| `v` | string | Must be `"1"` (status file format version) | -| `gateway_port` | number | Container port where the HTTP gateway listens | -| `root_key` | string | Hex-encoded root key of the network | +| Field | Type | Description | +|----------------|--------|-----------------------------------------------| +| `v` | string | Must be `"1"` (status file format version) | +| `gateway_port` | number | Container port where the HTTP gateway listens | +| `root_key` | string | Hex-encoded root key of the network | **Example:** ```json @@ -463,6 +463,23 @@ If you're on Windows and want to use a manually instantiated `dockerd` in a WSL2 - `ICP_CLI_DOCKER_WSL2_DISTRO=` — the WSL2 distribution name running dockerd - `DOCKER_HOST=tcp://:` — the TCP address where dockerd is listening +## Always Use Containers + +If you prefer containers for all local networks without configuring each one individually, enable the `autocontainerize` setting: + +```bash +icp settings autocontainerize true +``` + +This makes all managed networks (including the implicit `local` network) run in Docker containers automatically. To check the current value or disable it: + +```bash +icp settings autocontainerize # Print current value +icp settings autocontainerize false # Disable +``` + +Note that this is the default behavior on Windows, where the setting will be ignored. + ## Related Documentation - [Managing Environments](managing-environments.md) — Configure environments that use containerized networks diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 5871a0cb..73db2ef6 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -50,6 +50,8 @@ This document contains the help content for the `icp` command-line program. * [`icp new`↴](#icp-new) * [`icp project`↴](#icp-project) * [`icp project show`↴](#icp-project-show) +* [`icp settings`↴](#icp-settings) +* [`icp settings autocontainerize`↴](#icp-settings-autocontainerize) * [`icp sync`↴](#icp-sync) * [`icp token`↴](#icp-token) * [`icp token balance`↴](#icp-token-balance) @@ -70,6 +72,7 @@ This document contains the help content for the `icp` command-line program. * `network` — Launch and manage local test networks * `new` — Create a new ICP project from a template * `project` — Display information about the current project +* `settings` — Configure user settings * `sync` — Synchronize canisters * `token` — Perform token transactions @@ -997,6 +1000,33 @@ The effective yaml configuration includes: +## `icp settings` + +Configure user settings + +**Usage:** `icp settings [OPTIONS] [VALUE]` + +###### **Subcommands:** + +* `autocontainerize` — Use Docker for the network launcher even when native mode is requested + + + +## `icp settings autocontainerize` + +Use Docker for the network launcher even when native mode is requested + +**Usage:** `icp settings autocontainerize [VALUE]` + +###### **Arguments:** + +* `` — Set to true or false. If omitted, prints the current value + + Possible values: `true`, `false` + + + + ## `icp sync` Synchronize canisters diff --git a/scripts/generate-cli-docs.sh b/scripts/generate-cli-docs.sh index 144ffc5d..9abeeba1 100755 --- a/scripts/generate-cli-docs.sh +++ b/scripts/generate-cli-docs.sh @@ -22,9 +22,9 @@ $(git rev-parse --show-toplevel)/target/debug/icp --markdown-help > $(git rev-pa # resulting in "icp token icp token ...". This sed command removes the duplication. # Note: sed -i has different syntax on macOS vs Linux if [[ "$OSTYPE" == "darwin"* ]]; then - sed -i '' 's/icp token icp token/icp token/g' $(git rev-parse --show-toplevel)/docs/reference/cli.md + sed -i '' -e 's/icp token icp token/icp token/g' -e 's/icp icp settings/icp settings/g' $(git rev-parse --show-toplevel)/docs/reference/cli.md else - sed -i 's/icp token icp token/icp token/g' $(git rev-parse --show-toplevel)/docs/reference/cli.md + sed -i -e 's/icp token icp token/icp token/g' -e 's/icp icp settings/icp settings/g' $(git rev-parse --show-toplevel)/docs/reference/cli.md fi echo "Documentation generated successfully at docs/reference/cli.md"