Skip to content

feat(icp): programmatic deploy API (manifest + create/install + sync) — DRAFT proposal#657

Draft
pietrodimarco-dfinity wants to merge 2 commits into
dfinity:mainfrom
pietrodimarco-dfinity:feat/programmatic-deploy-api
Draft

feat(icp): programmatic deploy API (manifest + create/install + sync) — DRAFT proposal#657
pietrodimarco-dfinity wants to merge 2 commits into
dfinity:mainfrom
pietrodimarco-dfinity:feat/programmatic-deploy-api

Conversation

@pietrodimarco-dfinity

Copy link
Copy Markdown

Summary (DRAFT — proposal for the maintainers)

A backend service (dfinity/control-panel) wants to deploy marketplace app bundles programmatically — read + validate a project manifest, create canisters and install their wasm, and sync asset canisters — from Rust, in-process, without shelling out to the icp binary.

Today the create/install logic lives only in the icp-cli binary (operations::{create,install}), so an external consumer cannot reach it. This PR adds a minimal, self-contained public library surface on the icp crate for the three capabilities the maintainer identified, and an example that compiles as an external consumer to prove the surface is sufficient.

This is a draft to start the conversation — API naming, whether the binary should converge onto these functions, and distribution (git dep vs. publishing) are all open for the maintainers to shape.

The three capabilities and what each maps to

1. Read, parse and validate the manifestalready public; added a one-call entry point.

  • New icp::project::load_project(dir) -> Result<Project, ProjectLoadError>: loads icp.yaml, consolidates + validates it into a Project. Wraps the existing load_manifest_from_path + consolidate_manifest, which were public but required the caller to also supply a recipe::Resolve.
  • New icp::project::NoRecipes: a public recipe::Resolve that rejects recipe references, for consumers deploying only prebuilt bundles (no @scope/name registry recipes). Backed by a new recipe::ResolveError::Unsupported variant.

2. Deploy the wasms (create canisters + install code)new; was binary-only.
New module icp::deploy (crates/icp/src/deploy.rs) with clean management-canister calls:

  • create_canister(agent, effective_canister_id, settings) -> Principal
  • create_canister_on_subnet(agent, subnet, settings) -> Principal (convenience)
  • effective_canister_id_for_subnet(agent, subnet) -> Principal
  • canister_status(agent, canister_id) -> CanisterStatusResult
  • resolve_install_mode(agent, canister_id) -> CanisterInstallMode (the --mode auto logic)
  • install_wasm(agent, canister_id, wasm, mode, init_args) — direct install_code, transparently switching to chunked install for modules > 2 MiB
  • DeployError

This deliberately does not port the binary's cycles-ledger / cycles-minting-canister / proxy-canister funding paths or its progress bars — a plain management-canister create+install is enough for local replicas and cloud-engine subnets, which is what this consumer targets. It modestly duplicates logic in operations::{create,install}; the binary could converge onto these functions later.

3. Sync assets via the wasm pluginalready public; confirmed drivable externally.
No library changes were needed. icp::canister::sync::{Syncer, Synchronize, Params, SynchronizeError} and icp::package::PackageCache are already public, and the example drives an asset sync end-to-end through them (the binary's operations::sync is just a progress-bar wrapper over the same Synchronize).

Example (the proof)

crates/icp/examples/deploy_bundle.rs compiles as an external consumer of icp (so it can only touch pub items) and wires the full flow in code: load_project → for each canister create_canister_on_subnet + resolve_install_mode + install_wasm → run each sync step via Syncer. It performs live calls when given real arguments and prints usage otherwise; CI only needs it to compile, which is the whole point — it forces the pub surface to be sufficient.

Build with: cargo build -p icp --examples.

What is minimal / TODO

  • install_wasm is a plain install_code; unlike the binary it does not stop/start the canister around an upgrade or auto-detect enhanced-orthogonal-persistence. Documented on the function; callers needing those can stop the canister first. // TODO(maintainers) markers are not left in code, but this is the main behavioral gap vs. the binary.
  • Mainnet funding (cycles ledger / CMC) is intentionally out of scope for this surface.

API gaps found while doing this

  • Recipe support is not externally usable. manifest::recipe is pub(crate), so recipe::Recipe / RecipeType cannot be named outside the crate, which means an external consumer cannot implement recipe::Resolve or fully destructure ConsolidateManifestError::Recipe. NoRecipes sidesteps this for prebuilt bundles; supporting registry recipes externally would need those types (and ideally a public registry resolver) made public.
  • icp::canister::SettingsCanisterSettings (From) drops controllers (they are ControllerRefs resolved elsewhere), so a programmatic caller must set controllers on the CanisterSettings itself — the example does this explicitly.

Distribution

publish = false is left as-is (the crate depends on git revisions of dfinity/ic, which blocks crates.io). Consumers would use a git dependency on this crate. Whether to restructure for publishing is a maintainer decision.

🤖 Generated with Claude Code

…sync)

Adds a minimal, self-contained public library surface on the `icp` crate so
an external consumer (e.g. a backend deploying marketplace app bundles) can
deploy without shelling out to the `icp` binary:

- manifest: `project::load_project` + `project::NoRecipes` (prebuilt bundles)
- deploy wasms: new `deploy` module (create_canister[_on_subnet],
  resolve_install_mode, install_wasm with chunking, canister_status)
- sync: confirmed drivable via existing public `canister::sync` + PackageCache

Includes `crates/icp/examples/deploy_bundle.rs`, which compiles as an external
consumer and wires the three capabilities end-to-end, proving the public
surface is sufficient.

DRAFT proposal for the maintainers.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings July 20, 2026 13:30
@cla-idx-bot

cla-idx-bot Bot commented Jul 20, 2026

Copy link
Copy Markdown

Dear @pietrodimarco-dfinity,

In order to potentially merge your code in this open-source repository and therefore proceed with your contribution, we need to have your approval on DFINITY's CLA.

If you decide to agree with it, please visit this issue and read the instructions there. Once you have signed it, re-trigger the workflow on this PR to see if your code can be merged.

— The DFINITY Foundation

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds a public Rust API for loading manifests, creating/installing canisters, and synchronizing marketplace bundles without invoking the CLI.

Changes:

  • Adds manifest loading with recipe rejection.
  • Adds direct and chunked canister deployment APIs.
  • Adds an external-consumer deployment example.

Reviewed changes

Copilot reviewed 5 out of 5 changed files in this pull request and generated 6 comments.

Show a summary per file
File Description
crates/icp/src/project.rs Adds NoRecipes and load_project.
crates/icp/src/lib.rs Exposes the deployment module.
crates/icp/src/deploy.rs Implements create, status, and install operations.
crates/icp/src/canister/recipe/mod.rs Adds unsupported-recipe errors.
crates/icp/examples/deploy_bundle.rs Demonstrates programmatic bundle deployment.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread crates/icp/src/deploy.rs
Comment thread crates/icp/src/deploy.rs
Comment thread crates/icp/examples/deploy_bundle.rs Outdated
Comment thread crates/icp/examples/deploy_bundle.rs
Comment thread crates/icp/src/project.rs Outdated
Comment thread crates/icp/src/deploy.rs
…ocs, tests

- install_wasm: clear chunk store on every failure in the chunked path
  (best-effort), preserving the original error over any cleanup error;
  split out upload_and_install_chunked.
- deploy docs: state create is direct/cycles-free, suitable for
  CloudEngine-style subnets only; cycles-ledger/CMC funding is out of scope
  (use the binary's operations::create).
- deploy_bundle example: restructure into create-all / install-all /
  sync-all phases so the full name->id map is known before controllers,
  install and sync; resolve+retain manifest controllers and append the
  deployer without duplicates (From<Settings> drops controllers).
- project docs: custom Resolve impls are internal-only (recipe input types
  are crate-private); external consumers should use NoRecipes.
- deploy: extract pure needs_chunked_install and add unit tests for the
  chunking-threshold boundaries.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@pietrodimarco-dfinity

Copy link
Copy Markdown
Author

New: public bundle extractor (icp::bundle)

Added the missing consume side of the bundle format — the counterpart to icp project bundle (operations::bundle::write_archive). The binary can write a .tar.gz bundle, but the library had no public way to open one back into a project directory, so an external consumer couldn't feed a bundle to the new deploy API.

API (crates/icp/src/bundle.rs, registered as pub mod bundle):

pub fn open_to_dir(archive: &std::path::Path, dest: &std::path::Path) -> Result<(), BundleOpenError>;
pub fn open_bytes(bytes: &[u8], dest: &std::path::Path) -> Result<(), BundleOpenError>;

#[non_exhaustive]
pub enum BundleOpenError { Read { .. }, Extract { .. }, UnsafeEntry { .. } }

open_to_dir reads the file and delegates to open_bytes (control-panel already has the bundle bytes in memory). Both produce a directory with icp.yaml at its root — exactly what icp::project::load_project expects — closing the loop bundle .tar.gz → dir → load_project → deploy with no icp subprocess.

Why std::path in the signatures: this is an external interchange boundary — a backend hands us archive/destination paths it already holds as std::path — so the public surface speaks std::path (with a scoped, documented allow for the crate's camino lint) rather than the internal Utf8Path.

Safety hardening: the archive is not unpacked blindly. Entries are iterated and each is rejected if its path is absolute or contains a .. component (path traversal), or if it is a symlink/hardlink entry (only regular files and directories are extracted). This mirrors the hardening in control-panel's own backend extractor and prevents a crafted archive from writing outside dest or planting an escaping link.

Example: examples/deploy_bundle.rs now accepts either a bundle .tar.gz (opened into a temp dir via bundle::open_to_dir) or a project directory, then runs the existing create-all → controllers → install-all → sync-all phases.

Tests (no network): round-trip extraction of files under subdirs; path-traversal rejection (both absolute and .., asserting nothing is written outside dest); symlink-entry rejection.

tar/flate2 were already dependencies of the icp crate; only tempfile was added as a dev-dependency (already in the workspace).

Verified: cargo build -p icp --examples, cargo test -p icp bundle:: (5 passing), cargo clippy -p icp --examples --all-targets (0 warnings), cargo fmt -p icp --check.

🤖 Generated with Claude Code

@pietrodimarco-dfinity
pietrodimarco-dfinity force-pushed the feat/programmatic-deploy-api branch from aaf8825 to 853f409 Compare July 20, 2026 14:44
@pietrodimarco-dfinity

Copy link
Copy Markdown
Author

Update: I've removed the bundle extractor (icp::bundle::open_to_dir/open_bytes) from this PR (supersedes the note above). Rationale: extracting the .tar.gz is generic (flate2 + tar) and orthogonal to the deploy API — load_project works on any directory, and consumers can untar however they like. Keeping this PR focused on the genuine gap: the programmatic deploy API (icp::project::load_project + icp::deploy create/install + the existing Syncer sync surface).

If a consume-counterpart to icp project bundle is wanted (e.g. load_project accepting an archive, or a dedicated open helper), that's better as a separate follow-up where the shape can be decided independently.

🤖 Generated with Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants