Skip to content

Latest commit

 

History

History
209 lines (153 loc) · 10.1 KB

File metadata and controls

209 lines (153 loc) · 10.1 KB
title Writing a Sync Plugin
description Author a WebAssembly sync plugin in Rust that runs sandboxed post-deployment logic against a canister during icp sync.

This guide walks through writing a sync plugin in Rust — a WebAssembly component that icp-cli runs during icp sync to perform post-deployment work against a canister. If you only want to use an existing plugin (for example, one emitted by a recipe), you don't need this guide; see Plugin Sync in the Configuration Reference instead.

For a complete, runnable project, see the icp-sync-plugin example.

Prerequisites

A plugin compiles to the wasm32-wasip2 target. Add it once:

rustup target add wasm32-wasip2

You also need the plugin interface definition, sync-plugin.wit. Copy it into your plugin crate (e.g. as sync-plugin.wit) so the build can generate bindings from it. The .wit file is the source of truth for the interface.

Set Up the Crate

A plugin is a cdylib crate. Its Cargo.toml needs candid (to encode call arguments) and wit-bindgen (to generate the interface bindings):

[package]
name = "my-plugin"
version = "0.1.0"
edition = "2024"

[lib]
crate-type = ["cdylib"]

[dependencies]
candid = "0.10"
wit-bindgen = { version = "0.56", features = ["realloc"] }

Generate Bindings and Implement exec

wit_bindgen::generate! reads the WIT at build time and produces the Guest trait you implement, the input/request types, and the host functions (canister_call, canister_metadata_section). The exec export is your entry point — it returns Ok(()) on success or Err(message) to fail the sync step.

// src/lib.rs
wit_bindgen::generate!({
    world: "sync-plugin",
    path: "sync-plugin.wit",
});

use candid::{Encode, Principal};

struct Plugin;

impl Guest for Plugin {
    fn exec(input: SyncExecInput) -> Result<(), String> {
        // stdout: transient progress, discarded when the step ends.
        println!(
            "syncing canister {} (environment: {})",
            input.canister_id, input.environment
        );

        // Encode the Candid argument yourself; the host forwards the bytes unchanged.
        let uploader = Principal::from_text(&input.identity_principal)
            .map_err(|e| format!("invalid identity principal: {e}"))?;
        let arg = Encode!(&uploader).map_err(|e| format!("encode arg: {e}"))?;

        // Call a method on the canister being synced.
        canister_call(&CanisterCallRequest {
            target: CallTarget::Host, // the canister being synced
            method: "set_uploader".to_string(),
            arg,
            call_type: icp::sync_plugin::types::CallType::Update,
            direct: false, // route update calls through the proxy if one is configured
            cycles: 0,
        })?;

        // stderr: printed persistently after the step completes — use for summaries.
        eprintln!("set_uploader: ok");
        Ok(())
    }
}

export!(Plugin);

A few things to note:

  • You encode the arguments. arg is raw Candid bytes. Encode with candid::Encode!; decode any response (Vec<u8>) with candid::Decode!.
  • You choose the target. target: CallTarget::Host reaches the canister being synced. To call another canister, declare it in the manifest's canisters: list and address it with CallTarget::Name("ledger".into()) — the name matches the entries in input.canister_ids. Names are the only way to reach another canister; the host resolves them per environment. The host rejects a target you did not declare. A name is always the one the plugin's own project uses, so hardcoding it stays correct when that project is vendored into a workspace as a subproject.
  • direct and cycles control proxy routing. With direct: false, update calls go through the proxy canister when one is configured, and cycles can fund the forwarded call. With direct: true, the call always goes straight to the target. See The Plugin Interface for the full semantics.

Read Canister Metadata

canister_metadata_section reads a metadata section off a canister — useful for inspecting what is actually deployed before acting on it, e.g. its candid:service interface:

let interface = canister_metadata_section(&MetadataSectionRequest {
    target: CallTarget::Host, // same targets, same rules, as canister_call
    name: "candid:service".to_string(),
    direct: false, // route through the proxy if one is configured
})?;

match interface {
    Some(bytes) => println!("interface: {}", String::from_utf8_lossy(&bytes)),
    // `None` means the canister provably has no such section — not a failure.
    // A section you may not read, or a canister that does not exist, is an error.
    None => println!("canister exposes no Candid interface"),
}

direct picks who the target sees asking, which is what decides whether a private section is readable: a direct read is signed by the sync identity, a proxied one is made by the proxy canister on your behalf. See Reading canister metadata for the full semantics.

Read Declared Files and Directories

A plugin can't see the filesystem freely — only what you grant it in the manifest's files:. That one setting holds directories and files alike, named: seed: assets/seed-data. The host splits them by what is on disk and hands you input.dirs and input.files, so a plugin never declares up front which an entry will be.

Directories arrive in input.dirs, preopened read-only at the same relative path. Each entry gives you its path plus the key it was declared under. Traverse them with standard std::fs:

for dir in &input.dirs {
    for entry in std::fs::read_dir(&dir.path).map_err(|e| e.to_string())? {
        let path = entry.map_err(|e| e.to_string())?.path();
        let content = std::fs::read_to_string(&path).map_err(|e| e.to_string())?;
        // ... encode and send to the canister; dir.key groups related dirs ...
    }
}

Files arrive in input.files, read by the host up front and passed inline — read them from the input struct, not from disk. Each entry carries its key, name (the path), and content:

for file in &input.files {
    println!("{} = {}", file.name, file.content.trim());
}

Every entry carries the key it was declared under, so a plugin can group or label paths (for example, tell seed: directories from migrations:) without hardcoding paths. A name holding a list of paths yields several entries sharing that key.

Open each entry at the path it arrives with, whatever it looks like: a manifest may declare a directory elsewhere in the project (../shared/assets), and the preopen carries that same spelling. Writes, and paths that escape a preopen, are rejected by the sandbox at runtime. See The Sandbox for the full capability list and resource limits.

Read Declared Fields

Key-value pairs declared in the manifest's fields: are passed inline as string values. Use them for small configuration a plugin needs without shipping a file:

for field in &input.fields {
    println!("{} = {}", field.name, field.value);
}

A value always arrives as a string, so parse the ones you want as another type — a manifest may write retries: 3 unquoted, and the plugin receives "3".

Know Where the Network Is

input.api_url is the endpoint the host submits your canister calls to, and input.gateway_url is the HTTP gateway serving canisters over HTTP — absent when the network exposes none. You have no sockets, so neither is something to fetch: use them to tell the user where something landed, or to hand a canister the address it is reachable at.

match &input.gateway_url {
    Some(gateway) => eprintln!("{} is served from {gateway}", input.canister_id),
    None => eprintln!("{} synced ({} has no HTTP gateway)", input.canister_id, input.environment),
}

Both arrive normalized, so a URL with no path carries a trailing slash (http://127.0.0.1:4943/) — strip it before joining a path onto it.

Build

cargo build --target wasm32-wasip2 --release

The output .wasm is loaded directly by icp-cli — no extra component-packaging step is required. It lands in <cargo-target-dir>/wasm32-wasip2/release/, which is ./target only for a standalone crate with no CARGO_TARGET_DIR or build.target-dir set; in a workspace it sits at the workspace root.

Wire It Into the Manifest

Reference the built wasm from a plugin sync step and declare the files, directories, and fields the plugin needs:

sync:
  steps:
    - type: plugin
      path: target/wasm32-wasip2/release/my_plugin.wasm
      files:
        seed: seed-data
        config: config.txt
      fields:
        api_url: https://example.com
        retries: 3

path is a plain path relative to the canister directory — it is not expanded, so it cannot reference $CARGO_TARGET_DIR. If your target directory is elsewhere, point path at the real location or copy the plugin wasm into the canister directory as a build step.

Then run the sync phase:

icp sync my-canister

For remote distribution, host the .wasm and reference it with url plus a required sha256. See Plugin Sync for all manifest fields.

Next Steps