From f3e4cc2960c1dcaefe94be6b5fc7ca36e649ccc9 Mon Sep 17 00:00:00 2001 From: Adam Spofford Date: Wed, 9 Sep 2026 08:20:40 -0700 Subject: [PATCH 1/6] refactor: give operations a project-scoped Host instead of Context MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `operations::deploy` and `operations::settings` were the only things in `icp` reaching for `Context`, and `Context` is the bag that also holds the identity loader, the keyring, the global directories and the password prompt. Nothing app-scoped can leave this crate while an operation can still reach all of that. `icp::host::Host` is that surface instead: the project loader, the id and artifact stores, the builder, the syncer, network resolution, and the environment/canister-id resolution methods that used to hang off `Context`. `Context` now holds a `Host` beside its own app-side fields. `deploy` takes `(&Host, &Agent, &PackageCache, ...)` — the agent and the package cache resolved by the command — so the operations layer no longer knows that identities exist. `Context::update_custom_domains` becomes `network::Access::publish_friendly_domains`. The project side collects every environment's `friendly name -> canister id` entries; the network side decides which of them a running gateway serves, and where the file goes. That was the one project-to-app call inside `deploy`. Two intended behavior changes fall out: - The identity is unlocked before the build rather than after, so a deploy aimed at a network that is not running fails before spending time on a build. - `icp deploy` builds one agent for the whole run and reuses it for the URLs printed at the end rather than building a second one, so a network with `root-key: fetch` is fetched, and warned about, once. To keep the first of those from costing an error message, `resolve_targets` now checks the names it was given while that is still a question about the project alone: a canister-name typo is reported as such instead of being pre-empted by whatever an unreachable network had to say. --- crates/icp-cli/src/commands/args.rs | 5 +- crates/icp-cli/src/commands/build.rs | 17 +- crates/icp-cli/src/commands/canister/call.rs | 15 +- .../icp-cli/src/commands/canister/create.rs | 22 +- .../icp-cli/src/commands/canister/delete.rs | 9 +- .../icp-cli/src/commands/canister/install.rs | 5 +- crates/icp-cli/src/commands/canister/link.rs | 10 +- crates/icp-cli/src/commands/canister/list.rs | 2 +- .../src/commands/canister/migrate_id.rs | 2 +- .../src/commands/canister/settings/sync.rs | 4 +- .../src/commands/canister/settings/update.rs | 4 +- .../icp-cli/src/commands/canister/status.rs | 5 +- crates/icp-cli/src/commands/deploy.rs | 36 +- .../icp-cli/src/commands/environment/list.rs | 2 +- crates/icp-cli/src/commands/network/list.rs | 2 +- crates/icp-cli/src/commands/network/ping.rs | 4 +- crates/icp-cli/src/commands/network/start.rs | 6 +- crates/icp-cli/src/commands/network/status.rs | 6 +- crates/icp-cli/src/commands/network/stop.rs | 4 +- crates/icp-cli/src/commands/project/bundle.rs | 16 +- crates/icp-cli/src/commands/project/show.rs | 7 +- crates/icp-cli/src/commands/sync.rs | 16 +- crates/icp-cli/src/complete.rs | 2 +- crates/icp-cli/src/options.rs | 2 +- crates/icp/src/context/init.rs | 18 +- crates/icp/src/context/mod.rs | 470 +----------------- crates/icp/src/context/tests.rs | 399 +++++++++------ crates/icp/src/host.rs | 461 +++++++++++++++++ crates/icp/src/lib.rs | 1 + crates/icp/src/network/mod.rs | 72 ++- crates/icp/src/operations/deploy.rs | 108 ++-- crates/icp/src/operations/settings.rs | 12 +- 32 files changed, 993 insertions(+), 751 deletions(-) create mode 100644 crates/icp/src/host.rs diff --git a/crates/icp-cli/src/commands/args.rs b/crates/icp-cli/src/commands/args.rs index 1a5c00652..5d71c81b0 100644 --- a/crates/icp-cli/src/commands/args.rs +++ b/crates/icp-cli/src/commands/args.rs @@ -4,11 +4,14 @@ use anyhow::{Context as _, bail}; use candid::Principal; use clap::{Args, ValueHint}; use clap_complete::ArgValueCandidates; -use icp::context::{CanisterSelection, EnvironmentSelection, NetworkSelection}; use icp::identity::IdentitySelection; use icp::manifest::ArgsFormat; use icp::prelude::PathBuf; use icp::{CanisterArgs, fs}; +use icp::{ + context::NetworkSelection, + host::{CanisterSelection, EnvironmentSelection}, +}; use crate::options::{EnvironmentOpt, IdentityOpt, NetworkOpt}; diff --git a/crates/icp-cli/src/commands/build.rs b/crates/icp-cli/src/commands/build.rs index 47914ae60..de8a8e44e 100644 --- a/crates/icp-cli/src/commands/build.rs +++ b/crates/icp-cli/src/commands/build.rs @@ -1,7 +1,7 @@ use clap::Args; use clap_complete::ArgValueCandidates; use futures::future::try_join_all; -use icp::context::{Context, EnvironmentSelection}; +use icp::{context::Context, host::EnvironmentSelection}; use tracing::info; @@ -34,7 +34,7 @@ pub(crate) async fn exec(ctx: &Context, args: &BuildArgs) -> Result<(), anyhow:: let environment_selection: EnvironmentSelection = args.environment.0.clone().into(); // Load target environment - let env = ctx.get_environment(&environment_selection).await?; + let env = ctx.host.get_environment(&environment_selection).await?; // Determine which canisters to build let cnames = match args.canisters.is_empty() { @@ -50,11 +50,10 @@ pub(crate) async fn exec(ctx: &Context, args: &BuildArgs) -> Result<(), anyhow:: return Ok(()); } - let canisters_to_build = try_join_all( - cnames - .iter() - .map(|name| ctx.get_canister_and_path_for_env(name, &environment_selection)), - ) + let canisters_to_build = try_join_all(cnames.iter().map(|name| { + ctx.host + .get_canister_and_path_for_env(name, &environment_selection) + })) .await?; // Build the selected canisters info!("Building canisters:"); @@ -64,8 +63,8 @@ pub(crate) async fn exec(ctx: &Context, args: &BuildArgs) -> Result<(), anyhow:: build_many( canisters_to_build, environment_selection.name(), - ctx.builder.clone(), - ctx.artifacts.clone(), + ctx.host.builder.clone(), + ctx.host.artifacts.clone(), &pkg_cache, reporter, ) diff --git a/crates/icp-cli/src/commands/canister/call.rs b/crates/icp-cli/src/commands/canister/call.rs index fee4c2ad2..829c4f30d 100644 --- a/crates/icp-cli/src/commands/canister/call.rs +++ b/crates/icp-cli/src/commands/canister/call.rs @@ -4,7 +4,6 @@ use candid_parser::assist; use candid_parser::parse_idl_args; use clap::{Args, ValueHint}; use ic_agent::agent::EffectiveId; -use icp::context::{Context, EnvironmentSelection, NetworkSelection}; use icp::manifest::ArgsFormat; use icp::network::{Configuration as NetworkConfiguration, RootKeySpec}; use icp::parsers::{CyclesAmount, DurationAmount}; @@ -12,6 +11,10 @@ use icp::prelude::*; use icp::signed_message::{ self, CallType, Destination, Request, SignedMessage, Summary, WindowState, }; +use icp::{ + context::{Context, NetworkSelection}, + host::EnvironmentSelection, +}; use std::io::{self, Write}; use std::str::FromStr; use time::{OffsetDateTime, format_description::well_known::Rfc3339}; @@ -505,7 +508,7 @@ async fn resolve_network_offline( | (EnvironmentSelection::Named(_), NetworkSelection::Url(_, _)) => { bail!("You can't specify both an environment and a network") } - (_, NetworkSelection::Default) => ctx.get_environment(environment).await?.network, + (_, NetworkSelection::Default) => ctx.host.get_environment(environment).await?.network, (EnvironmentSelection::Default, _) => ctx.get_network(network).await?, }; @@ -525,7 +528,7 @@ async fn resolve_network_offline( // A managed network's root key comes out of the descriptor this machine // wrote when it started the network: a local file, not a request. NetworkConfiguration::Managed { .. } => { - let access = ctx.network.access(&net).await?; + let access = ctx.host.network.access(&net).await?; Ok((access.api_url, RootKeySpec::Explicit(access.root_key))) } } @@ -550,11 +553,11 @@ fn floor_to_minute(t: OffsetDateTime) -> OffsetDateTime { /// by principal rather than by name, simply yields nothing. async fn local_candid_type( ctx: &Context, - canister: &icp::context::CanisterSelection, + canister: &icp::host::CanisterSelection, ) -> Option { - let icp::context::CanisterSelection::Named(name) = canister else { + let icp::host::CanisterSelection::Named(name) = canister else { return None; }; - let wasm = ctx.artifacts.lookup(name).await.ok()?; + let wasm = ctx.host.artifacts.lookup(name).await.ok()?; CanisterInterface::from_text(extract_candid_service(&wasm)?).ok() } diff --git a/crates/icp-cli/src/commands/canister/create.rs b/crates/icp-cli/src/commands/canister/create.rs index f406718d9..3ecea0749 100644 --- a/crates/icp-cli/src/commands/canister/create.rs +++ b/crates/icp-cli/src/commands/canister/create.rs @@ -6,11 +6,14 @@ use candid::{Nat, Principal}; use clap::{ArgGroup, Args, Parser}; use ic_management_canister_types::CanisterSettings as MgmtCanisterSettings; use icp::canister::resolve_controllers; -use icp::context::{Context, EnvironmentSelection, NetworkSelection}; use icp::identity::IdentitySelection; use icp::parsers::{CyclesAmount, DurationAmount, MemoryAmount, parse_token_amount}; use icp::store_id::IdMapping; -use icp::{Canister, context::CanisterSelection, prelude::*}; +use icp::{Canister, host::CanisterSelection, prelude::*}; +use icp::{ + context::{Context, NetworkSelection}, + host::EnvironmentSelection, +}; use serde::Serialize; use tracing::{info, warn}; @@ -340,12 +343,13 @@ async fn create_project_canister(ctx: &Context, args: &CreateArgs) -> Result<(), CanisterSelection::Principal(_) => Err(anyhow!("Cannot create a canister by principal"))?, }; - let env = ctx.get_environment(&selections.environment).await?; + let env = ctx.host.get_environment(&selections.environment).await?; let (_, canister_info) = env.get_canister_info(&canister).map_err(|e| anyhow!(e))?; if ctx + .host .get_canister_id_for_env( - &icp::context::CanisterSelection::Named(canister.clone()), + &icp::host::CanisterSelection::Named(canister.clone()), &selections.environment, ) .await @@ -363,6 +367,7 @@ async fn create_project_canister(ctx: &Context, args: &CreateArgs) -> Result<(), .get_agent_for_env(&selections.identity, &selections.environment) .await?; let ids = ctx + .host .ids_by_environment(&selections.environment) .await .map_err(|e| anyhow!(e))?; @@ -385,11 +390,12 @@ async fn create_project_canister(ctx: &Context, args: &CreateArgs) -> Result<(), let id = create_operation.create(&canister_settings).await?; - ctx.set_canister_id_for_env(&canister, id, &selections.environment) + ctx.host + .set_canister_id_for_env(&canister, id, &selections.environment) .await?; icp::operations::settings::sync_controller_dependents( - ctx, + &ctx.host, &agent, args.proxy, &canister, @@ -398,7 +404,9 @@ async fn create_project_canister(ctx: &Context, args: &CreateArgs) -> Result<(), .await .map_err(|e| anyhow!(e))?; - ctx.update_custom_domains(&selections.environment).await; + ctx.host + .update_custom_domains(&selections.environment) + .await; if args.quiet { println!("{id}"); diff --git a/crates/icp-cli/src/commands/canister/delete.rs b/crates/icp-cli/src/commands/canister/delete.rs index 0d49cbb2a..7643ce9ea 100644 --- a/crates/icp-cli/src/commands/canister/delete.rs +++ b/crates/icp-cli/src/commands/canister/delete.rs @@ -2,7 +2,7 @@ use anyhow::anyhow; use candid::Principal; use clap::Args; use ic_management_canister_types::CanisterIdRecord; -use icp::context::{CanisterSelection, Context}; +use icp::{context::Context, host::CanisterSelection}; use icp::operations::{proxy_management, recover_cycles}; @@ -68,9 +68,12 @@ pub(crate) async fn exec(ctx: &Context, args: &DeleteArgs) -> Result<(), anyhow: // Remove canister ID from the id store if it was referenced by name if let CanisterSelection::Named(canister_name) = &selections.canister { - ctx.remove_canister_id_for_env(canister_name, &selections.environment) + ctx.host + .remove_canister_id_for_env(canister_name, &selections.environment) .await?; - ctx.update_custom_domains(&selections.environment).await; + ctx.host + .update_custom_domains(&selections.environment) + .await; } Ok(()) diff --git a/crates/icp-cli/src/commands/canister/install.rs b/crates/icp-cli/src/commands/canister/install.rs index 293f3de8c..a765f0399 100644 --- a/crates/icp-cli/src/commands/canister/install.rs +++ b/crates/icp-cli/src/commands/canister/install.rs @@ -5,9 +5,9 @@ use candid::Principal; use clap::{Args, ValueHint}; use dialoguer::Confirm; use ic_management_canister_types::CanisterInstallMode; -use icp::context::{CanisterSelection, Context}; use icp::fs; use icp::prelude::*; +use icp::{context::Context, host::CanisterSelection}; use tracing::{info, warn}; use icp::operations::{ @@ -76,7 +76,8 @@ pub(crate) async fn exec(ctx: &Context, args: &InstallArgs) -> Result<(), anyhow ))?; } }; - ctx.artifacts + ctx.host + .artifacts .lookup(canister) .await .map_err(|e| anyhow!(e))? diff --git a/crates/icp-cli/src/commands/canister/link.rs b/crates/icp-cli/src/commands/canister/link.rs index e8c219ad2..03f99d635 100644 --- a/crates/icp-cli/src/commands/canister/link.rs +++ b/crates/icp-cli/src/commands/canister/link.rs @@ -2,7 +2,7 @@ use anyhow::bail; use candid::Principal; use clap::Args; use clap_complete::ArgValueCandidates; -use icp::context::{Context, EnvironmentSelection}; +use icp::{context::Context, host::EnvironmentSelection}; use tracing::info; use crate::options::EnvironmentOpt; @@ -35,7 +35,7 @@ pub(crate) async fn exec(ctx: &Context, args: &LinkArgs) -> Result<(), anyhow::E // A principal must map to at most one canister within an environment; linking an // ID already claimed by another canister would create an ambiguous mapping. - let existing = ctx.ids_by_environment(&environment).await?; + let existing = ctx.host.ids_by_environment(&environment).await?; if let Some((owner, _)) = existing .iter() .find(|(name, id)| **id == args.principal && *name != &args.name) @@ -50,11 +50,13 @@ pub(crate) async fn exec(ctx: &Context, args: &LinkArgs) -> Result<(), anyhow::E // Replacing an existing entry requires clearing it first; the id store refuses // to register a name that is already mapped. if args.force { - ctx.remove_canister_id_for_env(&args.name, &environment) + ctx.host + .remove_canister_id_for_env(&args.name, &environment) .await?; } - ctx.set_canister_id_for_env(&args.name, args.principal, &environment) + ctx.host + .set_canister_id_for_env(&args.name, args.principal, &environment) .await?; info!( diff --git a/crates/icp-cli/src/commands/canister/list.rs b/crates/icp-cli/src/commands/canister/list.rs index e2af589e1..456718640 100644 --- a/crates/icp-cli/src/commands/canister/list.rs +++ b/crates/icp-cli/src/commands/canister/list.rs @@ -21,7 +21,7 @@ pub(crate) struct ListArgs { pub(crate) async fn exec(ctx: &Context, args: &ListArgs) -> Result<(), anyhow::Error> { let environment_selection = args.environment.clone().into(); - let env = ctx.get_environment(&environment_selection).await?; + let env = ctx.host.get_environment(&environment_selection).await?; let canisters = env.canisters.keys().cloned().collect(); if args.json { serde_json::to_writer(stdout(), &JsonList { canisters })?; diff --git a/crates/icp-cli/src/commands/canister/migrate_id.rs b/crates/icp-cli/src/commands/canister/migrate_id.rs index 84eaac135..d0f109abd 100644 --- a/crates/icp-cli/src/commands/canister/migrate_id.rs +++ b/crates/icp-cli/src/commands/canister/migrate_id.rs @@ -15,7 +15,7 @@ use num_traits::ToPrimitive; use tracing::{info, warn}; use crate::commands::args::{self, Canister}; -use icp::context::CanisterSelection; +use icp::host::CanisterSelection; use icp::operations::canister_migration::{ get_subnet_for_canister, migrate_canister, migration_status, }; diff --git a/crates/icp-cli/src/commands/canister/settings/sync.rs b/crates/icp-cli/src/commands/canister/settings/sync.rs index 68d6e966b..1b42152e5 100644 --- a/crates/icp-cli/src/commands/canister/settings/sync.rs +++ b/crates/icp-cli/src/commands/canister/settings/sync.rs @@ -1,7 +1,7 @@ use anyhow::bail; use candid::Principal; use clap::Args; -use icp::context::{CanisterSelection, Context}; +use icp::{context::Context, host::CanisterSelection}; use tracing::warn; use crate::commands::args::CanisterCommandArgs; @@ -24,6 +24,7 @@ pub(crate) async fn exec(ctx: &Context, args: &SyncArgs) -> Result<(), anyhow::E }; let (_, canister) = ctx + .host .get_canister_and_path_for_env(name, &selections.environment) .await?; @@ -42,6 +43,7 @@ pub(crate) async fn exec(ctx: &Context, args: &SyncArgs) -> Result<(), anyhow::E ) .await?; let ids = ctx + .host .ids_by_environment(&selections.environment) .await .map_err(|e| anyhow::anyhow!(e))?; diff --git a/crates/icp-cli/src/commands/canister/settings/update.rs b/crates/icp-cli/src/commands/canister/settings/update.rs index 50d7d2599..f0cc4b3cb 100644 --- a/crates/icp-cli/src/commands/canister/settings/update.rs +++ b/crates/icp-cli/src/commands/canister/settings/update.rs @@ -10,8 +10,8 @@ use ic_management_canister_types::{ }; use icp::ProjectLoadError; use icp::canister::Visibility; -use icp::context::{CanisterSelection, Context}; use icp::parsers::{CyclesAmount, DurationAmount, MemoryAmount}; +use icp::{context::Context, host::CanisterSelection}; use std::collections::{HashMap, HashSet}; use tracing::warn; @@ -412,7 +412,7 @@ pub(crate) async fn exec(ctx: &Context, args: &UpdateArgs) -> Result<(), anyhow: .await?; let configured_settings = if let CanisterSelection::Named(name) = &selections.canister { - match ctx.project.load().await { + match ctx.host.project.load().await { Ok(p) => p.canisters[name].1.settings.clone(), Err(ProjectLoadError::Locate { .. }) => <_>::default(), Err(e) => bail!("failed to load project: {}", e), diff --git a/crates/icp-cli/src/commands/canister/status.rs b/crates/icp-cli/src/commands/canister/status.rs index 5eca81c4e..6e9752237 100644 --- a/crates/icp-cli/src/commands/canister/status.rs +++ b/crates/icp-cli/src/commands/canister/status.rs @@ -5,7 +5,8 @@ use ic_agent::{Agent, AgentError, agent::RejectResponse, export::Principal}; use ic_management_canister_types::{CanisterIdRecord, CanisterStatusResult, EnvironmentVariable}; use icp::{ canister::Visibility, - context::{CanisterSelection, Context, EnvironmentSelection, NetworkSelection}, + context::{Context, NetworkSelection}, + host::{CanisterSelection, EnvironmentSelection}, identity::IdentitySelection, }; use serde::Serialize; @@ -131,7 +132,7 @@ async fn get_principals( }; } None => { - let env = ctx.get_environment(environment).await?; + let env = ctx.host.get_environment(environment).await?; for (_, c) in env.canisters.values() { let cid = ctx .get_canister_id( diff --git a/crates/icp-cli/src/commands/deploy.rs b/crates/icp-cli/src/commands/deploy.rs index ff3c9139e..889aceb74 100644 --- a/crates/icp-cli/src/commands/deploy.rs +++ b/crates/icp-cli/src/commands/deploy.rs @@ -6,7 +6,8 @@ use ic_agent::{Agent, AgentError}; use icp::operations::deploy::{DeployParams, DeployReport, deploy, resolve_targets}; use icp::parsers::CyclesAmount; use icp::{ - context::{CanisterSelection, Context, EnvironmentSelection}, + context::Context, + host::{CanisterSelection, EnvironmentSelection}, identity::IdentitySelection, network::Configuration as NetworkConfiguration, }; @@ -97,7 +98,7 @@ pub(crate) async fn exec(ctx: &Context, args: &DeployArgs) -> Result<(), anyhow: let environment_selection: EnvironmentSelection = args.environment.0.clone().into(); let identity_selection: IdentitySelection = args.identity.clone().into(); - let canisters = resolve_targets(ctx, &environment_selection, &args.names).await?; + let canisters = resolve_targets(&ctx.host, &environment_selection, &args.names).await?; // Skip doing any work if no canisters are targeted. Say so: an environment // whose `canisters` lists leave out everything in scope is otherwise an @@ -114,9 +115,16 @@ pub(crate) async fn exec(ctx: &Context, args: &DeployArgs) -> Result<(), anyhow: bail!("--args and --args-file can only be used when deploying a single canister"); } + // Resolved up front and used for the whole run, including the URLs printed + // at the end: one agent means one identity unlock and, for a network whose + // root key is fetched, one fetch rather than one per phase. + let agent = ctx + .get_agent_for_env(&identity_selection, &environment_selection) + .await?; + let pkg_cache = ctx.dirs.package_cache()?; + let params = DeployParams { environment: environment_selection.clone(), - identity: identity_selection.clone(), canisters: canisters.clone(), mode: args.mode.clone(), subnet: args.subnet, @@ -132,7 +140,15 @@ pub(crate) async fn exec(ctx: &Context, args: &DeployArgs) -> Result<(), anyhow: // command having to await it phase by phase. let mut report = DeployReport::default(); let result = rendered(ctx.debug, async |reporter| { - deploy(ctx, ¶ms, reporter, &mut report).await + deploy( + &ctx.host, + &agent, + &pkg_cache, + ¶ms, + reporter, + &mut report, + ) + .await }) .await; @@ -147,9 +163,6 @@ pub(crate) async fn exec(ctx: &Context, args: &DeployArgs) -> Result<(), anyhow: } result?; - let agent = ctx - .get_agent_for_env(&identity_selection, &environment_selection) - .await?; print_canister_urls(ctx, &environment_selection, agent, &canisters, args.json).await?; Ok(()) @@ -228,12 +241,12 @@ async fn print_canister_urls( ) -> Result<(), anyhow::Error> { use icp::network::custom_domains::{canister_gateway_url, gateway_domain}; - let env = ctx.get_environment(environment_selection).await?; + let env = ctx.host.get_environment(environment_selection).await?; // Get the network URL let (http_gateway_url, has_friendly) = match &env.network.configuration { NetworkConfiguration::Managed { managed: _ } => { - let access = ctx.network.access(&env.network).await?; + let access = ctx.host.network.access(&env.network).await?; (access.http_gateway_url.clone(), access.use_friendly_domains) } NetworkConfiguration::Connected { connected } => { @@ -253,6 +266,7 @@ async fn print_canister_urls( for name in canister_names { let canister_id = match ctx + .host .get_canister_id_for_env( &CanisterSelection::Named(name.clone()), environment_selection, @@ -386,12 +400,12 @@ async fn get_candid_ui_id( ctx: &Context, environment_selection: &EnvironmentSelection, ) -> Option { - let env = ctx.get_environment(environment_selection).await.ok()?; + let env = ctx.host.get_environment(environment_selection).await.ok()?; match &env.network.configuration { NetworkConfiguration::Managed { managed: _ } => { // Try to get the candid UI ID from the network descriptor - let nd = ctx.network.get_network_directory(&env.network).ok()?; + let nd = ctx.host.network.get_network_directory(&env.network).ok()?; if let Ok(Some(desc)) = nd.load_network_descriptor().await && let Some(candid_ui) = desc.candid_ui_canister_id { diff --git a/crates/icp-cli/src/commands/environment/list.rs b/crates/icp-cli/src/commands/environment/list.rs index 816aebdfa..11d16ad87 100644 --- a/crates/icp-cli/src/commands/environment/list.rs +++ b/crates/icp-cli/src/commands/environment/list.rs @@ -10,7 +10,7 @@ pub(crate) struct ListArgs; pub(crate) async fn exec(ctx: &Context, _: &ListArgs) -> Result<(), anyhow::Error> { // Load project - let pm = ctx.project.load().await?; + let pm = ctx.host.project.load().await?; for e in pm.environments.keys() { println!("{e}"); diff --git a/crates/icp-cli/src/commands/network/list.rs b/crates/icp-cli/src/commands/network/list.rs index 9a2a921e0..433511795 100644 --- a/crates/icp-cli/src/commands/network/list.rs +++ b/crates/icp-cli/src/commands/network/list.rs @@ -7,7 +7,7 @@ pub(crate) struct ListArgs; pub(crate) async fn exec(ctx: &Context, _: &ListArgs) -> Result<(), anyhow::Error> { // Load project - let pm = ctx.project.load().await?; + let pm = ctx.host.project.load().await?; for e in pm.networks.keys() { println!("{e}"); diff --git a/crates/icp-cli/src/commands/network/ping.rs b/crates/icp-cli/src/commands/network/ping.rs index efa4d9836..d26086c7b 100644 --- a/crates/icp-cli/src/commands/network/ping.rs +++ b/crates/icp-cli/src/commands/network/ping.rs @@ -62,7 +62,7 @@ pub(crate) async fn exec(ctx: &Context, args: &PingArgs) -> Result<(), anyhow::E .await? } else { // Load project - let _ = ctx.project.load().await?; + let _ = ctx.host.project.load().await?; // Convert args to selection and get network let selection: Result<_, _> = args.network_selection.clone().into(); @@ -71,7 +71,7 @@ pub(crate) async fn exec(ctx: &Context, args: &PingArgs) -> Result<(), anyhow::E // NetworkAccess // TODO We might want to expose the ctx.create_agent function that takes a NetworkAccess // instead of doing this - let access = ctx.network.access(&network).await?; + let access = ctx.host.network.access(&network).await?; let agent = ctx .get_agent_for_url(&IdentitySelection::Anonymous, &access.api_url) .await?; diff --git a/crates/icp-cli/src/commands/network/start.rs b/crates/icp-cli/src/commands/network/start.rs index 8c7e6764f..9d247bf56 100644 --- a/crates/icp-cli/src/commands/network/start.rs +++ b/crates/icp-cli/src/commands/network/start.rs @@ -68,7 +68,7 @@ pub(crate) struct StartArgs { pub(crate) async fn exec(ctx: &Context, args: &StartArgs) -> Result<(), anyhow::Error> { // Load project - let p = ctx.project.load().await?; + let p = ctx.host.project.load().await?; // Convert args to selection and get network let selection: Result<_, _> = args.network_selection.clone().into(); @@ -87,7 +87,7 @@ pub(crate) async fn exec(ctx: &Context, args: &StartArgs) -> Result<(), anyhow:: let pdir = &p.dir; // Network directory - let nd = ctx.network.get_network_directory(&network)?; + let nd = ctx.host.network.get_network_directory(&network)?; nd.ensure_exists() .context("failed to create network directory")?; @@ -125,7 +125,7 @@ pub(crate) async fn exec(ctx: &Context, args: &StartArgs) -> Result<(), anyhow:: for env in p.environments.values() { if env.network == network { // It's been ensured that the network is managed, so is_cache is true. - ctx.ids.cleanup(true, env.name.as_str())?; + ctx.host.ids.cleanup(true, env.name.as_str())?; } } diff --git a/crates/icp-cli/src/commands/network/status.rs b/crates/icp-cli/src/commands/network/status.rs index 84802085c..9f010c370 100644 --- a/crates/icp-cli/src/commands/network/status.rs +++ b/crates/icp-cli/src/commands/network/status.rs @@ -56,12 +56,12 @@ struct NetworkStatus { pub(crate) async fn exec(ctx: &Context, args: &StatusArgs) -> Result<(), anyhow::Error> { // Load project - let _ = ctx.project.load().await?; + let _ = ctx.host.project.load().await?; // Convert args to selection and get network let selection: Result<_, _> = args.network_selection.clone().into(); let network = ctx.get_network_or_environment(&selection?).await?; - let network_access = ctx.network.access(&network).await.context(format!( + let network_access = ctx.host.network.access(&network).await.context(format!( "unable to access network '{}', is it running?", network.name ))?; @@ -69,7 +69,7 @@ pub(crate) async fn exec(ctx: &Context, args: &StatusArgs) -> Result<(), anyhow: let status = match &network.configuration { Configuration::Managed { managed: _ } => { // Network directory - let nd = ctx.network.get_network_directory(&network)?; + let nd = ctx.host.network.get_network_directory(&network)?; // Load network descriptor let descriptor = nd diff --git a/crates/icp-cli/src/commands/network/stop.rs b/crates/icp-cli/src/commands/network/stop.rs index df34d1ce0..ec1f7911c 100644 --- a/crates/icp-cli/src/commands/network/stop.rs +++ b/crates/icp-cli/src/commands/network/stop.rs @@ -36,7 +36,7 @@ pub struct Cmd { pub async fn exec(ctx: &Context, cmd: &Cmd) -> Result<(), anyhow::Error> { // Load project - let _ = ctx.project.load().await?; + let _ = ctx.host.project.load().await?; // Convert args to selection and get network let selection: Result<_, _> = cmd.network_selection.clone().into(); @@ -47,7 +47,7 @@ pub async fn exec(ctx: &Context, cmd: &Cmd) -> Result<(), anyhow::Error> { }; // Network directory - let nd = ctx.network.get_network_directory(&network)?; + let nd = ctx.host.network.get_network_directory(&network)?; let descriptor = nd .load_network_descriptor() diff --git a/crates/icp-cli/src/commands/project/bundle.rs b/crates/icp-cli/src/commands/project/bundle.rs index a43ac03b3..4d2bddd4d 100644 --- a/crates/icp-cli/src/commands/project/bundle.rs +++ b/crates/icp-cli/src/commands/project/bundle.rs @@ -2,7 +2,8 @@ use std::collections::HashSet; use anyhow::Context as _; use clap::{Args, ValueHint}; -use icp::context::{Context, EnvironmentSelection}; +use icp::context::Context; +use icp::host::EnvironmentSelection; use icp::prelude::*; use tracing::warn; @@ -32,9 +33,14 @@ pub(crate) struct BundleArgs { } pub(crate) async fn exec(ctx: &Context, args: &BundleArgs) -> Result<(), anyhow::Error> { - let project = ctx.project.load().await.context("failed to load project")?; + let project = ctx + .host + .project + .load() + .await + .context("failed to load project")?; let environment_selection = EnvironmentSelection::Named(args.environment.clone()); - let env = ctx.get_environment(&environment_selection).await?; + let env = ctx.host.get_environment(&environment_selection).await?; let canisters: Vec<_> = project.canisters.into_values().collect(); let selected: HashSet = env.canisters.keys().cloned().collect(); @@ -52,8 +58,8 @@ pub(crate) async fn exec(ctx: &Context, args: &BundleArgs) -> Result<(), anyhow: canisters, &selected, &args.environment, - ctx.builder.clone(), - ctx.artifacts.clone(), + ctx.host.builder.clone(), + ctx.host.artifacts.clone(), &pkg_cache, reporter, &args.output, diff --git a/crates/icp-cli/src/commands/project/show.rs b/crates/icp-cli/src/commands/project/show.rs index 14998c4bb..757b05764 100644 --- a/crates/icp-cli/src/commands/project/show.rs +++ b/crates/icp-cli/src/commands/project/show.rs @@ -20,7 +20,12 @@ pub(crate) struct ShowArgs; /// after resolving recipes pub(crate) async fn exec(ctx: &Context, _: &ShowArgs) -> Result<(), anyhow::Error> { // Load the project manifest, which defines the canisters to be built. - let p = ctx.project.load().await.context("failed to load project")?; + let p = ctx + .host + .project + .load() + .await + .context("failed to load project")?; let yaml = serde_yaml::to_string(&p).expect("Serializing to yaml failed"); print!("{yaml}"); diff --git a/crates/icp-cli/src/commands/sync.rs b/crates/icp-cli/src/commands/sync.rs index 22415374e..b246f736a 100644 --- a/crates/icp-cli/src/commands/sync.rs +++ b/crates/icp-cli/src/commands/sync.rs @@ -4,8 +4,11 @@ use clap::Args; use clap_complete::ArgValueCandidates; use futures::future::try_join_all; use ic_management_canister_types::{CanisterId, CanisterIdRecord, CanisterStatusType}; -use icp::context::{CanisterSelection, Context, EnvironmentSelection}; use icp::identity::IdentitySelection; +use icp::{ + context::Context, + host::{CanisterSelection, EnvironmentSelection}, +}; use std::collections::BTreeMap; use tracing::info; @@ -40,7 +43,7 @@ pub(crate) async fn exec(ctx: &Context, args: &SyncArgs) -> Result<(), anyhow::E let identity_selection: IdentitySelection = args.identity.clone().into(); // Get environment - let env = ctx.get_environment(&environment_selection).await?; + let env = ctx.host.get_environment(&environment_selection).await?; // Determine which canisters to sync let cnames = match args.canisters.is_empty() { @@ -64,9 +67,11 @@ pub(crate) async fn exec(ctx: &Context, args: &SyncArgs) -> Result<(), anyhow::E // Prepare list of canisters with their info for syncing let sync_canisters = try_join_all(cnames.iter().map(|name| async { let (canister_path, info) = ctx + .host .get_canister_and_path_for_env(name, &environment_selection) .await?; let cid = ctx + .host .get_canister_id_for_env( &CanisterSelection::Named(name.clone()), &environment_selection, @@ -121,18 +126,19 @@ pub(crate) async fn exec(ctx: &Context, args: &SyncArgs) -> Result<(), anyhow::E info!("Syncing canisters:"); let canister_ids: BTreeMap = ctx + .host .ids_by_environment(&environment_selection) .await? .into_iter() .collect(); let pkg_cache = ctx.dirs.package_cache()?; - let project_dir = ctx.project.load().await?.dir; - let urls = ctx.network.urls(&env.network).await?; + let project_dir = ctx.host.project.load().await?.dir; + let urls = ctx.host.network.urls(&env.network).await?; rendered(ctx.debug, async |reporter| { sync_many( - ctx.syncer.clone(), + ctx.host.syncer.clone(), agent, sync_canisters, project_dir, diff --git a/crates/icp-cli/src/complete.rs b/crates/icp-cli/src/complete.rs index 66a48cf58..36b9ef6f5 100644 --- a/crates/icp-cli/src/complete.rs +++ b/crates/icp-cli/src/complete.rs @@ -77,7 +77,7 @@ fn project() -> Option<&'static Project> { static PROJECT: OnceLock> = OnceLock::new(); PROJECT - .get_or_init(|| block_on(context()?.project.load())?.ok()) + .get_or_init(|| block_on(context()?.host.project.load())?.ok()) .as_ref() } diff --git a/crates/icp-cli/src/options.rs b/crates/icp-cli/src/options.rs index dfe70e416..cab7104ee 100644 --- a/crates/icp-cli/src/options.rs +++ b/crates/icp-cli/src/options.rs @@ -1,10 +1,10 @@ use clap::error::ErrorKind; use clap::{ArgGroup, ArgMatches, Args, FromArgMatches}; use clap_complete::ArgValueCandidates; -use icp::context::{EnvironmentSelection, NetworkSelection}; use icp::identity::IdentitySelection; use icp::network::RootKeySpec; use icp::prelude::LOCAL; +use icp::{context::NetworkSelection, host::EnvironmentSelection}; use url::Url; mod heading { diff --git a/crates/icp/src/context/init.rs b/crates/icp/src/context/init.rs index 4af5cda79..d96c1d243 100644 --- a/crates/icp/src/context/init.rs +++ b/crates/icp/src/context/init.rs @@ -12,7 +12,8 @@ use crate::store_artifact::ArtifactStore; use std::time::Duration; use crate::{ - Lazy, ProjectLoadImpl, agent, identity, identity::PasswordFunc, manifest, network, store_id, + Lazy, ProjectLoadImpl, agent, host::Host, identity, identity::PasswordFunc, manifest, network, + store_id, }; #[derive(Debug, Snafu)] @@ -140,15 +141,18 @@ pub fn initialize( // Setup environment Ok(Context { + host: Host { + project: pload, + ids, + artifacts, + builder, + syncer, + network: netaccess, + telemetry_data: telemetry_data.clone(), + }, dirs, - ids, - artifacts, - project: pload, identity: idload, - network: netaccess, agent: agent_creator, - builder, - syncer, debug, telemetry_data, password_func, diff --git a/crates/icp/src/context/mod.rs b/crates/icp/src/context/mod.rs index 3ae5ea72a..938ac1996 100644 --- a/crates/icp/src/context/mod.rs +++ b/crates/icp/src/context/mod.rs @@ -2,15 +2,16 @@ use std::sync::Arc; use url::Url; use crate::{ - Canister, agent::CreateAgentError, - canister::{build::Build, sync::Synchronize}, directories, + host::{ + CanisterSelection, EnvironmentSelection, GetCanisterIdForEnvError, GetEnvironmentError, + Host, + }, identity::IdentitySelection, manifest::network::RootKeySpec, network::{Configuration as NetworkConfiguration, access::NetworkAccess}, prelude::*, - store_id::{IdMapping, LookupIdError}, telemetry_data::NetworkType, }; use candid::Principal; @@ -35,24 +36,6 @@ pub enum NetworkSelection { Url(Url, RootKeySpec), } -/// Selection type for environments - similar to IdentitySelection -#[derive(Clone, Debug, PartialEq)] -pub enum EnvironmentSelection { - /// Use the default environment (local) - Default, - /// Use a named environment - Named(String), -} - -impl EnvironmentSelection { - pub fn name(&self) -> &str { - match self { - EnvironmentSelection::Default => LOCAL, - EnvironmentSelection::Named(name) => name, - } - } -} - /// Selection type for network commands that accept either network name or environment #[derive(Clone, Debug, PartialEq)] pub enum NetworkOrEnvironmentSelection { @@ -62,44 +45,20 @@ pub enum NetworkOrEnvironmentSelection { Environment(String), } -/// Selection type for canisters - similar to IdentitySelection -#[derive(Clone, Debug, PartialEq)] -pub enum CanisterSelection { - /// Use a canister by name (requires project context) - Named(String), - /// Use a canister by principal - Principal(Principal), -} - #[derive(Clone)] pub struct Context { + /// The project-side resources operations run against. + pub host: Host, + /// Various cli-related directories (cache, configuration, etc). pub dirs: Arc, - /// Canisters ID Store for lookup and storage - pub ids: Arc, - - /// An artifact store for canister build artifacts - pub artifacts: Arc, - - /// Project loader - pub project: Arc, - /// Identity loader identity: Arc, - /// NetworkAccess loader - pub network: Arc, - /// Agent creator agent: Arc, - /// Canister builder - pub builder: Arc, - - /// Canister synchronizer - pub syncer: Arc, - /// Whether debug is enabled pub debug: bool, @@ -126,49 +85,6 @@ impl Context { }) } - /// Gets an environment by name from the currently loaded project. - /// - /// # Errors - /// - /// Returns an error if the project cannot be loaded or if the environment is not found. - pub async fn get_environment( - &self, - environment: &EnvironmentSelection, - ) -> Result { - // Load project - let p = self.project.load().await?; - - // Load target environment - let env = p - .environments - .get(environment.name()) - .context(EnvironmentNotFoundSnafu { - name: environment.name().to_owned(), - })?; - - // Strict rule: every vendored member must declare the selected - // environment. Enforced here (not at load time) so a member missing some - // other environment never blocks deploys to the ones it does declare. - if let Some(missing) = p.member_missing_envs.get(environment.name()) - && let Some(member) = missing.first() - { - return MissingDependencyEnvironmentSnafu { - environment: environment.name().to_owned(), - member: member.clone(), - } - .fail(); - } - - let network_type = match &env.network.configuration { - NetworkConfiguration::Managed { .. } => NetworkType::Managed, - NetworkConfiguration::Connected { .. } => NetworkType::Connected, - }; - self.telemetry_data.set_network_type(network_type); - self.telemetry_data.set_project(&p); - - Ok(env.clone()) - } - /// Gets an Network by name from the currently loaded project. /// /// # Errors @@ -180,8 +96,8 @@ impl Context { ) -> Result { let network = match network_selection { NetworkSelection::Named(network_name) => { - if self.project.exists().await? { - let p = self.project.load().await?; + if self.host.project.exists().await? { + let p = self.host.project.load().await?; let net = p.networks.get(network_name).context(NetworkNotFoundSnafu { name: network_name.to_owned(), })?; @@ -243,147 +159,20 @@ impl Context { } NetworkOrEnvironmentSelection::Environment(env_name) => { let env_selection = EnvironmentSelection::Named(env_name.clone()); - let env = self.get_environment(&env_selection).await?; + let env = self.host.get_environment(&env_selection).await?; Ok(env.network) } } } - pub async fn get_canister_and_path_for_env( - &self, - canister_name: &str, - environment: &EnvironmentSelection, - ) -> Result<(PathBuf, Canister), GetEnvCanisterError> { - let p = self.project.load().await?; - let Some((path, canister)) = p.get_canister(canister_name) else { - return CanisterNotFoundInProjectSnafu { - canister_name: canister_name.to_owned(), - } - .fail(); - }; - - let env = self.get_environment(environment).await?; - if !env.contains_canister(canister_name) { - return CanisterNotInEnvSnafu { - canister_name: canister_name.to_owned(), - environment_name: environment.name().to_owned(), - } - .fail(); - } - Ok((path.clone(), canister.clone())) - } - - /// Gets the canister ID for a given canister selection in a specified environment. - /// - /// # Errors - /// - /// Returns an error if the environment cannot be loaded or if the canister ID cannot be found. - pub async fn get_canister_id_for_env( - &self, - canister: &CanisterSelection, - environment: &EnvironmentSelection, - ) -> Result { - let principal = match canister { - CanisterSelection::Named(canister_name) => { - let env = self.get_environment(environment).await?; - let is_cache = match env.network.configuration { - NetworkConfiguration::Managed { .. } => true, - NetworkConfiguration::Connected { .. } => false, - }; - - if !env.canisters.contains_key(canister_name) { - return CanisterNotFoundInEnvSnafu { - canister_name: canister_name.to_owned(), - environment_name: environment.name().to_owned(), - } - .fail(); - } - - // Lookup the canister id - self.ids - .lookup(is_cache, &env.name, canister_name) - .context(CanisterIdLookupSnafu { - canister_name: canister_name.to_owned(), - environment_name: environment.name().to_owned(), - })? - } - CanisterSelection::Principal(principal) => { - // Make sure a valid environment was requested - let _ = self.get_environment(environment).await?; - *principal - } - }; - - Ok(principal) - } - - /// Sets the canister ID for a given canister name in a specified environment. - /// - /// # Errors - /// - /// Returns an error if the environment cannot be loaded or if the canister ID cannot be registered. - pub async fn set_canister_id_for_env( - &self, - canister_name: &str, - canister_id: Principal, - environment: &EnvironmentSelection, - ) -> Result<(), SetCanisterIdForEnvError> { - let env = self.get_environment(environment).await?; - let is_cache = match env.network.configuration { - NetworkConfiguration::Managed { .. } => true, - NetworkConfiguration::Connected { .. } => false, - }; - - if !env.canisters.contains_key(canister_name) { - return SetCanisterNotFoundInEnvSnafu { - canister_name: canister_name.to_owned(), - environment_name: environment.name().to_owned(), - } - .fail(); - } - - // Register the canister id - self.ids - .register(is_cache, &env.name, canister_name, canister_id) - .context(CanisterIdRegisterSnafu { - canister_name: canister_name.to_owned(), - environment_name: environment.name().to_owned(), - })?; - - Ok(()) - } - - /// Removes the canister ID for a given canister name in a specified environment. - pub async fn remove_canister_id_for_env( - &self, - canister_name: &str, - environment: &EnvironmentSelection, - ) -> Result<(), RemoveCanisterIdForEnvError> { - let env = self.get_environment(environment).await?; - let is_cache = match env.network.configuration { - NetworkConfiguration::Managed { .. } => true, - NetworkConfiguration::Connected { .. } => false, - }; - - // Unregister the canister id - self.ids - .unregister(is_cache, &env.name, canister_name) - .context(CanisterIdUnregisterSnafu { - canister_name: canister_name.to_owned(), - environment_name: environment.name().to_owned(), - })?; - - Ok(()) - } - /// Creates an agent for a given identity and environment. pub async fn get_agent_for_env( &self, identity: &IdentitySelection, environment: &EnvironmentSelection, ) -> Result { - let env = self.get_environment(environment).await?; - let access = self.network.access(&env.network).await?; + let env = self.host.get_environment(environment).await?; + let access = self.host.network.access(&env.network).await?; let id = self .get_identity(identity, Some(access.root_key.clone())) .await?; @@ -397,7 +186,7 @@ impl Context { network_selection: &NetworkSelection, ) -> Result { let network = self.get_network(network_selection).await?; - let access = self.network.access(&network).await?; + let access = self.host.network.access(&network).await?; let id = self .get_identity(identity, Some(access.root_key.clone())) .await?; @@ -519,125 +308,26 @@ impl Context { } // Only environment specified - (_, NetworkSelection::Default) => { - Ok(self.get_canister_id_for_env(canister, environment).await?) - } + (_, NetworkSelection::Default) => Ok(self + .host + .get_canister_id_for_env(canister, environment) + .await?), } } } } - pub async fn ids_by_environment( - &self, - environment: &EnvironmentSelection, - ) -> Result { - let env = self.get_environment(environment).await?; - let is_cache = match env.network.configuration { - NetworkConfiguration::Managed { .. } => true, - NetworkConfiguration::Connected { .. } => false, - }; - self.ids - .lookup_by_environment(is_cache, environment.name()) - .context(IdsByEnvironmentLookupSnafu { - environment_name: environment.name().to_owned(), - }) - } - - /// Updates the `custom-domains.txt` file for the managed network used by the - /// given environment. Collects ID mappings from all environments that share - /// the same managed network, then writes the file to the network's status - /// directory. - /// - /// This is a best-effort operation: errors are logged but not propagated, - /// because a failure to update friendly domains should not block canister - /// creation or deletion. - pub async fn update_custom_domains(&self, environment: &EnvironmentSelection) { - let Ok(env) = self.get_environment(environment).await else { - return; - }; - let NetworkConfiguration::Managed { .. } = &env.network.configuration else { - return; - }; - let Ok(nd) = self.network.get_network_directory(&env.network) else { - return; - }; - let Ok(Some(desc)) = nd.load_network_descriptor().await else { - return; - }; - let Some(status_dir) = &desc.status_dir else { - return; - }; - let gateway_url_str = format!("http://{}:{}", desc.gateway.host, desc.gateway.port); - let Ok(gateway_url) = Url::parse(&gateway_url_str) else { - tracing::warn!("Failed to parse gateway URL {gateway_url_str:?} for custom domains"); - return; - }; - let domain = crate::network::custom_domains::gateway_domain(&gateway_url); - let Some(domain) = domain else { - return; - }; - // Collect mappings from all environments that use this network - let Ok(project) = self.project.load().await else { - return; - }; - // For each environment sharing this network, turn its stored - // `store_key -> principal` mapping into `(friendly_name, principal)` - // entries by joining against the consolidated canisters (keyed by the - // same store key). A canister contributes one entry per friendly name — - // several for a de-duplicated shared dependency canister (§17.3). - let mut env_entries: std::collections::BTreeMap> = - std::collections::BTreeMap::new(); - for (env_name, env) in &project.environments { - if env.network.name != desc.network { - continue; - } - let is_cache = matches!( - env.network.configuration, - NetworkConfiguration::Managed { .. } - ); - let Ok(mapping) = self.ids.lookup_by_environment(is_cache, env_name) else { - continue; - }; - let mut entries = Vec::new(); - for (store_key, principal) in &mapping { - if let Some((_, canister)) = env.canisters.get(store_key) { - for friendly_name in &canister.friendly_names { - entries.push((friendly_name.clone(), *principal)); - } - } - } - if !entries.is_empty() { - env_entries.insert(env_name.clone(), entries); - } - } - let extra: Vec<_> = crate::network::custom_domains::ii_custom_domain_entry(desc.ii, domain) - .into_iter() - .collect(); - if let Err(e) = crate::network::custom_domains::write_custom_domains( - status_dir, - domain, - &env_entries, - &extra, - ) { - tracing::warn!("Failed to update custom domains: {e}"); - } - } - #[cfg(test)] /// Creates a test context with all mocks pub fn mocked() -> Context { + let host = Host::mocked(); Context { + telemetry_data: host.telemetry_data.clone(), + host, dirs: Arc::new(crate::directories::UnimplementedMockDirs), - ids: Arc::new(crate::store_id::mock::MockInMemoryIdStore::new()), - artifacts: Arc::new(crate::store_artifact::MockInMemoryArtifactStore::new()), - project: Arc::new(crate::MockProjectLoader::minimal()), identity: Arc::new(crate::identity::MockIdentityLoader::anonymous()), - network: Arc::new(crate::network::MockNetworkAccessor::new()), agent: Arc::new(crate::agent::Creator), - builder: Arc::new(crate::canister::build::UnimplementedMockBuilder), - syncer: Arc::new(crate::canister::sync::UnimplementedMockSyncer), debug: false, - telemetry_data: Arc::new(crate::telemetry_data::TelemetryData::default()), password_func: Arc::new(|| Err("no password available in mock context".to_string())), } } @@ -652,21 +342,6 @@ pub enum GetIdentityError { }, } -#[derive(Debug, Snafu)] -pub enum GetEnvironmentError { - #[snafu(transparent)] - ProjectLoad { source: crate::ProjectLoadError }, - - #[snafu(display("project does not contain an environment named '{}'", name))] - EnvironmentNotFound { name: String }, - - #[snafu(display( - "environment '{environment}' is not defined by dependency '{member}'; \ - a dependency must declare every environment the workspace targets" - ))] - MissingDependencyEnvironment { environment: String, member: String }, -} - #[derive(Debug, Snafu)] pub enum GetNetworkError { #[snafu(transparent)] @@ -691,79 +366,6 @@ pub enum GetNetworkOrEnvironmentError { EnvironmentResolution { source: GetEnvironmentError }, } -#[derive(Debug, Snafu)] -pub enum GetCanisterIdForEnvError { - #[snafu(transparent)] - GetEnvironment { source: GetEnvironmentError }, - - #[snafu(display( - "canister '{}' not found in environment '{}'", - canister_name, - environment_name - ))] - CanisterNotFoundInEnv { - canister_name: String, - environment_name: String, - }, - - #[snafu(display( - "failed to lookup canister ID for canister '{}' in environment '{}'", - canister_name, - environment_name - ))] - CanisterIdLookup { - #[snafu(source(from(LookupIdError, Box::new)))] - source: Box, - canister_name: String, - environment_name: String, - }, -} - -#[derive(Debug, Snafu)] -pub enum SetCanisterIdForEnvError { - #[snafu(transparent)] - GetEnvironment { source: GetEnvironmentError }, - - #[snafu(display( - "canister '{}' not found in environment '{}'", - canister_name, - environment_name - ))] - SetCanisterNotFoundInEnv { - canister_name: String, - environment_name: String, - }, - - #[snafu(display( - "failed to register canister ID for canister '{}' in environment '{}'", - canister_name, - environment_name - ))] - CanisterIdRegister { - source: crate::store_id::RegisterError, - canister_name: String, - environment_name: String, - }, -} - -#[derive(Debug, Snafu)] -pub enum RemoveCanisterIdForEnvError { - #[snafu(transparent)] - GetEnvironment { source: GetEnvironmentError }, - - #[snafu(display( - "failed to unregister canister ID for canister '{}' in environment '{}': {}", - canister_name, - environment_name, - source - ))] - CanisterIdUnregister { - source: crate::store_id::UnregisterError, - canister_name: String, - environment_name: String, - }, -} - #[derive(Debug, Snafu)] pub enum GetAgentForEnvError { #[snafu(transparent)] @@ -862,37 +464,5 @@ pub enum GetCanisterIdError { GetCanisterIdForEnv { source: GetCanisterIdForEnvError }, } -#[derive(Debug, Snafu)] -pub enum GetIdsByEnvironmentError { - #[snafu(transparent)] - GetEnvironment { source: GetEnvironmentError }, - - #[snafu(display("failed to lookup IDs for environment '{environment_name}'"))] - IdsByEnvironmentLookup { - source: crate::store_id::LookupIdError, - environment_name: String, - }, -} - -#[derive(Debug, Snafu)] -pub enum GetEnvCanisterError { - #[snafu(transparent)] - ProjectLoad { source: crate::ProjectLoadError }, - - #[snafu(transparent)] - GetEnvironment { source: GetEnvironmentError }, - - #[snafu(display("project does not contain a canister named '{canister_name}'"))] - CanisterNotFoundInProject { canister_name: String }, - - #[snafu(display( - "environment '{environment_name}' does not contain a canister named '{canister_name}'" - ))] - CanisterNotInEnv { - canister_name: String, - environment_name: String, - }, -} - #[cfg(test)] mod tests; diff --git a/crates/icp/src/context/tests.rs b/crates/icp/src/context/tests.rs index 4c25ea726..788c0cb4c 100644 --- a/crates/icp/src/context/tests.rs +++ b/crates/icp/src/context/tests.rs @@ -1,6 +1,7 @@ use super::*; use crate::{ Environment, MockProjectLoader, Network, Project, + host::SetCanisterIdForEnvError, identity::MockIdentityLoader, network::{ Configuration, Gateway, Managed, ManagedLauncherConfig, ManagedMode, MockNetworkAccessor, @@ -70,11 +71,15 @@ async fn test_get_identity_named_not_found() { #[tokio::test] async fn test_get_environment_success() { let ctx = Context { - project: Arc::new(MockProjectLoader::complex()), + host: Host { + project: Arc::new(MockProjectLoader::complex()), + ..Host::mocked() + }, ..Context::mocked() }; let env = ctx + .host .get_environment(&EnvironmentSelection::Named("dev".to_string())) .await .unwrap(); @@ -87,6 +92,7 @@ async fn test_get_environment_not_found() { let ctx = Context::mocked(); let result = ctx + .host .get_environment(&EnvironmentSelection::Named("nonexistent".to_string())) .await; @@ -99,7 +105,10 @@ async fn test_get_environment_not_found() { #[tokio::test] async fn test_get_network_success() { let ctx = Context { - project: Arc::new(MockProjectLoader::complex()), + host: Host { + project: Arc::new(MockProjectLoader::complex()), + ..Host::mocked() + }, ..Context::mocked() }; @@ -136,12 +145,16 @@ async fn test_get_canister_id_for_env_success() { .unwrap(); let ctx = Context { - project: Arc::new(MockProjectLoader::complex()), - ids: ids_store, + host: Host { + project: Arc::new(MockProjectLoader::complex()), + ids: ids_store, + ..Host::mocked() + }, ..Context::mocked() }; let cid = ctx + .host .get_canister_id_for_env( &CanisterSelection::Named("backend".to_string()), &EnvironmentSelection::Named("dev".to_string()), @@ -155,12 +168,16 @@ async fn test_get_canister_id_for_env_success() { #[tokio::test] async fn test_get_canister_id_for_env_canister_not_in_env() { let ctx = Context { - project: Arc::new(MockProjectLoader::complex()), + host: Host { + project: Arc::new(MockProjectLoader::complex()), + ..Host::mocked() + }, ..Context::mocked() }; // "database" is only in "dev" environment, not in "test" let result = ctx + .host .get_canister_id_for_env( &CanisterSelection::Named("database".to_string()), &EnvironmentSelection::Named("test".to_string()), @@ -179,12 +196,16 @@ async fn test_get_canister_id_for_env_canister_not_in_env() { #[tokio::test] async fn test_get_canister_id_for_env_id_not_registered() { let ctx = Context { - project: Arc::new(MockProjectLoader::complex()), + host: Host { + project: Arc::new(MockProjectLoader::complex()), + ..Host::mocked() + }, ..Context::mocked() }; // Environment exists and canister is in it, but ID not registered let result = ctx + .host .get_canister_id_for_env( &CanisterSelection::Named("backend".to_string()), &EnvironmentSelection::Named("dev".to_string()), @@ -206,21 +227,25 @@ async fn test_set_canister_id_for_env_success() { let ids_store = Arc::new(MockInMemoryIdStore::new()); let ctx = Context { - project: Arc::new(MockProjectLoader::complex()), - ids: ids_store.clone() as Arc, + host: Host { + project: Arc::new(MockProjectLoader::complex()), + ids: ids_store.clone() as Arc, + ..Host::mocked() + }, ..Context::mocked() }; let canister_id = Principal::from_text("rrkah-fqaaa-aaaaa-aaaaq-cai").unwrap(); // Set the canister ID - ctx.set_canister_id_for_env( - "backend", - canister_id, - &EnvironmentSelection::Named("dev".to_string()), - ) - .await - .unwrap(); + ctx.host + .set_canister_id_for_env( + "backend", + canister_id, + &EnvironmentSelection::Named("dev".to_string()), + ) + .await + .unwrap(); // Verify it was registered by reading it back let registered_id = ids_store.lookup(true, "dev", "backend").unwrap(); @@ -231,7 +256,10 @@ async fn test_set_canister_id_for_env_success() { #[tokio::test] async fn test_set_canister_id_for_env_canister_not_in_env() { let ctx = Context { - project: Arc::new(MockProjectLoader::complex()), + host: Host { + project: Arc::new(MockProjectLoader::complex()), + ..Host::mocked() + }, ..Context::mocked() }; @@ -239,6 +267,7 @@ async fn test_set_canister_id_for_env_canister_not_in_env() { // "database" is only in "dev" environment, not in "test" let result = ctx + .host .set_canister_id_for_env( "database", canister_id, @@ -266,14 +295,18 @@ async fn test_set_canister_id_for_env_already_registered() { .unwrap(); let ctx = Context { - project: Arc::new(MockProjectLoader::complex()), - ids: ids_store, + host: Host { + project: Arc::new(MockProjectLoader::complex()), + ids: ids_store, + ..Host::mocked() + }, ..Context::mocked() }; // Try to register a different ID for the same canister let second_id = Principal::from_text("ryjl3-tyaaa-aaaaa-aaaba-cai").unwrap(); let result = ctx + .host .set_canister_id_for_env( "backend", second_id, @@ -298,8 +331,11 @@ async fn test_remove_canister_id_for_env_success() { .unwrap(); let ctx = Context { - project: Arc::new(MockProjectLoader::complex()), - ids: ids_store.clone() as Arc, + host: Host { + project: Arc::new(MockProjectLoader::complex()), + ids: ids_store.clone() as Arc, + ..Host::mocked() + }, ..Context::mocked() }; @@ -308,7 +344,8 @@ async fn test_remove_canister_id_for_env_success() { assert_eq!(lookup_result, canister_id); // Remove the canister ID - ctx.remove_canister_id_for_env("backend", &EnvironmentSelection::Named("dev".to_string())) + ctx.host + .remove_canister_id_for_env("backend", &EnvironmentSelection::Named("dev".to_string())) .await .unwrap(); @@ -324,13 +361,17 @@ async fn test_remove_canister_id_for_env_success() { async fn test_remove_canister_id_for_env_nonexistent_canister() { let ids_store = Arc::new(MockInMemoryIdStore::new()); let ctx = Context { - project: Arc::new(MockProjectLoader::complex()), - ids: ids_store.clone() as Arc, + host: Host { + project: Arc::new(MockProjectLoader::complex()), + ids: ids_store.clone() as Arc, + ..Host::mocked() + }, ..Context::mocked() }; // Remove a canister that was never registered - should not fail let result = ctx + .host .remove_canister_id_for_env("backend", &EnvironmentSelection::Named("dev".to_string())) .await; assert!(result.is_ok()); @@ -343,30 +384,33 @@ async fn test_get_agent_for_env_uses_environment_network() { // Complex project has "test" environment which uses "staging" network let ctx = Context { - project: Arc::new(MockProjectLoader::complex()), - network: Arc::new( - MockNetworkAccessor::new() - .with_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, - }, - ) - .with_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, - }, - ), - ), + host: Host { + project: Arc::new(MockProjectLoader::complex()), + network: Arc::new( + MockNetworkAccessor::new() + .with_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, + }, + ) + .with_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, + }, + ), + ), + ..Host::mocked() + }, ..Context::mocked() }; @@ -405,7 +449,10 @@ async fn test_get_agent_for_env_network_not_configured() { // Environment "dev" exists in project and uses "local" network, // but "local" network is not configured in MockNetworkAccessor let ctx = Context { - project: Arc::new(MockProjectLoader::complex()), + host: Host { + project: Arc::new(MockProjectLoader::complex()), + ..Host::mocked() + }, // MockNetworkAccessor has no networks configured ..Context::mocked() }; @@ -430,17 +477,20 @@ async fn test_get_agent_for_network_success() { let root_key = vec![1, 2, 3]; let ctx = Context { - project: Arc::new(MockProjectLoader::complex()), - network: Arc::new(MockNetworkAccessor::new().with_network( - "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, - }, - )), + host: Host { + project: Arc::new(MockProjectLoader::complex()), + network: Arc::new(MockNetworkAccessor::new().with_network( + "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, + }, + )), + ..Host::mocked() + }, ..Context::mocked() }; @@ -478,7 +528,10 @@ async fn test_get_agent_for_network_network_not_found() { async fn test_get_agent_for_network_not_configured() { // Network "local" exists in project but is not configured in MockNetworkAccessor let ctx = Context { - project: Arc::new(MockProjectLoader::complex()), + host: Host { + project: Arc::new(MockProjectLoader::complex()), + ..Host::mocked() + }, // MockNetworkAccessor has no networks configured ..Context::mocked() }; @@ -523,8 +576,11 @@ async fn test_get_canister_id_for_env() { .unwrap(); let ctx = Context { - project: Arc::new(MockProjectLoader::complex()), - ids: ids_store, + host: Host { + project: Arc::new(MockProjectLoader::complex()), + ids: ids_store, + ..Host::mocked() + }, ..Context::mocked() }; @@ -532,13 +588,14 @@ async fn test_get_canister_id_for_env() { let environment_selection = EnvironmentSelection::Named("dev".to_string()); assert!( - matches!(ctx.get_canister_id_for_env(&canister_selection, &environment_selection).await, Ok(id) if id == canister_id) + matches!(ctx.host.get_canister_id_for_env(&canister_selection, &environment_selection).await, Ok(id) if id == canister_id) ); let canister_selection = CanisterSelection::Named("INVALID".to_string()); let environment_selection = EnvironmentSelection::Named("dev".to_string()); let res = ctx + .host .get_canister_id_for_env(&canister_selection, &environment_selection) .await; assert!( @@ -562,12 +619,16 @@ async fn test_ids_by_environment() { .unwrap(); let ctx = Context { - project: Arc::new(MockProjectLoader::complex()), - ids: ids_store, + host: Host { + project: Arc::new(MockProjectLoader::complex()), + ids: ids_store, + ..Host::mocked() + }, ..Context::mocked() }; let result = ctx + .host .ids_by_environment(&EnvironmentSelection::Named("dev".to_string())) .await .unwrap(); @@ -580,7 +641,10 @@ async fn test_ids_by_environment() { #[tokio::test] async fn test_get_agent_defaults_outside_project() { let ctx = Context { - project: Arc::new(crate::NoProjectLoader), + host: Host { + project: Arc::new(crate::NoProjectLoader), + ..Host::mocked() + }, ..Context::mocked() }; @@ -646,17 +710,20 @@ async fn test_get_agent_defaults_inside_project_with_default_local() { }; let ctx = Context { - project: Arc::new(crate::MockProjectLoader::new(project)), - network: Arc::new(MockNetworkAccessor::new().with_network( - 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, - }, - )), + host: Host { + project: Arc::new(crate::MockProjectLoader::new(project)), + network: Arc::new(MockNetworkAccessor::new().with_network( + 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, + }, + )), + ..Host::mocked() + }, ..Context::mocked() }; @@ -721,17 +788,20 @@ async fn test_get_agent_defaults_with_overridden_local_network() { let custom_root_key = vec![1, 2, 3, 4]; let ctx = Context { - project: Arc::new(crate::MockProjectLoader::new(project)), - network: Arc::new(MockNetworkAccessor::new().with_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, - }, - )), + host: Host { + project: Arc::new(crate::MockProjectLoader::new(project)), + network: Arc::new(MockNetworkAccessor::new().with_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, + }, + )), + ..Host::mocked() + }, ..Context::mocked() }; @@ -821,30 +891,33 @@ async fn test_get_agent_defaults_with_overridden_local_environment() { let custom_root_key = vec![5, 6, 7, 8]; let ctx = Context { - project: Arc::new(crate::MockProjectLoader::new(project)), - network: Arc::new( - MockNetworkAccessor::new() - .with_network( - 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, - }, - ) - .with_network( - "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, - }, - ), - ), + host: Host { + project: Arc::new(crate::MockProjectLoader::new(project)), + network: Arc::new( + MockNetworkAccessor::new() + .with_network( + 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, + }, + ) + .with_network( + "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, + }, + ), + ), + ..Host::mocked() + }, ..Context::mocked() }; @@ -867,30 +940,33 @@ async fn test_get_agent_explicit_network_inside_project() { let staging_root_key = vec![12, 13, 14]; let ctx = Context { - project: Arc::new(MockProjectLoader::complex()), - network: Arc::new( - MockNetworkAccessor::new() - .with_network( - 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, - }, - ) - .with_network( - "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, - }, - ), - ), + host: Host { + project: Arc::new(MockProjectLoader::complex()), + network: Arc::new( + MockNetworkAccessor::new() + .with_network( + 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, + }, + ) + .with_network( + "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, + }, + ), + ), + ..Host::mocked() + }, ..Context::mocked() }; @@ -914,30 +990,33 @@ async fn test_get_agent_explicit_environment_inside_project() { // complex() has "test" environment using "staging" network let ctx = Context { - project: Arc::new(MockProjectLoader::complex()), - network: Arc::new( - MockNetworkAccessor::new() - .with_network( - 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, - }, - ) - .with_network( - "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, - }, - ), - ), + host: Host { + project: Arc::new(MockProjectLoader::complex()), + network: Arc::new( + MockNetworkAccessor::new() + .with_network( + 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, + }, + ) + .with_network( + "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, + }, + ), + ), + ..Host::mocked() + }, ..Context::mocked() }; diff --git a/crates/icp/src/host.rs b/crates/icp/src/host.rs new file mode 100644 index 000000000..3a7042405 --- /dev/null +++ b/crates/icp/src/host.rs @@ -0,0 +1,461 @@ +//! What an operation needs from the world around it. +//! +//! Operations act on a project: they load it, resolve an environment out of it, +//! look canister ids up and record new ones, build and sync canisters. Every +//! one of those is reached through a trait object here rather than through the +//! ambient [`Context`](crate::context::Context), which also carries the +//! identity loader, the keyring-backed key store and the global directories — +//! none of which an operation has any business reaching. +//! +//! So this is the whole surface. An operation takes `&Host` and whatever its +//! caller resolved for it (an agent, install arguments), and nothing else. + +use std::sync::Arc; + +use candid::Principal; +use snafu::{OptionExt, ResultExt, Snafu}; + +use crate::{ + Canister, + canister::{build::Build, sync::Synchronize}, + network::{Configuration as NetworkConfiguration, FriendlyDomains}, + prelude::*, + store_id::{IdMapping, LookupIdError}, + telemetry_data::NetworkType, +}; + +/// Selection type for environments +#[derive(Clone, Debug, PartialEq)] +pub enum EnvironmentSelection { + /// Use the default environment (local) + Default, + /// Use a named environment + Named(String), +} + +impl EnvironmentSelection { + pub fn name(&self) -> &str { + match self { + EnvironmentSelection::Default => LOCAL, + EnvironmentSelection::Named(name) => name, + } + } +} + +/// Selection type for canisters +#[derive(Clone, Debug, PartialEq)] +pub enum CanisterSelection { + /// Use a canister by name (requires project context) + Named(String), + /// Use a canister by principal + Principal(Principal), +} + +/// The project-side resources an operation runs against. +#[derive(Clone)] +pub struct Host { + /// Project loader + pub project: Arc, + + /// Canister ID store for lookup and storage + pub ids: Arc, + + /// Store for canister build artifacts + pub artifacts: Arc, + + /// Canister builder + pub builder: Arc, + + /// Canister synchronizer + pub syncer: Arc, + + /// Network resolution: endpoints, root keys, friendly domains + pub network: Arc, + + /// Telemetry data collected during command execution. + // TODO: telemetry is app-global, not project-scoped. Once the app layer is + // its own crate, it should derive these facts from the loaded project + // itself rather than having project loading write into a bag it owns. + pub telemetry_data: Arc, +} + +impl Host { + #[cfg(test)] + /// A host whose every seam is a mock, for tests that only exercise the + /// resolution methods below. + pub fn mocked() -> Self { + Self { + project: Arc::new(crate::MockProjectLoader::minimal()), + ids: Arc::new(crate::store_id::mock::MockInMemoryIdStore::new()), + artifacts: Arc::new(crate::store_artifact::MockInMemoryArtifactStore::new()), + builder: Arc::new(crate::canister::build::UnimplementedMockBuilder), + syncer: Arc::new(crate::canister::sync::UnimplementedMockSyncer), + network: Arc::new(crate::network::MockNetworkAccessor::new()), + telemetry_data: Arc::new(crate::telemetry_data::TelemetryData::default()), + } + } + + /// Gets an environment by name from the currently loaded project. + /// + /// # Errors + /// + /// Returns an error if the project cannot be loaded or if the environment is not found. + pub async fn get_environment( + &self, + environment: &EnvironmentSelection, + ) -> Result { + // Load project + let p = self.project.load().await?; + + // Load target environment + let env = p + .environments + .get(environment.name()) + .context(EnvironmentNotFoundSnafu { + name: environment.name().to_owned(), + })?; + + // Strict rule: every vendored member must declare the selected + // environment. Enforced here (not at load time) so a member missing some + // other environment never blocks deploys to the ones it does declare. + if let Some(missing) = p.member_missing_envs.get(environment.name()) + && let Some(member) = missing.first() + { + return MissingDependencyEnvironmentSnafu { + environment: environment.name().to_owned(), + member: member.clone(), + } + .fail(); + } + + let network_type = match &env.network.configuration { + NetworkConfiguration::Managed { .. } => NetworkType::Managed, + NetworkConfiguration::Connected { .. } => NetworkType::Connected, + }; + self.telemetry_data.set_network_type(network_type); + self.telemetry_data.set_project(&p); + + Ok(env.clone()) + } + + pub async fn get_canister_and_path_for_env( + &self, + canister_name: &str, + environment: &EnvironmentSelection, + ) -> Result<(PathBuf, Canister), GetEnvCanisterError> { + let p = self.project.load().await?; + let Some((path, canister)) = p.get_canister(canister_name) else { + return CanisterNotFoundInProjectSnafu { + canister_name: canister_name.to_owned(), + } + .fail(); + }; + + let env = self.get_environment(environment).await?; + if !env.contains_canister(canister_name) { + return CanisterNotInEnvSnafu { + canister_name: canister_name.to_owned(), + environment_name: environment.name().to_owned(), + } + .fail(); + } + Ok((path.clone(), canister.clone())) + } + + /// Gets the canister ID for a given canister selection in a specified environment. + /// + /// # Errors + /// + /// Returns an error if the environment cannot be loaded or if the canister ID cannot be found. + pub async fn get_canister_id_for_env( + &self, + canister: &CanisterSelection, + environment: &EnvironmentSelection, + ) -> Result { + let principal = match canister { + CanisterSelection::Named(canister_name) => { + let env = self.get_environment(environment).await?; + let is_cache = match env.network.configuration { + NetworkConfiguration::Managed { .. } => true, + NetworkConfiguration::Connected { .. } => false, + }; + + if !env.canisters.contains_key(canister_name) { + return CanisterNotFoundInEnvSnafu { + canister_name: canister_name.to_owned(), + environment_name: environment.name().to_owned(), + } + .fail(); + } + + // Lookup the canister id + self.ids + .lookup(is_cache, &env.name, canister_name) + .context(CanisterIdLookupSnafu { + canister_name: canister_name.to_owned(), + environment_name: environment.name().to_owned(), + })? + } + CanisterSelection::Principal(principal) => { + // Make sure a valid environment was requested + let _ = self.get_environment(environment).await?; + *principal + } + }; + + Ok(principal) + } + + /// Sets the canister ID for a given canister name in a specified environment. + /// + /// # Errors + /// + /// Returns an error if the environment cannot be loaded or if the canister ID cannot be registered. + pub async fn set_canister_id_for_env( + &self, + canister_name: &str, + canister_id: Principal, + environment: &EnvironmentSelection, + ) -> Result<(), SetCanisterIdForEnvError> { + let env = self.get_environment(environment).await?; + let is_cache = match env.network.configuration { + NetworkConfiguration::Managed { .. } => true, + NetworkConfiguration::Connected { .. } => false, + }; + + if !env.canisters.contains_key(canister_name) { + return SetCanisterNotFoundInEnvSnafu { + canister_name: canister_name.to_owned(), + environment_name: environment.name().to_owned(), + } + .fail(); + } + + // Register the canister id + self.ids + .register(is_cache, &env.name, canister_name, canister_id) + .context(CanisterIdRegisterSnafu { + canister_name: canister_name.to_owned(), + environment_name: environment.name().to_owned(), + })?; + + Ok(()) + } + + /// Removes the canister ID for a given canister name in a specified environment. + pub async fn remove_canister_id_for_env( + &self, + canister_name: &str, + environment: &EnvironmentSelection, + ) -> Result<(), RemoveCanisterIdForEnvError> { + let env = self.get_environment(environment).await?; + let is_cache = match env.network.configuration { + NetworkConfiguration::Managed { .. } => true, + NetworkConfiguration::Connected { .. } => false, + }; + + // Unregister the canister id + self.ids + .unregister(is_cache, &env.name, canister_name) + .context(CanisterIdUnregisterSnafu { + canister_name: canister_name.to_owned(), + environment_name: environment.name().to_owned(), + })?; + + Ok(()) + } + + pub async fn ids_by_environment( + &self, + environment: &EnvironmentSelection, + ) -> Result { + let env = self.get_environment(environment).await?; + let is_cache = match env.network.configuration { + NetworkConfiguration::Managed { .. } => true, + NetworkConfiguration::Connected { .. } => false, + }; + self.ids + .lookup_by_environment(is_cache, environment.name()) + .context(IdsByEnvironmentLookupSnafu { + environment_name: environment.name().to_owned(), + }) + } + + /// Republishes the friendly canister domains of the managed network the + /// given environment targets. + /// + /// Collects the `friendly name -> canister id` mappings of every + /// environment in the project and hands them to the network layer, which + /// owns where they are written and which of them the running network + /// actually serves. + /// + /// This is a best-effort operation: a failure to update friendly domains + /// should not block canister creation or deletion, so nothing here is + /// propagated. + pub async fn update_custom_domains(&self, environment: &EnvironmentSelection) { + let Ok(env) = self.get_environment(environment).await else { + return; + }; + let NetworkConfiguration::Managed { .. } = &env.network.configuration else { + return; + }; + let Ok(project) = self.project.load().await else { + return; + }; + + // For each environment, turn its stored `store_key -> principal` + // mapping into `(friendly_name, principal)` entries by joining against + // the consolidated canisters (keyed by the same store key). A canister + // contributes one entry per friendly name — several for a de-duplicated + // shared dependency canister. + let mut collected = Vec::new(); + for (env_name, env) in &project.environments { + let is_cache = matches!( + env.network.configuration, + NetworkConfiguration::Managed { .. } + ); + let Ok(mapping) = self.ids.lookup_by_environment(is_cache, env_name) else { + continue; + }; + let mut entries = Vec::new(); + for (store_key, principal) in &mapping { + if let Some((_, canister)) = env.canisters.get(store_key) { + for friendly_name in &canister.friendly_names { + entries.push((friendly_name.clone(), *principal)); + } + } + } + if !entries.is_empty() { + collected.push(FriendlyDomains { + environment: env_name.clone(), + network: env.network.name.clone(), + entries, + }); + } + } + + self.network + .publish_friendly_domains(&env.network, &collected) + .await; + } +} + +#[derive(Debug, Snafu)] +pub enum GetEnvironmentError { + #[snafu(transparent)] + ProjectLoad { source: crate::ProjectLoadError }, + + #[snafu(display("project does not contain an environment named '{}'", name))] + EnvironmentNotFound { name: String }, + + #[snafu(display( + "environment '{environment}' is not defined by dependency '{member}'; \ + a dependency must declare every environment the workspace targets" + ))] + MissingDependencyEnvironment { environment: String, member: String }, +} + +#[derive(Debug, Snafu)] +pub enum GetCanisterIdForEnvError { + #[snafu(transparent)] + GetEnvironment { source: GetEnvironmentError }, + + #[snafu(display( + "canister '{}' not found in environment '{}'", + canister_name, + environment_name + ))] + CanisterNotFoundInEnv { + canister_name: String, + environment_name: String, + }, + + #[snafu(display( + "failed to lookup canister ID for canister '{}' in environment '{}'", + canister_name, + environment_name + ))] + CanisterIdLookup { + #[snafu(source(from(LookupIdError, Box::new)))] + source: Box, + canister_name: String, + environment_name: String, + }, +} + +#[derive(Debug, Snafu)] +pub enum SetCanisterIdForEnvError { + #[snafu(transparent)] + GetEnvironment { source: GetEnvironmentError }, + + #[snafu(display( + "canister '{}' not found in environment '{}'", + canister_name, + environment_name + ))] + SetCanisterNotFoundInEnv { + canister_name: String, + environment_name: String, + }, + + #[snafu(display( + "failed to register canister ID for canister '{}' in environment '{}'", + canister_name, + environment_name + ))] + CanisterIdRegister { + source: crate::store_id::RegisterError, + canister_name: String, + environment_name: String, + }, +} + +#[derive(Debug, Snafu)] +pub enum RemoveCanisterIdForEnvError { + #[snafu(transparent)] + GetEnvironment { source: GetEnvironmentError }, + + #[snafu(display( + "failed to unregister canister ID for canister '{}' in environment '{}': {}", + canister_name, + environment_name, + source + ))] + CanisterIdUnregister { + source: crate::store_id::UnregisterError, + canister_name: String, + environment_name: String, + }, +} + +#[derive(Debug, Snafu)] +pub enum GetIdsByEnvironmentError { + #[snafu(transparent)] + GetEnvironment { source: GetEnvironmentError }, + + #[snafu(display("failed to lookup IDs for environment '{environment_name}'"))] + IdsByEnvironmentLookup { + source: crate::store_id::LookupIdError, + environment_name: String, + }, +} + +#[derive(Debug, Snafu)] +pub enum GetEnvCanisterError { + #[snafu(transparent)] + ProjectLoad { source: crate::ProjectLoadError }, + + #[snafu(transparent)] + GetEnvironment { source: GetEnvironmentError }, + + #[snafu(display("project does not contain a canister named '{canister_name}'"))] + CanisterNotFoundInProject { canister_name: String }, + + #[snafu(display( + "environment '{environment_name}' does not contain a canister named '{canister_name}'" + ))] + CanisterNotInEnv { + canister_name: String, + environment_name: String, + }, +} diff --git a/crates/icp/src/lib.rs b/crates/icp/src/lib.rs index 17c226155..a788e3a50 100644 --- a/crates/icp/src/lib.rs +++ b/crates/icp/src/lib.rs @@ -29,6 +29,7 @@ pub mod canister; pub mod context; pub mod directories; pub mod fs; +pub mod host; pub mod identity; pub mod manifest; pub mod network; diff --git a/crates/icp/src/network/mod.rs b/crates/icp/src/network/mod.rs index f8faebbad..dfb1cf2c8 100644 --- a/crates/icp/src/network/mod.rs +++ b/crates/icp/src/network/mod.rs @@ -1,6 +1,7 @@ -use std::sync::Arc; +use std::{collections::BTreeMap, sync::Arc}; use async_trait::async_trait; +use candid::Principal; use schemars::JsonSchema; use serde::{Deserialize, Deserializer, Serialize}; use snafu::prelude::*; @@ -344,6 +345,25 @@ pub enum AccessError { GetNetworkAccess { source: GetNetworkAccessError }, } +/// One environment's friendly-name mappings, as collected from the project. +/// +/// The project layer knows which canisters have ids and what they are called; +/// which of those a running network actually serves, and where the mapping is +/// written, is this layer's business. So the project hands over every +/// environment it has and lets [`Access::publish_friendly_domains`] pick. +#[derive(Clone, Debug)] +pub struct FriendlyDomains { + /// Environment the mappings belong to. + pub environment: String, + + /// Name of the network that environment targets. + pub network: String, + + /// `(friendly name, canister id)`, one entry per friendly name — so several + /// for a de-duplicated shared dependency canister. + pub entries: Vec<(String, Principal)>, +} + #[async_trait] pub trait Access: Sync + Send { fn get_network_directory(&self, network: &Network) -> Result; @@ -353,6 +373,15 @@ pub trait Access: Sync + Send { /// key, so a caller that only needs an endpoint does not make a connected /// network fetch one. async fn urls(&self, network: &Network) -> Result; + + /// Rewrites the friendly-domain mapping a running managed network serves. + /// + /// Best-effort by contract: a network that is not running, or is not + /// managed, or whose mapping cannot be written, is not an error — this is + /// called on paths (canister creation, deletion) that must not fail because + /// a convenience URL is stale. Implementations that serve no friendly + /// domains need not override it. + async fn publish_friendly_domains(&self, _network: &Network, _envs: &[FriendlyDomains]) {} } pub struct Accessor { @@ -409,6 +438,47 @@ impl Access for Accessor { }), } } + + async fn publish_friendly_domains(&self, network: &Network, envs: &[FriendlyDomains]) { + let Configuration::Managed { .. } = &network.configuration else { + return; + }; + let Ok(nd) = self.get_network_directory(network) else { + return; + }; + let Ok(Some(desc)) = nd.load_network_descriptor().await else { + return; + }; + let Some(status_dir) = &desc.status_dir else { + return; + }; + let gateway_url_str = format!("http://{}:{}", desc.gateway.host, desc.gateway.port); + let Ok(gateway_url) = Url::parse(&gateway_url_str) else { + tracing::warn!("Failed to parse gateway URL {gateway_url_str:?} for custom domains"); + return; + }; + let Some(domain) = custom_domains::gateway_domain(&gateway_url) else { + return; + }; + + // The descriptor names the network the gateway is actually serving, so + // it — not the environment's own view — decides which environments share + // this network and therefore this mapping file. + let env_entries: BTreeMap> = envs + .iter() + .filter(|e| e.network == desc.network) + .map(|e| (e.environment.clone(), e.entries.clone())) + .collect(); + + let extra: Vec<_> = custom_domains::ii_custom_domain_entry(desc.ii, domain) + .into_iter() + .collect(); + if let Err(e) = + custom_domains::write_custom_domains(status_dir, domain, &env_entries, &extra) + { + tracing::warn!("Failed to update custom domains: {e}"); + } + } } #[cfg(test)] diff --git a/crates/icp/src/operations/deploy.rs b/crates/icp/src/operations/deploy.rs index b3b04818d..663417c40 100644 --- a/crates/icp/src/operations/deploy.rs +++ b/crates/icp/src/operations/deploy.rs @@ -24,13 +24,10 @@ use icp_events::TaskOutcome; use itertools::Itertools; use snafu::{OptionExt, ResultExt, Snafu}; -use crate::context::{ - CanisterSelection, Context, EnvironmentSelection, GetAgentForEnvError, - GetCanisterIdForEnvError, GetEnvCanisterError, GetEnvironmentError, GetIdsByEnvironmentError, - SetCanisterIdForEnvError, +use crate::host::{ + CanisterSelection, EnvironmentSelection, GetCanisterIdForEnvError, GetEnvCanisterError, + GetEnvironmentError, GetIdsByEnvironmentError, Host, SetCanisterIdForEnvError, }; -use crate::fs::lock::LockError; -use crate::identity::IdentitySelection; use crate::operations::{ binding_env_vars::{SetBindingEnvVarsManyError, set_binding_env_vars_many}, build::{BuildManyError, build_many}, @@ -48,6 +45,7 @@ use crate::operations::{ sync::{SyncOperationError, sync_many}, task::{Reporter, Task, TaskReporter, notice}, }; +use crate::package::PackageCache; use crate::project::ArgsField; use crate::{CanisterArgsToBytesError, ProjectLoadError}; @@ -62,18 +60,12 @@ pub enum DeployError { #[snafu(transparent)] ResolveBuildTargets { source: GetEnvCanisterError }, - #[snafu(transparent)] - PackageCache { source: LockError }, - #[snafu(transparent)] Build { source: BuildManyError }, #[snafu(transparent)] GetEnvironment { source: GetEnvironmentError }, - #[snafu(transparent)] - GetAgent { source: GetAgentForEnvError }, - #[snafu(transparent)] GetIds { source: GetIdsByEnvironmentError }, @@ -161,6 +153,9 @@ pub enum ResolveTargetsError { #[snafu(transparent)] LoadEnvironment { source: GetEnvironmentError }, + #[snafu(transparent)] + ResolveNamedCanister { source: GetEnvCanisterError }, + #[snafu(transparent)] LoadProject { source: ProjectLoadError }, @@ -179,7 +174,6 @@ pub enum ResolveTargetsError { /// command so this layer never touches clap. pub struct DeployParams { pub environment: EnvironmentSelection, - pub identity: IdentitySelection, /// Canisters to deploy, already resolved from the command line (or from /// the environment when none were named). pub canisters: Vec, @@ -210,9 +204,14 @@ pub struct DeployReport { /// Run a full deploy, reporting progress as one task tree. /// +/// `agent` speaks for the identity the caller resolved: which identity that is, +/// and how its key was unlocked, is not this layer's business. +/// /// `report` is written as the run goes; see [`DeployReport`]. pub async fn deploy( - ctx: &Context, + host: &Host, + agent: &Agent, + pkg_cache: &PackageCache, params: &DeployParams, reporter: &Reporter, report: &mut DeployReport, @@ -223,30 +222,26 @@ pub async fn deploy( let canisters_to_build = try_join_all( cnames .iter() - .map(|name| ctx.get_canister_and_path_for_env(name, environment_selection)), + .map(|name| host.get_canister_and_path_for_env(name, environment_selection)), ) .await?; // Build - let pkg_cache = ctx.dirs.package_cache()?; let phase = reporter.task(Task::phase("Building canisters:")); let result = build_many( canisters_to_build, environment_selection.name(), - ctx.builder.clone(), - ctx.artifacts.clone(), - &pkg_cache, + host.builder.clone(), + host.artifacts.clone(), + pkg_cache, &phase.reporter(), ) .await; finish(&phase, result)?; // Create any canisters that do not exist yet - let env = ctx.get_environment(environment_selection).await?; - let agent = ctx - .get_agent_for_env(¶ms.identity, environment_selection) - .await?; - let existing_canisters = ctx.ids_by_environment(environment_selection).await?; + let env = host.get_environment(environment_selection).await?; + let existing_canisters = host.ids_by_environment(environment_selection).await?; let canisters_to_create = cnames .iter() .filter(|name| !existing_canisters.contains_key(*name)) @@ -262,9 +257,9 @@ pub async fn deploy( } else { let phase = reporter.task(Task::phase("Creating canisters:")); let result = create_canisters( - ctx, + host, params, - &agent, + agent, &env, &canisters_to_create, existing_canisters.into_values().collect(), @@ -275,14 +270,14 @@ pub async fn deploy( finish(&phase, result)?; } - ctx.update_custom_domains(environment_selection).await; + host.update_custom_domains(environment_selection).await; // Wire canister ids into each other's environment variables, then apply // manifest settings. - let env = ctx.get_environment(environment_selection).await?; + let env = host.get_environment(environment_selection).await?; let env_canisters = &env.canisters; let target_canisters = try_join_all(cnames.iter().map(|name| async move { - let cid = ctx + let cid = host .get_canister_id_for_env( &CanisterSelection::Named(name.clone()), environment_selection, @@ -295,7 +290,7 @@ pub async fn deploy( })) .await?; - let canister_list = ctx.ids_by_environment(environment_selection).await?; + let canister_list = host.ids_by_environment(environment_selection).await?; let phase = reporter.task(Task::phase("Setting environment variables:")); let result = set_binding_env_vars_many( @@ -325,7 +320,7 @@ pub async fn deploy( let canisters = try_join_all(cnames.iter().map(|name| { let agent = agent.clone(); async move { - let cid = ctx + let cid = host .get_canister_id_for_env( &CanisterSelection::Named(name.clone()), environment_selection, @@ -336,7 +331,7 @@ pub async fn deploy( resolve_install_mode_and_status(&agent, params.proxy, name, &cid, ¶ms.mode) .await?; - let env = ctx.get_environment(environment_selection).await?; + let env = host.get_environment(environment_selection).await?; let (_canister_path, canister_info) = env .get_canister_info(name) .map_err(|message| DeployError::CanisterNotInEnvironment { message })?; @@ -382,7 +377,7 @@ pub async fn deploy( canisters .iter() .map(|(name, cid, mode, _, _)| (&**name, *cid, *mode)), - ctx.artifacts.clone(), + host.artifacts.clone(), &phase.reporter(), ) .await; @@ -395,13 +390,13 @@ pub async fn deploy( agent.clone(), params.proxy, canisters, - ctx.artifacts.clone(), + host.artifacts.clone(), &phase.reporter(), ) .await; finish(&phase, result)?; - sync(ctx, params, &agent, reporter).await?; + sync(host, pkg_cache, params, agent, reporter).await?; Ok(()) } @@ -412,7 +407,7 @@ pub async fn deploy( /// anything that could still fail, so a partial run still reports what it made. #[allow(clippy::too_many_arguments)] async fn create_canisters( - ctx: &Context, + host: &Host, params: &DeployParams, agent: &Agent, env: &crate::Environment, @@ -473,14 +468,14 @@ async fn create_canisters( // Scoped to this canister rather than the loop, so a failure here // holds up only its own follow-up work. let result = async { - ctx.set_canister_id_for_env(canister_name, id, ¶ms.environment) + host.set_canister_id_for_env(canister_name, id, ¶ms.environment) .await?; // Apply controller settings for any already-created canister that // was waiting for this one to exist (e.g. created via // `icp canister create`). Skipped when the id never reached the // store, since that is what a dependent would be looking it up in. sync_controller_dependents( - ctx, + host, agent, params.proxy, canister_name, @@ -511,17 +506,18 @@ async fn create_canisters( /// Run the sync steps of every canister that has any. async fn sync( - ctx: &Context, + host: &Host, + pkg_cache: &PackageCache, params: &DeployParams, agent: &Agent, reporter: &Reporter, ) -> Result<(), DeployError> { let environment_selection = ¶ms.environment; - let env = ctx.get_environment(environment_selection).await?; + let env = host.get_environment(environment_selection).await?; let env_canisters = &env.canisters; let sync_canisters = try_join_all(params.canisters.iter().map(|name| async move { - let cid = ctx + let cid = host .get_canister_id_for_env( &CanisterSelection::Named(name.clone()), environment_selection, @@ -588,19 +584,18 @@ async fn sync( // canister) will fail because the user's identity lacks the required permissions. // The fix is to make a proxy call to the frontend canister's `grant_permission` // method to permit the user identity to upload assets directly before syncing. - let canister_ids: BTreeMap = ctx + let canister_ids: BTreeMap = host .ids_by_environment(environment_selection) .await? .into_iter() .collect(); - let pkg_cache = ctx.dirs.package_cache()?; - let project_dir = ctx.project.load().await?.dir; - let urls = ctx.network.urls(&env.network).await?; + let project_dir = host.project.load().await?.dir; + let urls = host.network.urls(&env.network).await?; let phase = reporter.task(Task::phase("Syncing canisters:")); let result = sync_many( - ctx.syncer.clone(), + host.syncer.clone(), agent.clone(), sync_canisters, project_dir, @@ -609,7 +604,7 @@ async fn sync( urls, canister_ids, proxy, - &pkg_cache, + pkg_cache, &phase.reporter(), ) .await; @@ -633,11 +628,11 @@ fn finish(phase: &TaskReporter, result: Result) - /// Resolve the canisters a deploy targets, and check that a member-scoped /// deploy is not about to wire canisters to dependencies that do not exist. pub async fn resolve_targets( - ctx: &Context, + host: &Host, environment_selection: &EnvironmentSelection, named: &[String], ) -> Result, ResolveTargetsError> { - let env = ctx.get_environment(environment_selection).await?; + let env = host.get_environment(environment_selection).await?; let mut member_scoped = false; let cnames: Vec = if named.is_empty() { @@ -645,8 +640,8 @@ pub async fn resolve_targets( // command is run inside a vendored member — then scope to that member's // own canisters. (The resolved-root notice is emitted centrally during // project load.) - let project = ctx.project.load().await?; - let member_dir = ctx.project.member_dir(); + let project = host.project.load().await?; + let member_dir = host.project.member_dir(); match crate::project::member_scoped_canisters(&project.dir, member_dir.as_deref(), &env) { Some(scoped) => { member_scoped = true; @@ -655,6 +650,15 @@ pub async fn resolve_targets( None => env.canisters.keys().cloned().collect(), } } else { + // Check the names while this is still a pure question about the + // project. The build phase would reject a typo too, but only after the + // caller has resolved an identity and an endpoint — so on a network + // that is not running, the network would answer first and the typo + // would never get mentioned. + for name in named { + host.get_canister_and_path_for_env(name, environment_selection) + .await?; + } named.to_vec() }; @@ -665,7 +669,7 @@ pub async fn resolve_targets( // deploying an unwired canister. if member_scoped { let scoped: HashSet<&str> = cnames.iter().map(String::as_str).collect(); - let deployed: BTreeMap = ctx + let deployed: BTreeMap = host .ids_by_environment(environment_selection) .await? .into_iter() diff --git a/crates/icp/src/operations/settings.rs b/crates/icp/src/operations/settings.rs index 5ee12235b..6049bd783 100644 --- a/crates/icp/src/operations/settings.rs +++ b/crates/icp/src/operations/settings.rs @@ -6,7 +6,7 @@ use std::{ use crate::{ Canister, Environment, canister::{Settings, Visibility, resolve_controllers}, - context::{Context, EnvironmentSelection}, + host::{EnvironmentSelection, Host}, store_id::IdMapping, }; use candid::{Nat, Principal}; @@ -301,12 +301,12 @@ pub async fn sync_settings_many( pub enum SyncControllerDependentsError { #[snafu(display("failed to load environment for controller dependent sync"))] GetEnvironment { - source: crate::context::GetEnvironmentError, + source: crate::host::GetEnvironmentError, }, #[snafu(display("failed to load canister IDs for controller dependent sync"))] GetIds { - source: crate::context::GetIdsByEnvironmentError, + source: crate::host::GetIdsByEnvironmentError, }, } @@ -314,17 +314,17 @@ pub enum SyncControllerDependentsError { /// that list `newly_created_name` as a controller and already have a stored ID. Calls /// `sync_settings` for each so the controller is applied now that it can be resolved. pub async fn sync_controller_dependents( - ctx: &Context, + host: &Host, agent: &Agent, proxy: Option, newly_created_name: &str, env: &EnvironmentSelection, ) -> Result<(), SyncControllerDependentsError> { - let env_data = ctx + let env_data = host .get_environment(env) .await .context(GetEnvironmentSnafu)?; - let ids = ctx.ids_by_environment(env).await.context(GetIdsSnafu)?; + let ids = host.ids_by_environment(env).await.context(GetIdsSnafu)?; for (name, (_, canister)) in &env_data.canisters { let references_new = canister.settings.controllers.as_ref().is_some_and(|crefs| { From a49b12338833a9be13ae771c1e9cd8623f68ef95 Mon Sep 17 00:00:00 2001 From: Adam Spofford Date: Fri, 11 Sep 2026 05:05:50 -0700 Subject: [PATCH 2/6] refactor: resolve the deploy agent lazily, collect domains on demand MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-ups to the Host extraction: - `deploy` takes a `LazyAgent` and resolves it only once the build has succeeded. Resolving it up front meant a deploy with a broken build unlocked the identity's key — a password prompt, for an encrypted one — and, on a stopped managed network, reported the network error in place of the build error. - `Access::publish_friendly_domains` is handed the means to collect the project's friendly-name mappings rather than a finished collection, and asks only once it knows there is something to write and which network is being served. A `canister create`/`delete` against a stopped network no longer does an id-store read per environment for nothing. - That method is now required rather than defaulting to a no-op: a second implementor, or a decorator over `Access`, would otherwise silently stop publishing friendly domains with no compile error. - `Context::telemetry_data` is gone in favour of the `Host`'s. The two were separate fields the design assumed aliased one `Arc`, which the `Context { host: Host { .. }, ..Context::mocked() }` pattern in the tests quietly breaks. --- crates/icp-cli/src/commands/deploy.rs | 28 ++++++--- crates/icp-cli/src/main.rs | 2 +- crates/icp/src/agent.rs | 65 +++++++++++++++++++- crates/icp/src/context/init.rs | 3 +- crates/icp/src/context/mod.rs | 9 +-- crates/icp/src/host.rs | 85 ++++++++++++++++----------- crates/icp/src/network/mod.rs | 60 +++++++++++++------ crates/icp/src/operations/deploy.rs | 14 ++++- 8 files changed, 190 insertions(+), 76 deletions(-) diff --git a/crates/icp-cli/src/commands/deploy.rs b/crates/icp-cli/src/commands/deploy.rs index 889aceb74..2400c4fca 100644 --- a/crates/icp-cli/src/commands/deploy.rs +++ b/crates/icp-cli/src/commands/deploy.rs @@ -6,6 +6,7 @@ use ic_agent::{Agent, AgentError}; use icp::operations::deploy::{DeployParams, DeployReport, deploy, resolve_targets}; use icp::parsers::CyclesAmount; use icp::{ + agent::LazyAgent, context::Context, host::{CanisterSelection, EnvironmentSelection}, identity::IdentitySelection, @@ -115,12 +116,14 @@ pub(crate) async fn exec(ctx: &Context, args: &DeployArgs) -> Result<(), anyhow: bail!("--args and --args-file can only be used when deploying a single canister"); } - // Resolved up front and used for the whole run, including the URLs printed - // at the end: one agent means one identity unlock and, for a network whose - // root key is fetched, one fetch rather than one per phase. - let agent = ctx - .get_agent_for_env(&identity_selection, &environment_selection) - .await?; + // One agent for the whole run, including the URLs printed at the end: one + // identity unlock and, for a network whose root key is fetched, one fetch + // rather than one per phase. Deferred rather than made here, because + // unlocking a key and reaching a network are exactly what a deploy that + // fails to build should not do; the first phase that needs the network + // creates it. + let (identity, environment) = (&identity_selection, &environment_selection); + let agent = LazyAgent::new(move || ctx.get_agent_for_env(identity, environment)); let pkg_cache = ctx.dirs.package_cache()?; let params = DeployParams { @@ -163,7 +166,14 @@ pub(crate) async fn exec(ctx: &Context, args: &DeployArgs) -> Result<(), anyhow: } result?; - print_canister_urls(ctx, &environment_selection, agent, &canisters, args.json).await?; + print_canister_urls( + ctx, + &environment_selection, + agent.get().await?, + &canisters, + args.json, + ) + .await?; Ok(()) } @@ -235,7 +245,7 @@ fn is_method_not_found(err: &AgentError) -> bool { async fn print_canister_urls( ctx: &Context, environment_selection: &EnvironmentSelection, - agent: Agent, + agent: &Agent, canister_names: &[String], json: bool, ) -> Result<(), anyhow::Error> { @@ -289,7 +299,7 @@ async fn print_canister_urls( continue; }; - if has_http_request(&agent, canister_id).await { + if has_http_request(agent, canister_id).await { // A canister carries one friendly name normally, or several when // it's a de-duplicated shared dependency canister reached via // multiple alias chains — print one URL for each. Fall back to a diff --git a/crates/icp-cli/src/main.rs b/crates/icp-cli/src/main.rs index edcfb9a85..7db1bd947 100644 --- a/crates/icp-cli/src/main.rs +++ b/crates/icp-cli/src/main.rs @@ -202,7 +202,7 @@ async fn run() -> Result<(), Error> { let result = dispatch(&ctx, command).instrument(trace_span).await; if let Some(session) = telemetry_session { - session.finish(result.is_ok(), &ctx.telemetry_data); + session.finish(result.is_ok(), &ctx.host.telemetry_data); } // Show update nag after command output diff --git a/crates/icp/src/agent.rs b/crates/icp/src/agent.rs index b0df5c6de..991075f37 100644 --- a/crates/icp/src/agent.rs +++ b/crates/icp/src/agent.rs @@ -1,8 +1,10 @@ -use std::{sync::Arc, time::Duration}; +use std::{error::Error, fmt, future::Future, sync::Arc, time::Duration}; use async_trait::async_trait; +use futures::future::BoxFuture; use ic_agent::{Agent, AgentError, Identity}; use snafu::prelude::*; +use tokio::sync::OnceCell; use crate::prelude::*; @@ -57,6 +59,67 @@ impl Create for Creator { } } +/// An [`Agent`] created on first use. +/// +/// Creating an agent unlocks an identity — a password prompt, for an encrypted +/// one — and, on a network whose root key is fetched, costs a round trip. An +/// operation that can fail before it ever speaks to the network, such as a +/// deploy whose build fails, should cost neither: it takes one of these and the +/// first phase that actually needs the network pays for it. +pub struct LazyAgent<'a> { + cell: OnceCell, + create: Box BoxFuture<'a, Result> + Send + Sync + 'a>, +} + +impl<'a> LazyAgent<'a> { + /// Wraps whatever the caller does to produce an agent. + /// + /// `create` runs on the first [`get`](Self::get), and on a later one only + /// while it keeps failing; the first agent it yields is the one every + /// caller sees. + pub fn new(create: F) -> Self + where + F: Fn() -> Fut + Send + Sync + 'a, + Fut: Future> + Send + 'a, + E: Error + Send + Sync + 'static, + { + Self { + cell: OnceCell::new(), + create: Box::new(move || { + let creating = create(); + Box::pin(async move { creating.await.map_err(|e| LazyAgentError(Box::new(e))) }) + }), + } + } + + /// The agent, creating it if this is the first call. + pub async fn get(&self) -> Result<&Agent, LazyAgentError> { + self.cell.get_or_try_init(|| (self.create)()).await + } +} + +/// Whatever went wrong in a [`LazyAgent`]'s creation function. +/// +/// Type-erased, and hand-written rather than a Snafu variant, because how an +/// identity is resolved and unlocked belongs to the caller: an operation holding +/// a `LazyAgent` cannot name that error, and does nothing with it but report it. +/// So this adds no message of its own — display and source both pass straight +/// through, as `snafu(transparent)` would. +#[derive(Debug)] +pub struct LazyAgentError(Box); + +impl fmt::Display for LazyAgentError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + self.0.fmt(f) + } +} + +impl Error for LazyAgentError { + fn source(&self) -> Option<&(dyn Error + 'static)> { + self.0.source() + } +} + /// How far a test has advanced the replica's clock past this machine's, so the /// default ingress expiry stays ahead of replica time. fn test_time_advance() -> Duration { diff --git a/crates/icp/src/context/init.rs b/crates/icp/src/context/init.rs index d96c1d243..a45c4d482 100644 --- a/crates/icp/src/context/init.rs +++ b/crates/icp/src/context/init.rs @@ -148,13 +148,12 @@ pub fn initialize( builder, syncer, network: netaccess, - telemetry_data: telemetry_data.clone(), + telemetry_data, }, dirs, identity: idload, agent: agent_creator, debug, - telemetry_data, password_func, }) } diff --git a/crates/icp/src/context/mod.rs b/crates/icp/src/context/mod.rs index 938ac1996..96eed7386 100644 --- a/crates/icp/src/context/mod.rs +++ b/crates/icp/src/context/mod.rs @@ -62,9 +62,6 @@ pub struct Context { /// Whether debug is enabled pub debug: bool, - /// Telemetry data collected during command execution - pub telemetry_data: Arc, - /// Password reader for identity decryption; shared with the identity loader. pub password_func: Arc Result + Send + Sync>, } @@ -138,7 +135,7 @@ impl Context { NetworkConfiguration::Managed { .. } => NetworkType::Managed, NetworkConfiguration::Connected { .. } => NetworkType::Connected, }; - self.telemetry_data.set_network_type(network_type); + self.host.telemetry_data.set_network_type(network_type); Ok(network) } @@ -320,10 +317,8 @@ impl Context { #[cfg(test)] /// Creates a test context with all mocks pub fn mocked() -> Context { - let host = Host::mocked(); Context { - telemetry_data: host.telemetry_data.clone(), - host, + host: Host::mocked(), dirs: Arc::new(crate::directories::UnimplementedMockDirs), identity: Arc::new(crate::identity::MockIdentityLoader::anonymous()), agent: Arc::new(crate::agent::Creator), diff --git a/crates/icp/src/host.rs b/crates/icp/src/host.rs index 3a7042405..9cc60ca84 100644 --- a/crates/icp/src/host.rs +++ b/crates/icp/src/host.rs @@ -284,10 +284,11 @@ impl Host { /// Republishes the friendly canister domains of the managed network the /// given environment targets. /// - /// Collects the `friendly name -> canister id` mappings of every - /// environment in the project and hands them to the network layer, which - /// owns where they are written and which of them the running network - /// actually serves. + /// Hands the network layer the means to collect the project's + /// `friendly name -> canister id` mappings, rather than the mappings + /// themselves: it owns where they are written and which network is actually + /// being served, and only it knows whether there is anything to write at + /// all — so it decides whether the id store is read. /// /// This is a best-effort operation: a failure to update friendly domains /// should not block canister creation or deletion, so nothing here is @@ -299,45 +300,59 @@ impl Host { let NetworkConfiguration::Managed { .. } = &env.network.configuration else { return; }; + // The load is cached, so this costs a clone; the per-environment id + // lookups are what the callback defers. let Ok(project) = self.project.load().await else { return; }; - // For each environment, turn its stored `store_key -> principal` - // mapping into `(friendly_name, principal)` entries by joining against - // the consolidated canisters (keyed by the same store key). A canister - // contributes one entry per friendly name — several for a de-duplicated - // shared dependency canister. - let mut collected = Vec::new(); - for (env_name, env) in &project.environments { - let is_cache = matches!( - env.network.configuration, - NetworkConfiguration::Managed { .. } - ); - let Ok(mapping) = self.ids.lookup_by_environment(is_cache, env_name) else { - continue; - }; - let mut entries = Vec::new(); - for (store_key, principal) in &mapping { - if let Some((_, canister)) = env.canisters.get(store_key) { - for friendly_name in &canister.friendly_names { - entries.push((friendly_name.clone(), *principal)); - } + self.network + .publish_friendly_domains(&env.network, &|network| { + collect_friendly_domains(&project, &*self.ids, network) + }) + .await; + } +} + +/// The friendly-name mappings of every environment targeting `network`. +/// +/// Turns each environment's stored `store_key -> principal` mapping into +/// `(friendly_name, principal)` entries by joining against the consolidated +/// canisters (keyed by the same store key). A canister contributes one entry per +/// friendly name — several for a de-duplicated shared dependency canister. +fn collect_friendly_domains( + project: &crate::Project, + ids: &dyn crate::store_id::Access, + network: &str, +) -> Vec { + let mut collected = Vec::new(); + for (env_name, env) in &project.environments { + if env.network.name != network { + continue; + } + let is_cache = matches!( + env.network.configuration, + NetworkConfiguration::Managed { .. } + ); + let Ok(mapping) = ids.lookup_by_environment(is_cache, env_name) else { + continue; + }; + let mut entries = Vec::new(); + for (store_key, principal) in &mapping { + if let Some((_, canister)) = env.canisters.get(store_key) { + for friendly_name in &canister.friendly_names { + entries.push((friendly_name.clone(), *principal)); } } - if !entries.is_empty() { - collected.push(FriendlyDomains { - environment: env_name.clone(), - network: env.network.name.clone(), - entries, - }); - } } - - self.network - .publish_friendly_domains(&env.network, &collected) - .await; + if !entries.is_empty() { + collected.push(FriendlyDomains { + environment: env_name.clone(), + entries, + }); + } } + collected } #[derive(Debug, Snafu)] diff --git a/crates/icp/src/network/mod.rs b/crates/icp/src/network/mod.rs index dfb1cf2c8..d30810946 100644 --- a/crates/icp/src/network/mod.rs +++ b/crates/icp/src/network/mod.rs @@ -346,24 +346,25 @@ pub enum AccessError { } /// One environment's friendly-name mappings, as collected from the project. -/// -/// The project layer knows which canisters have ids and what they are called; -/// which of those a running network actually serves, and where the mapping is -/// written, is this layer's business. So the project hands over every -/// environment it has and lets [`Access::publish_friendly_domains`] pick. #[derive(Clone, Debug)] pub struct FriendlyDomains { /// Environment the mappings belong to. pub environment: String, - /// Name of the network that environment targets. - pub network: String, - /// `(friendly name, canister id)`, one entry per friendly name — so several /// for a de-duplicated shared dependency canister. pub entries: Vec<(String, Principal)>, } +/// Collects the mappings of every environment that targets the named network. +/// +/// The project layer knows which canisters have ids and what they are called; +/// which network is actually being served, and where the mapping is written, is +/// this layer's business. So the project hands over the means to collect rather +/// than a finished collection, and [`Access::publish_friendly_domains`] names +/// the network — and decides whether to ask at all. +pub type CollectFriendlyDomains<'a> = dyn Fn(&str) -> Vec + Send + Sync + 'a; + #[async_trait] pub trait Access: Sync + Send { fn get_network_directory(&self, network: &Network) -> Result; @@ -379,9 +380,17 @@ pub trait Access: Sync + Send { /// Best-effort by contract: a network that is not running, or is not /// managed, or whose mapping cannot be written, is not an error — this is /// called on paths (canister creation, deletion) that must not fail because - /// a convenience URL is stale. Implementations that serve no friendly - /// domains need not override it. - async fn publish_friendly_domains(&self, _network: &Network, _envs: &[FriendlyDomains]) {} + /// a convenience URL is stale. + /// + /// Call `collect` only once it is settled that there is something to + /// publish, and only for the network being served: reading the mappings + /// costs an id-store read per environment, and a stopped network should + /// cost none. + async fn publish_friendly_domains( + &self, + network: &Network, + collect: &CollectFriendlyDomains<'_>, + ); } pub struct Accessor { @@ -439,7 +448,11 @@ impl Access for Accessor { } } - async fn publish_friendly_domains(&self, network: &Network, envs: &[FriendlyDomains]) { + async fn publish_friendly_domains( + &self, + network: &Network, + collect: &CollectFriendlyDomains<'_>, + ) { let Configuration::Managed { .. } = &network.configuration else { return; }; @@ -461,13 +474,14 @@ impl Access for Accessor { return; }; - // The descriptor names the network the gateway is actually serving, so - // it — not the environment's own view — decides which environments share - // this network and therefore this mapping file. - let env_entries: BTreeMap> = envs - .iter() - .filter(|e| e.network == desc.network) - .map(|e| (e.environment.clone(), e.entries.clone())) + // Only here, past every way this can turn out to have nothing to write, + // is the project asked for any mappings. The descriptor names the + // network the gateway is actually serving, so it — not the + // environment's own view — decides which environments share this + // network and therefore this mapping file. + let env_entries: BTreeMap> = collect(&desc.network) + .into_iter() + .map(|e| (e.environment, e.entries)) .collect(); let extra: Vec<_> = custom_domains::ii_custom_domain_entry(desc.ii, domain) @@ -541,6 +555,14 @@ impl Access for MockNetworkAccessor { http_gateway_url: access.http_gateway_url, }) } + + /// The mock serves no friendly domains, so it never asks for any. + async fn publish_friendly_domains( + &self, + _network: &Network, + _collect: &CollectFriendlyDomains<'_>, + ) { + } } #[cfg(test)] diff --git a/crates/icp/src/operations/deploy.rs b/crates/icp/src/operations/deploy.rs index 663417c40..cb7ef03ae 100644 --- a/crates/icp/src/operations/deploy.rs +++ b/crates/icp/src/operations/deploy.rs @@ -24,6 +24,7 @@ use icp_events::TaskOutcome; use itertools::Itertools; use snafu::{OptionExt, ResultExt, Snafu}; +use crate::agent::{LazyAgent, LazyAgentError}; use crate::host::{ CanisterSelection, EnvironmentSelection, GetCanisterIdForEnvError, GetEnvCanisterError, GetEnvironmentError, GetIdsByEnvironmentError, Host, SetCanisterIdForEnvError, @@ -63,6 +64,9 @@ pub enum DeployError { #[snafu(transparent)] Build { source: BuildManyError }, + #[snafu(transparent)] + CreateAgent { source: LazyAgentError }, + #[snafu(transparent)] GetEnvironment { source: GetEnvironmentError }, @@ -205,12 +209,14 @@ pub struct DeployReport { /// Run a full deploy, reporting progress as one task tree. /// /// `agent` speaks for the identity the caller resolved: which identity that is, -/// and how its key was unlocked, is not this layer's business. +/// and how its key was unlocked, is not this layer's business. It is resolved +/// once the build has succeeded and not before — a deploy that cannot build has +/// no business unlocking a key or reaching a network. /// /// `report` is written as the run goes; see [`DeployReport`]. pub async fn deploy( host: &Host, - agent: &Agent, + agent: &LazyAgent<'_>, pkg_cache: &PackageCache, params: &DeployParams, reporter: &Reporter, @@ -239,6 +245,10 @@ pub async fn deploy( .await; finish(&phase, result)?; + // Everything from here on talks to the network, so this is where the agent + // gets made — and where the identity it speaks for gets unlocked. + let agent = agent.get().await?; + // Create any canisters that do not exist yet let env = host.get_environment(environment_selection).await?; let existing_canisters = host.ids_by_environment(environment_selection).await?; From 381eb436656b768bedc133525757a11d6dd14560 Mon Sep 17 00:00:00 2001 From: Adam Spofford Date: Fri, 11 Sep 2026 06:05:31 -0700 Subject: [PATCH 3/6] fix: stop appending the validation cause to its own message `CanisterMigrationError::ValidationFailed` interpolated `{source}` into its display while also reporting it as a source, so the cause printed twice in every chain. Its three sibling variants already left the cause to the chain; this one was the exception. Predates this stack; it is fixed at the base so the rest of the stack carries the fix. --- crates/icp/src/operations/canister_migration.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/icp/src/operations/canister_migration.rs b/crates/icp/src/operations/canister_migration.rs index 3c1c18f30..f632676a3 100644 --- a/crates/icp/src/operations/canister_migration.rs +++ b/crates/icp/src/operations/canister_migration.rs @@ -24,7 +24,7 @@ pub enum CanisterMigrationError { #[snafu(display("Failed to query migration status"))] QueryMigrationStatus { source: AgentError }, - #[snafu(display("Validation failed: {source}"))] + #[snafu(display("Validation failed"))] ValidationFailed { source: ValidationError }, #[snafu(display("Validation failed with unknown error"))] From 86cecbafcfad4ad7e504f84be0c414722d94131a Mon Sep 17 00:00:00 2001 From: Adam Spofford Date: Fri, 11 Sep 2026 06:11:06 -0700 Subject: [PATCH 4/6] fix: stop appending causes to the messages that wrap them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Twenty-three error variants interpolated `{source}` into their own display while also reporting it as a source, so every chain printed the cause twice — once inside the wrapping message and once beneath it: Error: failed to get available subnets: canister rejected the call Caused by: canister rejected the call Each message keeps the context it adds, naming the operation that failed, and the cause is left to the chain that already reports it. Nothing is lost: these all reach the user through the anyhow chain, which prints every source below the top line. Predate this stack, so they are fixed at its base and the rest of the stack carries the fix. --- crates/icp/src/operations/create.rs | 20 +++++++++---------- crates/icp/src/operations/proxy.rs | 12 +++++------ crates/icp/src/operations/proxy_management.rs | 8 ++++---- crates/icp/src/operations/token/allowance.rs | 2 +- crates/icp/src/operations/token/approve.rs | 2 +- crates/icp/src/operations/token/balance.rs | 2 +- 6 files changed, 23 insertions(+), 23 deletions(-) diff --git a/crates/icp/src/operations/create.rs b/crates/icp/src/operations/create.rs index 0acacd67d..2256d0c33 100644 --- a/crates/icp/src/operations/create.rs +++ b/crates/icp/src/operations/create.rs @@ -40,31 +40,31 @@ use super::proxy_management; #[derive(Debug, Snafu)] pub enum CreateOperationError { - #[snafu(display("failed to encode candid: {source}"))] + #[snafu(display("failed to encode candid"))] CandidEncode { source: candid::Error }, - #[snafu(display("failed to decode candid: {source}"))] + #[snafu(display("failed to decode candid"))] CandidDecode { source: candid::Error }, - #[snafu(display("agent error: {source}"))] + #[snafu(display("agent error"))] Agent { source: AgentError }, #[snafu(display("failed to create canister: {message}"))] CreateCanister { message: String }, - #[snafu(display("failed to get subnet for canister: {source}"))] + #[snafu(display("failed to get subnet for canister"))] GetSubnet { source: AgentError }, - #[snafu(display("failed to sign the subnet-scoped create_canister call: {source}"))] + #[snafu(display("failed to sign the subnet-scoped create_canister call"))] SignSubnetCreate { source: AgentError }, - #[snafu(display("failed to submit create_canister to subnet {subnet}: {source}"))] + #[snafu(display("failed to submit create_canister to subnet {subnet}"))] SubmitSubnetCreate { source: AgentError, subnet: Principal, }, - #[snafu(display("failed to await create_canister on subnet {subnet}: {source}"))] + #[snafu(display("failed to await create_canister on subnet {subnet}"))] AwaitSubnetCreate { source: AgentError, subnet: Principal, @@ -73,7 +73,7 @@ pub enum CreateOperationError { #[snafu(display("invalid engine-canister id: {message}"))] EngineCanisterId { message: String }, - #[snafu(display("failed to query the engine-canister registry: {source}"))] + #[snafu(display("failed to query the engine-canister registry"))] EngineCanisterQuery { source: AgentError }, #[snafu(display( @@ -90,7 +90,7 @@ pub enum CreateOperationError { #[snafu(display("missing subnet id in registry response"))] MissingSubnetId, - #[snafu(display("failed to get available subnets: {source}"))] + #[snafu(display("failed to get available subnets"))] GetAvailableSubnets { source: AgentError }, #[snafu(display("no available subnets found"))] @@ -108,7 +108,7 @@ pub enum CreateOperationError { #[snafu(display("invalid ICP amount: {message}"))] InvalidIcpAmount { message: String }, - #[snafu(display("failed to transfer ICP to the cycles minting canister: {source}"))] + #[snafu(display("failed to transfer ICP to the cycles minting canister"))] TransferIcp { source: AgentError }, #[snafu(display("ICP ledger transfer failed: {message}"))] diff --git a/crates/icp/src/operations/proxy.rs b/crates/icp/src/operations/proxy.rs index a50f49475..c9e972f8a 100644 --- a/crates/icp/src/operations/proxy.rs +++ b/crates/icp/src/operations/proxy.rs @@ -6,25 +6,25 @@ use snafu::{ResultExt, Snafu}; #[derive(Debug, Snafu)] pub enum UpdateOrProxyError { - #[snafu(display("failed to encode proxy call arguments: {source}"))] + #[snafu(display("failed to encode proxy call arguments"))] ProxyEncode { source: candid::Error }, - #[snafu(display("direct update call failed: {source}"))] + #[snafu(display("direct update call failed"))] DirectUpdateCall { source: ic_agent::AgentError }, - #[snafu(display("proxy update call failed: {source}"))] + #[snafu(display("proxy update call failed"))] ProxyUpdateCall { source: ic_agent::AgentError }, - #[snafu(display("failed to decode proxy canister response: {source}"))] + #[snafu(display("failed to decode proxy canister response"))] ProxyDecode { source: candid::Error }, #[snafu(display("proxy call failed: {message}"))] ProxyCall { message: String }, - #[snafu(display("failed to encode call arguments: {source}"))] + #[snafu(display("failed to encode call arguments"))] CandidEncode { source: candid::Error }, - #[snafu(display("failed to decode call response: {source}"))] + #[snafu(display("failed to decode call response"))] CandidDecode { source: candid::Error }, } diff --git a/crates/icp/src/operations/proxy_management.rs b/crates/icp/src/operations/proxy_management.rs index ffc7cac70..1c7662dad 100644 --- a/crates/icp/src/operations/proxy_management.rs +++ b/crates/icp/src/operations/proxy_management.rs @@ -201,16 +201,16 @@ pub async fn clear_chunk_store( #[derive(Debug, Snafu)] pub enum FetchCanisterLogsError { - #[snafu(display("failed to encode call arguments: {source}"))] + #[snafu(display("failed to encode call arguments"))] CandidEncode { source: candid::Error }, - #[snafu(display("failed to decode call response: {source}"))] + #[snafu(display("failed to decode call response"))] CandidDecode { source: candid::Error }, - #[snafu(display("direct query call failed: {source}"))] + #[snafu(display("direct query call failed"))] DirectQueryCall { source: ic_agent::AgentError }, - #[snafu(display("proxied call failed: {source}"))] + #[snafu(display("proxied call failed"))] ProxiedCall { source: UpdateOrProxyError }, } diff --git a/crates/icp/src/operations/token/allowance.rs b/crates/icp/src/operations/token/allowance.rs index 31c34000a..63bb93d9c 100644 --- a/crates/icp/src/operations/token/allowance.rs +++ b/crates/icp/src/operations/token/allowance.rs @@ -9,7 +9,7 @@ use super::{TOKEN_LEDGER_CIDS, TokenAmount}; #[derive(Debug, Snafu)] pub enum GetAllowanceError { - #[snafu(display("failed to parse canister id '{canister_id}': {source}"))] + #[snafu(display("failed to parse canister id '{canister_id}'"))] ParseCanisterId { canister_id: String, source: candid::types::principal::PrincipalError, diff --git a/crates/icp/src/operations/token/approve.rs b/crates/icp/src/operations/token/approve.rs index 6e5099513..9a5ca4723 100644 --- a/crates/icp/src/operations/token/approve.rs +++ b/crates/icp/src/operations/token/approve.rs @@ -10,7 +10,7 @@ use super::{TOKEN_LEDGER_CIDS, TokenAmount}; #[derive(Debug, Snafu)] pub enum TokenApproveError { - #[snafu(display("failed to parse canister id '{canister_id}': {source}"))] + #[snafu(display("failed to parse canister id '{canister_id}'"))] ParseCanisterId { canister_id: String, source: candid::types::principal::PrincipalError, diff --git a/crates/icp/src/operations/token/balance.rs b/crates/icp/src/operations/token/balance.rs index 271a7672f..8e846c569 100644 --- a/crates/icp/src/operations/token/balance.rs +++ b/crates/icp/src/operations/token/balance.rs @@ -8,7 +8,7 @@ use super::{TOKEN_LEDGER_CIDS, TokenAmount}; #[derive(Debug, Snafu)] pub enum GetBalanceError { - #[snafu(display("failed to parse canister id '{canister_id}': {source}"))] + #[snafu(display("failed to parse canister id '{canister_id}'"))] ParseCanisterId { canister_id: String, source: candid::types::principal::PrincipalError, From c02e290d38acd72bbf46b49f88f42aef12dfba7b Mon Sep 17 00:00:00 2001 From: Adam Spofford Date: Mon, 14 Sep 2026 05:51:07 -0700 Subject: [PATCH 5/6] fix: refuse --no-create before the agent is created Which canisters exist comes out of the local id store, so the refusal does not need a network or an unlocked identity. Deciding it first means `--no-create` reports the missing canisters whether or not the network is up, instead of prompting for a key or failing to reach the replica. Co-Authored-By: Claude Opus 5 --- crates/icp-cli/tests/deploy_tests.rs | 40 ++++++++++++++++++++++++++++ crates/icp/src/operations/deploy.rs | 25 ++++++++++------- 2 files changed, 55 insertions(+), 10 deletions(-) diff --git a/crates/icp-cli/tests/deploy_tests.rs b/crates/icp-cli/tests/deploy_tests.rs index c78b7cfdb..599d5a079 100644 --- a/crates/icp-cli/tests/deploy_tests.rs +++ b/crates/icp-cli/tests/deploy_tests.rs @@ -193,6 +193,46 @@ async fn deploy_no_create_fails_when_canister_missing() { ); } +/// The `--no-create` refusal is decided from the local id store, so it reads +/// the same whether or not the network is reachable: with the network never +/// started, the deploy still names the missing canisters rather than failing to +/// reach the replica. +#[test] +fn deploy_no_create_fails_when_canister_missing_without_network() { + let ctx = TestContext::new(); + let project_dir = ctx.create_project_dir("icp"); + + let wasm = ctx.make_asset("example_icp_mo.wasm"); + + let pm = formatdoc! {r#" + canisters: + - name: my-canister + build: + steps: + - type: script + command: cp '{wasm}' "$ICP_WASM_OUTPUT_PATH" + + {NETWORK_RANDOM_PORT} + {ENVIRONMENT_RANDOM_PORT} + "#}; + + write_string(&project_dir.join("icp.yaml"), &pm).expect("failed to write project manifest"); + + ctx.icp() + .current_dir(&project_dir) + .args([ + "deploy", + "--environment", + "random-environment", + "--no-create", + ]) + .assert() + .failure() + .stderr(contains( + "`--no-create` was specified but the following canisters do not exist: my-canister", + )); +} + /// `deploy --no-create` succeeds when the canister already exists: it skips /// creation and proceeds to install as normal. #[tokio::test] diff --git a/crates/icp/src/operations/deploy.rs b/crates/icp/src/operations/deploy.rs index cb7ef03ae..9981dc63f 100644 --- a/crates/icp/src/operations/deploy.rs +++ b/crates/icp/src/operations/deploy.rs @@ -210,8 +210,9 @@ pub struct DeployReport { /// /// `agent` speaks for the identity the caller resolved: which identity that is, /// and how its key was unlocked, is not this layer's business. It is resolved -/// once the build has succeeded and not before — a deploy that cannot build has -/// no business unlocking a key or reaching a network. +/// at the first phase that needs the network and not before — a deploy that +/// cannot build, or that `--no-create` refuses, has no business unlocking a key +/// or reaching a network. /// /// `report` is written as the run goes; see [`DeployReport`]. pub async fn deploy( @@ -245,11 +246,9 @@ pub async fn deploy( .await; finish(&phase, result)?; - // Everything from here on talks to the network, so this is where the agent - // gets made — and where the identity it speaks for gets unlocked. - let agent = agent.get().await?; - - // Create any canisters that do not exist yet + // Create any canisters that do not exist yet. Which ones exist comes out of + // the local id store, so `--no-create` refuses before anything reaches the + // network: the refusal is the same whether or not the network is up. let env = host.get_environment(environment_selection).await?; let existing_canisters = host.ids_by_environment(environment_selection).await?; let canisters_to_create = cnames @@ -257,13 +256,19 @@ pub async fn deploy( .filter(|name| !existing_canisters.contains_key(*name)) .collect::>(); - if canisters_to_create.is_empty() { - notice(reporter, "All canisters already exist"); - } else if params.no_create { + if params.no_create && !canisters_to_create.is_empty() { return NoCreateSnafu { canisters: canisters_to_create.into_iter().cloned().collect::>(), } .fail(); + } + + // Everything from here on talks to the network, so this is where the agent + // gets made — and where the identity it speaks for gets unlocked. + let agent = agent.get().await?; + + if canisters_to_create.is_empty() { + notice(reporter, "All canisters already exist"); } else { let phase = reporter.task(Task::phase("Creating canisters:")); let result = create_canisters( From d943bb95e64b6c20be3af8bf3838552b0af5a112 Mon Sep 17 00:00:00 2001 From: Adam Spofford Date: Mon, 14 Sep 2026 11:34:22 -0700 Subject: [PATCH 6/6] Update rustls for advisory --- Cargo.lock | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index b783cad2a..b84c8bf6b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -433,9 +433,9 @@ checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" [[package]] name = "aws-lc-rs" -version = "1.17.0" +version = "1.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ec2f1fc3ec205783a5da9a7e6c1509cc69dedf09a1949e412c1e18469326d00" +checksum = "b281d307588d634de920874890732659e2e7672f72b5e10e81badc1a8a83621e" dependencies = [ "aws-lc-sys", "zeroize", @@ -443,14 +443,15 @@ dependencies = [ [[package]] name = "aws-lc-sys" -version = "0.41.0" +version = "0.45.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a2f9779ce85b93ab6170dd940ad0169b5766ff848247aff13bb788b832fe3f4" +checksum = "9bff6c3b54fad79a2e60b8102caf565819711497c1f5f092f49508e2f5c31b27" dependencies = [ "cc", "cmake", "dunce", "fs_extra", + "pkg-config", ] [[package]] @@ -6249,9 +6250,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.41" +version = "0.23.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b92b125634d9b795e7beca796cc790df15a7fb38323bf3196fda83292d06b1f" +checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" dependencies = [ "aws-lc-rs", "once_cell", @@ -6312,9 +6313,9 @@ checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" [[package]] name = "rustls-webpki" -version = "0.103.13" +version = "0.103.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" dependencies = [ "aws-lc-rs", "ring",