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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
# Unreleased

* feat: Added 'friendly name' domains for canisters - instead of `<frontend principal>.localhost` you can access `frontend.local.localhost`.

# v0.2.0-beta.0

* feat: Added `bind` key to network gateway config to pick your network interface (previous documentation mentioned a `host` key, but it did not do anything)
Expand Down
6 changes: 5 additions & 1 deletion crates/icp-canister-interfaces/src/candid_ui.rs
Original file line number Diff line number Diff line change
@@ -1 +1,5 @@
pub const MAINNET_CANDID_UI_CID: &str = "a4gq6-oaaaa-aaaab-qaa4q-cai";
use candid::Principal;

// a4gq6-oaaaa-aaaab-qaa4q-cai
pub const MAINNET_CANDID_UI_CID: Principal =
Principal::from_slice(&[0x00, 0x00, 0x00, 0x00, 0x00, 0x30, 0x00, 0x39, 0x01, 0x01]);
1 change: 1 addition & 0 deletions crates/icp-cli/src/commands/canister/create.rs
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,7 @@ async fn create_project_canister(ctx: &Context, args: &CreateArgs) -> Result<(),

ctx.set_canister_id_for_env(&canister, id, &selections.environment)
.await?;
ctx.update_custom_domains(&selections.environment).await;

if args.quiet {
let _ = ctx.term.write_line(&format!("{id}"));
Expand Down
1 change: 1 addition & 0 deletions crates/icp-cli/src/commands/canister/delete.rs
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ pub(crate) async fn exec(ctx: &Context, args: &DeleteArgs) -> Result<(), anyhow:
if let CanisterSelection::Named(canister_name) = &selections.canister {
ctx.remove_canister_id_for_env(canister_name, &selections.environment)
.await?;
ctx.update_custom_domains(&selections.environment).await;
}

Ok(())
Expand Down
65 changes: 29 additions & 36 deletions crates/icp-cli/src/commands/deploy/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ use candid::{CandidType, Principal};
use clap::Args;
use futures::{StreamExt, future::try_join_all, stream::FuturesOrdered};
use ic_agent::Agent;
use icp::network::{Managed, ManagedMode};
use icp::parsers::CyclesAmount;
use icp::{
context::{CanisterSelection, Context, EnvironmentSelection},
Expand Down Expand Up @@ -176,6 +177,8 @@ pub(crate) async fn exec(ctx: &Context, args: &DeployArgs) -> Result<(), anyhow:
}
}

ctx.update_custom_domains(&environment_selection).await;

let _ = ctx.term.write_line("\n\nSetting environment variables:");
let env = ctx
.get_environment(&environment_selection)
Expand Down Expand Up @@ -377,21 +380,29 @@ async fn print_canister_urls(
agent: Agent,
canister_names: &[String],
) -> Result<(), anyhow::Error> {
use icp::network::custom_domains::{canister_gateway_url, gateway_domain};

let env = ctx.get_environment(environment_selection).await?;

// Get the network URL
let http_gateway_url = match &env.network.configuration {
NetworkConfiguration::Managed { managed: _ } => {
// For managed networks, construct localhost URL
let access = ctx.network.access(&env.network).await?;
access.http_gateway_url.clone()
}
NetworkConfiguration::Connected { connected } => {
// For connected networks, use the configured URL
connected.http_gateway_url.clone()
}
NetworkConfiguration::Connected { connected } => connected.http_gateway_url.clone(),
};

// Friendly domains are available for managed networks where we write custom-domains.txt
let has_friendly = matches!(
&env.network.configuration,
NetworkConfiguration::Managed {
managed: Managed {
mode: ManagedMode::Launcher(config)
}
} if config.version.is_none()
);

let _ = ctx.term.write_line("\n\nDeployed canisters:");

for name in canister_names {
Expand All @@ -407,58 +418,40 @@ async fn print_canister_urls(
};

if let Some(http_gateway_url) = &http_gateway_url {
// Check if canister has http_request
let has_http = has_http_request(&agent, canister_id).await;
let domain = if let Some(domain) = http_gateway_url.domain() {
Some(domain)
} else if let Some(host) = http_gateway_url.host_str()
&& (host == "127.0.0.1" || host == "[::1]")
{
Some("localhost")
let friendly = if has_friendly {
Some((name.as_str(), environment_selection.name()))
} else {
None
};

if has_http {
let mut canister_url = http_gateway_url.clone();
if let Some(domain) = domain {
canister_url
.set_host(Some(&format!("{canister_id}.{domain}")))
.unwrap();
} else {
canister_url.set_query(Some(&format!("canisterId={canister_id}")));
}
let canister_url = canister_gateway_url(http_gateway_url, canister_id, friendly);
let _ = ctx
.term
.write_line(&format!(" {}: {}", name, canister_url));
} else {
// For canisters without http_request, show the Candid UI URL
if let Some(ref ui_id) = get_candid_ui_id(ctx, environment_selection).await {
let mut candid_url = http_gateway_url.clone();
if let Some(domain) = domain {
candid_url
.set_host(Some(&format!("{ui_id}.{domain}",)))
.unwrap();
if let Some(ui_id) = get_candid_ui_id(ctx, environment_selection).await {
let domain = gateway_domain(http_gateway_url);
let mut candid_url = canister_gateway_url(http_gateway_url, ui_id, None);
if domain.is_some() {
candid_url.set_query(Some(&format!("id={canister_id}")));
} else {
candid_url.set_query(Some(&format!("canisterId={ui_id}&id={canister_id}")));
}
let _ = ctx
.term
.write_line(&format!(" {} (Candid UI): {}", name, candid_url));
.write_line(&format!(" {name} (Candid UI): {candid_url}"));
} else {
// No Candid UI available - just show the canister ID
let _ = ctx.term.write_line(&format!(
" {}: {} (Candid UI not available)",
name, canister_id
" {name}: {canister_id} (Candid UI not available)",
));
}
}
} else {
// No gateway subdomains available - just show the canister ID
let _ = ctx.term.write_line(&format!(
" {}: {} (No gateway URL available)",
name, canister_id
" {name}: {canister_id} (No gateway URL available)",
));
}
}
Expand All @@ -471,7 +464,7 @@ async fn print_canister_urls(
async fn get_candid_ui_id(
ctx: &Context,
environment_selection: &EnvironmentSelection,
) -> Option<String> {
) -> Option<Principal> {
let env = ctx.get_environment(environment_selection).await.ok()?;

match &env.network.configuration {
Expand All @@ -481,14 +474,14 @@ async fn get_candid_ui_id(
if let Ok(Some(desc)) = nd.load_network_descriptor().await
&& let Some(candid_ui) = desc.candid_ui_canister_id
{
return Some(candid_ui.to_string());
return Some(candid_ui);
}
// No Candid UI available for this managed network
None
}
NetworkConfiguration::Connected { .. } => {
// For connected networks, use the mainnet Candid UI
Some(MAINNET_CANDID_UI_CID.to_string())
Some(MAINNET_CANDID_UI_CID)
}
}
}
62 changes: 61 additions & 1 deletion crates/icp-cli/tests/deploy_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,10 @@ use predicates::{
};

use crate::common::{ENVIRONMENT_RANDOM_PORT, NETWORK_RANDOM_PORT, TestContext, clients};
use icp::{fs::write_string, prelude::*};
use icp::{
fs::{create_dir_all, write_string},
prelude::*,
};

mod common;

Expand Down Expand Up @@ -403,6 +406,63 @@ async fn deploy_prints_canister_urls() {
.stdout(contains("?id="));
}

#[tokio::test]
async fn deploy_prints_friendly_url_for_asset_canister() {
let ctx = TestContext::new();

// Setup project
let project_dir = ctx.create_project_dir("icp");
let assets_dir = project_dir.join("www");
create_dir_all(&assets_dir).expect("failed to create assets directory");
write_string(&assets_dir.join("index.html"), "hello").expect("failed to create index page");

// Project manifest with a pre-built asset canister
let pm = formatdoc! {r#"
canisters:
- name: my-canister
build:
steps:
- type: pre-built
url: https://github.com/dfinity/sdk/raw/refs/tags/0.27.0/src/distributed/assetstorage.wasm.gz
sha256: 865eb25df5a6d857147e078bb33c727797957247f7af2635846d65c5397b36a6
sync:
steps:
- type: assets
dirs:
- {assets_dir}
{NETWORK_RANDOM_PORT}
{ENVIRONMENT_RANDOM_PORT}
"#};

write_string(&project_dir.join("icp.yaml"), &pm).expect("failed to write project manifest");

// Start network
let _g = ctx.start_network_in(&project_dir, "random-network").await;
ctx.ping_until_healthy(&project_dir, "random-network");

clients::icp(&ctx, &project_dir, Some("random-environment".to_string()))
.mint_cycles(10 * TRILLION);

// Deploy and check that the friendly URL is printed (not the Candid UI form)
ctx.icp()
.current_dir(&project_dir)
.args([
"deploy",
"--subnet",
common::SUBNET_ID,
"--environment",
"random-environment",
])
.assert()
.success()
.stdout(contains("Deployed canisters:"))
.stdout(contains(
"my-canister: http://my-canister.random-environment.localhost:",
));
}

#[cfg(unix)] // moc
#[tokio::test]
async fn deploy_upgrade_rejects_incompatible_candid() {
Expand Down
22 changes: 21 additions & 1 deletion crates/icp-cli/tests/sync_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -245,7 +245,7 @@ async fn sync_adapter_static_assets() {
.assert()
.success();

// Verify that assets canister was synced
// Verify that assets canister was synced via canisterId query param
let resp = reqwest::get(format!("http://localhost:{network_port}/?canisterId={cid}"))
.await
.expect("request failed");
Expand All @@ -256,6 +256,26 @@ async fn sync_adapter_static_assets() {
.expect("failed to read canister response body");

assert_eq!(out, "hello");

// Verify that the friendly domain also works
let friendly_domain = "my-canister.random-environment.localhost";
let client = reqwest::Client::builder()
.resolve(
friendly_domain,
std::net::SocketAddr::from(([127, 0, 0, 1], network_port)),
)
.build()
.expect("failed to build reqwest client");
let resp = client
.get(format!("http://{friendly_domain}:{network_port}/"))
.send()
.await
.expect("friendly domain request failed");
let out = resp
.text()
.await
.expect("failed to read friendly domain response body");
assert_eq!(out, "hello");
}

#[tokio::test]
Expand Down
59 changes: 59 additions & 0 deletions crates/icp/src/context/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -500,6 +500,65 @@ impl Context {
})
}

/// 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;
};
let mut env_mappings = 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 { .. }
);
if let Ok(mapping) = self.ids.lookup_by_environment(is_cache, env_name)
&& !mapping.is_empty()
{
env_mappings.insert(env_name.clone(), mapping);
}
}
if let Err(e) =
crate::network::custom_domains::write_custom_domains(status_dir, domain, &env_mappings)
{
tracing::warn!("Failed to update custom domains: {e}");
}
}

#[cfg(test)]
/// Creates a test context with all mocks
pub fn mocked() -> Context {
Expand Down
4 changes: 4 additions & 0 deletions crates/icp/src/network/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,10 @@ pub struct NetworkDescriptorModel {
pub candid_ui_canister_id: Option<Principal>,
/// Canister ID of the deployed proxy canister, if any.
pub proxy_canister_id: Option<Principal>,
/// Path to the status directory shared with the network launcher.
/// Used to write `custom-domains.txt` for friendly domain routing.
#[serde(default)]
pub status_dir: Option<PathBuf>,
}

/// Identifies the process or container running a managed network.
Expand Down
Loading