Skip to content
Closed
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,697 changes: 1,615 additions & 82 deletions Cargo.lock

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ exclude = [
]

[workspace]
members = [".", "crates/imcp2-core", "crates/imcp2-local"]
members = [".", "crates/imcp2-core", "crates/imcp2-deploy", "crates/imcp2-local"]

# The hosted server releases to crates.io (publish-crate.yml), never as dist
# binaries — dist releases only imcp2-local (see dist-workspace.toml).
Expand Down
4 changes: 3 additions & 1 deletion crates/imcp2-core/src/identities.rs
Original file line number Diff line number Diff line change
Expand Up @@ -472,7 +472,9 @@ impl Identities {
/// transport, route provider, and root key, so calls signed as `identity`
/// go through the embedding application's boundary-node routing — never a
/// second, hard-coded endpoint.
pub(crate) fn agent_as<I: ic_agent::Identity + 'static>(&self, identity: I) -> Agent {
/// A clone of the injected base agent signing as `identity`: the same
/// endpoint, root key and boundary-node routing as every other call.
pub fn agent_as<I: ic_agent::Identity + 'static>(&self, identity: I) -> Agent {
let mut agent = self.agent.clone();
agent.set_identity(identity);
agent
Expand Down
4 changes: 4 additions & 0 deletions crates/imcp2-core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,10 @@ mod e2e_canister_tools;
/// crate links.
pub use ic_agent::{self, Agent};
pub use identities::{IiInstance, SessionGauges};
/// The cycles-ledger balance of a session's management principal: the
/// `icp_cycles_balance` tool's logic, for an embedder that serves that tool
/// on its own surface (the local server's deploy tools do).
pub use management::{cycles_balance, cycles_balance_of, CyclesBalance};
pub use tools::{IcCanisterTools, IcProtocolTools, IcTools, SessionResolver};

/// A sensible default IC API boundary node (the public mainnet endpoint) for
Expand Down
11 changes: 11 additions & 0 deletions crates/imcp2-core/src/management.rs
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,17 @@ fn default_init_arg() -> String {
/// here can operate a canister the user created.
pub async fn cycles_balance(ids: &Identities, session_id: &str) -> Result<CyclesBalance, String> {
let (agent, principal) = management_agent(ids, session_id).await?;
cycles_balance_of(&agent, principal).await
}

/// The cycles-ledger balance of `principal`, read through `agent`. The query
/// is anonymous-readable, so the agent's identity only matters for routing;
/// callers acting as a principal other than the management identity (the
/// deploy tools) pair this with their own agent.
pub async fn cycles_balance_of(
agent: &Agent,
principal: Principal,
) -> Result<CyclesBalance, String> {
let ledger = parse_principal(CYCLES_LEDGER)?;
let account = Account { owner: principal, subaccount: None };
let arg = Encode!(&account).map_err(|e| format!("encode account: {e}"))?;
Expand Down
62 changes: 62 additions & 0 deletions crates/imcp2-deploy/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
[package]
name = "imcp2-deploy"
version = "0.6.0"
edition = "2021"
description = "The imcp2 `icp_deploy_bundle` tool: deploys an icp project bundle through the icp CLI's own project library."
license = "Apache-2.0"
repository = "https://github.com/dfinity/imcp2"
# NOT publishable, by construction: the deploy runs on `icp-project` (and its
# siblings) pulled straight from the icp-cli git repository — those crates are
# `publish = false` upstream, and crates.io refuses any crate with a git
# dependency. That is why this is its own crate rather than a module of
# `imcp2-core`, and why only `imcp2-local` (released as dist binaries, never
# published) composes it: `imcp2-core` and `imcp2` keep publishing untouched.
publish = false

[dependencies]
# The session engine (which Internet Identity session a call acts as, and
# the management identity minted from it) and the `SessionResolver` seam.
imcp2-core = { workspace = true }
rmcp = { workspace = true, features = ["server", "macros"] }
ic-agent = { workspace = true }
candid = { workspace = true }
# `rt-multi-thread`: the sync-plugin runtime runs a wasm component on a
# blocking thread (`block_in_place`), which only a multi-thread runtime has.
tokio = { workspace = true, features = ["macros", "rt-multi-thread", "sync", "fs"] }
serde = { workspace = true }
serde_json = { workspace = true }
tracing = { workspace = true }
base64 = { workspace = true }
sha2 = { workspace = true }
hex = { workspace = true }
url = { workspace = true }
async-trait = "0.1"
# `icp-project` spells every path as a UTF-8 path.
camino = "1"
# A bundle is the `.tar.gz` that `icp project bundle` writes.
tar = "0.4"
flate2 = "1"
# Each deploy extracts its bundle into a scratch directory that is removed
# when the deploy ends, whichever way it ends.
tempfile = "3"

# The icp CLI's project library and the seams it is driven through, pinned to
# one commit so the four stay in lockstep (they share a workspace version and
# are not released independently). Bump the `rev` on all four together.
#
# * icp-project: manifests, the deploy operation, the id/artifact stores.
# * icp-events: the progress event stream the operations report on.
# * icp-sync-plugin: the wasmtime runtime that runs a canister's sync plugin
# (the asset uploads of a frontend canister). The heavy one — it is what
# brings wasmtime into this workspace.
# * icp-canister-interfaces: the engine-canister registry id a created
# canister's subnet is looked up in.
icp-project = { git = "https://github.com/dfinity/icp-cli", rev = "f4c7b6627cab56b091c01cc18102f5dd88aa811f" }
icp-events = { git = "https://github.com/dfinity/icp-cli", rev = "f4c7b6627cab56b091c01cc18102f5dd88aa811f" }
icp-sync-plugin = { git = "https://github.com/dfinity/icp-cli", rev = "f4c7b6627cab56b091c01cc18102f5dd88aa811f" }
icp-canister-interfaces = { git = "https://github.com/dfinity/icp-cli", rev = "f4c7b6627cab56b091c01cc18102f5dd88aa811f" }

[dev-dependencies]
# The tests hand-build small bundles (a manifest plus a wasm) and load them
# through the same host the tool uses.
wat = "1"
224 changes: 224 additions & 0 deletions crates/imcp2-deploy/src/calls.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,224 @@
//! The project library's one seam to the network, over this connection's
//! `ic-agent`: [`CanisterCalls`] signed as the user's management identity.
//!
//! Modelled on the icp CLI's own implementation (`icp_app::calls::AgentCalls`),
//! minus proxy-canister routing: this server never names a proxy, so every call
//! is made directly by the identity the agent carries. The certified reads are
//! what the deploy operation decides on — whether a canister exists, what is
//! installed on it, which subnet it sits on — so they are answered from
//! certificates rather than from anything a canister says about itself.

use async_trait::async_trait;
use candid::Principal;
use ic_agent::{
agent::{CallResponse, EffectiveId, SubnetType},
hash_tree::{Label, LookupResult},
Agent, AgentError,
};
use icp_project::calls::{Authority, Call, CallError, CanisterCalls, RouteTo};

/// [`CanisterCalls`] over an `ic-agent`.
pub struct AgentCalls {
agent: Agent,
/// The agent's principal, read once here. [`CanisterCalls::caller`] is
/// infallible while [`Agent::get_principal`] is not (an identity may
/// decline to name its sender), so the one place the failure can be
/// reported is construction; the agent's identity cannot change after it.
caller: Principal,
}

impl AgentCalls {
pub fn new(agent: Agent) -> Result<Self, String> {
let caller = agent.get_principal()?;
Comment thread
raymondk marked this conversation as resolved.
Resolved
Ok(Self { agent, caller })
}

/// Turn an agent error into the shape the trait speaks in: a rejection is
/// a verdict and keeps its code, a call that ran out of time or never got
/// down the wire went unanswered, and anything else — a reply that would
/// not parse, a certificate that would not verify — failed for good.
fn wrap(canister: Principal, method: &str, err: AgentError) -> CallError {
match &err {
AgentError::CertifiedReject { reject, .. }
| AgentError::UncertifiedReject { reject, .. } => CallError::Rejected {
canister,
method: method.to_owned(),
code: reject.error_code.clone(),
message: reject.reject_message.clone(),
},
AgentError::TimeoutWaitingForResponse() | AgentError::TransportError(_) => {
CallError::unanswered(canister, method, err)
}
_ => CallError::failed(canister, method, err),
}
}

/// Whether `canister` exists, which its controllers answer — see
/// [`CanisterCalls::controllers`]. A check that could not be made reads as
/// "does not exist": every caller asks this while deciding whether to
/// report a read failure of its own, and an existence check that failed
/// too is no reason to suppress it.
async fn exists(&self, canister: Principal) -> bool {
self.controllers(canister).await.is_ok_and(|controllers| controllers.is_some())
}

/// Ask the target's subnet to certify a metadata section, reporting only
/// what the certificate proves.
///
/// The section path is requested together with `controllers`, because a
/// metadata path proven absent is equally what a canister that was never
/// created looks like — `controllers` is written at creation, so its
/// presence is what separates the two. A canister with no module installed
/// has no sections at all, which the certificate reports as an absent path
/// under a canister that exists, and so as `Ok(None)`.
async fn certified_metadata_section(
&self,
canister: Principal,
path: &str,
) -> Result<Option<Vec<u8>>, CallError> {
let metadata_path: Vec<Label<Vec<u8>>> = vec![
"canister".into(),
Label::from_bytes(canister.as_slice()),
"metadata".into(),
path.into(),
];
let controllers_path: Vec<Label<Vec<u8>>> =
vec!["canister".into(), Label::from_bytes(canister.as_slice()), "controllers".into()];
let method = "read_state(metadata)";
let cert = self
.agent
.read_state_raw(vec![metadata_path.clone(), controllers_path.clone()], canister)
.await
.map_err(|err| Self::wrap(canister, method, err))?;

let unproven = |about: String| {
Err(CallError::Rejected {
canister,
method: method.to_owned(),
code: None,
message: format!("the certificate proves nothing about {about}"),
})
};
match cert.tree.lookup_path(&metadata_path) {
LookupResult::Found(bytes) => Ok(Some(bytes.to_vec())),
LookupResult::Absent => match cert.tree.lookup_path(&controllers_path) {
LookupResult::Found(_) => Ok(None),
LookupResult::Absent => Err(CallError::Rejected {
canister,
method: method.to_owned(),
code: None,
message: format!("canister {canister} does not exist"),
}),
_ => unproven(format!("canister {canister}")),
},
// Not proof of absence, just a certificate that says nothing about
// the path — reporting the section missing off this would be a
// guess, and a private section is exactly what it looks like.
_ => unproven(format!("section `{path}` of canister {canister}")),
}
}

/// A subnet-scoped update: routed to a subnet rather than to any canister
/// on it, which the agent only exposes through a signed submission.
async fn to_subnet(&self, subnet: Principal, call: &Call) -> Result<Vec<u8>, CallError> {
let effective = EffectiveId::Subnet(subnet);
let fail = |e: AgentError| Self::wrap(call.canister, &call.method, e);

let signed = self
.agent
.update(&call.canister, &call.method)
.with_arg(call.arg.clone())
.sign()
.map_err(fail)?;
let response =
self.agent.update_signed(effective, signed.signed_update).await.map_err(fail)?;
match response {
CallResponse::Response(bytes) => Ok(bytes),
CallResponse::Poll(request_id) => {
let status = self.agent.sign_request_status(effective, request_id).map_err(fail)?;
Ok(self
.agent
.wait_signed(&request_id, effective, status.signed_request_status)
.await
.map_err(fail)?
.0)
}
}
}
}

#[async_trait]
impl CanisterCalls for AgentCalls {
fn caller(&self) -> Principal {
self.caller
}

async fn update(&self, call: Call) -> Result<Vec<u8>, CallError> {
if let RouteTo::Subnet(subnet) = call.route {
return self.to_subnet(subnet, &call).await;
}
let mut builder =
self.agent.update(&call.canister, &call.method).with_arg(call.arg.clone());
if let RouteTo::Canister(effective) = call.route {
builder = builder.with_effective_canister_id(effective);
}
builder.await.map_err(|e| Self::wrap(call.canister, &call.method, e))
}

async fn query(&self, call: Call) -> Result<Vec<u8>, CallError> {
let mut builder = self.agent.query(&call.canister, &call.method).with_arg(call.arg.clone());
if let RouteTo::Canister(effective) = call.route {
builder = builder.with_effective_canister_id(effective);
}
builder.call().await.map_err(|e| Self::wrap(call.canister, &call.method, e))
}

async fn metadata_section(
&self,
canister: Principal,
path: &str,
// With no proxy there is one authority, and both values mean it.
_authority: Authority,
) -> Result<Option<Vec<u8>>, CallError> {
self.certified_metadata_section(canister, path).await
}

async fn controllers(&self, canister: Principal) -> Result<Option<Vec<Principal>>, CallError> {
match self.agent.read_state_canister_controllers(canister).await {
Ok(controllers) => Ok(Some(controllers)),
Err(AgentError::LookupPathAbsent(_)) => Ok(None),
Err(err) => Err(Self::wrap(canister, "read_state(controllers)", err)),
}
}

async fn module_hash(&self, canister: Principal) -> Result<Option<Vec<u8>>, CallError> {
let err = match self.agent.read_state_canister_module_hash(canister).await {
Ok(hash) => return Ok(Some(hash)),
Err(err) => err,
};
// No module-hash path means nothing is installed — or that there is no
// canister to install into, which is not an answer this can give.
if matches!(err, AgentError::LookupPathAbsent(_)) && self.exists(canister).await {
return Ok(None);
}
Err(Self::wrap(canister, "read_state(module_hash)", err))
}

async fn subnet_of(&self, canister: Principal) -> Result<Principal, CallError> {
Ok(self
.agent
.get_subnet_by_canister(&canister)
.await
.map_err(|e| Self::wrap(canister, "subnet_of", e))?
.id())
}

async fn subnet_uses_engine_operator(&self, subnet: Principal) -> Result<bool, CallError> {
let info = self
.agent
.get_subnet_by_id(&subnet)
.await
.map_err(|e| Self::wrap(subnet, "subnet_type", e))?;
Ok(matches!(info.subnet_type(), Some(SubnetType::CloudEngine)))
}
}
Loading
Loading