Compare commits
25 Commits
c399781369
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 2650b8ef14 | |||
| c982753eb8 | |||
| 2c6e761288 | |||
| 839874fb56 | |||
| 201d99c7e0 | |||
| d0d2e06f68 | |||
| 778fbd2a06 | |||
| 71654a8bcf | |||
| a325c60421 | |||
| 8d8d136510 | |||
| 044b64a9c7 | |||
| 5edf185c90 | |||
| 72c901c478 | |||
| 51b452ecc5 | |||
| bdbf868f75 | |||
| 72a264e0d1 | |||
| 5b32886efe | |||
| 3f5bb344f7 | |||
| 9160362fc0 | |||
| 1d3fac4be8 | |||
| a872008d44 | |||
| 8de300b634 | |||
| 12992186a9 | |||
| cfac7d85f0 | |||
| 893895a76c |
@@ -4,3 +4,4 @@
|
|||||||
/.idea/
|
/.idea/
|
||||||
/components/*/wit/deps/
|
/components/*/wit/deps/
|
||||||
/components/*/wit/.deps.*
|
/components/*/wit/.deps.*
|
||||||
|
/docs/design/
|
||||||
|
|||||||
Generated
+7
@@ -1995,6 +1995,13 @@ dependencies = [
|
|||||||
"wasm-bindgen",
|
"wasm-bindgen",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "kv-probe-component"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"wit-bindgen 0.41.0",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "lazy_static"
|
name = "lazy_static"
|
||||||
version = "1.5.0"
|
version = "1.5.0"
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ members = [
|
|||||||
"components/fault",
|
"components/fault",
|
||||||
"components/spin",
|
"components/spin",
|
||||||
"components/clock-probe",
|
"components/clock-probe",
|
||||||
|
"components/kv-probe",
|
||||||
"components/wasi-clock-probe",
|
"components/wasi-clock-probe",
|
||||||
]
|
]
|
||||||
default-members = ["crates/wasmeld-runtime"]
|
default-members = ["crates/wasmeld-runtime"]
|
||||||
|
|||||||
@@ -4,6 +4,15 @@ Wasmeld 是面向 WebAssembly Component 的服务运行与管理平台。内部
|
|||||||
Component,在受限 Wasmtime Sandbox 中以常驻 Actor 运行,并通过稳定 WIT 契约
|
Component,在受限 Wasmtime Sandbox 中以常驻 Actor 运行,并通过稳定 WIT 契约
|
||||||
暴露能力。
|
暴露能力。
|
||||||
|
|
||||||
|
Runtime 内置版本化 Host Capability Registry。注册 Component 时会直接读取其 WIT
|
||||||
|
imports,只链接实际请求且版本完全匹配的 Host 能力;未知能力会被拒绝。能力不需要在
|
||||||
|
`.wasmpkg` 中重复声明,管理面会展示每个服务版本解析出的完整接口标识。
|
||||||
|
|
||||||
|
当前 Host 能力:
|
||||||
|
|
||||||
|
- `wasmeld:clock/monotonic-clock@0.1.0`:Actor 内单调时钟
|
||||||
|
- `wasmeld:kv/store@0.1.0`:按服务隔离、跨 Revision 共享的持久化二进制 KV
|
||||||
|
|
||||||
## 结构
|
## 结构
|
||||||
|
|
||||||
```text
|
```text
|
||||||
@@ -23,6 +32,23 @@ wit/ Wasmeld WIT package 源码
|
|||||||
cargo +stable run -p wasmeld-console
|
cargo +stable run -p wasmeld-console
|
||||||
```
|
```
|
||||||
|
|
||||||
|
后端会启动本地管理 API `127.0.0.1:8080` 和公开数据面
|
||||||
|
`0.0.0.0:8081`。注册 Component 后,通过管理 API 激活一个版本:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST http://127.0.0.1:8080/api/v1/deployments/echo/activate \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d '{"revision":"0.1.0"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
客户端只访问 Gateway,并以原始二进制请求调用活动版本:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl http://127.0.0.1:8081/v1/services/echo/invoke \
|
||||||
|
-H 'Content-Type: application/octet-stream' \
|
||||||
|
--data-binary 'hello'
|
||||||
|
```
|
||||||
|
|
||||||
管理面:
|
管理面:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -30,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
|
||||||
@@ -37,6 +82,16 @@ cargo run -p wasmeld-package --bin wasmeld -- \
|
|||||||
pack components/echo/Cargo.toml --locked
|
pack components/echo/Cargo.toml --locked
|
||||||
```
|
```
|
||||||
|
|
||||||
|
KV 示例组件:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo run -p wasmeld-package --bin wasmeld -- \
|
||||||
|
pack components/kv-probe/Cargo.toml --locked
|
||||||
|
```
|
||||||
|
|
||||||
|
`kv-probe` 接受 `set:<key>:<value>`、`get:<key>` 和 `delete:<key>`,用于验证 Host KV
|
||||||
|
能力;它不是公开 Gateway 的业务协议。
|
||||||
|
|
||||||
发布 WIT Package:
|
发布 WIT Package:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -46,5 +101,7 @@ 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 来源。
|
||||||
[`docs/design/wit-package-management.md`](docs/design/wit-package-management.md)。
|
|
||||||
|
完整命令、开发选项、组件包格式和 WIT 依赖说明见
|
||||||
|
[`wasmeld-package` CLI 文档](crates/wasmeld-package/README.md)。
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
[package]
|
||||||
|
name = "kv-probe-component"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition.workspace = true
|
||||||
|
rust-version.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
|
||||||
|
[package.metadata.wasmeld]
|
||||||
|
id = "kv-probe"
|
||||||
|
world = "component:kv-probe/kv-probe-component@0.1.0"
|
||||||
|
|
||||||
|
[lib]
|
||||||
|
crate-type = ["cdylib"]
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
wit-bindgen.workspace = true
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
mod bindings {
|
||||||
|
wit_bindgen::generate!({
|
||||||
|
path: "wit",
|
||||||
|
world: "kv-probe-component",
|
||||||
|
generate_all,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
use bindings::wasmeld::kv::store;
|
||||||
|
|
||||||
|
struct KvProbe;
|
||||||
|
|
||||||
|
impl bindings::Guest for KvProbe {
|
||||||
|
fn init(_config: Vec<u8>) -> Result<(), bindings::ServiceError> {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn invoke(input: Vec<u8>) -> Result<Vec<u8>, bindings::ServiceError> {
|
||||||
|
if let Some(key) = input.strip_prefix(b"get:") {
|
||||||
|
let key = parse_key(key)?;
|
||||||
|
return store::get(&key)
|
||||||
|
.map(|value| value.unwrap_or_default())
|
||||||
|
.map_err(kv_error);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(command) = input.strip_prefix(b"set:") {
|
||||||
|
let separator = command
|
||||||
|
.iter()
|
||||||
|
.position(|byte| *byte == b':')
|
||||||
|
.ok_or_else(|| {
|
||||||
|
bindings::ServiceError::CallFailed(
|
||||||
|
"set command must be set:<key>:<value>".to_owned(),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
let key = parse_key(&command[..separator])?;
|
||||||
|
let value = command[separator + 1..].to_vec();
|
||||||
|
store::set(&key, &value).map_err(kv_error)?;
|
||||||
|
return Ok(Vec::new());
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(key) = input.strip_prefix(b"delete:") {
|
||||||
|
let key = parse_key(key)?;
|
||||||
|
store::delete(&key).map_err(kv_error)?;
|
||||||
|
return Ok(Vec::new());
|
||||||
|
}
|
||||||
|
|
||||||
|
Err(bindings::ServiceError::CallFailed(
|
||||||
|
"expected get:<key>, set:<key>:<value>, or delete:<key>".to_owned(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_key(bytes: &[u8]) -> Result<String, bindings::ServiceError> {
|
||||||
|
core::str::from_utf8(bytes)
|
||||||
|
.map(str::to_owned)
|
||||||
|
.map_err(|_| bindings::ServiceError::CallFailed("key must be valid UTF-8".to_owned()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn kv_error(error: store::KvError) -> bindings::ServiceError {
|
||||||
|
bindings::ServiceError::CallFailed(format!("KV operation failed: {error:?}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
bindings::export!(KvProbe with_types_in bindings);
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
schema_version = 1
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
"wasmeld:kv" = "0.1.0"
|
||||||
|
"wasmeld:service" = "0.1.0"
|
||||||
|
|
||||||
|
[replace."wasmeld:kv"]
|
||||||
|
path = "../../wit/kv"
|
||||||
|
|
||||||
|
[replace."wasmeld:service"]
|
||||||
|
path = "../../wit/service"
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
schema_version = 1
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wasmeld:kv"
|
||||||
|
version = "0.1.0"
|
||||||
|
source = "path+../../wit/kv"
|
||||||
|
sha256 = "6dc3a84dc03c7104aa8b305518466c95e4440e1031c4bef3b59843f40f24d0fb"
|
||||||
|
replaced = true
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wasmeld:service"
|
||||||
|
version = "0.1.0"
|
||||||
|
source = "path+../../wit/service"
|
||||||
|
sha256 = "d5497307bbcd1e159f7707f385b488a6f2e26362d5256d5bc1080ac603a51305"
|
||||||
|
replaced = true
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
package component:kv-probe@0.1.0;
|
||||||
|
|
||||||
|
world kv-probe-component {
|
||||||
|
include wasmeld:service/service-component@0.1.0;
|
||||||
|
import wasmeld:kv/store@0.1.0;
|
||||||
|
}
|
||||||
+30
-2
@@ -4,11 +4,19 @@ const API_BASE_STORAGE_KEY = "wasmeld-api-base";
|
|||||||
|
|
||||||
export type BackendServiceStatus = "running" | "stopped" | "faulted";
|
export type BackendServiceStatus = "running" | "stopped" | "faulted";
|
||||||
|
|
||||||
|
export type BackendCapability = {
|
||||||
|
interface: string;
|
||||||
|
package: string;
|
||||||
|
name: string;
|
||||||
|
version: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type BackendService = {
|
export type BackendService = {
|
||||||
id: string;
|
id: string;
|
||||||
revision: string;
|
revision: string;
|
||||||
artifact: string;
|
artifact: string;
|
||||||
world: string;
|
world: string;
|
||||||
|
capabilities: BackendCapability[];
|
||||||
status: BackendServiceStatus;
|
status: BackendServiceStatus;
|
||||||
updated_at_ms: number;
|
updated_at_ms: number;
|
||||||
limits: {
|
limits: {
|
||||||
@@ -27,7 +35,7 @@ export type BackendService = {
|
|||||||
export type BackendEvent = {
|
export type BackendEvent = {
|
||||||
id: number;
|
id: number;
|
||||||
timestamp_ms: number;
|
timestamp_ms: number;
|
||||||
kind: "registered" | "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;
|
||||||
@@ -41,6 +49,13 @@ export type BackendRuntime = {
|
|||||||
running_services: number;
|
running_services: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type BackendDeployment = {
|
||||||
|
service_id: string;
|
||||||
|
active_revision: string;
|
||||||
|
status: BackendServiceStatus;
|
||||||
|
updated_at_ms: number;
|
||||||
|
};
|
||||||
|
|
||||||
export type BackendWitDependency = {
|
export type BackendWitDependency = {
|
||||||
name: string;
|
name: string;
|
||||||
version: string;
|
version: string;
|
||||||
@@ -80,20 +95,33 @@ export function saveApiBase(value: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchSnapshot(apiBase: string) {
|
export async function fetchSnapshot(apiBase: string) {
|
||||||
const [services, events, runtime, witPackages] = await Promise.all([
|
const [services, deployments, events, runtime, witPackages] = await Promise.all([
|
||||||
request<{ services: BackendService[] }>(apiBase, "/api/v1/services"),
|
request<{ services: BackendService[] }>(apiBase, "/api/v1/services"),
|
||||||
|
request<{ deployments: BackendDeployment[] }>(apiBase, "/api/v1/deployments"),
|
||||||
request<{ events: BackendEvent[] }>(apiBase, "/api/v1/events"),
|
request<{ events: BackendEvent[] }>(apiBase, "/api/v1/events"),
|
||||||
request<BackendRuntime>(apiBase, "/api/v1/runtime"),
|
request<BackendRuntime>(apiBase, "/api/v1/runtime"),
|
||||||
request<{ packages: BackendWitPackage[] }>(apiBase, "/api/v1/wit/packages"),
|
request<{ packages: BackendWitPackage[] }>(apiBase, "/api/v1/wit/packages"),
|
||||||
]);
|
]);
|
||||||
return {
|
return {
|
||||||
services: services.services,
|
services: services.services,
|
||||||
|
deployments: deployments.deployments,
|
||||||
events: events.events,
|
events: events.events,
|
||||||
runtime,
|
runtime,
|
||||||
witPackages: witPackages.packages,
|
witPackages: witPackages.packages,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function activateDeployment(
|
||||||
|
apiBase: string,
|
||||||
|
service: Pick<BackendService, "id" | "revision">,
|
||||||
|
): Promise<BackendDeployment> {
|
||||||
|
return request(apiBase, `/api/v1/deployments/${encodeURIComponent(service.id)}/activate`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify({ revision: service.revision }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export async function registerComponent(
|
export async function registerComponent(
|
||||||
apiBase: string,
|
apiBase: string,
|
||||||
input: RegisterComponentInput,
|
input: RegisterComponentInput,
|
||||||
|
|||||||
+290
-20
@@ -18,6 +18,7 @@ import {
|
|||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
Package,
|
Package,
|
||||||
Play,
|
Play,
|
||||||
|
RadioTower,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
RotateCcw,
|
RotateCcw,
|
||||||
Search,
|
Search,
|
||||||
@@ -30,12 +31,15 @@ import {
|
|||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { FormEvent, ReactNode, useCallback, useEffect, useMemo, useState } from "react";
|
import { FormEvent, ReactNode, useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import {
|
import {
|
||||||
|
BackendCapability,
|
||||||
BackendEvent,
|
BackendEvent,
|
||||||
|
BackendDeployment,
|
||||||
BackendRuntime,
|
BackendRuntime,
|
||||||
BackendService,
|
BackendService,
|
||||||
BackendWitPackage,
|
BackendWitPackage,
|
||||||
InvokeResult,
|
InvokeResult,
|
||||||
RegisterComponentInput,
|
RegisterComponentInput,
|
||||||
|
activateDeployment,
|
||||||
changeRuntimeState,
|
changeRuntimeState,
|
||||||
changeServiceState,
|
changeServiceState,
|
||||||
fetchSnapshot,
|
fetchSnapshot,
|
||||||
@@ -63,9 +67,11 @@ type Service = {
|
|||||||
deadlineMs: number;
|
deadlineMs: number;
|
||||||
mailbox: number;
|
mailbox: number;
|
||||||
maxInputKb: number;
|
maxInputKb: number;
|
||||||
|
capabilities: BackendCapability[];
|
||||||
calls: number;
|
calls: number;
|
||||||
errors: number;
|
errors: number;
|
||||||
latencyMs: number | null;
|
latencyMs: number | null;
|
||||||
|
active: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
type RuntimeEvent = {
|
type RuntimeEvent = {
|
||||||
@@ -101,7 +107,7 @@ function serviceKey(service: Pick<Service, "id" | "revision">) {
|
|||||||
return `${service.id}@${service.revision}`;
|
return `${service.id}@${service.revision}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function toService(service: BackendService): Service {
|
function toService(service: BackendService, activeRevision?: string): Service {
|
||||||
return {
|
return {
|
||||||
id: service.id,
|
id: service.id,
|
||||||
revision: service.revision,
|
revision: service.revision,
|
||||||
@@ -116,9 +122,11 @@ function toService(service: BackendService): Service {
|
|||||||
deadlineMs: service.limits.deadline_ms,
|
deadlineMs: service.limits.deadline_ms,
|
||||||
mailbox: service.limits.mailbox_capacity,
|
mailbox: service.limits.mailbox_capacity,
|
||||||
maxInputKb: Math.round(service.limits.max_input_bytes / 1024),
|
maxInputKb: Math.round(service.limits.max_input_bytes / 1024),
|
||||||
|
capabilities: service.capabilities,
|
||||||
calls: service.calls,
|
calls: service.calls,
|
||||||
errors: service.errors,
|
errors: service.errors,
|
||||||
latencyMs: service.last_latency_ms,
|
latencyMs: service.last_latency_ms,
|
||||||
|
active: service.revision === activeRevision,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -127,6 +135,8 @@ 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" },
|
||||||
started: { label: "已启动", tone: "success" },
|
started: { label: "已启动", tone: "success" },
|
||||||
stopped: { label: "已停止", tone: "neutral" },
|
stopped: { label: "已停止", tone: "neutral" },
|
||||||
invoked: { label: "调用完成", tone: "success" },
|
invoked: { label: "调用完成", tone: "success" },
|
||||||
@@ -182,16 +192,65 @@ function invocationInputSize(value: string, format: "utf8" | "hex") {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type FormattedInvocationOutput = {
|
||||||
|
text: string;
|
||||||
|
format: "UTF-8" | "HEX" | "U64 LE";
|
||||||
|
automatic: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
function formatHex(bytes: Uint8Array) {
|
||||||
|
return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join(" ");
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeReadableUtf8(bytes: Uint8Array) {
|
||||||
|
let text: string;
|
||||||
|
try {
|
||||||
|
text = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const character of text) {
|
||||||
|
const codePoint = character.codePointAt(0) ?? 0;
|
||||||
|
const allowedWhitespace = codePoint === 0x09 || codePoint === 0x0a || codePoint === 0x0d;
|
||||||
|
if ((codePoint < 0x20 && !allowedWhitespace) || codePoint === 0x7f) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
function formatInvocationOutput(service: Service, output: Uint8Array, format: "utf8" | "hex") {
|
function formatInvocationOutput(service: Service, output: Uint8Array, format: "utf8" | "hex") {
|
||||||
if (service.id === "counter" && output.byteLength === 8) {
|
if (service.id === "counter" && output.byteLength === 8) {
|
||||||
return new DataView(output.buffer, output.byteOffset, output.byteLength)
|
return {
|
||||||
.getBigUint64(0, true)
|
text: new DataView(output.buffer, output.byteOffset, output.byteLength)
|
||||||
.toString();
|
.getBigUint64(0, true)
|
||||||
|
.toString(),
|
||||||
|
format: "U64 LE",
|
||||||
|
automatic: false,
|
||||||
|
} satisfies FormattedInvocationOutput;
|
||||||
}
|
}
|
||||||
if (format === "hex") {
|
if (format === "hex") {
|
||||||
return Array.from(output, (byte) => byte.toString(16).padStart(2, "0")).join(" ");
|
return {
|
||||||
|
text: formatHex(output) || "(empty)",
|
||||||
|
format: "HEX",
|
||||||
|
automatic: false,
|
||||||
|
} satisfies FormattedInvocationOutput;
|
||||||
}
|
}
|
||||||
return new TextDecoder().decode(output) || "(empty)";
|
|
||||||
|
const text = decodeReadableUtf8(output);
|
||||||
|
if (text !== null) {
|
||||||
|
return {
|
||||||
|
text: text || "(empty)",
|
||||||
|
format: "UTF-8",
|
||||||
|
automatic: false,
|
||||||
|
} satisfies FormattedInvocationOutput;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
text: formatHex(output),
|
||||||
|
format: "HEX",
|
||||||
|
automatic: true,
|
||||||
|
} satisfies FormattedInvocationOutput;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Route = createFileRoute("/")({
|
export const Route = createFileRoute("/")({
|
||||||
@@ -202,6 +261,7 @@ function Home() {
|
|||||||
const [view, setView] = useState<View>("overview");
|
const [view, setView] = useState<View>("overview");
|
||||||
const [services, setServices] = useState<Service[]>([]);
|
const [services, setServices] = useState<Service[]>([]);
|
||||||
const [events, setEvents] = useState<RuntimeEvent[]>([]);
|
const [events, setEvents] = useState<RuntimeEvent[]>([]);
|
||||||
|
const [deployments, setDeployments] = useState<BackendDeployment[]>([]);
|
||||||
const [witPackages, setWitPackages] = useState<BackendWitPackage[]>([]);
|
const [witPackages, setWitPackages] = useState<BackendWitPackage[]>([]);
|
||||||
const [runtime, setRuntime] = useState<BackendRuntime | null>(null);
|
const [runtime, setRuntime] = useState<BackendRuntime | null>(null);
|
||||||
const [runtimeAction, setRuntimeAction] = useState<"start" | "stop" | "restart" | null>(null);
|
const [runtimeAction, setRuntimeAction] = useState<"start" | "stop" | "restart" | null>(null);
|
||||||
@@ -213,11 +273,15 @@ function Home() {
|
|||||||
const [deployOpen, setDeployOpen] = useState(false);
|
const [deployOpen, setDeployOpen] = useState(false);
|
||||||
const [witPublishOpen, setWitPublishOpen] = useState(false);
|
const [witPublishOpen, setWitPublishOpen] = useState(false);
|
||||||
const [invokeKey, setInvokeKey] = useState<string | null>(null);
|
const [invokeKey, setInvokeKey] = useState<string | null>(null);
|
||||||
|
const [activationKey, setActivationKey] = useState<string | null>(null);
|
||||||
|
const [deploymentAction, setDeploymentAction] = useState<string | null>(null);
|
||||||
const [toast, setToast] = useState<string | null>(null);
|
const [toast, setToast] = useState<string | null>(null);
|
||||||
|
|
||||||
const selectedService =
|
const selectedService =
|
||||||
services.find((service) => serviceKey(service) === selectedKey) ?? services[0] ?? null;
|
services.find((service) => serviceKey(service) === selectedKey) ?? services[0] ?? null;
|
||||||
const invokeService = services.find((service) => serviceKey(service) === invokeKey) ?? null;
|
const invokeService = services.find((service) => serviceKey(service) === invokeKey) ?? null;
|
||||||
|
const activationService =
|
||||||
|
services.find((service) => serviceKey(service) === activationKey) ?? null;
|
||||||
|
|
||||||
const filteredServices = useMemo(() => {
|
const filteredServices = useMemo(() => {
|
||||||
const normalized = query.trim().toLowerCase();
|
const normalized = query.trim().toLowerCase();
|
||||||
@@ -250,8 +314,17 @@ function Home() {
|
|||||||
const refreshSnapshot = useCallback(async () => {
|
const refreshSnapshot = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const snapshot = await fetchSnapshot(apiBase);
|
const snapshot = await fetchSnapshot(apiBase);
|
||||||
const nextServices = snapshot.services.map(toService);
|
const activeRevisions = new Map(
|
||||||
|
snapshot.deployments.map((deployment) => [
|
||||||
|
deployment.service_id,
|
||||||
|
deployment.active_revision,
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
const nextServices = snapshot.services.map((service) =>
|
||||||
|
toService(service, activeRevisions.get(service.id)),
|
||||||
|
);
|
||||||
setServices(nextServices);
|
setServices(nextServices);
|
||||||
|
setDeployments(snapshot.deployments);
|
||||||
setEvents(snapshot.events.map(toEvent));
|
setEvents(snapshot.events.map(toEvent));
|
||||||
setRuntime(snapshot.runtime);
|
setRuntime(snapshot.runtime);
|
||||||
setWitPackages(snapshot.witPackages);
|
setWitPackages(snapshot.witPackages);
|
||||||
@@ -265,6 +338,7 @@ function Home() {
|
|||||||
setConnection("online");
|
setConnection("online");
|
||||||
} catch {
|
} catch {
|
||||||
setServices([]);
|
setServices([]);
|
||||||
|
setDeployments([]);
|
||||||
setEvents([]);
|
setEvents([]);
|
||||||
setRuntime(null);
|
setRuntime(null);
|
||||||
setWitPackages([]);
|
setWitPackages([]);
|
||||||
@@ -318,6 +392,21 @@ function Home() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function activateService(service: Service) {
|
||||||
|
const key = serviceKey(service);
|
||||||
|
setDeploymentAction(key);
|
||||||
|
try {
|
||||||
|
await activateDeployment(apiBase, service);
|
||||||
|
await refreshSnapshot();
|
||||||
|
setActivationKey(null);
|
||||||
|
notify(`${service.id}@${service.revision} 已设为对外版本`);
|
||||||
|
} catch (error) {
|
||||||
|
notify(error instanceof Error ? error.message : "Deployment 切换失败");
|
||||||
|
} finally {
|
||||||
|
setDeploymentAction(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function runInvocation(service: Service, input: Uint8Array) {
|
async function runInvocation(service: Service, input: Uint8Array) {
|
||||||
const result = await invokeComponent(apiBase, service, input);
|
const result = await invokeComponent(apiBase, service, input);
|
||||||
await refreshSnapshot();
|
await refreshSnapshot();
|
||||||
@@ -405,16 +494,19 @@ function Home() {
|
|||||||
running={running}
|
running={running}
|
||||||
faulted={faulted}
|
faulted={faulted}
|
||||||
totalCalls={totalCalls}
|
totalCalls={totalCalls}
|
||||||
|
deploymentCount={deployments.length}
|
||||||
runtime={runtime}
|
runtime={runtime}
|
||||||
selectedService={selectedService}
|
selectedService={selectedService}
|
||||||
onSelect={(service) => setSelectedKey(serviceKey(service))}
|
onSelect={(service) => setSelectedKey(serviceKey(service))}
|
||||||
onDeploy={() => setDeployOpen(true)}
|
onDeploy={() => setDeployOpen(true)}
|
||||||
onInvoke={(service) => setInvokeKey(serviceKey(service))}
|
onInvoke={(service) => setInvokeKey(serviceKey(service))}
|
||||||
onStatus={setServiceStatus}
|
onStatus={setServiceStatus}
|
||||||
|
onActivate={(service) => setActivationKey(serviceKey(service))}
|
||||||
onViewAll={() => setView("services")}
|
onViewAll={() => setView("services")}
|
||||||
onRefresh={() => void refreshSnapshot()}
|
onRefresh={() => void refreshSnapshot()}
|
||||||
onRuntimeAction={(action) => void setRuntimeStatus(action)}
|
onRuntimeAction={(action) => void setRuntimeStatus(action)}
|
||||||
runtimeAction={runtimeAction}
|
runtimeAction={runtimeAction}
|
||||||
|
deploymentAction={deploymentAction}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -427,6 +519,9 @@ function Home() {
|
|||||||
onInvoke={(service) => setInvokeKey(serviceKey(service))}
|
onInvoke={(service) => setInvokeKey(serviceKey(service))}
|
||||||
onSelect={(service) => setSelectedKey(serviceKey(service))}
|
onSelect={(service) => setSelectedKey(serviceKey(service))}
|
||||||
onStatus={setServiceStatus}
|
onStatus={setServiceStatus}
|
||||||
|
onActivate={(service) => setActivationKey(serviceKey(service))}
|
||||||
|
canActivate={runtime?.status === "running"}
|
||||||
|
deploymentAction={deploymentAction}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -484,6 +579,19 @@ function Home() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{activationService && (
|
||||||
|
<ActivateDeploymentDialog
|
||||||
|
service={activationService}
|
||||||
|
currentRevision={
|
||||||
|
services.find((service) => service.id === activationService.id && service.active)
|
||||||
|
?.revision ?? null
|
||||||
|
}
|
||||||
|
submitting={deploymentAction === serviceKey(activationService)}
|
||||||
|
onClose={() => setActivationKey(null)}
|
||||||
|
onConfirm={() => void activateService(activationService)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{toast && (
|
{toast && (
|
||||||
<output className="toast">
|
<output className="toast">
|
||||||
<CheckCircle2 size={17} />
|
<CheckCircle2 size={17} />
|
||||||
@@ -613,16 +721,19 @@ function Overview({
|
|||||||
running,
|
running,
|
||||||
faulted,
|
faulted,
|
||||||
totalCalls,
|
totalCalls,
|
||||||
|
deploymentCount,
|
||||||
runtime,
|
runtime,
|
||||||
selectedService,
|
selectedService,
|
||||||
onSelect,
|
onSelect,
|
||||||
onDeploy,
|
onDeploy,
|
||||||
onInvoke,
|
onInvoke,
|
||||||
onStatus,
|
onStatus,
|
||||||
|
onActivate,
|
||||||
onViewAll,
|
onViewAll,
|
||||||
onRefresh,
|
onRefresh,
|
||||||
onRuntimeAction,
|
onRuntimeAction,
|
||||||
runtimeAction,
|
runtimeAction,
|
||||||
|
deploymentAction,
|
||||||
}: {
|
}: {
|
||||||
services: Service[];
|
services: Service[];
|
||||||
filteredServices: Service[];
|
filteredServices: Service[];
|
||||||
@@ -630,16 +741,19 @@ function Overview({
|
|||||||
running: number;
|
running: number;
|
||||||
faulted: number;
|
faulted: number;
|
||||||
totalCalls: number;
|
totalCalls: number;
|
||||||
|
deploymentCount: number;
|
||||||
runtime: BackendRuntime | null;
|
runtime: BackendRuntime | null;
|
||||||
selectedService: Service | null;
|
selectedService: Service | null;
|
||||||
onSelect: (service: Service) => void;
|
onSelect: (service: Service) => void;
|
||||||
onDeploy: () => void;
|
onDeploy: () => void;
|
||||||
onInvoke: (service: Service) => void;
|
onInvoke: (service: Service) => void;
|
||||||
onStatus: (service: Service, status: ServiceStatus) => void;
|
onStatus: (service: Service, status: ServiceStatus) => void;
|
||||||
|
onActivate: (service: Service) => void;
|
||||||
onViewAll: () => void;
|
onViewAll: () => void;
|
||||||
onRefresh: () => void;
|
onRefresh: () => void;
|
||||||
onRuntimeAction: (action: "start" | "stop" | "restart") => void;
|
onRuntimeAction: (action: "start" | "stop" | "restart") => void;
|
||||||
runtimeAction: "start" | "stop" | "restart" | null;
|
runtimeAction: "start" | "stop" | "restart" | null;
|
||||||
|
deploymentAction: string | null;
|
||||||
}) {
|
}) {
|
||||||
const errorCount = services.reduce((sum, service) => sum + service.errors, 0);
|
const errorCount = services.reduce((sum, service) => sum + service.errors, 0);
|
||||||
const latencies = services
|
const latencies = services
|
||||||
@@ -677,7 +791,7 @@ function Overview({
|
|||||||
icon={<Package size={18} />}
|
icon={<Package size={18} />}
|
||||||
label="服务版本"
|
label="服务版本"
|
||||||
value={services.length.toString()}
|
value={services.length.toString()}
|
||||||
note="来自 Wasmeld"
|
note={`${deploymentCount} 个对外服务`}
|
||||||
tone="cyan"
|
tone="cyan"
|
||||||
/>
|
/>
|
||||||
<Metric
|
<Metric
|
||||||
@@ -794,6 +908,9 @@ function Overview({
|
|||||||
onSelect={onSelect}
|
onSelect={onSelect}
|
||||||
onInvoke={onInvoke}
|
onInvoke={onInvoke}
|
||||||
onStatus={onStatus}
|
onStatus={onStatus}
|
||||||
|
onActivate={onActivate}
|
||||||
|
canActivate={runtime?.status === "running"}
|
||||||
|
deploymentAction={deploymentAction}
|
||||||
compact
|
compact
|
||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
@@ -832,6 +949,17 @@ function Overview({
|
|||||||
<DetailValue label="Deadline" value={`${selectedService.deadlineMs} ms`} />
|
<DetailValue label="Deadline" value={`${selectedService.deadlineMs} ms`} />
|
||||||
<DetailValue label="Mailbox" value={selectedService.mailbox.toString()} />
|
<DetailValue label="Mailbox" value={selectedService.mailbox.toString()} />
|
||||||
<div className="detail-actions">
|
<div className="detail-actions">
|
||||||
|
{!selectedService.active && (
|
||||||
|
<button
|
||||||
|
className="secondary-button"
|
||||||
|
type="button"
|
||||||
|
disabled={runtime?.status !== "running" || deploymentAction !== null}
|
||||||
|
onClick={() => onActivate(selectedService)}
|
||||||
|
>
|
||||||
|
<RadioTower size={16} />
|
||||||
|
设为对外
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
className="secondary-button"
|
className="secondary-button"
|
||||||
type="button"
|
type="button"
|
||||||
@@ -902,12 +1030,35 @@ function StatusBadge({ status }: { status: ServiceStatus }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function CapabilitySummary({ capabilities }: { capabilities: BackendCapability[] }) {
|
||||||
|
if (capabilities.length === 0) {
|
||||||
|
return <span className="capability-empty">无</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [first, ...remaining] = capabilities;
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className="capability-summary"
|
||||||
|
title={capabilities.map((capability) => capability.interface).join("\n")}
|
||||||
|
>
|
||||||
|
<Braces size={12} />
|
||||||
|
<span>
|
||||||
|
{first.package}/{first.name} @{first.version}
|
||||||
|
</span>
|
||||||
|
{remaining.length > 0 && <small>+{remaining.length}</small>}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function ServiceTable({
|
function ServiceTable({
|
||||||
services,
|
services,
|
||||||
selectedKey,
|
selectedKey,
|
||||||
onSelect,
|
onSelect,
|
||||||
onInvoke,
|
onInvoke,
|
||||||
onStatus,
|
onStatus,
|
||||||
|
onActivate,
|
||||||
|
canActivate,
|
||||||
|
deploymentAction,
|
||||||
compact = false,
|
compact = false,
|
||||||
}: {
|
}: {
|
||||||
services: Service[];
|
services: Service[];
|
||||||
@@ -915,6 +1066,9 @@ function ServiceTable({
|
|||||||
onSelect: (service: Service) => void;
|
onSelect: (service: Service) => void;
|
||||||
onInvoke: (service: Service) => void;
|
onInvoke: (service: Service) => void;
|
||||||
onStatus: (service: Service, status: ServiceStatus) => void;
|
onStatus: (service: Service, status: ServiceStatus) => void;
|
||||||
|
onActivate: (service: Service) => void;
|
||||||
|
canActivate: boolean;
|
||||||
|
deploymentAction: string | null;
|
||||||
compact?: boolean;
|
compact?: boolean;
|
||||||
}) {
|
}) {
|
||||||
if (services.length === 0) {
|
if (services.length === 0) {
|
||||||
@@ -929,12 +1083,13 @@ function ServiceTable({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="table-scroll">
|
<div className="table-scroll">
|
||||||
<table className="data-table">
|
<table className={`data-table ${compact ? "service-table-compact" : "service-table-full"}`}>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>服务</th>
|
<th>服务</th>
|
||||||
<th>状态</th>
|
<th>状态</th>
|
||||||
{!compact && <th>制品</th>}
|
{!compact && <th>制品</th>}
|
||||||
|
{!compact && <th>Host 能力</th>}
|
||||||
<th>调用</th>
|
<th>调用</th>
|
||||||
<th>延迟</th>
|
<th>延迟</th>
|
||||||
<th>
|
<th>
|
||||||
@@ -959,7 +1114,15 @@ function ServiceTable({
|
|||||||
<Box size={16} />
|
<Box size={16} />
|
||||||
</span>
|
</span>
|
||||||
<span>
|
<span>
|
||||||
<strong>{service.id}</strong>
|
<span className="service-title">
|
||||||
|
<strong>{service.id}</strong>
|
||||||
|
{service.active && (
|
||||||
|
<span className="active-deployment-badge">
|
||||||
|
<RadioTower size={10} />
|
||||||
|
对外
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
<small>
|
<small>
|
||||||
{service.revision} · {service.description}
|
{service.revision} · {service.description}
|
||||||
</small>
|
</small>
|
||||||
@@ -970,12 +1133,41 @@ function ServiceTable({
|
|||||||
<StatusBadge status={service.status} />
|
<StatusBadge status={service.status} />
|
||||||
</td>
|
</td>
|
||||||
{!compact && <td className="artifact-cell">{service.artifact}</td>}
|
{!compact && <td className="artifact-cell">{service.artifact}</td>}
|
||||||
|
{!compact && (
|
||||||
|
<td>
|
||||||
|
<CapabilitySummary capabilities={service.capabilities} />
|
||||||
|
</td>
|
||||||
|
)}
|
||||||
<td className="numeric-cell">{service.calls.toLocaleString("zh-CN")}</td>
|
<td className="numeric-cell">{service.calls.toLocaleString("zh-CN")}</td>
|
||||||
<td className="numeric-cell">
|
<td className="numeric-cell">
|
||||||
{service.latencyMs ? `${service.latencyMs} ms` : "—"}
|
{service.latencyMs ? `${service.latencyMs} ms` : "—"}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<div className="row-actions">
|
<div className="row-actions">
|
||||||
|
<button
|
||||||
|
className={service.active ? "active-deployment-button" : ""}
|
||||||
|
type="button"
|
||||||
|
aria-label={
|
||||||
|
service.active
|
||||||
|
? `${service.id}@${service.revision} 是当前对外版本`
|
||||||
|
: `将 ${service.id}@${service.revision} 设为对外版本`
|
||||||
|
}
|
||||||
|
title={
|
||||||
|
service.active
|
||||||
|
? "当前对外版本"
|
||||||
|
: canActivate
|
||||||
|
? "设为对外版本"
|
||||||
|
: "请先启动 Runtime"
|
||||||
|
}
|
||||||
|
disabled={service.active || !canActivate || deploymentAction !== null}
|
||||||
|
onClick={() => onActivate(service)}
|
||||||
|
>
|
||||||
|
{deploymentAction === serviceKey(service) ? (
|
||||||
|
<RefreshCw className="spin" size={15} />
|
||||||
|
) : (
|
||||||
|
<RadioTower size={15} />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
{service.status === "running" ? (
|
{service.status === "running" ? (
|
||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
@@ -1055,6 +1247,9 @@ function ServicesView({
|
|||||||
onInvoke,
|
onInvoke,
|
||||||
onSelect,
|
onSelect,
|
||||||
onStatus,
|
onStatus,
|
||||||
|
onActivate,
|
||||||
|
canActivate,
|
||||||
|
deploymentAction,
|
||||||
}: {
|
}: {
|
||||||
services: Service[];
|
services: Service[];
|
||||||
statusFilter: "all" | ServiceStatus;
|
statusFilter: "all" | ServiceStatus;
|
||||||
@@ -1063,13 +1258,16 @@ function ServicesView({
|
|||||||
onInvoke: (service: Service) => void;
|
onInvoke: (service: Service) => void;
|
||||||
onSelect: (service: Service) => void;
|
onSelect: (service: Service) => void;
|
||||||
onStatus: (service: Service, status: ServiceStatus) => void;
|
onStatus: (service: Service, status: ServiceStatus) => void;
|
||||||
|
onActivate: (service: Service) => void;
|
||||||
|
canActivate: boolean;
|
||||||
|
deploymentAction: string | null;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<PageHeading
|
<PageHeading
|
||||||
eyebrow="REGISTRY"
|
eyebrow="REGISTRY"
|
||||||
title="服务版本"
|
title="服务版本"
|
||||||
description="管理 Runtime 已注册的不可变 Wasm Component 制品。"
|
description="管理不可变 Wasm Component 制品及当前对外版本。"
|
||||||
actions={
|
actions={
|
||||||
<button className="primary-button" type="button" onClick={onDeploy}>
|
<button className="primary-button" type="button" onClick={onDeploy}>
|
||||||
<CloudUpload size={17} />
|
<CloudUpload size={17} />
|
||||||
@@ -1103,6 +1301,9 @@ function ServicesView({
|
|||||||
onSelect={onSelect}
|
onSelect={onSelect}
|
||||||
onInvoke={onInvoke}
|
onInvoke={onInvoke}
|
||||||
onStatus={onStatus}
|
onStatus={onStatus}
|
||||||
|
onActivate={onActivate}
|
||||||
|
canActivate={canActivate}
|
||||||
|
deploymentAction={deploymentAction}
|
||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
</>
|
</>
|
||||||
@@ -1435,12 +1636,14 @@ function DialogFrame({
|
|||||||
description,
|
description,
|
||||||
icon,
|
icon,
|
||||||
onClose,
|
onClose,
|
||||||
|
closeDisabled = false,
|
||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
title: string;
|
title: string;
|
||||||
description: string;
|
description: string;
|
||||||
icon: ReactNode;
|
icon: ReactNode;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
|
closeDisabled?: boolean;
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
@@ -1449,9 +1652,12 @@ function DialogFrame({
|
|||||||
open
|
open
|
||||||
className="dialog"
|
className="dialog"
|
||||||
aria-label={title}
|
aria-label={title}
|
||||||
onCancel={onClose}
|
onCancel={(event) => {
|
||||||
|
if (closeDisabled) event.preventDefault();
|
||||||
|
else onClose();
|
||||||
|
}}
|
||||||
onKeyDown={(event) => {
|
onKeyDown={(event) => {
|
||||||
if (event.key === "Escape") onClose();
|
if (event.key === "Escape" && !closeDisabled) onClose();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="dialog-header">
|
<div className="dialog-header">
|
||||||
@@ -1467,6 +1673,7 @@ function DialogFrame({
|
|||||||
type="button"
|
type="button"
|
||||||
aria-label="关闭"
|
aria-label="关闭"
|
||||||
title="关闭"
|
title="关闭"
|
||||||
|
disabled={closeDisabled}
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
>
|
>
|
||||||
<X size={18} />
|
<X size={18} />
|
||||||
@@ -1478,6 +1685,56 @@ function DialogFrame({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ActivateDeploymentDialog({
|
||||||
|
service,
|
||||||
|
currentRevision,
|
||||||
|
submitting,
|
||||||
|
onClose,
|
||||||
|
onConfirm,
|
||||||
|
}: {
|
||||||
|
service: Service;
|
||||||
|
currentRevision: string | null;
|
||||||
|
submitting: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onConfirm: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<DialogFrame
|
||||||
|
title="切换对外版本"
|
||||||
|
description="更新该服务在 Gateway 上接收请求的活动版本。"
|
||||||
|
icon={<RadioTower size={19} />}
|
||||||
|
onClose={onClose}
|
||||||
|
closeDisabled={submitting}
|
||||||
|
>
|
||||||
|
<div className="deployment-confirmation">
|
||||||
|
<div className="deployment-route">
|
||||||
|
<div>
|
||||||
|
<span>当前版本</span>
|
||||||
|
<strong>{currentRevision ?? "尚未部署"}</strong>
|
||||||
|
</div>
|
||||||
|
<ArrowRight size={18} />
|
||||||
|
<div className="deployment-target">
|
||||||
|
<span>目标版本</span>
|
||||||
|
<strong>{service.revision}</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p>
|
||||||
|
Wasmeld 将先确认 {service.id}@{service.revision} 的 Actor 可用,再更新对外路由。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="dialog-footer">
|
||||||
|
<button className="secondary-button" type="button" disabled={submitting} onClick={onClose}>
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
<button className="primary-button" type="button" disabled={submitting} onClick={onConfirm}>
|
||||||
|
{submitting ? <RefreshCw className="spin" size={16} /> : <RadioTower size={16} />}
|
||||||
|
{submitting ? "正在切换" : "确认切换"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</DialogFrame>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function DeployDialog({
|
function DeployDialog({
|
||||||
onClose,
|
onClose,
|
||||||
onRegister,
|
onRegister,
|
||||||
@@ -1634,6 +1891,8 @@ function InvokeDialog({
|
|||||||
const [input, setInput] = useState(service.id === "echo" ? "hello wasm" : "");
|
const [input, setInput] = useState(service.id === "echo" ? "hello wasm" : "");
|
||||||
const [format, setFormat] = useState<"utf8" | "hex">("utf8");
|
const [format, setFormat] = useState<"utf8" | "hex">("utf8");
|
||||||
const [output, setOutput] = useState("");
|
const [output, setOutput] = useState("");
|
||||||
|
const [outputBytes, setOutputBytes] = useState<number | null>(null);
|
||||||
|
const [outputFormat, setOutputFormat] = useState<FormattedInvocationOutput | null>(null);
|
||||||
const [running, setRunning] = useState(false);
|
const [running, setRunning] = useState(false);
|
||||||
const [failed, setFailed] = useState(false);
|
const [failed, setFailed] = useState(false);
|
||||||
const [latency, setLatency] = useState<number | null>(null);
|
const [latency, setLatency] = useState<number | null>(null);
|
||||||
@@ -1641,11 +1900,16 @@ function InvokeDialog({
|
|||||||
async function invoke() {
|
async function invoke() {
|
||||||
setRunning(true);
|
setRunning(true);
|
||||||
setOutput("");
|
setOutput("");
|
||||||
|
setOutputBytes(null);
|
||||||
|
setOutputFormat(null);
|
||||||
setFailed(false);
|
setFailed(false);
|
||||||
setLatency(null);
|
setLatency(null);
|
||||||
try {
|
try {
|
||||||
const result = await onInvoke(service, parseInvocationInput(input, format));
|
const result = await onInvoke(service, parseInvocationInput(input, format));
|
||||||
setOutput(formatInvocationOutput(service, result.output, format));
|
const formatted = formatInvocationOutput(service, result.output, format);
|
||||||
|
setOutput(formatted.text);
|
||||||
|
setOutputBytes(result.outputBytes);
|
||||||
|
setOutputFormat(formatted);
|
||||||
setLatency(result.latencyMs);
|
setLatency(result.latencyMs);
|
||||||
} catch (invokeError) {
|
} catch (invokeError) {
|
||||||
setFailed(true);
|
setFailed(true);
|
||||||
@@ -1664,7 +1928,7 @@ function InvokeDialog({
|
|||||||
>
|
>
|
||||||
<div className="invoke-body">
|
<div className="invoke-body">
|
||||||
<div className="invoke-toolbar">
|
<div className="invoke-toolbar">
|
||||||
<div className="segmented">
|
<div className="segmented" aria-label="输入编码">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={format === "utf8" ? "active" : ""}
|
className={format === "utf8" ? "active" : ""}
|
||||||
@@ -1695,11 +1959,17 @@ function InvokeDialog({
|
|||||||
<div className={failed ? "invoke-output failed" : "invoke-output"}>
|
<div className={failed ? "invoke-output failed" : "invoke-output"}>
|
||||||
<div>
|
<div>
|
||||||
<span>输出</span>
|
<span>输出</span>
|
||||||
{output && latency !== null && (
|
{output && latency !== null && outputBytes !== null && outputFormat && (
|
||||||
<small>
|
<div className="invoke-output-meta">
|
||||||
<Clock3 size={13} />
|
<span>
|
||||||
{latency} ms
|
{outputBytes} B · {outputFormat.format}
|
||||||
</small>
|
{outputFormat.automatic ? " · 自动" : ""}
|
||||||
|
</span>
|
||||||
|
<small>
|
||||||
|
<Clock3 size={13} />
|
||||||
|
{latency} ms
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<pre>{running ? "invoking..." : output || "等待调用"}</pre>
|
<pre>{running ? "invoking..." : output || "等待调用"}</pre>
|
||||||
|
|||||||
+155
-2
@@ -768,6 +768,31 @@ button:disabled {
|
|||||||
table-layout: fixed;
|
table-layout: fixed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.service-table-full {
|
||||||
|
min-width: 980px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-table.service-table-full th:first-child {
|
||||||
|
width: 24%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.service-table-full th:nth-child(2) {
|
||||||
|
width: 90px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.service-table-full th:nth-child(3) {
|
||||||
|
width: 150px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.service-table-full th:nth-child(4) {
|
||||||
|
width: 220px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.service-table-full th:nth-child(5),
|
||||||
|
.service-table-full th:nth-child(6) {
|
||||||
|
width: 72px;
|
||||||
|
}
|
||||||
|
|
||||||
.data-table th {
|
.data-table th {
|
||||||
height: 34px;
|
height: 34px;
|
||||||
padding: 0 12px;
|
padding: 0 12px;
|
||||||
@@ -785,7 +810,7 @@ button:disabled {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.data-table th:nth-last-child(1) {
|
.data-table th:nth-last-child(1) {
|
||||||
width: 122px;
|
width: 150px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.data-table td {
|
.data-table td {
|
||||||
@@ -849,6 +874,33 @@ button:disabled {
|
|||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.service-title {
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.service-title strong {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.active-deployment-badge {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
height: 17px;
|
||||||
|
padding: 0 5px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 3px;
|
||||||
|
color: #176b4d;
|
||||||
|
background: #e4f3ed;
|
||||||
|
border: 1px solid #c5e4d8;
|
||||||
|
border-radius: 9px;
|
||||||
|
font-size: 7px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
.service-cell small {
|
.service-cell small {
|
||||||
margin-top: 3px;
|
margin-top: 3px;
|
||||||
color: var(--ink-faint);
|
color: var(--ink-faint);
|
||||||
@@ -864,6 +916,38 @@ button:disabled {
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.capability-summary {
|
||||||
|
max-width: 100%;
|
||||||
|
height: 23px;
|
||||||
|
padding: 0 7px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
color: #315f52;
|
||||||
|
background: #edf5f2;
|
||||||
|
border: 1px solid #d2e4de;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-family: var(--font-geist-mono), monospace;
|
||||||
|
font-size: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.capability-summary > span {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.capability-summary > small {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
color: var(--ink-faint);
|
||||||
|
font-size: 7px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.capability-empty {
|
||||||
|
color: var(--ink-faint);
|
||||||
|
font-size: 9px;
|
||||||
|
}
|
||||||
|
|
||||||
.numeric-cell {
|
.numeric-cell {
|
||||||
color: var(--ink-soft);
|
color: var(--ink-soft);
|
||||||
font-family: var(--font-geist-mono), monospace;
|
font-family: var(--font-geist-mono), monospace;
|
||||||
@@ -917,7 +1001,7 @@ button:disabled {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.row-actions {
|
.row-actions {
|
||||||
min-width: 100px;
|
min-width: 128px;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
@@ -943,6 +1027,14 @@ button:disabled {
|
|||||||
border-color: var(--line);
|
border-color: var(--line);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.row-actions .active-deployment-button:disabled {
|
||||||
|
color: #197253;
|
||||||
|
background: #e7f4ef;
|
||||||
|
border-color: #c6e4d9;
|
||||||
|
cursor: default;
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
.event-list {
|
.event-list {
|
||||||
padding: 4px 14px;
|
padding: 4px 14px;
|
||||||
}
|
}
|
||||||
@@ -1094,6 +1186,7 @@ button:disabled {
|
|||||||
.detail-actions {
|
.detail-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
|
gap: 7px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.full-panel {
|
.full-panel {
|
||||||
@@ -1597,6 +1690,54 @@ button:disabled {
|
|||||||
margin: 18px -18px -18px;
|
margin: 18px -18px -18px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.deployment-confirmation {
|
||||||
|
padding: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.deployment-route {
|
||||||
|
min-height: 72px;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) 24px minmax(0, 1fr);
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.deployment-route > svg {
|
||||||
|
color: var(--ink-faint);
|
||||||
|
}
|
||||||
|
|
||||||
|
.deployment-route span,
|
||||||
|
.deployment-route strong {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.deployment-route span {
|
||||||
|
margin-bottom: 5px;
|
||||||
|
color: var(--ink-faint);
|
||||||
|
font-size: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.deployment-route strong {
|
||||||
|
overflow: hidden;
|
||||||
|
font-family: var(--font-geist-mono), monospace;
|
||||||
|
font-size: 13px;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.deployment-target {
|
||||||
|
color: #176b4d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.deployment-confirmation p {
|
||||||
|
margin: 13px 0 0;
|
||||||
|
color: var(--ink-soft);
|
||||||
|
font-size: 9px;
|
||||||
|
line-height: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
.invoke-body {
|
.invoke-body {
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
}
|
}
|
||||||
@@ -1642,6 +1783,18 @@ button:disabled {
|
|||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.invoke-output-meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invoke-output-meta > span {
|
||||||
|
color: var(--ink-faint);
|
||||||
|
font-family: var(--font-geist-mono), monospace;
|
||||||
|
font-size: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
.invoke-output small {
|
.invoke-output small {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -77,15 +77,16 @@ test("server-renders the Wasm management console", async () => {
|
|||||||
assert.match(html, /运行概览/);
|
assert.match(html, /运行概览/);
|
||||||
assert.match(html, /WIT 包/);
|
assert.match(html, /WIT 包/);
|
||||||
assert.match(html, /连接中/);
|
assert.match(html, /连接中/);
|
||||||
assert.match(html, /来自 Wasmeld/);
|
assert.match(html, /0 个对外服务/);
|
||||||
assert.match(html, /Component Model/);
|
assert.match(html, /Component Model/);
|
||||||
assert.doesNotMatch(html, /本地演示|codex-preview|vinext|next\.js/i);
|
assert.doesNotMatch(html, /本地演示|codex-preview|vinext|next\.js/i);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("uses TanStack Start routing and produces Node artifacts", async () => {
|
test("uses TanStack Start routing and produces Node artifacts", async () => {
|
||||||
const [rootRoute, indexRoute, router, packageJson, viteConfig] = await Promise.all([
|
const [rootRoute, indexRoute, apiClient, router, packageJson, viteConfig] = await Promise.all([
|
||||||
readFile(new URL("../src/routes/__root.tsx", import.meta.url), "utf8"),
|
readFile(new URL("../src/routes/__root.tsx", import.meta.url), "utf8"),
|
||||||
readFile(new URL("../src/routes/index.tsx", import.meta.url), "utf8"),
|
readFile(new URL("../src/routes/index.tsx", import.meta.url), "utf8"),
|
||||||
|
readFile(new URL("../src/api.ts", import.meta.url), "utf8"),
|
||||||
readFile(new URL("../src/router.tsx", import.meta.url), "utf8"),
|
readFile(new URL("../src/router.tsx", import.meta.url), "utf8"),
|
||||||
readFile(new URL("../package.json", import.meta.url), "utf8"),
|
readFile(new URL("../package.json", import.meta.url), "utf8"),
|
||||||
readFile(new URL("../vite.config.ts", import.meta.url), "utf8"),
|
readFile(new URL("../vite.config.ts", import.meta.url), "utf8"),
|
||||||
@@ -95,6 +96,13 @@ test("uses TanStack Start routing and produces Node artifacts", async () => {
|
|||||||
assert.match(rootRoute, /<HeadContent \/>/);
|
assert.match(rootRoute, /<HeadContent \/>/);
|
||||||
assert.match(rootRoute, /<Scripts \/>/);
|
assert.match(rootRoute, /<Scripts \/>/);
|
||||||
assert.match(indexRoute, /createFileRoute\("\/"\)/);
|
assert.match(indexRoute, /createFileRoute\("\/"\)/);
|
||||||
|
assert.match(indexRoute, /切换对外版本/);
|
||||||
|
assert.match(indexRoute, /Host 能力/);
|
||||||
|
assert.match(indexRoute, /service\.capabilities/);
|
||||||
|
assert.match(indexRoute, /TextDecoder\("utf-8", \{ fatal: true \}\)/);
|
||||||
|
assert.match(indexRoute, /outputFormat\.automatic/);
|
||||||
|
assert.match(apiClient, /\/api\/v1\/deployments/);
|
||||||
|
assert.match(apiClient, /capabilities: BackendCapability\[\]/);
|
||||||
assert.match(router, /createRouter/);
|
assert.match(router, /createRouter/);
|
||||||
assert.match(packageJson, /"@tanstack\/react-start"/);
|
assert.match(packageJson, /"@tanstack\/react-start"/);
|
||||||
assert.match(packageJson, /"srvx"/);
|
assert.match(packageJson, /"srvx"/);
|
||||||
|
|||||||
@@ -6,17 +6,21 @@
|
|||||||
- 发布、查询和下载不可变的二进制 WIT Package
|
- 发布、查询和下载不可变的二进制 WIT Package
|
||||||
- 启动、停止和重启内嵌的 `wasmeld-runtime`
|
- 启动、停止和重启内嵌的 `wasmeld-runtime`
|
||||||
- 启动、停止和重启常驻 Actor
|
- 启动、停止和重启常驻 Actor
|
||||||
- 转发二进制调用
|
- 管理服务 ID 到活动版本的 Deployment
|
||||||
|
- 通过独立 Gateway 转发原始二进制调用
|
||||||
|
- 为导入 `wasmeld:kv/store@0.1.0` 的 Component 提供持久化 KV
|
||||||
- 暴露调用计数、状态和最近事件
|
- 暴露调用计数、状态和最近事件
|
||||||
|
|
||||||
Wasm 制品保存在本地文件系统。服务 manifest、调用计数和最近 256 条事件通过
|
Wasm 制品保存在本地文件系统。服务 manifest、调用计数和最近 256 条事件通过
|
||||||
[Toasty](https://github.com/tokio-rs/toasty) 写入本地 libSQL 数据库,默认路径是
|
[Toasty](https://github.com/tokio-rs/toasty) 写入本地 libSQL 数据库,默认路径是
|
||||||
`var/wasmeld/console.db`。
|
`var/wasmeld/console.db`。Deployment 也存储在同一数据库中。
|
||||||
|
Host KV 使用 `(service_id, key)` 复合主键存储在该数据库中:同一服务的 Revision
|
||||||
|
共享数据,不同服务互相隔离,Runtime 或 Console 重启不会清空 KV。
|
||||||
|
|
||||||
`wasmeld-console` 启动时会在同一进程内创建 `wasmeld-runtime`、加载 Wasmtime Engine,
|
`wasmeld-console` 启动时会在同一进程内创建 `wasmeld-runtime`、加载 Wasmtime Engine,
|
||||||
并重新注册已保存的 Component。Runtime 停止后 Console HTTP 和数据库仍然在线;
|
并重新注册已保存的 Component。Runtime 停止后 Console HTTP 和数据库仍然在线;
|
||||||
再次启动 Runtime 会重新注册 Component,但 Actor 内存不做快照,服务运行状态统一
|
再次启动 Runtime 会重新注册 Component。未部署的版本恢复为 `stopped`;Deployment
|
||||||
恢复为 `stopped`。
|
指向的版本会用空初始化配置创建新的 Actor。Actor 的 Store 和线性内存不做快照。
|
||||||
|
|
||||||
## 启动
|
## 启动
|
||||||
|
|
||||||
@@ -27,11 +31,18 @@ Wasm 制品保存在本地文件系统。服务 manifest、调用计数和最近
|
|||||||
cargo +stable run -p wasmeld-console
|
cargo +stable run -p wasmeld-console
|
||||||
```
|
```
|
||||||
|
|
||||||
默认监听 `127.0.0.1:8080`。
|
进程默认启动两个独立 Listener:
|
||||||
|
|
||||||
|
- 管理 API:`127.0.0.1:8080`
|
||||||
|
- Gateway:`0.0.0.0:8081`
|
||||||
|
|
||||||
|
管理 Router 不会挂载到 Gateway Listener,Gateway 也不暴露注册、Runtime 生命周期和
|
||||||
|
WIT Registry 路由。
|
||||||
|
|
||||||
可用环境变量:
|
可用环境变量:
|
||||||
|
|
||||||
- `WASMELD_ADDR`
|
- `WASMELD_ADDR`
|
||||||
|
- `WASMELD_GATEWAY_ADDR`
|
||||||
- `WASMELD_ARTIFACT_DIR`
|
- `WASMELD_ARTIFACT_DIR`
|
||||||
- `WASMELD_DATABASE_PATH`
|
- `WASMELD_DATABASE_PATH`
|
||||||
- `WASMELD_WIT_REGISTRY_DIR`
|
- `WASMELD_WIT_REGISTRY_DIR`
|
||||||
@@ -46,20 +57,56 @@ cargo +stable run -p wasmeld-console
|
|||||||
| `GET` | `/api/v1/runtime` | Runtime 状态 |
|
| `GET` | `/api/v1/runtime` | Runtime 状态 |
|
||||||
| `POST` | `/api/v1/runtime/start` | 启动 Runtime 并重新注册受管 Component |
|
| `POST` | `/api/v1/runtime/start` | 启动 Runtime 并重新注册受管 Component |
|
||||||
| `POST` | `/api/v1/runtime/stop` | 停止 Runtime 并释放所有 Actor |
|
| `POST` | `/api/v1/runtime/stop` | 停止 Runtime 并释放所有 Actor |
|
||||||
| `POST` | `/api/v1/runtime/restart` | 重建 Runtime,服务恢复为停止状态 |
|
| `POST` | `/api/v1/runtime/restart` | 重建 Runtime,并重新创建活动 Deployment 的 Actor |
|
||||||
| `GET` | `/api/v1/services` | 服务版本列表 |
|
| `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 |
|
||||||
| `POST` | `/api/v1/services/{id}/{revision}/invoke` | Base64 二进制调用 |
|
| `POST` | `/api/v1/services/{id}/{revision}/invoke` | Base64 二进制调用 |
|
||||||
|
| `GET` | `/api/v1/deployments` | 活动 Deployment 列表 |
|
||||||
|
| `GET` | `/api/v1/deployments/{id}` | 查询服务的活动版本 |
|
||||||
|
| `POST` | `/api/v1/deployments/{id}/activate` | 启动目标版本并原子切换活动版本 |
|
||||||
| `GET` | `/api/v1/events` | 最近 256 条持久化事件 |
|
| `GET` | `/api/v1/events` | 最近 256 条持久化事件 |
|
||||||
| `GET` | `/api/v1/wit/packages` | WIT 包版本列表 |
|
| `GET` | `/api/v1/wit/packages` | WIT 包版本列表 |
|
||||||
| `POST` | `/api/v1/wit/packages` | multipart 发布单个二进制 WIT `package` 字段 |
|
| `POST` | `/api/v1/wit/packages` | multipart 发布单个二进制 WIT `package` 字段 |
|
||||||
| `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 的编译、注册和切换,
|
||||||
[`docs/design/wasmeld-component-package.md`](../../docs/design/wasmeld-component-package.md)。
|
再注销上一份开发 Revision,因此失败的构建不会影响当前可调用版本。
|
||||||
WIT 依赖、Registry 和本地 replace 说明见
|
|
||||||
[`docs/design/wit-package-management.md`](../../docs/design/wit-package-management.md)。
|
激活已注册版本:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST http://127.0.0.1:8080/api/v1/deployments/echo/activate \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d '{"revision":"0.1.0"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
Gateway 只提供 `GET /healthz` 和
|
||||||
|
`POST /v1/services/{id}/invoke`。调用请求与成功响应的
|
||||||
|
`Content-Type` 都是 `application/octet-stream`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl http://127.0.0.1:8081/v1/services/echo/invoke \
|
||||||
|
-H 'Content-Type: application/octet-stream' \
|
||||||
|
--data-binary 'hello'
|
||||||
|
```
|
||||||
|
|
||||||
|
成功响应通过 `X-Wasmeld-Revision` 返回实际路由的版本。Gateway 错误统一返回 JSON;
|
||||||
|
未部署为 `404`,Actor 不可用为 `503`,过载为 `429`,执行超时为 `504`。
|
||||||
|
|
||||||
|
服务响应的 `capabilities` 来自 Component 二进制中的 WIT imports,例如
|
||||||
|
`wasmeld:clock/monotonic-clock@0.1.0`。Console 不接受手工能力声明,Runtime 只会链接
|
||||||
|
注册表中存在且版本完全匹配的接口。
|
||||||
|
|
||||||
|
## Host KV
|
||||||
|
|
||||||
|
`wasmeld:kv/store@0.1.0` 提供 `get`、`set` 和 `delete`。Key 必须是非空 UTF-8,
|
||||||
|
最大 256 B;Value 最大 64 KiB。数据库命令通过有界专用工作线程执行,Host 等待时间
|
||||||
|
不会超过服务的 `deadline_ms`。v0.1.0 不提供 TTL、遍历、事务或 CAS。
|
||||||
|
|
||||||
|
组件包格式、开发循环、WIT 依赖、Registry 和本地 `replace` 说明见
|
||||||
|
[`wasmeld-package` CLI 文档](../wasmeld-package/README.md)。
|
||||||
|
|||||||
@@ -0,0 +1,219 @@
|
|||||||
|
//! Bounded bridge between synchronous Component Host calls and async Toasty.
|
||||||
|
|
||||||
|
use std::{
|
||||||
|
fmt, io,
|
||||||
|
sync::{
|
||||||
|
Arc, Mutex,
|
||||||
|
mpsc::{self, Receiver, SyncSender, TrySendError},
|
||||||
|
},
|
||||||
|
thread,
|
||||||
|
time::Duration,
|
||||||
|
};
|
||||||
|
|
||||||
|
use wasmeld_runtime::{KvBackend, KvBackendError};
|
||||||
|
|
||||||
|
use crate::persistence::Persistence;
|
||||||
|
|
||||||
|
const COMMAND_CAPACITY: usize = 64;
|
||||||
|
const MAX_RESPONSE_TIMEOUT: Duration = Duration::from_millis(400);
|
||||||
|
|
||||||
|
pub(crate) struct ToastyKvBackend {
|
||||||
|
inner: Arc<BackendInner>,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct BackendInner {
|
||||||
|
sender: SyncSender<KvCommand>,
|
||||||
|
worker: Mutex<Option<thread::JoinHandle<()>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
enum KvCommand {
|
||||||
|
Get {
|
||||||
|
service_id: String,
|
||||||
|
key: String,
|
||||||
|
response: SyncSender<Result<Option<Vec<u8>>, KvBackendError>>,
|
||||||
|
},
|
||||||
|
Set {
|
||||||
|
service_id: String,
|
||||||
|
key: String,
|
||||||
|
value: Vec<u8>,
|
||||||
|
response: SyncSender<Result<(), KvBackendError>>,
|
||||||
|
},
|
||||||
|
Delete {
|
||||||
|
service_id: String,
|
||||||
|
key: String,
|
||||||
|
response: SyncSender<Result<(), KvBackendError>>,
|
||||||
|
},
|
||||||
|
Shutdown,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ToastyKvBackend {
|
||||||
|
pub(crate) fn start(persistence: Persistence) -> io::Result<Self> {
|
||||||
|
let (sender, receiver) = mpsc::sync_channel(COMMAND_CAPACITY);
|
||||||
|
let (ready_sender, ready_receiver) = mpsc::sync_channel(1);
|
||||||
|
let worker = thread::Builder::new()
|
||||||
|
.name("wasmeld-kv-backend".to_owned())
|
||||||
|
.spawn(move || run_worker(persistence, receiver, ready_sender))?;
|
||||||
|
|
||||||
|
match ready_receiver.recv() {
|
||||||
|
Ok(Ok(())) => Ok(Self {
|
||||||
|
inner: Arc::new(BackendInner {
|
||||||
|
sender,
|
||||||
|
worker: Mutex::new(Some(worker)),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
Ok(Err(error)) => {
|
||||||
|
let _ = worker.join();
|
||||||
|
Err(error)
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
let _ = worker.join();
|
||||||
|
Err(io::Error::other("KV backend worker stopped during startup"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn request<T>(
|
||||||
|
&self,
|
||||||
|
timeout: Duration,
|
||||||
|
build: impl FnOnce(SyncSender<Result<T, KvBackendError>>) -> KvCommand,
|
||||||
|
) -> Result<T, KvBackendError> {
|
||||||
|
let (response, receiver) = mpsc::sync_channel(1);
|
||||||
|
match self.inner.sender.try_send(build(response)) {
|
||||||
|
Ok(()) => {}
|
||||||
|
Err(TrySendError::Full(_)) => return Err(KvBackendError::Busy),
|
||||||
|
Err(TrySendError::Disconnected(_)) => {
|
||||||
|
return Err(KvBackendError::Operation(
|
||||||
|
"backend worker is unavailable".to_owned(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
receiver
|
||||||
|
.recv_timeout(timeout.min(MAX_RESPONSE_TIMEOUT))
|
||||||
|
.map_err(|_| KvBackendError::Timeout)?
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Clone for ToastyKvBackend {
|
||||||
|
fn clone(&self) -> Self {
|
||||||
|
Self {
|
||||||
|
inner: Arc::clone(&self.inner),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Debug for ToastyKvBackend {
|
||||||
|
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
formatter
|
||||||
|
.debug_struct("ToastyKvBackend")
|
||||||
|
.finish_non_exhaustive()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl KvBackend for ToastyKvBackend {
|
||||||
|
fn get(
|
||||||
|
&self,
|
||||||
|
service_id: &str,
|
||||||
|
key: &str,
|
||||||
|
timeout: Duration,
|
||||||
|
) -> Result<Option<Vec<u8>>, KvBackendError> {
|
||||||
|
self.request(timeout, |response| KvCommand::Get {
|
||||||
|
service_id: service_id.to_owned(),
|
||||||
|
key: key.to_owned(),
|
||||||
|
response,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set(
|
||||||
|
&self,
|
||||||
|
service_id: &str,
|
||||||
|
key: &str,
|
||||||
|
value: &[u8],
|
||||||
|
timeout: Duration,
|
||||||
|
) -> Result<(), KvBackendError> {
|
||||||
|
self.request(timeout, |response| KvCommand::Set {
|
||||||
|
service_id: service_id.to_owned(),
|
||||||
|
key: key.to_owned(),
|
||||||
|
value: value.to_vec(),
|
||||||
|
response,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn delete(&self, service_id: &str, key: &str, timeout: Duration) -> Result<(), KvBackendError> {
|
||||||
|
self.request(timeout, |response| KvCommand::Delete {
|
||||||
|
service_id: service_id.to_owned(),
|
||||||
|
key: key.to_owned(),
|
||||||
|
response,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for BackendInner {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
let _ = self.sender.send(KvCommand::Shutdown);
|
||||||
|
if let Ok(worker) = self.worker.get_mut()
|
||||||
|
&& let Some(worker) = worker.take()
|
||||||
|
{
|
||||||
|
let _ = worker.join();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_worker(
|
||||||
|
persistence: Persistence,
|
||||||
|
receiver: Receiver<KvCommand>,
|
||||||
|
ready: SyncSender<io::Result<()>>,
|
||||||
|
) {
|
||||||
|
let runtime = match tokio::runtime::Builder::new_current_thread()
|
||||||
|
.enable_all()
|
||||||
|
.build()
|
||||||
|
{
|
||||||
|
Ok(runtime) => runtime,
|
||||||
|
Err(error) => {
|
||||||
|
let _ = ready.send(Err(error));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let _ = ready.send(Ok(()));
|
||||||
|
|
||||||
|
while let Ok(command) = receiver.recv() {
|
||||||
|
match command {
|
||||||
|
KvCommand::Get {
|
||||||
|
service_id,
|
||||||
|
key,
|
||||||
|
response,
|
||||||
|
} => {
|
||||||
|
let result = runtime
|
||||||
|
.block_on(persistence.kv_get(&service_id, &key))
|
||||||
|
.map_err(operation_failed);
|
||||||
|
let _ = response.send(result);
|
||||||
|
}
|
||||||
|
KvCommand::Set {
|
||||||
|
service_id,
|
||||||
|
key,
|
||||||
|
value,
|
||||||
|
response,
|
||||||
|
} => {
|
||||||
|
let result = runtime
|
||||||
|
.block_on(persistence.kv_set(service_id, key, value))
|
||||||
|
.map_err(operation_failed);
|
||||||
|
let _ = response.send(result);
|
||||||
|
}
|
||||||
|
KvCommand::Delete {
|
||||||
|
service_id,
|
||||||
|
key,
|
||||||
|
response,
|
||||||
|
} => {
|
||||||
|
let result = runtime
|
||||||
|
.block_on(persistence.kv_delete(&service_id, &key))
|
||||||
|
.map_err(operation_failed);
|
||||||
|
let _ = response.send(result);
|
||||||
|
}
|
||||||
|
KvCommand::Shutdown => return,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn operation_failed(error: toasty::Error) -> KvBackendError {
|
||||||
|
tracing::error!(%error, "persistent KV operation failed");
|
||||||
|
KvBackendError::Operation("persistent storage failure".to_owned())
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@
|
|||||||
//! package Registry. Actor memory remains process-local and is never presented
|
//! package Registry. Actor memory remains process-local and is never presented
|
||||||
//! as durable state.
|
//! as durable state.
|
||||||
|
|
||||||
|
mod kv_backend;
|
||||||
mod persistence;
|
mod persistence;
|
||||||
mod wit_registry;
|
mod wit_registry;
|
||||||
|
|
||||||
@@ -19,11 +20,11 @@ use std::{
|
|||||||
|
|
||||||
use axum::{
|
use axum::{
|
||||||
Json, Router,
|
Json, Router,
|
||||||
body::Body,
|
body::{Body, Bytes},
|
||||||
extract::{DefaultBodyLimit, Multipart, Path as AxumPath, State},
|
extract::{DefaultBodyLimit, Multipart, Path as AxumPath, State, rejection::BytesRejection},
|
||||||
http::{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};
|
||||||
@@ -39,16 +40,19 @@ use wasmeld_package::{
|
|||||||
wit_package::{WitPackageError, WitPackageMetadata},
|
wit_package::{WitPackageError, WitPackageMetadata},
|
||||||
};
|
};
|
||||||
use wasmeld_runtime::{
|
use wasmeld_runtime::{
|
||||||
ResourceLimits, Runtime, RuntimeConfig, RuntimeError, ServiceKey, ServiceManifest,
|
CapabilityDescriptor, ResourceLimits, Runtime, RuntimeConfig, RuntimeError, ServiceKey,
|
||||||
|
ServiceManifest,
|
||||||
};
|
};
|
||||||
|
|
||||||
use persistence::{Persistence, StoredEvent, StoredService};
|
use kv_backend::ToastyKvBackend;
|
||||||
|
use persistence::{Persistence, StoredDeployment, StoredEvent, StoredService};
|
||||||
use wit_registry::WitRegistry;
|
use wit_registry::WitRegistry;
|
||||||
pub use wit_registry::WitRegistryError;
|
pub use wit_registry::WitRegistryError;
|
||||||
|
|
||||||
const MAX_EVENTS: usize = 256;
|
const MAX_EVENTS: usize = 256;
|
||||||
const DEFAULT_MAX_ARTIFACT_BYTES: usize = 64 * 1024 * 1024;
|
const DEFAULT_MAX_ARTIFACT_BYTES: usize = 64 * 1024 * 1024;
|
||||||
const DEFAULT_MAX_WIT_PACKAGE_BYTES: usize = 4 * 1024 * 1024;
|
const DEFAULT_MAX_WIT_PACKAGE_BYTES: usize = 4 * 1024 * 1024;
|
||||||
|
type PersistenceSnapshot = (Vec<StoredService>, Vec<StoredEvent>, Vec<StoredDeployment>);
|
||||||
|
|
||||||
/// Filesystem, persistence, upload, and Runtime policy used by [`Console`].
|
/// Filesystem, persistence, upload, and Runtime policy used by [`Console`].
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
@@ -105,12 +109,14 @@ struct ConsoleState {
|
|||||||
runtime_started_at: Option<Instant>,
|
runtime_started_at: Option<Instant>,
|
||||||
next_event_id: u64,
|
next_event_id: u64,
|
||||||
services: BTreeMap<ServiceKey, ServiceRecord>,
|
services: BTreeMap<ServiceKey, ServiceRecord>,
|
||||||
|
deployments: BTreeMap<String, DeploymentRecord>,
|
||||||
events: VecDeque<EventView>,
|
events: VecDeque<EventView>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
struct ServiceRecord {
|
struct ServiceRecord {
|
||||||
manifest: ServiceManifest,
|
manifest: ServiceManifest,
|
||||||
|
capabilities: Vec<CapabilityView>,
|
||||||
status: ServiceStatus,
|
status: ServiceStatus,
|
||||||
updated_at_ms: u64,
|
updated_at_ms: u64,
|
||||||
calls: u64,
|
calls: u64,
|
||||||
@@ -118,8 +124,14 @@ struct ServiceRecord {
|
|||||||
last_latency_ms: Option<u64>,
|
last_latency_ms: Option<u64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
struct DeploymentRecord {
|
||||||
|
active_revision: String,
|
||||||
|
updated_at_ms: u64,
|
||||||
|
}
|
||||||
|
|
||||||
/// Lifecycle status exposed for one registered service version.
|
/// Lifecycle status exposed for one registered service version.
|
||||||
#[derive(Clone, Copy, Debug, Serialize)]
|
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
|
||||||
#[serde(rename_all = "snake_case")]
|
#[serde(rename_all = "snake_case")]
|
||||||
pub enum ServiceStatus {
|
pub enum ServiceStatus {
|
||||||
Running,
|
Running,
|
||||||
@@ -127,6 +139,35 @@ pub enum ServiceStatus {
|
|||||||
Faulted,
|
Faulted,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// API projection of one public service deployment.
|
||||||
|
#[derive(Clone, Debug, Serialize)]
|
||||||
|
pub struct DeploymentView {
|
||||||
|
pub service_id: String,
|
||||||
|
pub active_revision: String,
|
||||||
|
pub status: ServiceStatus,
|
||||||
|
pub updated_at_ms: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Exact versioned host interface imported by a registered Component.
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
|
||||||
|
pub struct CapabilityView {
|
||||||
|
pub interface: String,
|
||||||
|
pub package: String,
|
||||||
|
pub name: String,
|
||||||
|
pub version: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<CapabilityDescriptor> for CapabilityView {
|
||||||
|
fn from(capability: CapabilityDescriptor) -> Self {
|
||||||
|
Self {
|
||||||
|
interface: capability.interface().to_owned(),
|
||||||
|
package: capability.package().to_owned(),
|
||||||
|
name: capability.name().to_owned(),
|
||||||
|
version: capability.version().to_owned(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// API projection of a registered service and its current metrics.
|
/// API projection of a registered service and its current metrics.
|
||||||
#[derive(Clone, Debug, Serialize)]
|
#[derive(Clone, Debug, Serialize)]
|
||||||
pub struct ServiceView {
|
pub struct ServiceView {
|
||||||
@@ -134,6 +175,7 @@ pub struct ServiceView {
|
|||||||
pub revision: String,
|
pub revision: String,
|
||||||
pub artifact: String,
|
pub artifact: String,
|
||||||
pub world: String,
|
pub world: String,
|
||||||
|
pub capabilities: Vec<CapabilityView>,
|
||||||
pub status: ServiceStatus,
|
pub status: ServiceStatus,
|
||||||
pub updated_at_ms: u64,
|
pub updated_at_ms: u64,
|
||||||
pub limits: ResourceLimits,
|
pub limits: ResourceLimits,
|
||||||
@@ -149,6 +191,7 @@ impl ServiceRecord {
|
|||||||
revision: self.manifest.revision.clone(),
|
revision: self.manifest.revision.clone(),
|
||||||
artifact: self.manifest.component.clone(),
|
artifact: self.manifest.component.clone(),
|
||||||
world: self.manifest.world.clone(),
|
world: self.manifest.world.clone(),
|
||||||
|
capabilities: self.capabilities.clone(),
|
||||||
status: self.status,
|
status: self.status,
|
||||||
updated_at_ms: self.updated_at_ms,
|
updated_at_ms: self.updated_at_ms,
|
||||||
limits: self.manifest.limits.clone(),
|
limits: self.manifest.limits.clone(),
|
||||||
@@ -175,6 +218,8 @@ pub struct EventView {
|
|||||||
#[serde(rename_all = "snake_case")]
|
#[serde(rename_all = "snake_case")]
|
||||||
pub enum EventKind {
|
pub enum EventKind {
|
||||||
Registered,
|
Registered,
|
||||||
|
Unregistered,
|
||||||
|
Deployed,
|
||||||
Started,
|
Started,
|
||||||
Stopped,
|
Stopped,
|
||||||
Invoked,
|
Invoked,
|
||||||
@@ -185,6 +230,8 @@ 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::Started => "started",
|
Self::Started => "started",
|
||||||
Self::Stopped => "stopped",
|
Self::Stopped => "stopped",
|
||||||
Self::Invoked => "invoked",
|
Self::Invoked => "invoked",
|
||||||
@@ -195,6 +242,8 @@ 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),
|
||||||
"started" => Ok(Self::Started),
|
"started" => Ok(Self::Started),
|
||||||
"stopped" => Ok(Self::Stopped),
|
"stopped" => Ok(Self::Stopped),
|
||||||
"invoked" => Ok(Self::Invoked),
|
"invoked" => Ok(Self::Invoked),
|
||||||
@@ -233,11 +282,23 @@ struct InvokeRequest {
|
|||||||
input_base64: String,
|
input_base64: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
struct ActivateDeploymentRequest {
|
||||||
|
revision: String,
|
||||||
|
init_config_base64: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize)]
|
#[derive(Debug, Serialize)]
|
||||||
struct ServiceList {
|
struct ServiceList {
|
||||||
services: Vec<ServiceView>,
|
services: Vec<ServiceView>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
struct DeploymentList {
|
||||||
|
deployments: Vec<DeploymentView>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize)]
|
#[derive(Debug, Serialize)]
|
||||||
struct EventList {
|
struct EventList {
|
||||||
events: Vec<EventView>,
|
events: Vec<EventView>,
|
||||||
@@ -271,6 +332,16 @@ struct InvokeResponse {
|
|||||||
latency_ms: u64,
|
latency_ms: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct InvocationResult {
|
||||||
|
output: Vec<u8>,
|
||||||
|
latency_ms: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct GatewayInvocation {
|
||||||
|
revision: String,
|
||||||
|
result: InvocationResult,
|
||||||
|
}
|
||||||
|
|
||||||
/// Errors raised by Console state, persistence, Registry, or Runtime operations.
|
/// Errors raised by Console state, persistence, Registry, or Runtime operations.
|
||||||
#[derive(Debug, Error)]
|
#[derive(Debug, Error)]
|
||||||
pub enum ConsoleError {
|
pub enum ConsoleError {
|
||||||
@@ -280,6 +351,12 @@ pub enum ConsoleError {
|
|||||||
#[error("service revision {0} is not managed by Wasmeld")]
|
#[error("service revision {0} is not managed by Wasmeld")]
|
||||||
ServiceNotFound(ServiceKey),
|
ServiceNotFound(ServiceKey),
|
||||||
|
|
||||||
|
#[error("service {0} has no active deployment")]
|
||||||
|
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,
|
||||||
|
|
||||||
@@ -305,6 +382,9 @@ pub enum ConsoleError {
|
|||||||
#[error("database operation failed: {0}")]
|
#[error("database operation failed: {0}")]
|
||||||
Database(#[from] toasty::Error),
|
Database(#[from] toasty::Error),
|
||||||
|
|
||||||
|
#[error("failed to start persistent KV backend: {0}")]
|
||||||
|
KvBackendStart(#[source] io::Error),
|
||||||
|
|
||||||
#[error("database contains invalid console data: {0}")]
|
#[error("database contains invalid console data: {0}")]
|
||||||
InvalidDatabaseData(String),
|
InvalidDatabaseData(String),
|
||||||
|
|
||||||
@@ -319,10 +399,10 @@ pub enum ConsoleError {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl Console {
|
impl Console {
|
||||||
/// Opens durable stores, restores service metadata, and starts a fresh Runtime.
|
/// Opens durable stores, restores control data, and starts a fresh Runtime.
|
||||||
///
|
///
|
||||||
/// Persisted actors are deliberately restored as stopped services because
|
/// Actors selected by durable deployments are recreated with empty init
|
||||||
/// their Store and linear memory cannot be reconstructed from control data.
|
/// configuration. Their previous Store and linear memory are not restored.
|
||||||
pub async fn new(config: ConsoleConfig) -> Result<Self, ConsoleError> {
|
pub async fn new(config: ConsoleConfig) -> Result<Self, ConsoleError> {
|
||||||
config.registration_limits.validate()?;
|
config.registration_limits.validate()?;
|
||||||
fs::create_dir_all(&config.artifact_dir).map_err(|source| ConsoleError::Storage {
|
fs::create_dir_all(&config.artifact_dir).map_err(|source| ConsoleError::Storage {
|
||||||
@@ -341,9 +421,16 @@ impl Console {
|
|||||||
})?;
|
})?;
|
||||||
}
|
}
|
||||||
let persistence = Persistence::open(&config.database_path).await?;
|
let persistence = Persistence::open(&config.database_path).await?;
|
||||||
|
let mut runtime_config = config.runtime;
|
||||||
|
if runtime_config.kv_backend.is_none() {
|
||||||
|
runtime_config.kv_backend = Some(Arc::new(
|
||||||
|
ToastyKvBackend::start(persistence.clone())
|
||||||
|
.map_err(ConsoleError::KvBackendStart)?,
|
||||||
|
));
|
||||||
|
}
|
||||||
let wit_registry =
|
let wit_registry =
|
||||||
WitRegistry::open(&config.wit_registry_dir, config.max_wit_package_bytes)?;
|
WitRegistry::open(&config.wit_registry_dir, config.max_wit_package_bytes)?;
|
||||||
let (stored_services, mut stored_events) = persistence.load().await?;
|
let (stored_services, mut stored_events, stored_deployments) = persistence.load().await?;
|
||||||
stored_events.sort_unstable_by_key(|event| std::cmp::Reverse(event.id));
|
stored_events.sort_unstable_by_key(|event| std::cmp::Reverse(event.id));
|
||||||
stored_events.truncate(MAX_EVENTS);
|
stored_events.truncate(MAX_EVENTS);
|
||||||
let next_event_id = stored_events
|
let next_event_id = stored_events
|
||||||
@@ -355,10 +442,10 @@ impl Console {
|
|||||||
.map(EventView::try_from)
|
.map(EventView::try_from)
|
||||||
.collect::<Result<VecDeque<_>, _>>()?;
|
.collect::<Result<VecDeque<_>, _>>()?;
|
||||||
|
|
||||||
let runtime = Runtime::new(config.runtime.clone())?;
|
let runtime = Runtime::new(runtime_config.clone())?;
|
||||||
let console = Self {
|
let console = Self {
|
||||||
runtime: RwLock::new(Some(runtime)),
|
runtime: RwLock::new(Some(runtime)),
|
||||||
runtime_config: config.runtime,
|
runtime_config,
|
||||||
artifact_dir,
|
artifact_dir,
|
||||||
wit_registry,
|
wit_registry,
|
||||||
max_artifact_bytes: config.max_artifact_bytes,
|
max_artifact_bytes: config.max_artifact_bytes,
|
||||||
@@ -368,6 +455,7 @@ impl Console {
|
|||||||
runtime_started_at: Some(Instant::now()),
|
runtime_started_at: Some(Instant::now()),
|
||||||
next_event_id,
|
next_event_id,
|
||||||
services: BTreeMap::new(),
|
services: BTreeMap::new(),
|
||||||
|
deployments: BTreeMap::new(),
|
||||||
events,
|
events,
|
||||||
}),
|
}),
|
||||||
persistence,
|
persistence,
|
||||||
@@ -377,6 +465,8 @@ impl Console {
|
|||||||
// then used to recover artifacts that predate or missed a DB snapshot.
|
// then used to recover artifacts that predate or missed a DB snapshot.
|
||||||
console.load_stored_services(stored_services)?;
|
console.load_stored_services(stored_services)?;
|
||||||
console.load_manifest_services()?;
|
console.load_manifest_services()?;
|
||||||
|
console.load_stored_deployments(stored_deployments)?;
|
||||||
|
console.restore_deployments()?;
|
||||||
console.persist_snapshot().await?;
|
console.persist_snapshot().await?;
|
||||||
Ok(console)
|
Ok(console)
|
||||||
}
|
}
|
||||||
@@ -391,6 +481,11 @@ impl Console {
|
|||||||
self.wit_registry.max_package_bytes()
|
self.wit_registry.max_package_bytes()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Returns the maximum raw request size accepted by the Gateway.
|
||||||
|
pub fn max_gateway_input_bytes(&self) -> usize {
|
||||||
|
self.registration_limits.max_input_bytes
|
||||||
|
}
|
||||||
|
|
||||||
/// Returns registered service versions in stable key order.
|
/// Returns registered service versions in stable key order.
|
||||||
pub fn services(&self) -> Result<Vec<ServiceView>, ConsoleError> {
|
pub fn services(&self) -> Result<Vec<ServiceView>, ConsoleError> {
|
||||||
let state = self.state()?;
|
let state = self.state()?;
|
||||||
@@ -403,6 +498,26 @@ impl Console {
|
|||||||
Ok(state.events.iter().cloned().collect())
|
Ok(state.events.iter().cloned().collect())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Returns active deployments in stable service id order.
|
||||||
|
pub fn deployments(&self) -> Result<Vec<DeploymentView>, ConsoleError> {
|
||||||
|
let state = self.state()?;
|
||||||
|
state
|
||||||
|
.deployments
|
||||||
|
.iter()
|
||||||
|
.map(|(service_id, deployment)| deployment_view(&state, service_id, deployment))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the active deployment for one public service id.
|
||||||
|
pub fn deployment(&self, service_id: &str) -> Result<DeploymentView, ConsoleError> {
|
||||||
|
let state = self.state()?;
|
||||||
|
let deployment = state
|
||||||
|
.deployments
|
||||||
|
.get(service_id)
|
||||||
|
.ok_or_else(|| ConsoleError::DeploymentNotFound(service_id.to_owned()))?;
|
||||||
|
deployment_view(&state, service_id, deployment)
|
||||||
|
}
|
||||||
|
|
||||||
fn runtime_view(&self) -> Result<RuntimeView, ConsoleError> {
|
fn runtime_view(&self) -> Result<RuntimeView, ConsoleError> {
|
||||||
let runtime = self.runtime()?;
|
let runtime = self.runtime()?;
|
||||||
let state = self.state()?;
|
let state = self.state()?;
|
||||||
@@ -555,24 +670,52 @@ impl Console {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn build_runtime(&self) -> Result<Runtime, ConsoleError> {
|
fn build_runtime(&self) -> Result<Runtime, ConsoleError> {
|
||||||
let manifests = self
|
let state = self.state()?;
|
||||||
.state()?
|
let manifests = state
|
||||||
.services
|
.services
|
||||||
.values()
|
.values()
|
||||||
.map(|service| service.manifest.clone())
|
.map(|service| service.manifest.clone())
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
|
let deployments = state
|
||||||
|
.deployments
|
||||||
|
.iter()
|
||||||
|
.map(|(service_id, deployment)| {
|
||||||
|
ServiceKey::new(service_id.clone(), deployment.active_revision.clone())
|
||||||
|
})
|
||||||
|
.collect::<Result<Vec<_>, _>>()?;
|
||||||
|
drop(state);
|
||||||
|
|
||||||
let runtime = Runtime::new(self.runtime_config.clone())?;
|
let runtime = Runtime::new(self.runtime_config.clone())?;
|
||||||
for manifest in manifests {
|
for manifest in manifests {
|
||||||
runtime.register_from_file(manifest)?;
|
runtime.register_from_file(manifest)?;
|
||||||
}
|
}
|
||||||
|
for key in deployments {
|
||||||
|
runtime.start(&key, Vec::new())?;
|
||||||
|
}
|
||||||
Ok(runtime)
|
Ok(runtime)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn mark_runtime_started(&self) -> Result<(), ConsoleError> {
|
fn mark_runtime_started(&self) -> Result<(), ConsoleError> {
|
||||||
let mut state = self.state()?;
|
let mut state = self.state()?;
|
||||||
|
let deployed = state
|
||||||
|
.deployments
|
||||||
|
.iter()
|
||||||
|
.map(|(service_id, deployment)| {
|
||||||
|
ServiceKey::new(service_id.clone(), deployment.active_revision.clone())
|
||||||
|
})
|
||||||
|
.collect::<Result<Vec<_>, _>>()?;
|
||||||
|
let updated_at_ms = unix_time_ms();
|
||||||
state.runtime_started_at = Some(Instant::now());
|
state.runtime_started_at = Some(Instant::now());
|
||||||
for service in state.services.values_mut() {
|
for (key, service) in &mut state.services {
|
||||||
service.status = ServiceStatus::Stopped;
|
let status = if deployed.contains(key) {
|
||||||
|
ServiceStatus::Running
|
||||||
|
} else {
|
||||||
|
ServiceStatus::Stopped
|
||||||
|
};
|
||||||
|
if service.status != status {
|
||||||
|
service.status = status;
|
||||||
|
service.updated_at_ms = updated_at_ms;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -642,8 +785,9 @@ impl Console {
|
|||||||
let _ = fs::remove_file(&manifest_path);
|
let _ = fs::remove_file(&manifest_path);
|
||||||
return Err(error.into());
|
return Err(error.into());
|
||||||
}
|
}
|
||||||
|
let capabilities = runtime_capabilities(runtime, &key)?;
|
||||||
|
|
||||||
let view = self.insert_service(manifest, ServiceStatus::Stopped)?;
|
let view = self.insert_service(manifest, capabilities, ServiceStatus::Stopped)?;
|
||||||
self.push_event(
|
self.push_event(
|
||||||
EventKind::Registered,
|
EventKind::Registered,
|
||||||
Some(&key),
|
Some(&key),
|
||||||
@@ -652,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)?;
|
||||||
@@ -686,7 +874,55 @@ impl Console {
|
|||||||
Ok(view)
|
Ok(view)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn invoke(&self, key: &ServiceKey, input: Vec<u8>) -> Result<InvokeResponse, ConsoleError> {
|
fn activate_deployment(
|
||||||
|
&self,
|
||||||
|
service_id: String,
|
||||||
|
revision: String,
|
||||||
|
init_config: Vec<u8>,
|
||||||
|
) -> Result<DeploymentView, ConsoleError> {
|
||||||
|
validate_path_segment("id", &service_id)?;
|
||||||
|
validate_path_segment("revision", &revision)?;
|
||||||
|
let key = ServiceKey::new(service_id.clone(), revision.clone())?;
|
||||||
|
let _lifecycle = self
|
||||||
|
.lifecycle
|
||||||
|
.lock()
|
||||||
|
.map_err(|_| ConsoleError::LockPoisoned)?;
|
||||||
|
let runtime = self.runtime()?;
|
||||||
|
let runtime = runtime.as_ref().ok_or(ConsoleError::RuntimeNotRunning)?;
|
||||||
|
self.ensure_managed(&key)?;
|
||||||
|
|
||||||
|
// Starting is idempotent. The old revision stays resident so calls
|
||||||
|
// that resolved immediately before this switch can finish safely and
|
||||||
|
// rollback does not require recompilation.
|
||||||
|
runtime.start(&key, init_config)?;
|
||||||
|
|
||||||
|
let updated_at_ms = unix_time_ms();
|
||||||
|
let view = {
|
||||||
|
let mut state = self.state()?;
|
||||||
|
let service = state
|
||||||
|
.services
|
||||||
|
.get_mut(&key)
|
||||||
|
.ok_or_else(|| ConsoleError::ServiceNotFound(key.clone()))?;
|
||||||
|
service.status = ServiceStatus::Running;
|
||||||
|
service.updated_at_ms = updated_at_ms;
|
||||||
|
let deployment = DeploymentRecord {
|
||||||
|
active_revision: revision,
|
||||||
|
updated_at_ms,
|
||||||
|
};
|
||||||
|
state
|
||||||
|
.deployments
|
||||||
|
.insert(service_id.clone(), deployment.clone());
|
||||||
|
deployment_view(&state, &service_id, &deployment)?
|
||||||
|
};
|
||||||
|
self.push_event(
|
||||||
|
EventKind::Deployed,
|
||||||
|
Some(&key),
|
||||||
|
"deployment activated".to_owned(),
|
||||||
|
)?;
|
||||||
|
Ok(view)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn invoke(&self, key: &ServiceKey, input: Vec<u8>) -> Result<InvocationResult, 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)?;
|
||||||
self.ensure_managed(key)?;
|
self.ensure_managed(key)?;
|
||||||
@@ -711,11 +947,7 @@ impl Console {
|
|||||||
Some(key),
|
Some(key),
|
||||||
format!("invoke completed in {latency_ms} ms"),
|
format!("invoke completed in {latency_ms} ms"),
|
||||||
)?;
|
)?;
|
||||||
Ok(InvokeResponse {
|
Ok(InvocationResult { output, latency_ms })
|
||||||
output_base64: BASE64.encode(&output),
|
|
||||||
output_bytes: output.len(),
|
|
||||||
latency_ms,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
record.errors = record.errors.saturating_add(1);
|
record.errors = record.errors.saturating_add(1);
|
||||||
@@ -729,6 +961,24 @@ impl Console {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn gateway_invoke(
|
||||||
|
&self,
|
||||||
|
service_id: &str,
|
||||||
|
input: Vec<u8>,
|
||||||
|
) -> Result<GatewayInvocation, ConsoleError> {
|
||||||
|
let revision = {
|
||||||
|
let state = self.state()?;
|
||||||
|
state
|
||||||
|
.deployments
|
||||||
|
.get(service_id)
|
||||||
|
.map(|deployment| deployment.active_revision.clone())
|
||||||
|
.ok_or_else(|| ConsoleError::DeploymentNotFound(service_id.to_owned()))?
|
||||||
|
};
|
||||||
|
let key = ServiceKey::new(service_id.to_owned(), revision.clone())?;
|
||||||
|
let result = self.invoke(&key, input)?;
|
||||||
|
Ok(GatewayInvocation { revision, result })
|
||||||
|
}
|
||||||
|
|
||||||
fn load_stored_services(
|
fn load_stored_services(
|
||||||
&self,
|
&self,
|
||||||
stored_services: Vec<StoredService>,
|
stored_services: Vec<StoredService>,
|
||||||
@@ -747,10 +997,12 @@ impl Console {
|
|||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
runtime.register_from_file(manifest.clone())?;
|
runtime.register_from_file(manifest.clone())?;
|
||||||
|
let capabilities = runtime_capabilities(runtime, &key)?;
|
||||||
self.state()?.services.insert(
|
self.state()?.services.insert(
|
||||||
key,
|
key,
|
||||||
ServiceRecord {
|
ServiceRecord {
|
||||||
manifest,
|
manifest,
|
||||||
|
capabilities,
|
||||||
status: ServiceStatus::Stopped,
|
status: ServiceStatus::Stopped,
|
||||||
updated_at_ms: stored.updated_at_ms,
|
updated_at_ms: stored.updated_at_ms,
|
||||||
calls: stored.calls,
|
calls: stored.calls,
|
||||||
@@ -793,22 +1045,79 @@ impl Console {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
runtime.register_from_file(manifest.clone())?;
|
runtime.register_from_file(manifest.clone())?;
|
||||||
self.insert_service(manifest, ServiceStatus::Stopped)?;
|
let capabilities = runtime_capabilities(runtime, &key)?;
|
||||||
|
self.insert_service(manifest, capabilities, ServiceStatus::Stopped)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn load_stored_deployments(
|
||||||
|
&self,
|
||||||
|
stored_deployments: Vec<StoredDeployment>,
|
||||||
|
) -> Result<(), ConsoleError> {
|
||||||
|
let mut state = self.state()?;
|
||||||
|
for stored in stored_deployments {
|
||||||
|
validate_path_segment("id", &stored.service_id)?;
|
||||||
|
validate_path_segment("revision", &stored.active_revision)?;
|
||||||
|
let key = ServiceKey::new(stored.service_id.clone(), stored.active_revision.clone())?;
|
||||||
|
if !state.services.contains_key(&key) {
|
||||||
|
return Err(ConsoleError::InvalidDatabaseData(format!(
|
||||||
|
"deployment for {:?} references unmanaged revision {key}",
|
||||||
|
stored.service_id
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
state.deployments.insert(
|
||||||
|
stored.service_id,
|
||||||
|
DeploymentRecord {
|
||||||
|
active_revision: stored.active_revision,
|
||||||
|
updated_at_ms: stored.updated_at_ms,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn restore_deployments(&self) -> Result<(), ConsoleError> {
|
||||||
|
let deployments = {
|
||||||
|
let state = self.state()?;
|
||||||
|
state
|
||||||
|
.deployments
|
||||||
|
.iter()
|
||||||
|
.map(|(service_id, deployment)| {
|
||||||
|
ServiceKey::new(service_id.clone(), deployment.active_revision.clone())
|
||||||
|
})
|
||||||
|
.collect::<Result<Vec<_>, _>>()?
|
||||||
|
};
|
||||||
|
{
|
||||||
|
let runtime = self.runtime()?;
|
||||||
|
let runtime = runtime.as_ref().ok_or(ConsoleError::RuntimeNotRunning)?;
|
||||||
|
for key in &deployments {
|
||||||
|
runtime.start(key, Vec::new())?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut state = self.state()?;
|
||||||
|
for key in deployments {
|
||||||
|
let service = state
|
||||||
|
.services
|
||||||
|
.get_mut(&key)
|
||||||
|
.ok_or_else(|| ConsoleError::ServiceNotFound(key.clone()))?;
|
||||||
|
service.status = ServiceStatus::Running;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
async fn persist_snapshot(&self) -> Result<(), ConsoleError> {
|
async fn persist_snapshot(&self) -> Result<(), ConsoleError> {
|
||||||
// Serialize snapshots so a slower earlier write cannot overwrite a
|
// Serialize snapshots so a slower earlier write cannot overwrite a
|
||||||
// newer state captured by a concurrent HTTP operation.
|
// newer state captured by a concurrent HTTP operation.
|
||||||
let _sync = self.persistence_sync.lock().await;
|
let _sync = self.persistence_sync.lock().await;
|
||||||
let (services, events) = self.persistence_snapshot()?;
|
let (services, events, deployments) = self.persistence_snapshot()?;
|
||||||
self.persistence.sync(services, events).await?;
|
self.persistence.sync(services, events, deployments).await?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn persistence_snapshot(&self) -> Result<(Vec<StoredService>, Vec<StoredEvent>), ConsoleError> {
|
fn persistence_snapshot(&self) -> Result<PersistenceSnapshot, ConsoleError> {
|
||||||
let state = self.state()?;
|
let state = self.state()?;
|
||||||
let services = state
|
let services = state
|
||||||
.services
|
.services
|
||||||
@@ -836,18 +1145,29 @@ impl Console {
|
|||||||
message: event.message.clone(),
|
message: event.message.clone(),
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
Ok((services, events))
|
let deployments = state
|
||||||
|
.deployments
|
||||||
|
.iter()
|
||||||
|
.map(|(service_id, deployment)| StoredDeployment {
|
||||||
|
service_id: service_id.clone(),
|
||||||
|
active_revision: deployment.active_revision.clone(),
|
||||||
|
updated_at_ms: deployment.updated_at_ms,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
Ok((services, events, deployments))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn insert_service(
|
fn insert_service(
|
||||||
&self,
|
&self,
|
||||||
manifest: ServiceManifest,
|
manifest: ServiceManifest,
|
||||||
|
capabilities: Vec<CapabilityView>,
|
||||||
status: ServiceStatus,
|
status: ServiceStatus,
|
||||||
) -> Result<ServiceView, ConsoleError> {
|
) -> Result<ServiceView, ConsoleError> {
|
||||||
let key = manifest.key()?;
|
let key = manifest.key()?;
|
||||||
let mut state = self.state()?;
|
let mut state = self.state()?;
|
||||||
let record = ServiceRecord {
|
let record = ServiceRecord {
|
||||||
manifest,
|
manifest,
|
||||||
|
capabilities,
|
||||||
status,
|
status,
|
||||||
updated_at_ms: unix_time_ms(),
|
updated_at_ms: unix_time_ms(),
|
||||||
calls: 0,
|
calls: 0,
|
||||||
@@ -912,6 +1232,35 @@ impl Console {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn runtime_capabilities(
|
||||||
|
runtime: &Runtime,
|
||||||
|
key: &ServiceKey,
|
||||||
|
) -> Result<Vec<CapabilityView>, ConsoleError> {
|
||||||
|
Ok(runtime
|
||||||
|
.capabilities(key)?
|
||||||
|
.into_iter()
|
||||||
|
.map(Into::into)
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn deployment_view(
|
||||||
|
state: &ConsoleState,
|
||||||
|
service_id: &str,
|
||||||
|
deployment: &DeploymentRecord,
|
||||||
|
) -> Result<DeploymentView, ConsoleError> {
|
||||||
|
let key = ServiceKey::new(service_id.to_owned(), deployment.active_revision.clone())?;
|
||||||
|
let service = state
|
||||||
|
.services
|
||||||
|
.get(&key)
|
||||||
|
.ok_or_else(|| ConsoleError::ServiceNotFound(key))?;
|
||||||
|
Ok(DeploymentView {
|
||||||
|
service_id: service_id.to_owned(),
|
||||||
|
active_revision: deployment.active_revision.clone(),
|
||||||
|
status: service.status,
|
||||||
|
updated_at_ms: deployment.updated_at_ms,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/// Builds the Axum management API around a shared [`Console`].
|
/// Builds the Axum management API around a shared [`Console`].
|
||||||
///
|
///
|
||||||
/// The returned router exposes Runtime control, component registration,
|
/// The returned router exposes Runtime control, component registration,
|
||||||
@@ -922,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);
|
||||||
@@ -935,6 +1284,16 @@ 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/{id}", get(get_deployment))
|
||||||
|
.route(
|
||||||
|
"/api/v1/deployments/{id}/activate",
|
||||||
|
post(activate_deployment),
|
||||||
|
)
|
||||||
.route(
|
.route(
|
||||||
"/api/v1/wit/packages",
|
"/api/v1/wit/packages",
|
||||||
get(list_wit_packages).post(publish_wit_package),
|
get(list_wit_packages).post(publish_wit_package),
|
||||||
@@ -971,10 +1330,71 @@ pub fn app(console: Arc<Console>, allowed_origins: Vec<HeaderValue>) -> Router {
|
|||||||
.with_state(console)
|
.with_state(console)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Builds the public data-plane API around the same resident Runtime.
|
||||||
|
///
|
||||||
|
/// This router intentionally contains no registration, lifecycle, Registry,
|
||||||
|
/// or deployment management routes.
|
||||||
|
pub fn gateway_app(console: Arc<Console>) -> Router {
|
||||||
|
let max_input_bytes = console.max_gateway_input_bytes();
|
||||||
|
Router::new()
|
||||||
|
.route("/healthz", get(health))
|
||||||
|
.route("/v1/services/{id}/invoke", post(gateway_invoke))
|
||||||
|
.layer(DefaultBodyLimit::max(max_input_bytes))
|
||||||
|
.layer(
|
||||||
|
TraceLayer::new_for_http()
|
||||||
|
.make_span_with(DefaultMakeSpan::new().level(Level::INFO))
|
||||||
|
.on_response(DefaultOnResponse::new().level(Level::INFO)),
|
||||||
|
)
|
||||||
|
.with_state(console)
|
||||||
|
}
|
||||||
|
|
||||||
async fn health() -> Json<serde_json::Value> {
|
async fn health() -> Json<serde_json::Value> {
|
||||||
Json(json!({ "status": "ok" }))
|
Json(json!({ "status": "ok" }))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn gateway_invoke(
|
||||||
|
State(console): State<Arc<Console>>,
|
||||||
|
AxumPath(service_id): AxumPath<String>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
body: Result<Bytes, BytesRejection>,
|
||||||
|
) -> Result<Response, GatewayError> {
|
||||||
|
validate_path_segment("id", &service_id)?;
|
||||||
|
let content_type = headers
|
||||||
|
.get(header::CONTENT_TYPE)
|
||||||
|
.and_then(|value| value.to_str().ok())
|
||||||
|
.and_then(|value| value.split(';').next())
|
||||||
|
.map(str::trim);
|
||||||
|
if !content_type.is_some_and(|value| value.eq_ignore_ascii_case("application/octet-stream")) {
|
||||||
|
return Err(GatewayError::UnsupportedMediaType);
|
||||||
|
}
|
||||||
|
let input = body.map_err(|_| GatewayError::PayloadTooLarge {
|
||||||
|
limit: console.max_gateway_input_bytes(),
|
||||||
|
})?;
|
||||||
|
let invocation = run_blocking(console, move |console| {
|
||||||
|
console.gateway_invoke(&service_id, input.to_vec())
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let mut response_headers = HeaderMap::new();
|
||||||
|
response_headers.insert(
|
||||||
|
header::CONTENT_TYPE,
|
||||||
|
HeaderValue::from_static("application/octet-stream"),
|
||||||
|
);
|
||||||
|
response_headers.insert(
|
||||||
|
"x-wasmeld-revision",
|
||||||
|
HeaderValue::from_str(&invocation.revision).map_err(|_| {
|
||||||
|
ConsoleError::InvalidRequest("revision is not a valid header".to_owned())
|
||||||
|
})?,
|
||||||
|
);
|
||||||
|
response_headers.insert(
|
||||||
|
"x-wasmeld-latency-ms",
|
||||||
|
HeaderValue::from_str(&invocation.result.latency_ms.to_string()).map_err(|_| {
|
||||||
|
ConsoleError::InvalidRequest("latency is not a valid header".to_owned())
|
||||||
|
})?,
|
||||||
|
);
|
||||||
|
Ok((response_headers, invocation.result.output).into_response())
|
||||||
|
}
|
||||||
|
|
||||||
async fn runtime_status(
|
async fn runtime_status(
|
||||||
State(console): State<Arc<Console>>,
|
State(console): State<Arc<Console>>,
|
||||||
) -> Result<Json<RuntimeView>, ApiError> {
|
) -> Result<Json<RuntimeView>, ApiError> {
|
||||||
@@ -1007,6 +1427,38 @@ async fn list_services(State(console): State<Arc<Console>>) -> Result<Json<Servi
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn list_deployments(
|
||||||
|
State(console): State<Arc<Console>>,
|
||||||
|
) -> Result<Json<DeploymentList>, ApiError> {
|
||||||
|
Ok(Json(DeploymentList {
|
||||||
|
deployments: console.deployments()?,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_deployment(
|
||||||
|
State(console): State<Arc<Console>>,
|
||||||
|
AxumPath(service_id): AxumPath<String>,
|
||||||
|
) -> Result<Json<DeploymentView>, ApiError> {
|
||||||
|
validate_path_segment("id", &service_id)?;
|
||||||
|
Ok(Json(console.deployment(&service_id)?))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn activate_deployment(
|
||||||
|
State(console): State<Arc<Console>>,
|
||||||
|
AxumPath(service_id): AxumPath<String>,
|
||||||
|
Json(request): Json<ActivateDeploymentRequest>,
|
||||||
|
) -> Result<Json<DeploymentView>, ApiError> {
|
||||||
|
validate_path_segment("id", &service_id)?;
|
||||||
|
validate_path_segment("revision", &request.revision)?;
|
||||||
|
let init_config = decode_optional_base64(request.init_config_base64.as_deref())?;
|
||||||
|
Ok(Json(
|
||||||
|
run_blocking(console, move |console| {
|
||||||
|
console.activate_deployment(service_id, request.revision, init_config)
|
||||||
|
})
|
||||||
|
.await?,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
async fn list_events(State(console): State<Arc<Console>>) -> Result<Json<EventList>, ApiError> {
|
async fn list_events(State(console): State<Arc<Console>>) -> Result<Json<EventList>, ApiError> {
|
||||||
Ok(Json(EventList {
|
Ok(Json(EventList {
|
||||||
events: console.events()?,
|
events: console.events()?,
|
||||||
@@ -1175,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)>,
|
||||||
@@ -1200,9 +1662,12 @@ async fn invoke_service(
|
|||||||
let input = BASE64.decode(request.input_base64).map_err(|error| {
|
let input = BASE64.decode(request.input_base64).map_err(|error| {
|
||||||
ConsoleError::InvalidRequest(format!("input_base64 is invalid: {error}"))
|
ConsoleError::InvalidRequest(format!("input_base64 is invalid: {error}"))
|
||||||
})?;
|
})?;
|
||||||
Ok(Json(
|
let result = run_blocking(console, move |console| console.invoke(&key, input)).await?;
|
||||||
run_blocking(console, move |console| console.invoke(&key, input)).await?,
|
Ok(Json(InvokeResponse {
|
||||||
))
|
output_base64: BASE64.encode(&result.output),
|
||||||
|
output_bytes: result.output.len(),
|
||||||
|
latency_ms: result.latency_ms,
|
||||||
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn run_blocking<T, F>(console: Arc<Console>, operation: F) -> Result<T, ConsoleError>
|
async fn run_blocking<T, F>(console: Arc<Console>, operation: F) -> Result<T, ConsoleError>
|
||||||
@@ -1298,6 +1763,106 @@ fn duration_ms(duration: std::time::Duration) -> u64 {
|
|||||||
u64::try_from(duration.as_millis()).unwrap_or(u64::MAX)
|
u64::try_from(duration.as_millis()).unwrap_or(u64::MAX)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum GatewayError {
|
||||||
|
Console(ConsoleError),
|
||||||
|
UnsupportedMediaType,
|
||||||
|
PayloadTooLarge { limit: usize },
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<ConsoleError> for GatewayError {
|
||||||
|
fn from(error: ConsoleError) -> Self {
|
||||||
|
Self::Console(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl IntoResponse for GatewayError {
|
||||||
|
fn into_response(self) -> Response {
|
||||||
|
let (status, code, message) = match self {
|
||||||
|
Self::UnsupportedMediaType => (
|
||||||
|
StatusCode::UNSUPPORTED_MEDIA_TYPE,
|
||||||
|
"unsupported_media_type",
|
||||||
|
"Content-Type must be application/octet-stream".to_owned(),
|
||||||
|
),
|
||||||
|
Self::PayloadTooLarge { limit } => (
|
||||||
|
StatusCode::PAYLOAD_TOO_LARGE,
|
||||||
|
"payload_too_large",
|
||||||
|
format!("request body exceeds the {limit}-byte limit"),
|
||||||
|
),
|
||||||
|
Self::Console(error) => {
|
||||||
|
let (status, code, message) = match &error {
|
||||||
|
ConsoleError::InvalidRequest(_) => (
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
"invalid_request",
|
||||||
|
error.to_string(),
|
||||||
|
),
|
||||||
|
ConsoleError::DeploymentNotFound(_)
|
||||||
|
| ConsoleError::ServiceNotFound(_)
|
||||||
|
| ConsoleError::Runtime(RuntimeError::ServiceNotRegistered(_)) => (
|
||||||
|
StatusCode::NOT_FOUND,
|
||||||
|
"service_not_found",
|
||||||
|
"service is not deployed".to_owned(),
|
||||||
|
),
|
||||||
|
ConsoleError::RuntimeNotRunning
|
||||||
|
| ConsoleError::Runtime(RuntimeError::ActorUnavailable(_))
|
||||||
|
| ConsoleError::Runtime(RuntimeError::ActorStopped(_)) => (
|
||||||
|
StatusCode::SERVICE_UNAVAILABLE,
|
||||||
|
"service_unavailable",
|
||||||
|
"service is temporarily unavailable".to_owned(),
|
||||||
|
),
|
||||||
|
ConsoleError::Runtime(RuntimeError::ActorOverloaded(_)) => (
|
||||||
|
StatusCode::TOO_MANY_REQUESTS,
|
||||||
|
"service_overloaded",
|
||||||
|
"service is overloaded".to_owned(),
|
||||||
|
),
|
||||||
|
ConsoleError::Runtime(RuntimeError::InputTooLarge { .. }) => (
|
||||||
|
StatusCode::PAYLOAD_TOO_LARGE,
|
||||||
|
"payload_too_large",
|
||||||
|
error.to_string(),
|
||||||
|
),
|
||||||
|
ConsoleError::Runtime(RuntimeError::DeadlineExceeded { .. }) => (
|
||||||
|
StatusCode::GATEWAY_TIMEOUT,
|
||||||
|
"deadline_exceeded",
|
||||||
|
"service execution deadline exceeded".to_owned(),
|
||||||
|
),
|
||||||
|
ConsoleError::Runtime(
|
||||||
|
RuntimeError::OutputTooLarge { .. }
|
||||||
|
| RuntimeError::ActorFault { .. }
|
||||||
|
| RuntimeError::ComponentError { .. },
|
||||||
|
) => (StatusCode::BAD_GATEWAY, "service_error", error.to_string()),
|
||||||
|
ConsoleError::ArtifactTooLarge { .. }
|
||||||
|
| ConsoleError::ActiveDeployment(_)
|
||||||
|
| ConsoleError::Package(_)
|
||||||
|
| ConsoleError::WitRegistry(_)
|
||||||
|
| ConsoleError::Storage { .. }
|
||||||
|
| ConsoleError::ManifestSerialize(_)
|
||||||
|
| ConsoleError::Database(_)
|
||||||
|
| ConsoleError::KvBackendStart(_)
|
||||||
|
| ConsoleError::InvalidDatabaseData(_)
|
||||||
|
| ConsoleError::Runtime(_)
|
||||||
|
| ConsoleError::Join(_)
|
||||||
|
| ConsoleError::LockPoisoned => (
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
"internal_error",
|
||||||
|
"internal gateway error".to_owned(),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
(status, code, message)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
(
|
||||||
|
status,
|
||||||
|
Json(json!({
|
||||||
|
"error": {
|
||||||
|
"code": code,
|
||||||
|
"message": message,
|
||||||
|
}
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
.into_response()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
struct ApiError(ConsoleError);
|
struct ApiError(ConsoleError);
|
||||||
|
|
||||||
impl From<ConsoleError> for ApiError {
|
impl From<ConsoleError> for ApiError {
|
||||||
@@ -1317,6 +1882,8 @@ impl IntoResponse for ApiError {
|
|||||||
let (status, code) = match &self.0 {
|
let (status, code) = match &self.0 {
|
||||||
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::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 { .. })
|
||||||
@@ -1361,6 +1928,7 @@ impl IntoResponse for ApiError {
|
|||||||
ConsoleError::Storage { .. }
|
ConsoleError::Storage { .. }
|
||||||
| ConsoleError::ManifestSerialize(_)
|
| ConsoleError::ManifestSerialize(_)
|
||||||
| ConsoleError::Database(_)
|
| ConsoleError::Database(_)
|
||||||
|
| ConsoleError::KvBackendStart(_)
|
||||||
| ConsoleError::InvalidDatabaseData(_)
|
| ConsoleError::InvalidDatabaseData(_)
|
||||||
| ConsoleError::Runtime(_)
|
| ConsoleError::Runtime(_)
|
||||||
| ConsoleError::WitRegistry(WitRegistryError::InvalidStorage(_))
|
| ConsoleError::WitRegistry(WitRegistryError::InvalidStorage(_))
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
//! Standalone Wasmeld management server entry point.
|
//! Standalone Wasmeld control-plane and data-plane server entry point.
|
||||||
|
|
||||||
use std::{env, net::SocketAddr, path::PathBuf, sync::Arc};
|
use std::{env, net::SocketAddr, path::PathBuf, sync::Arc};
|
||||||
|
|
||||||
use axum::http::HeaderValue;
|
use axum::http::HeaderValue;
|
||||||
use tracing::info;
|
use tracing::info;
|
||||||
use tracing_subscriber::EnvFilter;
|
use tracing_subscriber::EnvFilter;
|
||||||
use wasmeld_console::{Console, ConsoleConfig, app};
|
use wasmeld_console::{Console, ConsoleConfig, app, gateway_app};
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
@@ -16,9 +16,12 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
)
|
)
|
||||||
.init();
|
.init();
|
||||||
|
|
||||||
let address = env::var("WASMELD_ADDR")
|
let management_address = env::var("WASMELD_ADDR")
|
||||||
.unwrap_or_else(|_| "127.0.0.1:8080".to_owned())
|
.unwrap_or_else(|_| "127.0.0.1:8080".to_owned())
|
||||||
.parse::<SocketAddr>()?;
|
.parse::<SocketAddr>()?;
|
||||||
|
let gateway_address = env::var("WASMELD_GATEWAY_ADDR")
|
||||||
|
.unwrap_or_else(|_| "0.0.0.0:8081".to_owned())
|
||||||
|
.parse::<SocketAddr>()?;
|
||||||
let artifact_dir = env::var_os("WASMELD_ARTIFACT_DIR")
|
let artifact_dir = env::var_os("WASMELD_ARTIFACT_DIR")
|
||||||
.map(PathBuf::from)
|
.map(PathBuf::from)
|
||||||
.unwrap_or_else(|| PathBuf::from("var/wasmeld/components"));
|
.unwrap_or_else(|| PathBuf::from("var/wasmeld/components"));
|
||||||
@@ -38,13 +41,26 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
})
|
})
|
||||||
.await?,
|
.await?,
|
||||||
);
|
);
|
||||||
let application = app(console, allowed_origins);
|
let management = app(Arc::clone(&console), allowed_origins);
|
||||||
let listener = tokio::net::TcpListener::bind(address).await?;
|
let gateway = gateway_app(console);
|
||||||
|
let management_listener = tokio::net::TcpListener::bind(management_address).await?;
|
||||||
|
let gateway_listener = tokio::net::TcpListener::bind(gateway_address).await?;
|
||||||
|
let (shutdown_sender, shutdown_receiver) = tokio::sync::watch::channel(false);
|
||||||
|
let signal_task = tokio::spawn(async move {
|
||||||
|
shutdown_signal().await;
|
||||||
|
let _ = shutdown_sender.send(true);
|
||||||
|
});
|
||||||
|
|
||||||
info!(%address, "Wasmeld listening");
|
info!(%management_address, "Wasmeld management API listening");
|
||||||
axum::serve(listener, application)
|
info!(%gateway_address, "Wasmeld Gateway listening");
|
||||||
.with_graceful_shutdown(shutdown_signal())
|
let result = tokio::try_join!(
|
||||||
.await?;
|
axum::serve(management_listener, management)
|
||||||
|
.with_graceful_shutdown(wait_for_shutdown(shutdown_receiver.clone())),
|
||||||
|
axum::serve(gateway_listener, gateway)
|
||||||
|
.with_graceful_shutdown(wait_for_shutdown(shutdown_receiver)),
|
||||||
|
);
|
||||||
|
signal_task.abort();
|
||||||
|
result?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,3 +79,11 @@ async fn shutdown_signal() {
|
|||||||
tracing::warn!("failed to install Ctrl+C handler");
|
tracing::warn!("failed to install Ctrl+C handler");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn wait_for_shutdown(mut receiver: tokio::sync::watch::Receiver<bool>) {
|
||||||
|
while !*receiver.borrow() {
|
||||||
|
if receiver.changed().await.is_err() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,9 +1,15 @@
|
|||||||
//! Toasty models and transactional libSQL snapshots for Console control data.
|
//! Toasty models and transactional libSQL snapshots for Console control data.
|
||||||
//!
|
//!
|
||||||
//! Component bytes and WIT packages remain filesystem artifacts. This module
|
//! Component bytes and WIT packages remain filesystem artifacts. This module
|
||||||
//! persists only service manifests, metrics, and the bounded event history.
|
//! persists service manifests, deployments, metrics, the bounded event
|
||||||
|
//! history, and service-scoped Host KV entries.
|
||||||
|
|
||||||
use std::path::Path;
|
use std::{
|
||||||
|
collections::BTreeSet,
|
||||||
|
path::Path,
|
||||||
|
sync::Arc,
|
||||||
|
time::{SystemTime, UNIX_EPOCH},
|
||||||
|
};
|
||||||
|
|
||||||
use toasty::Db;
|
use toasty::Db;
|
||||||
use toasty_driver_turso::Turso;
|
use toasty_driver_turso::Turso;
|
||||||
@@ -31,31 +37,88 @@ pub(crate) struct StoredEvent {
|
|||||||
pub message: String,
|
pub message: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, toasty::Model)]
|
||||||
|
pub(crate) struct StoredDeployment {
|
||||||
|
#[key]
|
||||||
|
pub service_id: String,
|
||||||
|
pub active_revision: String,
|
||||||
|
pub updated_at_ms: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, toasty::Model)]
|
||||||
|
#[key(service_id, entry_key)]
|
||||||
|
pub(crate) struct StoredKvEntry {
|
||||||
|
pub service_id: String,
|
||||||
|
pub entry_key: String,
|
||||||
|
pub value: Vec<u8>,
|
||||||
|
pub updated_at_ms: u64,
|
||||||
|
}
|
||||||
|
|
||||||
/// Serialized access to the Toasty database connection.
|
/// Serialized access to the Toasty database connection.
|
||||||
|
#[derive(Clone)]
|
||||||
pub(crate) struct Persistence {
|
pub(crate) struct Persistence {
|
||||||
db: Mutex<Db>,
|
db: Arc<Mutex<Db>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Persistence {
|
impl Persistence {
|
||||||
/// Opens the database and installs the schema for a new file.
|
/// Opens the database and installs or advances its schema.
|
||||||
pub async fn open(path: &Path) -> toasty::Result<Self> {
|
pub async fn open(path: &Path) -> toasty::Result<Self> {
|
||||||
let is_new_database = !path.exists();
|
let is_new_database = !path.exists();
|
||||||
let db = Db::builder()
|
let db = Db::builder()
|
||||||
.models(toasty::models!(StoredService, StoredEvent))
|
.models(toasty::models!(
|
||||||
|
StoredService,
|
||||||
|
StoredEvent,
|
||||||
|
StoredDeployment,
|
||||||
|
StoredKvEntry
|
||||||
|
))
|
||||||
.build(Turso::file(path))
|
.build(Turso::file(path))
|
||||||
.await?;
|
.await?;
|
||||||
if is_new_database {
|
if is_new_database {
|
||||||
db.push_schema().await?;
|
db.push_schema().await?;
|
||||||
|
} else {
|
||||||
|
// This project predates Toasty's migration history. Keep the
|
||||||
|
// additive bootstrap explicit so existing databases can be opened
|
||||||
|
// without recreating the service and event tables.
|
||||||
|
let mut db = db;
|
||||||
|
toasty::sql::statement(
|
||||||
|
r#"CREATE TABLE IF NOT EXISTS "stored_deployments" (
|
||||||
|
"service_id" TEXT NOT NULL,
|
||||||
|
"active_revision" TEXT NOT NULL,
|
||||||
|
"updated_at_ms" INTEGER NOT NULL,
|
||||||
|
PRIMARY KEY ("service_id")
|
||||||
|
)"#,
|
||||||
|
)
|
||||||
|
.exec(&mut db)
|
||||||
|
.await?;
|
||||||
|
toasty::sql::statement(
|
||||||
|
r#"CREATE TABLE IF NOT EXISTS "stored_kv_entries" (
|
||||||
|
"service_id" TEXT NOT NULL,
|
||||||
|
"entry_key" TEXT NOT NULL,
|
||||||
|
"value" BLOB NOT NULL,
|
||||||
|
"updated_at_ms" INTEGER NOT NULL,
|
||||||
|
PRIMARY KEY ("service_id", "entry_key")
|
||||||
|
)"#,
|
||||||
|
)
|
||||||
|
.exec(&mut db)
|
||||||
|
.await?;
|
||||||
|
return Ok(Self {
|
||||||
|
db: Arc::new(Mutex::new(db)),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
Ok(Self { db: Mutex::new(db) })
|
Ok(Self {
|
||||||
|
db: Arc::new(Mutex::new(db)),
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Loads the complete service set and retained event history.
|
/// Loads the complete service and deployment sets plus retained events.
|
||||||
pub async fn load(&self) -> toasty::Result<(Vec<StoredService>, Vec<StoredEvent>)> {
|
pub async fn load(
|
||||||
|
&self,
|
||||||
|
) -> toasty::Result<(Vec<StoredService>, Vec<StoredEvent>, Vec<StoredDeployment>)> {
|
||||||
let mut db = self.db.lock().await;
|
let mut db = self.db.lock().await;
|
||||||
let services = StoredService::all().exec(&mut *db).await?;
|
let services = StoredService::all().exec(&mut *db).await?;
|
||||||
let events = StoredEvent::all().exec(&mut *db).await?;
|
let events = StoredEvent::all().exec(&mut *db).await?;
|
||||||
Ok((services, events))
|
let deployments = StoredDeployment::all().exec(&mut *db).await?;
|
||||||
|
Ok((services, events, deployments))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Atomically upserts a Console snapshot and prunes expired events.
|
/// Atomically upserts a Console snapshot and prunes expired events.
|
||||||
@@ -63,10 +126,20 @@ impl Persistence {
|
|||||||
&self,
|
&self,
|
||||||
services: Vec<StoredService>,
|
services: Vec<StoredService>,
|
||||||
events: Vec<StoredEvent>,
|
events: Vec<StoredEvent>,
|
||||||
|
deployments: Vec<StoredDeployment>,
|
||||||
) -> toasty::Result<()> {
|
) -> toasty::Result<()> {
|
||||||
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)
|
||||||
@@ -96,6 +169,65 @@ 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 {
|
||||||
|
StoredDeployment::upsert_by_service_id(deployment.service_id)
|
||||||
|
.active_revision(deployment.active_revision)
|
||||||
|
.updated_at_ms(deployment.updated_at_ms)
|
||||||
|
.exec(&mut tx)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
|
||||||
tx.commit().await
|
tx.commit().await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn kv_get(
|
||||||
|
&self,
|
||||||
|
service_id: &str,
|
||||||
|
entry_key: &str,
|
||||||
|
) -> toasty::Result<Option<Vec<u8>>> {
|
||||||
|
let mut db = self.db.lock().await;
|
||||||
|
Ok(
|
||||||
|
StoredKvEntry::filter_by_service_id_and_entry_key(service_id, entry_key)
|
||||||
|
.first()
|
||||||
|
.exec(&mut *db)
|
||||||
|
.await?
|
||||||
|
.map(|entry| entry.value),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn kv_set(
|
||||||
|
&self,
|
||||||
|
service_id: String,
|
||||||
|
entry_key: String,
|
||||||
|
value: Vec<u8>,
|
||||||
|
) -> toasty::Result<()> {
|
||||||
|
let mut db = self.db.lock().await;
|
||||||
|
StoredKvEntry::upsert_by_service_id_and_entry_key(service_id, entry_key)
|
||||||
|
.value(value)
|
||||||
|
.updated_at_ms(unix_time_ms())
|
||||||
|
.exec(&mut *db)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn kv_delete(&self, service_id: &str, entry_key: &str) -> toasty::Result<()> {
|
||||||
|
let mut db = self.db.lock().await;
|
||||||
|
StoredKvEntry::delete_by_service_id_and_entry_key(&mut *db, service_id, entry_key).await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn unix_time_ms() -> u64 {
|
||||||
|
SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.map(|duration| u64::try_from(duration.as_millis()).unwrap_or(u64::MAX))
|
||||||
|
.unwrap_or_default()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64};
|
|||||||
use serde_json::{Value, json};
|
use serde_json::{Value, json};
|
||||||
use tempfile::TempDir;
|
use tempfile::TempDir;
|
||||||
use tower::ServiceExt;
|
use tower::ServiceExt;
|
||||||
use wasmeld_console::{Console, ConsoleConfig, app};
|
use wasmeld_console::{Console, ConsoleConfig, app, gateway_app};
|
||||||
use wasmeld_package::{
|
use wasmeld_package::{
|
||||||
module::{ModuleLock, sync_dependencies},
|
module::{ModuleLock, sync_dependencies},
|
||||||
wit_package::build_wit_package,
|
wit_package::build_wit_package,
|
||||||
@@ -40,6 +40,7 @@ async fn manages_a_resident_component_over_http() {
|
|||||||
assert_eq!(status, StatusCode::CREATED, "{registered}");
|
assert_eq!(status, StatusCode::CREATED, "{registered}");
|
||||||
assert_eq!(registered["id"], "echo");
|
assert_eq!(registered["id"], "echo");
|
||||||
assert_eq!(registered["status"], "stopped");
|
assert_eq!(registered["status"], "stopped");
|
||||||
|
assert_eq!(registered["capabilities"], json!([]));
|
||||||
|
|
||||||
let response = application
|
let response = application
|
||||||
.clone()
|
.clone()
|
||||||
@@ -89,6 +90,302 @@ async fn manages_a_resident_component_over_http() {
|
|||||||
assert!(events["events"].as_array().unwrap().len() >= 5);
|
assert!(events["events"].as_array().unwrap().len() >= 5);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn reports_exact_component_host_capabilities() {
|
||||||
|
let artifact_dir = TempDir::new().expect("temporary artifact directory");
|
||||||
|
let application = test_app(artifact_dir.path()).await;
|
||||||
|
let component = fs::read(component_artifact("clock_probe_component.wasm"))
|
||||||
|
.expect("clock probe component should be readable");
|
||||||
|
|
||||||
|
let response = application
|
||||||
|
.clone()
|
||||||
|
.oneshot(package_request("clock-probe", "0.1.0", &component))
|
||||||
|
.await
|
||||||
|
.expect("register request should complete");
|
||||||
|
assert_eq!(response.status(), StatusCode::CREATED);
|
||||||
|
let registered = response_json(response).await;
|
||||||
|
assert_eq!(
|
||||||
|
registered["capabilities"],
|
||||||
|
json!([{
|
||||||
|
"interface": "wasmeld:clock/monotonic-clock@0.1.0",
|
||||||
|
"package": "wasmeld:clock",
|
||||||
|
"name": "monotonic-clock",
|
||||||
|
"version": "0.1.0"
|
||||||
|
}])
|
||||||
|
);
|
||||||
|
|
||||||
|
let response = application
|
||||||
|
.oneshot(get_request("/api/v1/services"))
|
||||||
|
.await
|
||||||
|
.expect("service list request should complete");
|
||||||
|
let services = response_json(response).await;
|
||||||
|
assert_eq!(
|
||||||
|
services["services"][0]["capabilities"],
|
||||||
|
registered["capabilities"]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn routes_raw_gateway_calls_through_the_active_deployment() {
|
||||||
|
let artifact_dir = TempDir::new().expect("temporary artifact directory");
|
||||||
|
let (management, gateway) = test_apps(artifact_dir.path()).await;
|
||||||
|
let component = fs::read(echo_component()).expect("echo component should be readable");
|
||||||
|
|
||||||
|
let response = gateway
|
||||||
|
.clone()
|
||||||
|
.oneshot(raw_request(
|
||||||
|
"/v1/services/echo/invoke",
|
||||||
|
"application/octet-stream",
|
||||||
|
b"not deployed",
|
||||||
|
))
|
||||||
|
.await
|
||||||
|
.expect("gateway request should complete");
|
||||||
|
assert_eq!(response.status(), StatusCode::NOT_FOUND);
|
||||||
|
assert_eq!(
|
||||||
|
response_json(response).await["error"]["code"],
|
||||||
|
"service_not_found"
|
||||||
|
);
|
||||||
|
|
||||||
|
let response = gateway
|
||||||
|
.clone()
|
||||||
|
.oneshot(get_request("/api/v1/services"))
|
||||||
|
.await
|
||||||
|
.expect("gateway route isolation request should complete");
|
||||||
|
assert_eq!(response.status(), StatusCode::NOT_FOUND);
|
||||||
|
|
||||||
|
for revision in ["0.1.0", "0.2.0"] {
|
||||||
|
let response = management
|
||||||
|
.clone()
|
||||||
|
.oneshot(package_request("echo", revision, &component))
|
||||||
|
.await
|
||||||
|
.expect("register request should complete");
|
||||||
|
assert_eq!(response.status(), StatusCode::CREATED);
|
||||||
|
}
|
||||||
|
|
||||||
|
let response = management
|
||||||
|
.clone()
|
||||||
|
.oneshot(json_request(
|
||||||
|
"/api/v1/deployments/echo/activate",
|
||||||
|
json!({ "revision": "0.1.0" }),
|
||||||
|
))
|
||||||
|
.await
|
||||||
|
.expect("activation request should complete");
|
||||||
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
|
let deployment = response_json(response).await;
|
||||||
|
assert_eq!(deployment["active_revision"], "0.1.0");
|
||||||
|
assert_eq!(deployment["status"], "running");
|
||||||
|
|
||||||
|
let response = gateway
|
||||||
|
.clone()
|
||||||
|
.oneshot(raw_request(
|
||||||
|
"/v1/services/echo/invoke",
|
||||||
|
"application/octet-stream",
|
||||||
|
b"public bytes",
|
||||||
|
))
|
||||||
|
.await
|
||||||
|
.expect("gateway invocation should complete");
|
||||||
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
|
assert_eq!(
|
||||||
|
response.headers()["content-type"],
|
||||||
|
"application/octet-stream"
|
||||||
|
);
|
||||||
|
assert_eq!(response.headers()["x-wasmeld-revision"], "0.1.0");
|
||||||
|
assert_eq!(
|
||||||
|
to_bytes(response.into_body(), usize::MAX).await.unwrap(),
|
||||||
|
b"public bytes".as_slice()
|
||||||
|
);
|
||||||
|
|
||||||
|
let response = management
|
||||||
|
.clone()
|
||||||
|
.oneshot(json_request(
|
||||||
|
"/api/v1/deployments/echo/activate",
|
||||||
|
json!({ "revision": "0.2.0" }),
|
||||||
|
))
|
||||||
|
.await
|
||||||
|
.expect("switch request should complete");
|
||||||
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
|
|
||||||
|
let response = gateway
|
||||||
|
.clone()
|
||||||
|
.oneshot(raw_request(
|
||||||
|
"/v1/services/echo/invoke",
|
||||||
|
"application/octet-stream",
|
||||||
|
b"switched",
|
||||||
|
))
|
||||||
|
.await
|
||||||
|
.expect("gateway invocation should complete");
|
||||||
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
|
assert_eq!(response.headers()["x-wasmeld-revision"], "0.2.0");
|
||||||
|
|
||||||
|
let response = gateway
|
||||||
|
.clone()
|
||||||
|
.oneshot(raw_request(
|
||||||
|
"/v1/services/echo/invoke",
|
||||||
|
"application/json",
|
||||||
|
b"{}",
|
||||||
|
))
|
||||||
|
.await
|
||||||
|
.expect("unsupported media request should complete");
|
||||||
|
assert_eq!(response.status(), StatusCode::UNSUPPORTED_MEDIA_TYPE);
|
||||||
|
assert_eq!(
|
||||||
|
response_json(response).await["error"]["code"],
|
||||||
|
"unsupported_media_type"
|
||||||
|
);
|
||||||
|
|
||||||
|
let response = management
|
||||||
|
.clone()
|
||||||
|
.oneshot(empty_post("/api/v1/services/echo/0.2.0/stop"))
|
||||||
|
.await
|
||||||
|
.expect("active actor stop request should complete");
|
||||||
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
|
let response = gateway
|
||||||
|
.oneshot(raw_request(
|
||||||
|
"/v1/services/echo/invoke",
|
||||||
|
"application/octet-stream",
|
||||||
|
b"stopped",
|
||||||
|
))
|
||||||
|
.await
|
||||||
|
.expect("stopped deployment request should complete");
|
||||||
|
assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
|
||||||
|
assert_eq!(
|
||||||
|
response_json(response).await["error"]["code"],
|
||||||
|
"service_unavailable"
|
||||||
|
);
|
||||||
|
|
||||||
|
let response = management
|
||||||
|
.oneshot(get_request("/api/v1/deployments"))
|
||||||
|
.await
|
||||||
|
.expect("deployment list request should complete");
|
||||||
|
let deployments = response_json(response).await;
|
||||||
|
assert_eq!(deployments["deployments"].as_array().unwrap().len(), 1);
|
||||||
|
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]
|
||||||
|
async fn restores_the_active_deployment_with_a_fresh_actor() {
|
||||||
|
let artifact_dir = TempDir::new().expect("temporary artifact directory");
|
||||||
|
let component = fs::read(echo_component()).expect("echo component should be readable");
|
||||||
|
|
||||||
|
{
|
||||||
|
let (management, gateway) = test_apps(artifact_dir.path()).await;
|
||||||
|
let response = management
|
||||||
|
.clone()
|
||||||
|
.oneshot(package_request("restored", "1.0.0", &component))
|
||||||
|
.await
|
||||||
|
.expect("register request should complete");
|
||||||
|
assert_eq!(response.status(), StatusCode::CREATED);
|
||||||
|
let response = management
|
||||||
|
.oneshot(json_request(
|
||||||
|
"/api/v1/deployments/restored/activate",
|
||||||
|
json!({ "revision": "1.0.0" }),
|
||||||
|
))
|
||||||
|
.await
|
||||||
|
.expect("activation request should complete");
|
||||||
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
|
|
||||||
|
let response = gateway
|
||||||
|
.oneshot(raw_request(
|
||||||
|
"/v1/services/restored/invoke",
|
||||||
|
"application/octet-stream",
|
||||||
|
b"before restart",
|
||||||
|
))
|
||||||
|
.await
|
||||||
|
.expect("gateway invocation should complete");
|
||||||
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
|
}
|
||||||
|
|
||||||
|
let (management, gateway) = test_apps(artifact_dir.path()).await;
|
||||||
|
let response = management
|
||||||
|
.oneshot(get_request("/api/v1/deployments/restored"))
|
||||||
|
.await
|
||||||
|
.expect("restored deployment request should complete");
|
||||||
|
let deployment = response_json(response).await;
|
||||||
|
assert_eq!(deployment["active_revision"], "1.0.0");
|
||||||
|
assert_eq!(deployment["status"], "running");
|
||||||
|
|
||||||
|
let response = gateway
|
||||||
|
.oneshot(raw_request(
|
||||||
|
"/v1/services/restored/invoke",
|
||||||
|
"application/octet-stream",
|
||||||
|
b"after restart",
|
||||||
|
))
|
||||||
|
.await
|
||||||
|
.expect("restored gateway invocation should complete");
|
||||||
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
|
assert_eq!(
|
||||||
|
to_bytes(response.into_body(), usize::MAX).await.unwrap(),
|
||||||
|
b"after restart".as_slice()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn reloads_manifests_without_restoring_actor_memory() {
|
async fn reloads_manifests_without_restoring_actor_memory() {
|
||||||
let artifact_dir = TempDir::new().expect("temporary artifact directory");
|
let artifact_dir = TempDir::new().expect("temporary artifact directory");
|
||||||
@@ -140,6 +437,80 @@ async fn reloads_manifests_without_restoring_actor_memory() {
|
|||||||
assert!(body["events"].as_array().unwrap().len() >= 3);
|
assert!(body["events"].as_array().unwrap().len() >= 3);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn persists_service_scoped_kv_across_revisions_and_restarts() {
|
||||||
|
const KV_WORLD: &str = "component:kv-probe/kv-probe-component@0.1.0";
|
||||||
|
|
||||||
|
let artifact_dir = TempDir::new().expect("temporary artifact directory");
|
||||||
|
let component = fs::read(component_artifact("kv_probe_component.wasm"))
|
||||||
|
.expect("KV probe component should be readable");
|
||||||
|
|
||||||
|
{
|
||||||
|
let application = test_app(artifact_dir.path()).await;
|
||||||
|
for (id, revision) in [
|
||||||
|
("shared-kv", "0.1.0"),
|
||||||
|
("shared-kv", "0.2.0"),
|
||||||
|
("isolated-kv", "0.1.0"),
|
||||||
|
] {
|
||||||
|
let response = application
|
||||||
|
.clone()
|
||||||
|
.oneshot(package_request_with_world(
|
||||||
|
id, revision, KV_WORLD, &component,
|
||||||
|
))
|
||||||
|
.await
|
||||||
|
.expect("KV component registration should complete");
|
||||||
|
assert_eq!(response.status(), StatusCode::CREATED);
|
||||||
|
let registered = response_json(response).await;
|
||||||
|
assert_eq!(
|
||||||
|
registered["capabilities"][0]["interface"],
|
||||||
|
"wasmeld:kv/store@0.1.0"
|
||||||
|
);
|
||||||
|
|
||||||
|
let response = application
|
||||||
|
.clone()
|
||||||
|
.oneshot(empty_post(&format!(
|
||||||
|
"/api/v1/services/{id}/{revision}/start"
|
||||||
|
)))
|
||||||
|
.await
|
||||||
|
.expect("KV actor start should complete");
|
||||||
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
invoke_service(&application, "shared-kv", "0.1.0", b"set:answer:forty-two").await,
|
||||||
|
b""
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
invoke_service(&application, "shared-kv", "0.2.0", b"get:answer").await,
|
||||||
|
b"forty-two"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
invoke_service(&application, "isolated-kv", "0.1.0", b"get:answer").await,
|
||||||
|
b""
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let application = test_app(artifact_dir.path()).await;
|
||||||
|
let response = application
|
||||||
|
.clone()
|
||||||
|
.oneshot(empty_post("/api/v1/services/shared-kv/0.2.0/start"))
|
||||||
|
.await
|
||||||
|
.expect("restored KV actor start should complete");
|
||||||
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
|
assert_eq!(
|
||||||
|
invoke_service(&application, "shared-kv", "0.2.0", b"get:answer").await,
|
||||||
|
b"forty-two"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
invoke_service(&application, "shared-kv", "0.2.0", b"delete:answer").await,
|
||||||
|
b""
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
invoke_service(&application, "shared-kv", "0.2.0", b"get:answer").await,
|
||||||
|
b""
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn manages_the_embedded_runtime_lifecycle() {
|
async fn manages_the_embedded_runtime_lifecycle() {
|
||||||
let artifact_dir = TempDir::new().expect("temporary artifact directory");
|
let artifact_dir = TempDir::new().expect("temporary artifact directory");
|
||||||
@@ -473,6 +844,10 @@ async fn client_fetches_transitive_wit_dependencies_from_the_registry() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn test_app(artifact_dir: &Path) -> Router {
|
async fn test_app(artifact_dir: &Path) -> Router {
|
||||||
|
test_apps(artifact_dir).await.0
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn test_apps(artifact_dir: &Path) -> (Router, Router) {
|
||||||
let console = Console::new(ConsoleConfig {
|
let console = Console::new(ConsoleConfig {
|
||||||
artifact_dir: artifact_dir.to_path_buf(),
|
artifact_dir: artifact_dir.to_path_buf(),
|
||||||
wit_registry_dir: artifact_dir.join("wit-packages"),
|
wit_registry_dir: artifact_dir.join("wit-packages"),
|
||||||
@@ -481,16 +856,28 @@ async fn test_app(artifact_dir: &Path) -> Router {
|
|||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.expect("console should start");
|
.expect("console should start");
|
||||||
app(Arc::new(console), Vec::new())
|
let console = Arc::new(console);
|
||||||
|
(app(Arc::clone(&console), Vec::new()), gateway_app(console))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn package_request(id: &str, revision: &str, component: &[u8]) -> Request<Body> {
|
fn package_request(id: &str, revision: &str, component: &[u8]) -> Request<Body> {
|
||||||
let mut package = Cursor::new(Vec::new());
|
|
||||||
let world = if id == "counter" {
|
let world = if id == "counter" {
|
||||||
"component:counter/counter-component@0.1.0"
|
"component:counter/counter-component@0.1.0"
|
||||||
|
} else if id == "clock-probe" {
|
||||||
|
"component:clock-probe/clock-probe-component@0.1.0"
|
||||||
} else {
|
} else {
|
||||||
"component:echo/echo-component@0.1.0"
|
"component:echo/echo-component@0.1.0"
|
||||||
};
|
};
|
||||||
|
package_request_with_world(id, revision, world, component)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn package_request_with_world(
|
||||||
|
id: &str,
|
||||||
|
revision: &str,
|
||||||
|
world: &str,
|
||||||
|
component: &[u8],
|
||||||
|
) -> Request<Body> {
|
||||||
|
let mut package = Cursor::new(Vec::new());
|
||||||
write_package(&mut package, id, revision, world, component)
|
write_package(&mut package, id, revision, world, component)
|
||||||
.expect("test package should be valid");
|
.expect("test package should be valid");
|
||||||
package_bytes_request(package.get_ref())
|
package_bytes_request(package.get_ref())
|
||||||
@@ -552,6 +939,15 @@ fn json_request(uri: &str, value: Value) -> Request<Body> {
|
|||||||
.unwrap()
|
.unwrap()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn raw_request(uri: &str, content_type: &str, body: &[u8]) -> Request<Body> {
|
||||||
|
Request::builder()
|
||||||
|
.method("POST")
|
||||||
|
.uri(uri)
|
||||||
|
.header(header::CONTENT_TYPE, content_type)
|
||||||
|
.body(Body::from(body.to_vec()))
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
fn empty_post(uri: &str) -> Request<Body> {
|
fn empty_post(uri: &str) -> Request<Body> {
|
||||||
Request::builder()
|
Request::builder()
|
||||||
.method("POST")
|
.method("POST")
|
||||||
@@ -560,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()
|
||||||
}
|
}
|
||||||
@@ -571,6 +975,23 @@ async fn response_json(response: axum::response::Response) -> Value {
|
|||||||
serde_json::from_slice(&bytes).expect("response should be JSON")
|
serde_json::from_slice(&bytes).expect("response should be JSON")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn invoke_service(application: &Router, id: &str, revision: &str, input: &[u8]) -> Vec<u8> {
|
||||||
|
let response = application
|
||||||
|
.clone()
|
||||||
|
.oneshot(json_request(
|
||||||
|
&format!("/api/v1/services/{id}/{revision}/invoke"),
|
||||||
|
json!({ "input_base64": BASE64.encode(input) }),
|
||||||
|
))
|
||||||
|
.await
|
||||||
|
.expect("component invocation should complete");
|
||||||
|
let status = response.status();
|
||||||
|
let body = response_json(response).await;
|
||||||
|
assert_eq!(status, StatusCode::OK, "{body}");
|
||||||
|
BASE64
|
||||||
|
.decode(body["output_base64"].as_str().unwrap())
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
fn echo_component() -> PathBuf {
|
fn echo_component() -> PathBuf {
|
||||||
component_artifact("echo_component.wasm")
|
component_artifact("echo_component.wasm")
|
||||||
}
|
}
|
||||||
@@ -580,6 +1001,8 @@ fn component_artifact(name: &str) -> PathBuf {
|
|||||||
for manifest in [
|
for manifest in [
|
||||||
"components/echo/Cargo.toml",
|
"components/echo/Cargo.toml",
|
||||||
"components/counter/Cargo.toml",
|
"components/counter/Cargo.toml",
|
||||||
|
"components/clock-probe/Cargo.toml",
|
||||||
|
"components/kv-probe/Cargo.toml",
|
||||||
] {
|
] {
|
||||||
let status = Command::new("rustup")
|
let status = Command::new("rustup")
|
||||||
.args([
|
.args([
|
||||||
|
|||||||
@@ -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 地址 |
|
||||||
@@ -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(¤t) {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>]
|
||||||
|
|||||||
@@ -5,12 +5,3 @@ wasmtime::component::bindgen!({
|
|||||||
path: "../../wit/service",
|
path: "../../wit/service",
|
||||||
world: "service-component",
|
world: "service-component",
|
||||||
});
|
});
|
||||||
|
|
||||||
pub(crate) mod clock {
|
|
||||||
// Host capabilities are generated separately so adding a capability does
|
|
||||||
// not expand the baseline service contract for every component.
|
|
||||||
wasmtime::component::bindgen!({
|
|
||||||
path: "../../wit/clock",
|
|
||||||
world: "clock-host",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
//! `wasmeld:clock/monotonic-clock@0.1.0` host implementation.
|
||||||
|
|
||||||
|
use std::time::Instant;
|
||||||
|
|
||||||
|
use wasmtime::component::Linker;
|
||||||
|
|
||||||
|
use crate::{RuntimeError, runtime::HostState};
|
||||||
|
|
||||||
|
wasmtime::component::bindgen!({
|
||||||
|
path: "../../wit/clock",
|
||||||
|
world: "clock-host",
|
||||||
|
});
|
||||||
|
|
||||||
|
pub(crate) const INTERFACE: &str = "wasmeld:clock/monotonic-clock@0.1.0";
|
||||||
|
pub(crate) const PACKAGE: &str = "wasmeld:clock";
|
||||||
|
pub(crate) const NAME: &str = "monotonic-clock";
|
||||||
|
pub(crate) const VERSION: &str = "0.1.0";
|
||||||
|
|
||||||
|
pub(crate) struct ClockState {
|
||||||
|
origin: Instant,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ClockState {
|
||||||
|
pub(crate) fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
origin: Instant::now(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn now(&self) -> u64 {
|
||||||
|
u64::try_from(self.origin.elapsed().as_nanos()).unwrap_or(u64::MAX)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl wasmeld::clock::monotonic_clock::Host for HostState {
|
||||||
|
fn now(&mut self) -> u64 {
|
||||||
|
self.capabilities.monotonic_clock_now()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn add_to_linker(linker: &mut Linker<HostState>) -> Result<(), RuntimeError> {
|
||||||
|
wasmeld::clock::monotonic_clock::add_to_linker::<HostState, HostState>(linker, |state| state)
|
||||||
|
.map_err(|error| RuntimeError::ActorInitialization(error.to_string()))
|
||||||
|
}
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
//! `wasmeld:kv/store@0.1.0` host implementation and backend boundary.
|
||||||
|
|
||||||
|
use std::{fmt, sync::Arc, time::Duration};
|
||||||
|
|
||||||
|
use thiserror::Error;
|
||||||
|
use wasmtime::component::Linker;
|
||||||
|
|
||||||
|
use crate::{RuntimeError, runtime::HostState};
|
||||||
|
|
||||||
|
wasmtime::component::bindgen!({
|
||||||
|
path: "../../wit/kv",
|
||||||
|
world: "kv-host",
|
||||||
|
});
|
||||||
|
|
||||||
|
pub(crate) const INTERFACE: &str = "wasmeld:kv/store@0.1.0";
|
||||||
|
pub(crate) const PACKAGE: &str = "wasmeld:kv";
|
||||||
|
pub(crate) const NAME: &str = "store";
|
||||||
|
pub(crate) const VERSION: &str = "0.1.0";
|
||||||
|
|
||||||
|
/// Maximum UTF-8 byte length accepted for one KV key.
|
||||||
|
pub const KV_MAX_KEY_BYTES: usize = 256;
|
||||||
|
/// Maximum byte length accepted for one KV value.
|
||||||
|
pub const KV_MAX_VALUE_BYTES: usize = 64 * 1024;
|
||||||
|
|
||||||
|
/// Synchronous storage boundary used by the Wasmtime host implementation.
|
||||||
|
///
|
||||||
|
/// Implementations must bound their own blocking time because calls execute on
|
||||||
|
/// the Component Actor thread.
|
||||||
|
pub trait KvBackend: fmt::Debug + Send + Sync {
|
||||||
|
/// Returns the value stored under a service-scoped key.
|
||||||
|
fn get(
|
||||||
|
&self,
|
||||||
|
service_id: &str,
|
||||||
|
key: &str,
|
||||||
|
timeout: Duration,
|
||||||
|
) -> Result<Option<Vec<u8>>, KvBackendError>;
|
||||||
|
/// Creates or replaces a service-scoped value.
|
||||||
|
fn set(
|
||||||
|
&self,
|
||||||
|
service_id: &str,
|
||||||
|
key: &str,
|
||||||
|
value: &[u8],
|
||||||
|
timeout: Duration,
|
||||||
|
) -> Result<(), KvBackendError>;
|
||||||
|
/// Removes a service-scoped value if it exists.
|
||||||
|
fn delete(&self, service_id: &str, key: &str, timeout: Duration) -> Result<(), KvBackendError>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Storage failures returned by a [`KvBackend`].
|
||||||
|
#[derive(Debug, Error)]
|
||||||
|
pub enum KvBackendError {
|
||||||
|
#[error("KV backend is busy")]
|
||||||
|
Busy,
|
||||||
|
#[error("KV backend timed out")]
|
||||||
|
Timeout,
|
||||||
|
#[error("KV backend operation failed: {0}")]
|
||||||
|
Operation(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) struct KvState {
|
||||||
|
service_id: String,
|
||||||
|
backend: Arc<dyn KvBackend>,
|
||||||
|
timeout: Duration,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl KvState {
|
||||||
|
pub(crate) fn new(service_id: &str, backend: Arc<dyn KvBackend>, timeout: Duration) -> Self {
|
||||||
|
Self {
|
||||||
|
service_id: service_id.to_owned(),
|
||||||
|
backend,
|
||||||
|
timeout,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get(&self, key: &str) -> Result<Option<Vec<u8>>, wasmeld::kv::store::KvError> {
|
||||||
|
validate_key(key)?;
|
||||||
|
self.backend
|
||||||
|
.get(&self.service_id, key, self.timeout)
|
||||||
|
.map_err(operation_failed)
|
||||||
|
.and_then(|value| match value {
|
||||||
|
Some(value) if value.len() > KV_MAX_VALUE_BYTES => {
|
||||||
|
Err(wasmeld::kv::store::KvError::ValueTooLarge(
|
||||||
|
u64::try_from(value.len()).unwrap_or(u64::MAX),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
value => Ok(value),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set(&self, key: &str, value: &[u8]) -> Result<(), wasmeld::kv::store::KvError> {
|
||||||
|
validate_key(key)?;
|
||||||
|
if value.len() > KV_MAX_VALUE_BYTES {
|
||||||
|
return Err(wasmeld::kv::store::KvError::ValueTooLarge(
|
||||||
|
u64::try_from(value.len()).unwrap_or(u64::MAX),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
self.backend
|
||||||
|
.set(&self.service_id, key, value, self.timeout)
|
||||||
|
.map_err(operation_failed)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn delete(&self, key: &str) -> Result<(), wasmeld::kv::store::KvError> {
|
||||||
|
validate_key(key)?;
|
||||||
|
self.backend
|
||||||
|
.delete(&self.service_id, key, self.timeout)
|
||||||
|
.map_err(operation_failed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl wasmeld::kv::store::Host for HostState {
|
||||||
|
fn get(&mut self, key: String) -> Result<Option<Vec<u8>>, wasmeld::kv::store::KvError> {
|
||||||
|
self.capabilities.kv().get(&key)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set(&mut self, key: String, value: Vec<u8>) -> Result<(), wasmeld::kv::store::KvError> {
|
||||||
|
self.capabilities.kv().set(&key, &value)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn delete(&mut self, key: String) -> Result<(), wasmeld::kv::store::KvError> {
|
||||||
|
self.capabilities.kv().delete(&key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn add_to_linker(linker: &mut Linker<HostState>) -> Result<(), RuntimeError> {
|
||||||
|
wasmeld::kv::store::add_to_linker::<HostState, HostState>(linker, |state| state)
|
||||||
|
.map_err(|error| RuntimeError::ActorInitialization(error.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_key(key: &str) -> Result<(), wasmeld::kv::store::KvError> {
|
||||||
|
if key.is_empty() {
|
||||||
|
return Err(wasmeld::kv::store::KvError::InvalidKey(
|
||||||
|
"key must not be empty".to_owned(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if key.len() > KV_MAX_KEY_BYTES {
|
||||||
|
return Err(wasmeld::kv::store::KvError::InvalidKey(format!(
|
||||||
|
"key exceeds the {KV_MAX_KEY_BYTES}-byte limit"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn operation_failed(error: KvBackendError) -> wasmeld::kv::store::KvError {
|
||||||
|
wasmeld::kv::store::KvError::OperationFailed(error.to_string())
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
//! Versioned host capabilities available to WebAssembly Components.
|
||||||
|
|
||||||
|
mod clock;
|
||||||
|
mod kv;
|
||||||
|
mod registry;
|
||||||
|
|
||||||
|
pub use kv::{KV_MAX_KEY_BYTES, KV_MAX_VALUE_BYTES, KvBackend, KvBackendError};
|
||||||
|
pub use registry::CapabilityDescriptor;
|
||||||
|
pub(crate) use registry::{Capability, CapabilityRegistry};
|
||||||
|
|
||||||
|
/// Per-Actor state owned by enabled host capabilities.
|
||||||
|
pub(crate) struct HostCapabilities {
|
||||||
|
clock: Option<clock::ClockState>,
|
||||||
|
kv: Option<kv::KvState>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl HostCapabilities {
|
||||||
|
pub(crate) fn new(
|
||||||
|
capabilities: &[Capability],
|
||||||
|
service_id: &str,
|
||||||
|
kv_backend: Option<std::sync::Arc<dyn KvBackend>>,
|
||||||
|
deadline: std::time::Duration,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
clock: capabilities
|
||||||
|
.contains(&Capability::MonotonicClock)
|
||||||
|
.then(clock::ClockState::new),
|
||||||
|
kv: capabilities.contains(&Capability::KvStore).then(|| {
|
||||||
|
kv::KvState::new(
|
||||||
|
service_id,
|
||||||
|
kv_backend.expect("KV Components are rejected when no backend is configured"),
|
||||||
|
deadline,
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn monotonic_clock_now(&self) -> u64 {
|
||||||
|
self.clock
|
||||||
|
.as_ref()
|
||||||
|
.expect("the Clock interface is linked only when its state is enabled")
|
||||||
|
.now()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn kv(&self) -> &kv::KvState {
|
||||||
|
self.kv
|
||||||
|
.as_ref()
|
||||||
|
.expect("the KV interface is linked only when its state is enabled")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
//! Exact interface lookup and Linker installation for host capabilities.
|
||||||
|
|
||||||
|
use wasmtime::component::Linker;
|
||||||
|
|
||||||
|
use super::{clock, kv};
|
||||||
|
use crate::{RuntimeError, runtime::HostState};
|
||||||
|
|
||||||
|
/// Stable description of one versioned WIT interface used by a Component.
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
|
||||||
|
pub struct CapabilityDescriptor {
|
||||||
|
interface: &'static str,
|
||||||
|
package: &'static str,
|
||||||
|
name: &'static str,
|
||||||
|
version: &'static str,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CapabilityDescriptor {
|
||||||
|
const fn new(
|
||||||
|
interface: &'static str,
|
||||||
|
package: &'static str,
|
||||||
|
name: &'static str,
|
||||||
|
version: &'static str,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
interface,
|
||||||
|
package,
|
||||||
|
name,
|
||||||
|
version,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the fully qualified and versioned WIT interface identity.
|
||||||
|
pub const fn interface(&self) -> &'static str {
|
||||||
|
self.interface
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the versioned WIT package identity without the version suffix.
|
||||||
|
pub const fn package(&self) -> &'static str {
|
||||||
|
self.package
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the WIT interface name inside the package.
|
||||||
|
pub const fn name(&self) -> &'static str {
|
||||||
|
self.name
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the exact semantic version implemented by the Runtime.
|
||||||
|
pub const fn version(&self) -> &'static str {
|
||||||
|
self.version
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
|
||||||
|
pub(crate) enum Capability {
|
||||||
|
MonotonicClock,
|
||||||
|
KvStore,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Capability {
|
||||||
|
pub(crate) const fn descriptor(self) -> CapabilityDescriptor {
|
||||||
|
match self {
|
||||||
|
Self::MonotonicClock => CapabilityDescriptor::new(
|
||||||
|
clock::INTERFACE,
|
||||||
|
clock::PACKAGE,
|
||||||
|
clock::NAME,
|
||||||
|
clock::VERSION,
|
||||||
|
),
|
||||||
|
Self::KvStore => {
|
||||||
|
CapabilityDescriptor::new(kv::INTERFACE, kv::PACKAGE, kv::NAME, kv::VERSION)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn add_to_linker(self, linker: &mut Linker<HostState>) -> Result<(), RuntimeError> {
|
||||||
|
match self {
|
||||||
|
Self::MonotonicClock => clock::add_to_linker(linker),
|
||||||
|
Self::KvStore => kv::add_to_linker(linker),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Registry of the exact WIT interface versions implemented by this Runtime.
|
||||||
|
pub(crate) struct CapabilityRegistry;
|
||||||
|
|
||||||
|
impl CapabilityRegistry {
|
||||||
|
/// Resolves an exact Component import to a supported host capability.
|
||||||
|
pub(crate) fn resolve(interface: &str) -> Option<Capability> {
|
||||||
|
match interface {
|
||||||
|
clock::INTERFACE => Some(Capability::MonotonicClock),
|
||||||
|
kv::INTERFACE => Some(Capability::KvStore),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Installs only the capabilities requested by one Component.
|
||||||
|
pub(crate) fn add_to_linker(
|
||||||
|
linker: &mut Linker<HostState>,
|
||||||
|
capabilities: &[Capability],
|
||||||
|
) -> Result<(), RuntimeError> {
|
||||||
|
for capability in capabilities {
|
||||||
|
capability.add_to_linker(linker)?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::{Capability, CapabilityRegistry};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolves_only_the_exact_supported_interface_version() {
|
||||||
|
assert_eq!(
|
||||||
|
CapabilityRegistry::resolve("wasmeld:clock/monotonic-clock@0.1.0"),
|
||||||
|
Some(Capability::MonotonicClock)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
CapabilityRegistry::resolve("wasmeld:clock/monotonic-clock@0.2.0"),
|
||||||
|
None
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
CapabilityRegistry::resolve("wasmeld:clock/other@0.1.0"),
|
||||||
|
None
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
CapabilityRegistry::resolve("wasmeld:kv/store@0.1.0"),
|
||||||
|
Some(Capability::KvStore)
|
||||||
|
);
|
||||||
|
assert_eq!(CapabilityRegistry::resolve("wasmeld:kv/store@0.2.0"), None);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -30,6 +30,9 @@ pub enum RuntimeError {
|
|||||||
#[error("component imports unsupported capability {0}")]
|
#[error("component imports unsupported capability {0}")]
|
||||||
UnsupportedImport(String),
|
UnsupportedImport(String),
|
||||||
|
|
||||||
|
#[error("component requires unavailable capability {0}")]
|
||||||
|
CapabilityUnavailable(String),
|
||||||
|
|
||||||
#[error("runtime creation failed: {0}")]
|
#[error("runtime creation failed: {0}")]
|
||||||
RuntimeCreation(#[source] wasmtime::Error),
|
RuntimeCreation(#[source] wasmtime::Error),
|
||||||
|
|
||||||
|
|||||||
@@ -6,10 +6,14 @@
|
|||||||
//! service exports, and invokes it through one serial Actor.
|
//! service exports, and invokes it through one serial Actor.
|
||||||
|
|
||||||
mod bindings;
|
mod bindings;
|
||||||
|
mod capability;
|
||||||
mod error;
|
mod error;
|
||||||
mod manifest;
|
mod manifest;
|
||||||
mod runtime;
|
mod runtime;
|
||||||
|
|
||||||
|
pub use capability::{
|
||||||
|
CapabilityDescriptor, KV_MAX_KEY_BYTES, KV_MAX_VALUE_BYTES, KvBackend, KvBackendError,
|
||||||
|
};
|
||||||
pub use error::RuntimeError;
|
pub use error::RuntimeError;
|
||||||
pub use manifest::{ResourceLimits, ServiceKey, ServiceManifest};
|
pub use manifest::{ResourceLimits, ServiceKey, ServiceManifest};
|
||||||
pub use runtime::{ActorHandle, Runtime, RuntimeConfig, RuntimeStats};
|
pub use runtime::{ActorHandle, Runtime, RuntimeConfig, RuntimeStats};
|
||||||
|
|||||||
@@ -30,8 +30,9 @@ use wasmtime_wasi::{
|
|||||||
};
|
};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
RuntimeError, ServiceKey, ServiceManifest,
|
CapabilityDescriptor, KvBackend, RuntimeError, ServiceKey, ServiceManifest,
|
||||||
bindings::{ServiceComponent, ServiceError, clock as clock_bindings},
|
bindings::{ServiceComponent, ServiceError},
|
||||||
|
capability::{Capability, CapabilityRegistry, HostCapabilities},
|
||||||
manifest::ResourceLimits,
|
manifest::ResourceLimits,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -40,7 +41,6 @@ const MAX_EPOCH_TICK: Duration = Duration::from_millis(10);
|
|||||||
const DEFAULT_MAX_WASM_STACK: usize = 512 * 1024;
|
const DEFAULT_MAX_WASM_STACK: usize = 512 * 1024;
|
||||||
const ACTOR_RESOURCE_COUNT_LIMIT: usize = 16;
|
const ACTOR_RESOURCE_COUNT_LIMIT: usize = 16;
|
||||||
const ACTOR_TABLE_ELEMENT_LIMIT: usize = 16 * 1024;
|
const ACTOR_TABLE_ELEMENT_LIMIT: usize = 16 * 1024;
|
||||||
const MONOTONIC_CLOCK_IMPORT: &str = "wasmeld:clock/monotonic-clock@0.1.0";
|
|
||||||
const ALLOWED_WASI_IMPORTS: &[&str] = &[
|
const ALLOWED_WASI_IMPORTS: &[&str] = &[
|
||||||
"wasi:cli/environment@",
|
"wasi:cli/environment@",
|
||||||
"wasi:cli/exit@",
|
"wasi:cli/exit@",
|
||||||
@@ -61,6 +61,8 @@ pub struct RuntimeConfig {
|
|||||||
pub epoch_tick: Duration,
|
pub epoch_tick: Duration,
|
||||||
/// Maximum native stack reservation for WebAssembly execution.
|
/// Maximum native stack reservation for WebAssembly execution.
|
||||||
pub max_wasm_stack: usize,
|
pub max_wasm_stack: usize,
|
||||||
|
/// Service-scoped storage used by Components importing the KV capability.
|
||||||
|
pub kv_backend: Option<Arc<dyn KvBackend>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for RuntimeConfig {
|
impl Default for RuntimeConfig {
|
||||||
@@ -68,6 +70,7 @@ impl Default for RuntimeConfig {
|
|||||||
Self {
|
Self {
|
||||||
epoch_tick: DEFAULT_EPOCH_TICK,
|
epoch_tick: DEFAULT_EPOCH_TICK,
|
||||||
max_wasm_stack: DEFAULT_MAX_WASM_STACK,
|
max_wasm_stack: DEFAULT_MAX_WASM_STACK,
|
||||||
|
kv_backend: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -117,6 +120,7 @@ struct RuntimeInner {
|
|||||||
actors: Mutex<HashMap<ServiceKey, ActorHandle>>,
|
actors: Mutex<HashMap<ServiceKey, ActorHandle>>,
|
||||||
lifecycle: Mutex<()>,
|
lifecycle: Mutex<()>,
|
||||||
ticker: Arc<EpochTicker>,
|
ticker: Arc<EpochTicker>,
|
||||||
|
kv_backend: Option<Arc<dyn KvBackend>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
struct EpochTicker {
|
struct EpochTicker {
|
||||||
@@ -162,12 +166,7 @@ impl Drop for EpochTicker {
|
|||||||
struct RegisteredService {
|
struct RegisteredService {
|
||||||
manifest: ServiceManifest,
|
manifest: ServiceManifest,
|
||||||
component: Arc<Component>,
|
component: Arc<Component>,
|
||||||
capabilities: Vec<HostCapability>,
|
capabilities: Vec<Capability>,
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
|
||||||
enum HostCapability {
|
|
||||||
MonotonicClock,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Runtime {
|
impl Runtime {
|
||||||
@@ -194,6 +193,7 @@ impl Runtime {
|
|||||||
actors: Mutex::new(HashMap::new()),
|
actors: Mutex::new(HashMap::new()),
|
||||||
lifecycle: Mutex::new(()),
|
lifecycle: Mutex::new(()),
|
||||||
ticker,
|
ticker,
|
||||||
|
kv_backend: config.kv_backend,
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -212,6 +212,11 @@ impl Runtime {
|
|||||||
let component = Component::new(&self.inner.engine, component_bytes.as_ref())
|
let component = Component::new(&self.inner.engine, component_bytes.as_ref())
|
||||||
.map_err(RuntimeError::ComponentCompilation)?;
|
.map_err(RuntimeError::ComponentCompilation)?;
|
||||||
let capabilities = validate_component_imports(&component, &self.inner.engine)?;
|
let capabilities = validate_component_imports(&component, &self.inner.engine)?;
|
||||||
|
if capabilities.contains(&Capability::KvStore) && self.inner.kv_backend.is_none() {
|
||||||
|
return Err(RuntimeError::CapabilityUnavailable(
|
||||||
|
Capability::KvStore.descriptor().interface().to_owned(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
let mut services = self
|
let mut services = self
|
||||||
.inner
|
.inner
|
||||||
@@ -303,6 +308,7 @@ impl Runtime {
|
|||||||
key.clone(),
|
key.clone(),
|
||||||
service,
|
service,
|
||||||
init_config,
|
init_config,
|
||||||
|
self.inner.kv_backend.clone(),
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
self.inner
|
self.inner
|
||||||
@@ -327,6 +333,26 @@ impl Runtime {
|
|||||||
actor.invoke(input)
|
actor.invoke(input)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Returns the exact versioned host capabilities imported by a service.
|
||||||
|
pub fn capabilities(
|
||||||
|
&self,
|
||||||
|
key: &ServiceKey,
|
||||||
|
) -> Result<Vec<CapabilityDescriptor>, RuntimeError> {
|
||||||
|
let services = self
|
||||||
|
.inner
|
||||||
|
.services
|
||||||
|
.lock()
|
||||||
|
.map_err(|_| RuntimeError::LockPoisoned)?;
|
||||||
|
let service = services
|
||||||
|
.get(key)
|
||||||
|
.ok_or_else(|| RuntimeError::ServiceNotRegistered(key.clone()))?;
|
||||||
|
Ok(service
|
||||||
|
.capabilities
|
||||||
|
.iter()
|
||||||
|
.map(|capability| capability.descriptor())
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
/// Stops one Actor and releases its Store and Component Instance.
|
/// Stops one Actor and releases its Store and Component Instance.
|
||||||
pub fn stop(&self, key: &ServiceKey) -> Result<(), RuntimeError> {
|
pub fn stop(&self, key: &ServiceKey) -> Result<(), RuntimeError> {
|
||||||
let _lifecycle = self
|
let _lifecycle = self
|
||||||
@@ -364,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
|
||||||
@@ -557,15 +632,20 @@ impl ActorStatus {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct HostState {
|
pub(crate) struct HostState {
|
||||||
store_limits: StoreLimits,
|
store_limits: StoreLimits,
|
||||||
wasi: WasiCtx,
|
wasi: WasiCtx,
|
||||||
wasi_table: ResourceTable,
|
wasi_table: ResourceTable,
|
||||||
clock_origin: Instant,
|
pub(crate) capabilities: HostCapabilities,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl HostState {
|
impl HostState {
|
||||||
fn new(limits: &ResourceLimits) -> Self {
|
fn new(
|
||||||
|
limits: &ResourceLimits,
|
||||||
|
capabilities: &[Capability],
|
||||||
|
service_id: &str,
|
||||||
|
kv_backend: Option<Arc<dyn KvBackend>>,
|
||||||
|
) -> Self {
|
||||||
let store_limits = StoreLimitsBuilder::new()
|
let store_limits = StoreLimitsBuilder::new()
|
||||||
.memory_size(limits.memory_bytes)
|
.memory_size(limits.memory_bytes)
|
||||||
.table_elements(ACTOR_TABLE_ELEMENT_LIMIT)
|
.table_elements(ACTOR_TABLE_ELEMENT_LIMIT)
|
||||||
@@ -585,7 +665,12 @@ impl HostState {
|
|||||||
store_limits,
|
store_limits,
|
||||||
wasi: wasi.build(),
|
wasi: wasi.build(),
|
||||||
wasi_table: ResourceTable::new(),
|
wasi_table: ResourceTable::new(),
|
||||||
clock_origin: Instant::now(),
|
capabilities: HostCapabilities::new(
|
||||||
|
capabilities,
|
||||||
|
service_id,
|
||||||
|
kv_backend,
|
||||||
|
limits.deadline(),
|
||||||
|
),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -594,12 +679,6 @@ impl HasData for HostState {
|
|||||||
type Data<'a> = &'a mut HostState;
|
type Data<'a> = &'a mut HostState;
|
||||||
}
|
}
|
||||||
|
|
||||||
impl clock_bindings::wasmeld::clock::monotonic_clock::Host for HostState {
|
|
||||||
fn now(&mut self) -> u64 {
|
|
||||||
u64::try_from(self.clock_origin.elapsed().as_nanos()).unwrap_or(u64::MAX)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl WasiView for HostState {
|
impl WasiView for HostState {
|
||||||
fn ctx(&mut self) -> WasiCtxView<'_> {
|
fn ctx(&mut self) -> WasiCtxView<'_> {
|
||||||
WasiCtxView {
|
WasiCtxView {
|
||||||
@@ -630,6 +709,7 @@ impl ActorWorker {
|
|||||||
key: ServiceKey,
|
key: ServiceKey,
|
||||||
service: RegisteredService,
|
service: RegisteredService,
|
||||||
init_config: Vec<u8>,
|
init_config: Vec<u8>,
|
||||||
|
kv_backend: Option<Arc<dyn KvBackend>>,
|
||||||
) -> Result<Self, RuntimeError> {
|
) -> Result<Self, RuntimeError> {
|
||||||
let limits = Arc::new(service.manifest.limits.clone());
|
let limits = Arc::new(service.manifest.limits.clone());
|
||||||
if init_config.len() > limits.max_input_bytes {
|
if init_config.len() > limits.max_input_bytes {
|
||||||
@@ -639,12 +719,15 @@ impl ActorWorker {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut store = Store::new(&engine, HostState::new(&limits));
|
let mut store = Store::new(
|
||||||
|
&engine,
|
||||||
|
HostState::new(&limits, &service.capabilities, key.id(), kv_backend),
|
||||||
|
);
|
||||||
store.limiter(|state| &mut state.store_limits);
|
store.limiter(|state| &mut state.store_limits);
|
||||||
|
|
||||||
let mut linker = Linker::new(&engine);
|
let mut linker = Linker::new(&engine);
|
||||||
add_restricted_wasi(&mut linker)?;
|
add_restricted_wasi(&mut linker)?;
|
||||||
add_host_capabilities(&mut linker, &service.capabilities)?;
|
CapabilityRegistry::add_to_linker(&mut linker, &service.capabilities)?;
|
||||||
|
|
||||||
let epoch_ticks = ticks_for(limits.deadline(), epoch_tick);
|
let epoch_ticks = ticks_for(limits.deadline(), epoch_tick);
|
||||||
configure_call_budget(&mut store, &limits, epoch_ticks)?;
|
configure_call_budget(&mut store, &limits, epoch_ticks)?;
|
||||||
@@ -758,33 +841,16 @@ fn link_wasi(result: wasmtime::Result<()>) -> Result<(), RuntimeError> {
|
|||||||
result.map_err(|error| RuntimeError::ActorInitialization(error.to_string()))
|
result.map_err(|error| RuntimeError::ActorInitialization(error.to_string()))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn add_host_capabilities(
|
|
||||||
linker: &mut Linker<HostState>,
|
|
||||||
capabilities: &[HostCapability],
|
|
||||||
) -> Result<(), RuntimeError> {
|
|
||||||
for capability in capabilities {
|
|
||||||
match capability {
|
|
||||||
HostCapability::MonotonicClock => link_wasi(
|
|
||||||
clock_bindings::wasmeld::clock::monotonic_clock::add_to_linker::<
|
|
||||||
HostState,
|
|
||||||
HostState,
|
|
||||||
>(linker, |state| state),
|
|
||||||
)?,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn validate_component_imports(
|
fn validate_component_imports(
|
||||||
component: &Component,
|
component: &Component,
|
||||||
engine: &Engine,
|
engine: &Engine,
|
||||||
) -> Result<Vec<HostCapability>, RuntimeError> {
|
) -> Result<Vec<Capability>, RuntimeError> {
|
||||||
// Import validation is deny-by-default. A host function is linked only
|
// Import validation is deny-by-default. A host function is linked only
|
||||||
// after its exact WIT identity or WASI family has passed this allowlist.
|
// after its exact WIT identity or WASI family has passed this allowlist.
|
||||||
let mut capabilities = Vec::new();
|
let mut capabilities = Vec::new();
|
||||||
for (name, _) in component.component_type().imports(engine) {
|
for (name, _) in component.component_type().imports(engine) {
|
||||||
if name == MONOTONIC_CLOCK_IMPORT {
|
if let Some(capability) = CapabilityRegistry::resolve(name) {
|
||||||
capabilities.push(HostCapability::MonotonicClock);
|
capabilities.push(capability);
|
||||||
} else if !ALLOWED_WASI_IMPORTS
|
} else if !ALLOWED_WASI_IMPORTS
|
||||||
.iter()
|
.iter()
|
||||||
.any(|allowed| name.starts_with(allowed))
|
.any(|allowed| name.starts_with(allowed))
|
||||||
@@ -793,6 +859,8 @@ fn validate_component_imports(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
capabilities.sort_unstable();
|
||||||
|
capabilities.dedup();
|
||||||
Ok(capabilities)
|
Ok(capabilities)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -803,6 +871,7 @@ fn spawn_actor(
|
|||||||
key: ServiceKey,
|
key: ServiceKey,
|
||||||
service: RegisteredService,
|
service: RegisteredService,
|
||||||
init_config: Vec<u8>,
|
init_config: Vec<u8>,
|
||||||
|
kv_backend: Option<Arc<dyn KvBackend>>,
|
||||||
) -> Result<ActorHandle, RuntimeError> {
|
) -> Result<ActorHandle, RuntimeError> {
|
||||||
let limits = Arc::new(service.manifest.limits.clone());
|
let limits = Arc::new(service.manifest.limits.clone());
|
||||||
let (sender, receiver) = mpsc::sync_channel(limits.mailbox_capacity);
|
let (sender, receiver) = mpsc::sync_channel(limits.mailbox_capacity);
|
||||||
@@ -817,7 +886,7 @@ fn spawn_actor(
|
|||||||
.spawn(move || {
|
.spawn(move || {
|
||||||
let _ticker = worker_ticker;
|
let _ticker = worker_ticker;
|
||||||
|
|
||||||
match ActorWorker::create(engine, epoch_tick, key, service, init_config) {
|
match ActorWorker::create(engine, epoch_tick, key, service, init_config, kv_backend) {
|
||||||
Ok(mut worker) => {
|
Ok(mut worker) => {
|
||||||
worker_status.mark_ready();
|
worker_status.mark_ready();
|
||||||
let _ = ready_sender.send(Ok(()));
|
let _ = ready_sender.send(Ok(()));
|
||||||
|
|||||||
@@ -1,13 +1,17 @@
|
|||||||
use std::{
|
use std::{
|
||||||
|
collections::HashMap,
|
||||||
fs,
|
fs,
|
||||||
path::{Path, PathBuf},
|
path::{Path, PathBuf},
|
||||||
process::Command,
|
process::Command,
|
||||||
sync::OnceLock,
|
sync::{Arc, Mutex, OnceLock},
|
||||||
thread,
|
thread,
|
||||||
time::{Duration, Instant},
|
time::{Duration, Instant},
|
||||||
};
|
};
|
||||||
|
|
||||||
use wasmeld_runtime::{ResourceLimits, Runtime, RuntimeConfig, RuntimeError, ServiceManifest};
|
use wasmeld_runtime::{
|
||||||
|
KV_MAX_VALUE_BYTES, KvBackend, KvBackendError, ResourceLimits, Runtime, RuntimeConfig,
|
||||||
|
RuntimeError, ServiceManifest,
|
||||||
|
};
|
||||||
|
|
||||||
static COMPONENTS_BUILT: OnceLock<()> = OnceLock::new();
|
static COMPONENTS_BUILT: OnceLock<()> = OnceLock::new();
|
||||||
|
|
||||||
@@ -16,6 +20,7 @@ fn echo_component_round_trips_bytes() {
|
|||||||
let runtime = Runtime::new(RuntimeConfig::default()).expect("runtime should start");
|
let runtime = Runtime::new(RuntimeConfig::default()).expect("runtime should start");
|
||||||
let (key, actor) = start_component(&runtime, "echo", "echo_component.wasm");
|
let (key, actor) = start_component(&runtime, "echo", "echo_component.wasm");
|
||||||
|
|
||||||
|
assert!(runtime.capabilities(&key).unwrap().is_empty());
|
||||||
assert_eq!(actor.invoke(b"hello wasm".to_vec()).unwrap(), b"hello wasm");
|
assert_eq!(actor.invoke(b"hello wasm".to_vec()).unwrap(), b"hello wasm");
|
||||||
|
|
||||||
runtime.stop(&key).unwrap();
|
runtime.stop(&key).unwrap();
|
||||||
@@ -171,6 +176,13 @@ fn explicit_clock_capability_is_linked() {
|
|||||||
let runtime = Runtime::new(RuntimeConfig::default()).expect("runtime should start");
|
let runtime = Runtime::new(RuntimeConfig::default()).expect("runtime should start");
|
||||||
let (key, actor) = start_component(&runtime, "clock-probe", "clock_probe_component.wasm");
|
let (key, actor) = start_component(&runtime, "clock-probe", "clock_probe_component.wasm");
|
||||||
|
|
||||||
|
let capabilities = runtime.capabilities(&key).unwrap();
|
||||||
|
assert_eq!(capabilities.len(), 1);
|
||||||
|
assert_eq!(
|
||||||
|
capabilities[0].interface(),
|
||||||
|
"wasmeld:clock/monotonic-clock@0.1.0"
|
||||||
|
);
|
||||||
|
|
||||||
let first = actor.invoke(b"first".to_vec()).unwrap();
|
let first = actor.invoke(b"first".to_vec()).unwrap();
|
||||||
let second = actor.invoke(b"second".to_vec()).unwrap();
|
let second = actor.invoke(b"second".to_vec()).unwrap();
|
||||||
assert_eq!(&first[8..], b"first");
|
assert_eq!(&first[8..], b"first");
|
||||||
@@ -180,6 +192,114 @@ fn explicit_clock_capability_is_linked() {
|
|||||||
runtime.stop(&key).unwrap();
|
runtime.stop(&key).unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn kv_capability_is_service_scoped_and_shared_across_revisions() {
|
||||||
|
let backend = Arc::new(MemoryKvBackend::default());
|
||||||
|
let runtime = Runtime::new(RuntimeConfig {
|
||||||
|
kv_backend: Some(backend),
|
||||||
|
..RuntimeConfig::default()
|
||||||
|
})
|
||||||
|
.expect("runtime should start");
|
||||||
|
let first_key = register_component_revision(
|
||||||
|
&runtime,
|
||||||
|
"shared-kv",
|
||||||
|
"0.1.0",
|
||||||
|
"kv_probe_component.wasm",
|
||||||
|
test_limits(),
|
||||||
|
);
|
||||||
|
let second_key = register_component_revision(
|
||||||
|
&runtime,
|
||||||
|
"shared-kv",
|
||||||
|
"0.2.0",
|
||||||
|
"kv_probe_component.wasm",
|
||||||
|
test_limits(),
|
||||||
|
);
|
||||||
|
let isolated_key = register_component_revision(
|
||||||
|
&runtime,
|
||||||
|
"isolated-kv",
|
||||||
|
"0.1.0",
|
||||||
|
"kv_probe_component.wasm",
|
||||||
|
test_limits(),
|
||||||
|
);
|
||||||
|
|
||||||
|
let first = runtime.start(&first_key, Vec::new()).unwrap();
|
||||||
|
let second = runtime.start(&second_key, Vec::new()).unwrap();
|
||||||
|
let isolated = runtime.start(&isolated_key, Vec::new()).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(first.invoke(b"set:answer:forty-two".to_vec()).unwrap(), b"");
|
||||||
|
assert_eq!(second.invoke(b"get:answer".to_vec()).unwrap(), b"forty-two");
|
||||||
|
assert_eq!(isolated.invoke(b"get:answer".to_vec()).unwrap(), b"");
|
||||||
|
assert_eq!(
|
||||||
|
runtime.capabilities(&first_key).unwrap()[0].interface(),
|
||||||
|
"wasmeld:kv/store@0.1.0"
|
||||||
|
);
|
||||||
|
|
||||||
|
runtime.stop_all().unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn kv_capability_requires_a_backend_and_enforces_limits() {
|
||||||
|
let artifact = component_artifact("kv_probe_component.wasm");
|
||||||
|
let manifest = ServiceManifest {
|
||||||
|
id: "kv-without-backend".to_owned(),
|
||||||
|
revision: "0.1.0".to_owned(),
|
||||||
|
component: artifact.display().to_string(),
|
||||||
|
world: component_world("kv_probe_component.wasm").to_owned(),
|
||||||
|
limits: test_limits(),
|
||||||
|
};
|
||||||
|
let bytes = fs::read(&artifact).unwrap();
|
||||||
|
let runtime = Runtime::new(RuntimeConfig::default()).unwrap();
|
||||||
|
assert!(matches!(
|
||||||
|
runtime.register(manifest, &bytes),
|
||||||
|
Err(RuntimeError::CapabilityUnavailable(interface))
|
||||||
|
if interface == "wasmeld:kv/store@0.1.0"
|
||||||
|
));
|
||||||
|
|
||||||
|
let runtime = Runtime::new(RuntimeConfig {
|
||||||
|
kv_backend: Some(Arc::new(MemoryKvBackend::default())),
|
||||||
|
..RuntimeConfig::default()
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
let mut limits = test_limits();
|
||||||
|
limits.max_input_bytes = 128 * 1024;
|
||||||
|
let key = register_component(&runtime, "kv-limits", "kv_probe_component.wasm", limits);
|
||||||
|
let actor = runtime.start(&key, Vec::new()).unwrap();
|
||||||
|
|
||||||
|
let long_key = format!("get:{}", "k".repeat(257));
|
||||||
|
assert!(matches!(
|
||||||
|
actor.invoke(long_key.into_bytes()),
|
||||||
|
Err(RuntimeError::ComponentError { message, .. })
|
||||||
|
if message.contains("InvalidKey")
|
||||||
|
));
|
||||||
|
|
||||||
|
let mut oversized = b"set:key:".to_vec();
|
||||||
|
oversized.resize(oversized.len() + KV_MAX_VALUE_BYTES + 1, b'x');
|
||||||
|
assert!(matches!(
|
||||||
|
actor.invoke(oversized),
|
||||||
|
Err(RuntimeError::ComponentError { message, .. })
|
||||||
|
if message.contains("ValueTooLarge")
|
||||||
|
));
|
||||||
|
|
||||||
|
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");
|
||||||
@@ -217,11 +337,21 @@ fn register_component(
|
|||||||
service_id: &str,
|
service_id: &str,
|
||||||
artifact_name: &str,
|
artifact_name: &str,
|
||||||
limits: ResourceLimits,
|
limits: ResourceLimits,
|
||||||
|
) -> wasmeld_runtime::ServiceKey {
|
||||||
|
register_component_revision(runtime, service_id, "0.1.0", artifact_name, limits)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn register_component_revision(
|
||||||
|
runtime: &Runtime,
|
||||||
|
service_id: &str,
|
||||||
|
revision: &str,
|
||||||
|
artifact_name: &str,
|
||||||
|
limits: ResourceLimits,
|
||||||
) -> wasmeld_runtime::ServiceKey {
|
) -> wasmeld_runtime::ServiceKey {
|
||||||
let artifact = component_artifact(artifact_name);
|
let artifact = component_artifact(artifact_name);
|
||||||
let manifest = ServiceManifest {
|
let manifest = ServiceManifest {
|
||||||
id: service_id.to_owned(),
|
id: service_id.to_owned(),
|
||||||
revision: "0.1.0".to_owned(),
|
revision: revision.to_owned(),
|
||||||
component: artifact.display().to_string(),
|
component: artifact.display().to_string(),
|
||||||
world: component_world(artifact_name).to_owned(),
|
world: component_world(artifact_name).to_owned(),
|
||||||
limits,
|
limits,
|
||||||
@@ -248,6 +378,7 @@ fn build_components_once() {
|
|||||||
"components/fault/Cargo.toml",
|
"components/fault/Cargo.toml",
|
||||||
"components/spin/Cargo.toml",
|
"components/spin/Cargo.toml",
|
||||||
"components/clock-probe/Cargo.toml",
|
"components/clock-probe/Cargo.toml",
|
||||||
|
"components/kv-probe/Cargo.toml",
|
||||||
"components/wasi-clock-probe/Cargo.toml",
|
"components/wasi-clock-probe/Cargo.toml",
|
||||||
] {
|
] {
|
||||||
let status = Command::new("rustup")
|
let status = Command::new("rustup")
|
||||||
@@ -315,9 +446,58 @@ fn component_world(artifact_name: &str) -> &'static str {
|
|||||||
"fault_component.wasm" => "component:fault/fault-component@0.1.0",
|
"fault_component.wasm" => "component:fault/fault-component@0.1.0",
|
||||||
"spin_component.wasm" => "component:spin/spin-component@0.1.0",
|
"spin_component.wasm" => "component:spin/spin-component@0.1.0",
|
||||||
"clock_probe_component.wasm" => "component:clock-probe/clock-probe-component@0.1.0",
|
"clock_probe_component.wasm" => "component:clock-probe/clock-probe-component@0.1.0",
|
||||||
|
"kv_probe_component.wasm" => "component:kv-probe/kv-probe-component@0.1.0",
|
||||||
"wasi_clock_probe_component.wasm" => {
|
"wasi_clock_probe_component.wasm" => {
|
||||||
"component:wasi-clock-probe/wasi-clock-probe-component@0.1.0"
|
"component:wasi-clock-probe/wasi-clock-probe-component@0.1.0"
|
||||||
}
|
}
|
||||||
other => panic!("no WIT world registered for {other}"),
|
other => panic!("no WIT world registered for {other}"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Default)]
|
||||||
|
struct MemoryKvBackend {
|
||||||
|
entries: Mutex<HashMap<(String, String), Vec<u8>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl KvBackend for MemoryKvBackend {
|
||||||
|
fn get(
|
||||||
|
&self,
|
||||||
|
service_id: &str,
|
||||||
|
key: &str,
|
||||||
|
_timeout: Duration,
|
||||||
|
) -> Result<Option<Vec<u8>>, KvBackendError> {
|
||||||
|
Ok(self
|
||||||
|
.entries
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.get(&(service_id.to_owned(), key.to_owned()))
|
||||||
|
.cloned())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set(
|
||||||
|
&self,
|
||||||
|
service_id: &str,
|
||||||
|
key: &str,
|
||||||
|
value: &[u8],
|
||||||
|
_timeout: Duration,
|
||||||
|
) -> Result<(), KvBackendError> {
|
||||||
|
self.entries
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.insert((service_id.to_owned(), key.to_owned()), value.to_vec());
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn delete(
|
||||||
|
&self,
|
||||||
|
service_id: &str,
|
||||||
|
key: &str,
|
||||||
|
_timeout: Duration,
|
||||||
|
) -> Result<(), KvBackendError> {
|
||||||
|
self.entries
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.remove(&(service_id.to_owned(), key.to_owned()));
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,101 +0,0 @@
|
|||||||
# Wasmeld Host Capabilities
|
|
||||||
|
|
||||||
**状态:** Implemented
|
|
||||||
|
|
||||||
**首个能力:** `wasmeld:clock/monotonic-clock@0.1.0`
|
|
||||||
|
|
||||||
## 边界
|
|
||||||
|
|
||||||
所有组件实现稳定的 `service-component` 导出契约。宿主能力不加入这个基础 World,
|
|
||||||
而是定义成独立 WIT interface,由组件专属 World 按需 import。
|
|
||||||
|
|
||||||
```text
|
|
||||||
wit/
|
|
||||||
├── service/package.wit # wasmeld:service@0.1.0
|
|
||||||
└── clock/package.wit # wasmeld:clock@0.1.0
|
|
||||||
```
|
|
||||||
|
|
||||||
例如普通 Echo 不导入宿主能力:
|
|
||||||
|
|
||||||
```wit
|
|
||||||
package component:echo@0.1.0;
|
|
||||||
|
|
||||||
world echo-component {
|
|
||||||
include wasmeld:service/service-component@0.1.0;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Clock Probe 显式申请单调时钟:
|
|
||||||
|
|
||||||
```wit
|
|
||||||
package component:clock-probe@0.1.0;
|
|
||||||
|
|
||||||
world clock-probe-component {
|
|
||||||
include wasmeld:service/service-component@0.1.0;
|
|
||||||
import wasmeld:clock/monotonic-clock@0.1.0;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
组件通过自己的 World 生成 binding:
|
|
||||||
|
|
||||||
```rust
|
|
||||||
wit_bindgen::generate!({
|
|
||||||
path: "wit",
|
|
||||||
world: "clock-probe-component",
|
|
||||||
generate_all,
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
组件根目录中的 `wit/deps` 由 `wasmeld wit fetch` 根据 `wasmeld.toml` 和
|
|
||||||
`wit.lock` 生成。这几行只是编译期 binding 入口,不是网络协议。组件最终只包含
|
|
||||||
所选 World 的 imports 和 exports。
|
|
||||||
|
|
||||||
## Runtime
|
|
||||||
|
|
||||||
Runtime 注册 Component 时读取真实 imports,并映射为内部 `HostCapability`。未知
|
|
||||||
import 会在注册阶段拒绝。Actor 创建 Linker 时,只为该 Component 实际导入的能力
|
|
||||||
注册实现。
|
|
||||||
|
|
||||||
```text
|
|
||||||
Component imports
|
|
||||||
|
|
|
||||||
v
|
|
||||||
validate_component_imports
|
|
||||||
|
|
|
||||||
v
|
|
||||||
Vec<HostCapability>
|
|
||||||
|
|
|
||||||
v
|
|
||||||
Actor Linker
|
|
||||||
```
|
|
||||||
|
|
||||||
当前 `monotonic-clock.now` 返回 Actor 创建后经过的纳秒数。它不提供系统时间、网络、
|
|
||||||
文件或其他宿主访问。
|
|
||||||
|
|
||||||
Rust `std::time::Instant` 会引入 `wasi:clocks/monotonic-clock`,该 WASI 接口仍不在
|
|
||||||
Sandbox 白名单内。组件必须使用平台明确提供的 `wasmeld:clock/monotonic-clock`,
|
|
||||||
不能通过标准库绕过能力控制。
|
|
||||||
|
|
||||||
## 新增能力
|
|
||||||
|
|
||||||
新增能力时必须同时完成:
|
|
||||||
|
|
||||||
1. 在独立 `.wit` 文件中定义小型 interface。
|
|
||||||
2. 只在需要它的组件 World 中 import。
|
|
||||||
3. 在 Runtime 中实现生成的 Host trait。
|
|
||||||
4. 将完全限定的 import 名称映射到新的 `HostCapability`。
|
|
||||||
5. 在 Actor Linker 中注册实现。
|
|
||||||
6. 增加允许和拒绝两类 Sandbox 测试。
|
|
||||||
|
|
||||||
不要把可选能力加入 `service-component`。不要仅依据包 manifest 授权,Runtime 必须
|
|
||||||
以编译后 Component 的真实 imports 为准。
|
|
||||||
|
|
||||||
## 版本策略
|
|
||||||
|
|
||||||
- 已发布的 WIT 版本不可原地修改。
|
|
||||||
- 破坏性变更发布新的 major version。
|
|
||||||
- Runtime 在迁移期可以同时实现两个版本。
|
|
||||||
- 不相关能力独立演进,不能迫使所有组件重新编译。
|
|
||||||
|
|
||||||
`service` 和 `clock` 已经是两个独立版本的 WIT package。源码只维护当前版本,历史
|
|
||||||
版本由 Git tag 与不可变 Registry 制品保留;组件通过包名、版本和锁文件选择依赖。
|
|
||||||
@@ -1,94 +0,0 @@
|
|||||||
# Wasmeld 组件包
|
|
||||||
|
|
||||||
**状态:** Implemented
|
|
||||||
|
|
||||||
**格式版本:** 1
|
|
||||||
|
|
||||||
`.wasmpkg` 是平台内部发布 WebAssembly Component 的单文件制品。文件使用 ZIP
|
|
||||||
容器,扩展名固定为 `.wasmpkg`,并且只能包含两个根目录条目:
|
|
||||||
|
|
||||||
```text
|
|
||||||
echo-0.1.0.wasmpkg
|
|
||||||
├── package.toml
|
|
||||||
└── component.wasm
|
|
||||||
```
|
|
||||||
|
|
||||||
`package.toml` 示例:
|
|
||||||
|
|
||||||
```toml
|
|
||||||
schema_version = 1
|
|
||||||
id = "echo"
|
|
||||||
revision = "0.1.0"
|
|
||||||
world = "component:echo/echo-component@0.1.0"
|
|
||||||
component = "component.wasm"
|
|
||||||
sha256 = "..."
|
|
||||||
```
|
|
||||||
|
|
||||||
包中不包含内存、Fuel、超时或 mailbox 等部署策略。这些限制由
|
|
||||||
`wasmeld-console` 的 `registration_limits` 注入,组件发布者不能通过上传制品扩大
|
|
||||||
Sandbox 权限。
|
|
||||||
|
|
||||||
## 组件配置
|
|
||||||
|
|
||||||
组件必须是 `cdylib`,并在 `Cargo.toml` 中声明平台服务 ID:
|
|
||||||
|
|
||||||
```toml
|
|
||||||
[lib]
|
|
||||||
crate-type = ["cdylib"]
|
|
||||||
|
|
||||||
[package.metadata.wasmeld]
|
|
||||||
id = "echo"
|
|
||||||
world = "component:echo/echo-component@0.1.0"
|
|
||||||
```
|
|
||||||
|
|
||||||
版本直接使用 `[package].version`。`world` 必须是组件选择的完整、带版本 WIT World;
|
|
||||||
打包工具会将它写入制品,控制台不需要手工填写。
|
|
||||||
|
|
||||||
组件的 WIT 依赖由同目录的 `wasmeld.toml` 和 `wit.lock` 管理。`pack` 会在 Cargo
|
|
||||||
编译前自动生成 `wit/deps`;CI 应传入 `--locked`,禁止依赖摘要发生漂移。
|
|
||||||
|
|
||||||
## 打包
|
|
||||||
|
|
||||||
从项目根目录运行:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cargo run -p wasmeld-package --bin wasmeld -- \
|
|
||||||
pack components/echo/Cargo.toml --locked
|
|
||||||
```
|
|
||||||
|
|
||||||
命令默认使用 Rust `1.90.0` 构建 `wasm32-wasip2` release Component,并输出:
|
|
||||||
|
|
||||||
```text
|
|
||||||
dist/echo-0.1.0.wasmpkg
|
|
||||||
```
|
|
||||||
|
|
||||||
可以覆盖输出路径,或者复用已经构建的 Component:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cargo run -p wasmeld-package --bin wasmeld -- \
|
|
||||||
pack components/echo/Cargo.toml \
|
|
||||||
--output dist/echo.wasmpkg \
|
|
||||||
--no-build
|
|
||||||
```
|
|
||||||
|
|
||||||
通过 `WASMELD_COMPONENT_TOOLCHAIN` 可以覆盖组件构建工具链。
|
|
||||||
|
|
||||||
## 注册
|
|
||||||
|
|
||||||
管理 API 只接收一个 multipart 字段:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -F package=@dist/echo-0.1.0.wasmpkg \
|
|
||||||
http://127.0.0.1:8080/api/v1/services
|
|
||||||
```
|
|
||||||
|
|
||||||
Console 在写入制品目录前执行以下校验:
|
|
||||||
|
|
||||||
- ZIP 恰好包含 `package.toml` 和 `component.wasm`
|
|
||||||
- schema、服务 ID、版本和 WIT World 合法
|
|
||||||
- 解压后的 Component 未超过平台上限
|
|
||||||
- Component 的 SHA-256 与 manifest 一致
|
|
||||||
- Wasmtime 能编译 Component,且 imports 符合 Sandbox 白名单
|
|
||||||
- 同一服务 ID 和版本尚未注册
|
|
||||||
|
|
||||||
校验成功后,服务以 `stopped` 状态注册。启动 Actor 仍是独立的管理操作。
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
# Wasmeld Console 管理后端
|
|
||||||
|
|
||||||
**状态:** Implemented
|
|
||||||
|
|
||||||
**范围:** 本地 Wasmeld Runtime 管理控制面
|
|
||||||
|
|
||||||
## 边界
|
|
||||||
|
|
||||||
`wasmeld-console` 是独立 Rust 进程,对外提供管理 HTTP API,对内只调用
|
|
||||||
`wasmeld-runtime`。Runtime 作为库嵌入同一进程,不另起网络服务。前端 `console`
|
|
||||||
不直接访问 Runtime、文件系统或数据库。
|
|
||||||
|
|
||||||
~~~text
|
|
||||||
TanStack Console / wasmeld CLI
|
|
||||||
|
|
|
||||||
| HTTP + JSON / multipart
|
|
||||||
v
|
|
||||||
wasmeld-console
|
|
||||||
+-- API / CORS
|
|
||||||
+-- component artifact registry
|
|
||||||
+-- immutable WIT package registry
|
|
||||||
+-- Toasty persistence
|
|
||||||
+-- local libSQL
|
|
||||||
|
|
|
||||||
v
|
|
||||||
wasmeld-runtime
|
|
||||||
+-- Wasmtime Engine
|
|
||||||
+-- resident Actor
|
|
||||||
+-- Store + Component Instance
|
|
||||||
~~~
|
|
||||||
|
|
||||||
## 状态
|
|
||||||
|
|
||||||
- Console 接收一个 `.wasmpkg`,从包内读取并校验组件身份。
|
|
||||||
- `component.wasm` 和平台生成的运行时 `manifest.toml` 保存到本地制品目录。
|
|
||||||
- 运行限制由 Console 策略注入,不接受组件包覆盖。
|
|
||||||
- manifest、调用计数和最近 256 条事件通过 Toasty 保存到 libSQL。
|
|
||||||
- Console 进程启动时自动创建 Runtime Engine,从数据库恢复服务元数据,并从制品目录注册 Component。
|
|
||||||
- Console 可显式启动、停止和重启 Runtime;停止 Runtime 不会停止 Console HTTP 管理面。
|
|
||||||
- Runtime 停止时释放全部 Actor、Store、Instance 和 epoch worker。
|
|
||||||
- Actor 内存和运行状态只存在于当前进程。
|
|
||||||
- Runtime 或 Console 重启后所有服务回到 `stopped`,调用计数与事件保留,但不会伪造 Wasm 内存恢复。
|
|
||||||
|
|
||||||
## 约束
|
|
||||||
|
|
||||||
- 默认只监听 `127.0.0.1:8080`。
|
|
||||||
- libSQL 数据库默认位于 `var/wasmeld/console.db`。
|
|
||||||
- CORS 默认只允许本地开发面板,其他 Origin 必须显式配置。
|
|
||||||
- 服务 ID 和 revision 只能包含 ASCII 字母、数字、点、下划线和短横线。
|
|
||||||
- `.wasmpkg` 上传和解压后的 Component 上限均默认为 64 MiB。
|
|
||||||
- 二进制 WIT Package 上传上限默认为 4 MiB,同名同版本发布后不可覆盖。
|
|
||||||
- 包内固定只有 `package.toml` 与 `component.wasm`,不会直接解压任意路径。
|
|
||||||
- Wasm 编译、实例化和调用通过阻塞任务执行,不占用 Tokio 异步 worker。
|
|
||||||
- Sandbox、fuel、deadline、内存和 mailbox 限制仍由 `wasmeld-runtime` 执行。
|
|
||||||
@@ -1,319 +0,0 @@
|
|||||||
# Wasmeld Runtime 架构
|
|
||||||
|
|
||||||
**状态:** Draft
|
|
||||||
|
|
||||||
**日期:** 2026-07-22
|
|
||||||
|
|
||||||
**范围:** 仅实现 Wasmeld Runtime
|
|
||||||
|
|
||||||
## 1. 范围
|
|
||||||
|
|
||||||
本阶段只解决一件事:在一个 Rust 宿主进程中,安全地加载并常驻运行内部 Rust 编译的 WebAssembly Component。
|
|
||||||
|
|
||||||
需要具备:
|
|
||||||
|
|
||||||
- Rust Component 的构建、注册、加载、调用、停止与重载。
|
|
||||||
- 同一实例跨多次调用保留内存。
|
|
||||||
- 同一实例不发生并发重入。
|
|
||||||
- Wasmtime 资源限制与最小 sandbox。
|
|
||||||
- 统一且不含业务语义的 Component 调用契约。
|
|
||||||
|
|
||||||
当前不做 HTTP、客户、鉴权、数据库、网络、消息队列、业务状态持久化、分布式部署或发布治理。
|
|
||||||
|
|
||||||
## 2. 核心结构
|
|
||||||
|
|
||||||
~~~text
|
|
||||||
Rust Component source
|
|
||||||
|
|
|
||||||
| cargo build --target wasm32-wasip2 --release
|
|
||||||
v
|
|
||||||
component.wasm + manifest.toml
|
|
||||||
|
|
|
||||||
v
|
|
||||||
Component Registry
|
|
||||||
|
|
|
||||||
v
|
|
||||||
Wasmeld Runtime (one Rust process)
|
|
||||||
|
|
|
||||||
+-- Engine
|
|
||||||
+-- compiled Component cache
|
|
||||||
+-- Service Manager
|
|
||||||
|
|
|
||||||
+-- ServiceActor
|
|
||||||
+-- bounded mailbox
|
|
||||||
+-- Store<HostState>
|
|
||||||
+-- restricted Linker
|
|
||||||
+-- Component Instance
|
|
||||||
~~~
|
|
||||||
|
|
||||||
Runtime 是一个 Rust 库或单独进程,不提供网络协议。对内只暴露:
|
|
||||||
|
|
||||||
~~~text
|
|
||||||
register(manifest, artifact) -> ServiceKey
|
|
||||||
start(service_key, init_config) -> ActorHandle
|
|
||||||
invoke(actor_handle, bytes) -> bytes or RuntimeError
|
|
||||||
stop(actor_handle)
|
|
||||||
reload(next_manifest, next_artifact, init_config) -> ActorHandle
|
|
||||||
~~~
|
|
||||||
|
|
||||||
调用方未来可以是 HTTP Adapter、CLI 或测试程序;Runtime 本身不关心调用来源。
|
|
||||||
|
|
||||||
## 3. 核心对象
|
|
||||||
|
|
||||||
| 对象 | 责任 |
|
|
||||||
| --- | --- |
|
|
||||||
| Component Artifact | 一个不可变的 component.wasm 与最小 manifest。 |
|
|
||||||
| Service Revision | Artifact 的 id + revision。 |
|
|
||||||
| Engine | 进程级共享的 Wasmtime 编译和执行引擎。 |
|
|
||||||
| Component Cache | 缓存编译后的 Component,不保存实例内存。 |
|
|
||||||
| Service Manager | 管理 Service Revision 与 ActorHandle 的映射。 |
|
|
||||||
| ServiceActor | 独占 Store、Instance、HostState 与有界邮箱。 |
|
|
||||||
| ActorHandle | 向 Actor 发送调用,不能接触 Store 或 Instance。 |
|
|
||||||
| HostState | Store 专有的运行时状态,不包含业务数据。 |
|
|
||||||
|
|
||||||
V1 采用:
|
|
||||||
|
|
||||||
~~~text
|
|
||||||
one Service Revision -> one ServiceActor -> one Store + one Instance
|
|
||||||
~~~
|
|
||||||
|
|
||||||
多个 Actor、分片和水平扩展属于后续能力。
|
|
||||||
|
|
||||||
## 4. 制品与注册
|
|
||||||
|
|
||||||
内部服务构建为 Component:
|
|
||||||
|
|
||||||
~~~bash
|
|
||||||
cargo build --target wasm32-wasip2 --release
|
|
||||||
~~~
|
|
||||||
|
|
||||||
最小制品:
|
|
||||||
|
|
||||||
~~~text
|
|
||||||
component.wasm
|
|
||||||
manifest.toml
|
|
||||||
~~~
|
|
||||||
|
|
||||||
manifest 只包含:
|
|
||||||
|
|
||||||
~~~text
|
|
||||||
id
|
|
||||||
revision
|
|
||||||
component path
|
|
||||||
expected WIT world
|
|
||||||
memory limit
|
|
||||||
fuel per call
|
|
||||||
deadline
|
|
||||||
mailbox capacity
|
|
||||||
input/output byte limits
|
|
||||||
~~~
|
|
||||||
|
|
||||||
注册规则:
|
|
||||||
|
|
||||||
- ServiceKey 由 id 与 revision 构成,不允许覆盖。
|
|
||||||
- Runtime 校验 manifest 的 WIT world 格式与资源策略;实例化时由 typed binding 校验基础导出契约。
|
|
||||||
- V1 只接受本地受信任制品;Registry、签名、SBOM 与远程下载后置。
|
|
||||||
- 已编译 Component 可缓存,但 Actor 的内存只能存在于 Store + Instance 中。
|
|
||||||
|
|
||||||
## 5. 最小 WIT 契约
|
|
||||||
|
|
||||||
V1 不定义任何业务协议。输入输出都是有长度上限的 opaque bytes。
|
|
||||||
|
|
||||||
~~~wit
|
|
||||||
package wasmeld:service@0.1.0;
|
|
||||||
|
|
||||||
world service-component {
|
|
||||||
variant service-error {
|
|
||||||
init-failed(string),
|
|
||||||
call-failed(string),
|
|
||||||
}
|
|
||||||
|
|
||||||
export init: func(config: list<u8>) -> result<_, service-error>;
|
|
||||||
export invoke: func(input: list<u8>) -> result<list<u8>, service-error>;
|
|
||||||
}
|
|
||||||
~~~
|
|
||||||
|
|
||||||
规则:
|
|
||||||
|
|
||||||
- init 仅在一个新 Instance 上调用一次。
|
|
||||||
- invoke 是唯一的普通调用入口。
|
|
||||||
- Component 返回 service-error 后,Actor 可继续使用。
|
|
||||||
- trap、资源超限或中断后,整个 Store + Instance 必须丢弃,不能继续调用。
|
|
||||||
- 基础 world 不定义宿主能力 import。组件专属 World 通过独立 interface 按需 import
|
|
||||||
平台能力,详见 `wasmeld-capabilities.md`。普通 Rust `wasm32-wasip2` 产物会隐式导入标准 WASI P2
|
|
||||||
接口,因此 Runtime 提供受限的 WASI 兼容层,而不是把它们暴露为业务 ABI。
|
|
||||||
- Runtime 在注册时只允许以下兼容 import:`wasi:cli/{environment,exit,stdin,stdout,stderr}`、
|
|
||||||
`wasi:clocks/wall-clock`、`wasi:filesystem/{preopens,types}`、`wasi:io/{error,streams}`。
|
|
||||||
`wasi:io/poll`、WASI monotonic clock、随机数、socket、HTTP 和未知平台 import 都会被拒绝。
|
|
||||||
- V1 提供独立的 `wasmeld:clock/monotonic-clock@0.1.0` 平台能力。只有显式导入
|
|
||||||
该 interface 的组件会获得对应 Linker 实现。
|
|
||||||
- health、shutdown、日志、文件、网络和标准 I/O 都不属于基础 ABI。wall clock 仅作为
|
|
||||||
Rust/WASI 兼容层的一部分存在,不用于传递业务能力;随机数不在 V1 白名单内。
|
|
||||||
|
|
||||||
## 6. 常驻 Actor
|
|
||||||
|
|
||||||
~~~text
|
|
||||||
Caller
|
|
||||||
-> ActorHandle
|
|
||||||
-> bounded Sender<Invocation>
|
|
||||||
-> dedicated Wasm worker
|
|
||||||
-> Store + Instance + HostState
|
|
||||||
~~~
|
|
||||||
|
|
||||||
一个 Actor 的 worker 独占 Store。不能使用 Arc<Mutex<Store>> 共享 Store,也不能让同一 Instance 重入。
|
|
||||||
|
|
||||||
处理流程:
|
|
||||||
|
|
||||||
1. ActorHandle 提交 Invocation。
|
|
||||||
2. Actor 从 FIFO mailbox 取出一条消息。
|
|
||||||
3. Actor 在同一 Store + Instance 上调用 service.invoke。
|
|
||||||
4. Actor 通过 oneshot response 返回结果。
|
|
||||||
5. 下一条消息才可进入该 Instance。
|
|
||||||
|
|
||||||
这保证 Rust heap、Wasm linear memory 和全局变量在多次调用间常驻,同时保证状态修改串行。
|
|
||||||
|
|
||||||
实现约束:
|
|
||||||
|
|
||||||
- Wasm 调用放在专用 worker 线程或线程池,不能阻塞 Tokio 的通用 worker。
|
|
||||||
- V1 不使用 Wasmtime async 或 Wasm threads。为兼容 Rust 的 `wasm32-wasip2` 组件,
|
|
||||||
链接同步、受限的 WASI P2 白名单实现。
|
|
||||||
- mailbox 满时立即返回 RuntimeOverloaded。
|
|
||||||
- 调用 deadline 到期且尚未进入 Instance 时直接取消。
|
|
||||||
- Component 不得在 invoke 之外启动后台循环。
|
|
||||||
|
|
||||||
Actor 内存是易失的。Runtime 重启、Actor fault、stop 或 reload 后,下一实例重新执行 init,不恢复旧内存。
|
|
||||||
|
|
||||||
## 7. 生命周期
|
|
||||||
|
|
||||||
~~~text
|
|
||||||
Registered -> Starting -> Ready -> Draining -> Stopped
|
|
||||||
|
|
|
||||||
+-> Failed
|
|
||||||
~~~
|
|
||||||
|
|
||||||
- register:保存 Artifact 与 manifest。
|
|
||||||
- start:取得已编译 Component,创建 Store、受限 WASI Linker 与 Instance,然后调用 init。
|
|
||||||
- ready:接收 invoke。
|
|
||||||
- draining:拒绝新调用,等待当前调用完成。
|
|
||||||
- stopped:释放 Store 和 Instance。
|
|
||||||
- failed:trap、fuel 耗尽、deadline 或 Runtime 错误后立即释放 Store 和 Instance。
|
|
||||||
|
|
||||||
故障后:
|
|
||||||
|
|
||||||
~~~text
|
|
||||||
fault
|
|
||||||
-> mark Actor failed
|
|
||||||
-> reject queued calls
|
|
||||||
-> drop Store + Instance
|
|
||||||
-> optionally create a new Actor
|
|
||||||
~~~
|
|
||||||
|
|
||||||
自动重建可配置,但 V1 不应隐藏故障或声称保留了旧内存。
|
|
||||||
|
|
||||||
reload 的最小流程:
|
|
||||||
|
|
||||||
1. 注册新的 Service Revision。
|
|
||||||
2. start 新 Actor,确保 init 成功。
|
|
||||||
3. 调用方切换到新的 ActorHandle。
|
|
||||||
4. 调用方显式 stop 旧 Actor 并释放。
|
|
||||||
|
|
||||||
V1 不做请求级流量切换或状态迁移。
|
|
||||||
|
|
||||||
## 8. Sandbox 与资源限制
|
|
||||||
|
|
||||||
V1 Linker 注册受限 WASI P2 兼容实现,并按 Component 的真实 imports 注册已批准的
|
|
||||||
平台能力。每个 Actor 都有独立
|
|
||||||
`WasiCtx`,并且显式禁止 TCP、UDP、DNS 和所有 socket 地址。默认配置还保证无环境变量、
|
|
||||||
参数、工作目录或预打开目录,stdin 关闭,stdout/stderr 丢弃。因此 Component 默认没有:
|
|
||||||
|
|
||||||
- 文件系统、环境变量、命令行参数或标准 I/O 继承。
|
|
||||||
- 网络、DNS、socket 或原始系统调用。
|
|
||||||
- 数据库、密钥、配置文件、宿主目录或宿主进程能力。
|
|
||||||
|
|
||||||
每个 Actor 必须显式设置:
|
|
||||||
|
|
||||||
| 限制 | 目的 |
|
|
||||||
| --- | --- |
|
|
||||||
| linear memory | 阻止无限内存增长。 |
|
|
||||||
| tables / instances / memories | 限制 Wasm 对象数量。 |
|
|
||||||
| fuel per invocation | 限制确定性的计算工作量。 |
|
|
||||||
| epoch deadline | 限制 wall-clock 执行时间。 |
|
|
||||||
| input/output bytes | 限制单次数据传输。 |
|
|
||||||
| mailbox capacity | 实现背压,避免无限排队。 |
|
|
||||||
|
|
||||||
fuel 和 epoch 只约束 Wasm 执行。未来一旦加入 Host I/O,必须单独定义 timeout、取消和连接资源策略。
|
|
||||||
|
|
||||||
Runtime 的 epoch ticker 上限为 10 ms,且服务 deadline 不能小于该 tick,以避免配置使
|
|
||||||
wall-clock deadline 失效。ActorHandle 持有 ticker 的生命周期引用,因此 Runtime 对象释放后,
|
|
||||||
仍被调用方持有的 ActorHandle 不会失去 epoch 中断能力。
|
|
||||||
|
|
||||||
Wasm Store 隔离 Component 内存和可见 import,但不是完整 OS 隔离。V1 以第一方代码为前提;进程、Pod、gVisor 或 Kata 隔离在实际需要时再加入。
|
|
||||||
|
|
||||||
## 9. 最小代码结构
|
|
||||||
|
|
||||||
~~~text
|
|
||||||
wit/
|
|
||||||
service/
|
|
||||||
package.wit
|
|
||||||
clock/
|
|
||||||
package.wit
|
|
||||||
|
|
||||||
crates/
|
|
||||||
wasmeld-runtime/
|
|
||||||
bindings.rs
|
|
||||||
error.rs
|
|
||||||
manifest.rs
|
|
||||||
runtime.rs
|
|
||||||
lib.rs
|
|
||||||
|
|
||||||
components/
|
|
||||||
echo/
|
|
||||||
counter/
|
|
||||||
fault/
|
|
||||||
spin/
|
|
||||||
clock-probe/
|
|
||||||
|
|
||||||
tests/
|
|
||||||
runtime_components.rs
|
|
||||||
~~~
|
|
||||||
|
|
||||||
## 10. 验收测试
|
|
||||||
|
|
||||||
测试 Component:
|
|
||||||
|
|
||||||
- echo:原样返回输入,验证基础加载与调用。
|
|
||||||
- counter:init 创建计数状态,每次 invoke 递增,验证内存常驻。
|
|
||||||
- fault:显式 trap,验证旧 Store 会被丢弃且 Actor 可重建。
|
|
||||||
- spin:不可被优化掉的 CPU 循环,验证 fuel 与 epoch 中断。
|
|
||||||
- clock-probe:显式导入平台 monotonic-clock,验证能力按需 Link。
|
|
||||||
- wasi-clock-probe:通过 Rust 标准库导入 WASI monotonic clock,验证注册时拒绝越权。
|
|
||||||
|
|
||||||
必须验证:
|
|
||||||
|
|
||||||
1. Component 只有在导出匹配预期 WIT world、且其 import 可由受限 WASI Linker 满足时才可启动。
|
|
||||||
2. 同一 Actor 调用 counter 两次能观察到递增状态。
|
|
||||||
3. stop、fault 或 Runtime 重启后,counter 从 init 重新开始。
|
|
||||||
4. 多个并发 invoke 会严格经同一 Actor 串行执行。
|
|
||||||
5. 无限循环会被 fuel 或 epoch deadline 终止。
|
|
||||||
6. trap 后旧 Store 被丢弃,重建 Actor 后可再次调用。
|
|
||||||
7. mailbox 满时立即出现 overload,不无限等待。
|
|
||||||
8. memory.grow 超过限制会导致 Actor fault。
|
|
||||||
9. reload 能启动新 revision 的 Actor;旧 Actor 由调用方显式 stop。
|
|
||||||
|
|
||||||
## 11. 后续扩展触发条件
|
|
||||||
|
|
||||||
| 明确需求 | 再引入的能力 |
|
|
||||||
| --- | --- |
|
|
||||||
| 需要网络入口 | HTTP/gRPC Adapter 与 API Gateway。 |
|
|
||||||
| 需要调用宿主资源 | 独立 Host Capability 与 WIT import。 |
|
|
||||||
| 需要跨重启保留状态 | 外部状态、checkpoint 与恢复协议。 |
|
|
||||||
| 需要多个实例 | Actor 池、分片和放置策略。 |
|
|
||||||
| 需要生产发布治理 | 制品签名、Registry、灰度与回滚。 |
|
|
||||||
| 需要执行非第一方代码 | 额外进程或 OS sandbox。 |
|
|
||||||
|
|
||||||
## 12. 参考资料
|
|
||||||
|
|
||||||
- [WebAssembly Component Model](https://component-model.bytecodealliance.org/)
|
|
||||||
- [Rust Components with wasm32-wasip2](https://component-model.bytecodealliance.org/language-support/building-a-simple-component/rust.html)
|
|
||||||
- [Wasmtime](https://docs.wasmtime.dev/)
|
|
||||||
- [Wasmtime interruption with fuel and epochs](https://docs.wasmtime.dev/examples-interrupting-wasm.html)
|
|
||||||
@@ -1,145 +0,0 @@
|
|||||||
# WIT Package 管理
|
|
||||||
|
|
||||||
**状态:** Implemented
|
|
||||||
|
|
||||||
**Manifest 版本:** 1
|
|
||||||
|
|
||||||
**Lock 版本:** 1
|
|
||||||
|
|
||||||
## 边界
|
|
||||||
|
|
||||||
`wasmeld-console` 作为版本化 WIT Registry 和管理面。组件构建侧由 `wasmeld`
|
|
||||||
解析依赖、锁定摘要并生成本地 `wit/deps`。`wit-bindgen` 始终只读取本地文件,
|
|
||||||
Component 运行时不访问 Registry。
|
|
||||||
|
|
||||||
```text
|
|
||||||
WIT Git tag
|
|
||||||
|
|
|
||||||
v
|
|
||||||
Wasmeld Registry
|
|
||||||
|
|
|
||||||
v
|
|
||||||
wasmeld -> wit.lock -> wit/deps -> wit-bindgen
|
|
||||||
```
|
|
||||||
|
|
||||||
WIT 源码不按版本复制目录。每个 package 只维护当前源码,发布时用
|
|
||||||
`service/v1.0.0`、`clock/v1.1.0` 等 Git tag 标记源码版本。Registry 保存由
|
|
||||||
Component Model 工具链编码的标准二进制 WIT Package,并从制品本身解析包名、
|
|
||||||
版本、直接依赖和 SHA-256。相同包名和版本不可覆盖。
|
|
||||||
|
|
||||||
## 组件目录
|
|
||||||
|
|
||||||
```text
|
|
||||||
components/clock-probe/
|
|
||||||
├── Cargo.toml
|
|
||||||
├── wasmeld.toml
|
|
||||||
├── wit.lock
|
|
||||||
├── src/lib.rs
|
|
||||||
└── wit/
|
|
||||||
├── world.wit
|
|
||||||
└── deps/ # 自动生成,不提交
|
|
||||||
```
|
|
||||||
|
|
||||||
`wasmeld.toml` 当前只接受精确版本:
|
|
||||||
|
|
||||||
```toml
|
|
||||||
schema_version = 1
|
|
||||||
|
|
||||||
[registry]
|
|
||||||
url = "http://127.0.0.1:8080"
|
|
||||||
|
|
||||||
[dependencies]
|
|
||||||
"wasmeld:clock" = "0.1.0"
|
|
||||||
"wasmeld:service" = "0.1.0"
|
|
||||||
|
|
||||||
[replace."wasmeld:clock"]
|
|
||||||
path = "../../wit/clock"
|
|
||||||
|
|
||||||
[replace."wasmeld:service@0.1.0"]
|
|
||||||
path = "../../wit/service"
|
|
||||||
```
|
|
||||||
|
|
||||||
精确版本 replace 的优先级高于包级 replace。只有根组件的 replace 生效;依赖包
|
|
||||||
自身不能修改整个依赖图。替换目录声明的 WIT package 名称和版本必须与依赖完全
|
|
||||||
相同。没有 replace 的直接依赖和传递依赖从 `[registry].url` 获取。
|
|
||||||
|
|
||||||
## 锁文件
|
|
||||||
|
|
||||||
`wit.lock` 必须提交:
|
|
||||||
|
|
||||||
```toml
|
|
||||||
schema_version = 1
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "wasmeld:clock"
|
|
||||||
version = "0.1.0"
|
|
||||||
source = "registry+http://127.0.0.1:8080"
|
|
||||||
sha256 = "..."
|
|
||||||
replaced = false
|
|
||||||
```
|
|
||||||
|
|
||||||
Registry 依赖的摘要覆盖完整二进制包;path replace 的摘要覆盖包目录下排序后的
|
|
||||||
顶层 `.wit` 文件名和内容。`--locked` 在安装 `wit/deps` 前比较完整传递依赖图、
|
|
||||||
来源和摘要,不一致时构建失败。
|
|
||||||
|
|
||||||
## 命令
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 将 WIT 源码编码成标准二进制 WIT Package
|
|
||||||
cargo run -p wasmeld-package --bin wasmeld -- \
|
|
||||||
wit build wit/service
|
|
||||||
|
|
||||||
# 构建并发布;也可用 WASMELD_REGISTRY 设置 Registry
|
|
||||||
cargo run -p wasmeld-package --bin wasmeld -- \
|
|
||||||
wit publish wit/service --registry http://127.0.0.1:8080
|
|
||||||
|
|
||||||
# 获取依赖并更新 wit.lock
|
|
||||||
cargo run -p wasmeld-package --bin wasmeld -- \
|
|
||||||
wit fetch --manifest components/clock-probe/wasmeld.toml
|
|
||||||
|
|
||||||
# 验证锁文件,不更新
|
|
||||||
cargo run -p wasmeld-package --bin wasmeld -- \
|
|
||||||
wit fetch --manifest components/clock-probe/wasmeld.toml --locked
|
|
||||||
|
|
||||||
# 查看实际来源
|
|
||||||
cargo run -p wasmeld-package --bin wasmeld -- \
|
|
||||||
wit graph --manifest components/clock-probe/wasmeld.toml
|
|
||||||
|
|
||||||
# 添加或删除本地替换
|
|
||||||
cargo run -p wasmeld-package --bin wasmeld -- \
|
|
||||||
wit replace wasmeld:clock@0.1.0 \
|
|
||||||
--path ../../wit/clock \
|
|
||||||
--manifest components/clock-probe/wasmeld.toml
|
|
||||||
|
|
||||||
cargo run -p wasmeld-package --bin wasmeld -- \
|
|
||||||
wit replace wasmeld:clock@0.1.0 \
|
|
||||||
--drop \
|
|
||||||
--manifest components/clock-probe/wasmeld.toml
|
|
||||||
```
|
|
||||||
|
|
||||||
`pack` 自动执行相同的同步步骤:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cargo run -p wasmeld-package --bin wasmeld -- \
|
|
||||||
pack components/clock-probe/Cargo.toml --locked
|
|
||||||
```
|
|
||||||
|
|
||||||
## Registry API
|
|
||||||
|
|
||||||
| 方法 | 路径 | 用途 |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| `GET` | `/api/v1/wit/packages` | 列出全部包版本 |
|
|
||||||
| `POST` | `/api/v1/wit/packages` | multipart 发布 `package.wasm` |
|
|
||||||
| `GET` | `/api/v1/wit/packages/{namespace}/{name}/{version}` | 查询元数据 |
|
|
||||||
| `GET` | `/api/v1/wit/packages/{namespace}/{name}/{version}/content` | 下载二进制包 |
|
|
||||||
|
|
||||||
Console 管理面提供同一 Registry 的列表、发布和下载操作。Registry 元数据不接受
|
|
||||||
客户端填写,服务端以二进制包解析结果为准。
|
|
||||||
|
|
||||||
## 当前边界
|
|
||||||
|
|
||||||
- 依赖版本必须是精确 SemVer,不解析版本范围。
|
|
||||||
- Registry 使用本地文件系统持久化,默认目录为 `var/wasmeld/wit-packages`。
|
|
||||||
- Registry 不保存 Git 仓库或源码;Git tag 与 commit 由源码仓库管理。
|
|
||||||
- 本地 `replace` 只支持目录,不支持 Git replace。
|
|
||||||
- 暂不包含认证、权限、废弃标记和垃圾回收。
|
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
package wasmeld:kv@0.1.0;
|
||||||
|
|
||||||
|
interface store {
|
||||||
|
variant kv-error {
|
||||||
|
invalid-key(string),
|
||||||
|
value-too-large(u64),
|
||||||
|
operation-failed(string),
|
||||||
|
}
|
||||||
|
|
||||||
|
get: func(key: string) -> result<option<list<u8>>, kv-error>;
|
||||||
|
set: func(key: string, value: list<u8>) -> result<_, kv-error>;
|
||||||
|
delete: func(key: string) -> result<_, kv-error>;
|
||||||
|
}
|
||||||
|
|
||||||
|
world kv-host {
|
||||||
|
import store;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user