Repository navigation
imcp2-deploy 3/3: serve icp_deploy_bundle and icp_cycles_balance from the local MCP server #232
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
30a5a39
51d7076
5c60be8
09ed508
08516e7
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -28,6 +28,7 @@ use icp_project::{ | |
| deploy::{deploy, resolve_targets, DeployParams, DeployReport}, | ||
| task::Task, | ||
| }, | ||
| prelude::{IC_MAINNET_NETWORK_API_URL, IC_ROOT_KEY}, | ||
| }; | ||
|
|
||
| use crate::{calls::AgentCalls, host}; | ||
|
|
@@ -75,6 +76,13 @@ pub struct DeployRequest { | |
| pub no_create: bool, | ||
| /// Skip the Candid interface compatibility check on upgrades. | ||
| pub skip_candid_check: bool, | ||
| /// Where the deploy's working directory is made (the system temporary | ||
| /// directory when `None`). A run that returns removes it; a run that is | ||
| /// cut off — the server stopped mid-deploy, the call cancelled — leaves | ||
| /// it, and with it `.icp/data/mappings/<environment>.ids.json`, which | ||
| /// records every canister the deploy created, so a caller who never got | ||
| /// the answer can recover the ids instead of creating a second set. | ||
| pub workdir: Option<Utf8PathBuf>, | ||
| } | ||
|
|
||
| /// How a deploy ended. | ||
|
|
@@ -171,12 +179,21 @@ pub async fn deploy_bundle( | |
| BundleSource::Path(path) => read_bundle_file(&path, host::MAX_BUNDLE_BYTES).await?, | ||
| }; | ||
|
|
||
| // The scratch directory lives exactly as long as this function: the | ||
| // bundle's files, the ids the deploy records, and the build artifacts all | ||
| // go with it, whichever way the deploy ends. | ||
| let scratch = tempfile::Builder::new() | ||
| .prefix("imcp2-deploy-") | ||
| .tempdir() | ||
| // The working directory holds the bundle's files, the ids the deploy | ||
| // records, and the build artifacts. Until the deploy starts it is a | ||
| // temporary directory, gone with any early return; from then on it is | ||
| // kept on purpose and removed only once the outcome — the ids above all — | ||
| // has been assembled, so a run that is cut off leaves its record behind. | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Same question as above, how do we make sure runs are isolated between users?
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Same answer as above: this is where the per-run directory is made, fresh and randomly named for every call, and a second deploy while one runs is refused before anything else ( |
||
| let parent = match &request.workdir { | ||
| Some(dir) => { | ||
| std::fs::create_dir_all(dir).map_err(|e| { | ||
| RequestError(format!("cannot create the deploy directory '{dir}': {e}")) | ||
| })?; | ||
| dir.as_std_path().to_path_buf() | ||
| } | ||
| None => std::env::temp_dir(), | ||
| }; | ||
| let scratch = deploy_directory(&parent) | ||
| .map_err(|e| RequestError(format!("cannot create a scratch directory: {e}")))?; | ||
| let dir = Utf8PathBuf::from_path_buf(scratch.path().to_path_buf()).map_err(|p| { | ||
| RequestError(format!("scratch directory path is not UTF-8: {}", p.display())) | ||
|
|
@@ -270,26 +287,13 @@ pub async fn deploy_bundle( | |
| }; | ||
|
|
||
| // The agent is made by the first phase that reaches the network, as the | ||
| // CLI does: a deploy that fails to load its wasms never resolves a root | ||
| // key. The identity is already unlocked — it is the session's — so what | ||
| // is deferred here is only the network resolution. | ||
| let network = env.network.clone(); | ||
| let host_for_agent = host.clone(); | ||
| // CLI does: a deploy that fails to load its wasms never builds one. The | ||
| // network is the Internet Computer — `check_bundle` held the environment | ||
| // to it — so the agent is the mainnet one, as the balance check's is. | ||
| let calls = Deferred::new(move || { | ||
| let host = host_for_agent.clone(); | ||
| let network = network.clone(); | ||
| let identity = identity.clone(); | ||
| async move { | ||
| let access = | ||
| host.network.access(&network).await.map_err(|e| RequestError(error_chain(&e)))?; | ||
| let agent = Agent::builder() | ||
| .with_url(access.api_url.as_str()) | ||
| .with_arc_identity(identity) | ||
| .build() | ||
| .map_err(|e| { | ||
| RequestError(format!("cannot build an agent for {}: {e}", access.api_url)) | ||
| })?; | ||
| agent.set_root_key(access.root_key); | ||
| let agent = mainnet_agent(identity).map_err(RequestError)?; | ||
| let calls: Arc<dyn CanisterCalls> = | ||
| Arc::new(AgentCalls::new(agent).map_err(RequestError)?); | ||
| Ok::<_, RequestError>(calls) | ||
|
|
@@ -301,6 +305,8 @@ pub async fn deploy_bundle( | |
| let (reporter, receiver) = icp_events::channel::<Task>(); | ||
| let collector = tokio::spawn(collect_log(receiver)); | ||
|
|
||
| // From here the directory outlives this future (see above). | ||
| let workdir = scratch.keep(); | ||
|
raymondk marked this conversation as resolved.
Resolved
|
||
| let mut report = DeployReport::default(); | ||
| let result = deploy(&host, &calls, ¶ms, &reporter, &mut report).await; | ||
| // The receiver ends only once every reporter handle is gone. | ||
|
|
@@ -332,10 +338,30 @@ pub async fn deploy_bundle( | |
| outcome.canister_ids.insert(name.clone(), *id); | ||
| } | ||
|
|
||
| drop(scratch); | ||
| // The outcome carries everything the directory recorded; the directory | ||
| // has done its job. | ||
| drop(host); | ||
| if let Err(e) = tokio::fs::remove_dir_all(&workdir).await { | ||
|
raymondk marked this conversation as resolved.
Resolved
|
||
| outcome | ||
| .log | ||
| .push(format!("removing the deploy directory '{}' failed: {e}", workdir.display())); | ||
| } | ||
| Ok(outcome) | ||
| } | ||
|
|
||
| /// An agent for the Internet Computer signing as `identity`: the mainnet | ||
| /// endpoint and the pinned root key, the one network a deploy reaches. The | ||
| /// balance check uses the same, so what it reports is what a deploy spends. | ||
| pub fn mainnet_agent(identity: Arc<dyn Identity>) -> Result<Agent, String> { | ||
| let agent = Agent::builder() | ||
| .with_url(IC_MAINNET_NETWORK_API_URL) | ||
| .with_arc_identity(identity) | ||
| .build() | ||
| .map_err(|e| format!("cannot build an agent for the Internet Computer: {e}"))?; | ||
| agent.set_root_key(IC_ROOT_KEY.to_vec()); | ||
| Ok(agent) | ||
| } | ||
|
|
||
| /// Read the bundle at `path`, which must be an absolute path to a regular | ||
| /// file of at most `limit` bytes, and nothing else: a device or a pipe reports | ||
| /// a length that says nothing about what a read of it produces (`/dev/zero` | ||
|
|
@@ -444,6 +470,21 @@ fn describe(task: &Task) -> String { | |
| } | ||
| } | ||
|
|
||
| /// A fresh directory for one deploy under `parent`, randomly named, and on | ||
| /// Unix readable by its owner alone: the bundle's files and the ids the | ||
| /// deploy records are the user's business, and the parent (the user's local | ||
| /// data directory, or the system temporary directory) may be more open. | ||
| fn deploy_directory(parent: &std::path::Path) -> std::io::Result<tempfile::TempDir> { | ||
| let mut builder = tempfile::Builder::new(); | ||
| builder.prefix("imcp2-deploy-"); | ||
| #[cfg(unix)] | ||
| { | ||
| use std::os::unix::fs::PermissionsExt; | ||
| builder.permissions(std::fs::Permissions::from_mode(0o700)); | ||
| } | ||
| builder.tempdir_in(parent) | ||
| } | ||
|
|
||
| #[cfg(test)] | ||
| mod tests { | ||
| use super::*; | ||
|
|
@@ -493,6 +534,7 @@ mod tests { | |
| // verified the bundled wasm. | ||
| no_create: true, | ||
| skip_candid_check: false, | ||
| workdir: None, | ||
| } | ||
| } | ||
|
|
||
|
|
@@ -508,6 +550,24 @@ mod tests { | |
| // phase of a bundle), then stop where `--no-create` stops `icp deploy`. | ||
| // The outcome reports the failure, the targets, and the log of the phase | ||
| // that succeeded before it. | ||
| // Each deploy gets its own fresh directory, and on Unix one no other | ||
| // user of the machine can read: the parent may be world-readable. | ||
| #[test] | ||
| fn a_deploy_directory_is_fresh_and_private() { | ||
| let parent = tempfile::tempdir().unwrap(); | ||
| let a = deploy_directory(parent.path()).unwrap(); | ||
| let b = deploy_directory(parent.path()).unwrap(); | ||
| assert_ne!(a.path(), b.path()); | ||
| assert!(a.path().starts_with(parent.path())); | ||
| assert!(a.path().file_name().unwrap().to_str().unwrap().starts_with("imcp2-deploy-")); | ||
| #[cfg(unix)] | ||
| { | ||
| use std::os::unix::fs::PermissionsExt; | ||
| let mode = std::fs::metadata(a.path()).unwrap().permissions().mode() & 0o777; | ||
| assert_eq!(mode, 0o700, "mode {mode:o}"); | ||
| } | ||
| } | ||
|
|
||
| #[tokio::test(flavor = "multi_thread")] | ||
| async fn a_bundle_deploys_up_to_the_create_phase_offline() { | ||
| let (wasm, sha) = wasm(); | ||
|
|
@@ -689,6 +749,23 @@ mod tests { | |
| // A `bundle_path` is read only when it is a regular file: a directory | ||
| // here, and by the same check a device or a pipe, whose length says | ||
| // nothing about what reading it would produce. | ||
| // A deploy works under the directory it is given and removes its own | ||
| // when it returns, so a run that is cut off is the only one to leave a | ||
| // record there. | ||
| #[tokio::test(flavor = "multi_thread")] | ||
| async fn a_deploy_works_under_its_directory_and_removes_it_when_done() { | ||
| let (wasm, sha) = wasm(); | ||
| let parent = tempfile::tempdir().unwrap(); | ||
| let workdir = Utf8PathBuf::from_path_buf(parent.path().join("deploys")).unwrap(); | ||
| let mut request = request(bundle(&sha, &wasm), "ic"); | ||
| request.workdir = Some(workdir.clone()); | ||
| let outcome = deploy_bundle(Arc::new(AnonymousIdentity), request).await.unwrap(); | ||
| assert_eq!(outcome.status, Status::Failed, "{:?}", outcome.log); | ||
| assert!(workdir.is_dir(), "the parent is made"); | ||
| let left: Vec<_> = std::fs::read_dir(&workdir).unwrap().collect(); | ||
| assert!(left.is_empty(), "the run's own directory is removed: {left:?}"); | ||
| } | ||
|
|
||
| // The library resolves the mode at install, after creation, and panics on | ||
| // a spelling it does not know: refused at entry instead, before anything | ||
| // is read. | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Any risk here of one user writing a malicious value to
.icp/data/mappings/<environment>.ids.jsonthat then is served to another user?There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
For a given agent call, the server should create a temporary directory to run the deployment from and this file is a byproduct of the deployment. The directory is not supposed to be reused. even if it is reused the agent would still need a controller identity.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
No run reads another's file. Each call makes a fresh, randomly named directory (
deploy_directoryin deploy.rs, called where the scratch directory is made) under the user's local data directory (main.rs,deploys_in), extracts the bundle there, and that run alone reads it. The ids file in it is seeded from the call's owncanister_idsargument and written by the library during the run; the bundle's own.icp/entries are refused at extraction (host.rs), so a bundle cannot plant one. imcp2-local is one user per process, and the deploy tools are not on the gateway. What was missing: the directory's mode followed the umask, so another OS user could read a left-behind one where the home allows. 08516e7 makes it 0700 on Unix, with a test.