Compare commits
5 Commits
a5718da1da
...
4e32a1246b
| Author | SHA1 | Date | |
|---|---|---|---|
| 4e32a1246b | |||
| 223ead37c8 | |||
| aea42ba0a6 | |||
| 73d7c76446 | |||
| a1b808013a |
@@ -1,4 +1,12 @@
|
|||||||
|
//! Example service that imports the versioned Wasmeld monotonic-clock capability.
|
||||||
|
//!
|
||||||
|
//! Each response is eight little-endian timestamp bytes followed by the input.
|
||||||
|
//! The timestamp is Actor-local monotonic time, not a Unix timestamp.
|
||||||
|
|
||||||
mod bindings {
|
mod bindings {
|
||||||
|
// This macro generates Rust ABI adapters at compile time from the
|
||||||
|
// materialized local WIT graph. It does not make a remote protocol call or
|
||||||
|
// embed the WIT source files; the Component carries canonical ABI types.
|
||||||
wit_bindgen::generate!({
|
wit_bindgen::generate!({
|
||||||
path: "wit",
|
path: "wit",
|
||||||
world: "clock-probe-component",
|
world: "clock-probe-component",
|
||||||
|
|||||||
@@ -4,12 +4,12 @@ schema_version = 1
|
|||||||
name = "wasmeld:clock"
|
name = "wasmeld:clock"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
source = "path+../../wit/clock"
|
source = "path+../../wit/clock"
|
||||||
sha256 = "93079c69d1b9cb3afc28da6fe4a7e37f724d0a09e780c847f8524d3920a90960"
|
sha256 = "43ece485a550eca94cf06cb711f0b42c9f4977d275e9a042c3ac84eeb1f941e1"
|
||||||
replaced = true
|
replaced = true
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wasmeld:service"
|
name = "wasmeld:service"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
source = "path+../../wit/service"
|
source = "path+../../wit/service"
|
||||||
sha256 = "d5497307bbcd1e159f7707f385b488a6f2e26362d5256d5bc1080ac603a51305"
|
sha256 = "1b2069606ccbf5202789667570bc824f702f34b147aa38e81c2ab677444ffa0e"
|
||||||
replaced = true
|
replaced = true
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
package component:clock-probe@0.1.0;
|
package component:clock-probe@0.1.0;
|
||||||
|
|
||||||
|
/// Base service exports plus the exact monotonic-clock Host capability used by
|
||||||
|
/// this Component. Other Wasmeld capabilities are not linked implicitly.
|
||||||
world clock-probe-component {
|
world clock-probe-component {
|
||||||
include wasmeld:service/service-component@0.1.0;
|
include wasmeld:service/service-component@0.1.0;
|
||||||
import wasmeld:clock/monotonic-clock@0.1.0;
|
import wasmeld:clock/monotonic-clock@0.1.0;
|
||||||
|
|||||||
@@ -1,6 +1,14 @@
|
|||||||
|
//! Minimal stateful Component used to demonstrate a warm Actor Store.
|
||||||
|
//!
|
||||||
|
//! Invoke returns an eight-byte little-endian counter. The value survives
|
||||||
|
//! calls to the same Actor but resets on `init`, restart, or process recovery.
|
||||||
|
|
||||||
use core::sync::atomic::{AtomicU64, Ordering};
|
use core::sync::atomic::{AtomicU64, Ordering};
|
||||||
|
|
||||||
mod bindings {
|
mod bindings {
|
||||||
|
// Bindings are generated from `wit/world.wit` plus materialized
|
||||||
|
// `wit/deps`; WIT is a compile-time ABI contract, not downloaded at run
|
||||||
|
// time and not executed as code.
|
||||||
wit_bindgen::generate!({
|
wit_bindgen::generate!({
|
||||||
path: "wit",
|
path: "wit",
|
||||||
world: "counter-component",
|
world: "counter-component",
|
||||||
|
|||||||
@@ -4,5 +4,5 @@ schema_version = 1
|
|||||||
name = "wasmeld:service"
|
name = "wasmeld:service"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
source = "path+../../wit/service"
|
source = "path+../../wit/service"
|
||||||
sha256 = "d5497307bbcd1e159f7707f385b488a6f2e26362d5256d5bc1080ac603a51305"
|
sha256 = "1b2069606ccbf5202789667570bc824f702f34b147aa38e81c2ab677444ffa0e"
|
||||||
replaced = true
|
replaced = true
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package component:counter@0.1.0;
|
package component:counter@0.1.0;
|
||||||
|
|
||||||
|
/// Counter only needs the base service lifecycle and imports no Host capability.
|
||||||
world counter-component {
|
world counter-component {
|
||||||
include wasmeld:service/service-component@0.1.0;
|
include wasmeld:service/service-component@0.1.0;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
|
//! Smallest request/response Component example: it returns input bytes unchanged.
|
||||||
|
|
||||||
mod bindings {
|
mod bindings {
|
||||||
|
// Generate the guest trait and export adapter from the selected WIT world.
|
||||||
|
// Dependency sync must populate `wit/deps` before this macro is compiled.
|
||||||
wit_bindgen::generate!({
|
wit_bindgen::generate!({
|
||||||
path: "wit",
|
path: "wit",
|
||||||
world: "echo-component",
|
world: "echo-component",
|
||||||
|
|||||||
@@ -4,5 +4,5 @@ schema_version = 1
|
|||||||
name = "wasmeld:service"
|
name = "wasmeld:service"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
source = "path+../../wit/service"
|
source = "path+../../wit/service"
|
||||||
sha256 = "d5497307bbcd1e159f7707f385b488a6f2e26362d5256d5bc1080ac603a51305"
|
sha256 = "1b2069606ccbf5202789667570bc824f702f34b147aa38e81c2ab677444ffa0e"
|
||||||
replaced = true
|
replaced = true
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package component:echo@0.1.0;
|
package component:echo@0.1.0;
|
||||||
|
|
||||||
|
/// Echo only needs the base service lifecycle and imports no Host capability.
|
||||||
world echo-component {
|
world echo-component {
|
||||||
include wasmeld:service/service-component@0.1.0;
|
include wasmeld:service/service-component@0.1.0;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,10 @@
|
|||||||
|
//! Deliberately trapping Component used to verify Actor fault isolation.
|
||||||
|
//!
|
||||||
|
//! Sending the exact bytes `fault` triggers a panic. This crate is a test
|
||||||
|
//! fixture and must not be deployed as an application service.
|
||||||
|
|
||||||
mod bindings {
|
mod bindings {
|
||||||
|
// Compile the versioned WIT world into canonical ABI Rust bindings.
|
||||||
wit_bindgen::generate!({
|
wit_bindgen::generate!({
|
||||||
path: "wit",
|
path: "wit",
|
||||||
world: "fault-component",
|
world: "fault-component",
|
||||||
@@ -13,6 +19,8 @@ impl bindings::Guest for Fault {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn invoke(input: Vec<u8>) -> Result<Vec<u8>, bindings::ServiceError> {
|
fn invoke(input: Vec<u8>) -> Result<Vec<u8>, bindings::ServiceError> {
|
||||||
|
// A panic becomes a Wasm trap so Runtime tests can assert that one
|
||||||
|
// faulted Actor does not corrupt other service instances.
|
||||||
assert_ne!(input, b"fault", "intentional test fault");
|
assert_ne!(input, b"fault", "intentional test fault");
|
||||||
Ok(input)
|
Ok(input)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,5 +4,5 @@ schema_version = 1
|
|||||||
name = "wasmeld:service"
|
name = "wasmeld:service"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
source = "path+../../wit/service"
|
source = "path+../../wit/service"
|
||||||
sha256 = "d5497307bbcd1e159f7707f385b488a6f2e26362d5256d5bc1080ac603a51305"
|
sha256 = "1b2069606ccbf5202789667570bc824f702f34b147aa38e81c2ab677444ffa0e"
|
||||||
replaced = true
|
replaced = true
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package component:fault@0.1.0;
|
package component:fault@0.1.0;
|
||||||
|
|
||||||
|
/// Fault fixture exports only the base service lifecycle.
|
||||||
world fault-component {
|
world fault-component {
|
||||||
include wasmeld:service/service-component@0.1.0;
|
include wasmeld:service/service-component@0.1.0;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,12 @@
|
|||||||
|
//! Example service that exercises persistent, service-scoped Host KV.
|
||||||
|
//!
|
||||||
|
//! Input uses the test protocol `get:<key>`, `set:<key>:<value>`, or
|
||||||
|
//! `delete:<key>`. Production Components should define an application-specific
|
||||||
|
//! request format instead of copying this colon-delimited probe protocol.
|
||||||
|
|
||||||
mod bindings {
|
mod bindings {
|
||||||
|
// `generate_all` emits both the service export and imported KV module from
|
||||||
|
// the exact versions selected in `wit/world.wit`.
|
||||||
wit_bindgen::generate!({
|
wit_bindgen::generate!({
|
||||||
path: "wit",
|
path: "wit",
|
||||||
world: "kv-probe-component",
|
world: "kv-probe-component",
|
||||||
|
|||||||
@@ -4,12 +4,12 @@ schema_version = 1
|
|||||||
name = "wasmeld:kv"
|
name = "wasmeld:kv"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
source = "path+../../wit/kv"
|
source = "path+../../wit/kv"
|
||||||
sha256 = "6dc3a84dc03c7104aa8b305518466c95e4440e1031c4bef3b59843f40f24d0fb"
|
sha256 = "10dc81b1f04bd9c8f7b324a96e0c1d24d5af0c2dabfc723d003b8729cfd933d9"
|
||||||
replaced = true
|
replaced = true
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wasmeld:service"
|
name = "wasmeld:service"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
source = "path+../../wit/service"
|
source = "path+../../wit/service"
|
||||||
sha256 = "d5497307bbcd1e159f7707f385b488a6f2e26362d5256d5bc1080ac603a51305"
|
sha256 = "1b2069606ccbf5202789667570bc824f702f34b147aa38e81c2ab677444ffa0e"
|
||||||
replaced = true
|
replaced = true
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
package component:kv-probe@0.1.0;
|
package component:kv-probe@0.1.0;
|
||||||
|
|
||||||
|
/// Base service exports plus the exact persistent KV Host capability used by
|
||||||
|
/// this Component.
|
||||||
world kv-probe-component {
|
world kv-probe-component {
|
||||||
include wasmeld:service/service-component@0.1.0;
|
include wasmeld:service/service-component@0.1.0;
|
||||||
import wasmeld:kv/store@0.1.0;
|
import wasmeld:kv/store@0.1.0;
|
||||||
|
|||||||
@@ -1,6 +1,14 @@
|
|||||||
|
//! Resident event/effect fixture covering every current Host-driven I/O family.
|
||||||
|
//!
|
||||||
|
//! The callback echoes stream chunks and datagrams, rearms fired timers,
|
||||||
|
//! acknowledges messages, and reflects extension commands. It owns no socket,
|
||||||
|
//! timer, or subscription handles; those resources remain in the Host driver.
|
||||||
|
|
||||||
use std::sync::atomic::{AtomicU64, Ordering};
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
|
|
||||||
mod bindings {
|
mod bindings {
|
||||||
|
// One generated module contains both the base service export and resident
|
||||||
|
// actor export because this component world composes both WIT packages.
|
||||||
wit_bindgen::generate!({
|
wit_bindgen::generate!({
|
||||||
path: "wit",
|
path: "wit",
|
||||||
world: "resident-probe-component",
|
world: "resident-probe-component",
|
||||||
@@ -35,6 +43,8 @@ impl ResidentGuest for ResidentProbe {
|
|||||||
fn handle_event(
|
fn handle_event(
|
||||||
input: Event,
|
input: Event,
|
||||||
) -> Result<Vec<Effect>, bindings::exports::wasmeld::resident::actor::ResidentError> {
|
) -> Result<Vec<Effect>, bindings::exports::wasmeld::resident::actor::ResidentError> {
|
||||||
|
// Event callbacks are serialized with normal invocations by the same
|
||||||
|
// Actor mailbox, so this in-memory count is deterministic per Actor.
|
||||||
EVENTS_HANDLED.fetch_add(1, Ordering::Relaxed);
|
EVENTS_HANDLED.fetch_add(1, Ordering::Relaxed);
|
||||||
Ok(match input {
|
Ok(match input {
|
||||||
Event::StreamData(chunk) => vec![Effect::WriteStream(StreamWrite {
|
Event::StreamData(chunk) => vec![Effect::WriteStream(StreamWrite {
|
||||||
|
|||||||
@@ -4,12 +4,12 @@ schema_version = 1
|
|||||||
name = "wasmeld:resident"
|
name = "wasmeld:resident"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
source = "path+../../wit/resident"
|
source = "path+../../wit/resident"
|
||||||
sha256 = "683a3c7196a6dd77b3f619227aa093430ff6995c097d39bb82d501b7c59a196c"
|
sha256 = "a13728b0e6f000c03eb0f88e2e54bad1c6e5160ac3ca4b9e87619247c0043d1b"
|
||||||
replaced = true
|
replaced = true
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wasmeld:service"
|
name = "wasmeld:service"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
source = "path+../../wit/service"
|
source = "path+../../wit/service"
|
||||||
sha256 = "d5497307bbcd1e159f7707f385b488a6f2e26362d5256d5bc1080ac603a51305"
|
sha256 = "1b2069606ccbf5202789667570bc824f702f34b147aa38e81c2ab677444ffa0e"
|
||||||
replaced = true
|
replaced = true
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
package component:resident-probe@0.1.0;
|
package component:resident-probe@0.1.0;
|
||||||
|
|
||||||
|
/// Composes ordinary request/response exports with the optional resident actor
|
||||||
|
/// export. Network and timer drivers remain Host-owned.
|
||||||
world resident-probe-component {
|
world resident-probe-component {
|
||||||
include wasmeld:service/service-component@0.1.0;
|
include wasmeld:service/service-component@0.1.0;
|
||||||
export wasmeld:resident/actor@0.1.0;
|
export wasmeld:resident/actor@0.1.0;
|
||||||
|
|||||||
@@ -1,4 +1,11 @@
|
|||||||
|
//! CPU-bound fixture used to verify fuel and epoch deadline interruption.
|
||||||
|
//!
|
||||||
|
//! The input's first eight bytes select a little-endian iteration count.
|
||||||
|
//! Initializing with `spin` intentionally runs without a finite loop bound.
|
||||||
|
//! This crate is a Runtime test fixture, not an application service.
|
||||||
|
|
||||||
mod bindings {
|
mod bindings {
|
||||||
|
// Compile the base service WIT contract into guest and export adapters.
|
||||||
wit_bindgen::generate!({
|
wit_bindgen::generate!({
|
||||||
path: "wit",
|
path: "wit",
|
||||||
world: "spin-component",
|
world: "spin-component",
|
||||||
|
|||||||
@@ -4,5 +4,5 @@ schema_version = 1
|
|||||||
name = "wasmeld:service"
|
name = "wasmeld:service"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
source = "path+../../wit/service"
|
source = "path+../../wit/service"
|
||||||
sha256 = "d5497307bbcd1e159f7707f385b488a6f2e26362d5256d5bc1080ac603a51305"
|
sha256 = "1b2069606ccbf5202789667570bc824f702f34b147aa38e81c2ab677444ffa0e"
|
||||||
replaced = true
|
replaced = true
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package component:spin@0.1.0;
|
package component:spin@0.1.0;
|
||||||
|
|
||||||
|
/// Spin fixture exports only the base service lifecycle.
|
||||||
world spin-component {
|
world spin-component {
|
||||||
include wasmeld:service/service-component@0.1.0;
|
include wasmeld:service/service-component@0.1.0;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,12 @@
|
|||||||
|
//! Fixture proving that ambient WASI clock imports are denied by the sandbox.
|
||||||
|
//!
|
||||||
|
//! Calling `std::time::Instant::now` causes this Component to import WASI
|
||||||
|
//! monotonic-clock directly. Wasmeld only links explicitly approved capability
|
||||||
|
//! packages, so registration or startup must reject this fixture.
|
||||||
|
|
||||||
mod bindings {
|
mod bindings {
|
||||||
|
// The declared service WIT contains no WASI clock capability; the direct
|
||||||
|
// standard-library use below deliberately creates an undeclared import.
|
||||||
wit_bindgen::generate!({
|
wit_bindgen::generate!({
|
||||||
path: "wit",
|
path: "wit",
|
||||||
world: "wasi-clock-probe-component",
|
world: "wasi-clock-probe-component",
|
||||||
|
|||||||
@@ -4,5 +4,5 @@ schema_version = 1
|
|||||||
name = "wasmeld:service"
|
name = "wasmeld:service"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
source = "path+../../wit/service"
|
source = "path+../../wit/service"
|
||||||
sha256 = "d5497307bbcd1e159f7707f385b488a6f2e26362d5256d5bc1080ac603a51305"
|
sha256 = "1b2069606ccbf5202789667570bc824f702f34b147aa38e81c2ab677444ffa0e"
|
||||||
replaced = true
|
replaced = true
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
package component:wasi-clock-probe@0.1.0;
|
package component:wasi-clock-probe@0.1.0;
|
||||||
|
|
||||||
|
/// The declared world grants only the base service contract. Any direct WASI
|
||||||
|
/// clock import introduced by implementation code remains undeclared.
|
||||||
world wasi-clock-probe-component {
|
world wasi-clock-probe-component {
|
||||||
include wasmeld:service/service-component@0.1.0;
|
include wasmeld:service/service-component@0.1.0;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,12 @@
|
|||||||
|
/**
|
||||||
|
* Typed management-plane client for `wasmeld-console`.
|
||||||
|
*
|
||||||
|
* These DTOs intentionally preserve the Rust API's snake_case fields; view
|
||||||
|
* formatting belongs in `console-model.ts`. Management invocation bytes use
|
||||||
|
* base64 inside JSON for inspectability. Public clients should call the
|
||||||
|
* gateway's `application/octet-stream` endpoint instead.
|
||||||
|
*/
|
||||||
|
|
||||||
export const DEFAULT_API_BASE = import.meta.env.VITE_WASMELD_API_URL ?? "http://127.0.0.1:8080";
|
export const DEFAULT_API_BASE = import.meta.env.VITE_WASMELD_API_URL ?? "http://127.0.0.1:8080";
|
||||||
|
|
||||||
const API_BASE_STORAGE_KEY = "wasmeld-api-base";
|
const API_BASE_STORAGE_KEY = "wasmeld-api-base";
|
||||||
@@ -80,6 +89,8 @@ export type InvokeResult = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export function getApiBase(): string {
|
export function getApiBase(): string {
|
||||||
|
// TanStack Start can evaluate modules during SSR, where localStorage does
|
||||||
|
// not exist. The environment default keeps server rendering deterministic.
|
||||||
if (typeof window === "undefined") return DEFAULT_API_BASE;
|
if (typeof window === "undefined") return DEFAULT_API_BASE;
|
||||||
return window.localStorage.getItem(API_BASE_STORAGE_KEY) ?? DEFAULT_API_BASE;
|
return window.localStorage.getItem(API_BASE_STORAGE_KEY) ?? DEFAULT_API_BASE;
|
||||||
}
|
}
|
||||||
@@ -95,6 +106,9 @@ export function saveApiBase(value: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchSnapshot(apiBase: string) {
|
export async function fetchSnapshot(apiBase: string) {
|
||||||
|
// Parallel reads minimize refresh latency but are not a database
|
||||||
|
// transaction. A deployment can change between responses; the five-second
|
||||||
|
// poll in the route reconciles that short-lived inconsistency.
|
||||||
const [services, deployments, events, runtime, witPackages] = await Promise.all([
|
const [services, deployments, events, runtime, witPackages] = await Promise.all([
|
||||||
request<{ services: BackendService[] }>(apiBase, "/api/v1/services"),
|
request<{ services: BackendService[] }>(apiBase, "/api/v1/services"),
|
||||||
request<{ deployments: BackendDeployment[] }>(apiBase, "/api/v1/deployments"),
|
request<{ deployments: BackendDeployment[] }>(apiBase, "/api/v1/deployments"),
|
||||||
@@ -181,6 +195,9 @@ export async function invokeComponent(
|
|||||||
service: Pick<BackendService, "id" | "revision">,
|
service: Pick<BackendService, "id" | "revision">,
|
||||||
input: Uint8Array,
|
input: Uint8Array,
|
||||||
): Promise<InvokeResult> {
|
): Promise<InvokeResult> {
|
||||||
|
// Example: `invokeComponent(base, service, new TextEncoder().encode("ping"))`.
|
||||||
|
// The management endpoint targets an exact revision and is intended for the
|
||||||
|
// operator console, not public application traffic.
|
||||||
const response = await request<{
|
const response = await request<{
|
||||||
output_base64: string;
|
output_base64: string;
|
||||||
output_bytes: number;
|
output_bytes: number;
|
||||||
@@ -216,6 +233,9 @@ async function request<T>(apiBase: string, path: string, init?: RequestInit): Pr
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
|
// Both management and gateway errors use `{ error: { code, message } }`.
|
||||||
|
// The UI displays only the server-safe message and falls back to status
|
||||||
|
// when a proxy returns HTML or an otherwise unrelated response.
|
||||||
const body = (await response.json().catch(() => null)) as {
|
const body = (await response.json().catch(() => null)) as {
|
||||||
error?: { message?: string };
|
error?: { message?: string };
|
||||||
} | null;
|
} | null;
|
||||||
@@ -225,6 +245,8 @@ async function request<T>(apiBase: string, path: string, init?: RequestInit): Pr
|
|||||||
}
|
}
|
||||||
|
|
||||||
function bytesToBase64(bytes: Uint8Array): string {
|
function bytesToBase64(bytes: Uint8Array): string {
|
||||||
|
// Invocation input is bounded by the Runtime, so constructing this binary
|
||||||
|
// string cannot grow beyond the configured management request limit.
|
||||||
let binary = "";
|
let binary = "";
|
||||||
for (const byte of bytes) binary += String.fromCharCode(byte);
|
for (const byte of bytes) binary += String.fromCharCode(byte);
|
||||||
return window.btoa(binary);
|
return window.btoa(binary);
|
||||||
|
|||||||
@@ -1,3 +1,11 @@
|
|||||||
|
/**
|
||||||
|
* Pure conversion and presentation helpers for the management UI.
|
||||||
|
*
|
||||||
|
* No function in this module performs I/O or changes backend state. Keeping
|
||||||
|
* byte decoding here prevents React views from silently treating arbitrary
|
||||||
|
* Component output as readable text.
|
||||||
|
*/
|
||||||
|
|
||||||
import { FileCode2, History, LayoutDashboard, Package, Server, Settings } from "lucide-react";
|
import { FileCode2, History, LayoutDashboard, Package, Server, Settings } from "lucide-react";
|
||||||
import { BackendCapability, BackendEvent, BackendService } from "./api";
|
import { BackendCapability, BackendEvent, BackendService } from "./api";
|
||||||
|
|
||||||
@@ -160,6 +168,9 @@ export function decodeReadableUtf8(bytes: Uint8Array) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Valid UTF-8 can still contain terminal controls. Reject them so the output
|
||||||
|
// panel cannot render invisible or misleading content; tab/newline/CR remain
|
||||||
|
// useful for ordinary textual responses.
|
||||||
for (const character of text) {
|
for (const character of text) {
|
||||||
const codePoint = character.codePointAt(0) ?? 0;
|
const codePoint = character.codePointAt(0) ?? 0;
|
||||||
const allowedWhitespace = codePoint === 0x09 || codePoint === 0x0a || codePoint === 0x0d;
|
const allowedWhitespace = codePoint === 0x09 || codePoint === 0x0a || codePoint === 0x0d;
|
||||||
@@ -175,6 +186,9 @@ export function formatInvocationOutput(
|
|||||||
output: Uint8Array,
|
output: Uint8Array,
|
||||||
format: "utf8" | "hex",
|
format: "utf8" | "hex",
|
||||||
) {
|
) {
|
||||||
|
// Counter is the one built-in example with a documented numeric wire
|
||||||
|
// format. Other eight-byte services remain generic bytes and are not
|
||||||
|
// guessed as integers.
|
||||||
if (service.id === "counter" && output.byteLength === 8) {
|
if (service.id === "counter" && output.byteLength === 8) {
|
||||||
return {
|
return {
|
||||||
text: new DataView(output.buffer, output.byteOffset, output.byteLength)
|
text: new DataView(output.buffer, output.byteOffset, output.byteLength)
|
||||||
@@ -192,6 +206,8 @@ export function formatInvocationOutput(
|
|||||||
} satisfies FormattedInvocationOutput;
|
} satisfies FormattedInvocationOutput;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UTF-8 decoding is fatal rather than replacement-based. If one byte is
|
||||||
|
// invalid or contains a disallowed control, show the exact bytes as HEX.
|
||||||
const text = decodeReadableUtf8(output);
|
const text = decodeReadableUtf8(output);
|
||||||
if (text !== null) {
|
if (text !== null) {
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -119,6 +119,9 @@ function Home() {
|
|||||||
setEvents(snapshot.events.map(toEvent));
|
setEvents(snapshot.events.map(toEvent));
|
||||||
setRuntime(snapshot.runtime);
|
setRuntime(snapshot.runtime);
|
||||||
setWitPackages(snapshot.witPackages);
|
setWitPackages(snapshot.witPackages);
|
||||||
|
// Preserve operator context when possible. Registrations and cleanup can
|
||||||
|
// remove the selected revision between polls, in which case the first
|
||||||
|
// remaining service becomes the deterministic fallback.
|
||||||
setSelectedKey((current) =>
|
setSelectedKey((current) =>
|
||||||
nextServices.some((service) => serviceKey(service) === current)
|
nextServices.some((service) => serviceKey(service) === current)
|
||||||
? current
|
? current
|
||||||
@@ -128,6 +131,9 @@ function Home() {
|
|||||||
);
|
);
|
||||||
setConnection("online");
|
setConnection("online");
|
||||||
} catch {
|
} catch {
|
||||||
|
// Clear the complete snapshot on any request failure. Mixing stale
|
||||||
|
// services with live deployments or Runtime state would present actions
|
||||||
|
// against identities that may no longer exist.
|
||||||
setServices([]);
|
setServices([]);
|
||||||
setDeployments([]);
|
setDeployments([]);
|
||||||
setEvents([]);
|
setEvents([]);
|
||||||
|
|||||||
@@ -1,4 +1,14 @@
|
|||||||
//! Axum management and public gateway protocol adapters.
|
//! Axum management and public gateway protocol adapters.
|
||||||
|
//!
|
||||||
|
//! The management router accepts JSON and multipart packages and exposes
|
||||||
|
//! lifecycle operations. The gateway router is intentionally smaller: it
|
||||||
|
//! accepts raw `application/octet-stream` input and returns raw bytes plus
|
||||||
|
//! `x-wasmeld-revision` and `x-wasmeld-latency-ms` response headers.
|
||||||
|
//!
|
||||||
|
//! Neither router installs authentication or authorization middleware. A
|
||||||
|
//! production deployment must place the appropriate identity, authorization,
|
||||||
|
//! TLS, rate-limit, and request-size policy in front of these routers. CORS is
|
||||||
|
//! a browser access policy and must not be treated as authentication.
|
||||||
|
|
||||||
use super::*;
|
use super::*;
|
||||||
use axum::{
|
use axum::{
|
||||||
@@ -21,6 +31,11 @@ use wasmeld_package::wit_package::WitPackageError;
|
|||||||
///
|
///
|
||||||
/// The returned router exposes Runtime control, component registration,
|
/// The returned router exposes Runtime control, component registration,
|
||||||
/// invocation, events, and WIT Registry endpoints.
|
/// invocation, events, and WIT Registry endpoints.
|
||||||
|
///
|
||||||
|
/// `allowed_origins` only affects browser CORS responses. Passing an empty
|
||||||
|
/// vector does not make the API private; it merely omits allowed origins.
|
||||||
|
/// Multipart bodies receive one MiB of framing headroom above the larger
|
||||||
|
/// configured artifact limit.
|
||||||
pub fn app(console: Arc<Console>, allowed_origins: Vec<HeaderValue>) -> Router {
|
pub fn app(console: Arc<Console>, allowed_origins: Vec<HeaderValue>) -> Router {
|
||||||
let max_body_bytes = console
|
let max_body_bytes = console
|
||||||
.max_artifact_bytes()
|
.max_artifact_bytes()
|
||||||
@@ -89,7 +104,18 @@ pub fn app(console: Arc<Console>, allowed_origins: Vec<HeaderValue>) -> Router {
|
|||||||
/// Builds the public data-plane API around the same resident Runtime.
|
/// Builds the public data-plane API around the same resident Runtime.
|
||||||
///
|
///
|
||||||
/// This router intentionally contains no registration, lifecycle, Registry,
|
/// This router intentionally contains no registration, lifecycle, Registry,
|
||||||
/// or deployment management routes.
|
/// or deployment management routes. Callers must send
|
||||||
|
/// `Content-Type: application/octet-stream`; the stable service ID is resolved
|
||||||
|
/// to its active immutable revision at the start of each request.
|
||||||
|
///
|
||||||
|
/// # Wire example
|
||||||
|
///
|
||||||
|
/// ```text
|
||||||
|
/// POST /v1/services/image-resize/invoke
|
||||||
|
/// Content-Type: application/octet-stream
|
||||||
|
///
|
||||||
|
/// <component-specific bytes>
|
||||||
|
/// ```
|
||||||
pub fn gateway_app(console: Arc<Console>) -> Router {
|
pub fn gateway_app(console: Arc<Console>) -> Router {
|
||||||
let max_input_bytes = console.max_gateway_input_bytes();
|
let max_input_bytes = console.max_gateway_input_bytes();
|
||||||
Router::new()
|
Router::new()
|
||||||
@@ -126,6 +152,9 @@ async fn gateway_invoke(
|
|||||||
let input = body.map_err(|_| GatewayError::PayloadTooLarge {
|
let input = body.map_err(|_| GatewayError::PayloadTooLarge {
|
||||||
limit: console.max_gateway_input_bytes(),
|
limit: console.max_gateway_input_bytes(),
|
||||||
})?;
|
})?;
|
||||||
|
// Deployment resolution and Actor enqueue happen in the same blocking
|
||||||
|
// closure. The returned revision header therefore identifies the exact
|
||||||
|
// revision that received this request, even if deployment changes later.
|
||||||
let invocation = run_blocking(console, move |console| {
|
let invocation = run_blocking(console, move |console| {
|
||||||
console.gateway_invoke(&service_id, input.to_vec())
|
console.gateway_invoke(&service_id, input.to_vec())
|
||||||
})
|
})
|
||||||
@@ -262,6 +291,9 @@ async fn publish_wit_package(
|
|||||||
State(console): State<Arc<Console>>,
|
State(console): State<Arc<Console>>,
|
||||||
mut multipart: Multipart,
|
mut multipart: Multipart,
|
||||||
) -> Result<(StatusCode, Json<WitPackageMetadata>), ApiError> {
|
) -> Result<(StatusCode, Json<WitPackageMetadata>), ApiError> {
|
||||||
|
// Axum materializes this one field in memory. DefaultBodyLimit above is
|
||||||
|
// only a transport guard; WitRegistry repeats the exact artifact limit and
|
||||||
|
// validates the package identity before committing it to disk.
|
||||||
let mut package = None;
|
let mut package = None;
|
||||||
while let Some(field) = multipart
|
while let Some(field) = multipart
|
||||||
.next_field()
|
.next_field()
|
||||||
@@ -453,6 +485,9 @@ where
|
|||||||
// an operation may have changed state before returning its error.
|
// an operation may have changed state before returning its error.
|
||||||
let operation_console = Arc::clone(&console);
|
let operation_console = Arc::clone(&console);
|
||||||
let result = run_blocking(operation_console, operation).await;
|
let result = run_blocking(operation_console, operation).await;
|
||||||
|
// The flush marker orders previously accepted metric deltas before the
|
||||||
|
// full snapshot transaction. It means "attempted", not "durably written";
|
||||||
|
// the telemetry worker logs write failures independently.
|
||||||
console.flush_invocation_telemetry().await;
|
console.flush_invocation_telemetry().await;
|
||||||
let persisted = console.persist_snapshot().await;
|
let persisted = console.persist_snapshot().await;
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,10 @@
|
|||||||
//! Bounded, asynchronous persistence for data-plane invocation telemetry.
|
//! Bounded, asynchronous persistence for data-plane invocation telemetry.
|
||||||
|
//!
|
||||||
|
//! Invocation responses must not wait for libSQL, so producers use
|
||||||
|
//! [`tokio::sync::mpsc::Sender::try_send`] and one task writes ordered batches.
|
||||||
|
//! When the queue is full or closed, the update is deliberately dropped and a
|
||||||
|
//! warning is emitted. These counters and events are operational telemetry;
|
||||||
|
//! they are not suitable as a billing ledger or audit log.
|
||||||
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
@@ -10,6 +16,10 @@ const QUEUE_CAPACITY: usize = 1024;
|
|||||||
const MAX_BATCH_SIZE: usize = 64;
|
const MAX_BATCH_SIZE: usize = 64;
|
||||||
|
|
||||||
/// Non-blocking producer used by Wasmtime invocation threads.
|
/// Non-blocking producer used by Wasmtime invocation threads.
|
||||||
|
///
|
||||||
|
/// All producers share one FIFO queue. Queue order is preserved for accepted
|
||||||
|
/// records, but a process crash can lose records that have not yet reached
|
||||||
|
/// libSQL.
|
||||||
pub(crate) struct InvocationPersistence {
|
pub(crate) struct InvocationPersistence {
|
||||||
sender: mpsc::Sender<Command>,
|
sender: mpsc::Sender<Command>,
|
||||||
}
|
}
|
||||||
@@ -21,6 +31,10 @@ enum Command {
|
|||||||
|
|
||||||
impl InvocationPersistence {
|
impl InvocationPersistence {
|
||||||
/// Starts the single ordered writer on the current Tokio runtime.
|
/// Starts the single ordered writer on the current Tokio runtime.
|
||||||
|
///
|
||||||
|
/// `sync` is shared with full Console snapshot persistence. Taking it
|
||||||
|
/// around each batch prevents a snapshot transaction from racing a metric
|
||||||
|
/// delta transaction.
|
||||||
pub(crate) fn start(persistence: Persistence, sync: Arc<Mutex<()>>) -> Self {
|
pub(crate) fn start(persistence: Persistence, sync: Arc<Mutex<()>>) -> Self {
|
||||||
let (sender, mut receiver) = mpsc::channel(QUEUE_CAPACITY);
|
let (sender, mut receiver) = mpsc::channel(QUEUE_CAPACITY);
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
@@ -45,6 +59,10 @@ impl InvocationPersistence {
|
|||||||
tracing::error!(%error, "failed to persist invocation telemetry");
|
tracing::error!(%error, "failed to persist invocation telemetry");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Flush is an ordering barrier, not a durability receipt. A
|
||||||
|
// database error above is logged rather than returned, and a
|
||||||
|
// flush may wait for records dequeued after the marker when
|
||||||
|
// those records fit in the same batch.
|
||||||
for flush in flushes {
|
for flush in flushes {
|
||||||
let _ = flush.send(());
|
let _ = flush.send(());
|
||||||
}
|
}
|
||||||
@@ -54,6 +72,9 @@ impl InvocationPersistence {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Enqueues telemetry without extending API response latency.
|
/// Enqueues telemetry without extending API response latency.
|
||||||
|
///
|
||||||
|
/// Saturation drops this update instead of applying backpressure to the
|
||||||
|
/// Component invocation path.
|
||||||
pub(crate) fn record(&self, update: InvocationUpdate) {
|
pub(crate) fn record(&self, update: InvocationUpdate) {
|
||||||
if let Err(error) = self.sender.try_send(Command::Record(update)) {
|
if let Err(error) = self.sender.try_send(Command::Record(update)) {
|
||||||
tracing::warn!(%error, "invocation telemetry queue is unavailable; update dropped");
|
tracing::warn!(%error, "invocation telemetry queue is unavailable; update dropped");
|
||||||
@@ -61,6 +82,10 @@ impl InvocationPersistence {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Waits until all telemetry queued before this call has been attempted.
|
/// Waits until all telemetry queued before this call has been attempted.
|
||||||
|
///
|
||||||
|
/// This method is used before a control-plane snapshot so the snapshot
|
||||||
|
/// reads the newest in-memory counters after older delta writes. It does
|
||||||
|
/// not report persistence failures; those are visible through tracing.
|
||||||
pub(crate) async fn flush(&self) {
|
pub(crate) async fn flush(&self) {
|
||||||
let (sender, receiver) = oneshot::channel();
|
let (sender, receiver) = oneshot::channel();
|
||||||
if self.sender.send(Command::Flush(sender)).await.is_ok() {
|
if self.sender.send(Command::Flush(sender)).await.is_ok() {
|
||||||
|
|||||||
@@ -1,4 +1,18 @@
|
|||||||
//! Bounded bridge between synchronous Component Host calls and async Toasty.
|
//! Bounded bridge between synchronous Component Host calls and async Toasty.
|
||||||
|
//!
|
||||||
|
//! Wasmtime calls [`wasmeld_runtime::KvBackend`] synchronously on an Actor
|
||||||
|
//! thread, while Toasty and libSQL are asynchronous. This adapter moves every
|
||||||
|
//! database operation onto one dedicated current-thread Tokio runtime. The
|
||||||
|
//! bounded channel prevents a slow database from accumulating unbounded work
|
||||||
|
//! or blocking every Actor while waiting for queue capacity.
|
||||||
|
//!
|
||||||
|
//! # Timeout semantics
|
||||||
|
//!
|
||||||
|
//! A timeout only stops the caller from waiting. It does not remove a command
|
||||||
|
//! that is already queued or cancel a libSQL operation that has started.
|
||||||
|
//! Consequently, `set` and `delete` may commit after the Component observes
|
||||||
|
//! `KvBackendError::Timeout`. Components should make retries idempotent and
|
||||||
|
//! must not interpret a timeout as proof that no mutation occurred.
|
||||||
|
|
||||||
use std::{
|
use std::{
|
||||||
fmt, io,
|
fmt, io,
|
||||||
@@ -15,6 +29,9 @@ use wasmeld_runtime::{KvBackend, KvBackendError};
|
|||||||
use crate::persistence::Persistence;
|
use crate::persistence::Persistence;
|
||||||
|
|
||||||
const COMMAND_CAPACITY: usize = 64;
|
const COMMAND_CAPACITY: usize = 64;
|
||||||
|
// Keep Host calls responsive even when a Component requests a larger timeout.
|
||||||
|
// The Component-facing deadline is an upper bound, not permission to occupy an
|
||||||
|
// Actor thread indefinitely.
|
||||||
const MAX_RESPONSE_TIMEOUT: Duration = Duration::from_millis(400);
|
const MAX_RESPONSE_TIMEOUT: Duration = Duration::from_millis(400);
|
||||||
|
|
||||||
pub(crate) struct ToastyKvBackend {
|
pub(crate) struct ToastyKvBackend {
|
||||||
@@ -47,6 +64,11 @@ enum KvCommand {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl ToastyKvBackend {
|
impl ToastyKvBackend {
|
||||||
|
/// Starts the database worker and waits until its Tokio runtime is ready.
|
||||||
|
///
|
||||||
|
/// Waiting for the ready signal makes construction fail deterministically
|
||||||
|
/// instead of returning a backend whose first operation discovers that
|
||||||
|
/// the worker could not initialize.
|
||||||
pub(crate) fn start(persistence: Persistence) -> io::Result<Self> {
|
pub(crate) fn start(persistence: Persistence) -> io::Result<Self> {
|
||||||
let (sender, receiver) = mpsc::sync_channel(COMMAND_CAPACITY);
|
let (sender, receiver) = mpsc::sync_channel(COMMAND_CAPACITY);
|
||||||
let (ready_sender, ready_receiver) = mpsc::sync_channel(1);
|
let (ready_sender, ready_receiver) = mpsc::sync_channel(1);
|
||||||
@@ -78,6 +100,8 @@ impl ToastyKvBackend {
|
|||||||
build: impl FnOnce(SyncSender<Result<T, KvBackendError>>) -> KvCommand,
|
build: impl FnOnce(SyncSender<Result<T, KvBackendError>>) -> KvCommand,
|
||||||
) -> Result<T, KvBackendError> {
|
) -> Result<T, KvBackendError> {
|
||||||
let (response, receiver) = mpsc::sync_channel(1);
|
let (response, receiver) = mpsc::sync_channel(1);
|
||||||
|
// `try_send` is intentional: waiting for queue capacity here would
|
||||||
|
// stall the Actor and defeat the Runtime's invocation deadline.
|
||||||
match self.inner.sender.try_send(build(response)) {
|
match self.inner.sender.try_send(build(response)) {
|
||||||
Ok(()) => {}
|
Ok(()) => {}
|
||||||
Err(TrySendError::Full(_)) => return Err(KvBackendError::Busy),
|
Err(TrySendError::Full(_)) => return Err(KvBackendError::Busy),
|
||||||
@@ -149,6 +173,9 @@ impl KvBackend for ToastyKvBackend {
|
|||||||
|
|
||||||
impl Drop for BackendInner {
|
impl Drop for BackendInner {
|
||||||
fn drop(&mut self) {
|
fn drop(&mut self) {
|
||||||
|
// Only the final Arc owner reaches this Drop. Joining guarantees the
|
||||||
|
// worker no longer holds the Persistence handle when Console shuts
|
||||||
|
// down. If commands precede Shutdown, the FIFO channel drains them.
|
||||||
let _ = self.sender.send(KvCommand::Shutdown);
|
let _ = self.sender.send(KvCommand::Shutdown);
|
||||||
if let Ok(worker) = self.worker.get_mut()
|
if let Ok(worker) = self.worker.get_mut()
|
||||||
&& let Some(worker) = worker.take()
|
&& let Some(worker) = worker.take()
|
||||||
@@ -163,6 +190,8 @@ fn run_worker(
|
|||||||
receiver: Receiver<KvCommand>,
|
receiver: Receiver<KvCommand>,
|
||||||
ready: SyncSender<io::Result<()>>,
|
ready: SyncSender<io::Result<()>>,
|
||||||
) {
|
) {
|
||||||
|
// Toasty is driven from one thread so synchronous Host calls never need to
|
||||||
|
// enter or block the Console server's multi-thread Tokio runtime.
|
||||||
let runtime = match tokio::runtime::Builder::new_current_thread()
|
let runtime = match tokio::runtime::Builder::new_current_thread()
|
||||||
.enable_all()
|
.enable_all()
|
||||||
.build()
|
.build()
|
||||||
@@ -175,6 +204,9 @@ fn run_worker(
|
|||||||
};
|
};
|
||||||
let _ = ready.send(Ok(()));
|
let _ = ready.send(Ok(()));
|
||||||
|
|
||||||
|
// A single consumer also defines the ordering of mutations submitted to
|
||||||
|
// this backend. Ordering across different Wasmeld processes is delegated
|
||||||
|
// to libSQL and is not guaranteed by this queue.
|
||||||
while let Ok(command) = receiver.recv() {
|
while let Ok(command) = receiver.recv() {
|
||||||
match command {
|
match command {
|
||||||
KvCommand::Get {
|
KvCommand::Get {
|
||||||
|
|||||||
@@ -5,6 +5,32 @@
|
|||||||
//! to libSQL through Toasty. It also hosts an immutable filesystem-backed WIT
|
//! to libSQL through Toasty. It also hosts an immutable filesystem-backed WIT
|
||||||
//! package Registry. Actor memory remains process-local and is never presented
|
//! package Registry. Actor memory remains process-local and is never presented
|
||||||
//! as durable state.
|
//! as durable state.
|
||||||
|
//!
|
||||||
|
//! # Ownership model
|
||||||
|
//!
|
||||||
|
//! * [`Console`] owns the in-process Runtime and serializes control-plane
|
||||||
|
//! mutations with a lifecycle mutex.
|
||||||
|
//! * libSQL stores desired service/deployment state and bounded telemetry.
|
||||||
|
//! * Component and WIT bytes are immutable filesystem artifacts addressed by
|
||||||
|
//! identity and digest.
|
||||||
|
//! * On restart, Actors are recreated from persisted manifests; their linear
|
||||||
|
//! memory is intentionally reset.
|
||||||
|
//!
|
||||||
|
//! # Example
|
||||||
|
//!
|
||||||
|
//! ```no_run
|
||||||
|
//! use wasmeld_console::{Console, ConsoleConfig};
|
||||||
|
//!
|
||||||
|
//! # async fn example() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
//! let console = Console::new(ConsoleConfig::default()).await?;
|
||||||
|
//! for service in console.services()? {
|
||||||
|
//! println!("{}@{}: {:?}", service.id, service.revision, service.status);
|
||||||
|
//! }
|
||||||
|
//! # Ok(())
|
||||||
|
//! # }
|
||||||
|
//! ```
|
||||||
|
|
||||||
|
#![warn(missing_docs)]
|
||||||
|
|
||||||
mod api;
|
mod api;
|
||||||
mod invocation_persistence;
|
mod invocation_persistence;
|
||||||
@@ -82,6 +108,10 @@ impl Default for ConsoleConfig {
|
|||||||
/// registered service metadata, artifacts, metrics, and events remain managed
|
/// registered service metadata, artifacts, metrics, and events remain managed
|
||||||
/// by the Console.
|
/// by the Console.
|
||||||
pub struct Console {
|
pub struct Console {
|
||||||
|
// Lock order for lifecycle mutations is `lifecycle` -> `runtime` ->
|
||||||
|
// `state`. Invocation does not take `lifecycle`; it briefly reads Runtime
|
||||||
|
// and then updates state after the Actor call. Keep new code on this order
|
||||||
|
// to avoid deadlocks between HTTP control operations.
|
||||||
runtime: RwLock<Option<Runtime>>,
|
runtime: RwLock<Option<Runtime>>,
|
||||||
runtime_config: RuntimeConfig,
|
runtime_config: RuntimeConfig,
|
||||||
artifact_dir: PathBuf,
|
artifact_dir: PathBuf,
|
||||||
@@ -124,26 +154,37 @@ struct DeploymentRecord {
|
|||||||
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
|
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
|
||||||
#[serde(rename_all = "snake_case")]
|
#[serde(rename_all = "snake_case")]
|
||||||
pub enum ServiceStatus {
|
pub enum ServiceStatus {
|
||||||
|
/// Actor is accepting invocations for this revision.
|
||||||
Running,
|
Running,
|
||||||
|
/// Revision is registered but has no active Actor.
|
||||||
Stopped,
|
Stopped,
|
||||||
|
/// Its most recent Actor trapped and must be started again.
|
||||||
Faulted,
|
Faulted,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// API projection of one public service deployment.
|
/// API projection of one public service deployment.
|
||||||
#[derive(Clone, Debug, Serialize)]
|
#[derive(Clone, Debug, Serialize)]
|
||||||
pub struct DeploymentView {
|
pub struct DeploymentView {
|
||||||
|
/// Stable public service ID used by the Gateway route.
|
||||||
pub service_id: String,
|
pub service_id: String,
|
||||||
|
/// Immutable revision currently selected for the service.
|
||||||
pub active_revision: String,
|
pub active_revision: String,
|
||||||
|
/// Current lifecycle status of the selected revision.
|
||||||
pub status: ServiceStatus,
|
pub status: ServiceStatus,
|
||||||
|
/// Unix timestamp of the most recent deployment change.
|
||||||
pub updated_at_ms: u64,
|
pub updated_at_ms: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Exact versioned host interface imported by a registered Component.
|
/// Exact versioned host interface imported by a registered Component.
|
||||||
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
|
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
|
||||||
pub struct CapabilityView {
|
pub struct CapabilityView {
|
||||||
|
/// Fully qualified versioned WIT interface.
|
||||||
pub interface: String,
|
pub interface: String,
|
||||||
|
/// WIT package identity without the version suffix.
|
||||||
pub package: String,
|
pub package: String,
|
||||||
|
/// Interface name inside the package.
|
||||||
pub name: String,
|
pub name: String,
|
||||||
|
/// Exact semantic version linked by the Runtime.
|
||||||
pub version: String,
|
pub version: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -161,16 +202,27 @@ impl From<CapabilityDescriptor> for CapabilityView {
|
|||||||
/// API projection of a registered service and its current metrics.
|
/// API projection of a registered service and its current metrics.
|
||||||
#[derive(Clone, Debug, Serialize)]
|
#[derive(Clone, Debug, Serialize)]
|
||||||
pub struct ServiceView {
|
pub struct ServiceView {
|
||||||
|
/// Stable service ID.
|
||||||
pub id: String,
|
pub id: String,
|
||||||
|
/// Immutable Component revision.
|
||||||
pub revision: String,
|
pub revision: String,
|
||||||
|
/// Canonical path of the stored Component artifact.
|
||||||
pub artifact: String,
|
pub artifact: String,
|
||||||
|
/// Fully versioned world declared by the package.
|
||||||
pub world: String,
|
pub world: String,
|
||||||
|
/// Exact Host interfaces discovered from Component imports.
|
||||||
pub capabilities: Vec<CapabilityView>,
|
pub capabilities: Vec<CapabilityView>,
|
||||||
|
/// Current Actor status for this revision.
|
||||||
pub status: ServiceStatus,
|
pub status: ServiceStatus,
|
||||||
|
/// Unix timestamp of the most recent lifecycle change.
|
||||||
pub updated_at_ms: u64,
|
pub updated_at_ms: u64,
|
||||||
|
/// Platform limits injected during registration.
|
||||||
pub limits: ResourceLimits,
|
pub limits: ResourceLimits,
|
||||||
|
/// Persisted number of completed invocation attempts.
|
||||||
pub calls: u64,
|
pub calls: u64,
|
||||||
|
/// Persisted number of failed invocation attempts.
|
||||||
pub errors: u64,
|
pub errors: u64,
|
||||||
|
/// Latency of the most recently persisted invocation.
|
||||||
pub last_latency_ms: Option<u64>,
|
pub last_latency_ms: Option<u64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -195,11 +247,17 @@ impl ServiceRecord {
|
|||||||
/// One persisted Console lifecycle or invocation event.
|
/// One persisted Console lifecycle or invocation event.
|
||||||
#[derive(Clone, Debug, Serialize)]
|
#[derive(Clone, Debug, Serialize)]
|
||||||
pub struct EventView {
|
pub struct EventView {
|
||||||
|
/// Process-independent monotonically increasing event identity.
|
||||||
pub id: u64,
|
pub id: u64,
|
||||||
|
/// Event creation time as Unix milliseconds.
|
||||||
pub timestamp_ms: u64,
|
pub timestamp_ms: u64,
|
||||||
|
/// Stable category used by API consumers.
|
||||||
pub kind: EventKind,
|
pub kind: EventKind,
|
||||||
|
/// Related service ID, or `None` for Runtime-wide events.
|
||||||
pub service_id: Option<String>,
|
pub service_id: Option<String>,
|
||||||
|
/// Related immutable revision when the event targets one version.
|
||||||
pub revision: Option<String>,
|
pub revision: Option<String>,
|
||||||
|
/// Human-readable operational detail; not a machine protocol.
|
||||||
pub message: String,
|
pub message: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -207,12 +265,19 @@ pub struct EventView {
|
|||||||
#[derive(Clone, Copy, Debug, Serialize)]
|
#[derive(Clone, Copy, Debug, Serialize)]
|
||||||
#[serde(rename_all = "snake_case")]
|
#[serde(rename_all = "snake_case")]
|
||||||
pub enum EventKind {
|
pub enum EventKind {
|
||||||
|
/// A Component package was registered.
|
||||||
Registered,
|
Registered,
|
||||||
|
/// An inactive revision and its artifacts were removed.
|
||||||
Unregistered,
|
Unregistered,
|
||||||
|
/// A public deployment switched to a revision.
|
||||||
Deployed,
|
Deployed,
|
||||||
|
/// Runtime or Actor startup completed.
|
||||||
Started,
|
Started,
|
||||||
|
/// Runtime or Actor shutdown completed.
|
||||||
Stopped,
|
Stopped,
|
||||||
|
/// One data-plane or management invocation completed.
|
||||||
Invoked,
|
Invoked,
|
||||||
|
/// A lifecycle or invocation operation failed.
|
||||||
Failed,
|
Failed,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -348,55 +413,76 @@ struct GatewayInvocation {
|
|||||||
/// Errors raised by Console state, persistence, Registry, or Runtime operations.
|
/// Errors raised by Console state, persistence, Registry, or Runtime operations.
|
||||||
#[derive(Debug, Error)]
|
#[derive(Debug, Error)]
|
||||||
pub enum ConsoleError {
|
pub enum ConsoleError {
|
||||||
|
/// API input failed semantic validation.
|
||||||
#[error("{0}")]
|
#[error("{0}")]
|
||||||
InvalidRequest(String),
|
InvalidRequest(String),
|
||||||
|
|
||||||
|
/// The requested registered service revision does not exist.
|
||||||
#[error("service revision {0} is not managed by Wasmeld")]
|
#[error("service revision {0} is not managed by Wasmeld")]
|
||||||
ServiceNotFound(ServiceKey),
|
ServiceNotFound(ServiceKey),
|
||||||
|
|
||||||
|
/// The public service has no selected revision.
|
||||||
#[error("service {0} has no active deployment")]
|
#[error("service {0} has no active deployment")]
|
||||||
DeploymentNotFound(String),
|
DeploymentNotFound(String),
|
||||||
|
|
||||||
|
/// Unregister was attempted on the revision currently serving traffic.
|
||||||
#[error("service revision {0} is the active deployment")]
|
#[error("service revision {0} is the active deployment")]
|
||||||
ActiveDeployment(ServiceKey),
|
ActiveDeployment(ServiceKey),
|
||||||
|
|
||||||
|
/// An operation requiring Wasmtime was attempted while Runtime is stopped.
|
||||||
#[error("Wasmeld Runtime is not running")]
|
#[error("Wasmeld Runtime is not running")]
|
||||||
RuntimeNotRunning,
|
RuntimeNotRunning,
|
||||||
|
|
||||||
|
/// Uploaded package bytes exceed the configured control-plane limit.
|
||||||
#[error("artifact exceeds the {limit}-byte limit")]
|
#[error("artifact exceeds the {limit}-byte limit")]
|
||||||
ArtifactTooLarge { limit: usize },
|
ArtifactTooLarge {
|
||||||
|
/// Maximum accepted upload bytes.
|
||||||
|
limit: usize,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// Component package decoding or integrity validation failed.
|
||||||
#[error(transparent)]
|
#[error(transparent)]
|
||||||
Package(#[from] PackageError),
|
Package(#[from] PackageError),
|
||||||
|
|
||||||
|
/// WIT Registry storage or integrity validation failed.
|
||||||
#[error(transparent)]
|
#[error(transparent)]
|
||||||
WitRegistry(#[from] WitRegistryError),
|
WitRegistry(#[from] WitRegistryError),
|
||||||
|
|
||||||
|
/// Component artifact or manifest filesystem access failed.
|
||||||
#[error("failed to access {path}: {source}")]
|
#[error("failed to access {path}: {source}")]
|
||||||
Storage {
|
Storage {
|
||||||
|
/// Path involved in the failed operation.
|
||||||
path: PathBuf,
|
path: PathBuf,
|
||||||
|
/// Underlying filesystem failure.
|
||||||
#[source]
|
#[source]
|
||||||
source: io::Error,
|
source: io::Error,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/// A generated Runtime manifest could not be encoded.
|
||||||
#[error("failed to serialize manifest: {0}")]
|
#[error("failed to serialize manifest: {0}")]
|
||||||
ManifestSerialize(#[from] toml::ser::Error),
|
ManifestSerialize(#[from] toml::ser::Error),
|
||||||
|
|
||||||
|
/// Toasty or libSQL control-state persistence failed.
|
||||||
#[error("database operation failed: {0}")]
|
#[error("database operation failed: {0}")]
|
||||||
Database(#[from] toasty::Error),
|
Database(#[from] toasty::Error),
|
||||||
|
|
||||||
|
/// The synchronous-to-async KV worker thread could not start.
|
||||||
#[error("failed to start persistent KV backend: {0}")]
|
#[error("failed to start persistent KV backend: {0}")]
|
||||||
KvBackendStart(#[source] io::Error),
|
KvBackendStart(#[source] io::Error),
|
||||||
|
|
||||||
|
/// Persisted rows contain an unknown enum, invalid key, or invalid revision.
|
||||||
#[error("database contains invalid console data: {0}")]
|
#[error("database contains invalid console data: {0}")]
|
||||||
InvalidDatabaseData(String),
|
InvalidDatabaseData(String),
|
||||||
|
|
||||||
|
/// Component compilation, Actor lifecycle, or invocation failed.
|
||||||
#[error(transparent)]
|
#[error(transparent)]
|
||||||
Runtime(#[from] RuntimeError),
|
Runtime(#[from] RuntimeError),
|
||||||
|
|
||||||
|
/// A blocking Runtime operation panicked or was cancelled.
|
||||||
#[error("blocking runtime task failed: {0}")]
|
#[error("blocking runtime task failed: {0}")]
|
||||||
Join(#[from] tokio::task::JoinError),
|
Join(#[from] tokio::task::JoinError),
|
||||||
|
|
||||||
|
/// An internal lifecycle, state, or Runtime lock was poisoned.
|
||||||
#[error("console state lock was poisoned")]
|
#[error("console state lock was poisoned")]
|
||||||
LockPoisoned,
|
LockPoisoned,
|
||||||
}
|
}
|
||||||
@@ -793,6 +879,11 @@ impl Console {
|
|||||||
};
|
};
|
||||||
manifest.validate()?;
|
manifest.validate()?;
|
||||||
|
|
||||||
|
// Artifacts are committed before Runtime registration so
|
||||||
|
// `register_from_file` can recover the same bytes after restart.
|
||||||
|
// Every later fallible step removes files it created until the Runtime
|
||||||
|
// owns the registration. The HTTP wrapper persists the state snapshot
|
||||||
|
// after this method returns.
|
||||||
write_atomic(&component_path, &component_bytes)?;
|
write_atomic(&component_path, &component_bytes)?;
|
||||||
let manifest_toml = toml::to_string_pretty(&manifest)?;
|
let manifest_toml = toml::to_string_pretty(&manifest)?;
|
||||||
if let Err(error) = write_atomic(&manifest_path, manifest_toml.as_bytes()) {
|
if let Err(error) = write_atomic(&manifest_path, manifest_toml.as_bytes()) {
|
||||||
@@ -950,6 +1041,10 @@ impl Console {
|
|||||||
let result = runtime.invoke(key, input);
|
let result = runtime.invoke(key, input);
|
||||||
let latency_ms = duration_ms(started_at.elapsed());
|
let latency_ms = duration_ms(started_at.elapsed());
|
||||||
|
|
||||||
|
// Update counters synchronously so management reads immediately
|
||||||
|
// observe this call. Durable telemetry is queued below and is
|
||||||
|
// intentionally best-effort: queue saturation or a process crash can
|
||||||
|
// lose recent counters/events. Do not use these values for billing.
|
||||||
let mut state = self.state()?;
|
let mut state = self.state()?;
|
||||||
let record = state
|
let record = state
|
||||||
.services
|
.services
|
||||||
@@ -995,6 +1090,9 @@ impl Console {
|
|||||||
service_id: &str,
|
service_id: &str,
|
||||||
input: Vec<u8>,
|
input: Vec<u8>,
|
||||||
) -> Result<GatewayInvocation, ConsoleError> {
|
) -> Result<GatewayInvocation, ConsoleError> {
|
||||||
|
// Clone the selected revision while holding state, then release the
|
||||||
|
// lock before entering Runtime. An activation racing this call affects
|
||||||
|
// only subsequent resolutions; this call remains pinned to `revision`.
|
||||||
let revision = {
|
let revision = {
|
||||||
let state = self.state()?;
|
let state = self.state()?;
|
||||||
state
|
state
|
||||||
@@ -1139,7 +1237,9 @@ impl Console {
|
|||||||
|
|
||||||
async fn persist_snapshot(&self) -> Result<(), ConsoleError> {
|
async fn persist_snapshot(&self) -> Result<(), ConsoleError> {
|
||||||
// Serialize snapshots so a slower earlier write cannot overwrite a
|
// Serialize snapshots so a slower earlier write cannot overwrite a
|
||||||
// newer state captured by a concurrent HTTP operation.
|
// newer state captured by a concurrent HTTP operation. This mutex is
|
||||||
|
// also shared with the invocation delta writer, which prevents the two
|
||||||
|
// transaction styles from interleaving.
|
||||||
let _sync = self.persistence_sync.lock().await;
|
let _sync = self.persistence_sync.lock().await;
|
||||||
let (services, events, deployments) = self.persistence_snapshot()?;
|
let (services, events, deployments) = self.persistence_snapshot()?;
|
||||||
self.persistence.sync(services, events, deployments).await?;
|
self.persistence.sync(services, events, deployments).await?;
|
||||||
|
|||||||
@@ -1,4 +1,25 @@
|
|||||||
//! Standalone Wasmeld control-plane and data-plane server entry point.
|
//! Standalone Wasmeld control-plane and data-plane server entry point.
|
||||||
|
//!
|
||||||
|
//! Configuration is supplied through environment variables:
|
||||||
|
//!
|
||||||
|
//! - `WASMELD_ADDR` binds the management API (default `127.0.0.1:8080`).
|
||||||
|
//! - `WASMELD_GATEWAY_ADDR` binds the public byte API (default `0.0.0.0:8081`).
|
||||||
|
//! - `WASMELD_ARTIFACT_DIR`, `WASMELD_WIT_REGISTRY_DIR`, and
|
||||||
|
//! `WASMELD_DATABASE_PATH` select durable local storage.
|
||||||
|
//! - `WASMELD_ALLOWED_ORIGINS` is a comma-separated management UI CORS list.
|
||||||
|
//! - `RUST_LOG` configures tracing.
|
||||||
|
//!
|
||||||
|
//! For example:
|
||||||
|
//!
|
||||||
|
//! ```text
|
||||||
|
//! WASMELD_ADDR=127.0.0.1:8080 \
|
||||||
|
//! WASMELD_GATEWAY_ADDR=0.0.0.0:8081 \
|
||||||
|
//! RUST_LOG=wasmeld_console=debug \
|
||||||
|
//! cargo run -p wasmeld-console
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! The defaults expose only the gateway on non-loopback interfaces.
|
||||||
|
//! Authentication and TLS must be supplied by the deployment environment.
|
||||||
|
|
||||||
use std::{env, net::SocketAddr, path::PathBuf, sync::Arc};
|
use std::{env, net::SocketAddr, path::PathBuf, sync::Arc};
|
||||||
|
|
||||||
@@ -45,6 +66,8 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
let gateway = gateway_app(Arc::clone(&console));
|
let gateway = gateway_app(Arc::clone(&console));
|
||||||
let management_listener = tokio::net::TcpListener::bind(management_address).await?;
|
let management_listener = tokio::net::TcpListener::bind(management_address).await?;
|
||||||
let gateway_listener = tokio::net::TcpListener::bind(gateway_address).await?;
|
let gateway_listener = tokio::net::TcpListener::bind(gateway_address).await?;
|
||||||
|
// Both servers share one shutdown edge so neither listener remains alive
|
||||||
|
// after Ctrl+C has begun process termination.
|
||||||
let (shutdown_sender, shutdown_receiver) = tokio::sync::watch::channel(false);
|
let (shutdown_sender, shutdown_receiver) = tokio::sync::watch::channel(false);
|
||||||
let signal_task = tokio::spawn(async move {
|
let signal_task = tokio::spawn(async move {
|
||||||
shutdown_signal().await;
|
shutdown_signal().await;
|
||||||
@@ -61,6 +84,9 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
);
|
);
|
||||||
signal_task.abort();
|
signal_task.abort();
|
||||||
result?;
|
result?;
|
||||||
|
// Axum has stopped accepting requests at this point. Flush accepted
|
||||||
|
// telemetry before dropping Console; persistence failures are traced by
|
||||||
|
// the worker because telemetry is an operational, best-effort stream.
|
||||||
console.flush_invocation_telemetry().await;
|
console.flush_invocation_telemetry().await;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,15 @@
|
|||||||
//! Component bytes and WIT packages remain filesystem artifacts. This module
|
//! Component bytes and WIT packages remain filesystem artifacts. This module
|
||||||
//! persists service manifests, deployments, metrics, the bounded event
|
//! persists service manifests, deployments, metrics, the bounded event
|
||||||
//! history, and service-scoped Host KV entries.
|
//! history, and service-scoped Host KV entries.
|
||||||
|
//!
|
||||||
|
//! There are two write paths:
|
||||||
|
//!
|
||||||
|
//! - [`Persistence::sync`] stores a complete control-plane snapshot.
|
||||||
|
//! - [`Persistence::record_invocations`] applies ordered metric deltas.
|
||||||
|
//!
|
||||||
|
//! Callers serialize these paths with the Console persistence mutex. Existing
|
||||||
|
//! service counters are intentionally owned by the delta path; a snapshot
|
||||||
|
//! updates their manifest and timestamp without replacing those counters.
|
||||||
|
|
||||||
use std::{
|
use std::{
|
||||||
collections::BTreeSet,
|
collections::BTreeSet,
|
||||||
@@ -55,6 +64,10 @@ pub(crate) struct StoredKvEntry {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// One invocation's durable metric delta and event.
|
/// One invocation's durable metric delta and event.
|
||||||
|
///
|
||||||
|
/// The caller assigns the event ID before enqueueing the update. A missing
|
||||||
|
/// service row is tolerated because unregister may race queued telemetry; the
|
||||||
|
/// event is still retained for diagnostics.
|
||||||
pub(crate) struct InvocationUpdate {
|
pub(crate) struct InvocationUpdate {
|
||||||
pub service_key: String,
|
pub service_key: String,
|
||||||
pub failed: bool,
|
pub failed: bool,
|
||||||
@@ -64,6 +77,10 @@ pub(crate) struct InvocationUpdate {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Serialized access to the Toasty database connection.
|
/// Serialized access to the Toasty database connection.
|
||||||
|
///
|
||||||
|
/// This mutex protects a single Toasty [`Db`] handle. It is separate from the
|
||||||
|
/// higher-level Console persistence mutex, which coordinates whole snapshot
|
||||||
|
/// and invocation-delta operations before they acquire this connection.
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub(crate) struct Persistence {
|
pub(crate) struct Persistence {
|
||||||
db: Arc<Mutex<Db>>,
|
db: Arc<Mutex<Db>>,
|
||||||
@@ -131,6 +148,11 @@ impl Persistence {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Atomically upserts a Console snapshot and prunes expired events.
|
/// Atomically upserts a Console snapshot and prunes expired events.
|
||||||
|
///
|
||||||
|
/// The input is authoritative for services and deployments: rows omitted
|
||||||
|
/// from those collections are removed. Events older than the oldest
|
||||||
|
/// supplied retained event are pruned. Existing invocation counters are
|
||||||
|
/// preserved because [`Self::record_invocations`] owns counter changes.
|
||||||
pub async fn sync(
|
pub async fn sync(
|
||||||
&self,
|
&self,
|
||||||
services: Vec<StoredService>,
|
services: Vec<StoredService>,
|
||||||
@@ -178,6 +200,8 @@ impl Persistence {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Console stores events newest-first, so the last element is the
|
||||||
|
// oldest ID that should survive snapshot pruning.
|
||||||
if let Some(oldest_event) = events.last() {
|
if let Some(oldest_event) = events.last() {
|
||||||
StoredEvent::filter(StoredEvent::fields().id().lt(oldest_event.id))
|
StoredEvent::filter(StoredEvent::fields().id().lt(oldest_event.id))
|
||||||
.delete()
|
.delete()
|
||||||
@@ -217,6 +241,9 @@ impl Persistence {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Atomically records invocation deltas and their bounded event history.
|
/// Atomically records invocation deltas and their bounded event history.
|
||||||
|
///
|
||||||
|
/// All supplied updates commit together. Metrics use saturating arithmetic
|
||||||
|
/// so corrupted or extremely old databases cannot wrap counters.
|
||||||
pub(crate) async fn record_invocations(
|
pub(crate) async fn record_invocations(
|
||||||
&self,
|
&self,
|
||||||
updates: Vec<InvocationUpdate>,
|
updates: Vec<InvocationUpdate>,
|
||||||
@@ -268,6 +295,8 @@ impl Persistence {
|
|||||||
service_id: &str,
|
service_id: &str,
|
||||||
entry_key: &str,
|
entry_key: &str,
|
||||||
) -> toasty::Result<Option<Vec<u8>>> {
|
) -> toasty::Result<Option<Vec<u8>>> {
|
||||||
|
// The namespace is the stable service ID rather than its revision.
|
||||||
|
// Deploying a new revision therefore preserves application state.
|
||||||
let mut db = self.db.lock().await;
|
let mut db = self.db.lock().await;
|
||||||
Ok(
|
Ok(
|
||||||
StoredKvEntry::filter_by_service_id_and_entry_key(service_id, entry_key)
|
StoredKvEntry::filter_by_service_id_and_entry_key(service_id, entry_key)
|
||||||
@@ -284,6 +313,8 @@ impl Persistence {
|
|||||||
entry_key: String,
|
entry_key: String,
|
||||||
value: Vec<u8>,
|
value: Vec<u8>,
|
||||||
) -> toasty::Result<()> {
|
) -> toasty::Result<()> {
|
||||||
|
// Upsert makes a retry after an ambiguous Host timeout idempotent for
|
||||||
|
// the same `(service_id, entry_key, value)`.
|
||||||
let mut db = self.db.lock().await;
|
let mut db = self.db.lock().await;
|
||||||
StoredKvEntry::upsert_by_service_id_and_entry_key(service_id, entry_key)
|
StoredKvEntry::upsert_by_service_id_and_entry_key(service_id, entry_key)
|
||||||
.value(value)
|
.value(value)
|
||||||
|
|||||||
@@ -37,28 +37,40 @@ struct StoredWitPackage {
|
|||||||
/// Errors produced by WIT Registry storage and integrity checks.
|
/// Errors produced by WIT Registry storage and integrity checks.
|
||||||
#[derive(Debug, Error)]
|
#[derive(Debug, Error)]
|
||||||
pub enum WitRegistryError {
|
pub enum WitRegistryError {
|
||||||
|
/// Uploaded binary WIT exceeds the configured byte limit.
|
||||||
#[error("WIT package exceeds the {limit}-byte limit")]
|
#[error("WIT package exceeds the {limit}-byte limit")]
|
||||||
TooLarge { limit: usize },
|
TooLarge {
|
||||||
|
/// Maximum accepted artifact bytes.
|
||||||
|
limit: usize,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// The immutable `name@version` already exists.
|
||||||
#[error("WIT package {0} is already published and cannot be overwritten")]
|
#[error("WIT package {0} is already published and cannot be overwritten")]
|
||||||
AlreadyExists(String),
|
AlreadyExists(String),
|
||||||
|
|
||||||
|
/// No indexed artifact matches the requested identity.
|
||||||
#[error("WIT package {0} was not found")]
|
#[error("WIT package {0} was not found")]
|
||||||
NotFound(String),
|
NotFound(String),
|
||||||
|
|
||||||
|
/// Uploaded or persisted bytes are not a valid versioned binary WIT package.
|
||||||
#[error(transparent)]
|
#[error(transparent)]
|
||||||
InvalidPackage(#[from] WitPackageError),
|
InvalidPackage(#[from] WitPackageError),
|
||||||
|
|
||||||
|
/// Directory layout and decoded artifact identity disagree.
|
||||||
#[error("invalid WIT registry data: {0}")]
|
#[error("invalid WIT registry data: {0}")]
|
||||||
InvalidStorage(String),
|
InvalidStorage(String),
|
||||||
|
|
||||||
|
/// Registry directory or artifact filesystem access failed.
|
||||||
#[error("failed to access {path}: {source}")]
|
#[error("failed to access {path}: {source}")]
|
||||||
Storage {
|
Storage {
|
||||||
|
/// Path involved in the failed operation.
|
||||||
path: PathBuf,
|
path: PathBuf,
|
||||||
|
/// Underlying filesystem failure.
|
||||||
#[source]
|
#[source]
|
||||||
source: io::Error,
|
source: io::Error,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/// The process-local Registry index lock was poisoned.
|
||||||
#[error("WIT registry lock was poisoned")]
|
#[error("WIT registry lock was poisoned")]
|
||||||
LockPoisoned,
|
LockPoisoned,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,22 @@
|
|||||||
//! Local build, watch, and deployment loop for Component development.
|
//! Local build, watch, and deployment loop for Component development.
|
||||||
|
//!
|
||||||
|
//! The loop fingerprints Rust/WIT inputs, materializes WIT dependencies,
|
||||||
|
//! compiles a Component, creates a content-addressed development revision,
|
||||||
|
//! registers it, and atomically switches the Console deployment. A failed
|
||||||
|
//! build or deployment never removes the previously active revision.
|
||||||
|
//!
|
||||||
|
//! Build and deployment are separate states. Once a build succeeds,
|
||||||
|
//! `pending_deployment` retains that exact package and retries network or
|
||||||
|
//! Console failures without rebuilding it. A later source change supersedes
|
||||||
|
//! the pending package and starts a new build.
|
||||||
|
//!
|
||||||
|
//! # Example
|
||||||
|
//!
|
||||||
|
//! ```text
|
||||||
|
//! cargo run -p wasmeld-package --bin wasmeld -- \
|
||||||
|
//! dev components/counter/Cargo.toml \
|
||||||
|
//! --console http://127.0.0.1:8080
|
||||||
|
//! ```
|
||||||
|
|
||||||
use std::{
|
use std::{
|
||||||
collections::BTreeSet,
|
collections::BTreeSet,
|
||||||
@@ -57,6 +75,9 @@ pub(crate) fn run(arguments: Vec<String>) -> Result<(), Box<dyn std::error::Erro
|
|||||||
fs::create_dir_all(&temporary_dir)?;
|
fs::create_dir_all(&temporary_dir)?;
|
||||||
let output = temporary_dir.join("component.wasmpkg");
|
let output = temporary_dir.join("component.wasmpkg");
|
||||||
let mut observed = None;
|
let mut observed = None;
|
||||||
|
// Keeping this separate from `observed` lets transient deployment errors
|
||||||
|
// retry the already-built bytes. Rebuilding on every HTTP failure would
|
||||||
|
// waste time and could create a different revision unexpectedly.
|
||||||
let mut pending_deployment = None;
|
let mut pending_deployment = None;
|
||||||
|
|
||||||
println!("watching:");
|
println!("watching:");
|
||||||
@@ -138,6 +159,10 @@ fn deploy_component(
|
|||||||
console: &str,
|
console: &str,
|
||||||
packed: &PackedComponent,
|
packed: &PackedComponent,
|
||||||
) -> Result<(), Box<dyn std::error::Error>> {
|
) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
// Order is intentional: register makes the immutable revision available,
|
||||||
|
// activate switches new gateway resolutions, and only then may old
|
||||||
|
// development revisions be removed. Cleanup is best-effort because a
|
||||||
|
// successful deployment must not be reported as failed due to stale files.
|
||||||
register(client, console, packed)?;
|
register(client, console, packed)?;
|
||||||
activate(
|
activate(
|
||||||
client,
|
client,
|
||||||
@@ -235,6 +260,9 @@ fn register(
|
|||||||
if response.status().is_success() {
|
if response.status().is_success() {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
// Retrying a package whose registration response was lost is safe only
|
||||||
|
// when the exact immutable identity already exists. Do not treat every
|
||||||
|
// conflict as success; it may represent a different control-plane error.
|
||||||
if response.status() == StatusCode::CONFLICT
|
if response.status() == StatusCode::CONFLICT
|
||||||
&& service_exists(
|
&& service_exists(
|
||||||
client,
|
client,
|
||||||
@@ -334,6 +362,9 @@ fn watch_roots(manifest_path: &Path) -> Result<Vec<PathBuf>, Box<dyn std::error:
|
|||||||
let module_path = component_root.join(MODULE_MANIFEST_FILE);
|
let module_path = component_root.join(MODULE_MANIFEST_FILE);
|
||||||
if module_path.is_file() {
|
if module_path.is_file() {
|
||||||
let module = ModuleManifest::read(&module_path)?;
|
let module = ModuleManifest::read(&module_path)?;
|
||||||
|
// Path replacements are source inputs just like the Component itself.
|
||||||
|
// Registry dependencies are immutable and represented by wit.lock, so
|
||||||
|
// they do not need independent watch roots.
|
||||||
for replacement in module.replacements.values() {
|
for replacement in module.replacements.values() {
|
||||||
let path = component_root.join(&replacement.path);
|
let path = component_root.join(&replacement.path);
|
||||||
if path.exists() {
|
if path.exists() {
|
||||||
@@ -351,6 +382,8 @@ fn source_fingerprint(roots: &[PathBuf]) -> Result<Vec<u8>, Box<dyn std::error::
|
|||||||
}
|
}
|
||||||
let mut digest = Sha256::new();
|
let mut digest = Sha256::new();
|
||||||
for path in files {
|
for path in files {
|
||||||
|
// Include paths as well as bytes so file renames trigger a rebuild even
|
||||||
|
// when their contents are unchanged.
|
||||||
digest.update(path.to_string_lossy().as_bytes());
|
digest.update(path.to_string_lossy().as_bytes());
|
||||||
match fs::read(&path) {
|
match fs::read(&path) {
|
||||||
Ok(bytes) => digest.update(bytes),
|
Ok(bytes) => digest.update(bytes),
|
||||||
@@ -384,6 +417,8 @@ fn ignored_directory(path: &Path) -> bool {
|
|||||||
name,
|
name,
|
||||||
Some(".git" | ".wasmeld" | "deps" | "dist" | "node_modules" | "target")
|
Some(".git" | ".wasmeld" | "deps" | "dist" | "node_modules" | "target")
|
||||||
) {
|
) {
|
||||||
|
// `wit/deps` is generated by dependency sync. Watching it would make
|
||||||
|
// each build rewrite watched files and trigger an endless rebuild.
|
||||||
return name != Some("deps")
|
return name != Some("deps")
|
||||||
|| path
|
|| path
|
||||||
.parent()
|
.parent()
|
||||||
@@ -395,6 +430,8 @@ fn ignored_directory(path: &Path) -> bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn is_source_file(path: &Path) -> bool {
|
fn is_source_file(path: &Path) -> bool {
|
||||||
|
// The list is deliberately narrow: fingerprints should represent inputs
|
||||||
|
// to Cargo/WIT resolution, not editor state or generated artifacts.
|
||||||
matches!(
|
matches!(
|
||||||
path.extension().and_then(|extension| extension.to_str()),
|
path.extension().and_then(|extension| extension.to_str()),
|
||||||
Some("rs" | "wit")
|
Some("rs" | "wit")
|
||||||
|
|||||||
@@ -5,6 +5,34 @@
|
|||||||
//! containing exactly [`PACKAGE_MANIFEST_PATH`] and [`COMPONENT_PATH`]. WIT
|
//! containing exactly [`PACKAGE_MANIFEST_PATH`] and [`COMPONENT_PATH`]. WIT
|
||||||
//! packages use the Component Model binary WIT encoding implemented by the
|
//! packages use the Component Model binary WIT encoding implemented by the
|
||||||
//! [`wit_package`] module; they are not stored in `.wasmpkg` containers.
|
//! [`wit_package`] module; they are not stored in `.wasmpkg` containers.
|
||||||
|
//!
|
||||||
|
//! # Example
|
||||||
|
//!
|
||||||
|
//! ```
|
||||||
|
//! use std::io::Cursor;
|
||||||
|
//! use wasmeld_package::{read_package, write_package};
|
||||||
|
//!
|
||||||
|
//! let component = b"\0asm\x0d\0\x01\0";
|
||||||
|
//! let mut archive = Cursor::new(Vec::new());
|
||||||
|
//! write_package(
|
||||||
|
//! &mut archive,
|
||||||
|
//! "image-resize",
|
||||||
|
//! "1.0.0",
|
||||||
|
//! "example:image-resize/service@1.0.0",
|
||||||
|
//! component,
|
||||||
|
//! )?;
|
||||||
|
//!
|
||||||
|
//! let decoded = read_package(archive.get_ref(), 1024 * 1024)?;
|
||||||
|
//! assert_eq!(decoded.manifest.id, "image-resize");
|
||||||
|
//! assert_eq!(decoded.component, component);
|
||||||
|
//! # Ok::<(), wasmeld_package::PackageError>(())
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! The decoder treats an uploaded archive as untrusted input: it accepts only
|
||||||
|
//! the two canonical entries, limits uncompressed sizes, validates identifiers,
|
||||||
|
//! and verifies the Component digest before returning bytes to the Runtime.
|
||||||
|
|
||||||
|
#![warn(missing_docs)]
|
||||||
|
|
||||||
use std::{
|
use std::{
|
||||||
collections::BTreeSet,
|
collections::BTreeSet,
|
||||||
@@ -35,16 +63,26 @@ const MAX_MANIFEST_BYTES: usize = 64 * 1024;
|
|||||||
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||||
#[serde(deny_unknown_fields)]
|
#[serde(deny_unknown_fields)]
|
||||||
pub struct PackageManifest {
|
pub struct PackageManifest {
|
||||||
|
/// Manifest schema understood by this version of Wasmeld.
|
||||||
pub schema_version: u32,
|
pub schema_version: u32,
|
||||||
|
/// Stable service identifier used in management and gateway routes.
|
||||||
pub id: String,
|
pub id: String,
|
||||||
|
/// Immutable service revision; the same `id@revision` cannot be overwritten.
|
||||||
pub revision: String,
|
pub revision: String,
|
||||||
|
/// Fully versioned WIT world implemented by the Component.
|
||||||
pub world: String,
|
pub world: String,
|
||||||
|
/// Archive path of the executable Component; currently always [`COMPONENT_PATH`].
|
||||||
pub component: String,
|
pub component: String,
|
||||||
|
/// Lowercase SHA-256 digest of the uncompressed Component bytes.
|
||||||
pub sha256: String,
|
pub sha256: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl PackageManifest {
|
impl PackageManifest {
|
||||||
/// Creates a manifest and computes the component digest.
|
/// Creates a manifest and computes the component digest.
|
||||||
|
///
|
||||||
|
/// This constructor does not reject invalid identifiers or worlds. Call
|
||||||
|
/// [`Self::validate`] before persisting it; [`write_package`] does this
|
||||||
|
/// automatically.
|
||||||
pub fn new(
|
pub fn new(
|
||||||
id: impl Into<String>,
|
id: impl Into<String>,
|
||||||
revision: impl Into<String>,
|
revision: impl Into<String>,
|
||||||
@@ -94,31 +132,48 @@ impl PackageManifest {
|
|||||||
/// A validated component package decoded from a `.wasmpkg` archive.
|
/// A validated component package decoded from a `.wasmpkg` archive.
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub struct ComponentPackage {
|
pub struct ComponentPackage {
|
||||||
|
/// Validated identity, world, and integrity metadata.
|
||||||
pub manifest: PackageManifest,
|
pub manifest: PackageManifest,
|
||||||
|
/// Uncompressed Component bytes whose digest matches the manifest.
|
||||||
pub component: Vec<u8>,
|
pub component: Vec<u8>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Errors produced while creating or validating component packages.
|
/// Errors produced while creating or validating component packages.
|
||||||
#[derive(Debug, Error)]
|
#[derive(Debug, Error)]
|
||||||
pub enum PackageError {
|
pub enum PackageError {
|
||||||
|
/// The archive layout or a manifest value violates the package contract.
|
||||||
#[error("invalid component package: {0}")]
|
#[error("invalid component package: {0}")]
|
||||||
InvalidPackage(String),
|
InvalidPackage(String),
|
||||||
|
|
||||||
|
/// The uncompressed Component exceeds the caller-selected upload limit.
|
||||||
#[error("component exceeds the {limit}-byte uncompressed limit")]
|
#[error("component exceeds the {limit}-byte uncompressed limit")]
|
||||||
ComponentTooLarge { limit: usize },
|
ComponentTooLarge {
|
||||||
|
/// Maximum number of accepted uncompressed bytes.
|
||||||
|
limit: usize,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// Component bytes do not match the immutable digest in `package.toml`.
|
||||||
#[error("component digest mismatch: expected {expected}, found {actual}")]
|
#[error("component digest mismatch: expected {expected}, found {actual}")]
|
||||||
DigestMismatch { expected: String, actual: String },
|
DigestMismatch {
|
||||||
|
/// Digest declared by the package manifest.
|
||||||
|
expected: String,
|
||||||
|
/// Digest computed from the uploaded Component.
|
||||||
|
actual: String,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// Reading or writing the package stream failed.
|
||||||
#[error("failed to read or write package: {0}")]
|
#[error("failed to read or write package: {0}")]
|
||||||
Io(#[from] io::Error),
|
Io(#[from] io::Error),
|
||||||
|
|
||||||
|
/// The bytes are not a valid constrained ZIP archive.
|
||||||
#[error("invalid ZIP container: {0}")]
|
#[error("invalid ZIP container: {0}")]
|
||||||
Zip(#[from] ZipError),
|
Zip(#[from] ZipError),
|
||||||
|
|
||||||
|
/// `package.toml` could not be decoded.
|
||||||
#[error("invalid package manifest: {0}")]
|
#[error("invalid package manifest: {0}")]
|
||||||
ManifestParse(#[from] toml::de::Error),
|
ManifestParse(#[from] toml::de::Error),
|
||||||
|
|
||||||
|
/// A generated package manifest could not be encoded.
|
||||||
#[error("failed to serialize package manifest: {0}")]
|
#[error("failed to serialize package manifest: {0}")]
|
||||||
ManifestSerialize(#[from] toml::ser::Error),
|
ManifestSerialize(#[from] toml::ser::Error),
|
||||||
}
|
}
|
||||||
@@ -127,6 +182,11 @@ pub enum PackageError {
|
|||||||
///
|
///
|
||||||
/// The caller owns the destination writer. Runtime limits are intentionally not
|
/// The caller owns the destination writer. Runtime limits are intentionally not
|
||||||
/// included because the Console injects platform policy during registration.
|
/// included because the Console injects platform policy during registration.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns an error when the identity or world is invalid, the Component is
|
||||||
|
/// empty, or the destination cannot be written.
|
||||||
pub fn write_package<W>(
|
pub fn write_package<W>(
|
||||||
writer: W,
|
writer: W,
|
||||||
id: impl Into<String>,
|
id: impl Into<String>,
|
||||||
@@ -162,6 +222,11 @@ where
|
|||||||
///
|
///
|
||||||
/// `max_component_bytes` applies to the uncompressed component so compressed
|
/// `max_component_bytes` applies to the uncompressed component so compressed
|
||||||
/// archives cannot bypass the platform's artifact limit.
|
/// archives cannot bypass the platform's artifact limit.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns an error for malformed ZIP data, extra or duplicate entries,
|
||||||
|
/// oversized content, invalid metadata, empty Components, or digest mismatch.
|
||||||
pub fn read_package(
|
pub fn read_package(
|
||||||
package_bytes: &[u8],
|
package_bytes: &[u8],
|
||||||
max_component_bytes: usize,
|
max_component_bytes: usize,
|
||||||
|
|||||||
@@ -1,4 +1,18 @@
|
|||||||
//! Command-line entry point for Component packaging and WIT dependency workflows.
|
//! Command-line entry point for Component packaging and WIT dependency workflows.
|
||||||
|
//!
|
||||||
|
//! Typical release packaging:
|
||||||
|
//!
|
||||||
|
//! ```text
|
||||||
|
//! wasmeld wit fetch --manifest components/counter/wasmeld.toml --locked
|
||||||
|
//! wasmeld pack components/counter/Cargo.toml --locked
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! `wit fetch` resolves registry or local `replace` sources into `wit/deps`
|
||||||
|
//! and writes `wit.lock`. `--locked` verifies that resolution matches the
|
||||||
|
//! existing lock file and is the appropriate mode for CI. A `replace` changes
|
||||||
|
//! only the development source used for a declared package identity; it does
|
||||||
|
//! not rename that package or make the local path part of the published
|
||||||
|
//! Component contract.
|
||||||
|
|
||||||
mod dev;
|
mod dev;
|
||||||
|
|
||||||
@@ -21,7 +35,9 @@ const TARGET: &str = "wasm32-wasip2";
|
|||||||
|
|
||||||
#[derive(Clone, Copy)]
|
#[derive(Clone, Copy)]
|
||||||
pub(crate) enum BuildProfile {
|
pub(crate) enum BuildProfile {
|
||||||
|
/// Fast local build with Cargo's `debug` profile.
|
||||||
Debug,
|
Debug,
|
||||||
|
/// Optimized artifact from Cargo's `release` profile.
|
||||||
Release,
|
Release,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -35,22 +51,36 @@ impl BuildProfile {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) enum PackageIdentity {
|
pub(crate) enum PackageIdentity {
|
||||||
|
/// Use `[package.metadata.wasmeld].id` and the Cargo package version.
|
||||||
Cargo,
|
Cargo,
|
||||||
|
/// Use a development service ID and a Component-content revision suffix.
|
||||||
Development { id: Option<String> },
|
Development { id: Option<String> },
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) struct PackRequest {
|
pub(crate) struct PackRequest {
|
||||||
|
/// Cargo manifest for the Component crate.
|
||||||
pub manifest_path: PathBuf,
|
pub manifest_path: PathBuf,
|
||||||
|
/// Destination package, or the workspace `dist` default.
|
||||||
pub output: Option<PathBuf>,
|
pub output: Option<PathBuf>,
|
||||||
|
/// Reuse an artifact already present under Cargo's target directory.
|
||||||
|
///
|
||||||
|
/// The caller is responsible for ensuring it matches current sources and
|
||||||
|
/// the requested profile.
|
||||||
pub no_build: bool,
|
pub no_build: bool,
|
||||||
|
/// Require `wit.lock` to match dependency resolution exactly.
|
||||||
pub locked: bool,
|
pub locked: bool,
|
||||||
|
/// Cargo profile used to build and locate the Component.
|
||||||
pub profile: BuildProfile,
|
pub profile: BuildProfile,
|
||||||
|
/// Stable release or content-addressed development identity.
|
||||||
pub identity: PackageIdentity,
|
pub identity: PackageIdentity,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) struct PackedComponent {
|
pub(crate) struct PackedComponent {
|
||||||
|
/// Validated metadata embedded in the `.wasmpkg`.
|
||||||
pub manifest: PackageManifest,
|
pub manifest: PackageManifest,
|
||||||
|
/// Path to the resulting `.wasmpkg`.
|
||||||
pub output: PathBuf,
|
pub output: PathBuf,
|
||||||
|
/// Path to the raw Component produced or reused by Cargo.
|
||||||
pub artifact: PathBuf,
|
pub artifact: PathBuf,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -141,6 +171,9 @@ pub(crate) fn pack_component(
|
|||||||
request: PackRequest,
|
request: PackRequest,
|
||||||
) -> Result<PackedComponent, Box<dyn std::error::Error>> {
|
) -> Result<PackedComponent, Box<dyn std::error::Error>> {
|
||||||
let manifest_path = fs::canonicalize(request.manifest_path)?;
|
let manifest_path = fs::canonicalize(request.manifest_path)?;
|
||||||
|
// Materialize WIT before asking Cargo for metadata or compiling: the
|
||||||
|
// component's `wit_bindgen::generate!` reads `wit/world.wit` and
|
||||||
|
// `wit/deps` during macro expansion.
|
||||||
sync_component_dependencies(&manifest_path, request.locked)?;
|
sync_component_dependencies(&manifest_path, request.locked)?;
|
||||||
let metadata = cargo_metadata(&manifest_path)?;
|
let metadata = cargo_metadata(&manifest_path)?;
|
||||||
let package = metadata
|
let package = metadata
|
||||||
@@ -189,6 +222,9 @@ pub(crate) fn pack_component(
|
|||||||
let (id, revision) = match request.identity {
|
let (id, revision) = match request.identity {
|
||||||
PackageIdentity::Cargo => (cargo_id, cargo_revision),
|
PackageIdentity::Cargo => (cargo_id, cargo_revision),
|
||||||
PackageIdentity::Development { id } => {
|
PackageIdentity::Development { id } => {
|
||||||
|
// The revision changes only when compiled Component bytes change.
|
||||||
|
// This makes registration retryable and avoids mutating an
|
||||||
|
// immutable identity while editing.
|
||||||
let digest = format!("{:x}", Sha256::digest(&component));
|
let digest = format!("{:x}", Sha256::digest(&component));
|
||||||
(
|
(
|
||||||
id.unwrap_or_else(|| format!("{cargo_id}-dev")),
|
id.unwrap_or_else(|| format!("{cargo_id}-dev")),
|
||||||
@@ -316,6 +352,8 @@ fn run_wit_replace(
|
|||||||
|
|
||||||
let manifest_path = resolve_module_manifest(manifest_path)?;
|
let manifest_path = resolve_module_manifest(manifest_path)?;
|
||||||
let mut manifest = ModuleManifest::read(&manifest_path)?;
|
let mut manifest = ModuleManifest::read(&manifest_path)?;
|
||||||
|
// Replace is recorded in the source manifest. The next fetch/pack resolves
|
||||||
|
// and locks its contents; generated `wit/deps` must not be hand-edited.
|
||||||
if drop {
|
if drop {
|
||||||
if !manifest.drop_replacement(&package) {
|
if !manifest.drop_replacement(&package) {
|
||||||
return Err(format!("replacement {package:?} does not exist").into());
|
return Err(format!("replacement {package:?} does not exist").into());
|
||||||
@@ -407,6 +445,9 @@ fn run_wit_publish(
|
|||||||
return Err(format!("registry returned HTTP {status}: {}", response.text()?).into());
|
return Err(format!("registry returned HTTP {status}: {}", response.text()?).into());
|
||||||
}
|
}
|
||||||
let published = response.json::<WitPackageMetadata>()?;
|
let published = response.json::<WitPackageMetadata>()?;
|
||||||
|
// A successful status is insufficient: compare the server's canonical
|
||||||
|
// identity and digest to detect a proxy or Registry returning metadata for
|
||||||
|
// different bytes.
|
||||||
if published != package.metadata {
|
if published != package.metadata {
|
||||||
return Err(format!(
|
return Err(format!(
|
||||||
"registry metadata mismatch: expected {}@{} ({})",
|
"registry metadata mismatch: expected {}@{} ({})",
|
||||||
|
|||||||
@@ -4,6 +4,23 @@
|
|||||||
//! prefers root-level path replacements and otherwise downloads immutable
|
//! prefers root-level path replacements and otherwise downloads immutable
|
||||||
//! binary packages from the configured Registry. The complete transitive graph
|
//! binary packages from the configured Registry. The complete transitive graph
|
||||||
//! is materialized under `wit/deps` and recorded in `wit.lock`.
|
//! is materialized under `wit/deps` and recorded in `wit.lock`.
|
||||||
|
//!
|
||||||
|
//! `wasmeld.toml` is the human-authored input; `wit.lock` is the reproducibility
|
||||||
|
//! record; and `wit/deps` is disposable generated output. Commit the first two,
|
||||||
|
//! but regenerate `wit/deps` with [`crate::module::sync_dependencies`] after
|
||||||
|
//! cloning.
|
||||||
|
//!
|
||||||
|
//! # Example
|
||||||
|
//!
|
||||||
|
//! ```no_run
|
||||||
|
//! use wasmeld_package::module::sync_dependencies;
|
||||||
|
//!
|
||||||
|
//! // CI uses locked mode so changed Registry content, replacements, or
|
||||||
|
//! // transitive dependencies fail instead of rewriting wit.lock.
|
||||||
|
//! let report = sync_dependencies("components/image-resize/wasmeld.toml", true)?;
|
||||||
|
//! println!("resolved {} packages", report.packages.len());
|
||||||
|
//! # Ok::<(), wasmeld_package::module::ModuleError>(())
|
||||||
|
//! ```
|
||||||
|
|
||||||
use std::{
|
use std::{
|
||||||
collections::{BTreeMap, BTreeSet},
|
collections::{BTreeMap, BTreeSet},
|
||||||
@@ -156,39 +173,79 @@ pub struct SyncReport {
|
|||||||
/// Errors raised while parsing, resolving, locking, or materializing WIT dependencies.
|
/// Errors raised while parsing, resolving, locking, or materializing WIT dependencies.
|
||||||
#[derive(Debug, Error)]
|
#[derive(Debug, Error)]
|
||||||
pub enum ModuleError {
|
pub enum ModuleError {
|
||||||
|
/// A manifest value, dependency identity, version, or path is invalid.
|
||||||
#[error("invalid WIT module: {0}")]
|
#[error("invalid WIT module: {0}")]
|
||||||
InvalidManifest(String),
|
InvalidManifest(String),
|
||||||
|
|
||||||
|
/// A manifest, lock file, or local WIT source could not be read.
|
||||||
#[error("failed to read {path}: {source}")]
|
#[error("failed to read {path}: {source}")]
|
||||||
Read { path: PathBuf, source: io::Error },
|
Read {
|
||||||
|
/// File or directory involved in the failed read.
|
||||||
|
path: PathBuf,
|
||||||
|
/// Underlying filesystem error.
|
||||||
|
source: io::Error,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// A lock file or materialized dependency could not be written.
|
||||||
#[error("failed to write {path}: {source}")]
|
#[error("failed to write {path}: {source}")]
|
||||||
Write { path: PathBuf, source: io::Error },
|
Write {
|
||||||
|
/// Destination that could not be updated.
|
||||||
|
path: PathBuf,
|
||||||
|
/// Underlying filesystem error.
|
||||||
|
source: io::Error,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// TOML input could not be decoded.
|
||||||
#[error("failed to parse {path}: {source}")]
|
#[error("failed to parse {path}: {source}")]
|
||||||
ParseManifest {
|
ParseManifest {
|
||||||
|
/// Manifest or lock file containing invalid TOML.
|
||||||
path: PathBuf,
|
path: PathBuf,
|
||||||
|
/// TOML decoder error.
|
||||||
source: toml::de::Error,
|
source: toml::de::Error,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/// A canonical manifest or lock file could not be encoded as TOML.
|
||||||
#[error("failed to serialize {path}: {source}")]
|
#[error("failed to serialize {path}: {source}")]
|
||||||
SerializeManifest {
|
SerializeManifest {
|
||||||
|
/// Destination whose model could not be serialized.
|
||||||
path: PathBuf,
|
path: PathBuf,
|
||||||
|
/// TOML encoder error.
|
||||||
source: toml::ser::Error,
|
source: toml::ser::Error,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/// Local or downloaded bytes are not the requested versioned WIT package.
|
||||||
#[error("invalid WIT package at {path}: {message}")]
|
#[error("invalid WIT package at {path}: {message}")]
|
||||||
InvalidWitPackage { path: PathBuf, message: String },
|
InvalidWitPackage {
|
||||||
|
/// Source path used while validating the package.
|
||||||
|
path: PathBuf,
|
||||||
|
/// Identity, version, parsing, or dependency validation detail.
|
||||||
|
message: String,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// Locked resolution produced a graph different from `wit.lock`.
|
||||||
#[error("locked dependency graph differs from {path}; run `wasmeld wit tidy`")]
|
#[error("locked dependency graph differs from {path}; run `wasmeld wit tidy`")]
|
||||||
LockMismatch { path: PathBuf },
|
LockMismatch {
|
||||||
|
/// Existing lock file that must be updated outside locked mode.
|
||||||
|
path: PathBuf,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// Fetching or validating an immutable Registry artifact failed.
|
||||||
#[error("WIT registry request to {url} failed: {message}")]
|
#[error("WIT registry request to {url} failed: {message}")]
|
||||||
Registry { url: String, message: String },
|
Registry {
|
||||||
|
/// Exact Registry endpoint requested by the resolver.
|
||||||
|
url: String,
|
||||||
|
/// HTTP, size-limit, digest, or package-validation detail.
|
||||||
|
message: String,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ModuleManifest {
|
impl ModuleManifest {
|
||||||
/// Reads and validates a `wasmeld.toml` manifest.
|
/// Reads and validates a `wasmeld.toml` manifest.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns [`ModuleError::Read`], [`ModuleError::ParseManifest`], or
|
||||||
|
/// [`ModuleError::InvalidManifest`] without modifying the source file.
|
||||||
pub fn read(path: impl AsRef<Path>) -> Result<Self, ModuleError> {
|
pub fn read(path: impl AsRef<Path>) -> Result<Self, ModuleError> {
|
||||||
let path = path.as_ref();
|
let path = path.as_ref();
|
||||||
let source = fs::read_to_string(path).map_err(|source| ModuleError::Read {
|
let source = fs::read_to_string(path).map_err(|source| ModuleError::Read {
|
||||||
@@ -205,6 +262,9 @@ impl ModuleManifest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Validates and writes a canonical TOML manifest.
|
/// Validates and writes a canonical TOML manifest.
|
||||||
|
///
|
||||||
|
/// Callers editing replacements should mutate a parsed value and invoke
|
||||||
|
/// this method once, so invalid intermediate state is never persisted.
|
||||||
pub fn write(&self, path: impl AsRef<Path>) -> Result<(), ModuleError> {
|
pub fn write(&self, path: impl AsRef<Path>) -> Result<(), ModuleError> {
|
||||||
self.validate()?;
|
self.validate()?;
|
||||||
let path = path.as_ref();
|
let path = path.as_ref();
|
||||||
@@ -248,6 +308,9 @@ impl ModuleManifest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Sets a package-wide or exact-version path replacement.
|
/// Sets a package-wide or exact-version path replacement.
|
||||||
|
///
|
||||||
|
/// An exact key such as `wasmeld:kv@0.1.0` takes precedence over the
|
||||||
|
/// package-wide `wasmeld:kv` key during resolution.
|
||||||
pub fn set_path_replacement(
|
pub fn set_path_replacement(
|
||||||
&mut self,
|
&mut self,
|
||||||
package: impl Into<String>,
|
package: impl Into<String>,
|
||||||
@@ -311,6 +374,16 @@ impl ModuleLock {
|
|||||||
/// dependencies embedded in Registry artifacts. With `locked = true`, the
|
/// dependencies embedded in Registry artifacts. With `locked = true`, the
|
||||||
/// computed graph must exactly match the existing `wit.lock`; the function does
|
/// computed graph must exactly match the existing `wit.lock`; the function does
|
||||||
/// not update the lock file.
|
/// not update the lock file.
|
||||||
|
///
|
||||||
|
/// The dependency directory is assembled in a temporary sibling directory and
|
||||||
|
/// swapped into place only after the complete graph validates. A failed fetch
|
||||||
|
/// therefore leaves both the lock and materialized dependencies intact.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns an error when the manifest or lock is invalid, a replacement has the
|
||||||
|
/// wrong identity, a Registry package is unavailable or oversized, or two paths
|
||||||
|
/// resolve the same package version to different content.
|
||||||
pub fn sync_dependencies(
|
pub fn sync_dependencies(
|
||||||
manifest_path: impl AsRef<Path>,
|
manifest_path: impl AsRef<Path>,
|
||||||
locked: bool,
|
locked: bool,
|
||||||
|
|||||||
@@ -21,7 +21,9 @@ pub const WIT_PACKAGE_SCHEMA_VERSION: u32 = 1;
|
|||||||
#[derive(Clone, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
|
#[derive(Clone, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
|
||||||
#[serde(deny_unknown_fields)]
|
#[serde(deny_unknown_fields)]
|
||||||
pub struct WitDependency {
|
pub struct WitDependency {
|
||||||
|
/// Package identity in `namespace:name` form.
|
||||||
pub name: String,
|
pub name: String,
|
||||||
|
/// Exact semantic version encoded by the dependency package.
|
||||||
pub version: String,
|
pub version: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -29,32 +31,48 @@ pub struct WitDependency {
|
|||||||
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||||
#[serde(deny_unknown_fields)]
|
#[serde(deny_unknown_fields)]
|
||||||
pub struct WitPackageMetadata {
|
pub struct WitPackageMetadata {
|
||||||
|
/// Metadata response schema used by the Registry API.
|
||||||
pub schema_version: u32,
|
pub schema_version: u32,
|
||||||
|
/// Package identity derived from binary WIT contents.
|
||||||
pub name: String,
|
pub name: String,
|
||||||
|
/// Explicit semantic version derived from binary WIT contents.
|
||||||
pub version: String,
|
pub version: String,
|
||||||
|
/// SHA-256 digest of the complete binary WIT artifact.
|
||||||
pub sha256: String,
|
pub sha256: String,
|
||||||
|
/// Exact direct dependencies embedded in the package.
|
||||||
pub dependencies: Vec<WitDependency>,
|
pub dependencies: Vec<WitDependency>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Encoded package bytes together with metadata derived from those bytes.
|
/// Encoded package bytes together with metadata derived from those bytes.
|
||||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
pub struct BuiltWitPackage {
|
pub struct BuiltWitPackage {
|
||||||
|
/// Metadata derived by decoding the bytes after encoding.
|
||||||
pub metadata: WitPackageMetadata,
|
pub metadata: WitPackageMetadata,
|
||||||
|
/// Standard Component Model binary WIT package.
|
||||||
pub bytes: Vec<u8>,
|
pub bytes: Vec<u8>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Errors raised while parsing WIT source or decoding a binary WIT package.
|
/// Errors raised while parsing WIT source or decoding a binary WIT package.
|
||||||
#[derive(Debug, Error)]
|
#[derive(Debug, Error)]
|
||||||
pub enum WitPackageError {
|
pub enum WitPackageError {
|
||||||
|
/// WIT source could not be parsed or resolved.
|
||||||
#[error("failed to parse WIT package at {path}: {message}")]
|
#[error("failed to parse WIT package at {path}: {message}")]
|
||||||
Source { path: PathBuf, message: String },
|
Source {
|
||||||
|
/// Source file or directory passed to the parser.
|
||||||
|
path: PathBuf,
|
||||||
|
/// Parser or dependency-resolution detail.
|
||||||
|
message: String,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// A resolved WIT package could not be encoded.
|
||||||
#[error("failed to encode WIT package: {0}")]
|
#[error("failed to encode WIT package: {0}")]
|
||||||
Encode(String),
|
Encode(String),
|
||||||
|
|
||||||
|
/// Input bytes are not a standard binary WIT package.
|
||||||
#[error("invalid binary WIT package: {0}")]
|
#[error("invalid binary WIT package: {0}")]
|
||||||
Decode(String),
|
Decode(String),
|
||||||
|
|
||||||
|
/// The package identity omits the version required by the Registry.
|
||||||
#[error("WIT package {0} must declare an explicit semantic version")]
|
#[error("WIT package {0} must declare an explicit semantic version")]
|
||||||
MissingVersion(String),
|
MissingVersion(String),
|
||||||
}
|
}
|
||||||
@@ -64,6 +82,11 @@ pub enum WitPackageError {
|
|||||||
/// The package must declare an explicit semantic version. Dependencies
|
/// The package must declare an explicit semantic version. Dependencies
|
||||||
/// available through the source path are encoded as Component Model package
|
/// available through the source path are encoded as Component Model package
|
||||||
/// references and reported in [`WitPackageMetadata::dependencies`].
|
/// references and reported in [`WitPackageMetadata::dependencies`].
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns an error when WIT parsing, dependency resolution, binary encoding,
|
||||||
|
/// or post-encode inspection fails.
|
||||||
pub fn build_wit_package(path: impl AsRef<Path>) -> Result<BuiltWitPackage, WitPackageError> {
|
pub fn build_wit_package(path: impl AsRef<Path>) -> Result<BuiltWitPackage, WitPackageError> {
|
||||||
let path = path.as_ref();
|
let path = path.as_ref();
|
||||||
let mut resolve = Resolve::default();
|
let mut resolve = Resolve::default();
|
||||||
@@ -83,6 +106,11 @@ pub fn build_wit_package(path: impl AsRef<Path>) -> Result<BuiltWitPackage, WitP
|
|||||||
///
|
///
|
||||||
/// Ordinary WebAssembly Components are rejected even though both formats use a
|
/// Ordinary WebAssembly Components are rejected even though both formats use a
|
||||||
/// Component Model binary container.
|
/// Component Model binary container.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns an error if the bytes are malformed, contain a Component instead of
|
||||||
|
/// a WIT package, or any package in the direct dependency set lacks a version.
|
||||||
pub fn inspect_wit_package(bytes: &[u8]) -> Result<WitPackageMetadata, WitPackageError> {
|
pub fn inspect_wit_package(bytes: &[u8]) -> Result<WitPackageMetadata, WitPackageError> {
|
||||||
let decoded = decode(bytes).map_err(|error| WitPackageError::Decode(error.to_string()))?;
|
let decoded = decode(bytes).map_err(|error| WitPackageError::Decode(error.to_string()))?;
|
||||||
let DecodedWasm::WitPackage(resolve, package) = decoded else {
|
let DecodedWasm::WitPackage(resolve, package) = decoded else {
|
||||||
|
|||||||
@@ -49,10 +49,13 @@ pub trait KvBackend: fmt::Debug + Send + Sync {
|
|||||||
/// Storage failures returned by a [`KvBackend`].
|
/// Storage failures returned by a [`KvBackend`].
|
||||||
#[derive(Debug, Error)]
|
#[derive(Debug, Error)]
|
||||||
pub enum KvBackendError {
|
pub enum KvBackendError {
|
||||||
|
/// Backend queue or connection pool is temporarily saturated.
|
||||||
#[error("KV backend is busy")]
|
#[error("KV backend is busy")]
|
||||||
Busy,
|
Busy,
|
||||||
|
/// Backend did not finish within the Host-call timeout.
|
||||||
#[error("KV backend timed out")]
|
#[error("KV backend timed out")]
|
||||||
Timeout,
|
Timeout,
|
||||||
|
/// Backend rejected or failed the operation for another reason.
|
||||||
#[error("KV backend operation failed: {0}")]
|
#[error("KV backend operation failed: {0}")]
|
||||||
Operation(String),
|
Operation(String),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,98 +9,158 @@ use crate::manifest::ServiceKey;
|
|||||||
/// Failures from Runtime configuration, sandbox validation, or Actor execution.
|
/// Failures from Runtime configuration, sandbox validation, or Actor execution.
|
||||||
#[derive(Debug, Error)]
|
#[derive(Debug, Error)]
|
||||||
pub enum RuntimeError {
|
pub enum RuntimeError {
|
||||||
|
/// Service ID or revision is empty.
|
||||||
#[error("service id and revision must not be empty")]
|
#[error("service id and revision must not be empty")]
|
||||||
InvalidServiceKey,
|
InvalidServiceKey,
|
||||||
|
|
||||||
|
/// Manifest identity, world, path, or resource limits are invalid.
|
||||||
#[error("invalid manifest: {0}")]
|
#[error("invalid manifest: {0}")]
|
||||||
InvalidManifest(String),
|
InvalidManifest(String),
|
||||||
|
|
||||||
|
/// Runtime manifest TOML could not be decoded.
|
||||||
#[error("manifest parse failed: {0}")]
|
#[error("manifest parse failed: {0}")]
|
||||||
ManifestParse(#[from] toml::de::Error),
|
ManifestParse(#[from] toml::de::Error),
|
||||||
|
|
||||||
|
/// A lifecycle operation addressed an unknown service revision.
|
||||||
#[error("service revision {0} is not registered")]
|
#[error("service revision {0} is not registered")]
|
||||||
ServiceNotRegistered(ServiceKey),
|
ServiceNotRegistered(ServiceKey),
|
||||||
|
|
||||||
|
/// Registration attempted to overwrite an immutable service revision.
|
||||||
#[error("service revision {0} is already registered")]
|
#[error("service revision {0} is already registered")]
|
||||||
ServiceAlreadyRegistered(ServiceKey),
|
ServiceAlreadyRegistered(ServiceKey),
|
||||||
|
|
||||||
|
/// Wasmtime rejected the Component before it entered the registry.
|
||||||
#[error("component compilation failed: {0}")]
|
#[error("component compilation failed: {0}")]
|
||||||
ComponentCompilation(#[source] wasmtime::Error),
|
ComponentCompilation(#[source] wasmtime::Error),
|
||||||
|
|
||||||
|
/// The Component imports a WASI or Wasmeld interface outside the allowlist.
|
||||||
#[error("component imports unsupported capability {0}")]
|
#[error("component imports unsupported capability {0}")]
|
||||||
UnsupportedImport(String),
|
UnsupportedImport(String),
|
||||||
|
|
||||||
|
/// A supported capability has no configured Host backend.
|
||||||
#[error("component requires unavailable capability {0}")]
|
#[error("component requires unavailable capability {0}")]
|
||||||
CapabilityUnavailable(String),
|
CapabilityUnavailable(String),
|
||||||
|
|
||||||
|
/// The shared Wasmtime Engine could not be created.
|
||||||
#[error("runtime creation failed: {0}")]
|
#[error("runtime creation failed: {0}")]
|
||||||
RuntimeCreation(#[source] wasmtime::Error),
|
RuntimeCreation(#[source] wasmtime::Error),
|
||||||
|
|
||||||
|
/// The configured compiled-Component cache directory could not be prepared.
|
||||||
#[error("failed to prepare Component compilation cache {path}: {source}")]
|
#[error("failed to prepare Component compilation cache {path}: {source}")]
|
||||||
CacheDirectory {
|
CacheDirectory {
|
||||||
|
/// Requested cache directory.
|
||||||
path: PathBuf,
|
path: PathBuf,
|
||||||
|
/// Underlying filesystem failure.
|
||||||
#[source]
|
#[source]
|
||||||
source: std::io::Error,
|
source: std::io::Error,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/// Wasmtime rejected the persistent cache configuration.
|
||||||
#[error("invalid Component compilation cache configuration: {0}")]
|
#[error("invalid Component compilation cache configuration: {0}")]
|
||||||
CacheConfiguration(#[source] wasmtime::Error),
|
CacheConfiguration(#[source] wasmtime::Error),
|
||||||
|
|
||||||
|
/// A Component path from a manifest could not be read.
|
||||||
#[error("failed to read component artifact {path}: {source}")]
|
#[error("failed to read component artifact {path}: {source}")]
|
||||||
ArtifactRead {
|
ArtifactRead {
|
||||||
|
/// Manifest-provided artifact path.
|
||||||
path: String,
|
path: String,
|
||||||
|
/// Underlying filesystem failure.
|
||||||
#[source]
|
#[source]
|
||||||
source: std::io::Error,
|
source: std::io::Error,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/// The operating system refused to create an Actor worker thread.
|
||||||
#[error("failed to spawn actor thread: {0}")]
|
#[error("failed to spawn actor thread: {0}")]
|
||||||
ActorThread(#[source] std::io::Error),
|
ActorThread(#[source] std::io::Error),
|
||||||
|
|
||||||
|
/// Linking, instantiation, or the Component `init` export failed.
|
||||||
#[error("actor initialization failed: {0}")]
|
#[error("actor initialization failed: {0}")]
|
||||||
ActorInitialization(String),
|
ActorInitialization(String),
|
||||||
|
|
||||||
|
/// The Actor is stopped, faulted, starting, or no longer accepting work.
|
||||||
#[error("actor for {0} is unavailable")]
|
#[error("actor for {0} is unavailable")]
|
||||||
ActorUnavailable(ServiceKey),
|
ActorUnavailable(ServiceKey),
|
||||||
|
|
||||||
|
/// The bounded Actor mailbox has no capacity for another command.
|
||||||
#[error("actor mailbox for {0} is full")]
|
#[error("actor mailbox for {0} is full")]
|
||||||
ActorOverloaded(ServiceKey),
|
ActorOverloaded(ServiceKey),
|
||||||
|
|
||||||
|
/// The worker exited before returning a queued response.
|
||||||
#[error("actor for {0} stopped before returning a result")]
|
#[error("actor for {0} stopped before returning a result")]
|
||||||
ActorStopped(ServiceKey),
|
ActorStopped(ServiceKey),
|
||||||
|
|
||||||
|
/// Resident dispatch was attempted on a passive service Component.
|
||||||
#[error("service revision {0} does not export the resident actor interface")]
|
#[error("service revision {0} does not export the resident actor interface")]
|
||||||
NotResident(ServiceKey),
|
NotResident(ServiceKey),
|
||||||
|
|
||||||
|
/// A Host event payload exceeds its configured resident or input limit.
|
||||||
#[error("resident event for {service} exceeds the {limit}-byte payload limit")]
|
#[error("resident event for {service} exceeds the {limit}-byte payload limit")]
|
||||||
ResidentEventTooLarge { service: ServiceKey, limit: usize },
|
ResidentEventTooLarge {
|
||||||
|
/// Actor revision receiving the event.
|
||||||
|
service: ServiceKey,
|
||||||
|
/// Maximum accepted bytes.
|
||||||
|
limit: usize,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// A Component returned more effects than one event is allowed to produce.
|
||||||
#[error("resident event for {service} returned more than {limit} effects")]
|
#[error("resident event for {service} returned more than {limit} effects")]
|
||||||
TooManyResidentEffects { service: ServiceKey, limit: usize },
|
TooManyResidentEffects {
|
||||||
|
/// Actor revision that returned the effect list.
|
||||||
|
service: ServiceKey,
|
||||||
|
/// Maximum effects accepted per event.
|
||||||
|
limit: usize,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// A raw Component effect has invalid IDs, payloads, names, or timer values.
|
||||||
#[error("invalid resident effect: {0}")]
|
#[error("invalid resident effect: {0}")]
|
||||||
InvalidResidentEffect(String),
|
InvalidResidentEffect(String),
|
||||||
|
|
||||||
|
/// Invocation input exceeds the service manifest limit.
|
||||||
#[error("input for {service} exceeds the {limit}-byte limit")]
|
#[error("input for {service} exceeds the {limit}-byte limit")]
|
||||||
InputTooLarge { service: ServiceKey, limit: usize },
|
InputTooLarge {
|
||||||
|
/// Actor revision receiving the input.
|
||||||
|
service: ServiceKey,
|
||||||
|
/// Maximum accepted bytes.
|
||||||
|
limit: usize,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// Invocation output exceeds the service manifest limit.
|
||||||
#[error("output for {service} exceeds the {limit}-byte limit")]
|
#[error("output for {service} exceeds the {limit}-byte limit")]
|
||||||
OutputTooLarge { service: ServiceKey, limit: usize },
|
OutputTooLarge {
|
||||||
|
/// Actor revision returning the output.
|
||||||
|
service: ServiceKey,
|
||||||
|
/// Maximum accepted bytes.
|
||||||
|
limit: usize,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// Queue waiting and Component execution exceeded the end-to-end deadline.
|
||||||
#[error("actor for {service} exceeded its {deadline:?} execution deadline")]
|
#[error("actor for {service} exceeded its {deadline:?} execution deadline")]
|
||||||
DeadlineExceeded {
|
DeadlineExceeded {
|
||||||
|
/// Actor revision whose command expired.
|
||||||
service: ServiceKey,
|
service: ServiceKey,
|
||||||
|
/// Configured end-to-end command deadline.
|
||||||
deadline: Duration,
|
deadline: Duration,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/// A Wasm trap made the current Actor instance unusable.
|
||||||
#[error("actor for {service} faulted: {message}")]
|
#[error("actor for {service} faulted: {message}")]
|
||||||
ActorFault {
|
ActorFault {
|
||||||
|
/// Faulted Actor revision.
|
||||||
service: ServiceKey,
|
service: ServiceKey,
|
||||||
|
/// Wasmtime trap or call failure detail.
|
||||||
message: String,
|
message: String,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/// Guest code returned a typed WIT error without trapping the Actor.
|
||||||
#[error("component returned {kind}: {message}")]
|
#[error("component returned {kind}: {message}")]
|
||||||
ComponentError { kind: &'static str, message: String },
|
ComponentError {
|
||||||
|
/// Export phase such as `init`, `invoke`, or `resident-event`.
|
||||||
|
kind: &'static str,
|
||||||
|
/// Guest-provided diagnostic text.
|
||||||
|
message: String,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// An internal registry or lifecycle mutex was poisoned by a panic.
|
||||||
#[error("runtime lock was poisoned")]
|
#[error("runtime lock was poisoned")]
|
||||||
LockPoisoned,
|
LockPoisoned,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,34 @@
|
|||||||
//! It provides a restricted WASI compatibility context, links explicitly
|
//! It provides a restricted WASI compatibility context, links explicitly
|
||||||
//! imported host capabilities, loads a Component that implements the stable
|
//! imported host capabilities, loads a Component that implements the stable
|
||||||
//! service exports, and invokes it through one serial Actor.
|
//! service exports, and invokes it through one serial Actor.
|
||||||
|
//!
|
||||||
|
//! # Request/response example
|
||||||
|
//!
|
||||||
|
//! ```no_run
|
||||||
|
//! use wasmeld_runtime::{ResourceLimits, Runtime, RuntimeConfig, ServiceManifest};
|
||||||
|
//!
|
||||||
|
//! let runtime = Runtime::new(RuntimeConfig::default())?;
|
||||||
|
//! let manifest = ServiceManifest {
|
||||||
|
//! id: "echo".into(),
|
||||||
|
//! revision: "1.0.0".into(),
|
||||||
|
//! component: "echo.wasm".into(),
|
||||||
|
//! world: "component:echo/echo-component@1.0.0".into(),
|
||||||
|
//! limits: ResourceLimits::default(),
|
||||||
|
//! };
|
||||||
|
//! let bytes = std::fs::read(&manifest.component)?;
|
||||||
|
//! let key = runtime.register(manifest, bytes)?;
|
||||||
|
//! let actor = runtime.start(&key, Vec::new())?;
|
||||||
|
//! assert_eq!(actor.invoke(b"hello".to_vec())?, b"hello");
|
||||||
|
//! runtime.stop(&key)?;
|
||||||
|
//! # Ok::<(), Box<dyn std::error::Error>>(())
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! A started Actor retains Component memory between calls, but Component code
|
||||||
|
//! is never allowed to monopolize the thread between calls. Active timers,
|
||||||
|
//! sockets, subscriptions, and other long-lived resources are Host-owned and
|
||||||
|
//! enter resident Components as bounded events through [`ResidentSession`].
|
||||||
|
|
||||||
|
#![warn(missing_docs)]
|
||||||
|
|
||||||
mod bindings;
|
mod bindings;
|
||||||
mod capability;
|
mod capability;
|
||||||
|
|||||||
@@ -5,6 +5,11 @@
|
|||||||
//! The Component never receives an operating-system descriptor. It can only
|
//! The Component never receives an operating-system descriptor. It can only
|
||||||
//! reference opaque [`ResourceId`] values and return [`ResidentEffect`] values
|
//! reference opaque [`ResourceId`] values and return [`ResidentEffect`] values
|
||||||
//! for the owning driver to validate and apply.
|
//! for the owning driver to validate and apply.
|
||||||
|
//!
|
||||||
|
//! Driver authors normally use [`ResidentSession`](crate::ResidentSession)
|
||||||
|
//! instead of constructing events directly. The session proves that every ID
|
||||||
|
//! belongs to the current service revision and converts raw effects into
|
||||||
|
//! validated [`ResidentOperation`](crate::ResidentOperation) values.
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
@@ -46,10 +51,15 @@ impl ResourceId {
|
|||||||
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||||
#[serde(rename_all = "snake_case")]
|
#[serde(rename_all = "snake_case")]
|
||||||
pub enum StreamCloseReason {
|
pub enum StreamCloseReason {
|
||||||
|
/// The remote peer completed or reset the connection.
|
||||||
PeerClosed,
|
PeerClosed,
|
||||||
|
/// The platform or Component requested an orderly close.
|
||||||
HostClosed,
|
HostClosed,
|
||||||
|
/// The Host driver enforced its configured inactivity timeout.
|
||||||
IdleTimeout,
|
IdleTimeout,
|
||||||
|
/// A protocol driver rejected malformed or out-of-order bytes.
|
||||||
ProtocolError,
|
ProtocolError,
|
||||||
|
/// The operating system or transport driver reported an I/O failure.
|
||||||
TransportError,
|
TransportError,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,44 +67,77 @@ pub enum StreamCloseReason {
|
|||||||
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||||
#[serde(tag = "kind", rename_all = "snake_case")]
|
#[serde(tag = "kind", rename_all = "snake_case")]
|
||||||
pub enum ResidentEvent {
|
pub enum ResidentEvent {
|
||||||
|
/// A registered Host timer reached its deadline.
|
||||||
Timer {
|
Timer {
|
||||||
|
/// Timer resource previously registered with the session.
|
||||||
timer_id: ResourceId,
|
timer_id: ResourceId,
|
||||||
|
/// Intended firing instant on the Host monotonic clock, in nanoseconds.
|
||||||
|
///
|
||||||
|
/// This is not wall-clock time and may be earlier than delivery when
|
||||||
|
/// the Actor mailbox is busy.
|
||||||
scheduled_at_ns: u64,
|
scheduled_at_ns: u64,
|
||||||
},
|
},
|
||||||
|
/// A TCP or Unix listener accepted a new byte stream.
|
||||||
StreamOpened {
|
StreamOpened {
|
||||||
|
/// Listener resource that accepted the connection.
|
||||||
endpoint_id: ResourceId,
|
endpoint_id: ResourceId,
|
||||||
|
/// Newly allocated stream resource scoped to this Actor revision.
|
||||||
stream_id: ResourceId,
|
stream_id: ResourceId,
|
||||||
|
/// Driver-formatted peer address, when the transport provides one.
|
||||||
peer: Option<String>,
|
peer: Option<String>,
|
||||||
},
|
},
|
||||||
|
/// A bounded chunk was read from an open stream.
|
||||||
StreamData {
|
StreamData {
|
||||||
|
/// Stream that produced the bytes.
|
||||||
stream_id: ResourceId,
|
stream_id: ResourceId,
|
||||||
|
/// Raw transport bytes; framing remains the Component or protocol driver's job.
|
||||||
bytes: Vec<u8>,
|
bytes: Vec<u8>,
|
||||||
},
|
},
|
||||||
|
/// Backpressure cleared and the stream can accept another write.
|
||||||
StreamWritable {
|
StreamWritable {
|
||||||
|
/// Stream whose driver write buffer has capacity.
|
||||||
stream_id: ResourceId,
|
stream_id: ResourceId,
|
||||||
},
|
},
|
||||||
|
/// The peer sent EOF but the local write half remains available.
|
||||||
StreamHalfClosed {
|
StreamHalfClosed {
|
||||||
|
/// Stream entering the peer-half-closed state.
|
||||||
stream_id: ResourceId,
|
stream_id: ResourceId,
|
||||||
},
|
},
|
||||||
|
/// A stream reached terminal state and will not produce more events.
|
||||||
StreamClosed {
|
StreamClosed {
|
||||||
|
/// Stream being released after this callback.
|
||||||
stream_id: ResourceId,
|
stream_id: ResourceId,
|
||||||
|
/// Terminal reason reported by the Host driver.
|
||||||
reason: StreamCloseReason,
|
reason: StreamCloseReason,
|
||||||
},
|
},
|
||||||
|
/// One UDP packet arrived on a registered datagram endpoint.
|
||||||
Datagram {
|
Datagram {
|
||||||
|
/// UDP endpoint that received the packet.
|
||||||
endpoint_id: ResourceId,
|
endpoint_id: ResourceId,
|
||||||
|
/// Driver-formatted source address used for a possible reply.
|
||||||
peer: String,
|
peer: String,
|
||||||
|
/// Complete datagram payload; datagram boundaries are preserved.
|
||||||
bytes: Vec<u8>,
|
bytes: Vec<u8>,
|
||||||
},
|
},
|
||||||
|
/// A broker or internal queue delivered one message.
|
||||||
Message {
|
Message {
|
||||||
|
/// Host-owned subscription that delivered the message.
|
||||||
subscription_id: ResourceId,
|
subscription_id: ResourceId,
|
||||||
|
/// Driver-scoped delivery identity used when acknowledging.
|
||||||
message_id: u64,
|
message_id: u64,
|
||||||
|
/// Opaque message body.
|
||||||
bytes: Vec<u8>,
|
bytes: Vec<u8>,
|
||||||
},
|
},
|
||||||
|
/// An event arrived from a separately versioned extension driver.
|
||||||
Source {
|
Source {
|
||||||
|
/// Registered extension source, such as a file watcher or serial port.
|
||||||
source_id: ResourceId,
|
source_id: ResourceId,
|
||||||
|
/// Capability-defined event name.
|
||||||
event: String,
|
event: String,
|
||||||
|
/// Capability-defined binary payload.
|
||||||
payload: Vec<u8>,
|
payload: Vec<u8>,
|
||||||
},
|
},
|
||||||
|
/// The deployment is draining and no later events should be accepted.
|
||||||
Shutdown,
|
Shutdown,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -105,39 +148,65 @@ pub enum ResidentEvent {
|
|||||||
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||||
#[serde(tag = "kind", rename_all = "snake_case")]
|
#[serde(tag = "kind", rename_all = "snake_case")]
|
||||||
pub enum ResidentEffect {
|
pub enum ResidentEffect {
|
||||||
|
/// Queue bytes for an open stream.
|
||||||
WriteStream {
|
WriteStream {
|
||||||
|
/// Stream to write; it must belong to the current session.
|
||||||
stream_id: ResourceId,
|
stream_id: ResourceId,
|
||||||
|
/// One bounded write chunk.
|
||||||
bytes: Vec<u8>,
|
bytes: Vec<u8>,
|
||||||
},
|
},
|
||||||
|
/// Ask the driver to close both halves of a stream.
|
||||||
CloseStream {
|
CloseStream {
|
||||||
|
/// Stream to transition into `closing`.
|
||||||
stream_id: ResourceId,
|
stream_id: ResourceId,
|
||||||
},
|
},
|
||||||
|
/// Stop delivering read events until a matching resume effect.
|
||||||
PauseStream {
|
PauseStream {
|
||||||
|
/// Stream whose read side should be paused.
|
||||||
stream_id: ResourceId,
|
stream_id: ResourceId,
|
||||||
},
|
},
|
||||||
|
/// Resume read delivery after Component backpressure clears.
|
||||||
ResumeStream {
|
ResumeStream {
|
||||||
|
/// Previously paused stream.
|
||||||
stream_id: ResourceId,
|
stream_id: ResourceId,
|
||||||
},
|
},
|
||||||
|
/// Send one UDP packet while preserving datagram boundaries.
|
||||||
SendDatagram {
|
SendDatagram {
|
||||||
|
/// Registered UDP endpoint used to send the packet.
|
||||||
endpoint_id: ResourceId,
|
endpoint_id: ResourceId,
|
||||||
|
/// Driver-formatted destination address.
|
||||||
peer: String,
|
peer: String,
|
||||||
|
/// Complete bounded datagram payload.
|
||||||
bytes: Vec<u8>,
|
bytes: Vec<u8>,
|
||||||
},
|
},
|
||||||
|
/// Schedule or reschedule a registered Host timer.
|
||||||
ArmTimer {
|
ArmTimer {
|
||||||
|
/// Timer resource to schedule.
|
||||||
timer_id: ResourceId,
|
timer_id: ResourceId,
|
||||||
|
/// Delay from the time the Host applies this effect.
|
||||||
delay_ms: u64,
|
delay_ms: u64,
|
||||||
|
/// Optional repeat period; `None` creates a one-shot timer.
|
||||||
interval_ms: Option<u64>,
|
interval_ms: Option<u64>,
|
||||||
},
|
},
|
||||||
|
/// Cancel a registered timer.
|
||||||
CancelTimer {
|
CancelTimer {
|
||||||
|
/// Timer whose pending occurrence should be removed.
|
||||||
timer_id: ResourceId,
|
timer_id: ResourceId,
|
||||||
},
|
},
|
||||||
|
/// Confirm successful processing of a delivered message.
|
||||||
AcknowledgeMessage {
|
AcknowledgeMessage {
|
||||||
|
/// Subscription that produced the delivery.
|
||||||
subscription_id: ResourceId,
|
subscription_id: ResourceId,
|
||||||
|
/// Exact message identity received in [`ResidentEvent::Message`].
|
||||||
message_id: u64,
|
message_id: u64,
|
||||||
},
|
},
|
||||||
|
/// Send a capability-defined command to an extension driver.
|
||||||
SourceCommand {
|
SourceCommand {
|
||||||
|
/// Extension source that should execute the command.
|
||||||
source_id: ResourceId,
|
source_id: ResourceId,
|
||||||
|
/// Capability-defined command name.
|
||||||
command: String,
|
command: String,
|
||||||
|
/// Capability-defined bounded binary payload.
|
||||||
payload: Vec<u8>,
|
payload: Vec<u8>,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -86,8 +86,13 @@ impl NetworkScope {
|
|||||||
/// between validation and use.
|
/// between validation and use.
|
||||||
#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
|
#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
|
||||||
pub struct ResidentPolicy {
|
pub struct ResidentPolicy {
|
||||||
|
/// Addresses on which TCP listener drivers may bind.
|
||||||
pub tcp_listen: NetworkScope,
|
pub tcp_listen: NetworkScope,
|
||||||
|
/// Addresses on which UDP drivers may bind.
|
||||||
pub udp_bind: NetworkScope,
|
pub udp_bind: NetworkScope,
|
||||||
|
/// Canonical parent directories allowed to contain Unix listener sockets.
|
||||||
|
///
|
||||||
|
/// An empty list disables Unix endpoints.
|
||||||
pub unix_listen_roots: Vec<PathBuf>,
|
pub unix_listen_roots: Vec<PathBuf>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -99,9 +104,27 @@ pub struct ResidentPolicy {
|
|||||||
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||||
#[serde(tag = "transport", rename_all = "snake_case")]
|
#[serde(tag = "transport", rename_all = "snake_case")]
|
||||||
pub enum ResidentEndpoint {
|
pub enum ResidentEndpoint {
|
||||||
Tcp { name: String, bind: SocketAddr },
|
/// Host-owned TCP listener that produces byte-stream resources.
|
||||||
Udp { name: String, bind: SocketAddr },
|
Tcp {
|
||||||
Unix { name: String, path: PathBuf },
|
/// Unique operator-facing name within the session.
|
||||||
|
name: String,
|
||||||
|
/// Numeric address checked against [`ResidentPolicy::tcp_listen`].
|
||||||
|
bind: SocketAddr,
|
||||||
|
},
|
||||||
|
/// Host-owned UDP socket that preserves datagram boundaries.
|
||||||
|
Udp {
|
||||||
|
/// Unique operator-facing name within the session.
|
||||||
|
name: String,
|
||||||
|
/// Numeric address checked against [`ResidentPolicy::udp_bind`].
|
||||||
|
bind: SocketAddr,
|
||||||
|
},
|
||||||
|
/// Host-owned Unix stream listener.
|
||||||
|
Unix {
|
||||||
|
/// Unique operator-facing name within the session.
|
||||||
|
name: String,
|
||||||
|
/// Absolute socket path beneath an allowed canonical root.
|
||||||
|
path: PathBuf,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ResidentEndpoint {
|
impl ResidentEndpoint {
|
||||||
@@ -116,12 +139,19 @@ impl ResidentEndpoint {
|
|||||||
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||||
#[serde(rename_all = "snake_case")]
|
#[serde(rename_all = "snake_case")]
|
||||||
pub enum ResidentResourceKind {
|
pub enum ResidentResourceKind {
|
||||||
|
/// TCP listener endpoint.
|
||||||
TcpEndpoint,
|
TcpEndpoint,
|
||||||
|
/// UDP datagram endpoint.
|
||||||
UdpEndpoint,
|
UdpEndpoint,
|
||||||
|
/// Unix stream listener endpoint.
|
||||||
UnixEndpoint,
|
UnixEndpoint,
|
||||||
|
/// Accepted TCP or Unix byte stream.
|
||||||
Stream,
|
Stream,
|
||||||
|
/// Host scheduler timer.
|
||||||
Timer,
|
Timer,
|
||||||
|
/// Queue, broker, or internal mailbox subscription.
|
||||||
MessageSubscription,
|
MessageSubscription,
|
||||||
|
/// Separately versioned extension driver.
|
||||||
Source,
|
Source,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -129,20 +159,30 @@ pub enum ResidentResourceKind {
|
|||||||
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||||
#[serde(rename_all = "snake_case")]
|
#[serde(rename_all = "snake_case")]
|
||||||
pub enum ResidentResourceState {
|
pub enum ResidentResourceState {
|
||||||
|
/// Non-stream resource is registered and available to its driver.
|
||||||
Ready,
|
Ready,
|
||||||
|
/// Stream accepts read and write activity.
|
||||||
Open,
|
Open,
|
||||||
|
/// Component backpressure has temporarily disabled read delivery.
|
||||||
ReadPaused,
|
ReadPaused,
|
||||||
|
/// Peer sent EOF; local writes are still permitted.
|
||||||
PeerHalfClosed,
|
PeerHalfClosed,
|
||||||
|
/// Close was requested and only the terminal close event remains valid.
|
||||||
Closing,
|
Closing,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Management-safe snapshot of one Host-owned resource.
|
/// Management-safe snapshot of one Host-owned resource.
|
||||||
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||||
pub struct ResidentResourceInfo {
|
pub struct ResidentResourceInfo {
|
||||||
|
/// Opaque identity scoped to one Actor revision.
|
||||||
pub id: ResourceId,
|
pub id: ResourceId,
|
||||||
|
/// Operator-facing name used in status and diagnostics.
|
||||||
pub name: String,
|
pub name: String,
|
||||||
|
/// Stable resource category.
|
||||||
pub kind: ResidentResourceKind,
|
pub kind: ResidentResourceKind,
|
||||||
|
/// Current state after all validated effects have been applied.
|
||||||
pub state: ResidentResourceState,
|
pub state: ResidentResourceState,
|
||||||
|
/// Driver correlation details that do not expose operating-system handles.
|
||||||
pub metadata: ResidentResourceMetadata,
|
pub metadata: ResidentResourceMetadata,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -150,11 +190,28 @@ pub struct ResidentResourceInfo {
|
|||||||
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||||
#[serde(tag = "kind", rename_all = "snake_case")]
|
#[serde(tag = "kind", rename_all = "snake_case")]
|
||||||
pub enum ResidentResourceMetadata {
|
pub enum ResidentResourceMetadata {
|
||||||
Endpoint { endpoint: ResidentEndpoint },
|
/// Complete management configuration for a listener or datagram endpoint.
|
||||||
Stream { endpoint_id: ResourceId },
|
Endpoint {
|
||||||
Timer { armed: bool },
|
/// Validated endpoint definition.
|
||||||
|
endpoint: ResidentEndpoint,
|
||||||
|
},
|
||||||
|
/// Parent listener of an accepted stream.
|
||||||
|
Stream {
|
||||||
|
/// TCP or Unix endpoint that accepted this stream.
|
||||||
|
endpoint_id: ResourceId,
|
||||||
|
},
|
||||||
|
/// Current scheduler status for a timer.
|
||||||
|
Timer {
|
||||||
|
/// Whether an arm operation is waiting to fire.
|
||||||
|
armed: bool,
|
||||||
|
},
|
||||||
|
/// Queue or broker details remain private to its driver.
|
||||||
MessageSubscription,
|
MessageSubscription,
|
||||||
Source { source_kind: String },
|
/// Extension source identity.
|
||||||
|
Source {
|
||||||
|
/// Versioned capability name understood by the Component and driver.
|
||||||
|
source_kind: String,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
/// An operation that passed payload, ownership, kind, and state validation.
|
/// An operation that passed payload, ownership, kind, and state validation.
|
||||||
@@ -164,39 +221,65 @@ pub enum ResidentResourceMetadata {
|
|||||||
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||||
#[serde(tag = "kind", rename_all = "snake_case")]
|
#[serde(tag = "kind", rename_all = "snake_case")]
|
||||||
pub enum ResidentOperation {
|
pub enum ResidentOperation {
|
||||||
|
/// Queue a bounded chunk on an open stream.
|
||||||
WriteStream {
|
WriteStream {
|
||||||
|
/// Target stream resource.
|
||||||
stream_id: ResourceId,
|
stream_id: ResourceId,
|
||||||
|
/// Bytes to enqueue in order.
|
||||||
bytes: Vec<u8>,
|
bytes: Vec<u8>,
|
||||||
},
|
},
|
||||||
|
/// Close a stream and later report its terminal close event.
|
||||||
CloseStream {
|
CloseStream {
|
||||||
|
/// Target stream resource.
|
||||||
stream_id: ResourceId,
|
stream_id: ResourceId,
|
||||||
},
|
},
|
||||||
|
/// Disable driver reads without closing the stream.
|
||||||
PauseStream {
|
PauseStream {
|
||||||
|
/// Target stream resource.
|
||||||
stream_id: ResourceId,
|
stream_id: ResourceId,
|
||||||
},
|
},
|
||||||
|
/// Re-enable reads on a previously paused stream.
|
||||||
ResumeStream {
|
ResumeStream {
|
||||||
|
/// Target stream resource.
|
||||||
stream_id: ResourceId,
|
stream_id: ResourceId,
|
||||||
},
|
},
|
||||||
|
/// Send one complete UDP datagram.
|
||||||
SendDatagram {
|
SendDatagram {
|
||||||
|
/// UDP endpoint used for the send.
|
||||||
endpoint_id: ResourceId,
|
endpoint_id: ResourceId,
|
||||||
|
/// Driver-formatted destination address.
|
||||||
peer: String,
|
peer: String,
|
||||||
|
/// Complete datagram payload.
|
||||||
bytes: Vec<u8>,
|
bytes: Vec<u8>,
|
||||||
},
|
},
|
||||||
|
/// Create or replace a timer schedule.
|
||||||
ArmTimer {
|
ArmTimer {
|
||||||
|
/// Registered timer resource.
|
||||||
timer_id: ResourceId,
|
timer_id: ResourceId,
|
||||||
|
/// Delay measured from operation application.
|
||||||
delay_ms: u64,
|
delay_ms: u64,
|
||||||
|
/// Optional repeating interval.
|
||||||
interval_ms: Option<u64>,
|
interval_ms: Option<u64>,
|
||||||
},
|
},
|
||||||
|
/// Remove a pending timer schedule.
|
||||||
CancelTimer {
|
CancelTimer {
|
||||||
|
/// Registered timer resource.
|
||||||
timer_id: ResourceId,
|
timer_id: ResourceId,
|
||||||
},
|
},
|
||||||
|
/// Acknowledge a delivered message after Component processing succeeds.
|
||||||
AcknowledgeMessage {
|
AcknowledgeMessage {
|
||||||
|
/// Subscription that produced the message.
|
||||||
subscription_id: ResourceId,
|
subscription_id: ResourceId,
|
||||||
|
/// Driver-scoped delivery identity.
|
||||||
message_id: u64,
|
message_id: u64,
|
||||||
},
|
},
|
||||||
|
/// Invoke a separately versioned extension driver command.
|
||||||
SourceCommand {
|
SourceCommand {
|
||||||
|
/// Target extension source.
|
||||||
source_id: ResourceId,
|
source_id: ResourceId,
|
||||||
|
/// Capability-defined command name.
|
||||||
command: String,
|
command: String,
|
||||||
|
/// Capability-defined binary payload.
|
||||||
payload: Vec<u8>,
|
payload: Vec<u8>,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -204,51 +287,78 @@ pub enum ResidentOperation {
|
|||||||
/// Host resource, policy, or Actor failure while processing resident work.
|
/// Host resource, policy, or Actor failure while processing resident work.
|
||||||
#[derive(Debug, Error)]
|
#[derive(Debug, Error)]
|
||||||
pub enum ResidentHostError {
|
pub enum ResidentHostError {
|
||||||
|
/// Actor dispatch, deadline, trap, or Component contract failure.
|
||||||
#[error(transparent)]
|
#[error(transparent)]
|
||||||
Runtime(#[from] RuntimeError),
|
Runtime(#[from] RuntimeError),
|
||||||
|
|
||||||
|
/// An event or resource registration was attempted after shutdown began.
|
||||||
#[error("resident session for {0} is shutting down")]
|
#[error("resident session for {0} is shutting down")]
|
||||||
ShuttingDown(ServiceKey),
|
ShuttingDown(ServiceKey),
|
||||||
|
|
||||||
|
/// The Actor revision reached its configured Host-resource count.
|
||||||
#[error("resident resource limit for {service} is {limit}")]
|
#[error("resident resource limit for {service} is {limit}")]
|
||||||
ResourceLimit { service: ServiceKey, limit: usize },
|
ResourceLimit {
|
||||||
|
/// Actor revision that owns the full resource table.
|
||||||
|
service: ServiceKey,
|
||||||
|
/// Maximum simultaneous Host resources.
|
||||||
|
limit: usize,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// The monotonic `u64` ID space was exhausted.
|
||||||
#[error("resident resource ids exhausted for {0}")]
|
#[error("resident resource ids exhausted for {0}")]
|
||||||
ResourceIdsExhausted(ServiceKey),
|
ResourceIdsExhausted(ServiceKey),
|
||||||
|
|
||||||
|
/// An operator-facing endpoint or source name was empty.
|
||||||
#[error("resident resource name must not be empty")]
|
#[error("resident resource name must not be empty")]
|
||||||
EmptyResourceName,
|
EmptyResourceName,
|
||||||
|
|
||||||
|
/// A named top-level resource conflicts with an existing name.
|
||||||
#[error("resident resource name {0} is already registered")]
|
#[error("resident resource name {0} is already registered")]
|
||||||
DuplicateResourceName(String),
|
DuplicateResourceName(String),
|
||||||
|
|
||||||
|
/// An endpoint cannot be released until all accepted streams are released.
|
||||||
#[error("resident resource {resource_id} for {service} still owns active streams")]
|
#[error("resident resource {resource_id} for {service} still owns active streams")]
|
||||||
ResourceInUse {
|
ResourceInUse {
|
||||||
|
/// Owning Actor revision.
|
||||||
service: ServiceKey,
|
service: ServiceKey,
|
||||||
|
/// Endpoint that still has child streams.
|
||||||
resource_id: u64,
|
resource_id: u64,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/// An ID is unknown, released, or belongs to another session.
|
||||||
#[error("resident resource {resource_id} does not belong to {service}")]
|
#[error("resident resource {resource_id} does not belong to {service}")]
|
||||||
UnknownResource {
|
UnknownResource {
|
||||||
|
/// Session in which the lookup was attempted.
|
||||||
service: ServiceKey,
|
service: ServiceKey,
|
||||||
|
/// Unknown opaque ID.
|
||||||
resource_id: u64,
|
resource_id: u64,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/// An operation addressed the wrong category of resource.
|
||||||
#[error("resident resource {resource_id} for {service} is {actual:?}, expected {expected}")]
|
#[error("resident resource {resource_id} for {service} is {actual:?}, expected {expected}")]
|
||||||
WrongResourceKind {
|
WrongResourceKind {
|
||||||
|
/// Owning Actor revision.
|
||||||
service: ServiceKey,
|
service: ServiceKey,
|
||||||
|
/// Addressed opaque ID.
|
||||||
resource_id: u64,
|
resource_id: u64,
|
||||||
|
/// Actual category stored for the ID.
|
||||||
actual: ResidentResourceKind,
|
actual: ResidentResourceKind,
|
||||||
|
/// Human-readable category required by the operation.
|
||||||
expected: &'static str,
|
expected: &'static str,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/// The resource exists but the requested transition is not legal.
|
||||||
#[error("resident resource {resource_id} for {service} is in invalid state {state:?}")]
|
#[error("resident resource {resource_id} for {service} is in invalid state {state:?}")]
|
||||||
InvalidResourceState {
|
InvalidResourceState {
|
||||||
|
/// Owning Actor revision.
|
||||||
service: ServiceKey,
|
service: ServiceKey,
|
||||||
|
/// Addressed opaque ID.
|
||||||
resource_id: u64,
|
resource_id: u64,
|
||||||
|
/// State that rejected the transition.
|
||||||
state: ResidentResourceState,
|
state: ResidentResourceState,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/// A bind address or Unix path falls outside platform policy.
|
||||||
#[error("resident endpoint denied by policy: {0}")]
|
#[error("resident endpoint denied by policy: {0}")]
|
||||||
EndpointDenied(String),
|
EndpointDenied(String),
|
||||||
}
|
}
|
||||||
@@ -333,6 +443,47 @@ impl From<StreamState> for ResidentResourceState {
|
|||||||
/// This type intentionally performs no operating-system I/O. It makes driver
|
/// This type intentionally performs no operating-system I/O. It makes driver
|
||||||
/// implementations replaceable and keeps Tokio or another async executor out
|
/// implementations replaceable and keeps Tokio or another async executor out
|
||||||
/// of the Wasmtime Actor thread.
|
/// of the Wasmtime Actor thread.
|
||||||
|
///
|
||||||
|
/// # Driver example
|
||||||
|
///
|
||||||
|
/// ```no_run
|
||||||
|
/// use std::net::SocketAddr;
|
||||||
|
/// use wasmeld_runtime::{
|
||||||
|
/// ActorHandle, NetworkScope, ResidentEndpoint, ResidentOperation,
|
||||||
|
/// ResidentPolicy, ResidentSession,
|
||||||
|
/// };
|
||||||
|
///
|
||||||
|
/// # let actor: ActorHandle = todo!("start a resident Actor with Runtime::start");
|
||||||
|
/// let policy = ResidentPolicy {
|
||||||
|
/// tcp_listen: NetworkScope::Loopback,
|
||||||
|
/// ..ResidentPolicy::default()
|
||||||
|
/// };
|
||||||
|
/// let mut session = ResidentSession::new(actor, policy)?;
|
||||||
|
/// let endpoint_id = session.register_endpoint(ResidentEndpoint::Tcp {
|
||||||
|
/// name: "local-api".into(),
|
||||||
|
/// bind: "127.0.0.1:9000".parse::<SocketAddr>()?,
|
||||||
|
/// })?;
|
||||||
|
///
|
||||||
|
/// // The Tokio driver binds only after policy validation. For every accepted
|
||||||
|
/// // socket it allocates a stream, then applies returned operations in order.
|
||||||
|
/// let (stream_id, operations) =
|
||||||
|
/// session.accept_stream(endpoint_id, Some("127.0.0.1:51000".into()))?;
|
||||||
|
/// for operation in operations {
|
||||||
|
/// match operation {
|
||||||
|
/// ResidentOperation::WriteStream { stream_id, bytes } => {
|
||||||
|
/// // Queue `bytes` on the driver handle mapped to `stream_id`.
|
||||||
|
/// }
|
||||||
|
/// _ => { /* apply the other validated operation variants */ }
|
||||||
|
/// }
|
||||||
|
/// }
|
||||||
|
/// # let _ = stream_id;
|
||||||
|
/// # Ok::<(), Box<dyn std::error::Error>>(())
|
||||||
|
/// ```
|
||||||
|
///
|
||||||
|
/// The session is deliberately `&mut self`: one supervisor task must serialize
|
||||||
|
/// all driver events. Do not put separate per-socket tasks around independent
|
||||||
|
/// copies of its state, because resource transitions and Component memory must
|
||||||
|
/// observe the same total order.
|
||||||
pub struct ResidentSession {
|
pub struct ResidentSession {
|
||||||
actor: ActorHandle,
|
actor: ActorHandle,
|
||||||
policy: ResidentPolicy,
|
policy: ResidentPolicy,
|
||||||
@@ -343,6 +494,9 @@ pub struct ResidentSession {
|
|||||||
|
|
||||||
impl ResidentSession {
|
impl ResidentSession {
|
||||||
/// Creates a resource session for an Actor exporting resident WIT 0.1.0.
|
/// Creates a resource session for an Actor exporting resident WIT 0.1.0.
|
||||||
|
///
|
||||||
|
/// Resource IDs start at one and are never reused during this session.
|
||||||
|
/// Creating a new deployment revision requires a new session.
|
||||||
pub fn new(actor: ActorHandle, policy: ResidentPolicy) -> Result<Self, ResidentHostError> {
|
pub fn new(actor: ActorHandle, policy: ResidentPolicy) -> Result<Self, ResidentHostError> {
|
||||||
if actor.execution() != ComponentExecution::Resident {
|
if actor.execution() != ComponentExecution::Resident {
|
||||||
return Err(RuntimeError::NotResident(actor.key().clone()).into());
|
return Err(RuntimeError::NotResident(actor.key().clone()).into());
|
||||||
@@ -362,6 +516,9 @@ impl ResidentSession {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Registers a configured listener or datagram endpoint after policy checks.
|
/// Registers a configured listener or datagram endpoint after policy checks.
|
||||||
|
///
|
||||||
|
/// Registration does not bind an operating-system socket. The driver binds
|
||||||
|
/// after this succeeds and calls [`Self::release_resource`] if bind fails.
|
||||||
pub fn register_endpoint(
|
pub fn register_endpoint(
|
||||||
&mut self,
|
&mut self,
|
||||||
endpoint: ResidentEndpoint,
|
endpoint: ResidentEndpoint,
|
||||||
@@ -422,6 +579,9 @@ impl ResidentSession {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Allocates a stream for a connection accepted by a TCP or Unix driver.
|
/// Allocates a stream for a connection accepted by a TCP or Unix driver.
|
||||||
|
///
|
||||||
|
/// If Component dispatch fails, the allocation is rolled back and the
|
||||||
|
/// caller must close the accepted operating-system socket.
|
||||||
pub fn accept_stream(
|
pub fn accept_stream(
|
||||||
&mut self,
|
&mut self,
|
||||||
endpoint_id: ResourceId,
|
endpoint_id: ResourceId,
|
||||||
@@ -457,6 +617,10 @@ impl ResidentSession {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Delivers one bounded stream read. Paused and closing streams reject data.
|
/// Delivers one bounded stream read. Paused and closing streams reject data.
|
||||||
|
///
|
||||||
|
/// Drivers must stop reading immediately after applying
|
||||||
|
/// [`ResidentOperation::PauseStream`]; queued data should remain in the
|
||||||
|
/// driver rather than bypassing Component backpressure.
|
||||||
pub fn stream_data(
|
pub fn stream_data(
|
||||||
&mut self,
|
&mut self,
|
||||||
stream_id: ResourceId,
|
stream_id: ResourceId,
|
||||||
@@ -490,6 +654,10 @@ impl ResidentSession {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Delivers terminal stream state and releases the resource identity.
|
/// Delivers terminal stream state and releases the resource identity.
|
||||||
|
///
|
||||||
|
/// The identity is released even when the Component callback fails because
|
||||||
|
/// terminal transport state is already an external fact. IDs remain
|
||||||
|
/// non-reusable, so a late driver event is rejected as unknown.
|
||||||
pub fn stream_closed(
|
pub fn stream_closed(
|
||||||
&mut self,
|
&mut self,
|
||||||
stream_id: ResourceId,
|
stream_id: ResourceId,
|
||||||
@@ -521,6 +689,10 @@ impl ResidentSession {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Delivers a timer occurrence.
|
/// Delivers a timer occurrence.
|
||||||
|
///
|
||||||
|
/// `scheduled_at_ns` is the driver's monotonic deadline, not wall time. The
|
||||||
|
/// timer is marked unarmed before dispatch; a returned arm operation creates
|
||||||
|
/// the next schedule.
|
||||||
pub fn timer_fired(
|
pub fn timer_fired(
|
||||||
&mut self,
|
&mut self,
|
||||||
timer_id: ResourceId,
|
timer_id: ResourceId,
|
||||||
@@ -537,6 +709,9 @@ impl ResidentSession {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Delivers a broker or internal queue message.
|
/// Delivers a broker or internal queue message.
|
||||||
|
///
|
||||||
|
/// A driver acknowledges externally only after it receives and successfully
|
||||||
|
/// applies [`ResidentOperation::AcknowledgeMessage`].
|
||||||
pub fn message(
|
pub fn message(
|
||||||
&mut self,
|
&mut self,
|
||||||
subscription_id: ResourceId,
|
subscription_id: ResourceId,
|
||||||
@@ -638,6 +813,8 @@ impl ResidentSession {
|
|||||||
) -> Result<Vec<ResidentOperation>, ResidentHostError> {
|
) -> Result<Vec<ResidentOperation>, ResidentHostError> {
|
||||||
// Validate the whole batch against a copy. A bad later effect must not
|
// Validate the whole batch against a copy. A bad later effect must not
|
||||||
// leave pause, close, or timer state from an earlier effect committed.
|
// leave pause, close, or timer state from an earlier effect committed.
|
||||||
|
// Drivers also receive operations only after this loop completes, so
|
||||||
|
// no external side effect can precede discovery of an invalid effect.
|
||||||
let mut next_resources = self.resources.clone();
|
let mut next_resources = self.resources.clone();
|
||||||
let mut operations = Vec::with_capacity(effects.len());
|
let mut operations = Vec::with_capacity(effects.len());
|
||||||
for effect in effects {
|
for effect in effects {
|
||||||
|
|||||||
@@ -64,12 +64,22 @@ const ALLOWED_WASI_IMPORTS: &[&str] = &[
|
|||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
pub struct RuntimeConfig {
|
pub struct RuntimeConfig {
|
||||||
/// Frequency used to advance Wasmtime epoch interruption.
|
/// Frequency used to advance Wasmtime epoch interruption.
|
||||||
|
///
|
||||||
|
/// Lower values make wall-time traps more precise but wake the ticker more
|
||||||
|
/// often. Service deadlines must be at least one tick.
|
||||||
pub epoch_tick: Duration,
|
pub epoch_tick: Duration,
|
||||||
/// Maximum native stack reservation for WebAssembly execution.
|
/// Maximum native stack reservation for WebAssembly execution.
|
||||||
|
///
|
||||||
|
/// This is separate from Component linear-memory limits.
|
||||||
pub max_wasm_stack: usize,
|
pub max_wasm_stack: usize,
|
||||||
/// Optional persistent cache for compiled Component machine code.
|
/// Optional persistent cache for compiled Component machine code.
|
||||||
|
///
|
||||||
|
/// The directory is process-shared and contains derived artifacts only; it
|
||||||
|
/// may be deleted without losing registered packages.
|
||||||
pub component_cache_dir: Option<PathBuf>,
|
pub component_cache_dir: Option<PathBuf>,
|
||||||
/// Service-scoped storage used by Components importing the KV capability.
|
/// Service-scoped storage used by Components importing the KV capability.
|
||||||
|
///
|
||||||
|
/// Registration rejects a KV-importing Component when this is `None`.
|
||||||
pub kv_backend: Option<Arc<dyn KvBackend>>,
|
pub kv_backend: Option<Arc<dyn KvBackend>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -227,6 +237,9 @@ impl Runtime {
|
|||||||
///
|
///
|
||||||
/// Imports are checked against the restricted WASI and explicit Wasmeld
|
/// Imports are checked against the restricted WASI and explicit Wasmeld
|
||||||
/// capability allowlist before the service becomes visible.
|
/// capability allowlist before the service becomes visible.
|
||||||
|
///
|
||||||
|
/// Registration is immutable: another Component cannot replace the same
|
||||||
|
/// [`ServiceKey`]. Register a new revision and switch the deployment instead.
|
||||||
pub fn register(
|
pub fn register(
|
||||||
&self,
|
&self,
|
||||||
manifest: ServiceManifest,
|
manifest: ServiceManifest,
|
||||||
@@ -281,7 +294,11 @@ impl Runtime {
|
|||||||
self.register(manifest, bytes)
|
self.register(manifest, bytes)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Starts or returns the resident Actor for one registered service revision.
|
/// Starts or returns the Actor for one registered service revision.
|
||||||
|
///
|
||||||
|
/// This operation is idempotent while the Actor is healthy. When an Actor
|
||||||
|
/// already exists, the supplied `init_config` is ignored because `init` ran
|
||||||
|
/// exactly once for that instance.
|
||||||
pub fn start(
|
pub fn start(
|
||||||
&self,
|
&self,
|
||||||
key: &ServiceKey,
|
key: &ServiceKey,
|
||||||
@@ -348,6 +365,10 @@ impl Runtime {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Enqueues one call on the service Actor's bounded serial mailbox.
|
/// Enqueues one call on the service Actor's bounded serial mailbox.
|
||||||
|
///
|
||||||
|
/// The deadline covers both time waiting behind earlier commands and Wasm
|
||||||
|
/// execution. A full mailbox returns [`RuntimeError::ActorOverloaded`]
|
||||||
|
/// immediately instead of blocking an unbounded number of callers.
|
||||||
pub fn invoke(&self, key: &ServiceKey, input: Vec<u8>) -> Result<Vec<u8>, RuntimeError> {
|
pub fn invoke(&self, key: &ServiceKey, input: Vec<u8>) -> Result<Vec<u8>, RuntimeError> {
|
||||||
let actor = self
|
let actor = self
|
||||||
.inner
|
.inner
|
||||||
@@ -392,6 +413,10 @@ impl Runtime {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Delivers one Host event through the Actor's bounded serial mailbox.
|
/// Delivers one Host event through the Actor's bounded serial mailbox.
|
||||||
|
///
|
||||||
|
/// This low-level method returns raw Component effects. Network and source
|
||||||
|
/// drivers should use [`crate::ResidentSession`] so resource ownership and
|
||||||
|
/// state are validated before any effect is executed.
|
||||||
pub fn dispatch_event(
|
pub fn dispatch_event(
|
||||||
&self,
|
&self,
|
||||||
key: &ServiceKey,
|
key: &ServiceKey,
|
||||||
@@ -409,6 +434,10 @@ impl Runtime {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Stops one Actor and releases its Store and Component Instance.
|
/// Stops one Actor and releases its Store and Component Instance.
|
||||||
|
///
|
||||||
|
/// Registered Component code remains available for a later [`Self::start`].
|
||||||
|
/// A resident deployment should first deliver shutdown and close its Host
|
||||||
|
/// resources through [`crate::ResidentSession`].
|
||||||
pub fn stop(&self, key: &ServiceKey) -> Result<(), RuntimeError> {
|
pub fn stop(&self, key: &ServiceKey) -> Result<(), RuntimeError> {
|
||||||
let _lifecycle = self
|
let _lifecycle = self
|
||||||
.inner
|
.inner
|
||||||
@@ -435,6 +464,10 @@ impl Runtime {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Registers a new revision and immediately starts its Actor.
|
/// Registers a new revision and immediately starts its Actor.
|
||||||
|
///
|
||||||
|
/// This does not switch a deployment pointer or stop an older revision. If
|
||||||
|
/// Actor initialization fails, the new revision remains registered so the
|
||||||
|
/// management layer can inspect or explicitly unregister it.
|
||||||
pub fn reload(
|
pub fn reload(
|
||||||
&self,
|
&self,
|
||||||
manifest: ServiceManifest,
|
manifest: ServiceManifest,
|
||||||
@@ -587,6 +620,10 @@ impl ActorHandle {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Sends an invocation and waits up to the remaining service deadline.
|
/// Sends an invocation and waits up to the remaining service deadline.
|
||||||
|
///
|
||||||
|
/// Cloned handles compete for the same bounded mailbox. A Wasm trap is
|
||||||
|
/// fatal for the Actor, while a typed WIT `call-failed` result is returned
|
||||||
|
/// as [`RuntimeError::ComponentError`] and leaves the Actor available.
|
||||||
pub fn invoke(&self, input: Vec<u8>) -> Result<Vec<u8>, RuntimeError> {
|
pub fn invoke(&self, input: Vec<u8>) -> Result<Vec<u8>, RuntimeError> {
|
||||||
if !self.is_available() {
|
if !self.is_available() {
|
||||||
return Err(RuntimeError::ActorUnavailable(self.key.clone()));
|
return Err(RuntimeError::ActorUnavailable(self.key.clone()));
|
||||||
@@ -629,7 +666,10 @@ impl ActorHandle {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Sends a Host-owned resource event and waits for validated effects.
|
/// Sends a Host-owned resource event and waits for size-validated effects.
|
||||||
|
///
|
||||||
|
/// Resource ownership and stream-state checks happen later in
|
||||||
|
/// [`crate::ResidentSession`]; drivers should not execute this raw result.
|
||||||
pub fn dispatch_event(
|
pub fn dispatch_event(
|
||||||
&self,
|
&self,
|
||||||
event: ResidentEvent,
|
event: ResidentEvent,
|
||||||
@@ -669,6 +709,9 @@ impl ActorHandle {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Prevents new calls, asks the worker to stop, and waits for acknowledgement.
|
/// Prevents new calls, asks the worker to stop, and waits for acknowledgement.
|
||||||
|
///
|
||||||
|
/// Stop is not a graceful resident drain. The owner must first stop external
|
||||||
|
/// event production, deliver shutdown, and close Host resources.
|
||||||
pub fn stop(&self) -> Result<(), RuntimeError> {
|
pub fn stop(&self) -> Result<(), RuntimeError> {
|
||||||
if !self.status.accepting.swap(false, Ordering::AcqRel) {
|
if !self.status.accepting.swap(false, Ordering::AcqRel) {
|
||||||
return Err(RuntimeError::ActorUnavailable(self.key.clone()));
|
return Err(RuntimeError::ActorUnavailable(self.key.clone()));
|
||||||
@@ -765,6 +808,10 @@ impl HostState {
|
|||||||
.build();
|
.build();
|
||||||
|
|
||||||
let mut wasi = WasiCtxBuilder::new();
|
let mut wasi = WasiCtxBuilder::new();
|
||||||
|
// Network access is denied twice: socket interfaces are absent from the
|
||||||
|
// linker allowlist, and the WASI context rejects TCP, UDP, DNS, and
|
||||||
|
// address checks. Resident network drivers live outside the Store and
|
||||||
|
// expose only opaque resource events.
|
||||||
wasi.allow_tcp(false)
|
wasi.allow_tcp(false)
|
||||||
.allow_udp(false)
|
.allow_udp(false)
|
||||||
.allow_ip_name_lookup(false)
|
.allow_ip_name_lookup(false)
|
||||||
@@ -839,6 +886,8 @@ impl ActorWorker {
|
|||||||
add_restricted_wasi(&mut linker)?;
|
add_restricted_wasi(&mut linker)?;
|
||||||
CapabilityRegistry::add_to_linker(&mut linker, &service.capabilities)?;
|
CapabilityRegistry::add_to_linker(&mut linker, &service.capabilities)?;
|
||||||
|
|
||||||
|
// Fuel limits deterministic instruction work; epoch interruption limits
|
||||||
|
// elapsed wall time. Both are reset before init and every later call.
|
||||||
let epoch_ticks = ticks_for(limits.deadline(), epoch_tick);
|
let epoch_ticks = ticks_for(limits.deadline(), epoch_tick);
|
||||||
configure_call_budget(&mut store, &limits, epoch_ticks)?;
|
configure_call_budget(&mut store, &limits, epoch_ticks)?;
|
||||||
let instance = linker
|
let instance = linker
|
||||||
@@ -1082,6 +1131,8 @@ fn spawn_actor(
|
|||||||
})
|
})
|
||||||
.map_err(RuntimeError::ActorThread)?;
|
.map_err(RuntimeError::ActorThread)?;
|
||||||
|
|
||||||
|
// Do not publish a handle until instantiation and guest init completed.
|
||||||
|
// This prevents callers from observing an Actor that cannot serve work.
|
||||||
ready_receiver.recv().map_err(|_| {
|
ready_receiver.recv().map_err(|_| {
|
||||||
RuntimeError::ActorInitialization("actor thread stopped during initialization".to_owned())
|
RuntimeError::ActorInitialization("actor thread stopped during initialization".to_owned())
|
||||||
})??;
|
})??;
|
||||||
@@ -1160,6 +1211,8 @@ fn run_actor(worker: &mut ActorWorker, receiver: Receiver<ActorCommand>, status:
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn drain_after_failure(receiver: Receiver<ActorCommand>, key: &ServiceKey) {
|
fn drain_after_failure(receiver: Receiver<ActorCommand>, key: &ServiceKey) {
|
||||||
|
// A trap invalidates the Store. Explicitly answer queued callers so they do
|
||||||
|
// not wait for their individual deadlines after the worker exits.
|
||||||
while let Ok(command) = receiver.try_recv() {
|
while let Ok(command) = receiver.try_recv() {
|
||||||
match command {
|
match command {
|
||||||
ActorCommand::Invoke {
|
ActorCommand::Invoke {
|
||||||
|
|||||||
@@ -1,9 +1,16 @@
|
|||||||
package wasmeld:clock@0.1.0;
|
package wasmeld:clock@0.1.0;
|
||||||
|
|
||||||
|
/// Actor-local monotonic time for measuring elapsed durations.
|
||||||
interface monotonic-clock {
|
interface monotonic-clock {
|
||||||
|
/// Returns nanoseconds elapsed since this Actor Store was created.
|
||||||
|
///
|
||||||
|
/// The value is monotonic and saturates at `u64::MAX`. It is not Unix time,
|
||||||
|
/// cannot be compared across Actor restarts, and must not be persisted as a
|
||||||
|
/// wall-clock timestamp.
|
||||||
now: func() -> u64;
|
now: func() -> u64;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Host-side world used by Wasmeld to link the clock implementation.
|
||||||
world clock-host {
|
world clock-host {
|
||||||
import monotonic-clock;
|
import monotonic-clock;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,37 @@
|
|||||||
package wasmeld:kv@0.1.0;
|
package wasmeld:kv@0.1.0;
|
||||||
|
|
||||||
|
/// Small persistent byte-value store scoped to the stable service ID.
|
||||||
|
///
|
||||||
|
/// Revisions of the same service share a namespace, so a deployment change
|
||||||
|
/// preserves values. Keys are non-empty UTF-8 strings of at most 256 bytes and
|
||||||
|
/// values are at most 64 KiB in this version of the Host implementation.
|
||||||
|
/// Operations are not multi-key transactions.
|
||||||
interface store {
|
interface store {
|
||||||
|
/// Expected validation or backend failures.
|
||||||
variant kv-error {
|
variant kv-error {
|
||||||
|
/// The key is empty or exceeds the Host byte limit.
|
||||||
invalid-key(string),
|
invalid-key(string),
|
||||||
|
/// The attempted or stored value exceeds the Host byte limit.
|
||||||
value-too-large(u64),
|
value-too-large(u64),
|
||||||
|
/// Persistence failed, timed out, or was temporarily unavailable.
|
||||||
|
///
|
||||||
|
/// A timeout is not cancellation: a mutation may finish after the caller
|
||||||
|
/// receives this error. Retried `set` and `delete` operations should be
|
||||||
|
/// idempotent.
|
||||||
operation-failed(string),
|
operation-failed(string),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Returns the current value, or `none` when the key does not exist.
|
||||||
get: func(key: string) -> result<option<list<u8>>, kv-error>;
|
get: func(key: string) -> result<option<list<u8>>, kv-error>;
|
||||||
|
|
||||||
|
/// Creates or replaces one value.
|
||||||
set: func(key: string, value: list<u8>) -> result<_, kv-error>;
|
set: func(key: string, value: list<u8>) -> result<_, kv-error>;
|
||||||
|
|
||||||
|
/// Removes one value; deleting a missing key succeeds.
|
||||||
delete: func(key: string) -> result<_, kv-error>;
|
delete: func(key: string) -> result<_, kv-error>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Host-side world used by Wasmeld to link persistent KV.
|
||||||
world kv-host {
|
world kv-host {
|
||||||
import store;
|
import store;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,43 +5,61 @@ package wasmeld:resident@0.1.0;
|
|||||||
/// and all operating-system handles. A Component handles one bounded event and
|
/// and all operating-system handles. A Component handles one bounded event and
|
||||||
/// returns effects before control goes back to the Runtime.
|
/// returns effects before control goes back to the Runtime.
|
||||||
interface actor {
|
interface actor {
|
||||||
|
/// Opaque, nonzero identity allocated by the Host for one resident session.
|
||||||
|
///
|
||||||
|
/// IDs are scoped to one service revision and session. They are never OS
|
||||||
|
/// handles, must not be guessed, and become invalid after Host release.
|
||||||
type resource-id = u64;
|
type resource-id = u64;
|
||||||
|
|
||||||
|
/// One occurrence of a previously registered and armed timer.
|
||||||
record timer-fired {
|
record timer-fired {
|
||||||
timer-id: resource-id,
|
timer-id: resource-id,
|
||||||
|
/// Driver monotonic deadline in nanoseconds, not wall-clock time.
|
||||||
scheduled-at-ns: u64,
|
scheduled-at-ns: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A stream accepted by a previously registered TCP or Unix endpoint.
|
||||||
record stream-opened {
|
record stream-opened {
|
||||||
endpoint-id: resource-id,
|
endpoint-id: resource-id,
|
||||||
stream-id: resource-id,
|
stream-id: resource-id,
|
||||||
|
/// Driver-formatted peer address when the transport provides one.
|
||||||
peer: option<string>,
|
peer: option<string>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// One bounded read chunk. Chunk boundaries are not message boundaries.
|
||||||
record stream-chunk {
|
record stream-chunk {
|
||||||
stream-id: resource-id,
|
stream-id: resource-id,
|
||||||
bytes: list<u8>,
|
bytes: list<u8>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Terminal reason reported by the Host driver.
|
||||||
enum close-reason {
|
enum close-reason {
|
||||||
|
/// The peer closed the transport normally.
|
||||||
peer-closed,
|
peer-closed,
|
||||||
|
/// The platform or Component requested closure.
|
||||||
host-closed,
|
host-closed,
|
||||||
|
/// The configured inactivity deadline elapsed.
|
||||||
idle-timeout,
|
idle-timeout,
|
||||||
|
/// Protocol validation failed above the transport layer.
|
||||||
protocol-error,
|
protocol-error,
|
||||||
|
/// The operating-system transport returned an error.
|
||||||
transport-error,
|
transport-error,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Final event for a stream identity.
|
||||||
record stream-closed {
|
record stream-closed {
|
||||||
stream-id: resource-id,
|
stream-id: resource-id,
|
||||||
reason: close-reason,
|
reason: close-reason,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// One UDP datagram. A datagram is never split across events.
|
||||||
record datagram {
|
record datagram {
|
||||||
endpoint-id: resource-id,
|
endpoint-id: resource-id,
|
||||||
peer: string,
|
peer: string,
|
||||||
bytes: list<u8>,
|
bytes: list<u8>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// One delivery from a Host-owned broker or internal queue.
|
||||||
record message {
|
record message {
|
||||||
subscription-id: resource-id,
|
subscription-id: resource-id,
|
||||||
message-id: u64,
|
message-id: u64,
|
||||||
@@ -59,65 +77,107 @@ interface actor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
variant event {
|
variant event {
|
||||||
|
/// A registered timer reached its monotonic deadline.
|
||||||
timer(timer-fired),
|
timer(timer-fired),
|
||||||
|
/// A Host driver accepted a new stream.
|
||||||
stream-opened(stream-opened),
|
stream-opened(stream-opened),
|
||||||
|
/// A stream produced one bounded read chunk.
|
||||||
stream-data(stream-chunk),
|
stream-data(stream-chunk),
|
||||||
|
/// The driver can accept writes after previous backpressure.
|
||||||
stream-writable(resource-id),
|
stream-writable(resource-id),
|
||||||
|
/// The peer closed its write half; local writes may still be valid.
|
||||||
stream-half-closed(resource-id),
|
stream-half-closed(resource-id),
|
||||||
|
/// The stream is terminal and its identity will be released.
|
||||||
stream-closed(stream-closed),
|
stream-closed(stream-closed),
|
||||||
|
/// A UDP endpoint received one datagram.
|
||||||
datagram(datagram),
|
datagram(datagram),
|
||||||
|
/// A subscription delivered one message.
|
||||||
message(message),
|
message(message),
|
||||||
|
/// A separately versioned extension source produced an event.
|
||||||
source(source-event),
|
source(source-event),
|
||||||
|
/// The session is stopping. No later events will be delivered.
|
||||||
shutdown,
|
shutdown,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Request to enqueue bytes for a stream driver.
|
||||||
record stream-write {
|
record stream-write {
|
||||||
stream-id: resource-id,
|
stream-id: resource-id,
|
||||||
bytes: list<u8>,
|
bytes: list<u8>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Request to send one datagram through a registered UDP endpoint.
|
||||||
record datagram-send {
|
record datagram-send {
|
||||||
endpoint-id: resource-id,
|
endpoint-id: resource-id,
|
||||||
peer: string,
|
peer: string,
|
||||||
bytes: list<u8>,
|
bytes: list<u8>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Request to arm or replace a registered timer schedule.
|
||||||
record timer-arm {
|
record timer-arm {
|
||||||
timer-id: resource-id,
|
timer-id: resource-id,
|
||||||
|
/// Delay from operation application, in milliseconds.
|
||||||
delay-ms: u64,
|
delay-ms: u64,
|
||||||
|
/// Optional repeating interval in milliseconds.
|
||||||
interval-ms: option<u64>,
|
interval-ms: option<u64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Request to acknowledge a previously delivered message.
|
||||||
record message-ack {
|
record message-ack {
|
||||||
subscription-id: resource-id,
|
subscription-id: resource-id,
|
||||||
message-id: u64,
|
message-id: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Driver-specific command for a separately versioned extension source.
|
||||||
record source-command {
|
record source-command {
|
||||||
source-id: resource-id,
|
source-id: resource-id,
|
||||||
command: string,
|
command: string,
|
||||||
payload: list<u8>,
|
payload: list<u8>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Host operations requested after handling one event.
|
||||||
|
///
|
||||||
|
/// Effects are declarations, not completed I/O. The Host validates the
|
||||||
|
/// entire returned list before applying any item, then the external driver
|
||||||
|
/// performs the operations in order.
|
||||||
variant effect {
|
variant effect {
|
||||||
|
/// Enqueue bytes for an open stream.
|
||||||
write-stream(stream-write),
|
write-stream(stream-write),
|
||||||
|
/// Begin closing an open stream.
|
||||||
close-stream(resource-id),
|
close-stream(resource-id),
|
||||||
|
/// Stop delivering new read data until resumed.
|
||||||
pause-stream(resource-id),
|
pause-stream(resource-id),
|
||||||
|
/// Resume delivery after a matching pause.
|
||||||
resume-stream(resource-id),
|
resume-stream(resource-id),
|
||||||
|
/// Send one UDP datagram.
|
||||||
send-datagram(datagram-send),
|
send-datagram(datagram-send),
|
||||||
|
/// Arm or replace one timer.
|
||||||
arm-timer(timer-arm),
|
arm-timer(timer-arm),
|
||||||
|
/// Disarm one timer; its identity remains registered.
|
||||||
cancel-timer(resource-id),
|
cancel-timer(resource-id),
|
||||||
|
/// Acknowledge one broker or queue message.
|
||||||
acknowledge-message(message-ack),
|
acknowledge-message(message-ack),
|
||||||
|
/// Forward an extension-specific command.
|
||||||
source-command(source-command),
|
source-command(source-command),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Expected Component failure while processing a resident event.
|
||||||
variant resident-error {
|
variant resident-error {
|
||||||
event-failed(string),
|
event-failed(string),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Handles exactly one serialized, bounded event.
|
||||||
|
///
|
||||||
|
/// This callback must not run an internal infinite loop or block on network
|
||||||
|
/// I/O. Update Component state, return requested effects, and let the Host
|
||||||
|
/// wait for the next external event. A trap faults the Actor; `event-failed`
|
||||||
|
/// reports an application error without changing the ABI.
|
||||||
handle-event: func(input: event) -> result<list<effect>, resident-error>;
|
handle-event: func(input: event) -> result<list<effect>, resident-error>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Optional export world for Components that need long-lived Host-driven I/O.
|
||||||
|
///
|
||||||
|
/// A Component may compose this package with `wasmeld:service` and only the
|
||||||
|
/// capability packages it actually imports.
|
||||||
world resident-component {
|
world resident-component {
|
||||||
export actor;
|
export actor;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,34 @@
|
|||||||
package wasmeld:service@0.1.0;
|
package wasmeld:service@0.1.0;
|
||||||
|
|
||||||
|
/// Minimum request/response lifecycle implemented by every Wasmeld service.
|
||||||
|
///
|
||||||
|
/// The Runtime creates one Component Store per Actor, calls `init` once, then
|
||||||
|
/// serializes `invoke` calls through that Actor's bounded mailbox. Component
|
||||||
|
/// memory can therefore remain warm between calls, but it is not durable and
|
||||||
|
/// is lost when the Actor or process restarts.
|
||||||
world service-component {
|
world service-component {
|
||||||
|
/// Application-level failures returned without trapping the Component.
|
||||||
|
///
|
||||||
|
/// Traps, fuel exhaustion, deadline interruption, and invalid ABI data are
|
||||||
|
/// Runtime failures and do not use this variant.
|
||||||
variant service-error {
|
variant service-error {
|
||||||
|
/// The Actor cannot accept calls because its initial configuration failed.
|
||||||
init-failed(string),
|
init-failed(string),
|
||||||
|
/// This input could not be processed; a later call may still succeed.
|
||||||
call-failed(string),
|
call-failed(string),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Initializes a newly created Actor Store.
|
||||||
|
///
|
||||||
|
/// `config` is opaque to the platform. Return `init-failed` for expected
|
||||||
|
/// configuration errors; do not retain borrowed views into this byte list.
|
||||||
export init: func(config: list<u8>) -> result<_, service-error>;
|
export init: func(config: list<u8>) -> result<_, service-error>;
|
||||||
|
|
||||||
|
/// Processes one opaque request and returns one opaque response.
|
||||||
|
///
|
||||||
|
/// The management API represents these bytes as base64, while the public
|
||||||
|
/// gateway transports them as `application/octet-stream`. The Component
|
||||||
|
/// should return promptly and use the resident actor contract for long-lived
|
||||||
|
/// I/O instead of blocking this call in an internal loop.
|
||||||
export invoke: func(input: list<u8>) -> result<list<u8>, service-error>;
|
export invoke: func(input: list<u8>) -> result<list<u8>, service-error>;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user