Compare commits

..

5 Commits

Author SHA1 Message Date
Maofeng c399781369 docs: document Wasmeld architecture and workflows
- describe resident Runtime boundaries and sandbox guarantees\n- specify component archive and host capability contracts\n- document Console ownership and persistence behavior\n- explain versioned WIT Registry, lock files, and path replace\n- provide startup, packaging, publication, and dependency commands
2026-07-27 05:03:24 +08:00
Maofeng d32895f5ca feat(console-ui): add TanStack management console
- provide Runtime overview, service lifecycle, invocation, and event views\n- add immutable WIT package listing, publication, and download workflows\n- connect all state to the Rust management API without direct database access\n- use TanStack Start with Node serving, Oxlint, Oxfmt, and SSR checks\n- include responsive operational layouts and empty Actor placeholders
2026-07-27 05:02:35 +08:00
Maofeng b7fffd0d0f feat(console): add runtime management and WIT Registry
- embed wasmeld-runtime behind an Axum management API\n- persist service metadata, metrics, and events with Toasty and libSQL\n- register immutable component artifacts with platform-owned limits\n- publish, list, inspect, and download binary WIT package versions\n- verify Runtime lifecycle, persistence, Registry immutability, and transitive fetch
2026-07-27 05:01:26 +08:00
Maofeng b63cf8c9eb feat(runtime): add resident Wasmtime actor runtime
- define versioned service and clock WIT contracts\n- enforce import allowlists, fuel, epoch, memory, I/O, and mailbox limits\n- keep one Store and Component Instance resident per serial Actor\n- add echo, counter, fault, spin, and capability probe components\n- cover lifecycle, concurrency, sandbox, and fault recovery behavior
2026-07-27 04:59:33 +08:00
Maofeng 892fc96f71 feat(package): add component and WIT package tooling
- define the constrained .wasmpkg archive and integrity checks\n- encode and inspect standard binary WIT packages\n- resolve exact Registry dependencies with wit.lock and path replace\n- provide wasmeld pack and wit build/fetch/publish CLI commands
2026-07-27 04:57:34 +08:00
37 changed files with 888 additions and 3592 deletions
-1
View File
@@ -4,4 +4,3 @@
/.idea/
/components/*/wit/deps/
/components/*/wit/.deps.*
/docs/design/
Generated
-7
View File
@@ -1995,13 +1995,6 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "kv-probe-component"
version = "0.1.0"
dependencies = [
"wit-bindgen 0.41.0",
]
[[package]]
name = "lazy_static"
version = "1.5.0"
-1
View File
@@ -8,7 +8,6 @@ members = [
"components/fault",
"components/spin",
"components/clock-probe",
"components/kv-probe",
"components/wasi-clock-probe",
]
default-members = ["crates/wasmeld-runtime"]
+2 -59
View File
@@ -4,15 +4,6 @@ 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
@@ -32,23 +23,6 @@ wit/ Wasmeld WIT package 源码
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
@@ -56,25 +30,6 @@ 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
@@ -82,16 +37,6 @@ 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
@@ -101,7 +46,5 @@ cargo run -p wasmeld-package --bin wasmeld -- \
组件在 `wasmeld.toml` 中声明 Registry 和精确版本依赖,`wasmeld wit fetch` 会递归
解析依赖、生成 `wit/deps`,并将完整解析结果记录在 `wit.lock`。本地开发可使用
`[replace]` 临时覆盖 Registry 来源。
完整命令、开发选项、组件包格式和 WIT 依赖说明见
[`wasmeld-package` CLI 文档](crates/wasmeld-package/README.md)。
`[replace]` 临时覆盖 Registry 来源。详见
[`docs/design/wit-package-management.md`](docs/design/wit-package-management.md)。
-16
View File
@@ -1,16 +0,0 @@
[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
@@ -1,63 +0,0 @@
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
@@ -1,11 +0,0 @@
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
@@ -1,15 +0,0 @@
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
@@ -1,6 +0,0 @@
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;
}
+2 -30
View File
@@ -4,19 +4,11 @@ const API_BASE_STORAGE_KEY = "wasmeld-api-base";
export type BackendServiceStatus = "running" | "stopped" | "faulted";
export type BackendCapability = {
interface: string;
package: string;
name: string;
version: string;
};
export type BackendService = {
id: string;
revision: string;
artifact: string;
world: string;
capabilities: BackendCapability[];
status: BackendServiceStatus;
updated_at_ms: number;
limits: {
@@ -35,7 +27,7 @@ export type BackendService = {
export type BackendEvent = {
id: number;
timestamp_ms: number;
kind: "registered" | "unregistered" | "deployed" | "started" | "stopped" | "invoked" | "failed";
kind: "registered" | "started" | "stopped" | "invoked" | "failed";
service_id: string | null;
revision: string | null;
message: string;
@@ -49,13 +41,6 @@ export type BackendRuntime = {
running_services: number;
};
export type BackendDeployment = {
service_id: string;
active_revision: string;
status: BackendServiceStatus;
updated_at_ms: number;
};
export type BackendWitDependency = {
name: string;
version: string;
@@ -95,33 +80,20 @@ export function saveApiBase(value: string): string {
}
export async function fetchSnapshot(apiBase: string) {
const [services, deployments, events, runtime, witPackages] = await Promise.all([
const [services, events, runtime, witPackages] = await Promise.all([
request<{ services: BackendService[] }>(apiBase, "/api/v1/services"),
request<{ deployments: BackendDeployment[] }>(apiBase, "/api/v1/deployments"),
request<{ events: BackendEvent[] }>(apiBase, "/api/v1/events"),
request<BackendRuntime>(apiBase, "/api/v1/runtime"),
request<{ packages: BackendWitPackage[] }>(apiBase, "/api/v1/wit/packages"),
]);
return {
services: services.services,
deployments: deployments.deployments,
events: events.events,
runtime,
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(
apiBase: string,
input: RegisterComponentInput,
+20 -290
View File
@@ -18,7 +18,6 @@ import {
LayoutDashboard,
Package,
Play,
RadioTower,
RefreshCw,
RotateCcw,
Search,
@@ -31,15 +30,12 @@ import {
} from "lucide-react";
import { FormEvent, ReactNode, useCallback, useEffect, useMemo, useState } from "react";
import {
BackendCapability,
BackendEvent,
BackendDeployment,
BackendRuntime,
BackendService,
BackendWitPackage,
InvokeResult,
RegisterComponentInput,
activateDeployment,
changeRuntimeState,
changeServiceState,
fetchSnapshot,
@@ -67,11 +63,9 @@ type Service = {
deadlineMs: number;
mailbox: number;
maxInputKb: number;
capabilities: BackendCapability[];
calls: number;
errors: number;
latencyMs: number | null;
active: boolean;
};
type RuntimeEvent = {
@@ -107,7 +101,7 @@ function serviceKey(service: Pick<Service, "id" | "revision">) {
return `${service.id}@${service.revision}`;
}
function toService(service: BackendService, activeRevision?: string): Service {
function toService(service: BackendService): Service {
return {
id: service.id,
revision: service.revision,
@@ -122,11 +116,9 @@ function toService(service: BackendService, activeRevision?: string): Service {
deadlineMs: service.limits.deadline_ms,
mailbox: service.limits.mailbox_capacity,
maxInputKb: Math.round(service.limits.max_input_bytes / 1024),
capabilities: service.capabilities,
calls: service.calls,
errors: service.errors,
latencyMs: service.last_latency_ms,
active: service.revision === activeRevision,
};
}
@@ -135,8 +127,6 @@ function toEvent(event: BackendEvent): RuntimeEvent {
event.service_id && event.revision ? `${event.service_id}@${event.revision}` : "Wasmeld";
const meta: Record<BackendEvent["kind"], { label: string; tone: EventTone }> = {
registered: { label: "已注册", tone: "success" },
unregistered: { label: "已注销", tone: "neutral" },
deployed: { label: "部署切换", tone: "success" },
started: { label: "已启动", tone: "success" },
stopped: { label: "已停止", tone: "neutral" },
invoked: { label: "调用完成", tone: "success" },
@@ -192,65 +182,16 @@ 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") {
if (service.id === "counter" && output.byteLength === 8) {
return {
text: new DataView(output.buffer, output.byteOffset, output.byteLength)
.getBigUint64(0, true)
.toString(),
format: "U64 LE",
automatic: false,
} satisfies FormattedInvocationOutput;
return new DataView(output.buffer, output.byteOffset, output.byteLength)
.getBigUint64(0, true)
.toString();
}
if (format === "hex") {
return {
text: formatHex(output) || "(empty)",
format: "HEX",
automatic: false,
} satisfies FormattedInvocationOutput;
return Array.from(output, (byte) => byte.toString(16).padStart(2, "0")).join(" ");
}
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;
return new TextDecoder().decode(output) || "(empty)";
}
export const Route = createFileRoute("/")({
@@ -261,7 +202,6 @@ function Home() {
const [view, setView] = useState<View>("overview");
const [services, setServices] = useState<Service[]>([]);
const [events, setEvents] = useState<RuntimeEvent[]>([]);
const [deployments, setDeployments] = useState<BackendDeployment[]>([]);
const [witPackages, setWitPackages] = useState<BackendWitPackage[]>([]);
const [runtime, setRuntime] = useState<BackendRuntime | null>(null);
const [runtimeAction, setRuntimeAction] = useState<"start" | "stop" | "restart" | null>(null);
@@ -273,15 +213,11 @@ function Home() {
const [deployOpen, setDeployOpen] = useState(false);
const [witPublishOpen, setWitPublishOpen] = useState(false);
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 selectedService =
services.find((service) => serviceKey(service) === selectedKey) ?? services[0] ?? null;
const invokeService = services.find((service) => serviceKey(service) === invokeKey) ?? null;
const activationService =
services.find((service) => serviceKey(service) === activationKey) ?? null;
const filteredServices = useMemo(() => {
const normalized = query.trim().toLowerCase();
@@ -314,17 +250,8 @@ function Home() {
const refreshSnapshot = useCallback(async () => {
try {
const snapshot = await fetchSnapshot(apiBase);
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)),
);
const nextServices = snapshot.services.map(toService);
setServices(nextServices);
setDeployments(snapshot.deployments);
setEvents(snapshot.events.map(toEvent));
setRuntime(snapshot.runtime);
setWitPackages(snapshot.witPackages);
@@ -338,7 +265,6 @@ function Home() {
setConnection("online");
} catch {
setServices([]);
setDeployments([]);
setEvents([]);
setRuntime(null);
setWitPackages([]);
@@ -392,21 +318,6 @@ 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) {
const result = await invokeComponent(apiBase, service, input);
await refreshSnapshot();
@@ -494,19 +405,16 @@ function Home() {
running={running}
faulted={faulted}
totalCalls={totalCalls}
deploymentCount={deployments.length}
runtime={runtime}
selectedService={selectedService}
onSelect={(service) => setSelectedKey(serviceKey(service))}
onDeploy={() => setDeployOpen(true)}
onInvoke={(service) => setInvokeKey(serviceKey(service))}
onStatus={setServiceStatus}
onActivate={(service) => setActivationKey(serviceKey(service))}
onViewAll={() => setView("services")}
onRefresh={() => void refreshSnapshot()}
onRuntimeAction={(action) => void setRuntimeStatus(action)}
runtimeAction={runtimeAction}
deploymentAction={deploymentAction}
/>
)}
@@ -519,9 +427,6 @@ function Home() {
onInvoke={(service) => setInvokeKey(serviceKey(service))}
onSelect={(service) => setSelectedKey(serviceKey(service))}
onStatus={setServiceStatus}
onActivate={(service) => setActivationKey(serviceKey(service))}
canActivate={runtime?.status === "running"}
deploymentAction={deploymentAction}
/>
)}
@@ -579,19 +484,6 @@ 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 && (
<output className="toast">
<CheckCircle2 size={17} />
@@ -721,19 +613,16 @@ function Overview({
running,
faulted,
totalCalls,
deploymentCount,
runtime,
selectedService,
onSelect,
onDeploy,
onInvoke,
onStatus,
onActivate,
onViewAll,
onRefresh,
onRuntimeAction,
runtimeAction,
deploymentAction,
}: {
services: Service[];
filteredServices: Service[];
@@ -741,19 +630,16 @@ function Overview({
running: number;
faulted: number;
totalCalls: number;
deploymentCount: number;
runtime: BackendRuntime | null;
selectedService: Service | null;
onSelect: (service: Service) => void;
onDeploy: () => void;
onInvoke: (service: Service) => void;
onStatus: (service: Service, status: ServiceStatus) => void;
onActivate: (service: Service) => void;
onViewAll: () => void;
onRefresh: () => void;
onRuntimeAction: (action: "start" | "stop" | "restart") => void;
runtimeAction: "start" | "stop" | "restart" | null;
deploymentAction: string | null;
}) {
const errorCount = services.reduce((sum, service) => sum + service.errors, 0);
const latencies = services
@@ -791,7 +677,7 @@ function Overview({
icon={<Package size={18} />}
label="服务版本"
value={services.length.toString()}
note={`${deploymentCount} 个对外服务`}
note="来自 Wasmeld"
tone="cyan"
/>
<Metric
@@ -908,9 +794,6 @@ function Overview({
onSelect={onSelect}
onInvoke={onInvoke}
onStatus={onStatus}
onActivate={onActivate}
canActivate={runtime?.status === "running"}
deploymentAction={deploymentAction}
compact
/>
</section>
@@ -949,17 +832,6 @@ function Overview({
<DetailValue label="Deadline" value={`${selectedService.deadlineMs} ms`} />
<DetailValue label="Mailbox" value={selectedService.mailbox.toString()} />
<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
className="secondary-button"
type="button"
@@ -1030,35 +902,12 @@ 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({
services,
selectedKey,
onSelect,
onInvoke,
onStatus,
onActivate,
canActivate,
deploymentAction,
compact = false,
}: {
services: Service[];
@@ -1066,9 +915,6 @@ function ServiceTable({
onSelect: (service: Service) => void;
onInvoke: (service: Service) => void;
onStatus: (service: Service, status: ServiceStatus) => void;
onActivate: (service: Service) => void;
canActivate: boolean;
deploymentAction: string | null;
compact?: boolean;
}) {
if (services.length === 0) {
@@ -1083,13 +929,12 @@ function ServiceTable({
return (
<div className="table-scroll">
<table className={`data-table ${compact ? "service-table-compact" : "service-table-full"}`}>
<table className="data-table">
<thead>
<tr>
<th></th>
<th></th>
{!compact && <th></th>}
{!compact && <th>Host </th>}
<th></th>
<th></th>
<th>
@@ -1114,15 +959,7 @@ function ServiceTable({
<Box size={16} />
</span>
<span>
<span className="service-title">
<strong>{service.id}</strong>
{service.active && (
<span className="active-deployment-badge">
<RadioTower size={10} />
</span>
)}
</span>
<strong>{service.id}</strong>
<small>
{service.revision} · {service.description}
</small>
@@ -1133,41 +970,12 @@ function ServiceTable({
<StatusBadge status={service.status} />
</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.latencyMs ? `${service.latencyMs} ms` : "—"}
</td>
<td>
<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" ? (
<>
<button
@@ -1247,9 +1055,6 @@ function ServicesView({
onInvoke,
onSelect,
onStatus,
onActivate,
canActivate,
deploymentAction,
}: {
services: Service[];
statusFilter: "all" | ServiceStatus;
@@ -1258,16 +1063,13 @@ function ServicesView({
onInvoke: (service: Service) => void;
onSelect: (service: Service) => void;
onStatus: (service: Service, status: ServiceStatus) => void;
onActivate: (service: Service) => void;
canActivate: boolean;
deploymentAction: string | null;
}) {
return (
<>
<PageHeading
eyebrow="REGISTRY"
title="服务版本"
description="管理不可变 Wasm Component 制品及当前对外版本。"
description="管理 Runtime 已注册的不可变 Wasm Component 制品。"
actions={
<button className="primary-button" type="button" onClick={onDeploy}>
<CloudUpload size={17} />
@@ -1301,9 +1103,6 @@ function ServicesView({
onSelect={onSelect}
onInvoke={onInvoke}
onStatus={onStatus}
onActivate={onActivate}
canActivate={canActivate}
deploymentAction={deploymentAction}
/>
</section>
</>
@@ -1636,14 +1435,12 @@ function DialogFrame({
description,
icon,
onClose,
closeDisabled = false,
children,
}: {
title: string;
description: string;
icon: ReactNode;
onClose: () => void;
closeDisabled?: boolean;
children: ReactNode;
}) {
return (
@@ -1652,12 +1449,9 @@ function DialogFrame({
open
className="dialog"
aria-label={title}
onCancel={(event) => {
if (closeDisabled) event.preventDefault();
else onClose();
}}
onCancel={onClose}
onKeyDown={(event) => {
if (event.key === "Escape" && !closeDisabled) onClose();
if (event.key === "Escape") onClose();
}}
>
<div className="dialog-header">
@@ -1673,7 +1467,6 @@ function DialogFrame({
type="button"
aria-label="关闭"
title="关闭"
disabled={closeDisabled}
onClick={onClose}
>
<X size={18} />
@@ -1685,56 +1478,6 @@ 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({
onClose,
onRegister,
@@ -1891,8 +1634,6 @@ function InvokeDialog({
const [input, setInput] = useState(service.id === "echo" ? "hello wasm" : "");
const [format, setFormat] = useState<"utf8" | "hex">("utf8");
const [output, setOutput] = useState("");
const [outputBytes, setOutputBytes] = useState<number | null>(null);
const [outputFormat, setOutputFormat] = useState<FormattedInvocationOutput | null>(null);
const [running, setRunning] = useState(false);
const [failed, setFailed] = useState(false);
const [latency, setLatency] = useState<number | null>(null);
@@ -1900,16 +1641,11 @@ function InvokeDialog({
async function invoke() {
setRunning(true);
setOutput("");
setOutputBytes(null);
setOutputFormat(null);
setFailed(false);
setLatency(null);
try {
const result = await onInvoke(service, parseInvocationInput(input, format));
const formatted = formatInvocationOutput(service, result.output, format);
setOutput(formatted.text);
setOutputBytes(result.outputBytes);
setOutputFormat(formatted);
setOutput(formatInvocationOutput(service, result.output, format));
setLatency(result.latencyMs);
} catch (invokeError) {
setFailed(true);
@@ -1928,7 +1664,7 @@ function InvokeDialog({
>
<div className="invoke-body">
<div className="invoke-toolbar">
<div className="segmented" aria-label="输入编码">
<div className="segmented">
<button
type="button"
className={format === "utf8" ? "active" : ""}
@@ -1959,17 +1695,11 @@ function InvokeDialog({
<div className={failed ? "invoke-output failed" : "invoke-output"}>
<div>
<span></span>
{output && latency !== null && outputBytes !== null && outputFormat && (
<div className="invoke-output-meta">
<span>
{outputBytes} B · {outputFormat.format}
{outputFormat.automatic ? " · 自动" : ""}
</span>
<small>
<Clock3 size={13} />
{latency} ms
</small>
</div>
{output && latency !== null && (
<small>
<Clock3 size={13} />
{latency} ms
</small>
)}
</div>
<pre>{running ? "invoking..." : output || "等待调用"}</pre>
+2 -155
View File
@@ -768,31 +768,6 @@ button:disabled {
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 {
height: 34px;
padding: 0 12px;
@@ -810,7 +785,7 @@ button:disabled {
}
.data-table th:nth-last-child(1) {
width: 150px;
width: 122px;
}
.data-table td {
@@ -874,33 +849,6 @@ button:disabled {
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 {
margin-top: 3px;
color: var(--ink-faint);
@@ -916,38 +864,6 @@ button:disabled {
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 {
color: var(--ink-soft);
font-family: var(--font-geist-mono), monospace;
@@ -1001,7 +917,7 @@ button:disabled {
}
.row-actions {
min-width: 128px;
min-width: 100px;
display: flex;
align-items: center;
justify-content: flex-end;
@@ -1027,14 +943,6 @@ button:disabled {
border-color: var(--line);
}
.row-actions .active-deployment-button:disabled {
color: #197253;
background: #e7f4ef;
border-color: #c6e4d9;
cursor: default;
opacity: 1;
}
.event-list {
padding: 4px 14px;
}
@@ -1186,7 +1094,6 @@ button:disabled {
.detail-actions {
display: flex;
justify-content: flex-end;
gap: 7px;
}
.full-panel {
@@ -1690,54 +1597,6 @@ button:disabled {
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 {
padding: 16px;
}
@@ -1783,18 +1642,6 @@ button:disabled {
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 {
display: flex;
align-items: center;
+2 -10
View File
@@ -77,16 +77,15 @@ test("server-renders the Wasm management console", async () => {
assert.match(html, /运行概览/);
assert.match(html, /WIT 包/);
assert.match(html, /连接中/);
assert.match(html, /0 个对外服务/);
assert.match(html, /来自 Wasmeld/);
assert.match(html, /Component Model/);
assert.doesNotMatch(html, /本地演示|codex-preview|vinext|next\.js/i);
});
test("uses TanStack Start routing and produces Node artifacts", async () => {
const [rootRoute, indexRoute, apiClient, router, packageJson, viteConfig] = await Promise.all([
const [rootRoute, indexRoute, router, packageJson, viteConfig] = await Promise.all([
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/api.ts", 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("../vite.config.ts", import.meta.url), "utf8"),
@@ -96,13 +95,6 @@ test("uses TanStack Start routing and produces Node artifacts", async () => {
assert.match(rootRoute, /<HeadContent \/>/);
assert.match(rootRoute, /<Scripts \/>/);
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(packageJson, /"@tanstack\/react-start"/);
assert.match(packageJson, /"srvx"/);
+11 -58
View File
@@ -6,21 +6,17 @@
- 发布、查询和下载不可变的二进制 WIT Package
- 启动、停止和重启内嵌的 `wasmeld-runtime`
- 启动、停止和重启常驻 Actor
- 管理服务 ID 到活动版本的 Deployment
- 通过独立 Gateway 转发原始二进制调用
- 为导入 `wasmeld:kv/store@0.1.0` 的 Component 提供持久化 KV
- 转发二进制调用
- 暴露调用计数、状态和最近事件
Wasm 制品保存在本地文件系统。服务 manifest、调用计数和最近 256 条事件通过
[Toasty](https://github.com/tokio-rs/toasty) 写入本地 libSQL 数据库,默认路径是
`var/wasmeld/console.db`Deployment 也存储在同一数据库中。
Host KV 使用 `(service_id, key)` 复合主键存储在该数据库中:同一服务的 Revision
共享数据,不同服务互相隔离,Runtime 或 Console 重启不会清空 KV。
`var/wasmeld/console.db`
`wasmeld-console` 启动时会在同一进程内创建 `wasmeld-runtime`、加载 Wasmtime Engine
并重新注册已保存的 Component。Runtime 停止后 Console HTTP 和数据库仍然在线;
再次启动 Runtime 会重新注册 Component。未部署的版本恢复为 `stopped`Deployment
指向的版本会用空初始化配置创建新的 Actor。Actor 的 Store 和线性内存不做快照
再次启动 Runtime 会重新注册 Component,但 Actor 内存不做快照,服务运行状态统一
恢复为 `stopped`
## 启动
@@ -31,18 +27,11 @@ Host KV 使用 `(service_id, key)` 复合主键存储在该数据库中:同一
cargo +stable run -p wasmeld-console
```
进程默认启动两个独立 Listener:
- 管理 API`127.0.0.1:8080`
- Gateway`0.0.0.0:8081`
管理 Router 不会挂载到 Gateway ListenerGateway 也不暴露注册、Runtime 生命周期和
WIT Registry 路由。
默认监听 `127.0.0.1:8080`
可用环境变量:
- `WASMELD_ADDR`
- `WASMELD_GATEWAY_ADDR`
- `WASMELD_ARTIFACT_DIR`
- `WASMELD_DATABASE_PATH`
- `WASMELD_WIT_REGISTRY_DIR`
@@ -57,56 +46,20 @@ WIT Registry 路由。
| `GET` | `/api/v1/runtime` | Runtime 状态 |
| `POST` | `/api/v1/runtime/start` | 启动 Runtime 并重新注册受管 Component |
| `POST` | `/api/v1/runtime/stop` | 停止 Runtime 并释放所有 Actor |
| `POST` | `/api/v1/runtime/restart` | 重建 Runtime并重新创建活动 Deployment 的 Actor |
| `GET` | `/api/v1/services` | 服务版本及精确 Host Capability 列表 |
| `POST` | `/api/v1/runtime/restart` | 重建 Runtime服务恢复为停止状态 |
| `GET` | `/api/v1/services` | 服务版本列表 |
| `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}/stop` | 停止 Actor |
| `POST` | `/api/v1/services/{id}/{revision}/restart` | 重启 Actor |
| `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/wit/packages` | WIT 包版本列表 |
| `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}/content` | 下载 WIT 包 |
活动 Deployment 不能直接注销。开发工具会先完成新 Revision 的编译、注册和切换,
再注销上一份开发 Revision,因此失败的构建不会影响当前可调用版本
激活已注册版本:
```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)。
组件包的构建与格式说明见
[`docs/design/wasmeld-component-package.md`](../../docs/design/wasmeld-component-package.md)
WIT 依赖、Registry 和本地 replace 说明见
[`docs/design/wit-package-management.md`](../../docs/design/wit-package-management.md)。
-219
View File
@@ -1,219 +0,0 @@
//! 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())
}
+33 -601
View File
@@ -6,7 +6,6 @@
//! package Registry. Actor memory remains process-local and is never presented
//! as durable state.
mod kv_backend;
mod persistence;
mod wit_registry;
@@ -20,11 +19,11 @@ use std::{
use axum::{
Json, Router,
body::{Body, Bytes},
extract::{DefaultBodyLimit, Multipart, Path as AxumPath, State, rejection::BytesRejection},
http::{HeaderMap, HeaderValue, Method, StatusCode, header},
body::Body,
extract::{DefaultBodyLimit, Multipart, Path as AxumPath, State},
http::{HeaderValue, Method, StatusCode, header},
response::{IntoResponse, Response},
routing::{delete, get, post},
routing::{get, post},
};
use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64};
use serde::{Deserialize, Serialize};
@@ -40,19 +39,16 @@ use wasmeld_package::{
wit_package::{WitPackageError, WitPackageMetadata},
};
use wasmeld_runtime::{
CapabilityDescriptor, ResourceLimits, Runtime, RuntimeConfig, RuntimeError, ServiceKey,
ServiceManifest,
ResourceLimits, Runtime, RuntimeConfig, RuntimeError, ServiceKey, ServiceManifest,
};
use kv_backend::ToastyKvBackend;
use persistence::{Persistence, StoredDeployment, StoredEvent, StoredService};
use persistence::{Persistence, StoredEvent, StoredService};
use wit_registry::WitRegistry;
pub use wit_registry::WitRegistryError;
const MAX_EVENTS: usize = 256;
const DEFAULT_MAX_ARTIFACT_BYTES: usize = 64 * 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`].
#[derive(Clone, Debug)]
@@ -109,14 +105,12 @@ struct ConsoleState {
runtime_started_at: Option<Instant>,
next_event_id: u64,
services: BTreeMap<ServiceKey, ServiceRecord>,
deployments: BTreeMap<String, DeploymentRecord>,
events: VecDeque<EventView>,
}
#[derive(Clone)]
struct ServiceRecord {
manifest: ServiceManifest,
capabilities: Vec<CapabilityView>,
status: ServiceStatus,
updated_at_ms: u64,
calls: u64,
@@ -124,14 +118,8 @@ struct ServiceRecord {
last_latency_ms: Option<u64>,
}
#[derive(Clone)]
struct DeploymentRecord {
active_revision: String,
updated_at_ms: u64,
}
/// Lifecycle status exposed for one registered service version.
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
#[derive(Clone, Copy, Debug, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum ServiceStatus {
Running,
@@ -139,35 +127,6 @@ pub enum ServiceStatus {
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.
#[derive(Clone, Debug, Serialize)]
pub struct ServiceView {
@@ -175,7 +134,6 @@ pub struct ServiceView {
pub revision: String,
pub artifact: String,
pub world: String,
pub capabilities: Vec<CapabilityView>,
pub status: ServiceStatus,
pub updated_at_ms: u64,
pub limits: ResourceLimits,
@@ -191,7 +149,6 @@ impl ServiceRecord {
revision: self.manifest.revision.clone(),
artifact: self.manifest.component.clone(),
world: self.manifest.world.clone(),
capabilities: self.capabilities.clone(),
status: self.status,
updated_at_ms: self.updated_at_ms,
limits: self.manifest.limits.clone(),
@@ -218,8 +175,6 @@ pub struct EventView {
#[serde(rename_all = "snake_case")]
pub enum EventKind {
Registered,
Unregistered,
Deployed,
Started,
Stopped,
Invoked,
@@ -230,8 +185,6 @@ impl EventKind {
fn as_database_value(self) -> &'static str {
match self {
Self::Registered => "registered",
Self::Unregistered => "unregistered",
Self::Deployed => "deployed",
Self::Started => "started",
Self::Stopped => "stopped",
Self::Invoked => "invoked",
@@ -242,8 +195,6 @@ impl EventKind {
fn from_database_value(value: &str) -> Result<Self, ConsoleError> {
match value {
"registered" => Ok(Self::Registered),
"unregistered" => Ok(Self::Unregistered),
"deployed" => Ok(Self::Deployed),
"started" => Ok(Self::Started),
"stopped" => Ok(Self::Stopped),
"invoked" => Ok(Self::Invoked),
@@ -282,23 +233,11 @@ struct InvokeRequest {
input_base64: String,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct ActivateDeploymentRequest {
revision: String,
init_config_base64: Option<String>,
}
#[derive(Debug, Serialize)]
struct ServiceList {
services: Vec<ServiceView>,
}
#[derive(Debug, Serialize)]
struct DeploymentList {
deployments: Vec<DeploymentView>,
}
#[derive(Debug, Serialize)]
struct EventList {
events: Vec<EventView>,
@@ -332,16 +271,6 @@ struct InvokeResponse {
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.
#[derive(Debug, Error)]
pub enum ConsoleError {
@@ -351,12 +280,6 @@ pub enum ConsoleError {
#[error("service revision {0} is not managed by Wasmeld")]
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")]
RuntimeNotRunning,
@@ -382,9 +305,6 @@ pub enum ConsoleError {
#[error("database operation failed: {0}")]
Database(#[from] toasty::Error),
#[error("failed to start persistent KV backend: {0}")]
KvBackendStart(#[source] io::Error),
#[error("database contains invalid console data: {0}")]
InvalidDatabaseData(String),
@@ -399,10 +319,10 @@ pub enum ConsoleError {
}
impl Console {
/// Opens durable stores, restores control data, and starts a fresh Runtime.
/// Opens durable stores, restores service metadata, and starts a fresh Runtime.
///
/// Actors selected by durable deployments are recreated with empty init
/// configuration. Their previous Store and linear memory are not restored.
/// Persisted actors are deliberately restored as stopped services because
/// their Store and linear memory cannot be reconstructed from control data.
pub async fn new(config: ConsoleConfig) -> Result<Self, ConsoleError> {
config.registration_limits.validate()?;
fs::create_dir_all(&config.artifact_dir).map_err(|source| ConsoleError::Storage {
@@ -421,16 +341,9 @@ impl Console {
})?;
}
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 =
WitRegistry::open(&config.wit_registry_dir, config.max_wit_package_bytes)?;
let (stored_services, mut stored_events, stored_deployments) = persistence.load().await?;
let (stored_services, mut stored_events) = persistence.load().await?;
stored_events.sort_unstable_by_key(|event| std::cmp::Reverse(event.id));
stored_events.truncate(MAX_EVENTS);
let next_event_id = stored_events
@@ -442,10 +355,10 @@ impl Console {
.map(EventView::try_from)
.collect::<Result<VecDeque<_>, _>>()?;
let runtime = Runtime::new(runtime_config.clone())?;
let runtime = Runtime::new(config.runtime.clone())?;
let console = Self {
runtime: RwLock::new(Some(runtime)),
runtime_config,
runtime_config: config.runtime,
artifact_dir,
wit_registry,
max_artifact_bytes: config.max_artifact_bytes,
@@ -455,7 +368,6 @@ impl Console {
runtime_started_at: Some(Instant::now()),
next_event_id,
services: BTreeMap::new(),
deployments: BTreeMap::new(),
events,
}),
persistence,
@@ -465,8 +377,6 @@ impl Console {
// then used to recover artifacts that predate or missed a DB snapshot.
console.load_stored_services(stored_services)?;
console.load_manifest_services()?;
console.load_stored_deployments(stored_deployments)?;
console.restore_deployments()?;
console.persist_snapshot().await?;
Ok(console)
}
@@ -481,11 +391,6 @@ impl Console {
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.
pub fn services(&self) -> Result<Vec<ServiceView>, ConsoleError> {
let state = self.state()?;
@@ -498,26 +403,6 @@ impl Console {
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> {
let runtime = self.runtime()?;
let state = self.state()?;
@@ -670,52 +555,24 @@ impl Console {
}
fn build_runtime(&self) -> Result<Runtime, ConsoleError> {
let state = self.state()?;
let manifests = state
let manifests = self
.state()?
.services
.values()
.map(|service| service.manifest.clone())
.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())?;
for manifest in manifests {
runtime.register_from_file(manifest)?;
}
for key in deployments {
runtime.start(&key, Vec::new())?;
}
Ok(runtime)
}
fn mark_runtime_started(&self) -> Result<(), ConsoleError> {
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());
for (key, service) in &mut state.services {
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;
}
for service in state.services.values_mut() {
service.status = ServiceStatus::Stopped;
}
Ok(())
}
@@ -785,9 +642,8 @@ impl Console {
let _ = fs::remove_file(&manifest_path);
return Err(error.into());
}
let capabilities = runtime_capabilities(runtime, &key)?;
let view = self.insert_service(manifest, capabilities, ServiceStatus::Stopped)?;
let view = self.insert_service(manifest, ServiceStatus::Stopped)?;
self.push_event(
EventKind::Registered,
Some(&key),
@@ -796,50 +652,6 @@ impl Console {
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> {
let runtime = self.runtime()?;
let runtime = runtime.as_ref().ok_or(ConsoleError::RuntimeNotRunning)?;
@@ -874,55 +686,7 @@ impl Console {
Ok(view)
}
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> {
fn invoke(&self, key: &ServiceKey, input: Vec<u8>) -> Result<InvokeResponse, ConsoleError> {
let runtime = self.runtime()?;
let runtime = runtime.as_ref().ok_or(ConsoleError::RuntimeNotRunning)?;
self.ensure_managed(key)?;
@@ -947,7 +711,11 @@ impl Console {
Some(key),
format!("invoke completed in {latency_ms} ms"),
)?;
Ok(InvocationResult { output, latency_ms })
Ok(InvokeResponse {
output_base64: BASE64.encode(&output),
output_bytes: output.len(),
latency_ms,
})
}
Err(error) => {
record.errors = record.errors.saturating_add(1);
@@ -961,24 +729,6 @@ 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(
&self,
stored_services: Vec<StoredService>,
@@ -997,12 +747,10 @@ impl Console {
)));
}
runtime.register_from_file(manifest.clone())?;
let capabilities = runtime_capabilities(runtime, &key)?;
self.state()?.services.insert(
key,
ServiceRecord {
manifest,
capabilities,
status: ServiceStatus::Stopped,
updated_at_ms: stored.updated_at_ms,
calls: stored.calls,
@@ -1045,79 +793,22 @@ impl Console {
continue;
}
runtime.register_from_file(manifest.clone())?;
let capabilities = runtime_capabilities(runtime, &key)?;
self.insert_service(manifest, capabilities, ServiceStatus::Stopped)?;
self.insert_service(manifest, ServiceStatus::Stopped)?;
}
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> {
// Serialize snapshots so a slower earlier write cannot overwrite a
// newer state captured by a concurrent HTTP operation.
let _sync = self.persistence_sync.lock().await;
let (services, events, deployments) = self.persistence_snapshot()?;
self.persistence.sync(services, events, deployments).await?;
let (services, events) = self.persistence_snapshot()?;
self.persistence.sync(services, events).await?;
Ok(())
}
fn persistence_snapshot(&self) -> Result<PersistenceSnapshot, ConsoleError> {
fn persistence_snapshot(&self) -> Result<(Vec<StoredService>, Vec<StoredEvent>), ConsoleError> {
let state = self.state()?;
let services = state
.services
@@ -1145,29 +836,18 @@ impl Console {
message: event.message.clone(),
})
.collect();
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))
Ok((services, events))
}
fn insert_service(
&self,
manifest: ServiceManifest,
capabilities: Vec<CapabilityView>,
status: ServiceStatus,
) -> Result<ServiceView, ConsoleError> {
let key = manifest.key()?;
let mut state = self.state()?;
let record = ServiceRecord {
manifest,
capabilities,
status,
updated_at_ms: unix_time_ms(),
calls: 0,
@@ -1232,35 +912,6 @@ 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`].
///
/// The returned router exposes Runtime control, component registration,
@@ -1271,7 +922,7 @@ pub fn app(console: Arc<Console>, allowed_origins: Vec<HeaderValue>) -> Router {
.max(console.max_wit_package_bytes())
.saturating_add(1024 * 1024);
let mut cors = CorsLayer::new()
.allow_methods([Method::GET, Method::POST, Method::DELETE])
.allow_methods([Method::GET, Method::POST])
.allow_headers([header::CONTENT_TYPE]);
if !allowed_origins.is_empty() {
cors = cors.allow_origin(allowed_origins);
@@ -1284,16 +935,6 @@ pub fn app(console: Arc<Console>, allowed_origins: Vec<HeaderValue>) -> Router {
.route("/api/v1/runtime/stop", post(stop_runtime))
.route("/api/v1/runtime/restart", post(restart_runtime))
.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(
"/api/v1/wit/packages",
get(list_wit_packages).post(publish_wit_package),
@@ -1330,71 +971,10 @@ pub fn app(console: Arc<Console>, allowed_origins: Vec<HeaderValue>) -> Router {
.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> {
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(
State(console): State<Arc<Console>>,
) -> Result<Json<RuntimeView>, ApiError> {
@@ -1427,38 +1007,6 @@ 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> {
Ok(Json(EventList {
events: console.events()?,
@@ -1627,16 +1175,6 @@ 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(
State(console): State<Arc<Console>>,
AxumPath((id, revision)): AxumPath<(String, String)>,
@@ -1662,12 +1200,9 @@ async fn invoke_service(
let input = BASE64.decode(request.input_base64).map_err(|error| {
ConsoleError::InvalidRequest(format!("input_base64 is invalid: {error}"))
})?;
let result = 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,
}))
Ok(Json(
run_blocking(console, move |console| console.invoke(&key, input)).await?,
))
}
async fn run_blocking<T, F>(console: Arc<Console>, operation: F) -> Result<T, ConsoleError>
@@ -1763,106 +1298,6 @@ fn duration_ms(duration: std::time::Duration) -> u64 {
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);
impl From<ConsoleError> for ApiError {
@@ -1882,8 +1317,6 @@ impl IntoResponse for ApiError {
let (status, code) = match &self.0 {
ConsoleError::InvalidRequest(_) => (StatusCode::BAD_REQUEST, "invalid_request"),
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::ArtifactTooLarge { .. }
| ConsoleError::Package(PackageError::ComponentTooLarge { .. })
@@ -1928,7 +1361,6 @@ impl IntoResponse for ApiError {
ConsoleError::Storage { .. }
| ConsoleError::ManifestSerialize(_)
| ConsoleError::Database(_)
| ConsoleError::KvBackendStart(_)
| ConsoleError::InvalidDatabaseData(_)
| ConsoleError::Runtime(_)
| ConsoleError::WitRegistry(WitRegistryError::InvalidStorage(_))
+9 -33
View File
@@ -1,11 +1,11 @@
//! Standalone Wasmeld control-plane and data-plane server entry point.
//! Standalone Wasmeld management server entry point.
use std::{env, net::SocketAddr, path::PathBuf, sync::Arc};
use axum::http::HeaderValue;
use tracing::info;
use tracing_subscriber::EnvFilter;
use wasmeld_console::{Console, ConsoleConfig, app, gateway_app};
use wasmeld_console::{Console, ConsoleConfig, app};
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
@@ -16,12 +16,9 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
)
.init();
let management_address = env::var("WASMELD_ADDR")
let address = env::var("WASMELD_ADDR")
.unwrap_or_else(|_| "127.0.0.1:8080".to_owned())
.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")
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("var/wasmeld/components"));
@@ -41,26 +38,13 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
})
.await?,
);
let management = app(Arc::clone(&console), allowed_origins);
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);
});
let application = app(console, allowed_origins);
let listener = tokio::net::TcpListener::bind(address).await?;
info!(%management_address, "Wasmeld management API listening");
info!(%gateway_address, "Wasmeld Gateway listening");
let result = tokio::try_join!(
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?;
info!(%address, "Wasmeld listening");
axum::serve(listener, application)
.with_graceful_shutdown(shutdown_signal())
.await?;
Ok(())
}
@@ -79,11 +63,3 @@ async fn shutdown_signal() {
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;
}
}
}
+9 -141
View File
@@ -1,15 +1,9 @@
//! Toasty models and transactional libSQL snapshots for Console control data.
//!
//! Component bytes and WIT packages remain filesystem artifacts. This module
//! persists service manifests, deployments, metrics, the bounded event
//! history, and service-scoped Host KV entries.
//! persists only service manifests, metrics, and the bounded event history.
use std::{
collections::BTreeSet,
path::Path,
sync::Arc,
time::{SystemTime, UNIX_EPOCH},
};
use std::path::Path;
use toasty::Db;
use toasty_driver_turso::Turso;
@@ -37,88 +31,31 @@ pub(crate) struct StoredEvent {
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.
#[derive(Clone)]
pub(crate) struct Persistence {
db: Arc<Mutex<Db>>,
db: Mutex<Db>,
}
impl Persistence {
/// Opens the database and installs or advances its schema.
/// Opens the database and installs the schema for a new file.
pub async fn open(path: &Path) -> toasty::Result<Self> {
let is_new_database = !path.exists();
let db = Db::builder()
.models(toasty::models!(
StoredService,
StoredEvent,
StoredDeployment,
StoredKvEntry
))
.models(toasty::models!(StoredService, StoredEvent))
.build(Turso::file(path))
.await?;
if is_new_database {
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: Arc::new(Mutex::new(db)),
})
Ok(Self { db: Mutex::new(db) })
}
/// Loads the complete service and deployment sets plus retained events.
pub async fn load(
&self,
) -> toasty::Result<(Vec<StoredService>, Vec<StoredEvent>, Vec<StoredDeployment>)> {
/// Loads the complete service set and retained event history.
pub async fn load(&self) -> toasty::Result<(Vec<StoredService>, Vec<StoredEvent>)> {
let mut db = self.db.lock().await;
let services = StoredService::all().exec(&mut *db).await?;
let events = StoredEvent::all().exec(&mut *db).await?;
let deployments = StoredDeployment::all().exec(&mut *db).await?;
Ok((services, events, deployments))
Ok((services, events))
}
/// Atomically upserts a Console snapshot and prunes expired events.
@@ -126,20 +63,10 @@ impl Persistence {
&self,
services: Vec<StoredService>,
events: Vec<StoredEvent>,
deployments: Vec<StoredDeployment>,
) -> toasty::Result<()> {
let mut db = self.db.lock().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 {
StoredService::upsert_by_service_key(service.service_key)
.manifest_toml(service.manifest_toml)
@@ -169,65 +96,6 @@ impl Persistence {
.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
}
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()
}
+3 -426
View File
@@ -15,7 +15,7 @@ use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64};
use serde_json::{Value, json};
use tempfile::TempDir;
use tower::ServiceExt;
use wasmeld_console::{Console, ConsoleConfig, app, gateway_app};
use wasmeld_console::{Console, ConsoleConfig, app};
use wasmeld_package::{
module::{ModuleLock, sync_dependencies},
wit_package::build_wit_package,
@@ -40,7 +40,6 @@ async fn manages_a_resident_component_over_http() {
assert_eq!(status, StatusCode::CREATED, "{registered}");
assert_eq!(registered["id"], "echo");
assert_eq!(registered["status"], "stopped");
assert_eq!(registered["capabilities"], json!([]));
let response = application
.clone()
@@ -90,302 +89,6 @@ async fn manages_a_resident_component_over_http() {
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]
async fn reloads_manifests_without_restoring_actor_memory() {
let artifact_dir = TempDir::new().expect("temporary artifact directory");
@@ -437,80 +140,6 @@ async fn reloads_manifests_without_restoring_actor_memory() {
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]
async fn manages_the_embedded_runtime_lifecycle() {
let artifact_dir = TempDir::new().expect("temporary artifact directory");
@@ -844,10 +473,6 @@ async fn client_fetches_transitive_wit_dependencies_from_the_registry() {
}
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 {
artifact_dir: artifact_dir.to_path_buf(),
wit_registry_dir: artifact_dir.join("wit-packages"),
@@ -856,28 +481,16 @@ async fn test_apps(artifact_dir: &Path) -> (Router, Router) {
})
.await
.expect("console should start");
let console = Arc::new(console);
(app(Arc::clone(&console), Vec::new()), gateway_app(console))
app(Arc::new(console), Vec::new())
}
fn package_request(id: &str, revision: &str, component: &[u8]) -> Request<Body> {
let mut package = Cursor::new(Vec::new());
let world = if id == "counter" {
"component:counter/counter-component@0.1.0"
} else if id == "clock-probe" {
"component:clock-probe/clock-probe-component@0.1.0"
} else {
"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)
.expect("test package should be valid");
package_bytes_request(package.get_ref())
@@ -939,15 +552,6 @@ fn json_request(uri: &str, value: Value) -> Request<Body> {
.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> {
Request::builder()
.method("POST")
@@ -956,14 +560,6 @@ fn empty_post(uri: &str) -> Request<Body> {
.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> {
Request::builder().uri(uri).body(Body::empty()).unwrap()
}
@@ -975,23 +571,6 @@ async fn response_json(response: axum::response::Response) -> Value {
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 {
component_artifact("echo_component.wasm")
}
@@ -1001,8 +580,6 @@ fn component_artifact(name: &str) -> PathBuf {
for manifest in [
"components/echo/Cargo.toml",
"components/counter/Cargo.toml",
"components/clock-probe/Cargo.toml",
"components/kv-probe/Cargo.toml",
] {
let status = Command::new("rustup")
.args([
-223
View File
@@ -1,223 +0,0 @@
# 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
@@ -1,431 +0,0 @@
//! 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);
}
}
+28 -108
View File
@@ -1,7 +1,5 @@
//! Command-line entry point for Component packaging and WIT dependency workflows.
mod dev;
use std::{
env, fs,
path::{Path, PathBuf},
@@ -9,7 +7,6 @@ use std::{
};
use serde::Deserialize;
use sha2::{Digest, Sha256};
use wasmeld_package::{
PackageManifest,
module::{MODULE_MANIFEST_FILE, ModuleManifest, find_module_manifest, sync_dependencies},
@@ -19,41 +16,6 @@ use wasmeld_package::{
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)]
struct CargoMetadata {
packages: Vec<CargoPackage>,
@@ -89,7 +51,6 @@ fn run() -> Result<(), Box<dyn std::error::Error>> {
let mut arguments = env::args().skip(1);
match arguments.next().as_deref() {
Some("pack") => run_pack(arguments.collect()),
Some("dev") => dev::run(arguments.collect()),
Some("wit") => run_wit(arguments.collect()),
_ => Err(usage().into()),
}
@@ -117,102 +78,69 @@ fn run_pack(arguments: Vec<String>) -> Result<(), Box<dyn std::error::Error>> {
}
}
let packed = pack_component(PackRequest {
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 manifest_path = fs::canonicalize(manifest_path)?;
sync_component_dependencies(&manifest_path, locked)?;
let metadata = cargo_metadata(&manifest_path)?;
let package = metadata
.packages
.iter()
.find(|package| package.manifest_path == manifest_path)
.ok_or("Cargo metadata did not contain the requested package")?;
let cargo_id = package
let id = package
.metadata
.get("wasmeld")
.and_then(|metadata| metadata.get("id"))
.and_then(serde_json::Value::as_str)
.ok_or("[package.metadata.wasmeld].id is required")?
.to_owned();
.ok_or("[package.metadata.wasmeld].id is required")?;
let world = package
.metadata
.get("wasmeld")
.and_then(|metadata| metadata.get("world"))
.and_then(serde_json::Value::as_str)
.ok_or("[package.metadata.wasmeld].world is required")?
.to_owned();
let cargo_revision = package.version.clone();
.ok_or("[package.metadata.wasmeld].world is required")?;
PackageManifest::new(id, &package.version, world, b"identity-validation").validate()?;
let target = package
.targets
.iter()
.find(|target| target.crate_types.iter().any(|kind| kind == "cdylib"))
.ok_or("component package must expose a cdylib target")?
.name
.clone();
.ok_or("component package must expose a cdylib target")?;
if !request.no_build {
build_component(&manifest_path, request.profile)?;
if !no_build {
build_component(&manifest_path)?;
}
let artifact = metadata
.target_directory
.join(TARGET)
.join(request.profile.directory())
.join(format!("{}.wasm", target.replace('-', "_")));
.join("release")
.join(format!("{}.wasm", target.name.replace('-', "_")));
let component = fs::read(&artifact).map_err(|error| {
format!(
"failed to read built component {}: {error}",
artifact.display()
)
})?;
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(|| {
let output = output.unwrap_or_else(|| {
metadata
.workspace_root
.join("dist")
.join(format!("{id}-{revision}.wasmpkg"))
.join(format!("{id}-{}.wasmpkg", package.version))
});
if let Some(parent) = output.parent() {
fs::create_dir_all(parent)?;
}
let file = fs::File::create(&output)?;
let manifest = write_package(file, id, revision, world, &component)?;
Ok(PackedComponent {
manifest,
output,
artifact,
})
let manifest = write_package(file, id, &package.version, world, &component)?;
println!(
"packed {}@{} -> {}",
manifest.id,
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>> {
@@ -498,21 +426,14 @@ fn cargo_metadata(manifest_path: &Path) -> Result<CargoMetadata, Box<dyn std::er
Ok(serde_json::from_slice(&output.stdout)?)
}
fn build_component(
manifest_path: &Path,
profile: BuildProfile,
) -> Result<(), Box<dyn std::error::Error>> {
fn build_component(manifest_path: &Path) -> Result<(), Box<dyn std::error::Error>> {
let toolchain = env::var("WASMELD_COMPONENT_TOOLCHAIN").unwrap_or_else(|_| "1.90.0".to_owned());
let mut command = Command::new("rustup");
command
let status = Command::new("rustup")
.args(["run", &toolchain, "cargo", "build"])
.arg("--manifest-path")
.arg(manifest_path)
.args(["--target", TARGET]);
if matches!(profile, BuildProfile::Release) {
command.arg("--release");
}
let status = command.status()?;
.args(["--target", TARGET, "--release"])
.status()?;
if !status.success() {
return Err(format!("component build failed with status {status}").into());
}
@@ -522,7 +443,6 @@ fn build_component(
fn usage() -> String {
"usage:
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 tidy [--manifest <wasmeld.toml>] [--locked]
wasmeld wit graph [--manifest <wasmeld.toml>]
+9
View File
@@ -5,3 +5,12 @@ wasmtime::component::bindgen!({
path: "../../wit/service",
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",
});
}
@@ -1,44 +0,0 @@
//! `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
@@ -1,145 +0,0 @@
//! `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())
}
@@ -1,50 +0,0 @@
//! 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")
}
}
@@ -1,131 +0,0 @@
//! 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,9 +30,6 @@ pub enum RuntimeError {
#[error("component imports unsupported capability {0}")]
UnsupportedImport(String),
#[error("component requires unavailable capability {0}")]
CapabilityUnavailable(String),
#[error("runtime creation failed: {0}")]
RuntimeCreation(#[source] wasmtime::Error),
-4
View File
@@ -6,14 +6,10 @@
//! service exports, and invokes it through one serial Actor.
mod bindings;
mod capability;
mod error;
mod manifest;
mod runtime;
pub use capability::{
CapabilityDescriptor, KV_MAX_KEY_BYTES, KV_MAX_VALUE_BYTES, KvBackend, KvBackendError,
};
pub use error::RuntimeError;
pub use manifest::{ResourceLimits, ServiceKey, ServiceManifest};
pub use runtime::{ActorHandle, Runtime, RuntimeConfig, RuntimeStats};
+42 -111
View File
@@ -30,9 +30,8 @@ use wasmtime_wasi::{
};
use crate::{
CapabilityDescriptor, KvBackend, RuntimeError, ServiceKey, ServiceManifest,
bindings::{ServiceComponent, ServiceError},
capability::{Capability, CapabilityRegistry, HostCapabilities},
RuntimeError, ServiceKey, ServiceManifest,
bindings::{ServiceComponent, ServiceError, clock as clock_bindings},
manifest::ResourceLimits,
};
@@ -41,6 +40,7 @@ const MAX_EPOCH_TICK: Duration = Duration::from_millis(10);
const DEFAULT_MAX_WASM_STACK: usize = 512 * 1024;
const ACTOR_RESOURCE_COUNT_LIMIT: usize = 16;
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] = &[
"wasi:cli/environment@",
"wasi:cli/exit@",
@@ -61,8 +61,6 @@ pub struct RuntimeConfig {
pub epoch_tick: Duration,
/// Maximum native stack reservation for WebAssembly execution.
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 {
@@ -70,7 +68,6 @@ impl Default for RuntimeConfig {
Self {
epoch_tick: DEFAULT_EPOCH_TICK,
max_wasm_stack: DEFAULT_MAX_WASM_STACK,
kv_backend: None,
}
}
}
@@ -120,7 +117,6 @@ struct RuntimeInner {
actors: Mutex<HashMap<ServiceKey, ActorHandle>>,
lifecycle: Mutex<()>,
ticker: Arc<EpochTicker>,
kv_backend: Option<Arc<dyn KvBackend>>,
}
struct EpochTicker {
@@ -166,7 +162,12 @@ impl Drop for EpochTicker {
struct RegisteredService {
manifest: ServiceManifest,
component: Arc<Component>,
capabilities: Vec<Capability>,
capabilities: Vec<HostCapability>,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum HostCapability {
MonotonicClock,
}
impl Runtime {
@@ -193,7 +194,6 @@ impl Runtime {
actors: Mutex::new(HashMap::new()),
lifecycle: Mutex::new(()),
ticker,
kv_backend: config.kv_backend,
}),
})
}
@@ -212,11 +212,6 @@ impl Runtime {
let component = Component::new(&self.inner.engine, component_bytes.as_ref())
.map_err(RuntimeError::ComponentCompilation)?;
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
.inner
@@ -308,7 +303,6 @@ impl Runtime {
key.clone(),
service,
init_config,
self.inner.kv_backend.clone(),
)?;
self.inner
@@ -333,26 +327,6 @@ impl Runtime {
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.
pub fn stop(&self, key: &ServiceKey) -> Result<(), RuntimeError> {
let _lifecycle = self
@@ -390,55 +364,6 @@ impl Runtime {
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.
pub fn stats(&self) -> Result<RuntimeStats, RuntimeError> {
let registered_services = self
@@ -632,20 +557,15 @@ impl ActorStatus {
}
}
pub(crate) struct HostState {
struct HostState {
store_limits: StoreLimits,
wasi: WasiCtx,
wasi_table: ResourceTable,
pub(crate) capabilities: HostCapabilities,
clock_origin: Instant,
}
impl HostState {
fn new(
limits: &ResourceLimits,
capabilities: &[Capability],
service_id: &str,
kv_backend: Option<Arc<dyn KvBackend>>,
) -> Self {
fn new(limits: &ResourceLimits) -> Self {
let store_limits = StoreLimitsBuilder::new()
.memory_size(limits.memory_bytes)
.table_elements(ACTOR_TABLE_ELEMENT_LIMIT)
@@ -665,12 +585,7 @@ impl HostState {
store_limits,
wasi: wasi.build(),
wasi_table: ResourceTable::new(),
capabilities: HostCapabilities::new(
capabilities,
service_id,
kv_backend,
limits.deadline(),
),
clock_origin: Instant::now(),
}
}
}
@@ -679,6 +594,12 @@ impl HasData for 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 {
fn ctx(&mut self) -> WasiCtxView<'_> {
WasiCtxView {
@@ -709,7 +630,6 @@ impl ActorWorker {
key: ServiceKey,
service: RegisteredService,
init_config: Vec<u8>,
kv_backend: Option<Arc<dyn KvBackend>>,
) -> Result<Self, RuntimeError> {
let limits = Arc::new(service.manifest.limits.clone());
if init_config.len() > limits.max_input_bytes {
@@ -719,15 +639,12 @@ impl ActorWorker {
});
}
let mut store = Store::new(
&engine,
HostState::new(&limits, &service.capabilities, key.id(), kv_backend),
);
let mut store = Store::new(&engine, HostState::new(&limits));
store.limiter(|state| &mut state.store_limits);
let mut linker = Linker::new(&engine);
add_restricted_wasi(&mut linker)?;
CapabilityRegistry::add_to_linker(&mut linker, &service.capabilities)?;
add_host_capabilities(&mut linker, &service.capabilities)?;
let epoch_ticks = ticks_for(limits.deadline(), epoch_tick);
configure_call_budget(&mut store, &limits, epoch_ticks)?;
@@ -841,16 +758,33 @@ fn link_wasi(result: wasmtime::Result<()>) -> Result<(), RuntimeError> {
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(
component: &Component,
engine: &Engine,
) -> Result<Vec<Capability>, RuntimeError> {
) -> Result<Vec<HostCapability>, RuntimeError> {
// Import validation is deny-by-default. A host function is linked only
// after its exact WIT identity or WASI family has passed this allowlist.
let mut capabilities = Vec::new();
for (name, _) in component.component_type().imports(engine) {
if let Some(capability) = CapabilityRegistry::resolve(name) {
capabilities.push(capability);
if name == MONOTONIC_CLOCK_IMPORT {
capabilities.push(HostCapability::MonotonicClock);
} else if !ALLOWED_WASI_IMPORTS
.iter()
.any(|allowed| name.starts_with(allowed))
@@ -859,8 +793,6 @@ fn validate_component_imports(
}
}
capabilities.sort_unstable();
capabilities.dedup();
Ok(capabilities)
}
@@ -871,7 +803,6 @@ fn spawn_actor(
key: ServiceKey,
service: RegisteredService,
init_config: Vec<u8>,
kv_backend: Option<Arc<dyn KvBackend>>,
) -> Result<ActorHandle, RuntimeError> {
let limits = Arc::new(service.manifest.limits.clone());
let (sender, receiver) = mpsc::sync_channel(limits.mailbox_capacity);
@@ -886,7 +817,7 @@ fn spawn_actor(
.spawn(move || {
let _ticker = worker_ticker;
match ActorWorker::create(engine, epoch_tick, key, service, init_config, kv_backend) {
match ActorWorker::create(engine, epoch_tick, key, service, init_config) {
Ok(mut worker) => {
worker_status.mark_ready();
let _ = ready_sender.send(Ok(()));
@@ -1,17 +1,13 @@
use std::{
collections::HashMap,
fs,
path::{Path, PathBuf},
process::Command,
sync::{Arc, Mutex, OnceLock},
sync::OnceLock,
thread,
time::{Duration, Instant},
};
use wasmeld_runtime::{
KV_MAX_VALUE_BYTES, KvBackend, KvBackendError, ResourceLimits, Runtime, RuntimeConfig,
RuntimeError, ServiceManifest,
};
use wasmeld_runtime::{ResourceLimits, Runtime, RuntimeConfig, RuntimeError, ServiceManifest};
static COMPONENTS_BUILT: OnceLock<()> = OnceLock::new();
@@ -20,7 +16,6 @@ fn echo_component_round_trips_bytes() {
let runtime = Runtime::new(RuntimeConfig::default()).expect("runtime should start");
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");
runtime.stop(&key).unwrap();
@@ -176,13 +171,6 @@ fn explicit_clock_capability_is_linked() {
let runtime = Runtime::new(RuntimeConfig::default()).expect("runtime should start");
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 second = actor.invoke(b"second".to_vec()).unwrap();
assert_eq!(&first[8..], b"first");
@@ -192,114 +180,6 @@ fn explicit_clock_capability_is_linked() {
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]
fn registration_rejects_non_whitelisted_wasi_imports() {
let runtime = Runtime::new(RuntimeConfig::default()).expect("runtime should start");
@@ -337,21 +217,11 @@ fn register_component(
service_id: &str,
artifact_name: &str,
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 {
let artifact = component_artifact(artifact_name);
let manifest = ServiceManifest {
id: service_id.to_owned(),
revision: revision.to_owned(),
revision: "0.1.0".to_owned(),
component: artifact.display().to_string(),
world: component_world(artifact_name).to_owned(),
limits,
@@ -378,7 +248,6 @@ fn build_components_once() {
"components/fault/Cargo.toml",
"components/spin/Cargo.toml",
"components/clock-probe/Cargo.toml",
"components/kv-probe/Cargo.toml",
"components/wasi-clock-probe/Cargo.toml",
] {
let status = Command::new("rustup")
@@ -446,58 +315,9 @@ fn component_world(artifact_name: &str) -> &'static str {
"fault_component.wasm" => "component:fault/fault-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",
"kv_probe_component.wasm" => "component:kv-probe/kv-probe-component@0.1.0",
"wasi_clock_probe_component.wasm" => {
"component:wasi-clock-probe/wasi-clock-probe-component@0.1.0"
}
other => panic!("no WIT world registered for {other}"),
}
}
#[derive(Debug, Default)]
struct MemoryKvBackend {
entries: Mutex<HashMap<(String, String), Vec<u8>>>,
}
impl KvBackend for MemoryKvBackend {
fn get(
&self,
service_id: &str,
key: &str,
_timeout: Duration,
) -> Result<Option<Vec<u8>>, KvBackendError> {
Ok(self
.entries
.lock()
.unwrap()
.get(&(service_id.to_owned(), key.to_owned()))
.cloned())
}
fn set(
&self,
service_id: &str,
key: &str,
value: &[u8],
_timeout: Duration,
) -> Result<(), KvBackendError> {
self.entries
.lock()
.unwrap()
.insert((service_id.to_owned(), key.to_owned()), value.to_vec());
Ok(())
}
fn delete(
&self,
service_id: &str,
key: &str,
_timeout: Duration,
) -> Result<(), KvBackendError> {
self.entries
.lock()
.unwrap()
.remove(&(service_id.to_owned(), key.to_owned()));
Ok(())
}
}
+101
View File
@@ -0,0 +1,101 @@
# Wasmeld Host Capabilities
**状态:** Implemented
**首个能力:** `wasmeld:clock/monotonic-clock@0.1.0`
## 边界
所有组件实现稳定的 `service-component` 导出契约。宿主能力不加入这个基础 World,
而是定义成独立 WIT interface,由组件专属 World 按需 import。
```text
wit/
├── service/package.wit # wasmeld:service@0.1.0
└── clock/package.wit # wasmeld:clock@0.1.0
```
例如普通 Echo 不导入宿主能力:
```wit
package component:echo@0.1.0;
world echo-component {
include wasmeld:service/service-component@0.1.0;
}
```
Clock Probe 显式申请单调时钟:
```wit
package component:clock-probe@0.1.0;
world clock-probe-component {
include wasmeld:service/service-component@0.1.0;
import wasmeld:clock/monotonic-clock@0.1.0;
}
```
组件通过自己的 World 生成 binding
```rust
wit_bindgen::generate!({
path: "wit",
world: "clock-probe-component",
generate_all,
});
```
组件根目录中的 `wit/deps``wasmeld wit fetch` 根据 `wasmeld.toml`
`wit.lock` 生成。这几行只是编译期 binding 入口,不是网络协议。组件最终只包含
所选 World 的 imports 和 exports。
## Runtime
Runtime 注册 Component 时读取真实 imports,并映射为内部 `HostCapability`。未知
import 会在注册阶段拒绝。Actor 创建 Linker 时,只为该 Component 实际导入的能力
注册实现。
```text
Component imports
|
v
validate_component_imports
|
v
Vec<HostCapability>
|
v
Actor Linker
```
当前 `monotonic-clock.now` 返回 Actor 创建后经过的纳秒数。它不提供系统时间、网络、
文件或其他宿主访问。
Rust `std::time::Instant` 会引入 `wasi:clocks/monotonic-clock`,该 WASI 接口仍不在
Sandbox 白名单内。组件必须使用平台明确提供的 `wasmeld:clock/monotonic-clock`
不能通过标准库绕过能力控制。
## 新增能力
新增能力时必须同时完成:
1. 在独立 `.wit` 文件中定义小型 interface。
2. 只在需要它的组件 World 中 import。
3. 在 Runtime 中实现生成的 Host trait。
4. 将完全限定的 import 名称映射到新的 `HostCapability`
5. 在 Actor Linker 中注册实现。
6. 增加允许和拒绝两类 Sandbox 测试。
不要把可选能力加入 `service-component`。不要仅依据包 manifest 授权,Runtime 必须
以编译后 Component 的真实 imports 为准。
## 版本策略
- 已发布的 WIT 版本不可原地修改。
- 破坏性变更发布新的 major version。
- Runtime 在迁移期可以同时实现两个版本。
- 不相关能力独立演进,不能迫使所有组件重新编译。
`service``clock` 已经是两个独立版本的 WIT package。源码只维护当前版本,历史
版本由 Git tag 与不可变 Registry 制品保留;组件通过包名、版本和锁文件选择依赖。
+94
View File
@@ -0,0 +1,94 @@
# Wasmeld 组件包
**状态:** Implemented
**格式版本:** 1
`.wasmpkg` 是平台内部发布 WebAssembly Component 的单文件制品。文件使用 ZIP
容器,扩展名固定为 `.wasmpkg`,并且只能包含两个根目录条目:
```text
echo-0.1.0.wasmpkg
├── package.toml
└── component.wasm
```
`package.toml` 示例:
```toml
schema_version = 1
id = "echo"
revision = "0.1.0"
world = "component:echo/echo-component@0.1.0"
component = "component.wasm"
sha256 = "..."
```
包中不包含内存、Fuel、超时或 mailbox 等部署策略。这些限制由
`wasmeld-console``registration_limits` 注入,组件发布者不能通过上传制品扩大
Sandbox 权限。
## 组件配置
组件必须是 `cdylib`,并在 `Cargo.toml` 中声明平台服务 ID
```toml
[lib]
crate-type = ["cdylib"]
[package.metadata.wasmeld]
id = "echo"
world = "component:echo/echo-component@0.1.0"
```
版本直接使用 `[package].version``world` 必须是组件选择的完整、带版本 WIT World;
打包工具会将它写入制品,控制台不需要手工填写。
组件的 WIT 依赖由同目录的 `wasmeld.toml``wit.lock` 管理。`pack` 会在 Cargo
编译前自动生成 `wit/deps`CI 应传入 `--locked`,禁止依赖摘要发生漂移。
## 打包
从项目根目录运行:
```bash
cargo run -p wasmeld-package --bin wasmeld -- \
pack components/echo/Cargo.toml --locked
```
命令默认使用 Rust `1.90.0` 构建 `wasm32-wasip2` release Component,并输出:
```text
dist/echo-0.1.0.wasmpkg
```
可以覆盖输出路径,或者复用已经构建的 Component:
```bash
cargo run -p wasmeld-package --bin wasmeld -- \
pack components/echo/Cargo.toml \
--output dist/echo.wasmpkg \
--no-build
```
通过 `WASMELD_COMPONENT_TOOLCHAIN` 可以覆盖组件构建工具链。
## 注册
管理 API 只接收一个 multipart 字段:
```bash
curl -F package=@dist/echo-0.1.0.wasmpkg \
http://127.0.0.1:8080/api/v1/services
```
Console 在写入制品目录前执行以下校验:
- ZIP 恰好包含 `package.toml``component.wasm`
- schema、服务 ID、版本和 WIT World 合法
- 解压后的 Component 未超过平台上限
- Component 的 SHA-256 与 manifest 一致
- Wasmtime 能编译 Component,且 imports 符合 Sandbox 白名单
- 同一服务 ID 和版本尚未注册
校验成功后,服务以 `stopped` 状态注册。启动 Actor 仍是独立的管理操作。
+54
View File
@@ -0,0 +1,54 @@
# Wasmeld Console 管理后端
**状态:** Implemented
**范围:** 本地 Wasmeld Runtime 管理控制面
## 边界
`wasmeld-console` 是独立 Rust 进程,对外提供管理 HTTP API,对内只调用
`wasmeld-runtime`。Runtime 作为库嵌入同一进程,不另起网络服务。前端 `console`
不直接访问 Runtime、文件系统或数据库。
~~~text
TanStack Console / wasmeld CLI
|
| HTTP + JSON / multipart
v
wasmeld-console
+-- API / CORS
+-- component artifact registry
+-- immutable WIT package registry
+-- Toasty persistence
+-- local libSQL
|
v
wasmeld-runtime
+-- Wasmtime Engine
+-- resident Actor
+-- Store + Component Instance
~~~
## 状态
- Console 接收一个 `.wasmpkg`,从包内读取并校验组件身份。
- `component.wasm` 和平台生成的运行时 `manifest.toml` 保存到本地制品目录。
- 运行限制由 Console 策略注入,不接受组件包覆盖。
- manifest、调用计数和最近 256 条事件通过 Toasty 保存到 libSQL。
- Console 进程启动时自动创建 Runtime Engine,从数据库恢复服务元数据,并从制品目录注册 Component。
- Console 可显式启动、停止和重启 Runtime;停止 Runtime 不会停止 Console HTTP 管理面。
- Runtime 停止时释放全部 Actor、Store、Instance 和 epoch worker。
- Actor 内存和运行状态只存在于当前进程。
- Runtime 或 Console 重启后所有服务回到 `stopped`,调用计数与事件保留,但不会伪造 Wasm 内存恢复。
## 约束
- 默认只监听 `127.0.0.1:8080`
- libSQL 数据库默认位于 `var/wasmeld/console.db`
- CORS 默认只允许本地开发面板,其他 Origin 必须显式配置。
- 服务 ID 和 revision 只能包含 ASCII 字母、数字、点、下划线和短横线。
- `.wasmpkg` 上传和解压后的 Component 上限均默认为 64 MiB。
- 二进制 WIT Package 上传上限默认为 4 MiB,同名同版本发布后不可覆盖。
- 包内固定只有 `package.toml``component.wasm`,不会直接解压任意路径。
- Wasm 编译、实例化和调用通过阻塞任务执行,不占用 Tokio 异步 worker。
- Sandbox、fuel、deadline、内存和 mailbox 限制仍由 `wasmeld-runtime` 执行。
+319
View File
@@ -0,0 +1,319 @@
# Wasmeld Runtime 架构
**状态:** Draft
**日期:** 2026-07-22
**范围:** 仅实现 Wasmeld Runtime
## 1. 范围
本阶段只解决一件事:在一个 Rust 宿主进程中,安全地加载并常驻运行内部 Rust 编译的 WebAssembly Component。
需要具备:
- Rust Component 的构建、注册、加载、调用、停止与重载。
- 同一实例跨多次调用保留内存。
- 同一实例不发生并发重入。
- Wasmtime 资源限制与最小 sandbox。
- 统一且不含业务语义的 Component 调用契约。
当前不做 HTTP、客户、鉴权、数据库、网络、消息队列、业务状态持久化、分布式部署或发布治理。
## 2. 核心结构
~~~text
Rust Component source
|
| cargo build --target wasm32-wasip2 --release
v
component.wasm + manifest.toml
|
v
Component Registry
|
v
Wasmeld Runtime (one Rust process)
|
+-- Engine
+-- compiled Component cache
+-- Service Manager
|
+-- ServiceActor
+-- bounded mailbox
+-- Store<HostState>
+-- restricted Linker
+-- Component Instance
~~~
Runtime 是一个 Rust 库或单独进程,不提供网络协议。对内只暴露:
~~~text
register(manifest, artifact) -> ServiceKey
start(service_key, init_config) -> ActorHandle
invoke(actor_handle, bytes) -> bytes or RuntimeError
stop(actor_handle)
reload(next_manifest, next_artifact, init_config) -> ActorHandle
~~~
调用方未来可以是 HTTP Adapter、CLI 或测试程序;Runtime 本身不关心调用来源。
## 3. 核心对象
| 对象 | 责任 |
| --- | --- |
| Component Artifact | 一个不可变的 component.wasm 与最小 manifest。 |
| Service Revision | Artifact 的 id + revision。 |
| Engine | 进程级共享的 Wasmtime 编译和执行引擎。 |
| Component Cache | 缓存编译后的 Component,不保存实例内存。 |
| Service Manager | 管理 Service Revision 与 ActorHandle 的映射。 |
| ServiceActor | 独占 Store、Instance、HostState 与有界邮箱。 |
| ActorHandle | 向 Actor 发送调用,不能接触 Store 或 Instance。 |
| HostState | Store 专有的运行时状态,不包含业务数据。 |
V1 采用:
~~~text
one Service Revision -> one ServiceActor -> one Store + one Instance
~~~
多个 Actor、分片和水平扩展属于后续能力。
## 4. 制品与注册
内部服务构建为 Component
~~~bash
cargo build --target wasm32-wasip2 --release
~~~
最小制品:
~~~text
component.wasm
manifest.toml
~~~
manifest 只包含:
~~~text
id
revision
component path
expected WIT world
memory limit
fuel per call
deadline
mailbox capacity
input/output byte limits
~~~
注册规则:
- ServiceKey 由 id 与 revision 构成,不允许覆盖。
- Runtime 校验 manifest 的 WIT world 格式与资源策略;实例化时由 typed binding 校验基础导出契约。
- V1 只接受本地受信任制品;Registry、签名、SBOM 与远程下载后置。
- 已编译 Component 可缓存,但 Actor 的内存只能存在于 Store + Instance 中。
## 5. 最小 WIT 契约
V1 不定义任何业务协议。输入输出都是有长度上限的 opaque bytes。
~~~wit
package wasmeld:service@0.1.0;
world service-component {
variant service-error {
init-failed(string),
call-failed(string),
}
export init: func(config: list<u8>) -> result<_, service-error>;
export invoke: func(input: list<u8>) -> result<list<u8>, service-error>;
}
~~~
规则:
- init 仅在一个新 Instance 上调用一次。
- invoke 是唯一的普通调用入口。
- Component 返回 service-error 后,Actor 可继续使用。
- trap、资源超限或中断后,整个 Store + Instance 必须丢弃,不能继续调用。
- 基础 world 不定义宿主能力 import。组件专属 World 通过独立 interface 按需 import
平台能力,详见 `wasmeld-capabilities.md`。普通 Rust `wasm32-wasip2` 产物会隐式导入标准 WASI P2
接口,因此 Runtime 提供受限的 WASI 兼容层,而不是把它们暴露为业务 ABI。
- Runtime 在注册时只允许以下兼容 import:`wasi:cli/{environment,exit,stdin,stdout,stderr}`
`wasi:clocks/wall-clock``wasi:filesystem/{preopens,types}``wasi:io/{error,streams}`
`wasi:io/poll`、WASI monotonic clock、随机数、socket、HTTP 和未知平台 import 都会被拒绝。
- V1 提供独立的 `wasmeld:clock/monotonic-clock@0.1.0` 平台能力。只有显式导入
该 interface 的组件会获得对应 Linker 实现。
- health、shutdown、日志、文件、网络和标准 I/O 都不属于基础 ABI。wall clock 仅作为
Rust/WASI 兼容层的一部分存在,不用于传递业务能力;随机数不在 V1 白名单内。
## 6. 常驻 Actor
~~~text
Caller
-> ActorHandle
-> bounded Sender<Invocation>
-> dedicated Wasm worker
-> Store + Instance + HostState
~~~
一个 Actor 的 worker 独占 Store。不能使用 Arc<Mutex<Store>> 共享 Store,也不能让同一 Instance 重入。
处理流程:
1. ActorHandle 提交 Invocation。
2. Actor 从 FIFO mailbox 取出一条消息。
3. Actor 在同一 Store + Instance 上调用 service.invoke。
4. Actor 通过 oneshot response 返回结果。
5. 下一条消息才可进入该 Instance。
这保证 Rust heap、Wasm linear memory 和全局变量在多次调用间常驻,同时保证状态修改串行。
实现约束:
- Wasm 调用放在专用 worker 线程或线程池,不能阻塞 Tokio 的通用 worker。
- V1 不使用 Wasmtime async 或 Wasm threads。为兼容 Rust 的 `wasm32-wasip2` 组件,
链接同步、受限的 WASI P2 白名单实现。
- mailbox 满时立即返回 RuntimeOverloaded。
- 调用 deadline 到期且尚未进入 Instance 时直接取消。
- Component 不得在 invoke 之外启动后台循环。
Actor 内存是易失的。Runtime 重启、Actor fault、stop 或 reload 后,下一实例重新执行 init,不恢复旧内存。
## 7. 生命周期
~~~text
Registered -> Starting -> Ready -> Draining -> Stopped
|
+-> Failed
~~~
- register:保存 Artifact 与 manifest。
- start:取得已编译 Component,创建 Store、受限 WASI Linker 与 Instance,然后调用 init。
- ready:接收 invoke。
- draining:拒绝新调用,等待当前调用完成。
- stopped:释放 Store 和 Instance。
- failedtrap、fuel 耗尽、deadline 或 Runtime 错误后立即释放 Store 和 Instance。
故障后:
~~~text
fault
-> mark Actor failed
-> reject queued calls
-> drop Store + Instance
-> optionally create a new Actor
~~~
自动重建可配置,但 V1 不应隐藏故障或声称保留了旧内存。
reload 的最小流程:
1. 注册新的 Service Revision。
2. start 新 Actor,确保 init 成功。
3. 调用方切换到新的 ActorHandle。
4. 调用方显式 stop 旧 Actor 并释放。
V1 不做请求级流量切换或状态迁移。
## 8. Sandbox 与资源限制
V1 Linker 注册受限 WASI P2 兼容实现,并按 Component 的真实 imports 注册已批准的
平台能力。每个 Actor 都有独立
`WasiCtx`,并且显式禁止 TCP、UDP、DNS 和所有 socket 地址。默认配置还保证无环境变量、
参数、工作目录或预打开目录,stdin 关闭,stdout/stderr 丢弃。因此 Component 默认没有:
- 文件系统、环境变量、命令行参数或标准 I/O 继承。
- 网络、DNS、socket 或原始系统调用。
- 数据库、密钥、配置文件、宿主目录或宿主进程能力。
每个 Actor 必须显式设置:
| 限制 | 目的 |
| --- | --- |
| linear memory | 阻止无限内存增长。 |
| tables / instances / memories | 限制 Wasm 对象数量。 |
| fuel per invocation | 限制确定性的计算工作量。 |
| epoch deadline | 限制 wall-clock 执行时间。 |
| input/output bytes | 限制单次数据传输。 |
| mailbox capacity | 实现背压,避免无限排队。 |
fuel 和 epoch 只约束 Wasm 执行。未来一旦加入 Host I/O,必须单独定义 timeout、取消和连接资源策略。
Runtime 的 epoch ticker 上限为 10 ms,且服务 deadline 不能小于该 tick,以避免配置使
wall-clock deadline 失效。ActorHandle 持有 ticker 的生命周期引用,因此 Runtime 对象释放后,
仍被调用方持有的 ActorHandle 不会失去 epoch 中断能力。
Wasm Store 隔离 Component 内存和可见 import,但不是完整 OS 隔离。V1 以第一方代码为前提;进程、Pod、gVisor 或 Kata 隔离在实际需要时再加入。
## 9. 最小代码结构
~~~text
wit/
service/
package.wit
clock/
package.wit
crates/
wasmeld-runtime/
bindings.rs
error.rs
manifest.rs
runtime.rs
lib.rs
components/
echo/
counter/
fault/
spin/
clock-probe/
tests/
runtime_components.rs
~~~
## 10. 验收测试
测试 Component
- echo:原样返回输入,验证基础加载与调用。
- counterinit 创建计数状态,每次 invoke 递增,验证内存常驻。
- fault:显式 trap,验证旧 Store 会被丢弃且 Actor 可重建。
- spin:不可被优化掉的 CPU 循环,验证 fuel 与 epoch 中断。
- clock-probe:显式导入平台 monotonic-clock,验证能力按需 Link。
- wasi-clock-probe:通过 Rust 标准库导入 WASI monotonic clock,验证注册时拒绝越权。
必须验证:
1. Component 只有在导出匹配预期 WIT world、且其 import 可由受限 WASI Linker 满足时才可启动。
2. 同一 Actor 调用 counter 两次能观察到递增状态。
3. stop、fault 或 Runtime 重启后,counter 从 init 重新开始。
4. 多个并发 invoke 会严格经同一 Actor 串行执行。
5. 无限循环会被 fuel 或 epoch deadline 终止。
6. trap 后旧 Store 被丢弃,重建 Actor 后可再次调用。
7. mailbox 满时立即出现 overload,不无限等待。
8. memory.grow 超过限制会导致 Actor fault。
9. reload 能启动新 revision 的 Actor;旧 Actor 由调用方显式 stop。
## 11. 后续扩展触发条件
| 明确需求 | 再引入的能力 |
| --- | --- |
| 需要网络入口 | HTTP/gRPC Adapter 与 API Gateway。 |
| 需要调用宿主资源 | 独立 Host Capability 与 WIT import。 |
| 需要跨重启保留状态 | 外部状态、checkpoint 与恢复协议。 |
| 需要多个实例 | Actor 池、分片和放置策略。 |
| 需要生产发布治理 | 制品签名、Registry、灰度与回滚。 |
| 需要执行非第一方代码 | 额外进程或 OS sandbox。 |
## 12. 参考资料
- [WebAssembly Component Model](https://component-model.bytecodealliance.org/)
- [Rust Components with wasm32-wasip2](https://component-model.bytecodealliance.org/language-support/building-a-simple-component/rust.html)
- [Wasmtime](https://docs.wasmtime.dev/)
- [Wasmtime interruption with fuel and epochs](https://docs.wasmtime.dev/examples-interrupting-wasm.html)
+145
View File
@@ -0,0 +1,145 @@
# WIT Package 管理
**状态:** Implemented
**Manifest 版本:** 1
**Lock 版本:** 1
## 边界
`wasmeld-console` 作为版本化 WIT Registry 和管理面。组件构建侧由 `wasmeld`
解析依赖、锁定摘要并生成本地 `wit/deps``wit-bindgen` 始终只读取本地文件,
Component 运行时不访问 Registry。
```text
WIT Git tag
|
v
Wasmeld Registry
|
v
wasmeld -> wit.lock -> wit/deps -> wit-bindgen
```
WIT 源码不按版本复制目录。每个 package 只维护当前源码,发布时用
`service/v1.0.0``clock/v1.1.0` 等 Git tag 标记源码版本。Registry 保存由
Component Model 工具链编码的标准二进制 WIT Package,并从制品本身解析包名、
版本、直接依赖和 SHA-256。相同包名和版本不可覆盖。
## 组件目录
```text
components/clock-probe/
├── Cargo.toml
├── wasmeld.toml
├── wit.lock
├── src/lib.rs
└── wit/
├── world.wit
└── deps/ # 自动生成,不提交
```
`wasmeld.toml` 当前只接受精确版本:
```toml
schema_version = 1
[registry]
url = "http://127.0.0.1:8080"
[dependencies]
"wasmeld:clock" = "0.1.0"
"wasmeld:service" = "0.1.0"
[replace."wasmeld:clock"]
path = "../../wit/clock"
[replace."wasmeld:service@0.1.0"]
path = "../../wit/service"
```
精确版本 replace 的优先级高于包级 replace。只有根组件的 replace 生效;依赖包
自身不能修改整个依赖图。替换目录声明的 WIT package 名称和版本必须与依赖完全
相同。没有 replace 的直接依赖和传递依赖从 `[registry].url` 获取。
## 锁文件
`wit.lock` 必须提交:
```toml
schema_version = 1
[[package]]
name = "wasmeld:clock"
version = "0.1.0"
source = "registry+http://127.0.0.1:8080"
sha256 = "..."
replaced = false
```
Registry 依赖的摘要覆盖完整二进制包;path replace 的摘要覆盖包目录下排序后的
顶层 `.wit` 文件名和内容。`--locked` 在安装 `wit/deps` 前比较完整传递依赖图、
来源和摘要,不一致时构建失败。
## 命令
```bash
# 将 WIT 源码编码成标准二进制 WIT Package
cargo run -p wasmeld-package --bin wasmeld -- \
wit build wit/service
# 构建并发布;也可用 WASMELD_REGISTRY 设置 Registry
cargo run -p wasmeld-package --bin wasmeld -- \
wit publish wit/service --registry http://127.0.0.1:8080
# 获取依赖并更新 wit.lock
cargo run -p wasmeld-package --bin wasmeld -- \
wit fetch --manifest components/clock-probe/wasmeld.toml
# 验证锁文件,不更新
cargo run -p wasmeld-package --bin wasmeld -- \
wit fetch --manifest components/clock-probe/wasmeld.toml --locked
# 查看实际来源
cargo run -p wasmeld-package --bin wasmeld -- \
wit graph --manifest components/clock-probe/wasmeld.toml
# 添加或删除本地替换
cargo run -p wasmeld-package --bin wasmeld -- \
wit replace wasmeld:clock@0.1.0 \
--path ../../wit/clock \
--manifest components/clock-probe/wasmeld.toml
cargo run -p wasmeld-package --bin wasmeld -- \
wit replace wasmeld:clock@0.1.0 \
--drop \
--manifest components/clock-probe/wasmeld.toml
```
`pack` 自动执行相同的同步步骤:
```bash
cargo run -p wasmeld-package --bin wasmeld -- \
pack components/clock-probe/Cargo.toml --locked
```
## Registry API
| 方法 | 路径 | 用途 |
| --- | --- | --- |
| `GET` | `/api/v1/wit/packages` | 列出全部包版本 |
| `POST` | `/api/v1/wit/packages` | multipart 发布 `package.wasm` |
| `GET` | `/api/v1/wit/packages/{namespace}/{name}/{version}` | 查询元数据 |
| `GET` | `/api/v1/wit/packages/{namespace}/{name}/{version}/content` | 下载二进制包 |
Console 管理面提供同一 Registry 的列表、发布和下载操作。Registry 元数据不接受
客户端填写,服务端以二进制包解析结果为准。
## 当前边界
- 依赖版本必须是精确 SemVer,不解析版本范围。
- Registry 使用本地文件系统持久化,默认目录为 `var/wasmeld/wit-packages`
- Registry 不保存 Git 仓库或源码;Git tag 与 commit 由源码仓库管理。
- 本地 `replace` 只支持目录,不支持 Git replace。
- 暂不包含认证、权限、废弃标记和垃圾回收。
-17
View File
@@ -1,17 +0,0 @@
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;
}