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
32 changed files with 3640 additions and 173 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"]
+107
View File
@@ -0,0 +1,107 @@
# Wasmeld
Wasmeld 是面向 WebAssembly Component 的服务运行与管理平台。内部服务编译为
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
crates/wasmeld-runtime Component Runtime 与 Sandbox
crates/wasmeld-console 管理 API 与持久化
crates/wasmeld-package wasmeld CLI、组件包与 WIT 依赖管理
components/ 示例 Component
console/ TanStack Start 管理面
wit/ Wasmeld WIT package 源码
```
## 启动
后端:
```bash
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
cd console
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
cargo run -p wasmeld-package --bin wasmeld -- \
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
```bash
cargo run -p wasmeld-package --bin wasmeld -- \
wit publish wit/service --registry http://127.0.0.1:8080
```
组件在 `wasmeld.toml` 中声明 Registry 和精确版本依赖,`wasmeld wit fetch` 会递归
解析依赖、生成 `wit/deps`,并将完整解析结果记录在 `wit.lock`。本地开发可使用
`[replace]` 临时覆盖 Registry 来源。
完整命令、开发选项、组件包格式和 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(())
}
}
+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;
}