Compare commits

...

7 Commits

Author SHA1 Message Date
Maofeng 2650b8ef14 docs(cli): add Wasmeld command reference
Document Component project requirements, live preview behavior and options, package construction, WIT dependency locking and replacement, Registry publishing, and CLI environment variables.

Link the root and Console guides to the maintained CLI reference and remove links to excluded design documents.
2026-07-29 20:52:32 +08:00
Maofeng c982753eb8 docs: clarify live WIT dependency updates
Recommend unlocked development mode when editing local WIT replacements, while retaining --locked for source-only work that must preserve the existing contract graph.
2026-07-29 20:26:57 +08:00
Maofeng 2c6e761288 docs: document live Component preview workflow
Explain the wasmeld dev watch-build-package-deploy loop, isolated development service IDs, hash revisions, failure retention, cleanup behavior, and command options.

Document the inactive-revision DELETE endpoint and the switch-before-unregister safety rule.
2026-07-29 20:26:25 +08:00
Maofeng 839874fb56 feat(cli): add live Component development loop
Add wasmeld dev with source and local WIT replace watching, Debug builds by default, deterministic hash-based development revisions, and optional one-shot or Release operation.

Build and validate before registering, switch the isolated development Deployment only after successful startup, and unregister the prior matching development revision after the switch.

Keep generated WIT dependencies and build output outside the watch fingerprint, retain the previous deployment after failures, and avoid introducing a platform-specific watcher dependency.
2026-07-29 20:26:16 +08:00
Maofeng 201d99c7e0 feat(console-ui): display revision unregister events
Extend the typed event model and activity feed metadata so development cleanup remains visible in the management console.
2026-07-29 20:26:10 +08:00
Maofeng d0d2e06f68 feat(console): unregister inactive revisions
Expose a management DELETE endpoint that refuses to remove an active Deployment, delegates Actor and compiled Component cleanup to Runtime, and removes stored artifacts.

Reconcile Toasty snapshots by deleting absent service and Deployment rows so removed revisions do not reappear after restart.

Persist unregister lifecycle events and verify active-revision protection, artifact deletion, Runtime cleanup, and restart behavior.
2026-07-29 20:26:06 +08:00
Maofeng 778fbd2a06 feat(runtime): unregister service revisions
Add a lifecycle-serialized unregister operation that stops a resident Actor before releasing its registered and compiled Component.

Return the existing not-registered and unavailable errors without leaving a partially removed live revision, and cover Actor and registration count cleanup.
2026-07-29 20:25:58 +08:00
12 changed files with 1021 additions and 35 deletions
+22
View File
@@ -56,6 +56,25 @@ cd console
npm run dev npm run dev
``` ```
## 组件开发
Console 启动后,使用开发模式监听组件源码和本地 WIT `replace`
```bash
cargo run -p wasmeld-package --bin wasmeld -- \
dev components/echo/Cargo.toml
```
首次构建和每次源码变化都会自动完成 WIT 同步、Debug 编译、打包、注册和 Deployment
切换。默认使用 `echo-dev` 这类独立服务 ID,并根据 Component 内容生成
`0.1.0-dev.h<hash>` Revision,不会覆盖正式服务。新版本构建、校验或启动失败时,上一
版本继续运行;切换成功后旧开发 Revision 会被注销并释放。
管理面每 5 秒刷新一次,可以直接在调用面板预览新版本。只构建并部署一次可使用
`--once`;其它选项包括 `--id``--console``--release``--poll-ms`。管理 API
地址也可通过 `WASMELD_CONSOLE` 设置。默认允许本地 `replace` 更新 `wit.lock`;若开发
期间不允许 WIT 契约发生变化,再添加 `--locked`
构建组件: 构建组件:
```bash ```bash
@@ -83,3 +102,6 @@ cargo run -p wasmeld-package --bin wasmeld -- \
组件在 `wasmeld.toml` 中声明 Registry 和精确版本依赖,`wasmeld wit fetch` 会递归 组件在 `wasmeld.toml` 中声明 Registry 和精确版本依赖,`wasmeld wit fetch` 会递归
解析依赖、生成 `wit/deps`,并将完整解析结果记录在 `wit.lock`。本地开发可使用 解析依赖、生成 `wit/deps`,并将完整解析结果记录在 `wit.lock`。本地开发可使用
`[replace]` 临时覆盖 Registry 来源。 `[replace]` 临时覆盖 Registry 来源。
完整命令、开发选项、组件包格式和 WIT 依赖说明见
[`wasmeld-package` CLI 文档](crates/wasmeld-package/README.md)。
+1 -1
View File
@@ -35,7 +35,7 @@ export type BackendService = {
export type BackendEvent = { export type BackendEvent = {
id: number; id: number;
timestamp_ms: number; timestamp_ms: number;
kind: "registered" | "deployed" | "started" | "stopped" | "invoked" | "failed"; kind: "registered" | "unregistered" | "deployed" | "started" | "stopped" | "invoked" | "failed";
service_id: string | null; service_id: string | null;
revision: string | null; revision: string | null;
message: string; message: string;
+1
View File
@@ -135,6 +135,7 @@ function toEvent(event: BackendEvent): RuntimeEvent {
event.service_id && event.revision ? `${event.service_id}@${event.revision}` : "Wasmeld"; event.service_id && event.revision ? `${event.service_id}@${event.revision}` : "Wasmeld";
const meta: Record<BackendEvent["kind"], { label: string; tone: EventTone }> = { const meta: Record<BackendEvent["kind"], { label: string; tone: EventTone }> = {
registered: { label: "已注册", tone: "success" }, registered: { label: "已注册", tone: "success" },
unregistered: { label: "已注销", tone: "neutral" },
deployed: { label: "部署切换", tone: "success" }, deployed: { label: "部署切换", tone: "success" },
started: { label: "已启动", tone: "success" }, started: { label: "已启动", tone: "success" },
stopped: { label: "已停止", tone: "neutral" }, stopped: { label: "已停止", tone: "neutral" },
+6 -4
View File
@@ -60,6 +60,7 @@ WIT Registry 路由。
| `POST` | `/api/v1/runtime/restart` | 重建 Runtime,并重新创建活动 Deployment 的 Actor | | `POST` | `/api/v1/runtime/restart` | 重建 Runtime,并重新创建活动 Deployment 的 Actor |
| `GET` | `/api/v1/services` | 服务版本及精确 Host Capability 列表 | | `GET` | `/api/v1/services` | 服务版本及精确 Host Capability 列表 |
| `POST` | `/api/v1/services` | multipart 上传单个 `package` 字段(`.wasmpkg` | | `POST` | `/api/v1/services` | multipart 上传单个 `package` 字段(`.wasmpkg` |
| `DELETE` | `/api/v1/services/{id}/{revision}` | 注销非活动 Revision 并删除制品 |
| `POST` | `/api/v1/services/{id}/{revision}/start` | 启动 Actor | | `POST` | `/api/v1/services/{id}/{revision}/start` | 启动 Actor |
| `POST` | `/api/v1/services/{id}/{revision}/stop` | 停止 Actor | | `POST` | `/api/v1/services/{id}/{revision}/stop` | 停止 Actor |
| `POST` | `/api/v1/services/{id}/{revision}/restart` | 重启 Actor | | `POST` | `/api/v1/services/{id}/{revision}/restart` | 重启 Actor |
@@ -73,6 +74,9 @@ WIT Registry 路由。
| `GET` | `/api/v1/wit/packages/{namespace}/{name}/{version}` | WIT 包元数据 | | `GET` | `/api/v1/wit/packages/{namespace}/{name}/{version}` | WIT 包元数据 |
| `GET` | `/api/v1/wit/packages/{namespace}/{name}/{version}/content` | 下载 WIT 包 | | `GET` | `/api/v1/wit/packages/{namespace}/{name}/{version}/content` | 下载 WIT 包 |
活动 Deployment 不能直接注销。开发工具会先完成新 Revision 的编译、注册和切换,
再注销上一份开发 Revision,因此失败的构建不会影响当前可调用版本。
激活已注册版本: 激活已注册版本:
```bash ```bash
@@ -104,7 +108,5 @@ curl http://127.0.0.1:8081/v1/services/echo/invoke \
最大 256 BValue 最大 64 KiB。数据库命令通过有界专用工作线程执行,Host 等待时间 最大 256 BValue 最大 64 KiB。数据库命令通过有界专用工作线程执行,Host 等待时间
不会超过服务的 `deadline_ms`。v0.1.0 不提供 TTL、遍历、事务或 CAS。 不会超过服务的 `deadline_ms`。v0.1.0 不提供 TTL、遍历、事务或 CAS。
组件包的构建与格式说明见 组件包格式、开发循环、WIT 依赖、Registry 和本地 `replace` 说明见
[`docs/design/wasmeld-component-package.md`](../../docs/design/wasmeld-component-package.md)。 [`wasmeld-package` CLI 文档](../wasmeld-package/README.md)。
WIT 依赖、Registry 和本地 replace 说明见
[`docs/design/wit-package-management.md`](../../docs/design/wit-package-management.md)。
+68 -2
View File
@@ -24,7 +24,7 @@ use axum::{
extract::{DefaultBodyLimit, Multipart, Path as AxumPath, State, rejection::BytesRejection}, extract::{DefaultBodyLimit, Multipart, Path as AxumPath, State, rejection::BytesRejection},
http::{HeaderMap, HeaderValue, Method, StatusCode, header}, http::{HeaderMap, HeaderValue, Method, StatusCode, header},
response::{IntoResponse, Response}, response::{IntoResponse, Response},
routing::{get, post}, routing::{delete, get, post},
}; };
use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64}; use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
@@ -218,6 +218,7 @@ pub struct EventView {
#[serde(rename_all = "snake_case")] #[serde(rename_all = "snake_case")]
pub enum EventKind { pub enum EventKind {
Registered, Registered,
Unregistered,
Deployed, Deployed,
Started, Started,
Stopped, Stopped,
@@ -229,6 +230,7 @@ impl EventKind {
fn as_database_value(self) -> &'static str { fn as_database_value(self) -> &'static str {
match self { match self {
Self::Registered => "registered", Self::Registered => "registered",
Self::Unregistered => "unregistered",
Self::Deployed => "deployed", Self::Deployed => "deployed",
Self::Started => "started", Self::Started => "started",
Self::Stopped => "stopped", Self::Stopped => "stopped",
@@ -240,6 +242,7 @@ impl EventKind {
fn from_database_value(value: &str) -> Result<Self, ConsoleError> { fn from_database_value(value: &str) -> Result<Self, ConsoleError> {
match value { match value {
"registered" => Ok(Self::Registered), "registered" => Ok(Self::Registered),
"unregistered" => Ok(Self::Unregistered),
"deployed" => Ok(Self::Deployed), "deployed" => Ok(Self::Deployed),
"started" => Ok(Self::Started), "started" => Ok(Self::Started),
"stopped" => Ok(Self::Stopped), "stopped" => Ok(Self::Stopped),
@@ -351,6 +354,9 @@ pub enum ConsoleError {
#[error("service {0} has no active deployment")] #[error("service {0} has no active deployment")]
DeploymentNotFound(String), DeploymentNotFound(String),
#[error("service revision {0} is the active deployment")]
ActiveDeployment(ServiceKey),
#[error("Wasmeld Runtime is not running")] #[error("Wasmeld Runtime is not running")]
RuntimeNotRunning, RuntimeNotRunning,
@@ -790,6 +796,50 @@ impl Console {
Ok(view) Ok(view)
} }
fn unregister(&self, key: &ServiceKey) -> Result<ServiceView, ConsoleError> {
let _lifecycle = self
.lifecycle
.lock()
.map_err(|_| ConsoleError::LockPoisoned)?;
let view = {
let state = self.state()?;
if state
.deployments
.get(key.id())
.is_some_and(|deployment| deployment.active_revision == key.revision())
{
return Err(ConsoleError::ActiveDeployment(key.clone()));
}
state
.services
.get(key)
.map(ServiceRecord::view)
.ok_or_else(|| ConsoleError::ServiceNotFound(key.clone()))?
};
let runtime = self.runtime()?;
let runtime = runtime.as_ref().ok_or(ConsoleError::RuntimeNotRunning)?;
runtime.unregister(key)?;
self.state()?.services.remove(key);
for path in [
self.artifact_dir.join(format!("{key}.wasm")),
self.artifact_dir.join(format!("{key}.toml")),
] {
if let Err(error) = fs::remove_file(&path)
&& error.kind() != io::ErrorKind::NotFound
{
tracing::warn!(path = %path.display(), %error, "failed to remove unregistered artifact");
}
}
self.push_event(
EventKind::Unregistered,
Some(key),
"component revision unregistered".to_owned(),
)?;
Ok(view)
}
fn start(&self, key: &ServiceKey, init_config: Vec<u8>) -> Result<ServiceView, ConsoleError> { fn start(&self, key: &ServiceKey, init_config: Vec<u8>) -> Result<ServiceView, ConsoleError> {
let runtime = self.runtime()?; let runtime = self.runtime()?;
let runtime = runtime.as_ref().ok_or(ConsoleError::RuntimeNotRunning)?; let runtime = runtime.as_ref().ok_or(ConsoleError::RuntimeNotRunning)?;
@@ -1221,7 +1271,7 @@ pub fn app(console: Arc<Console>, allowed_origins: Vec<HeaderValue>) -> Router {
.max(console.max_wit_package_bytes()) .max(console.max_wit_package_bytes())
.saturating_add(1024 * 1024); .saturating_add(1024 * 1024);
let mut cors = CorsLayer::new() let mut cors = CorsLayer::new()
.allow_methods([Method::GET, Method::POST]) .allow_methods([Method::GET, Method::POST, Method::DELETE])
.allow_headers([header::CONTENT_TYPE]); .allow_headers([header::CONTENT_TYPE]);
if !allowed_origins.is_empty() { if !allowed_origins.is_empty() {
cors = cors.allow_origin(allowed_origins); cors = cors.allow_origin(allowed_origins);
@@ -1234,6 +1284,10 @@ pub fn app(console: Arc<Console>, allowed_origins: Vec<HeaderValue>) -> Router {
.route("/api/v1/runtime/stop", post(stop_runtime)) .route("/api/v1/runtime/stop", post(stop_runtime))
.route("/api/v1/runtime/restart", post(restart_runtime)) .route("/api/v1/runtime/restart", post(restart_runtime))
.route("/api/v1/services", get(list_services).post(register)) .route("/api/v1/services", get(list_services).post(register))
.route(
"/api/v1/services/{id}/{revision}",
delete(unregister_service),
)
.route("/api/v1/deployments", get(list_deployments)) .route("/api/v1/deployments", get(list_deployments))
.route("/api/v1/deployments/{id}", get(get_deployment)) .route("/api/v1/deployments/{id}", get(get_deployment))
.route( .route(
@@ -1573,6 +1627,16 @@ async fn stop_service(
)) ))
} }
async fn unregister_service(
State(console): State<Arc<Console>>,
AxumPath((id, revision)): AxumPath<(String, String)>,
) -> Result<Json<ServiceView>, ApiError> {
let key = api_key(id, revision)?;
Ok(Json(
run_blocking(console, move |console| console.unregister(&key)).await?,
))
}
async fn restart_service( async fn restart_service(
State(console): State<Arc<Console>>, State(console): State<Arc<Console>>,
AxumPath((id, revision)): AxumPath<(String, String)>, AxumPath((id, revision)): AxumPath<(String, String)>,
@@ -1766,6 +1830,7 @@ impl IntoResponse for GatewayError {
| RuntimeError::ComponentError { .. }, | RuntimeError::ComponentError { .. },
) => (StatusCode::BAD_GATEWAY, "service_error", error.to_string()), ) => (StatusCode::BAD_GATEWAY, "service_error", error.to_string()),
ConsoleError::ArtifactTooLarge { .. } ConsoleError::ArtifactTooLarge { .. }
| ConsoleError::ActiveDeployment(_)
| ConsoleError::Package(_) | ConsoleError::Package(_)
| ConsoleError::WitRegistry(_) | ConsoleError::WitRegistry(_)
| ConsoleError::Storage { .. } | ConsoleError::Storage { .. }
@@ -1818,6 +1883,7 @@ impl IntoResponse for ApiError {
ConsoleError::InvalidRequest(_) => (StatusCode::BAD_REQUEST, "invalid_request"), ConsoleError::InvalidRequest(_) => (StatusCode::BAD_REQUEST, "invalid_request"),
ConsoleError::ServiceNotFound(_) => (StatusCode::NOT_FOUND, "service_not_found"), ConsoleError::ServiceNotFound(_) => (StatusCode::NOT_FOUND, "service_not_found"),
ConsoleError::DeploymentNotFound(_) => (StatusCode::NOT_FOUND, "deployment_not_found"), ConsoleError::DeploymentNotFound(_) => (StatusCode::NOT_FOUND, "deployment_not_found"),
ConsoleError::ActiveDeployment(_) => (StatusCode::CONFLICT, "active_deployment"),
ConsoleError::RuntimeNotRunning => (StatusCode::CONFLICT, "runtime_not_running"), ConsoleError::RuntimeNotRunning => (StatusCode::CONFLICT, "runtime_not_running"),
ConsoleError::ArtifactTooLarge { .. } ConsoleError::ArtifactTooLarge { .. }
| ConsoleError::Package(PackageError::ComponentTooLarge { .. }) | ConsoleError::Package(PackageError::ComponentTooLarge { .. })
+19
View File
@@ -5,6 +5,7 @@
//! history, and service-scoped Host KV entries. //! history, and service-scoped Host KV entries.
use std::{ use std::{
collections::BTreeSet,
path::Path, path::Path,
sync::Arc, sync::Arc,
time::{SystemTime, UNIX_EPOCH}, time::{SystemTime, UNIX_EPOCH},
@@ -130,6 +131,15 @@ impl Persistence {
let mut db = self.db.lock().await; let mut db = self.db.lock().await;
let mut tx = db.transaction().await?; let mut tx = db.transaction().await?;
let service_keys = services
.iter()
.map(|service| service.service_key.clone())
.collect::<BTreeSet<_>>();
for stored in StoredService::all().exec(&mut tx).await? {
if !service_keys.contains(&stored.service_key) {
StoredService::delete_by_service_key(&mut tx, &stored.service_key).await?;
}
}
for service in services { for service in services {
StoredService::upsert_by_service_key(service.service_key) StoredService::upsert_by_service_key(service.service_key)
.manifest_toml(service.manifest_toml) .manifest_toml(service.manifest_toml)
@@ -159,6 +169,15 @@ impl Persistence {
.await?; .await?;
} }
let deployment_ids = deployments
.iter()
.map(|deployment| deployment.service_id.clone())
.collect::<BTreeSet<_>>();
for stored in StoredDeployment::all().exec(&mut tx).await? {
if !deployment_ids.contains(&stored.service_id) {
StoredDeployment::delete_by_service_id(&mut tx, &stored.service_id).await?;
}
}
for deployment in deployments { for deployment in deployments {
StoredDeployment::upsert_by_service_id(deployment.service_id) StoredDeployment::upsert_by_service_id(deployment.service_id)
.active_revision(deployment.active_revision) .active_revision(deployment.active_revision)
+76
View File
@@ -261,6 +261,74 @@ async fn routes_raw_gateway_calls_through_the_active_deployment() {
assert_eq!(deployments["deployments"][0]["active_revision"], "0.2.0"); assert_eq!(deployments["deployments"][0]["active_revision"], "0.2.0");
} }
#[tokio::test]
async fn unregisters_only_inactive_service_revisions_and_prunes_persistence() {
let artifact_dir = TempDir::new().expect("temporary artifact directory");
let component = fs::read(echo_component()).expect("echo component should be readable");
{
let application = test_app(artifact_dir.path()).await;
for revision in ["0.1.0", "0.2.0"] {
let response = application
.clone()
.oneshot(package_request("dev-echo", revision, &component))
.await
.expect("register request should complete");
assert_eq!(response.status(), StatusCode::CREATED);
}
let response = application
.clone()
.oneshot(json_request(
"/api/v1/deployments/dev-echo/activate",
json!({ "revision": "0.1.0" }),
))
.await
.expect("activation request should complete");
assert_eq!(response.status(), StatusCode::OK);
let response = application
.clone()
.oneshot(delete_request("/api/v1/services/dev-echo/0.1.0"))
.await
.expect("active unregister request should complete");
assert_eq!(response.status(), StatusCode::CONFLICT);
assert_eq!(
response_json(response).await["error"]["code"],
"active_deployment"
);
let response = application
.clone()
.oneshot(json_request(
"/api/v1/deployments/dev-echo/activate",
json!({ "revision": "0.2.0" }),
))
.await
.expect("replacement activation should complete");
assert_eq!(response.status(), StatusCode::OK);
let response = application
.clone()
.oneshot(delete_request("/api/v1/services/dev-echo/0.1.0"))
.await
.expect("inactive unregister request should complete");
assert_eq!(response.status(), StatusCode::OK);
assert!(!artifact_dir.path().join("dev-echo@0.1.0.wasm").exists());
assert!(!artifact_dir.path().join("dev-echo@0.1.0.toml").exists());
}
let application = test_app(artifact_dir.path()).await;
let response = application
.oneshot(get_request("/api/v1/services"))
.await
.expect("restored service list request should complete");
let body = response_json(response).await;
let services = body["services"].as_array().unwrap();
assert_eq!(services.len(), 1);
assert_eq!(services[0]["revision"], "0.2.0");
}
#[tokio::test] #[tokio::test]
async fn restores_the_active_deployment_with_a_fresh_actor() { async fn restores_the_active_deployment_with_a_fresh_actor() {
let artifact_dir = TempDir::new().expect("temporary artifact directory"); let artifact_dir = TempDir::new().expect("temporary artifact directory");
@@ -888,6 +956,14 @@ fn empty_post(uri: &str) -> Request<Body> {
.unwrap() .unwrap()
} }
fn delete_request(uri: &str) -> Request<Body> {
Request::builder()
.method("DELETE")
.uri(uri)
.body(Body::empty())
.unwrap()
}
fn get_request(uri: &str) -> Request<Body> { fn get_request(uri: &str) -> Request<Body> {
Request::builder().uri(uri).body(Body::empty()).unwrap() Request::builder().uri(uri).body(Body::empty()).unwrap()
} }
+223
View File
@@ -0,0 +1,223 @@
# Wasmeld CLI
`wasmeld-package` 提供 `wasmeld` 命令,用于:
- 开发、编译和打包 WebAssembly Component
- 监听源码并将开发版本自动部署到 Wasmeld Console
- 解析、锁定和替换 WIT Package 依赖
- 构建并发布版本化 WIT Package
Runtime 不接收 Rust 源码。CLI 会先将 Rust 编译为 WebAssembly Component,再生成
`.wasmpkg` 或上传到 Console。
## 运行
在项目工作区内可以直接运行:
```bash
cargo +stable run -p wasmeld-package --bin wasmeld -- <command>
```
Component 默认使用 Rust `1.90.0``wasm32-wasip2` 目标构建。可以通过
`WASMELD_COMPONENT_TOOLCHAIN` 指定其它已安装工具链。
## Component 项目
一个可打包的 Component 至少需要:
```text
component/
├── Cargo.toml
├── wasmeld.toml
├── wit.lock
├── src/lib.rs
└── wit/world.wit
```
`Cargo.toml` 必须提供 `cdylib` Target 和 Wasmeld 元数据:
```toml
[package]
name = "echo-component"
version = "0.1.0"
[package.metadata.wasmeld]
id = "echo"
world = "component:echo/echo-component@0.1.0"
[lib]
crate-type = ["cdylib"]
```
`version` 成为正式包的 Revision`id` 是服务 ID`world` 必须与 Component 实际导出的
WIT World 一致。
## 边写边预览
先启动 Console
```bash
cargo +stable run -p wasmeld-console
```
再启动开发循环:
```bash
cargo +stable run -p wasmeld-package --bin wasmeld -- \
dev components/echo/Cargo.toml
```
开发循环会监听 Rust、Cargo、WIT 清单以及本地 `replace` WIT 源,并执行:
```text
同步 WIT 依赖
→ Debug 编译
→ 生成 .wasmpkg
→ 注册开发 Revision
→ 启动并切换 Deployment
→ 注销上一开发 Revision
```
默认服务 ID 为 `<id>-dev`Revision 为 `<version>-dev.h<component-hash>`。正式服务不会
被覆盖。构建、校验或启动失败时不会切换 Deployment,上一版本继续提供服务。
管理面默认每 5 秒刷新一次,也可以直接调用开发服务:
```bash
curl http://127.0.0.1:8081/v1/services/echo-dev/invoke \
-H 'Content-Type: application/octet-stream' \
--data-binary 'hello'
```
开发选项:
| 选项 | 作用 |
| --- | --- |
| `--console <url>` | Console 管理 API,默认 `http://127.0.0.1:8080` |
| `--id <service-id>` | 覆盖默认的 `<id>-dev` 服务 ID |
| `--once` | 构建、部署一次后退出,适合脚本和 CI |
| `--release` | 使用 Release 而不是 Debug 构建 |
| `--locked` | 要求依赖与现有 `wit.lock` 完全一致 |
| `--poll-ms <n>` | 源码扫描间隔,最小 100 ms,默认 350 ms |
也可以通过 `WASMELD_CONSOLE` 设置管理 API 地址。退出 `wasmeld dev` 不会停止当前开发
Deployment;它会继续运行,直到手工停止、切换或注销。
只修改 Rust 源码且不允许契约变化时使用 `--locked`。需要同步修改本地 WIT `replace`
时不要使用该选项,CLI 会更新 `wit.lock`
## 打包 Component
```bash
cargo +stable run -p wasmeld-package --bin wasmeld -- \
pack components/echo/Cargo.toml --locked
```
默认执行 Release 构建并输出:
```text
dist/echo-0.1.0.wasmpkg
```
可用选项:
| 选项 | 作用 |
| --- | --- |
| `--output <path>``-o <path>` | 指定输出文件 |
| `--no-build` | 使用现有 Release Component 制品 |
| `--locked` | 禁止 WIT 依赖解析结果改变 |
`.wasmpkg` 是受约束的 ZIP,固定包含:
```text
package.toml
component.wasm
```
`package.toml` 记录服务 ID、Revision、World 和 Component SHA-256。运行时资源限制不由
组件声明,而是在 Console 注册时由平台策略注入。
## WIT 依赖
`wasmeld.toml` 使用精确版本:
```toml
schema_version = 1
[wit]
root = "wit"
[registry]
url = "http://127.0.0.1:8080"
[dependencies]
"wasmeld:service" = "0.1.0"
"wasmeld:kv" = "0.1.0"
[replace."wasmeld:service"]
path = "../../wit/service"
```
依赖解析结果写入 `wit.lock`,源码开发时应提交该文件。解析出的 `wit/deps/` 是生成目录,
不应提交。
同步依赖:
```bash
wasmeld wit fetch --manifest components/echo/wasmeld.toml
```
校验锁定结果且禁止变化:
```bash
wasmeld wit fetch --manifest components/echo/wasmeld.toml --locked
```
`wit tidy` 当前与 `wit fetch` 使用相同的确定性解析和物化流程。查看根依赖和有效来源:
```bash
wasmeld wit graph --manifest components/echo/wasmeld.toml
```
添加本地替换:
```bash
wasmeld wit replace wasmeld:service@0.1.0 \
--path ../../wit/service \
--manifest components/echo/wasmeld.toml
```
包级替换可以省略 `@version`。精确版本替换的优先级更高。移除替换:
```bash
wasmeld wit replace wasmeld:service@0.1.0 \
--drop \
--manifest components/echo/wasmeld.toml
```
## WIT Package
将 WIT 源目录构建为标准二进制 WIT Package
```bash
wasmeld wit build wit/service \
--output dist/wit/wasmeld-service-0.1.0.wasm
```
发布到 Console 的不可变 WIT Registry
```bash
wasmeld wit publish wit/service \
--registry http://127.0.0.1:8080
```
Registry 地址也可以通过 `WASMELD_REGISTRY` 设置。相同 Package 版本不可覆盖;协议变化
应发布新版本,并由 Component 在 `wasmeld.toml` 中显式选择。
## 环境变量
| 变量 | 作用 |
| --- | --- |
| `WASMELD_COMPONENT_TOOLCHAIN` | Component Rust 工具链,默认 `1.90.0` |
| `WASMELD_CONSOLE` | `wasmeld dev` 使用的 Console 管理 API |
| `WASMELD_REGISTRY` | `wit publish` 使用的 Registry 地址 |
+431
View File
@@ -0,0 +1,431 @@
//! Local build, watch, and deployment loop for Component development.
use std::{
collections::BTreeSet,
env, fs, io,
path::{Path, PathBuf},
thread,
time::Duration,
};
use reqwest::{
StatusCode,
blocking::{Client, multipart},
};
use serde::Deserialize;
use sha2::{Digest, Sha256};
use wasmeld_package::module::{MODULE_MANIFEST_FILE, ModuleManifest};
use crate::{BuildProfile, PackRequest, PackageIdentity, PackedComponent, pack_component};
const DEFAULT_CONSOLE: &str = "http://127.0.0.1:8080";
const DEFAULT_POLL_MS: u64 = 350;
struct DevOptions {
manifest_path: PathBuf,
console: String,
service_id: Option<String>,
locked: bool,
release: bool,
once: bool,
poll_interval: Duration,
}
#[derive(Debug, Deserialize)]
struct ServiceList {
services: Vec<ServiceIdentity>,
}
#[derive(Debug, Deserialize)]
struct ServiceIdentity {
id: String,
revision: String,
}
#[derive(Debug, Deserialize)]
struct Deployment {
active_revision: String,
}
pub(crate) fn run(arguments: Vec<String>) -> Result<(), Box<dyn std::error::Error>> {
let options = parse_options(arguments)?;
let manifest_path = fs::canonicalize(&options.manifest_path)?;
let watch_roots = watch_roots(&manifest_path)?;
let client = Client::builder()
.user_agent(format!("wasmeld-dev/{}", env!("CARGO_PKG_VERSION")))
.timeout(Duration::from_secs(120))
.build()?;
ensure_console(&client, &options.console)?;
let temporary_dir = env::temp_dir().join(format!("wasmeld-dev-{}", std::process::id()));
fs::create_dir_all(&temporary_dir)?;
let output = temporary_dir.join("component.wasmpkg");
let mut observed = None;
println!("watching:");
for root in &watch_roots {
println!(" {}", root.display());
}
println!("console: {}", options.console);
loop {
let current = source_fingerprint(&watch_roots)?;
if observed.as_ref() == Some(&current) {
thread::sleep(options.poll_interval);
continue;
}
println!("change detected; building Component");
let result = build_and_deploy(&client, &options, &manifest_path, &output);
observed = Some(source_fingerprint(&watch_roots)?);
match result {
Ok(packed) => {
println!(
"ready: {}@{} ({})",
packed.manifest.id, packed.manifest.revision, packed.manifest.sha256
);
if options.once {
return Ok(());
}
}
Err(error) if options.once => return Err(error),
Err(error) => {
eprintln!("wasmeld dev: {error}");
eprintln!("waiting for the next source change; the previous deployment is intact");
}
}
}
}
fn parse_options(arguments: Vec<String>) -> Result<DevOptions, Box<dyn std::error::Error>> {
let mut arguments = arguments.into_iter();
let manifest_path = arguments.next().ok_or("dev requires a Cargo.toml path")?;
let mut console = env::var("WASMELD_CONSOLE").unwrap_or_else(|_| DEFAULT_CONSOLE.to_owned());
let mut service_id = None;
let mut locked = false;
let mut release = false;
let mut once = false;
let mut poll_ms = DEFAULT_POLL_MS;
while let Some(argument) = arguments.next() {
match argument.as_str() {
"--console" => {
console = arguments.next().ok_or("--console requires an HTTP URL")?;
}
"--id" => {
service_id = Some(arguments.next().ok_or("--id requires a service ID")?);
}
"--locked" => locked = true,
"--release" => release = true,
"--once" => once = true,
"--poll-ms" => {
poll_ms = arguments
.next()
.ok_or("--poll-ms requires a number")?
.parse::<u64>()?;
if poll_ms < 100 {
return Err("--poll-ms must be at least 100".into());
}
}
_ => return Err(format!("unknown dev argument {argument:?}").into()),
}
}
Ok(DevOptions {
manifest_path: PathBuf::from(manifest_path),
console: console.trim_end_matches('/').to_owned(),
service_id,
locked,
release,
once,
poll_interval: Duration::from_millis(poll_ms),
})
}
fn build_and_deploy(
client: &Client,
options: &DevOptions,
manifest_path: &Path,
output: &Path,
) -> Result<PackedComponent, Box<dyn std::error::Error>> {
let packed = pack_component(PackRequest {
manifest_path: manifest_path.to_owned(),
output: Some(output.to_owned()),
no_build: false,
locked: options.locked,
profile: if options.release {
BuildProfile::Release
} else {
BuildProfile::Debug
},
identity: PackageIdentity::Development {
id: options.service_id.clone(),
},
})?;
let previous = active_revision(client, &options.console, &packed.manifest.id)?;
register(client, &options.console, &packed)?;
activate(
client,
&options.console,
&packed.manifest.id,
&packed.manifest.revision,
)?;
if let Some(previous) = previous
&& previous != packed.manifest.revision
&& is_matching_dev_revision(&previous, &packed.manifest.revision)
&& let Err(error) = unregister(client, &options.console, &packed.manifest.id, &previous)
{
eprintln!(
"wasmeld dev: deployed successfully but could not unregister {}@{}: {error}",
packed.manifest.id, previous
);
}
Ok(packed)
}
fn ensure_console(client: &Client, console: &str) -> Result<(), Box<dyn std::error::Error>> {
let response = client.get(format!("{console}/healthz")).send()?;
if response.status().is_success() {
Ok(())
} else {
Err(http_error("Console health check", response))
}
}
fn active_revision(
client: &Client,
console: &str,
service_id: &str,
) -> Result<Option<String>, Box<dyn std::error::Error>> {
let response = client
.get(format!("{console}/api/v1/deployments/{service_id}"))
.send()?;
match response.status() {
status if status.is_success() => Ok(Some(response.json::<Deployment>()?.active_revision)),
StatusCode::NOT_FOUND => Ok(None),
_ => Err(http_error("deployment lookup", response)),
}
}
fn register(
client: &Client,
console: &str,
packed: &PackedComponent,
) -> Result<(), Box<dyn std::error::Error>> {
let package = fs::read(&packed.output)?;
let part = multipart::Part::bytes(package)
.file_name("component.wasmpkg")
.mime_str("application/vnd.wasmeld.package")?;
let response = client
.post(format!("{console}/api/v1/services"))
.multipart(multipart::Form::new().part("package", part))
.send()?;
if response.status().is_success() {
return Ok(());
}
if response.status() == StatusCode::CONFLICT
&& service_exists(
client,
console,
&packed.manifest.id,
&packed.manifest.revision,
)?
{
return Ok(());
}
Err(http_error("Component registration", response))
}
fn service_exists(
client: &Client,
console: &str,
service_id: &str,
revision: &str,
) -> Result<bool, Box<dyn std::error::Error>> {
let response = client.get(format!("{console}/api/v1/services")).send()?;
if !response.status().is_success() {
return Err(http_error("service lookup", response));
}
Ok(response
.json::<ServiceList>()?
.services
.iter()
.any(|service| service.id == service_id && service.revision == revision))
}
fn activate(
client: &Client,
console: &str,
service_id: &str,
revision: &str,
) -> Result<(), Box<dyn std::error::Error>> {
let response = client
.post(format!(
"{console}/api/v1/deployments/{service_id}/activate"
))
.json(&serde_json::json!({ "revision": revision }))
.send()?;
if response.status().is_success() {
Ok(())
} else {
Err(http_error("development deployment", response))
}
}
fn unregister(
client: &Client,
console: &str,
service_id: &str,
revision: &str,
) -> Result<(), Box<dyn std::error::Error>> {
let response = client
.delete(format!("{console}/api/v1/services/{service_id}/{revision}"))
.send()?;
if response.status().is_success() || response.status() == StatusCode::NOT_FOUND {
Ok(())
} else {
Err(http_error("old development revision cleanup", response))
}
}
fn http_error(action: &str, response: reqwest::blocking::Response) -> Box<dyn std::error::Error> {
let status = response.status();
let body = response
.text()
.unwrap_or_else(|error| format!("failed to read response: {error}"));
format!("{action} returned HTTP {status}: {body}").into()
}
fn is_matching_dev_revision(previous: &str, current: &str) -> bool {
let Some((previous_base, previous_hash)) = previous.rsplit_once("-dev.h") else {
return false;
};
let Some((current_base, current_hash)) = current.rsplit_once("-dev.h") else {
return false;
};
previous_base == current_base && valid_dev_hash(previous_hash) && valid_dev_hash(current_hash)
}
fn valid_dev_hash(value: &str) -> bool {
value.len() == 12 && value.bytes().all(|byte| byte.is_ascii_hexdigit())
}
fn watch_roots(manifest_path: &Path) -> Result<Vec<PathBuf>, Box<dyn std::error::Error>> {
let component_root = manifest_path
.parent()
.ok_or("Cargo manifest must have a parent directory")?
.to_owned();
let mut roots = BTreeSet::from([component_root.clone()]);
let module_path = component_root.join(MODULE_MANIFEST_FILE);
if module_path.is_file() {
let module = ModuleManifest::read(&module_path)?;
for replacement in module.replacements.values() {
let path = component_root.join(&replacement.path);
if path.exists() {
roots.insert(fs::canonicalize(path)?);
}
}
}
Ok(roots.into_iter().collect())
}
fn source_fingerprint(roots: &[PathBuf]) -> Result<Vec<u8>, Box<dyn std::error::Error>> {
let mut files = BTreeSet::new();
for root in roots {
collect_source_files(root, &mut files)?;
}
let mut digest = Sha256::new();
for path in files {
digest.update(path.to_string_lossy().as_bytes());
match fs::read(&path) {
Ok(bytes) => digest.update(bytes),
Err(error) if error.kind() == io::ErrorKind::NotFound => {}
Err(error) => return Err(error.into()),
}
}
Ok(digest.finalize().to_vec())
}
fn collect_source_files(
path: &Path,
files: &mut BTreeSet<PathBuf>,
) -> Result<(), Box<dyn std::error::Error>> {
if path.is_dir() {
if ignored_directory(path) {
return Ok(());
}
for entry in fs::read_dir(path)? {
collect_source_files(&entry?.path(), files)?;
}
} else if is_source_file(path) {
files.insert(path.to_owned());
}
Ok(())
}
fn ignored_directory(path: &Path) -> bool {
let name = path.file_name().and_then(|name| name.to_str());
if matches!(
name,
Some(".git" | ".wasmeld" | "deps" | "dist" | "node_modules" | "target")
) {
return name != Some("deps")
|| path
.parent()
.and_then(Path::file_name)
.and_then(|name| name.to_str())
== Some("wit");
}
false
}
fn is_source_file(path: &Path) -> bool {
matches!(
path.extension().and_then(|extension| extension.to_str()),
Some("rs" | "wit")
) || matches!(
path.file_name().and_then(|name| name.to_str()),
Some("Cargo.lock" | "Cargo.toml" | "config.toml" | "wasmeld.toml" | "wit.lock")
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn recognizes_only_matching_hash_development_revisions() {
assert!(is_matching_dev_revision(
"0.1.0-dev.h0123456789ab",
"0.1.0-dev.habcdef012345"
));
assert!(!is_matching_dev_revision(
"0.2.0-dev.h0123456789ab",
"0.1.0-dev.habcdef012345"
));
assert!(!is_matching_dev_revision(
"0.1.0",
"0.1.0-dev.habcdef012345"
));
}
#[test]
fn fingerprints_sources_but_ignores_materialized_wit_dependencies() {
let root = tempfile::tempdir().unwrap();
let source = root.path().join("src/lib.rs");
let generated = root.path().join("wit/deps/example/package.wit");
fs::create_dir_all(source.parent().unwrap()).unwrap();
fs::create_dir_all(generated.parent().unwrap()).unwrap();
fs::write(&source, "pub fn first() {}").unwrap();
fs::write(&generated, "package generated:dependency@1.0.0;").unwrap();
let roots = vec![root.path().to_owned()];
let initial = source_fingerprint(&roots).unwrap();
fs::write(&generated, "package generated:dependency@2.0.0;").unwrap();
assert_eq!(source_fingerprint(&roots).unwrap(), initial);
fs::write(&source, "pub fn second() {}").unwrap();
assert_ne!(source_fingerprint(&roots).unwrap(), initial);
}
}
+108 -28
View File
@@ -1,5 +1,7 @@
//! Command-line entry point for Component packaging and WIT dependency workflows. //! Command-line entry point for Component packaging and WIT dependency workflows.
mod dev;
use std::{ use std::{
env, fs, env, fs,
path::{Path, PathBuf}, path::{Path, PathBuf},
@@ -7,6 +9,7 @@ use std::{
}; };
use serde::Deserialize; use serde::Deserialize;
use sha2::{Digest, Sha256};
use wasmeld_package::{ use wasmeld_package::{
PackageManifest, PackageManifest,
module::{MODULE_MANIFEST_FILE, ModuleManifest, find_module_manifest, sync_dependencies}, module::{MODULE_MANIFEST_FILE, ModuleManifest, find_module_manifest, sync_dependencies},
@@ -16,6 +19,41 @@ use wasmeld_package::{
const TARGET: &str = "wasm32-wasip2"; const TARGET: &str = "wasm32-wasip2";
#[derive(Clone, Copy)]
pub(crate) enum BuildProfile {
Debug,
Release,
}
impl BuildProfile {
fn directory(self) -> &'static str {
match self {
Self::Debug => "debug",
Self::Release => "release",
}
}
}
pub(crate) enum PackageIdentity {
Cargo,
Development { id: Option<String> },
}
pub(crate) struct PackRequest {
pub manifest_path: PathBuf,
pub output: Option<PathBuf>,
pub no_build: bool,
pub locked: bool,
pub profile: BuildProfile,
pub identity: PackageIdentity,
}
pub(crate) struct PackedComponent {
pub manifest: PackageManifest,
pub output: PathBuf,
pub artifact: PathBuf,
}
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
struct CargoMetadata { struct CargoMetadata {
packages: Vec<CargoPackage>, packages: Vec<CargoPackage>,
@@ -51,6 +89,7 @@ fn run() -> Result<(), Box<dyn std::error::Error>> {
let mut arguments = env::args().skip(1); let mut arguments = env::args().skip(1);
match arguments.next().as_deref() { match arguments.next().as_deref() {
Some("pack") => run_pack(arguments.collect()), Some("pack") => run_pack(arguments.collect()),
Some("dev") => dev::run(arguments.collect()),
Some("wit") => run_wit(arguments.collect()), Some("wit") => run_wit(arguments.collect()),
_ => Err(usage().into()), _ => Err(usage().into()),
} }
@@ -78,69 +117,102 @@ fn run_pack(arguments: Vec<String>) -> Result<(), Box<dyn std::error::Error>> {
} }
} }
let manifest_path = fs::canonicalize(manifest_path)?; let packed = pack_component(PackRequest {
sync_component_dependencies(&manifest_path, locked)?; manifest_path: PathBuf::from(manifest_path),
output,
no_build,
locked,
profile: BuildProfile::Release,
identity: PackageIdentity::Cargo,
})?;
println!(
"packed {}@{} -> {}",
packed.manifest.id,
packed.manifest.revision,
packed.output.display()
);
println!("component: {}", packed.artifact.display());
println!("sha256: {}", packed.manifest.sha256);
Ok(())
}
pub(crate) fn pack_component(
request: PackRequest,
) -> Result<PackedComponent, Box<dyn std::error::Error>> {
let manifest_path = fs::canonicalize(request.manifest_path)?;
sync_component_dependencies(&manifest_path, request.locked)?;
let metadata = cargo_metadata(&manifest_path)?; let metadata = cargo_metadata(&manifest_path)?;
let package = metadata let package = metadata
.packages .packages
.iter() .iter()
.find(|package| package.manifest_path == manifest_path) .find(|package| package.manifest_path == manifest_path)
.ok_or("Cargo metadata did not contain the requested package")?; .ok_or("Cargo metadata did not contain the requested package")?;
let id = package let cargo_id = package
.metadata .metadata
.get("wasmeld") .get("wasmeld")
.and_then(|metadata| metadata.get("id")) .and_then(|metadata| metadata.get("id"))
.and_then(serde_json::Value::as_str) .and_then(serde_json::Value::as_str)
.ok_or("[package.metadata.wasmeld].id is required")?; .ok_or("[package.metadata.wasmeld].id is required")?
.to_owned();
let world = package let world = package
.metadata .metadata
.get("wasmeld") .get("wasmeld")
.and_then(|metadata| metadata.get("world")) .and_then(|metadata| metadata.get("world"))
.and_then(serde_json::Value::as_str) .and_then(serde_json::Value::as_str)
.ok_or("[package.metadata.wasmeld].world is required")?; .ok_or("[package.metadata.wasmeld].world is required")?
PackageManifest::new(id, &package.version, world, b"identity-validation").validate()?; .to_owned();
let cargo_revision = package.version.clone();
let target = package let target = package
.targets .targets
.iter() .iter()
.find(|target| target.crate_types.iter().any(|kind| kind == "cdylib")) .find(|target| target.crate_types.iter().any(|kind| kind == "cdylib"))
.ok_or("component package must expose a cdylib target")?; .ok_or("component package must expose a cdylib target")?
.name
.clone();
if !no_build { if !request.no_build {
build_component(&manifest_path)?; build_component(&manifest_path, request.profile)?;
} }
let artifact = metadata let artifact = metadata
.target_directory .target_directory
.join(TARGET) .join(TARGET)
.join("release") .join(request.profile.directory())
.join(format!("{}.wasm", target.name.replace('-', "_"))); .join(format!("{}.wasm", target.replace('-', "_")));
let component = fs::read(&artifact).map_err(|error| { let component = fs::read(&artifact).map_err(|error| {
format!( format!(
"failed to read built component {}: {error}", "failed to read built component {}: {error}",
artifact.display() artifact.display()
) )
})?; })?;
let output = output.unwrap_or_else(|| { let (id, revision) = match request.identity {
PackageIdentity::Cargo => (cargo_id, cargo_revision),
PackageIdentity::Development { id } => {
let digest = format!("{:x}", Sha256::digest(&component));
(
id.unwrap_or_else(|| format!("{cargo_id}-dev")),
format!("{cargo_revision}-dev.h{}", &digest[..12]),
)
}
};
PackageManifest::new(&id, &revision, &world, b"identity-validation").validate()?;
let output = request.output.unwrap_or_else(|| {
metadata metadata
.workspace_root .workspace_root
.join("dist") .join("dist")
.join(format!("{id}-{}.wasmpkg", package.version)) .join(format!("{id}-{revision}.wasmpkg"))
}); });
if let Some(parent) = output.parent() { if let Some(parent) = output.parent() {
fs::create_dir_all(parent)?; fs::create_dir_all(parent)?;
} }
let file = fs::File::create(&output)?; let file = fs::File::create(&output)?;
let manifest = write_package(file, id, &package.version, world, &component)?; let manifest = write_package(file, id, revision, world, &component)?;
Ok(PackedComponent {
println!( manifest,
"packed {}@{} -> {}", output,
manifest.id, artifact,
manifest.revision, })
output.display()
);
println!("component: {}", artifact.display());
println!("sha256: {}", manifest.sha256);
Ok(())
} }
fn run_wit(arguments: Vec<String>) -> Result<(), Box<dyn std::error::Error>> { fn run_wit(arguments: Vec<String>) -> Result<(), Box<dyn std::error::Error>> {
@@ -426,14 +498,21 @@ fn cargo_metadata(manifest_path: &Path) -> Result<CargoMetadata, Box<dyn std::er
Ok(serde_json::from_slice(&output.stdout)?) Ok(serde_json::from_slice(&output.stdout)?)
} }
fn build_component(manifest_path: &Path) -> Result<(), Box<dyn std::error::Error>> { fn build_component(
manifest_path: &Path,
profile: BuildProfile,
) -> Result<(), Box<dyn std::error::Error>> {
let toolchain = env::var("WASMELD_COMPONENT_TOOLCHAIN").unwrap_or_else(|_| "1.90.0".to_owned()); let toolchain = env::var("WASMELD_COMPONENT_TOOLCHAIN").unwrap_or_else(|_| "1.90.0".to_owned());
let status = Command::new("rustup") let mut command = Command::new("rustup");
command
.args(["run", &toolchain, "cargo", "build"]) .args(["run", &toolchain, "cargo", "build"])
.arg("--manifest-path") .arg("--manifest-path")
.arg(manifest_path) .arg(manifest_path)
.args(["--target", TARGET, "--release"]) .args(["--target", TARGET]);
.status()?; if matches!(profile, BuildProfile::Release) {
command.arg("--release");
}
let status = command.status()?;
if !status.success() { if !status.success() {
return Err(format!("component build failed with status {status}").into()); return Err(format!("component build failed with status {status}").into());
} }
@@ -443,6 +522,7 @@ fn build_component(manifest_path: &Path) -> Result<(), Box<dyn std::error::Error
fn usage() -> String { fn usage() -> String {
"usage: "usage:
wasmeld pack <Cargo.toml> [--output <file.wasmpkg>] [--no-build] [--locked] wasmeld pack <Cargo.toml> [--output <file.wasmpkg>] [--no-build] [--locked]
wasmeld dev <Cargo.toml> [--console <url>] [--id <service-id>] [--locked] [--release] [--once] [--poll-ms <milliseconds>]
wasmeld wit fetch [--manifest <wasmeld.toml>] [--locked] wasmeld wit fetch [--manifest <wasmeld.toml>] [--locked]
wasmeld wit tidy [--manifest <wasmeld.toml>] [--locked] wasmeld wit tidy [--manifest <wasmeld.toml>] [--locked]
wasmeld wit graph [--manifest <wasmeld.toml>] wasmeld wit graph [--manifest <wasmeld.toml>]
+49
View File
@@ -390,6 +390,55 @@ impl Runtime {
self.start(&key, init_config) self.start(&key, init_config)
} }
/// Stops and removes one registered service revision.
///
/// Callers must switch any external deployment reference away from this
/// revision before unregistering it.
pub fn unregister(&self, key: &ServiceKey) -> Result<(), RuntimeError> {
let _lifecycle = self
.inner
.lifecycle
.lock()
.map_err(|_| RuntimeError::LockPoisoned)?;
if !self
.inner
.services
.lock()
.map_err(|_| RuntimeError::LockPoisoned)?
.contains_key(key)
{
return Err(RuntimeError::ServiceNotRegistered(key.clone()));
}
let actor = self
.inner
.actors
.lock()
.map_err(|_| RuntimeError::LockPoisoned)?
.get(key)
.cloned();
if let Some(actor) = actor {
if actor.is_available() {
actor.stop()?;
} else if actor.is_alive() {
return Err(RuntimeError::ActorUnavailable(key.clone()));
}
self.inner
.actors
.lock()
.map_err(|_| RuntimeError::LockPoisoned)?
.remove(key);
}
self.inner
.services
.lock()
.map_err(|_| RuntimeError::LockPoisoned)?
.remove(key);
Ok(())
}
/// Returns counts without exposing internal Engine or Store state. /// Returns counts without exposing internal Engine or Store state.
pub fn stats(&self) -> Result<RuntimeStats, RuntimeError> { pub fn stats(&self) -> Result<RuntimeStats, RuntimeError> {
let registered_services = self let registered_services = self
@@ -283,6 +283,23 @@ fn kv_capability_requires_a_backend_and_enforces_limits() {
runtime.stop(&key).unwrap(); runtime.stop(&key).unwrap();
} }
#[test]
fn unregister_stops_actor_and_releases_compiled_revision() {
let runtime = Runtime::new(RuntimeConfig::default()).expect("runtime should start");
let key = register_component(&runtime, "unregister", "echo_component.wasm", test_limits());
runtime.start(&key, Vec::new()).unwrap();
runtime.unregister(&key).unwrap();
let stats = runtime.stats().unwrap();
assert_eq!(stats.registered_services, 0);
assert_eq!(stats.running_actors, 0);
assert!(matches!(
runtime.start(&key, Vec::new()),
Err(RuntimeError::ServiceNotRegistered(missing)) if missing == key
));
}
#[test] #[test]
fn registration_rejects_non_whitelisted_wasi_imports() { fn registration_rejects_non_whitelisted_wasi_imports() {
let runtime = Runtime::new(RuntimeConfig::default()).expect("runtime should start"); let runtime = Runtime::new(RuntimeConfig::default()).expect("runtime should start");