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", 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..2400c4fca 100644 --- a/crates/icp-cli/src/commands/deploy.rs +++ b/crates/icp-cli/src/commands/deploy.rs @@ -6,7 +6,9 @@ use ic_agent::{Agent, AgentError}; use icp::operations::deploy::{DeployParams, DeployReport, deploy, resolve_targets}; use icp::parsers::CyclesAmount; use icp::{ - context::{CanisterSelection, Context, EnvironmentSelection}, + agent::LazyAgent, + context::Context, + host::{CanisterSelection, EnvironmentSelection}, identity::IdentitySelection, network::Configuration as NetworkConfiguration, }; @@ -97,7 +99,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 +116,18 @@ 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"); } + // 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 { environment: environment_selection.clone(), - identity: identity_selection.clone(), canisters: canisters.clone(), mode: args.mode.clone(), subnet: args.subnet, @@ -132,7 +143,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,10 +166,14 @@ 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?; + print_canister_urls( + ctx, + &environment_selection, + agent.get().await?, + &canisters, + args.json, + ) + .await?; Ok(()) } @@ -222,18 +245,18 @@ 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> { 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 +276,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, @@ -275,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 @@ -386,12 +410,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/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-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-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/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 4af5cda79..a45c4d482 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,17 +141,19 @@ pub fn initialize( // Setup environment Ok(Context { + host: Host { + project: pload, + ids, + artifacts, + builder, + syncer, + network: netaccess, + telemetry_data, + }, 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..96eed7386 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,50 +45,23 @@ 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, - /// 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>, } @@ -126,49 +82,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 +93,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(), })?; @@ -222,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) } @@ -243,147 +156,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 +183,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 +305,24 @@ 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 { Context { + host: Host::mocked(), 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 +337,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 +361,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 +459,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..9cc60ca84 --- /dev/null +++ b/crates/icp/src/host.rs @@ -0,0 +1,476 @@ +//! 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. + /// + /// 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 + /// 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; + }; + // 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; + }; + + 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(), + entries, + }); + } + } + collected +} + +#[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..d30810946 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,26 @@ pub enum AccessError { GetNetworkAccess { source: GetNetworkAccessError }, } +/// One environment's friendly-name mappings, as collected from the project. +#[derive(Clone, Debug)] +pub struct FriendlyDomains { + /// Environment the mappings belong to. + pub environment: 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; @@ -353,6 +374,23 @@ 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. + /// + /// 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 { @@ -409,6 +447,52 @@ impl Access for Accessor { }), } } + + async fn publish_friendly_domains( + &self, + network: &Network, + collect: &CollectFriendlyDomains<'_>, + ) { + 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; + }; + + // 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) + .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)] @@ -471,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/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"))] 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/deploy.rs b/crates/icp/src/operations/deploy.rs index b3b04818d..9981dc63f 100644 --- a/crates/icp/src/operations/deploy.rs +++ b/crates/icp/src/operations/deploy.rs @@ -24,13 +24,11 @@ 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::agent::{LazyAgent, LazyAgentError}; +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 +46,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,17 +61,14 @@ pub enum DeployError { #[snafu(transparent)] ResolveBuildTargets { source: GetEnvCanisterError }, - #[snafu(transparent)] - PackageCache { source: LockError }, - #[snafu(transparent)] Build { source: BuildManyError }, #[snafu(transparent)] - GetEnvironment { source: GetEnvironmentError }, + CreateAgent { source: LazyAgentError }, #[snafu(transparent)] - GetAgent { source: GetAgentForEnvError }, + GetEnvironment { source: GetEnvironmentError }, #[snafu(transparent)] GetIds { source: GetIdsByEnvironmentError }, @@ -161,6 +157,9 @@ pub enum ResolveTargetsError { #[snafu(transparent)] LoadEnvironment { source: GetEnvironmentError }, + #[snafu(transparent)] + ResolveNamedCanister { source: GetEnvCanisterError }, + #[snafu(transparent)] LoadProject { source: ProjectLoadError }, @@ -179,7 +178,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 +208,17 @@ 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. It is resolved +/// 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( - ctx: &Context, + host: &Host, + agent: &LazyAgent<'_>, + pkg_cache: &PackageCache, params: &DeployParams, reporter: &Reporter, report: &mut DeployReport, @@ -223,48 +229,52 @@ 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?; + // 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 .iter() .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( - ctx, + host, params, - &agent, + agent, &env, &canisters_to_create, existing_canisters.into_values().collect(), @@ -275,14 +285,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 +305,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 +335,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 +346,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 +392,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 +405,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 +422,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 +483,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 +521,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 +599,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 +619,7 @@ async fn sync( urls, canister_ids, proxy, - &pkg_cache, + pkg_cache, &phase.reporter(), ) .await; @@ -633,11 +643,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 +655,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 +665,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 +684,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/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/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| { 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,