diff --git a/CHANGELOG.md b/CHANGELOG.md index 609a13a9..cc049791 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,17 +1,31 @@ + + # Unreleased * feat: `script` sync steps now receive `ICP_CLI_ENVIRONMENT`, `ICP_CLI_NETWORK`, `ICP_CLI_CID` (the current canister's principal), and `ICP_CLI_CID_` (every canister's principal) as environment variables. * fix: `icp canister call` with both `--json` and `-o hex` no longer prints both kinds of output at once. * fix: `icp` no longer picks up a stale inherited `$PWD` when launched as a subprocess via `chdir(2)` + `execve` (e.g. from a test harness). The logical `$PWD` path is now validated against `getcwd()` by inode before use, preserving symlink-aware project root discovery while ignoring stale values. +## Experimental + +* feat(sync-plugin): Plugins can now surface messages that persist after the step completes. Anything the plugin writes to stderr (e.g. `eprintln!` in Rust) is streamed live in the rolling step view AND printed under the canister name once the step ends; stdout remains transient. The `exec()` return signature has changed from `result, string>` to `result<_, string>` — plugins that returned a summary string should `eprintln!` it instead. + # v0.2.6 * feat: `icp token/cycles balance` now accept `--of-principal` * fix: The local wasm cache has moved from `.icp/cache/canisters/` to `.icp/cache/wasms/`. Existing cached files will be re-downloaded automatically on the next run. -* feat: Canister manifests now support a `plugin` sync step type. Plugins are WebAssembly components that run in a sandboxed environment and can drive arbitrary post-deployment logic against the canister being synced. See `crates/icp-sync-plugin/DESIGN.md` for details. -* feat: `icp sync` now accepts `--proxy` to route sync plugin calls to the target canister through a proxy canister. * fix: `icp canister call` now serializes arguments built via the interactive Candid assist prompt against the method's declared signature, matching the behavior of arguments passed on the command line. Previously, narrower values (e.g. a variant case from a multi-case variant) were encoded with a type table inferred only from the value, which the target canister rejected with errors like "Variant index N larger than length 1". +## Experimental + +* feat(sync-plugin): Canister manifests now support a `plugin` sync step type. Plugins are WebAssembly components that run in a sandboxed environment and can drive arbitrary post-deployment logic against the canister being synced. See `crates/icp-sync-plugin/DESIGN.md` for details. +* feat(sync-plugin): `icp sync` now accepts `--proxy` to route sync plugin calls to the target canister through a proxy canister. + # v0.2.5 * feat: `icp new --init` no longer requires specifying a project name. If non is provided, the containing folder's name is used as the project name diff --git a/Cargo.lock b/Cargo.lock index 1ff5f266..4ba6d270 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3905,6 +3905,8 @@ dependencies = [ name = "icp-sync-plugin" version = "0.2.6" dependencies = [ + "async-trait", + "bytes", "camino", "candid", "console 0.16.3", diff --git a/Cargo.toml b/Cargo.toml index 79914448..b160378a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -28,6 +28,7 @@ bigdecimal = "0.4.10" bip32 = "0.5.0" bollard = "0.20.2" byte-unit = "5.1.6" +bytes = "1.11" camino = { version = "1.1.9", features = ["serde1"] } cargo-generate = "0.23.7" camino-tempfile = "1" diff --git a/crates/icp-cli/src/operations/sync.rs b/crates/icp-cli/src/operations/sync.rs index 9431f793..18c77efc 100644 --- a/crates/icp-cli/src/operations/sync.rs +++ b/crates/icp-cli/src/operations/sync.rs @@ -41,8 +41,9 @@ async fn sync_canister( proxy: Option, pb: &mut MultiStepProgressBar, pkg_cache: &PackageCache, -) -> Result<(), SynchronizeError> { +) -> Result, SynchronizeError> { let step_count = canister_info.sync.steps.len(); + let mut stderr_lines = Vec::new(); for (i, step) in canister_info.sync.steps.iter().enumerate() { // Indicate to user the current step being executed @@ -72,10 +73,10 @@ async fn sync_canister( // Ensure background receiver drains all messages pb.end_step().await; - sync_result?; + stderr_lines.extend(sync_result?); } - Ok(()) + Ok(stderr_lines) } /// Orchestrates syncing multiple canisters with progress tracking @@ -129,6 +130,15 @@ pub(crate) async fn sync_many( ) .await; + // Print stderr lines the plugin emitted; the rolling buffer + // discards them on success, but they belong on the persistent + // output channel. + if let Ok(lines) = &result { + for line in lines { + eprintln!("[{}] {line}", canister_info.name); + } + } + // Map error to include canister context for deferred printing result.map_err(|error| SyncFailure { canister_name: canister_info.name.clone(), diff --git a/crates/icp-sync-plugin/Cargo.toml b/crates/icp-sync-plugin/Cargo.toml index 480fdc19..2f8ae325 100644 --- a/crates/icp-sync-plugin/Cargo.toml +++ b/crates/icp-sync-plugin/Cargo.toml @@ -7,6 +7,8 @@ repository.workspace = true publish.workspace = true [dependencies] +async-trait.workspace = true +bytes.workspace = true camino.workspace = true candid.workspace = true console.workspace = true diff --git a/crates/icp-sync-plugin/src/runtime.rs b/crates/icp-sync-plugin/src/runtime.rs index 16674af2..1b96d9e1 100644 --- a/crates/icp-sync-plugin/src/runtime.rs +++ b/crates/icp-sync-plugin/src/runtime.rs @@ -1,6 +1,9 @@ // Host-side Component Model runtime for sync plugins. +use std::pin::Pin; use std::sync::Arc; +use std::sync::Mutex as StdMutex; use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::task::{Context as TaskContext, Poll}; use std::time::{Duration, Instant}; const MAX_PLUGIN_OUTPUT: usize = 1024 * 1024; // 1 MiB per stream @@ -9,12 +12,15 @@ const MAX_WASM_STACK: usize = 512 * 1024; // How many seconds of pure wasm compute a plugin may use (host-call latency is excluded). const PLUGIN_COMPUTE_LIMIT_SECS: u64 = 60; +use bytes::Bytes; use camino::{Utf8Component, Utf8PathBuf}; use candid::{Encode, Principal}; use ic_agent::Agent; use snafu::prelude::*; +use tokio::io::{self, AsyncWrite}; use tokio::sync::mpsc::Sender; -use wasmtime_wasi::p2::pipe::MemoryOutputPipe; +use wasmtime_wasi::cli::{IsTerminal, StdoutStream}; +use wasmtime_wasi::p2::{OutputStream, Pollable, StreamError}; use wasmtime_wasi::{DirPerms, FilePerms}; wasmtime::component::bindgen!({ @@ -173,7 +179,7 @@ pub fn run_plugin( identity_principal: Principal, environment: String, stdio: Option>, -) -> Result<(), RunPluginError> { +) -> Result, RunPluginError> { use wasmtime::component::{Component, Linker}; use wasmtime::{Config, Engine, Store}; @@ -236,13 +242,12 @@ pub fn run_plugin( .context(PreopenDirSnafu { dir: host_path })?; } - let stdout_pipe = MemoryOutputPipe::new(MAX_PLUGIN_OUTPUT); - let stderr_pipe = MemoryOutputPipe::new(MAX_PLUGIN_OUTPUT); - if stdio.is_some() { - wasi_builder - .stdout(stdout_pipe.clone()) - .stderr(stderr_pipe.clone()); - } + let persistent_stderr: Arc>> = Arc::default(); + let stdout_capture = LineCapture::new("stdout", stdio.clone(), None); + let stderr_capture = LineCapture::new("stderr", stdio.clone(), Some(persistent_stderr.clone())); + wasi_builder + .stdout(stdout_capture.clone()) + .stderr(stderr_capture.clone()); let epoch_extension = Arc::new(AtomicU64::new(0)); let host_state = HostState { @@ -292,30 +297,172 @@ pub fn run_plugin( proxy_canister_id: proxy.map(|p| p.to_text()), }; - let result = plugin - .call_exec(&mut store, &input) - .context(CallExecSnafu { path: wasm_path })?; + let call_result = plugin.call_exec(&mut store, &input); + + // Flush any partial line and emit the truncation note (if any) before + // we hand control back, so the last line of plugin output isn't lost. + stdout_capture.finalize(); + stderr_capture.finalize(); + + match call_result.context(CallExecSnafu { path: wasm_path })? { + Ok(()) => {} + Err(message) => return PluginFailedSnafu { message }.fail(), + } + + let lines = std::mem::take(&mut *persistent_stderr.lock().unwrap()); + Ok(lines) +} + +// ------------------------------------------------------------------------- +// Plugin stdout/stderr capture +// ------------------------------------------------------------------------- +// +// `LineCapture` implements both `StdoutStream` (so it can be installed on a +// `WasiCtxBuilder`) and `OutputStream` / `AsyncWrite` (so the bytes written +// by the guest flow through the same code path). Each write is split on +// newlines; complete lines have ANSI escapes stripped and are pushed to the +// rolling-view `Sender` via `try_send` (best-effort). For stderr, +// the same lines are also appended to `persistent`, which is drained by +// `run_plugin()` after `exec()` returns. Total accepted bytes are capped at +// `MAX_PLUGIN_OUTPUT` per stream; further bytes are dropped and `finalize` +// emits a single "… N bytes of