Skip to content

Commit 4d690de

Browse files
feat: Implement friendly domains (#400)
1 parent 7f92e2c commit 4d690de

14 files changed

Lines changed: 388 additions & 56 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
# Unreleased
22

3+
* feat: Added 'friendly name' domains for canisters - instead of `<frontend principal>.localhost` you can access `frontend.local.localhost`.
4+
35
# v0.2.0-beta.0
46

57
* 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)
Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1,5 @@
1-
pub const MAINNET_CANDID_UI_CID: &str = "a4gq6-oaaaa-aaaab-qaa4q-cai";
1+
use candid::Principal;
2+
3+
// a4gq6-oaaaa-aaaab-qaa4q-cai
4+
pub const MAINNET_CANDID_UI_CID: Principal =
5+
Principal::from_slice(&[0x00, 0x00, 0x00, 0x00, 0x00, 0x30, 0x00, 0x39, 0x01, 0x01]);

crates/icp-cli/src/commands/canister/create.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -248,6 +248,7 @@ async fn create_project_canister(ctx: &Context, args: &CreateArgs) -> Result<(),
248248

249249
ctx.set_canister_id_for_env(&canister, id, &selections.environment)
250250
.await?;
251+
ctx.update_custom_domains(&selections.environment).await;
251252

252253
if args.quiet {
253254
let _ = ctx.term.write_line(&format!("{id}"));

crates/icp-cli/src/commands/canister/delete.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ pub(crate) async fn exec(ctx: &Context, args: &DeleteArgs) -> Result<(), anyhow:
3838
if let CanisterSelection::Named(canister_name) = &selections.canister {
3939
ctx.remove_canister_id_for_env(canister_name, &selections.environment)
4040
.await?;
41+
ctx.update_custom_domains(&selections.environment).await;
4142
}
4243

4344
Ok(())

crates/icp-cli/src/commands/deploy/mod.rs

Lines changed: 29 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ use candid::{CandidType, Principal};
33
use clap::Args;
44
use futures::{StreamExt, future::try_join_all, stream::FuturesOrdered};
55
use ic_agent::Agent;
6+
use icp::network::{Managed, ManagedMode};
67
use icp::parsers::CyclesAmount;
78
use icp::{
89
context::{CanisterSelection, Context, EnvironmentSelection},
@@ -176,6 +177,8 @@ pub(crate) async fn exec(ctx: &Context, args: &DeployArgs) -> Result<(), anyhow:
176177
}
177178
}
178179

180+
ctx.update_custom_domains(&environment_selection).await;
181+
179182
let _ = ctx.term.write_line("\n\nSetting environment variables:");
180183
let env = ctx
181184
.get_environment(&environment_selection)
@@ -377,21 +380,29 @@ async fn print_canister_urls(
377380
agent: Agent,
378381
canister_names: &[String],
379382
) -> Result<(), anyhow::Error> {
383+
use icp::network::custom_domains::{canister_gateway_url, gateway_domain};
384+
380385
let env = ctx.get_environment(environment_selection).await?;
381386

382387
// Get the network URL
383388
let http_gateway_url = match &env.network.configuration {
384389
NetworkConfiguration::Managed { managed: _ } => {
385-
// For managed networks, construct localhost URL
386390
let access = ctx.network.access(&env.network).await?;
387391
access.http_gateway_url.clone()
388392
}
389-
NetworkConfiguration::Connected { connected } => {
390-
// For connected networks, use the configured URL
391-
connected.http_gateway_url.clone()
392-
}
393+
NetworkConfiguration::Connected { connected } => connected.http_gateway_url.clone(),
393394
};
394395

396+
// Friendly domains are available for managed networks where we write custom-domains.txt
397+
let has_friendly = matches!(
398+
&env.network.configuration,
399+
NetworkConfiguration::Managed {
400+
managed: Managed {
401+
mode: ManagedMode::Launcher(config)
402+
}
403+
} if config.version.is_none()
404+
);
405+
395406
let _ = ctx.term.write_line("\n\nDeployed canisters:");
396407

397408
for name in canister_names {
@@ -407,58 +418,40 @@ async fn print_canister_urls(
407418
};
408419

409420
if let Some(http_gateway_url) = &http_gateway_url {
410-
// Check if canister has http_request
411421
let has_http = has_http_request(&agent, canister_id).await;
412-
let domain = if let Some(domain) = http_gateway_url.domain() {
413-
Some(domain)
414-
} else if let Some(host) = http_gateway_url.host_str()
415-
&& (host == "127.0.0.1" || host == "[::1]")
416-
{
417-
Some("localhost")
422+
let friendly = if has_friendly {
423+
Some((name.as_str(), environment_selection.name()))
418424
} else {
419425
None
420426
};
421427

422428
if has_http {
423-
let mut canister_url = http_gateway_url.clone();
424-
if let Some(domain) = domain {
425-
canister_url
426-
.set_host(Some(&format!("{canister_id}.{domain}")))
427-
.unwrap();
428-
} else {
429-
canister_url.set_query(Some(&format!("canisterId={canister_id}")));
430-
}
429+
let canister_url = canister_gateway_url(http_gateway_url, canister_id, friendly);
431430
let _ = ctx
432431
.term
433432
.write_line(&format!(" {}: {}", name, canister_url));
434433
} else {
435434
// For canisters without http_request, show the Candid UI URL
436-
if let Some(ref ui_id) = get_candid_ui_id(ctx, environment_selection).await {
437-
let mut candid_url = http_gateway_url.clone();
438-
if let Some(domain) = domain {
439-
candid_url
440-
.set_host(Some(&format!("{ui_id}.{domain}",)))
441-
.unwrap();
435+
if let Some(ui_id) = get_candid_ui_id(ctx, environment_selection).await {
436+
let domain = gateway_domain(http_gateway_url);
437+
let mut candid_url = canister_gateway_url(http_gateway_url, ui_id, None);
438+
if domain.is_some() {
442439
candid_url.set_query(Some(&format!("id={canister_id}")));
443440
} else {
444441
candid_url.set_query(Some(&format!("canisterId={ui_id}&id={canister_id}")));
445442
}
446443
let _ = ctx
447444
.term
448-
.write_line(&format!(" {} (Candid UI): {}", name, candid_url));
445+
.write_line(&format!(" {name} (Candid UI): {candid_url}"));
449446
} else {
450-
// No Candid UI available - just show the canister ID
451447
let _ = ctx.term.write_line(&format!(
452-
" {}: {} (Candid UI not available)",
453-
name, canister_id
448+
" {name}: {canister_id} (Candid UI not available)",
454449
));
455450
}
456451
}
457452
} else {
458-
// No gateway subdomains available - just show the canister ID
459453
let _ = ctx.term.write_line(&format!(
460-
" {}: {} (No gateway URL available)",
461-
name, canister_id
454+
" {name}: {canister_id} (No gateway URL available)",
462455
));
463456
}
464457
}
@@ -471,7 +464,7 @@ async fn print_canister_urls(
471464
async fn get_candid_ui_id(
472465
ctx: &Context,
473466
environment_selection: &EnvironmentSelection,
474-
) -> Option<String> {
467+
) -> Option<Principal> {
475468
let env = ctx.get_environment(environment_selection).await.ok()?;
476469

477470
match &env.network.configuration {
@@ -481,14 +474,14 @@ async fn get_candid_ui_id(
481474
if let Ok(Some(desc)) = nd.load_network_descriptor().await
482475
&& let Some(candid_ui) = desc.candid_ui_canister_id
483476
{
484-
return Some(candid_ui.to_string());
477+
return Some(candid_ui);
485478
}
486479
// No Candid UI available for this managed network
487480
None
488481
}
489482
NetworkConfiguration::Connected { .. } => {
490483
// For connected networks, use the mainnet Candid UI
491-
Some(MAINNET_CANDID_UI_CID.to_string())
484+
Some(MAINNET_CANDID_UI_CID)
492485
}
493486
}
494487
}

crates/icp-cli/tests/deploy_tests.rs

Lines changed: 61 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,10 @@ use predicates::{
55
};
66

77
use crate::common::{ENVIRONMENT_RANDOM_PORT, NETWORK_RANDOM_PORT, TestContext, clients};
8-
use icp::{fs::write_string, prelude::*};
8+
use icp::{
9+
fs::{create_dir_all, write_string},
10+
prelude::*,
11+
};
912

1013
mod common;
1114

@@ -403,6 +406,63 @@ async fn deploy_prints_canister_urls() {
403406
.stdout(contains("?id="));
404407
}
405408

409+
#[tokio::test]
410+
async fn deploy_prints_friendly_url_for_asset_canister() {
411+
let ctx = TestContext::new();
412+
413+
// Setup project
414+
let project_dir = ctx.create_project_dir("icp");
415+
let assets_dir = project_dir.join("www");
416+
create_dir_all(&assets_dir).expect("failed to create assets directory");
417+
write_string(&assets_dir.join("index.html"), "hello").expect("failed to create index page");
418+
419+
// Project manifest with a pre-built asset canister
420+
let pm = formatdoc! {r#"
421+
canisters:
422+
- name: my-canister
423+
build:
424+
steps:
425+
- type: pre-built
426+
url: https://github.com/dfinity/sdk/raw/refs/tags/0.27.0/src/distributed/assetstorage.wasm.gz
427+
sha256: 865eb25df5a6d857147e078bb33c727797957247f7af2635846d65c5397b36a6
428+
429+
sync:
430+
steps:
431+
- type: assets
432+
dirs:
433+
- {assets_dir}
434+
435+
{NETWORK_RANDOM_PORT}
436+
{ENVIRONMENT_RANDOM_PORT}
437+
"#};
438+
439+
write_string(&project_dir.join("icp.yaml"), &pm).expect("failed to write project manifest");
440+
441+
// Start network
442+
let _g = ctx.start_network_in(&project_dir, "random-network").await;
443+
ctx.ping_until_healthy(&project_dir, "random-network");
444+
445+
clients::icp(&ctx, &project_dir, Some("random-environment".to_string()))
446+
.mint_cycles(10 * TRILLION);
447+
448+
// Deploy and check that the friendly URL is printed (not the Candid UI form)
449+
ctx.icp()
450+
.current_dir(&project_dir)
451+
.args([
452+
"deploy",
453+
"--subnet",
454+
common::SUBNET_ID,
455+
"--environment",
456+
"random-environment",
457+
])
458+
.assert()
459+
.success()
460+
.stdout(contains("Deployed canisters:"))
461+
.stdout(contains(
462+
"my-canister: http://my-canister.random-environment.localhost:",
463+
));
464+
}
465+
406466
#[cfg(unix)] // moc
407467
#[tokio::test]
408468
async fn deploy_upgrade_rejects_incompatible_candid() {

crates/icp-cli/tests/sync_tests.rs

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -245,7 +245,7 @@ async fn sync_adapter_static_assets() {
245245
.assert()
246246
.success();
247247

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

258258
assert_eq!(out, "hello");
259+
260+
// Verify that the friendly domain also works
261+
let friendly_domain = "my-canister.random-environment.localhost";
262+
let client = reqwest::Client::builder()
263+
.resolve(
264+
friendly_domain,
265+
std::net::SocketAddr::from(([127, 0, 0, 1], network_port)),
266+
)
267+
.build()
268+
.expect("failed to build reqwest client");
269+
let resp = client
270+
.get(format!("http://{friendly_domain}:{network_port}/"))
271+
.send()
272+
.await
273+
.expect("friendly domain request failed");
274+
let out = resp
275+
.text()
276+
.await
277+
.expect("failed to read friendly domain response body");
278+
assert_eq!(out, "hello");
259279
}
260280

261281
#[tokio::test]

crates/icp/src/context/mod.rs

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -500,6 +500,65 @@ impl Context {
500500
})
501501
}
502502

503+
/// Updates the `custom-domains.txt` file for the managed network used by the
504+
/// given environment. Collects ID mappings from all environments that share
505+
/// the same managed network, then writes the file to the network's status
506+
/// directory.
507+
///
508+
/// This is a best-effort operation: errors are logged but not propagated,
509+
/// because a failure to update friendly domains should not block canister
510+
/// creation or deletion.
511+
pub async fn update_custom_domains(&self, environment: &EnvironmentSelection) {
512+
let Ok(env) = self.get_environment(environment).await else {
513+
return;
514+
};
515+
let NetworkConfiguration::Managed { .. } = &env.network.configuration else {
516+
return;
517+
};
518+
let Ok(nd) = self.network.get_network_directory(&env.network) else {
519+
return;
520+
};
521+
let Ok(Some(desc)) = nd.load_network_descriptor().await else {
522+
return;
523+
};
524+
let Some(status_dir) = &desc.status_dir else {
525+
return;
526+
};
527+
let gateway_url_str = format!("http://{}:{}", desc.gateway.host, desc.gateway.port);
528+
let Ok(gateway_url) = Url::parse(&gateway_url_str) else {
529+
tracing::warn!("Failed to parse gateway URL {gateway_url_str:?} for custom domains");
530+
return;
531+
};
532+
let domain = crate::network::custom_domains::gateway_domain(&gateway_url);
533+
let Some(domain) = domain else {
534+
return;
535+
};
536+
// Collect mappings from all environments that use this network
537+
let Ok(project) = self.project.load().await else {
538+
return;
539+
};
540+
let mut env_mappings = std::collections::BTreeMap::new();
541+
for (env_name, env) in &project.environments {
542+
if env.network.name != desc.network {
543+
continue;
544+
}
545+
let is_cache = matches!(
546+
env.network.configuration,
547+
NetworkConfiguration::Managed { .. }
548+
);
549+
if let Ok(mapping) = self.ids.lookup_by_environment(is_cache, env_name)
550+
&& !mapping.is_empty()
551+
{
552+
env_mappings.insert(env_name.clone(), mapping);
553+
}
554+
}
555+
if let Err(e) =
556+
crate::network::custom_domains::write_custom_domains(status_dir, domain, &env_mappings)
557+
{
558+
tracing::warn!("Failed to update custom domains: {e}");
559+
}
560+
}
561+
503562
#[cfg(test)]
504563
/// Creates a test context with all mocks
505564
pub fn mocked() -> Context {

crates/icp/src/network/config.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,10 @@ pub struct NetworkDescriptorModel {
7575
pub candid_ui_canister_id: Option<Principal>,
7676
/// Canister ID of the deployed proxy canister, if any.
7777
pub proxy_canister_id: Option<Principal>,
78+
/// Path to the status directory shared with the network launcher.
79+
/// Used to write `custom-domains.txt` for friendly domain routing.
80+
#[serde(default)]
81+
pub status_dir: Option<PathBuf>,
7882
}
7983

8084
/// Identifies the process or container running a managed network.

0 commit comments

Comments
 (0)