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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions crates/icp-cli/src/commands/sync.rs
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,7 @@ pub(crate) async fn exec(ctx: &Context, args: &SyncArgs) -> Result<(), anyhow::E

let pkg_cache = ctx.dirs.package_cache()?;
let project_dir = ctx.project.load().await?.dir;
let urls = ctx.network.urls(&env.network).await?;

rendered(ctx.debug, async |reporter| {
sync_many(
Expand All @@ -137,6 +138,7 @@ pub(crate) async fn exec(ctx: &Context, args: &SyncArgs) -> Result<(), anyhow::E
project_dir,
environment_selection.name().to_owned(),
env.network.name.clone(),
urls,
canister_ids,
args.proxy,
&pkg_cache,
Expand Down
9 changes: 8 additions & 1 deletion crates/icp-cli/tests/sync_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -448,12 +448,19 @@ async fn sync_plugin_registers_seed_data() {
// example's ic-wasm step, so the section genuinely isn't there — proving the
// host performed the round-trip and mapped a proven-absent section to `none`
// rather than to an error.
//
// It reports the network's URLs too, which for a managed network are the
// gateway the launcher happened to bind — so the port in the plugin's
// output is proof the running network's address reached it.
let gateway_url = ctx.gateway_url().clone();
ctx.icp()
.current_dir(&project_dir)
.args(["deploy", "--environment", "random-environment"])
.assert()
.success()
.stderr(contains("candid:service: absent"));
.stderr(contains("candid:service: absent").and(contains(format!(
"gateway: {gateway_url} (api: {gateway_url})"
))));

// Query the canister to verify all three fruits were registered
ctx.icp()
Expand Down
1 change: 1 addition & 0 deletions crates/icp-sync-plugin/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ icp-events.workspace = true
semver.workspace = true
snafu.workspace = true
tokio.workspace = true
url.workspace = true
wasmtime.workspace = true
wasmtime-wasi.workspace = true

Expand Down
11 changes: 6 additions & 5 deletions crates/icp-sync-plugin/DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,11 +90,12 @@ pub fn run_plugin(invocation: PluginInvocation) -> Result<Vec<String>, RunPlugin

`PluginInvocation` bundles the inputs: `wasm_path`, `base_dir`, `project_dir`,
`dirs`, `files`, `fields`, `host_canister_id` (the canister being synced),
`agent`, `proxy`, `identity_principal`, `environment`, `compute_limit_secs`, the
exposed `canister_ids` table, the `callable: CallableCanisters` enforcement set,
and `reporter`. The CLI resolves the manifest's declared `canisters:` into
`CallableCanisters` before calling; this crate stays free of any manifest
knowledge.
`agent`, `proxy`, `identity_principal`, `environment`, `api_url` and
`gateway_url` (where the network is reached — informational, since the guest has
no sockets), `compute_limit_secs`, the exposed `canister_ids` table, the
`callable: CallableCanisters` enforcement set, and `reporter`. The CLI resolves
the manifest's declared `canisters:` into `CallableCanisters` before calling;
this crate stays free of any manifest knowledge.

`dirs` and `files` are the manifest's own `dirs:`/`files:` settings as
manifest-relative paths (`KeyedPath`s carrying the map key each was declared
Expand Down
48 changes: 46 additions & 2 deletions crates/icp-sync-plugin/src/runtime.rs
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ use snafu::prelude::*;
// Aliased because wasmtime-wasi also has an `OutputStream` (imported below).
use icp_events::{OutputStream as EventStream, StepReporter};
use tokio::io::{self, AsyncWrite};
use url::Url;
use wasmtime::component::{Component, HasSelf, Linker};
use wasmtime::{Config, Engine, Store};
use wasmtime_wasi::cli::{IsTerminal, StdoutStream};
Expand Down Expand Up @@ -756,6 +757,12 @@ pub struct PluginInvocation {
pub identity_principal: Principal,
/// Name of the environment being synced.
pub environment: String,
/// The network's API endpoint — where canister calls are submitted.
/// Surfaced to v0.2.0 plugins; v0.1.0 plugins have no field for it.
pub api_url: Url,
/// The network's HTTP gateway, when it exposes one. Surfaced to v0.2.0
/// plugins; v0.1.0 plugins have no field for it.
pub gateway_url: Option<Url>,
/// Pure-wasm compute-time budget in seconds.
pub compute_limit_secs: u64,
/// The project's canister ID table for this environment, as exposed to the
Expand Down Expand Up @@ -783,6 +790,8 @@ pub fn run_plugin(invocation: PluginInvocation) -> Result<Vec<String>, RunPlugin
proxy,
identity_principal,
environment,
api_url,
gateway_url,
compute_limit_secs,
canister_ids,
callable,
Expand Down Expand Up @@ -968,6 +977,8 @@ pub fn run_plugin(invocation: PluginInvocation) -> Result<Vec<String>, RunPlugin
let input = v2::SyncExecInput {
canister_id: canister_id_text,
environment,
api_url: api_url.to_string(),
gateway_url: gateway_url.map(|url| url.to_string()),
dirs: dir_inputs
.into_iter()
.map(|entry| v2::DirInput {
Expand Down Expand Up @@ -1248,8 +1259,9 @@ mod tests {

/// A [`PluginInvocation`] with test-friendly defaults: anonymous canister
/// and identity, no proxy, no declared callable canisters, the default
/// compute limit, and the current directory as both the base and the
/// project. Tests override the few fields they care about.
/// compute limit, a local network with no gateway of its own, and the
/// current directory as both the base and the project. Tests override the
/// few fields they care about.
fn invocation(wasm_path: &str, environment: &str) -> PluginInvocation {
PluginInvocation {
wasm_path: wasm_path.into(),
Expand All @@ -1263,6 +1275,8 @@ mod tests {
proxy: None,
identity_principal: anon(),
environment: environment.to_string(),
api_url: Url::parse("http://127.0.0.1:4943").expect("valid api url"),
gateway_url: None,
compute_limit_secs: DEFAULT_PLUGIN_COMPUTE_LIMIT_SECS,
canister_ids: BTreeMap::new(),
callable: CallableCanisters::default(),
Expand Down Expand Up @@ -1785,6 +1799,36 @@ mod tests {
));
}

/// Both network URLs reach the plugin, the gateway one as a `some`.
#[test]
fn plugin_network_urls_are_passed_through() {
let Some(wasm_path) = option_env!("TEST_PLUGIN_WASM") else {
return;
};
let mut inv = invocation(wasm_path, "urls");
inv.api_url = Url::parse("https://icp-api.io").expect("valid api url");
inv.gateway_url = Some(Url::parse("https://icp0.io").expect("valid gateway url"));
let lines = run_plugin(inv).expect("plugin should succeed");
assert_eq!(
lines,
vec!["api=https://icp-api.io/ gateway=https://icp0.io/".to_string()]
);
}

/// A network with no HTTP gateway leaves `gateway-url` absent rather than
/// passing an empty or invented URL.
#[test]
fn plugin_gateway_url_is_absent_without_a_gateway() {
let Some(wasm_path) = option_env!("TEST_PLUGIN_WASM") else {
return;
};
let lines = run_plugin(invocation(wasm_path, "urls")).expect("plugin should succeed");
assert_eq!(
lines,
vec!["api=http://127.0.0.1:4943/ gateway=-".to_string()]
);
}

/// One `files:` map holds directories and files alike; the host splits them
/// by what is on disk, so each lands in the interface list its kind calls
/// for, carrying the key it was declared under.
Expand Down
10 changes: 10 additions & 0 deletions crates/icp-sync-plugin/sync-plugin.wit
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,16 @@ interface types {
canister-id: string,
/// Name of the environment being synced (e.g. "production", "local").
environment: string,
/// URL of the network's API endpoint: where the host submits the
/// canister calls it makes on the plugin's behalf. The plugin has no
/// sockets of its own, so this is something to compose a URL from or
/// hand to a canister, not something to fetch. Normalized, so a URL
/// with no path carries a trailing slash ("http://127.0.0.1:4943/").
api-url: string,
/// URL of the network's HTTP gateway, which serves canisters over
/// HTTP, normalized the same way. `none` when the network exposes no
/// gateway.
gateway-url: option<string>,
Comment on lines +95 to +104
/// Those entries of the manifest step's `files` setting that name a
/// directory, in the order they were written. The manifest declares
/// directories and files together under `files:`; the host splits them
Expand Down
10 changes: 10 additions & 0 deletions crates/icp-sync-plugin/tests/fixtures/test-plugin/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,16 @@ impl Guest for TestPlugin {
eprintln!("{rendered}");
Ok(())
}
// Echo the network URLs back as `api=<url> gateway=<url>`, using
// "-" for a network with no gateway.
"urls" => {
eprintln!(
"api={} gateway={}",
input.api_url,
input.gateway_url.as_deref().unwrap_or("-")
);
Ok(())
}
// Echo each entry as `kind key=path`, so the host can assert that
// keys survive the boundary and that a `files:` entry lands in the
// list its kind on disk calls for.
Expand Down
9 changes: 9 additions & 0 deletions crates/icp/src/canister/sync/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ use icp_events::StepReporter;
use snafu::prelude::*;

use crate::manifest::canister::SyncStep;
use crate::network::NetworkUrls;
use crate::package::PackageCache;
use crate::prelude::*;

Expand All @@ -32,6 +33,10 @@ pub struct Params {
pub environment: String,
/// Name of the network (e.g. "local", "ic").
pub network: String,
/// The network's API endpoint, where canister calls are submitted, and its
/// HTTP gateway if it exposes one. Passed to sync plugin steps via
/// `SyncExecInput`.
pub urls: NetworkUrls,
/// IDs of all named canisters in the project for this environment.
pub canister_ids: BTreeMap<String, Principal>,
/// Proxy canister to route calls through, if `--proxy` was passed.
Expand Down Expand Up @@ -177,6 +182,10 @@ mod tests {
name: "backend".to_owned(),
environment: "production".to_owned(),
network: "ic".to_owned(),
urls: NetworkUrls {
api_url: "https://icp-api.io".parse().expect("valid api url"),
http_gateway_url: Some("https://icp0.io".parse().expect("valid gateway url")),
},
canister_ids: BTreeMap::from([(
"my-frontend".to_owned(),
Principal::from_slice(&[8; 4]),
Expand Down
7 changes: 7 additions & 0 deletions crates/icp/src/canister/sync/plugin.rs
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,8 @@ pub(super) async fn sync(
proxy,
identity_principal,
environment: environment_owned,
api_url: params.urls.api_url.clone(),
gateway_url: params.urls.http_gateway_url.clone(),
compute_limit_secs,
canister_ids,
callable,
Expand Down Expand Up @@ -258,6 +260,7 @@ mod tests {
}

use crate::manifest::adapter::prebuilt::{LocalSource, SourceField};
use crate::network::NetworkUrls;

fn principal(byte: u8) -> Principal {
Principal::from_slice(&[byte; 4])
Expand All @@ -271,6 +274,10 @@ mod tests {
name: name.to_owned(),
environment: "demo".to_owned(),
network: "ic".to_owned(),
urls: NetworkUrls {
api_url: "https://icp-api.io".parse().expect("valid api url"),
http_gateway_url: Some("https://icp0.io".parse().expect("valid gateway url")),
},
canister_ids: ids.iter().map(|(n, p)| ((*n).to_owned(), *p)).collect(),
proxy: None,
}
Expand Down
5 changes: 5 additions & 0 deletions crates/icp/src/canister/sync/script.rs
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,7 @@ mod tests {

use super::*;
use crate::manifest::adapter::script::CommandField;
use crate::network::NetworkUrls;

/// Serializes the tests here that mutate the process environment, since
/// cargo runs tests in parallel threads. Async-aware because the variable
Expand All @@ -149,6 +150,10 @@ mod tests {
name: "backend".to_owned(),
environment: "production".to_owned(),
network: "ic".to_owned(),
urls: NetworkUrls {
api_url: "https://icp-api.io".parse().expect("valid api url"),
http_gateway_url: Some("https://icp0.io".parse().expect("valid gateway url")),
},
canister_ids: canister_ids
.iter()
.map(|(n, p)| ((*n).to_owned(), *p))
Expand Down
55 changes: 47 additions & 8 deletions crates/icp/src/network/access.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,10 @@ use crate::{
agent::{Create, CreateAgentError},
context::IC_ROOT_KEY,
manifest::network::RootKeySpec,
network::{Connected, NetworkDirectory, directory::LoadNetworkFileError},
network::{
Connected, NetworkDirectory, config::NetworkDescriptorModel,
directory::LoadNetworkFileError,
},
prelude::*,
};

Expand All @@ -28,6 +31,19 @@ pub enum RootKeySource {
Fetched,
}

/// The URLs a network is reached at, without any of the trust material
/// [`NetworkAccess`] carries. Resolving these never talks to the network, so a
/// caller that only needs to name an endpoint — to show it, or to hand it to a
/// sync plugin — does not trigger a root key fetch.
#[derive(Clone, Debug)]
pub struct NetworkUrls {
/// Endpoint canister calls are submitted to.
pub api_url: Url,

/// Gateway that serves canisters over HTTP, if the network exposes one.
pub http_gateway_url: Option<Url>,
}

#[derive(Clone)]
pub struct NetworkAccess {
/// Network's (resolved) root key.
Expand Down Expand Up @@ -88,6 +104,35 @@ pub enum GetNetworkAccessError {
pub async fn get_managed_network_access(
nd: NetworkDirectory,
) -> Result<NetworkAccess, GetNetworkAccessError> {
let (desc, gateway_url) = managed_network_gateway(nd).await?;
Ok(NetworkAccess {
root_key: desc.root_key,
root_key_source: RootKeySource::Managed,
api_url: gateway_url.clone(),
http_gateway_url: Some(gateway_url),
use_friendly_domains: desc.use_friendly_domains,
})
}

/// The URLs a running managed network is reached at. Its gateway serves the API
/// as well, so both URLs are the same one.
pub async fn get_managed_network_urls(
nd: NetworkDirectory,
) -> Result<NetworkUrls, GetNetworkAccessError> {
let (_, gateway_url) = managed_network_gateway(nd).await?;
Ok(NetworkUrls {
api_url: gateway_url.clone(),
http_gateway_url: Some(gateway_url),
})
}

/// A running managed network's descriptor and the URL its gateway is reachable
/// at. A network that is not running has no descriptor, and one whose fixed port
/// has since been taken by another project's network is not the network the
/// descriptor describes — both are errors rather than a URL nothing answers on.
async fn managed_network_gateway(
nd: NetworkDirectory,
) -> Result<(NetworkDescriptorModel, Url), GetNetworkAccessError> {
// Load network descriptor
let desc = nd
.load_network_descriptor()
Expand Down Expand Up @@ -118,13 +163,7 @@ pub async fn get_managed_network_access(
}
}
let http_gateway_url = Url::parse(&format!("http://{}:{port}", desc.gateway.host)).unwrap();
Ok(NetworkAccess {
root_key: desc.root_key,
root_key_source: RootKeySource::Managed,
api_url: http_gateway_url.clone(),
http_gateway_url: Some(http_gateway_url),
use_friendly_domains: desc.use_friendly_domains,
})
Ok((desc, http_gateway_url))
}

pub async fn get_connected_network_access(
Expand Down
Loading
Loading