Compare commits

..

21 Commits

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

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

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

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

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

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

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

Return the existing not-registered and unavailable errors without leaving a partially removed live revision, and cover Actor and registration count cleanup.
2026-07-29 20:25:58 +08:00
Maofeng 71654a8bcf docs: document persistent Host KV
Describe the versioned KV capability, service-level namespace, persistence behavior, limits, and kv-probe packaging workflow.

Record the v0.1 exclusions for TTL, enumeration, transactions, and compare-and-swap.
2026-07-29 19:37:19 +08:00
Maofeng a325c60421 feat(console): persist Host KV in libSQL
Store KV entries through Toasty with a composite service ID and key primary key, including an additive table migration for existing Console databases.

Bridge synchronous Wasmtime Host calls to async persistence with a bounded worker queue and response timeout capped by the service deadline.

Verify same-service revision sharing, cross-service isolation, deletion, exact capability reporting, and persistence after reopening Console.
2026-07-29 19:37:17 +08:00
Maofeng 8d8d136510 feat(runtime): host service-scoped KV capability
Introduce an injectable, deadline-aware KvBackend boundary and link only the exact wasmeld:kv/store@0.1.0 import requested by a Component.

Namespace entries by service ID so revisions share state while services remain isolated, reject registrations without a backend, and enforce 256-byte keys and 64 KiB values.

Cover capability discovery, cross-revision sharing, service isolation, missing backends, and resource limits with Component integration tests.
2026-07-29 19:37:10 +08:00
Maofeng 044b64a9c7 feat(component): add KV capability probe
Add a small Component that imports wasmeld:kv/store@0.1.0 and exercises get, set, and delete through a byte-oriented test protocol.

Pin service and KV WIT dependencies in wit.lock while keeping generated wit/deps content out of source control.
2026-07-29 19:37:05 +08:00
Maofeng 5edf185c90 feat(wit): add versioned KV store package
Define wasmeld:kv/store@0.1.0 with get, set, and delete operations.

Expose explicit invalid-key, value-too-large, and operation-failed variants so components can handle bounded storage failures without relying on host-specific errors.
2026-07-29 19:37:00 +08:00
Maofeng 72c901c478 fix(console-ui): render binary invocation output safely
- decode UTF-8 responses in fatal mode and reject control-byte payloads
- fall back to hexadecimal output instead of replacement characters
- show response byte count, selected representation, automatic fallback, and latency
- preserve the existing counter u64 representation and cover the wiring in SSR tests
2026-07-29 19:20:15 +08:00
Maofeng 51b452ecc5 docs: explain host capability discovery
- document exact-version import resolution and deny-by-default behavior
- clarify that Component imports remain the capability source of truth
- describe structured capability metadata returned by the Console API
2026-07-29 19:13:51 +08:00
Maofeng bdbf868f75 feat(console-ui): show component host capabilities
- consume structured capability metadata from service responses
- add a stable-width Host Capability column to the full service table
- preserve compact overview density and horizontal overflow on narrow screens
- cover API typing and capability rendering wiring in SSR source tests
2026-07-29 19:13:45 +08:00
Maofeng 72a264e0d1 feat(console): expose component capabilities
- project structured capability package, name, version, and interface fields
- derive capabilities during registration and artifact restoration
- keep Component imports as the source of truth without changing .wasmpkg
- verify empty and Clock capability responses through the management API
2026-07-29 19:13:41 +08:00
Maofeng 5b32886efe feat(runtime): modularize host capabilities
- add an exact-version Capability Registry and structured descriptors
- move Clock bindings, host state, and Linker installation into its own module
- derive required capabilities from Component imports and link only requested interfaces
- cover exact-version resolution and minimal per-component capability sets
2026-07-29 19:13:35 +08:00
Maofeng 3f5bb344f7 docs: document gateway deployment workflow
- explain the separate control-plane and data-plane listeners
- document revision activation and raw binary invocation
- clarify restart restoration and actor-memory semantics
2026-07-29 16:32:21 +08:00
Maofeng 9160362fc0 feat(console-ui): manage active deployments
- load deployment state with the console snapshot
- mark externally routed revisions in service tables
- add guarded activation confirmation and pending states
- cover deployment API wiring in SSR tests
2026-07-29 16:32:16 +08:00
Maofeng 1d3fac4be8 feat(console): add deployment-routed gateway
- persist service-to-revision deployments in libSQL with an additive migration
- restore active actors and atomically switch revisions
- expose an isolated raw-byte gateway on a second listener with stable errors
- cover routing, isolation, revision switching, and restart restoration
2026-07-29 16:32:12 +08:00
Maofeng a872008d44 docs: add project overview
- summarize the package, Runtime, Console, component, and frontend modules
- provide startup, component packaging, and WIT publication commands
- explain Registry dependency resolution and local path replace
- keep local architecture design drafts outside version control
2026-07-27 05:19:30 +08:00
37 changed files with 3592 additions and 888 deletions
+1
View File
@@ -4,3 +4,4 @@
/.idea/ /.idea/
/components/*/wit/deps/ /components/*/wit/deps/
/components/*/wit/.deps.* /components/*/wit/.deps.*
/docs/design/
Generated
+7
View File
@@ -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"
+1
View File
@@ -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"]
+59 -2
View File
@@ -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)。
+16
View File
@@ -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
+63
View File
@@ -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);
+11
View File
@@ -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"
+15
View File
@@ -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
+6
View File
@@ -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
View File
@@ -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,
+284 -14
View File
@@ -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 {
text: new DataView(output.buffer, output.byteOffset, output.byteLength)
.getBigUint64(0, true) .getBigUint64(0, true)
.toString(); .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>
<span className="service-title">
<strong>{service.id}</strong> <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 && (
<div className="invoke-output-meta">
<span>
{outputBytes} B · {outputFormat.format}
{outputFormat.automatic ? " · 自动" : ""}
</span>
<small> <small>
<Clock3 size={13} /> <Clock3 size={13} />
{latency} ms {latency} ms
</small> </small>
</div>
)} )}
</div> </div>
<pre>{running ? "invoking..." : output || "等待调用"}</pre> <pre>{running ? "invoking..." : output || "等待调用"}</pre>
+155 -2
View File
@@ -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;
+10 -2
View File
@@ -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"/);
+58 -11
View File
@@ -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 ListenerGateway 也不暴露注册、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 BValue 最大 64 KiB。数据库命令通过有界专用工作线程执行,Host 等待时间
不会超过服务的 `deadline_ms`。v0.1.0 不提供 TTL、遍历、事务或 CAS。
组件包格式、开发循环、WIT 依赖、Registry 和本地 `replace` 说明见
[`wasmeld-package` CLI 文档](../wasmeld-package/README.md)。
+219
View File
@@ -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())
}
+601 -33
View File
@@ -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(_))
+33 -9
View File
@@ -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;
}
}
}
+141 -9
View File
@@ -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()
} }
+426 -3
View File
@@ -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([
+223
View File
@@ -0,0 +1,223 @@
# Wasmeld CLI
`wasmeld-package` 提供 `wasmeld` 命令,用于:
- 开发、编译和打包 WebAssembly Component
- 监听源码并将开发版本自动部署到 Wasmeld Console
- 解析、锁定和替换 WIT Package 依赖
- 构建并发布版本化 WIT Package
Runtime 不接收 Rust 源码。CLI 会先将 Rust 编译为 WebAssembly Component,再生成
`.wasmpkg` 或上传到 Console。
## 运行
在项目工作区内可以直接运行:
```bash
cargo +stable run -p wasmeld-package --bin wasmeld -- <command>
```
Component 默认使用 Rust `1.90.0``wasm32-wasip2` 目标构建。可以通过
`WASMELD_COMPONENT_TOOLCHAIN` 指定其它已安装工具链。
## Component 项目
一个可打包的 Component 至少需要:
```text
component/
├── Cargo.toml
├── wasmeld.toml
├── wit.lock
├── src/lib.rs
└── wit/world.wit
```
`Cargo.toml` 必须提供 `cdylib` Target 和 Wasmeld 元数据:
```toml
[package]
name = "echo-component"
version = "0.1.0"
[package.metadata.wasmeld]
id = "echo"
world = "component:echo/echo-component@0.1.0"
[lib]
crate-type = ["cdylib"]
```
`version` 成为正式包的 Revision`id` 是服务 ID`world` 必须与 Component 实际导出的
WIT World 一致。
## 边写边预览
先启动 Console
```bash
cargo +stable run -p wasmeld-console
```
再启动开发循环:
```bash
cargo +stable run -p wasmeld-package --bin wasmeld -- \
dev components/echo/Cargo.toml
```
开发循环会监听 Rust、Cargo、WIT 清单以及本地 `replace` WIT 源,并执行:
```text
同步 WIT 依赖
→ Debug 编译
→ 生成 .wasmpkg
→ 注册开发 Revision
→ 启动并切换 Deployment
→ 注销上一开发 Revision
```
默认服务 ID 为 `<id>-dev`Revision 为 `<version>-dev.h<component-hash>`。正式服务不会
被覆盖。构建、校验或启动失败时不会切换 Deployment,上一版本继续提供服务。
管理面默认每 5 秒刷新一次,也可以直接调用开发服务:
```bash
curl http://127.0.0.1:8081/v1/services/echo-dev/invoke \
-H 'Content-Type: application/octet-stream' \
--data-binary 'hello'
```
开发选项:
| 选项 | 作用 |
| --- | --- |
| `--console <url>` | Console 管理 API,默认 `http://127.0.0.1:8080` |
| `--id <service-id>` | 覆盖默认的 `<id>-dev` 服务 ID |
| `--once` | 构建、部署一次后退出,适合脚本和 CI |
| `--release` | 使用 Release 而不是 Debug 构建 |
| `--locked` | 要求依赖与现有 `wit.lock` 完全一致 |
| `--poll-ms <n>` | 源码扫描间隔,最小 100 ms,默认 350 ms |
也可以通过 `WASMELD_CONSOLE` 设置管理 API 地址。退出 `wasmeld dev` 不会停止当前开发
Deployment;它会继续运行,直到手工停止、切换或注销。
只修改 Rust 源码且不允许契约变化时使用 `--locked`。需要同步修改本地 WIT `replace`
时不要使用该选项,CLI 会更新 `wit.lock`
## 打包 Component
```bash
cargo +stable run -p wasmeld-package --bin wasmeld -- \
pack components/echo/Cargo.toml --locked
```
默认执行 Release 构建并输出:
```text
dist/echo-0.1.0.wasmpkg
```
可用选项:
| 选项 | 作用 |
| --- | --- |
| `--output <path>``-o <path>` | 指定输出文件 |
| `--no-build` | 使用现有 Release Component 制品 |
| `--locked` | 禁止 WIT 依赖解析结果改变 |
`.wasmpkg` 是受约束的 ZIP,固定包含:
```text
package.toml
component.wasm
```
`package.toml` 记录服务 ID、Revision、World 和 Component SHA-256。运行时资源限制不由
组件声明,而是在 Console 注册时由平台策略注入。
## WIT 依赖
`wasmeld.toml` 使用精确版本:
```toml
schema_version = 1
[wit]
root = "wit"
[registry]
url = "http://127.0.0.1:8080"
[dependencies]
"wasmeld:service" = "0.1.0"
"wasmeld:kv" = "0.1.0"
[replace."wasmeld:service"]
path = "../../wit/service"
```
依赖解析结果写入 `wit.lock`,源码开发时应提交该文件。解析出的 `wit/deps/` 是生成目录,
不应提交。
同步依赖:
```bash
wasmeld wit fetch --manifest components/echo/wasmeld.toml
```
校验锁定结果且禁止变化:
```bash
wasmeld wit fetch --manifest components/echo/wasmeld.toml --locked
```
`wit tidy` 当前与 `wit fetch` 使用相同的确定性解析和物化流程。查看根依赖和有效来源:
```bash
wasmeld wit graph --manifest components/echo/wasmeld.toml
```
添加本地替换:
```bash
wasmeld wit replace wasmeld:service@0.1.0 \
--path ../../wit/service \
--manifest components/echo/wasmeld.toml
```
包级替换可以省略 `@version`。精确版本替换的优先级更高。移除替换:
```bash
wasmeld wit replace wasmeld:service@0.1.0 \
--drop \
--manifest components/echo/wasmeld.toml
```
## WIT Package
将 WIT 源目录构建为标准二进制 WIT Package
```bash
wasmeld wit build wit/service \
--output dist/wit/wasmeld-service-0.1.0.wasm
```
发布到 Console 的不可变 WIT Registry
```bash
wasmeld wit publish wit/service \
--registry http://127.0.0.1:8080
```
Registry 地址也可以通过 `WASMELD_REGISTRY` 设置。相同 Package 版本不可覆盖;协议变化
应发布新版本,并由 Component 在 `wasmeld.toml` 中显式选择。
## 环境变量
| 变量 | 作用 |
| --- | --- |
| `WASMELD_COMPONENT_TOOLCHAIN` | Component Rust 工具链,默认 `1.90.0` |
| `WASMELD_CONSOLE` | `wasmeld dev` 使用的 Console 管理 API |
| `WASMELD_REGISTRY` | `wit publish` 使用的 Registry 地址 |
+431
View File
@@ -0,0 +1,431 @@
//! Local build, watch, and deployment loop for Component development.
use std::{
collections::BTreeSet,
env, fs, io,
path::{Path, PathBuf},
thread,
time::Duration,
};
use reqwest::{
StatusCode,
blocking::{Client, multipart},
};
use serde::Deserialize;
use sha2::{Digest, Sha256};
use wasmeld_package::module::{MODULE_MANIFEST_FILE, ModuleManifest};
use crate::{BuildProfile, PackRequest, PackageIdentity, PackedComponent, pack_component};
const DEFAULT_CONSOLE: &str = "http://127.0.0.1:8080";
const DEFAULT_POLL_MS: u64 = 350;
struct DevOptions {
manifest_path: PathBuf,
console: String,
service_id: Option<String>,
locked: bool,
release: bool,
once: bool,
poll_interval: Duration,
}
#[derive(Debug, Deserialize)]
struct ServiceList {
services: Vec<ServiceIdentity>,
}
#[derive(Debug, Deserialize)]
struct ServiceIdentity {
id: String,
revision: String,
}
#[derive(Debug, Deserialize)]
struct Deployment {
active_revision: String,
}
pub(crate) fn run(arguments: Vec<String>) -> Result<(), Box<dyn std::error::Error>> {
let options = parse_options(arguments)?;
let manifest_path = fs::canonicalize(&options.manifest_path)?;
let watch_roots = watch_roots(&manifest_path)?;
let client = Client::builder()
.user_agent(format!("wasmeld-dev/{}", env!("CARGO_PKG_VERSION")))
.timeout(Duration::from_secs(120))
.build()?;
ensure_console(&client, &options.console)?;
let temporary_dir = env::temp_dir().join(format!("wasmeld-dev-{}", std::process::id()));
fs::create_dir_all(&temporary_dir)?;
let output = temporary_dir.join("component.wasmpkg");
let mut observed = None;
println!("watching:");
for root in &watch_roots {
println!(" {}", root.display());
}
println!("console: {}", options.console);
loop {
let current = source_fingerprint(&watch_roots)?;
if observed.as_ref() == Some(&current) {
thread::sleep(options.poll_interval);
continue;
}
println!("change detected; building Component");
let result = build_and_deploy(&client, &options, &manifest_path, &output);
observed = Some(source_fingerprint(&watch_roots)?);
match result {
Ok(packed) => {
println!(
"ready: {}@{} ({})",
packed.manifest.id, packed.manifest.revision, packed.manifest.sha256
);
if options.once {
return Ok(());
}
}
Err(error) if options.once => return Err(error),
Err(error) => {
eprintln!("wasmeld dev: {error}");
eprintln!("waiting for the next source change; the previous deployment is intact");
}
}
}
}
fn parse_options(arguments: Vec<String>) -> Result<DevOptions, Box<dyn std::error::Error>> {
let mut arguments = arguments.into_iter();
let manifest_path = arguments.next().ok_or("dev requires a Cargo.toml path")?;
let mut console = env::var("WASMELD_CONSOLE").unwrap_or_else(|_| DEFAULT_CONSOLE.to_owned());
let mut service_id = None;
let mut locked = false;
let mut release = false;
let mut once = false;
let mut poll_ms = DEFAULT_POLL_MS;
while let Some(argument) = arguments.next() {
match argument.as_str() {
"--console" => {
console = arguments.next().ok_or("--console requires an HTTP URL")?;
}
"--id" => {
service_id = Some(arguments.next().ok_or("--id requires a service ID")?);
}
"--locked" => locked = true,
"--release" => release = true,
"--once" => once = true,
"--poll-ms" => {
poll_ms = arguments
.next()
.ok_or("--poll-ms requires a number")?
.parse::<u64>()?;
if poll_ms < 100 {
return Err("--poll-ms must be at least 100".into());
}
}
_ => return Err(format!("unknown dev argument {argument:?}").into()),
}
}
Ok(DevOptions {
manifest_path: PathBuf::from(manifest_path),
console: console.trim_end_matches('/').to_owned(),
service_id,
locked,
release,
once,
poll_interval: Duration::from_millis(poll_ms),
})
}
fn build_and_deploy(
client: &Client,
options: &DevOptions,
manifest_path: &Path,
output: &Path,
) -> Result<PackedComponent, Box<dyn std::error::Error>> {
let packed = pack_component(PackRequest {
manifest_path: manifest_path.to_owned(),
output: Some(output.to_owned()),
no_build: false,
locked: options.locked,
profile: if options.release {
BuildProfile::Release
} else {
BuildProfile::Debug
},
identity: PackageIdentity::Development {
id: options.service_id.clone(),
},
})?;
let previous = active_revision(client, &options.console, &packed.manifest.id)?;
register(client, &options.console, &packed)?;
activate(
client,
&options.console,
&packed.manifest.id,
&packed.manifest.revision,
)?;
if let Some(previous) = previous
&& previous != packed.manifest.revision
&& is_matching_dev_revision(&previous, &packed.manifest.revision)
&& let Err(error) = unregister(client, &options.console, &packed.manifest.id, &previous)
{
eprintln!(
"wasmeld dev: deployed successfully but could not unregister {}@{}: {error}",
packed.manifest.id, previous
);
}
Ok(packed)
}
fn ensure_console(client: &Client, console: &str) -> Result<(), Box<dyn std::error::Error>> {
let response = client.get(format!("{console}/healthz")).send()?;
if response.status().is_success() {
Ok(())
} else {
Err(http_error("Console health check", response))
}
}
fn active_revision(
client: &Client,
console: &str,
service_id: &str,
) -> Result<Option<String>, Box<dyn std::error::Error>> {
let response = client
.get(format!("{console}/api/v1/deployments/{service_id}"))
.send()?;
match response.status() {
status if status.is_success() => Ok(Some(response.json::<Deployment>()?.active_revision)),
StatusCode::NOT_FOUND => Ok(None),
_ => Err(http_error("deployment lookup", response)),
}
}
fn register(
client: &Client,
console: &str,
packed: &PackedComponent,
) -> Result<(), Box<dyn std::error::Error>> {
let package = fs::read(&packed.output)?;
let part = multipart::Part::bytes(package)
.file_name("component.wasmpkg")
.mime_str("application/vnd.wasmeld.package")?;
let response = client
.post(format!("{console}/api/v1/services"))
.multipart(multipart::Form::new().part("package", part))
.send()?;
if response.status().is_success() {
return Ok(());
}
if response.status() == StatusCode::CONFLICT
&& service_exists(
client,
console,
&packed.manifest.id,
&packed.manifest.revision,
)?
{
return Ok(());
}
Err(http_error("Component registration", response))
}
fn service_exists(
client: &Client,
console: &str,
service_id: &str,
revision: &str,
) -> Result<bool, Box<dyn std::error::Error>> {
let response = client.get(format!("{console}/api/v1/services")).send()?;
if !response.status().is_success() {
return Err(http_error("service lookup", response));
}
Ok(response
.json::<ServiceList>()?
.services
.iter()
.any(|service| service.id == service_id && service.revision == revision))
}
fn activate(
client: &Client,
console: &str,
service_id: &str,
revision: &str,
) -> Result<(), Box<dyn std::error::Error>> {
let response = client
.post(format!(
"{console}/api/v1/deployments/{service_id}/activate"
))
.json(&serde_json::json!({ "revision": revision }))
.send()?;
if response.status().is_success() {
Ok(())
} else {
Err(http_error("development deployment", response))
}
}
fn unregister(
client: &Client,
console: &str,
service_id: &str,
revision: &str,
) -> Result<(), Box<dyn std::error::Error>> {
let response = client
.delete(format!("{console}/api/v1/services/{service_id}/{revision}"))
.send()?;
if response.status().is_success() || response.status() == StatusCode::NOT_FOUND {
Ok(())
} else {
Err(http_error("old development revision cleanup", response))
}
}
fn http_error(action: &str, response: reqwest::blocking::Response) -> Box<dyn std::error::Error> {
let status = response.status();
let body = response
.text()
.unwrap_or_else(|error| format!("failed to read response: {error}"));
format!("{action} returned HTTP {status}: {body}").into()
}
fn is_matching_dev_revision(previous: &str, current: &str) -> bool {
let Some((previous_base, previous_hash)) = previous.rsplit_once("-dev.h") else {
return false;
};
let Some((current_base, current_hash)) = current.rsplit_once("-dev.h") else {
return false;
};
previous_base == current_base && valid_dev_hash(previous_hash) && valid_dev_hash(current_hash)
}
fn valid_dev_hash(value: &str) -> bool {
value.len() == 12 && value.bytes().all(|byte| byte.is_ascii_hexdigit())
}
fn watch_roots(manifest_path: &Path) -> Result<Vec<PathBuf>, Box<dyn std::error::Error>> {
let component_root = manifest_path
.parent()
.ok_or("Cargo manifest must have a parent directory")?
.to_owned();
let mut roots = BTreeSet::from([component_root.clone()]);
let module_path = component_root.join(MODULE_MANIFEST_FILE);
if module_path.is_file() {
let module = ModuleManifest::read(&module_path)?;
for replacement in module.replacements.values() {
let path = component_root.join(&replacement.path);
if path.exists() {
roots.insert(fs::canonicalize(path)?);
}
}
}
Ok(roots.into_iter().collect())
}
fn source_fingerprint(roots: &[PathBuf]) -> Result<Vec<u8>, Box<dyn std::error::Error>> {
let mut files = BTreeSet::new();
for root in roots {
collect_source_files(root, &mut files)?;
}
let mut digest = Sha256::new();
for path in files {
digest.update(path.to_string_lossy().as_bytes());
match fs::read(&path) {
Ok(bytes) => digest.update(bytes),
Err(error) if error.kind() == io::ErrorKind::NotFound => {}
Err(error) => return Err(error.into()),
}
}
Ok(digest.finalize().to_vec())
}
fn collect_source_files(
path: &Path,
files: &mut BTreeSet<PathBuf>,
) -> Result<(), Box<dyn std::error::Error>> {
if path.is_dir() {
if ignored_directory(path) {
return Ok(());
}
for entry in fs::read_dir(path)? {
collect_source_files(&entry?.path(), files)?;
}
} else if is_source_file(path) {
files.insert(path.to_owned());
}
Ok(())
}
fn ignored_directory(path: &Path) -> bool {
let name = path.file_name().and_then(|name| name.to_str());
if matches!(
name,
Some(".git" | ".wasmeld" | "deps" | "dist" | "node_modules" | "target")
) {
return name != Some("deps")
|| path
.parent()
.and_then(Path::file_name)
.and_then(|name| name.to_str())
== Some("wit");
}
false
}
fn is_source_file(path: &Path) -> bool {
matches!(
path.extension().and_then(|extension| extension.to_str()),
Some("rs" | "wit")
) || matches!(
path.file_name().and_then(|name| name.to_str()),
Some("Cargo.lock" | "Cargo.toml" | "config.toml" | "wasmeld.toml" | "wit.lock")
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn recognizes_only_matching_hash_development_revisions() {
assert!(is_matching_dev_revision(
"0.1.0-dev.h0123456789ab",
"0.1.0-dev.habcdef012345"
));
assert!(!is_matching_dev_revision(
"0.2.0-dev.h0123456789ab",
"0.1.0-dev.habcdef012345"
));
assert!(!is_matching_dev_revision(
"0.1.0",
"0.1.0-dev.habcdef012345"
));
}
#[test]
fn fingerprints_sources_but_ignores_materialized_wit_dependencies() {
let root = tempfile::tempdir().unwrap();
let source = root.path().join("src/lib.rs");
let generated = root.path().join("wit/deps/example/package.wit");
fs::create_dir_all(source.parent().unwrap()).unwrap();
fs::create_dir_all(generated.parent().unwrap()).unwrap();
fs::write(&source, "pub fn first() {}").unwrap();
fs::write(&generated, "package generated:dependency@1.0.0;").unwrap();
let roots = vec![root.path().to_owned()];
let initial = source_fingerprint(&roots).unwrap();
fs::write(&generated, "package generated:dependency@2.0.0;").unwrap();
assert_eq!(source_fingerprint(&roots).unwrap(), initial);
fs::write(&source, "pub fn second() {}").unwrap();
assert_ne!(source_fingerprint(&roots).unwrap(), initial);
}
}
+108 -28
View File
@@ -1,5 +1,7 @@
//! Command-line entry point for Component packaging and WIT dependency workflows. //! Command-line entry point for Component packaging and WIT dependency workflows.
mod dev;
use std::{ use std::{
env, fs, env, fs,
path::{Path, PathBuf}, path::{Path, PathBuf},
@@ -7,6 +9,7 @@ use std::{
}; };
use serde::Deserialize; use serde::Deserialize;
use sha2::{Digest, Sha256};
use wasmeld_package::{ use wasmeld_package::{
PackageManifest, PackageManifest,
module::{MODULE_MANIFEST_FILE, ModuleManifest, find_module_manifest, sync_dependencies}, module::{MODULE_MANIFEST_FILE, ModuleManifest, find_module_manifest, sync_dependencies},
@@ -16,6 +19,41 @@ use wasmeld_package::{
const TARGET: &str = "wasm32-wasip2"; const TARGET: &str = "wasm32-wasip2";
#[derive(Clone, Copy)]
pub(crate) enum BuildProfile {
Debug,
Release,
}
impl BuildProfile {
fn directory(self) -> &'static str {
match self {
Self::Debug => "debug",
Self::Release => "release",
}
}
}
pub(crate) enum PackageIdentity {
Cargo,
Development { id: Option<String> },
}
pub(crate) struct PackRequest {
pub manifest_path: PathBuf,
pub output: Option<PathBuf>,
pub no_build: bool,
pub locked: bool,
pub profile: BuildProfile,
pub identity: PackageIdentity,
}
pub(crate) struct PackedComponent {
pub manifest: PackageManifest,
pub output: PathBuf,
pub artifact: PathBuf,
}
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
struct CargoMetadata { struct CargoMetadata {
packages: Vec<CargoPackage>, packages: Vec<CargoPackage>,
@@ -51,6 +89,7 @@ fn run() -> Result<(), Box<dyn std::error::Error>> {
let mut arguments = env::args().skip(1); let mut arguments = env::args().skip(1);
match arguments.next().as_deref() { match arguments.next().as_deref() {
Some("pack") => run_pack(arguments.collect()), Some("pack") => run_pack(arguments.collect()),
Some("dev") => dev::run(arguments.collect()),
Some("wit") => run_wit(arguments.collect()), Some("wit") => run_wit(arguments.collect()),
_ => Err(usage().into()), _ => Err(usage().into()),
} }
@@ -78,69 +117,102 @@ fn run_pack(arguments: Vec<String>) -> Result<(), Box<dyn std::error::Error>> {
} }
} }
let manifest_path = fs::canonicalize(manifest_path)?; let packed = pack_component(PackRequest {
sync_component_dependencies(&manifest_path, locked)?; manifest_path: PathBuf::from(manifest_path),
output,
no_build,
locked,
profile: BuildProfile::Release,
identity: PackageIdentity::Cargo,
})?;
println!(
"packed {}@{} -> {}",
packed.manifest.id,
packed.manifest.revision,
packed.output.display()
);
println!("component: {}", packed.artifact.display());
println!("sha256: {}", packed.manifest.sha256);
Ok(())
}
pub(crate) fn pack_component(
request: PackRequest,
) -> Result<PackedComponent, Box<dyn std::error::Error>> {
let manifest_path = fs::canonicalize(request.manifest_path)?;
sync_component_dependencies(&manifest_path, request.locked)?;
let metadata = cargo_metadata(&manifest_path)?; let metadata = cargo_metadata(&manifest_path)?;
let package = metadata let package = metadata
.packages .packages
.iter() .iter()
.find(|package| package.manifest_path == manifest_path) .find(|package| package.manifest_path == manifest_path)
.ok_or("Cargo metadata did not contain the requested package")?; .ok_or("Cargo metadata did not contain the requested package")?;
let id = package let cargo_id = package
.metadata .metadata
.get("wasmeld") .get("wasmeld")
.and_then(|metadata| metadata.get("id")) .and_then(|metadata| metadata.get("id"))
.and_then(serde_json::Value::as_str) .and_then(serde_json::Value::as_str)
.ok_or("[package.metadata.wasmeld].id is required")?; .ok_or("[package.metadata.wasmeld].id is required")?
.to_owned();
let world = package let world = package
.metadata .metadata
.get("wasmeld") .get("wasmeld")
.and_then(|metadata| metadata.get("world")) .and_then(|metadata| metadata.get("world"))
.and_then(serde_json::Value::as_str) .and_then(serde_json::Value::as_str)
.ok_or("[package.metadata.wasmeld].world is required")?; .ok_or("[package.metadata.wasmeld].world is required")?
PackageManifest::new(id, &package.version, world, b"identity-validation").validate()?; .to_owned();
let cargo_revision = package.version.clone();
let target = package let target = package
.targets .targets
.iter() .iter()
.find(|target| target.crate_types.iter().any(|kind| kind == "cdylib")) .find(|target| target.crate_types.iter().any(|kind| kind == "cdylib"))
.ok_or("component package must expose a cdylib target")?; .ok_or("component package must expose a cdylib target")?
.name
.clone();
if !no_build { if !request.no_build {
build_component(&manifest_path)?; build_component(&manifest_path, request.profile)?;
} }
let artifact = metadata let artifact = metadata
.target_directory .target_directory
.join(TARGET) .join(TARGET)
.join("release") .join(request.profile.directory())
.join(format!("{}.wasm", target.name.replace('-', "_"))); .join(format!("{}.wasm", target.replace('-', "_")));
let component = fs::read(&artifact).map_err(|error| { let component = fs::read(&artifact).map_err(|error| {
format!( format!(
"failed to read built component {}: {error}", "failed to read built component {}: {error}",
artifact.display() artifact.display()
) )
})?; })?;
let output = output.unwrap_or_else(|| { let (id, revision) = match request.identity {
PackageIdentity::Cargo => (cargo_id, cargo_revision),
PackageIdentity::Development { id } => {
let digest = format!("{:x}", Sha256::digest(&component));
(
id.unwrap_or_else(|| format!("{cargo_id}-dev")),
format!("{cargo_revision}-dev.h{}", &digest[..12]),
)
}
};
PackageManifest::new(&id, &revision, &world, b"identity-validation").validate()?;
let output = request.output.unwrap_or_else(|| {
metadata metadata
.workspace_root .workspace_root
.join("dist") .join("dist")
.join(format!("{id}-{}.wasmpkg", package.version)) .join(format!("{id}-{revision}.wasmpkg"))
}); });
if let Some(parent) = output.parent() { if let Some(parent) = output.parent() {
fs::create_dir_all(parent)?; fs::create_dir_all(parent)?;
} }
let file = fs::File::create(&output)?; let file = fs::File::create(&output)?;
let manifest = write_package(file, id, &package.version, world, &component)?; let manifest = write_package(file, id, revision, world, &component)?;
Ok(PackedComponent {
println!( manifest,
"packed {}@{} -> {}", output,
manifest.id, artifact,
manifest.revision, })
output.display()
);
println!("component: {}", artifact.display());
println!("sha256: {}", manifest.sha256);
Ok(())
} }
fn run_wit(arguments: Vec<String>) -> Result<(), Box<dyn std::error::Error>> { fn run_wit(arguments: Vec<String>) -> Result<(), Box<dyn std::error::Error>> {
@@ -426,14 +498,21 @@ fn cargo_metadata(manifest_path: &Path) -> Result<CargoMetadata, Box<dyn std::er
Ok(serde_json::from_slice(&output.stdout)?) Ok(serde_json::from_slice(&output.stdout)?)
} }
fn build_component(manifest_path: &Path) -> Result<(), Box<dyn std::error::Error>> { fn build_component(
manifest_path: &Path,
profile: BuildProfile,
) -> Result<(), Box<dyn std::error::Error>> {
let toolchain = env::var("WASMELD_COMPONENT_TOOLCHAIN").unwrap_or_else(|_| "1.90.0".to_owned()); let toolchain = env::var("WASMELD_COMPONENT_TOOLCHAIN").unwrap_or_else(|_| "1.90.0".to_owned());
let status = Command::new("rustup") let mut command = Command::new("rustup");
command
.args(["run", &toolchain, "cargo", "build"]) .args(["run", &toolchain, "cargo", "build"])
.arg("--manifest-path") .arg("--manifest-path")
.arg(manifest_path) .arg(manifest_path)
.args(["--target", TARGET, "--release"]) .args(["--target", TARGET]);
.status()?; if matches!(profile, BuildProfile::Release) {
command.arg("--release");
}
let status = command.status()?;
if !status.success() { if !status.success() {
return Err(format!("component build failed with status {status}").into()); return Err(format!("component build failed with status {status}").into());
} }
@@ -443,6 +522,7 @@ fn build_component(manifest_path: &Path) -> Result<(), Box<dyn std::error::Error
fn usage() -> String { fn usage() -> String {
"usage: "usage:
wasmeld pack <Cargo.toml> [--output <file.wasmpkg>] [--no-build] [--locked] wasmeld pack <Cargo.toml> [--output <file.wasmpkg>] [--no-build] [--locked]
wasmeld dev <Cargo.toml> [--console <url>] [--id <service-id>] [--locked] [--release] [--once] [--poll-ms <milliseconds>]
wasmeld wit fetch [--manifest <wasmeld.toml>] [--locked] wasmeld wit fetch [--manifest <wasmeld.toml>] [--locked]
wasmeld wit tidy [--manifest <wasmeld.toml>] [--locked] wasmeld wit tidy [--manifest <wasmeld.toml>] [--locked]
wasmeld wit graph [--manifest <wasmeld.toml>] wasmeld wit graph [--manifest <wasmeld.toml>]
-9
View File
@@ -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()))
}
+145
View File
@@ -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);
}
}
+3
View File
@@ -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),
+4
View File
@@ -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};
+111 -42
View File
@@ -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(())
}
}
-101
View File
@@ -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 制品保留;组件通过包名、版本和锁文件选择依赖。
-94
View File
@@ -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 仍是独立的管理操作。
-54
View File
@@ -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` 执行。
-319
View File
@@ -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。
- failedtrap、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:原样返回输入,验证基础加载与调用。
- counterinit 创建计数状态,每次 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)
-145
View File
@@ -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。
- 暂不包含认证、权限、废弃标记和垃圾回收。
+17
View File
@@ -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;
}