Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,11 @@ bump. Currently experimental: project bundling
# Unreleased

* feat: `icp canister delete` will now send the canister's remaining cycles to the caller
* feat: Connected networks now take an explicit `root-key`, which accepts a hex-encoded key or one of two new values:
* `mainnet`: use the canonical IC mainnet root key — handy for reaching mainnet through a custom boundary node without repeating the literal.
* `fetch`: fetch the key from the network on each use. This is trust-on-first-use and does *not* verify the key's provenance, so it's meant only for testnets you or someone you trust operate; `icp` prints a warning whenever it fetches.
* `network status` reports where the key came from — a `root_key_source` field in `--json`, and a `(fetched - unverified, trust-on-first-use)` label in text output.
* `root-key` is now required for connected networks (previously optional, silently defaulting to the mainnet key). This is technically breaking, but most working projects are unaffected: a non-mainnet connected network already needed an explicit key, so in practice only a mainnet-via-custom-URL network needs to add `root-key: mainnet`. The built-in `ic` network is unchanged.

# v1.0.2

Expand Down
16 changes: 14 additions & 2 deletions crates/icp-cli/src/commands/network/status.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
use anyhow::Context as _;
use clap::Args;
use icp::{context::Context, network::Configuration};
use icp::{
context::Context,
network::{Configuration, RootKeySource},
};
use serde::Serialize;

use super::args::NetworkOrEnvironmentArgs;
Expand Down Expand Up @@ -48,6 +51,7 @@ struct NetworkStatus {
#[serde(skip_serializing_if = "Option::is_none")]
proxy_canister_principal: Option<String>,
root_key: String,
root_key_source: RootKeySource,
}

pub(crate) async fn exec(ctx: &Context, args: &StatusArgs) -> Result<(), anyhow::Error> {
Expand Down Expand Up @@ -79,6 +83,7 @@ pub(crate) async fn exec(ctx: &Context, args: &StatusArgs) -> Result<(), anyhow:
api_url: network_access.api_url.to_string(),
gateway_url: network_access.http_gateway_url.map(|u| u.to_string()),
root_key: hex::encode(network_access.root_key),
root_key_source: network_access.root_key_source,
candid_ui_principal: descriptor.candid_ui_canister_id.map(|p| p.to_string()),
proxy_canister_principal: descriptor.proxy_canister_id.map(|p| p.to_string()),
}
Expand All @@ -90,6 +95,7 @@ pub(crate) async fn exec(ctx: &Context, args: &StatusArgs) -> Result<(), anyhow:
candid_ui_principal: None,
proxy_canister_principal: None,
root_key: hex::encode(network_access.root_key),
root_key_source: network_access.root_key_source,
},
};

Expand All @@ -102,7 +108,13 @@ pub(crate) async fn exec(ctx: &Context, args: &StatusArgs) -> Result<(), anyhow:
if let Some(gateway_url) = status.gateway_url {
output.push_str(&format!("Gateway Url: {}\n", gateway_url));
}
output.push_str(&format!("Root Key: {}\n", status.root_key));
let root_key_note = match status.root_key_source {
RootKeySource::Managed => "",
RootKeySource::Mainnet => " (mainnet)",
RootKeySource::Configured => " (configured)",
RootKeySource::Fetched => " (fetched - unverified, trust-on-first-use)",
};
output.push_str(&format!("Root Key: {}{root_key_note}\n", status.root_key));
if let Some(ref principal) = status.candid_ui_principal {
output.push_str(&format!("Candid UI Principal: {}\n", principal));
}
Expand Down
25 changes: 8 additions & 17 deletions crates/icp-cli/src/options.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ use clap::error::ErrorKind;
use clap::{ArgGroup, ArgMatches, Args, FromArgMatches};
use icp::context::{EnvironmentSelection, NetworkSelection};
use icp::identity::IdentitySelection;
use icp::network::RootKeySpec;
use icp::prelude::LOCAL;
use url::Url;

Expand Down Expand Up @@ -64,19 +65,8 @@ impl From<EnvironmentOpt> for EnvironmentSelection {
}
}

#[derive(Clone, Debug)]
pub(crate) struct RootKey(pub Vec<u8>);

fn parse_root_key(input: &str) -> Result<RootKey, String> {
let v = hex::decode(input).map_err(|e| format!("Invalid root key hex string: {e}"))?;
if v.len() != 133 {
Err(format!(
"Invalid root key. Expected 133 bytes but got {}",
v.len()
))
} else {
Ok(RootKey(v))
}
fn parse_root_key(input: &str) -> Result<RootKeySpec, String> {
RootKeySpec::try_from(input.to_string())
}

#[derive(Clone, Debug)]
Expand All @@ -100,16 +90,17 @@ pub(crate) struct NetworkOptInner {
network: Option<NetworkTarget>,

/// The root key to use if connecting to a network by URL.
/// Required when using `--network <URL>`.
/// Required when using `--network <URL>`. One of `mainnet`, `fetch`, or a
/// 266-character hex-encoded root key.
#[arg(long, short = 'k', requires = "network", help_heading = heading::NETWORK_PARAMETERS, value_parser = parse_root_key)]
root_key: Option<RootKey>,
root_key: Option<RootKeySpec>,
}

// This is wrapper around NetworkOptInner that will do some additional
// validation to only allow --root-key when the network is a url.
#[derive(Clone, Debug, Default)]
pub(crate) enum NetworkOpt {
Url(Url, RootKey),
Url(Url, RootKeySpec),

Name(String),

Expand Down Expand Up @@ -169,7 +160,7 @@ impl Args for NetworkOpt {
impl From<NetworkOpt> for NetworkSelection {
fn from(v: NetworkOpt) -> Self {
match v {
NetworkOpt::Url(url, RootKey(key)) => NetworkSelection::Url(url, key),
NetworkOpt::Url(url, root_key) => NetworkSelection::Url(url, root_key),
NetworkOpt::Name(name) => NetworkSelection::Named(name),
NetworkOpt::None => NetworkSelection::Default,
}
Expand Down
73 changes: 73 additions & 0 deletions crates/icp-cli/tests/network_status_tests.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
use icp::fs::write_string;
use indoc::formatdoc;
use predicates::str::{PredicateStrExt, contains};

mod common;
Expand Down Expand Up @@ -135,6 +136,7 @@ networks:
- name: connected-network
mode: connected
url: https://ic0.app
root-key: mainnet
"#,
)
.expect("failed to write project manifest");
Expand All @@ -147,6 +149,77 @@ networks:
.stdout(contains("Url: https://ic0.app"));
}

/// End-to-end test of the `root-key: fetch` path with no external network:
/// start a managed local network in one project, then in a second project
/// define a connected network pointing at it with `root-key: fetch`. `icp`
/// should fetch the running network's root key trust-on-first-use, warn about
/// it, and `network status` should report the key as `fetched` and match the
/// real root key.
#[tokio::test]
async fn status_connected_network_fetches_root_key() {
let ctx = TestContext::new();

// Provider project: start a managed local network on a random port.
let provider = ctx.create_project_dir("provider");
write_string(&provider.join("icp.yaml"), NETWORK_RANDOM_PORT)
.expect("failed to write provider manifest");
let _guard = ctx.start_network_in(&provider, "random-network").await;
let network = ctx.wait_for_network_descriptor(&provider, "random-network");
ctx.ping_until_healthy(&provider, "random-network");

// Consumer project: connect to the provider's network and fetch its root key.
let consumer = ctx.create_project_dir("consumer");
write_string(
&consumer.join("icp.yaml"),
&formatdoc! {r#"
networks:
- name: fetched-network
mode: connected
url: http://localhost:{port}
root-key: fetch
"#,
port = network.gateway_port,
},
)
.expect("failed to write consumer manifest");

// Text output: key is labeled as fetched, and the CLI warns on stderr.
ctx.icp()
.current_dir(&consumer)
.args(["network", "status", "fetched-network"])
.assert()
.success()
.stdout(contains("Root Key:"))
.stdout(contains("(fetched - unverified, trust-on-first-use)"))
.stderr(contains("provenance is not verified"));

// JSON output: root_key_source is "fetched" and the fetched key matches the
// running network's actual root key. (The warning lands on stderr, so the
// JSON on stdout stays clean.)
let output = ctx
.icp()
.current_dir(&consumer)
.args(["network", "status", "fetched-network", "--json"])
.assert()
.success()
.get_output()
.stdout
.clone();

let json_str = String::from_utf8(output).expect("output should be valid UTF-8");
let json: serde_json::Value =
serde_json::from_str(&json_str).expect("output should be valid JSON");

assert_eq!(json["root_key_source"], "fetched");
assert_eq!(
json["root_key"]
.as_str()
.expect("root_key should be a string"),
hex::encode(&network.root_key),
"fetched root key should match the running network's root key"
);
}

#[test]
fn status_not_in_project() {
let ctx = TestContext::new();
Expand Down
1 change: 1 addition & 0 deletions crates/icp-cli/tests/network_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -612,6 +612,7 @@ async fn cannot_override_ic() {
- name: ic
mode: connected
url: http://fake-ic.local
root-key: mainnet
"#},
)
.expect("failed to write project manifest");
Expand Down
1 change: 1 addition & 0 deletions crates/icp-cli/tests/project_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -280,6 +280,7 @@ fn redefine_ic_network_disallowed() {
- name: ic
mode: connected
url: https://fake-ic.io
root-key: mainnet
"#,
)
.expect("failed to write project manifest");
Expand Down
7 changes: 4 additions & 3 deletions crates/icp/src/context/init.rs
Original file line number Diff line number Diff line change
Expand Up @@ -128,15 +128,16 @@ pub fn initialize(
));
}

// Agent creator
let agent_creator = Arc::new(agent::Creator);

// Network accessor
let netaccess = Arc::new(network::Accessor {
project_root_locate: project_root_locate.clone(),
descriptors: dirs.port_descriptor(),
agent: agent_creator.clone(),
});

// Agent creator
let agent_creator = Arc::new(agent::Creator);

// Setup environment
Ok(Context {
dirs,
Expand Down
7 changes: 4 additions & 3 deletions crates/icp/src/context/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ use crate::{
canister::{build::Build, sync::Synchronize},
directories,
identity::IdentitySelection,
manifest::network::RootKeySpec,
network::{Configuration as NetworkConfiguration, access::NetworkAccess},
prelude::*,
store_id::{IdMapping, LookupIdError},
Expand All @@ -30,7 +31,7 @@ pub enum NetworkSelection {
/// Use a named network
Named(String),
/// Use a network by URL
Url(Url, Vec<u8>),
Url(Url, RootKeySpec),
}

/// Selection type for environments - similar to IdentitySelection
Expand Down Expand Up @@ -180,7 +181,7 @@ impl Context {
http_gateway_url: Some(
IC_MAINNET_NETWORK_GATEWAY_URL.parse().unwrap(),
),
root_key: IC_ROOT_KEY.to_vec(),
root_key: RootKeySpec::Mainnet,
},
},
}
Expand All @@ -197,7 +198,7 @@ impl Context {
connected: crate::network::Connected {
api_url: url.clone(),
http_gateway_url: Some(url.clone()),
root_key: root_key.to_vec(),
root_key: root_key.clone(),
},
},
},
Expand Down
11 changes: 11 additions & 0 deletions crates/icp/src/context/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -350,6 +350,7 @@ async fn test_get_agent_for_env_uses_environment_network() {
"local",
NetworkAccess {
root_key: local_root_key.clone(),
root_key_source: crate::network::RootKeySource::Configured,
api_url: Url::parse("http://localhost:8000").unwrap(),
http_gateway_url: None,
use_friendly_domains: false,
Expand All @@ -359,6 +360,7 @@ async fn test_get_agent_for_env_uses_environment_network() {
"staging",
NetworkAccess {
root_key: staging_root_key.clone(),
root_key_source: crate::network::RootKeySource::Configured,
api_url: Url::parse("http://staging:9000").unwrap(),
http_gateway_url: None,
use_friendly_domains: false,
Expand Down Expand Up @@ -433,6 +435,7 @@ async fn test_get_agent_for_network_success() {
"local",
NetworkAccess {
root_key: root_key.clone(),
root_key_source: crate::network::RootKeySource::Configured,
api_url: Url::parse("http://localhost:8000").unwrap(),
http_gateway_url: None,
use_friendly_domains: false,
Expand Down Expand Up @@ -647,6 +650,7 @@ async fn test_get_agent_defaults_inside_project_with_default_local() {
LOCAL,
NetworkAccess {
root_key: local_root_key.clone(),
root_key_source: crate::network::RootKeySource::Configured,
api_url: Url::parse(DEFAULT_LOCAL_NETWORK_URL).unwrap(),
http_gateway_url: None,
use_friendly_domains: false,
Expand Down Expand Up @@ -720,6 +724,7 @@ async fn test_get_agent_defaults_with_overridden_local_network() {
LOCAL,
NetworkAccess {
root_key: custom_root_key.clone(),
root_key_source: crate::network::RootKeySource::Configured,
api_url: Url::parse("http://localhost:9000").unwrap(), // Custom port
http_gateway_url: None,
use_friendly_domains: false,
Expand Down Expand Up @@ -820,6 +825,7 @@ async fn test_get_agent_defaults_with_overridden_local_environment() {
LOCAL,
NetworkAccess {
root_key: local_root_key.clone(),
root_key_source: crate::network::RootKeySource::Configured,
api_url: Url::parse(DEFAULT_LOCAL_NETWORK_URL).unwrap(),
http_gateway_url: None,
use_friendly_domains: false,
Expand All @@ -829,6 +835,7 @@ async fn test_get_agent_defaults_with_overridden_local_environment() {
"custom",
NetworkAccess {
root_key: custom_root_key.clone(),
root_key_source: crate::network::RootKeySource::Configured,
api_url: Url::parse("http://localhost:7000").unwrap(),
http_gateway_url: None,
use_friendly_domains: false,
Expand Down Expand Up @@ -864,6 +871,7 @@ async fn test_get_agent_explicit_network_inside_project() {
LOCAL,
NetworkAccess {
root_key: local_root_key.clone(),
root_key_source: crate::network::RootKeySource::Configured,
api_url: Url::parse(DEFAULT_LOCAL_NETWORK_URL).unwrap(),
http_gateway_url: None,
use_friendly_domains: false,
Expand All @@ -873,6 +881,7 @@ async fn test_get_agent_explicit_network_inside_project() {
"staging",
NetworkAccess {
root_key: staging_root_key.clone(),
root_key_source: crate::network::RootKeySource::Configured,
api_url: Url::parse("http://localhost:8001").unwrap(),
http_gateway_url: None,
use_friendly_domains: false,
Expand Down Expand Up @@ -909,6 +918,7 @@ async fn test_get_agent_explicit_environment_inside_project() {
LOCAL,
NetworkAccess {
root_key: local_root_key.clone(),
root_key_source: crate::network::RootKeySource::Configured,
api_url: Url::parse(DEFAULT_LOCAL_NETWORK_URL).unwrap(),
http_gateway_url: None,
use_friendly_domains: false,
Expand All @@ -918,6 +928,7 @@ async fn test_get_agent_explicit_environment_inside_project() {
"staging",
NetworkAccess {
root_key: staging_root_key.clone(),
root_key_source: crate::network::RootKeySource::Configured,
api_url: Url::parse("http://localhost:8001").unwrap(),
http_gateway_url: None,
use_friendly_domains: false,
Expand Down
Loading
Loading