Compare commits

..

5 Commits

Author SHA1 Message Date
Maofeng 2c6e761288 docs: document live Component preview workflow
Explain the wasmeld dev watch-build-package-deploy loop, isolated development service IDs, hash revisions, failure retention, cleanup behavior, and command options.

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

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

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

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

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

Return the existing not-registered and unavailable errors without leaving a partially removed live revision, and cover Actor and registration count cleanup.
2026-07-29 20:25:58 +08:00
11 changed files with 792 additions and 31 deletions
+18
View File
@@ -56,6 +56,24 @@ cd console
npm run dev npm run dev
``` ```
## 组件开发
Console 启动后,使用开发模式监听组件源码和本地 WIT `replace`
```bash
cargo run -p wasmeld-package --bin wasmeld -- \
dev components/echo/Cargo.toml --locked
```
首次构建和每次源码变化都会自动完成 WIT 同步、Debug 编译、打包、注册和 Deployment
切换。默认使用 `echo-dev` 这类独立服务 ID,并根据 Component 内容生成
`0.1.0-dev.h<hash>` Revision,不会覆盖正式服务。新版本构建、校验或启动失败时,上一
版本继续运行;切换成功后旧开发 Revision 会被注销并释放。
管理面每 5 秒刷新一次,可以直接在调用面板预览新版本。只构建并部署一次可使用
`--once`;其它选项包括 `--id``--console``--release``--poll-ms`。管理 API
地址也可通过 `WASMELD_CONSOLE` 设置。
构建组件: 构建组件:
```bash ```bash
+1 -1
View File
@@ -35,7 +35,7 @@ export type BackendService = {
export type BackendEvent = { export type BackendEvent = {
id: number; id: number;
timestamp_ms: number; timestamp_ms: number;
kind: "registered" | "deployed" | "started" | "stopped" | "invoked" | "failed"; kind: "registered" | "unregistered" | "deployed" | "started" | "stopped" | "invoked" | "failed";
service_id: string | null; service_id: string | null;
revision: string | null; revision: string | null;
message: string; message: string;
+1
View File
@@ -135,6 +135,7 @@ function toEvent(event: BackendEvent): RuntimeEvent {
event.service_id && event.revision ? `${event.service_id}@${event.revision}` : "Wasmeld"; event.service_id && event.revision ? `${event.service_id}@${event.revision}` : "Wasmeld";
const meta: Record<BackendEvent["kind"], { label: string; tone: EventTone }> = { const meta: Record<BackendEvent["kind"], { label: string; tone: EventTone }> = {
registered: { label: "已注册", tone: "success" }, registered: { label: "已注册", tone: "success" },
unregistered: { label: "已注销", tone: "neutral" },
deployed: { label: "部署切换", tone: "success" }, deployed: { label: "部署切换", tone: "success" },
started: { label: "已启动", tone: "success" }, started: { label: "已启动", tone: "success" },
stopped: { label: "已停止", tone: "neutral" }, stopped: { label: "已停止", tone: "neutral" },
+4
View File
@@ -60,6 +60,7 @@ WIT Registry 路由。
| `POST` | `/api/v1/runtime/restart` | 重建 Runtime,并重新创建活动 Deployment 的 Actor | | `POST` | `/api/v1/runtime/restart` | 重建 Runtime,并重新创建活动 Deployment 的 Actor |
| `GET` | `/api/v1/services` | 服务版本及精确 Host Capability 列表 | | `GET` | `/api/v1/services` | 服务版本及精确 Host Capability 列表 |
| `POST` | `/api/v1/services` | multipart 上传单个 `package` 字段(`.wasmpkg` | | `POST` | `/api/v1/services` | multipart 上传单个 `package` 字段(`.wasmpkg` |
| `DELETE` | `/api/v1/services/{id}/{revision}` | 注销非活动 Revision 并删除制品 |
| `POST` | `/api/v1/services/{id}/{revision}/start` | 启动 Actor | | `POST` | `/api/v1/services/{id}/{revision}/start` | 启动 Actor |
| `POST` | `/api/v1/services/{id}/{revision}/stop` | 停止 Actor | | `POST` | `/api/v1/services/{id}/{revision}/stop` | 停止 Actor |
| `POST` | `/api/v1/services/{id}/{revision}/restart` | 重启 Actor | | `POST` | `/api/v1/services/{id}/{revision}/restart` | 重启 Actor |
@@ -73,6 +74,9 @@ WIT Registry 路由。
| `GET` | `/api/v1/wit/packages/{namespace}/{name}/{version}` | WIT 包元数据 | | `GET` | `/api/v1/wit/packages/{namespace}/{name}/{version}` | WIT 包元数据 |
| `GET` | `/api/v1/wit/packages/{namespace}/{name}/{version}/content` | 下载 WIT 包 | | `GET` | `/api/v1/wit/packages/{namespace}/{name}/{version}/content` | 下载 WIT 包 |
活动 Deployment 不能直接注销。开发工具会先完成新 Revision 的编译、注册和切换,
再注销上一份开发 Revision,因此失败的构建不会影响当前可调用版本。
激活已注册版本: 激活已注册版本:
```bash ```bash
+68 -2
View File
@@ -24,7 +24,7 @@ use axum::{
extract::{DefaultBodyLimit, Multipart, Path as AxumPath, State, rejection::BytesRejection}, extract::{DefaultBodyLimit, Multipart, Path as AxumPath, State, rejection::BytesRejection},
http::{HeaderMap, HeaderValue, Method, StatusCode, header}, http::{HeaderMap, HeaderValue, Method, StatusCode, header},
response::{IntoResponse, Response}, response::{IntoResponse, Response},
routing::{get, post}, routing::{delete, get, post},
}; };
use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64}; use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
@@ -218,6 +218,7 @@ pub struct EventView {
#[serde(rename_all = "snake_case")] #[serde(rename_all = "snake_case")]
pub enum EventKind { pub enum EventKind {
Registered, Registered,
Unregistered,
Deployed, Deployed,
Started, Started,
Stopped, Stopped,
@@ -229,6 +230,7 @@ impl EventKind {
fn as_database_value(self) -> &'static str { fn as_database_value(self) -> &'static str {
match self { match self {
Self::Registered => "registered", Self::Registered => "registered",
Self::Unregistered => "unregistered",
Self::Deployed => "deployed", Self::Deployed => "deployed",
Self::Started => "started", Self::Started => "started",
Self::Stopped => "stopped", Self::Stopped => "stopped",
@@ -240,6 +242,7 @@ impl EventKind {
fn from_database_value(value: &str) -> Result<Self, ConsoleError> { fn from_database_value(value: &str) -> Result<Self, ConsoleError> {
match value { match value {
"registered" => Ok(Self::Registered), "registered" => Ok(Self::Registered),
"unregistered" => Ok(Self::Unregistered),
"deployed" => Ok(Self::Deployed), "deployed" => Ok(Self::Deployed),
"started" => Ok(Self::Started), "started" => Ok(Self::Started),
"stopped" => Ok(Self::Stopped), "stopped" => Ok(Self::Stopped),
@@ -351,6 +354,9 @@ pub enum ConsoleError {
#[error("service {0} has no active deployment")] #[error("service {0} has no active deployment")]
DeploymentNotFound(String), DeploymentNotFound(String),
#[error("service revision {0} is the active deployment")]
ActiveDeployment(ServiceKey),
#[error("Wasmeld Runtime is not running")] #[error("Wasmeld Runtime is not running")]
RuntimeNotRunning, RuntimeNotRunning,
@@ -790,6 +796,50 @@ impl Console {
Ok(view) Ok(view)
} }
fn unregister(&self, key: &ServiceKey) -> Result<ServiceView, ConsoleError> {
let _lifecycle = self
.lifecycle
.lock()
.map_err(|_| ConsoleError::LockPoisoned)?;
let view = {
let state = self.state()?;
if state
.deployments
.get(key.id())
.is_some_and(|deployment| deployment.active_revision == key.revision())
{
return Err(ConsoleError::ActiveDeployment(key.clone()));
}
state
.services
.get(key)
.map(ServiceRecord::view)
.ok_or_else(|| ConsoleError::ServiceNotFound(key.clone()))?
};
let runtime = self.runtime()?;
let runtime = runtime.as_ref().ok_or(ConsoleError::RuntimeNotRunning)?;
runtime.unregister(key)?;
self.state()?.services.remove(key);
for path in [
self.artifact_dir.join(format!("{key}.wasm")),
self.artifact_dir.join(format!("{key}.toml")),
] {
if let Err(error) = fs::remove_file(&path)
&& error.kind() != io::ErrorKind::NotFound
{
tracing::warn!(path = %path.display(), %error, "failed to remove unregistered artifact");
}
}
self.push_event(
EventKind::Unregistered,
Some(key),
"component revision unregistered".to_owned(),
)?;
Ok(view)
}
fn start(&self, key: &ServiceKey, init_config: Vec<u8>) -> Result<ServiceView, ConsoleError> { fn start(&self, key: &ServiceKey, init_config: Vec<u8>) -> Result<ServiceView, ConsoleError> {
let runtime = self.runtime()?; let runtime = self.runtime()?;
let runtime = runtime.as_ref().ok_or(ConsoleError::RuntimeNotRunning)?; let runtime = runtime.as_ref().ok_or(ConsoleError::RuntimeNotRunning)?;
@@ -1221,7 +1271,7 @@ pub fn app(console: Arc<Console>, allowed_origins: Vec<HeaderValue>) -> Router {
.max(console.max_wit_package_bytes()) .max(console.max_wit_package_bytes())
.saturating_add(1024 * 1024); .saturating_add(1024 * 1024);
let mut cors = CorsLayer::new() let mut cors = CorsLayer::new()
.allow_methods([Method::GET, Method::POST]) .allow_methods([Method::GET, Method::POST, Method::DELETE])
.allow_headers([header::CONTENT_TYPE]); .allow_headers([header::CONTENT_TYPE]);
if !allowed_origins.is_empty() { if !allowed_origins.is_empty() {
cors = cors.allow_origin(allowed_origins); cors = cors.allow_origin(allowed_origins);
@@ -1234,6 +1284,10 @@ pub fn app(console: Arc<Console>, allowed_origins: Vec<HeaderValue>) -> Router {
.route("/api/v1/runtime/stop", post(stop_runtime)) .route("/api/v1/runtime/stop", post(stop_runtime))
.route("/api/v1/runtime/restart", post(restart_runtime)) .route("/api/v1/runtime/restart", post(restart_runtime))
.route("/api/v1/services", get(list_services).post(register)) .route("/api/v1/services", get(list_services).post(register))
.route(
"/api/v1/services/{id}/{revision}",
delete(unregister_service),
)
.route("/api/v1/deployments", get(list_deployments)) .route("/api/v1/deployments", get(list_deployments))
.route("/api/v1/deployments/{id}", get(get_deployment)) .route("/api/v1/deployments/{id}", get(get_deployment))
.route( .route(
@@ -1573,6 +1627,16 @@ async fn stop_service(
)) ))
} }
async fn unregister_service(
State(console): State<Arc<Console>>,
AxumPath((id, revision)): AxumPath<(String, String)>,
) -> Result<Json<ServiceView>, ApiError> {
let key = api_key(id, revision)?;
Ok(Json(
run_blocking(console, move |console| console.unregister(&key)).await?,
))
}
async fn restart_service( async fn restart_service(
State(console): State<Arc<Console>>, State(console): State<Arc<Console>>,
AxumPath((id, revision)): AxumPath<(String, String)>, AxumPath((id, revision)): AxumPath<(String, String)>,
@@ -1766,6 +1830,7 @@ impl IntoResponse for GatewayError {
| RuntimeError::ComponentError { .. }, | RuntimeError::ComponentError { .. },
) => (StatusCode::BAD_GATEWAY, "service_error", error.to_string()), ) => (StatusCode::BAD_GATEWAY, "service_error", error.to_string()),
ConsoleError::ArtifactTooLarge { .. } ConsoleError::ArtifactTooLarge { .. }
| ConsoleError::ActiveDeployment(_)
| ConsoleError::Package(_) | ConsoleError::Package(_)
| ConsoleError::WitRegistry(_) | ConsoleError::WitRegistry(_)
| ConsoleError::Storage { .. } | ConsoleError::Storage { .. }
@@ -1818,6 +1883,7 @@ impl IntoResponse for ApiError {
ConsoleError::InvalidRequest(_) => (StatusCode::BAD_REQUEST, "invalid_request"), ConsoleError::InvalidRequest(_) => (StatusCode::BAD_REQUEST, "invalid_request"),
ConsoleError::ServiceNotFound(_) => (StatusCode::NOT_FOUND, "service_not_found"), ConsoleError::ServiceNotFound(_) => (StatusCode::NOT_FOUND, "service_not_found"),
ConsoleError::DeploymentNotFound(_) => (StatusCode::NOT_FOUND, "deployment_not_found"), ConsoleError::DeploymentNotFound(_) => (StatusCode::NOT_FOUND, "deployment_not_found"),
ConsoleError::ActiveDeployment(_) => (StatusCode::CONFLICT, "active_deployment"),
ConsoleError::RuntimeNotRunning => (StatusCode::CONFLICT, "runtime_not_running"), ConsoleError::RuntimeNotRunning => (StatusCode::CONFLICT, "runtime_not_running"),
ConsoleError::ArtifactTooLarge { .. } ConsoleError::ArtifactTooLarge { .. }
| ConsoleError::Package(PackageError::ComponentTooLarge { .. }) | ConsoleError::Package(PackageError::ComponentTooLarge { .. })
+19
View File
@@ -5,6 +5,7 @@
//! history, and service-scoped Host KV entries. //! history, and service-scoped Host KV entries.
use std::{ use std::{
collections::BTreeSet,
path::Path, path::Path,
sync::Arc, sync::Arc,
time::{SystemTime, UNIX_EPOCH}, time::{SystemTime, UNIX_EPOCH},
@@ -130,6 +131,15 @@ impl Persistence {
let mut db = self.db.lock().await; let mut db = self.db.lock().await;
let mut tx = db.transaction().await?; let mut tx = db.transaction().await?;
let service_keys = services
.iter()
.map(|service| service.service_key.clone())
.collect::<BTreeSet<_>>();
for stored in StoredService::all().exec(&mut tx).await? {
if !service_keys.contains(&stored.service_key) {
StoredService::delete_by_service_key(&mut tx, &stored.service_key).await?;
}
}
for service in services { for service in services {
StoredService::upsert_by_service_key(service.service_key) StoredService::upsert_by_service_key(service.service_key)
.manifest_toml(service.manifest_toml) .manifest_toml(service.manifest_toml)
@@ -159,6 +169,15 @@ impl Persistence {
.await?; .await?;
} }
let deployment_ids = deployments
.iter()
.map(|deployment| deployment.service_id.clone())
.collect::<BTreeSet<_>>();
for stored in StoredDeployment::all().exec(&mut tx).await? {
if !deployment_ids.contains(&stored.service_id) {
StoredDeployment::delete_by_service_id(&mut tx, &stored.service_id).await?;
}
}
for deployment in deployments { for deployment in deployments {
StoredDeployment::upsert_by_service_id(deployment.service_id) StoredDeployment::upsert_by_service_id(deployment.service_id)
.active_revision(deployment.active_revision) .active_revision(deployment.active_revision)
+76
View File
@@ -261,6 +261,74 @@ async fn routes_raw_gateway_calls_through_the_active_deployment() {
assert_eq!(deployments["deployments"][0]["active_revision"], "0.2.0"); assert_eq!(deployments["deployments"][0]["active_revision"], "0.2.0");
} }
#[tokio::test]
async fn unregisters_only_inactive_service_revisions_and_prunes_persistence() {
let artifact_dir = TempDir::new().expect("temporary artifact directory");
let component = fs::read(echo_component()).expect("echo component should be readable");
{
let application = test_app(artifact_dir.path()).await;
for revision in ["0.1.0", "0.2.0"] {
let response = application
.clone()
.oneshot(package_request("dev-echo", revision, &component))
.await
.expect("register request should complete");
assert_eq!(response.status(), StatusCode::CREATED);
}
let response = application
.clone()
.oneshot(json_request(
"/api/v1/deployments/dev-echo/activate",
json!({ "revision": "0.1.0" }),
))
.await
.expect("activation request should complete");
assert_eq!(response.status(), StatusCode::OK);
let response = application
.clone()
.oneshot(delete_request("/api/v1/services/dev-echo/0.1.0"))
.await
.expect("active unregister request should complete");
assert_eq!(response.status(), StatusCode::CONFLICT);
assert_eq!(
response_json(response).await["error"]["code"],
"active_deployment"
);
let response = application
.clone()
.oneshot(json_request(
"/api/v1/deployments/dev-echo/activate",
json!({ "revision": "0.2.0" }),
))
.await
.expect("replacement activation should complete");
assert_eq!(response.status(), StatusCode::OK);
let response = application
.clone()
.oneshot(delete_request("/api/v1/services/dev-echo/0.1.0"))
.await
.expect("inactive unregister request should complete");
assert_eq!(response.status(), StatusCode::OK);
assert!(!artifact_dir.path().join("dev-echo@0.1.0.wasm").exists());
assert!(!artifact_dir.path().join("dev-echo@0.1.0.toml").exists());
}
let application = test_app(artifact_dir.path()).await;
let response = application
.oneshot(get_request("/api/v1/services"))
.await
.expect("restored service list request should complete");
let body = response_json(response).await;
let services = body["services"].as_array().unwrap();
assert_eq!(services.len(), 1);
assert_eq!(services[0]["revision"], "0.2.0");
}
#[tokio::test] #[tokio::test]
async fn restores_the_active_deployment_with_a_fresh_actor() { async fn restores_the_active_deployment_with_a_fresh_actor() {
let artifact_dir = TempDir::new().expect("temporary artifact directory"); let artifact_dir = TempDir::new().expect("temporary artifact directory");
@@ -888,6 +956,14 @@ fn empty_post(uri: &str) -> Request<Body> {
.unwrap() .unwrap()
} }
fn delete_request(uri: &str) -> Request<Body> {
Request::builder()
.method("DELETE")
.uri(uri)
.body(Body::empty())
.unwrap()
}
fn get_request(uri: &str) -> Request<Body> { fn get_request(uri: &str) -> Request<Body> {
Request::builder().uri(uri).body(Body::empty()).unwrap() Request::builder().uri(uri).body(Body::empty()).unwrap()
} }
+431
View File
@@ -0,0 +1,431 @@
//! Local build, watch, and deployment loop for Component development.
use std::{
collections::BTreeSet,
env, fs, io,
path::{Path, PathBuf},
thread,
time::Duration,
};
use reqwest::{
StatusCode,
blocking::{Client, multipart},
};
use serde::Deserialize;
use sha2::{Digest, Sha256};
use wasmeld_package::module::{MODULE_MANIFEST_FILE, ModuleManifest};
use crate::{BuildProfile, PackRequest, PackageIdentity, PackedComponent, pack_component};
const DEFAULT_CONSOLE: &str = "http://127.0.0.1:8080";
const DEFAULT_POLL_MS: u64 = 350;
struct DevOptions {
manifest_path: PathBuf,
console: String,
service_id: Option<String>,
locked: bool,
release: bool,
once: bool,
poll_interval: Duration,
}
#[derive(Debug, Deserialize)]
struct ServiceList {
services: Vec<ServiceIdentity>,
}
#[derive(Debug, Deserialize)]
struct ServiceIdentity {
id: String,
revision: String,
}
#[derive(Debug, Deserialize)]
struct Deployment {
active_revision: String,
}
pub(crate) fn run(arguments: Vec<String>) -> Result<(), Box<dyn std::error::Error>> {
let options = parse_options(arguments)?;
let manifest_path = fs::canonicalize(&options.manifest_path)?;
let watch_roots = watch_roots(&manifest_path)?;
let client = Client::builder()
.user_agent(format!("wasmeld-dev/{}", env!("CARGO_PKG_VERSION")))
.timeout(Duration::from_secs(120))
.build()?;
ensure_console(&client, &options.console)?;
let temporary_dir = env::temp_dir().join(format!("wasmeld-dev-{}", std::process::id()));
fs::create_dir_all(&temporary_dir)?;
let output = temporary_dir.join("component.wasmpkg");
let mut observed = None;
println!("watching:");
for root in &watch_roots {
println!(" {}", root.display());
}
println!("console: {}", options.console);
loop {
let current = source_fingerprint(&watch_roots)?;
if observed.as_ref() == Some(&current) {
thread::sleep(options.poll_interval);
continue;
}
println!("change detected; building Component");
let result = build_and_deploy(&client, &options, &manifest_path, &output);
observed = Some(source_fingerprint(&watch_roots)?);
match result {
Ok(packed) => {
println!(
"ready: {}@{} ({})",
packed.manifest.id, packed.manifest.revision, packed.manifest.sha256
);
if options.once {
return Ok(());
}
}
Err(error) if options.once => return Err(error),
Err(error) => {
eprintln!("wasmeld dev: {error}");
eprintln!("waiting for the next source change; the previous deployment is intact");
}
}
}
}
fn parse_options(arguments: Vec<String>) -> Result<DevOptions, Box<dyn std::error::Error>> {
let mut arguments = arguments.into_iter();
let manifest_path = arguments.next().ok_or("dev requires a Cargo.toml path")?;
let mut console = env::var("WASMELD_CONSOLE").unwrap_or_else(|_| DEFAULT_CONSOLE.to_owned());
let mut service_id = None;
let mut locked = false;
let mut release = false;
let mut once = false;
let mut poll_ms = DEFAULT_POLL_MS;
while let Some(argument) = arguments.next() {
match argument.as_str() {
"--console" => {
console = arguments.next().ok_or("--console requires an HTTP URL")?;
}
"--id" => {
service_id = Some(arguments.next().ok_or("--id requires a service ID")?);
}
"--locked" => locked = true,
"--release" => release = true,
"--once" => once = true,
"--poll-ms" => {
poll_ms = arguments
.next()
.ok_or("--poll-ms requires a number")?
.parse::<u64>()?;
if poll_ms < 100 {
return Err("--poll-ms must be at least 100".into());
}
}
_ => return Err(format!("unknown dev argument {argument:?}").into()),
}
}
Ok(DevOptions {
manifest_path: PathBuf::from(manifest_path),
console: console.trim_end_matches('/').to_owned(),
service_id,
locked,
release,
once,
poll_interval: Duration::from_millis(poll_ms),
})
}
fn build_and_deploy(
client: &Client,
options: &DevOptions,
manifest_path: &Path,
output: &Path,
) -> Result<PackedComponent, Box<dyn std::error::Error>> {
let packed = pack_component(PackRequest {
manifest_path: manifest_path.to_owned(),
output: Some(output.to_owned()),
no_build: false,
locked: options.locked,
profile: if options.release {
BuildProfile::Release
} else {
BuildProfile::Debug
},
identity: PackageIdentity::Development {
id: options.service_id.clone(),
},
})?;
let previous = active_revision(client, &options.console, &packed.manifest.id)?;
register(client, &options.console, &packed)?;
activate(
client,
&options.console,
&packed.manifest.id,
&packed.manifest.revision,
)?;
if let Some(previous) = previous
&& previous != packed.manifest.revision
&& is_matching_dev_revision(&previous, &packed.manifest.revision)
&& let Err(error) = unregister(client, &options.console, &packed.manifest.id, &previous)
{
eprintln!(
"wasmeld dev: deployed successfully but could not unregister {}@{}: {error}",
packed.manifest.id, previous
);
}
Ok(packed)
}
fn ensure_console(client: &Client, console: &str) -> Result<(), Box<dyn std::error::Error>> {
let response = client.get(format!("{console}/healthz")).send()?;
if response.status().is_success() {
Ok(())
} else {
Err(http_error("Console health check", response))
}
}
fn active_revision(
client: &Client,
console: &str,
service_id: &str,
) -> Result<Option<String>, Box<dyn std::error::Error>> {
let response = client
.get(format!("{console}/api/v1/deployments/{service_id}"))
.send()?;
match response.status() {
status if status.is_success() => Ok(Some(response.json::<Deployment>()?.active_revision)),
StatusCode::NOT_FOUND => Ok(None),
_ => Err(http_error("deployment lookup", response)),
}
}
fn register(
client: &Client,
console: &str,
packed: &PackedComponent,
) -> Result<(), Box<dyn std::error::Error>> {
let package = fs::read(&packed.output)?;
let part = multipart::Part::bytes(package)
.file_name("component.wasmpkg")
.mime_str("application/vnd.wasmeld.package")?;
let response = client
.post(format!("{console}/api/v1/services"))
.multipart(multipart::Form::new().part("package", part))
.send()?;
if response.status().is_success() {
return Ok(());
}
if response.status() == StatusCode::CONFLICT
&& service_exists(
client,
console,
&packed.manifest.id,
&packed.manifest.revision,
)?
{
return Ok(());
}
Err(http_error("Component registration", response))
}
fn service_exists(
client: &Client,
console: &str,
service_id: &str,
revision: &str,
) -> Result<bool, Box<dyn std::error::Error>> {
let response = client.get(format!("{console}/api/v1/services")).send()?;
if !response.status().is_success() {
return Err(http_error("service lookup", response));
}
Ok(response
.json::<ServiceList>()?
.services
.iter()
.any(|service| service.id == service_id && service.revision == revision))
}
fn activate(
client: &Client,
console: &str,
service_id: &str,
revision: &str,
) -> Result<(), Box<dyn std::error::Error>> {
let response = client
.post(format!(
"{console}/api/v1/deployments/{service_id}/activate"
))
.json(&serde_json::json!({ "revision": revision }))
.send()?;
if response.status().is_success() {
Ok(())
} else {
Err(http_error("development deployment", response))
}
}
fn unregister(
client: &Client,
console: &str,
service_id: &str,
revision: &str,
) -> Result<(), Box<dyn std::error::Error>> {
let response = client
.delete(format!("{console}/api/v1/services/{service_id}/{revision}"))
.send()?;
if response.status().is_success() || response.status() == StatusCode::NOT_FOUND {
Ok(())
} else {
Err(http_error("old development revision cleanup", response))
}
}
fn http_error(action: &str, response: reqwest::blocking::Response) -> Box<dyn std::error::Error> {
let status = response.status();
let body = response
.text()
.unwrap_or_else(|error| format!("failed to read response: {error}"));
format!("{action} returned HTTP {status}: {body}").into()
}
fn is_matching_dev_revision(previous: &str, current: &str) -> bool {
let Some((previous_base, previous_hash)) = previous.rsplit_once("-dev.h") else {
return false;
};
let Some((current_base, current_hash)) = current.rsplit_once("-dev.h") else {
return false;
};
previous_base == current_base && valid_dev_hash(previous_hash) && valid_dev_hash(current_hash)
}
fn valid_dev_hash(value: &str) -> bool {
value.len() == 12 && value.bytes().all(|byte| byte.is_ascii_hexdigit())
}
fn watch_roots(manifest_path: &Path) -> Result<Vec<PathBuf>, Box<dyn std::error::Error>> {
let component_root = manifest_path
.parent()
.ok_or("Cargo manifest must have a parent directory")?
.to_owned();
let mut roots = BTreeSet::from([component_root.clone()]);
let module_path = component_root.join(MODULE_MANIFEST_FILE);
if module_path.is_file() {
let module = ModuleManifest::read(&module_path)?;
for replacement in module.replacements.values() {
let path = component_root.join(&replacement.path);
if path.exists() {
roots.insert(fs::canonicalize(path)?);
}
}
}
Ok(roots.into_iter().collect())
}
fn source_fingerprint(roots: &[PathBuf]) -> Result<Vec<u8>, Box<dyn std::error::Error>> {
let mut files = BTreeSet::new();
for root in roots {
collect_source_files(root, &mut files)?;
}
let mut digest = Sha256::new();
for path in files {
digest.update(path.to_string_lossy().as_bytes());
match fs::read(&path) {
Ok(bytes) => digest.update(bytes),
Err(error) if error.kind() == io::ErrorKind::NotFound => {}
Err(error) => return Err(error.into()),
}
}
Ok(digest.finalize().to_vec())
}
fn collect_source_files(
path: &Path,
files: &mut BTreeSet<PathBuf>,
) -> Result<(), Box<dyn std::error::Error>> {
if path.is_dir() {
if ignored_directory(path) {
return Ok(());
}
for entry in fs::read_dir(path)? {
collect_source_files(&entry?.path(), files)?;
}
} else if is_source_file(path) {
files.insert(path.to_owned());
}
Ok(())
}
fn ignored_directory(path: &Path) -> bool {
let name = path.file_name().and_then(|name| name.to_str());
if matches!(
name,
Some(".git" | ".wasmeld" | "deps" | "dist" | "node_modules" | "target")
) {
return name != Some("deps")
|| path
.parent()
.and_then(Path::file_name)
.and_then(|name| name.to_str())
== Some("wit");
}
false
}
fn is_source_file(path: &Path) -> bool {
matches!(
path.extension().and_then(|extension| extension.to_str()),
Some("rs" | "wit")
) || matches!(
path.file_name().and_then(|name| name.to_str()),
Some("Cargo.lock" | "Cargo.toml" | "config.toml" | "wasmeld.toml" | "wit.lock")
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn recognizes_only_matching_hash_development_revisions() {
assert!(is_matching_dev_revision(
"0.1.0-dev.h0123456789ab",
"0.1.0-dev.habcdef012345"
));
assert!(!is_matching_dev_revision(
"0.2.0-dev.h0123456789ab",
"0.1.0-dev.habcdef012345"
));
assert!(!is_matching_dev_revision(
"0.1.0",
"0.1.0-dev.habcdef012345"
));
}
#[test]
fn fingerprints_sources_but_ignores_materialized_wit_dependencies() {
let root = tempfile::tempdir().unwrap();
let source = root.path().join("src/lib.rs");
let generated = root.path().join("wit/deps/example/package.wit");
fs::create_dir_all(source.parent().unwrap()).unwrap();
fs::create_dir_all(generated.parent().unwrap()).unwrap();
fs::write(&source, "pub fn first() {}").unwrap();
fs::write(&generated, "package generated:dependency@1.0.0;").unwrap();
let roots = vec![root.path().to_owned()];
let initial = source_fingerprint(&roots).unwrap();
fs::write(&generated, "package generated:dependency@2.0.0;").unwrap();
assert_eq!(source_fingerprint(&roots).unwrap(), initial);
fs::write(&source, "pub fn second() {}").unwrap();
assert_ne!(source_fingerprint(&roots).unwrap(), initial);
}
}
+108 -28
View File
@@ -1,5 +1,7 @@
//! Command-line entry point for Component packaging and WIT dependency workflows. //! Command-line entry point for Component packaging and WIT dependency workflows.
mod dev;
use std::{ use std::{
env, fs, env, fs,
path::{Path, PathBuf}, path::{Path, PathBuf},
@@ -7,6 +9,7 @@ use std::{
}; };
use serde::Deserialize; use serde::Deserialize;
use sha2::{Digest, Sha256};
use wasmeld_package::{ use wasmeld_package::{
PackageManifest, PackageManifest,
module::{MODULE_MANIFEST_FILE, ModuleManifest, find_module_manifest, sync_dependencies}, module::{MODULE_MANIFEST_FILE, ModuleManifest, find_module_manifest, sync_dependencies},
@@ -16,6 +19,41 @@ use wasmeld_package::{
const TARGET: &str = "wasm32-wasip2"; const TARGET: &str = "wasm32-wasip2";
#[derive(Clone, Copy)]
pub(crate) enum BuildProfile {
Debug,
Release,
}
impl BuildProfile {
fn directory(self) -> &'static str {
match self {
Self::Debug => "debug",
Self::Release => "release",
}
}
}
pub(crate) enum PackageIdentity {
Cargo,
Development { id: Option<String> },
}
pub(crate) struct PackRequest {
pub manifest_path: PathBuf,
pub output: Option<PathBuf>,
pub no_build: bool,
pub locked: bool,
pub profile: BuildProfile,
pub identity: PackageIdentity,
}
pub(crate) struct PackedComponent {
pub manifest: PackageManifest,
pub output: PathBuf,
pub artifact: PathBuf,
}
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
struct CargoMetadata { struct CargoMetadata {
packages: Vec<CargoPackage>, packages: Vec<CargoPackage>,
@@ -51,6 +89,7 @@ fn run() -> Result<(), Box<dyn std::error::Error>> {
let mut arguments = env::args().skip(1); let mut arguments = env::args().skip(1);
match arguments.next().as_deref() { match arguments.next().as_deref() {
Some("pack") => run_pack(arguments.collect()), Some("pack") => run_pack(arguments.collect()),
Some("dev") => dev::run(arguments.collect()),
Some("wit") => run_wit(arguments.collect()), Some("wit") => run_wit(arguments.collect()),
_ => Err(usage().into()), _ => Err(usage().into()),
} }
@@ -78,69 +117,102 @@ fn run_pack(arguments: Vec<String>) -> Result<(), Box<dyn std::error::Error>> {
} }
} }
let manifest_path = fs::canonicalize(manifest_path)?; let packed = pack_component(PackRequest {
sync_component_dependencies(&manifest_path, locked)?; manifest_path: PathBuf::from(manifest_path),
output,
no_build,
locked,
profile: BuildProfile::Release,
identity: PackageIdentity::Cargo,
})?;
println!(
"packed {}@{} -> {}",
packed.manifest.id,
packed.manifest.revision,
packed.output.display()
);
println!("component: {}", packed.artifact.display());
println!("sha256: {}", packed.manifest.sha256);
Ok(())
}
pub(crate) fn pack_component(
request: PackRequest,
) -> Result<PackedComponent, Box<dyn std::error::Error>> {
let manifest_path = fs::canonicalize(request.manifest_path)?;
sync_component_dependencies(&manifest_path, request.locked)?;
let metadata = cargo_metadata(&manifest_path)?; let metadata = cargo_metadata(&manifest_path)?;
let package = metadata let package = metadata
.packages .packages
.iter() .iter()
.find(|package| package.manifest_path == manifest_path) .find(|package| package.manifest_path == manifest_path)
.ok_or("Cargo metadata did not contain the requested package")?; .ok_or("Cargo metadata did not contain the requested package")?;
let id = package let cargo_id = package
.metadata .metadata
.get("wasmeld") .get("wasmeld")
.and_then(|metadata| metadata.get("id")) .and_then(|metadata| metadata.get("id"))
.and_then(serde_json::Value::as_str) .and_then(serde_json::Value::as_str)
.ok_or("[package.metadata.wasmeld].id is required")?; .ok_or("[package.metadata.wasmeld].id is required")?
.to_owned();
let world = package let world = package
.metadata .metadata
.get("wasmeld") .get("wasmeld")
.and_then(|metadata| metadata.get("world")) .and_then(|metadata| metadata.get("world"))
.and_then(serde_json::Value::as_str) .and_then(serde_json::Value::as_str)
.ok_or("[package.metadata.wasmeld].world is required")?; .ok_or("[package.metadata.wasmeld].world is required")?
PackageManifest::new(id, &package.version, world, b"identity-validation").validate()?; .to_owned();
let cargo_revision = package.version.clone();
let target = package let target = package
.targets .targets
.iter() .iter()
.find(|target| target.crate_types.iter().any(|kind| kind == "cdylib")) .find(|target| target.crate_types.iter().any(|kind| kind == "cdylib"))
.ok_or("component package must expose a cdylib target")?; .ok_or("component package must expose a cdylib target")?
.name
.clone();
if !no_build { if !request.no_build {
build_component(&manifest_path)?; build_component(&manifest_path, request.profile)?;
} }
let artifact = metadata let artifact = metadata
.target_directory .target_directory
.join(TARGET) .join(TARGET)
.join("release") .join(request.profile.directory())
.join(format!("{}.wasm", target.name.replace('-', "_"))); .join(format!("{}.wasm", target.replace('-', "_")));
let component = fs::read(&artifact).map_err(|error| { let component = fs::read(&artifact).map_err(|error| {
format!( format!(
"failed to read built component {}: {error}", "failed to read built component {}: {error}",
artifact.display() artifact.display()
) )
})?; })?;
let output = output.unwrap_or_else(|| { let (id, revision) = match request.identity {
PackageIdentity::Cargo => (cargo_id, cargo_revision),
PackageIdentity::Development { id } => {
let digest = format!("{:x}", Sha256::digest(&component));
(
id.unwrap_or_else(|| format!("{cargo_id}-dev")),
format!("{cargo_revision}-dev.h{}", &digest[..12]),
)
}
};
PackageManifest::new(&id, &revision, &world, b"identity-validation").validate()?;
let output = request.output.unwrap_or_else(|| {
metadata metadata
.workspace_root .workspace_root
.join("dist") .join("dist")
.join(format!("{id}-{}.wasmpkg", package.version)) .join(format!("{id}-{revision}.wasmpkg"))
}); });
if let Some(parent) = output.parent() { if let Some(parent) = output.parent() {
fs::create_dir_all(parent)?; fs::create_dir_all(parent)?;
} }
let file = fs::File::create(&output)?; let file = fs::File::create(&output)?;
let manifest = write_package(file, id, &package.version, world, &component)?; let manifest = write_package(file, id, revision, world, &component)?;
Ok(PackedComponent {
println!( manifest,
"packed {}@{} -> {}", output,
manifest.id, artifact,
manifest.revision, })
output.display()
);
println!("component: {}", artifact.display());
println!("sha256: {}", manifest.sha256);
Ok(())
} }
fn run_wit(arguments: Vec<String>) -> Result<(), Box<dyn std::error::Error>> { fn run_wit(arguments: Vec<String>) -> Result<(), Box<dyn std::error::Error>> {
@@ -426,14 +498,21 @@ fn cargo_metadata(manifest_path: &Path) -> Result<CargoMetadata, Box<dyn std::er
Ok(serde_json::from_slice(&output.stdout)?) Ok(serde_json::from_slice(&output.stdout)?)
} }
fn build_component(manifest_path: &Path) -> Result<(), Box<dyn std::error::Error>> { fn build_component(
manifest_path: &Path,
profile: BuildProfile,
) -> Result<(), Box<dyn std::error::Error>> {
let toolchain = env::var("WASMELD_COMPONENT_TOOLCHAIN").unwrap_or_else(|_| "1.90.0".to_owned()); let toolchain = env::var("WASMELD_COMPONENT_TOOLCHAIN").unwrap_or_else(|_| "1.90.0".to_owned());
let status = Command::new("rustup") let mut command = Command::new("rustup");
command
.args(["run", &toolchain, "cargo", "build"]) .args(["run", &toolchain, "cargo", "build"])
.arg("--manifest-path") .arg("--manifest-path")
.arg(manifest_path) .arg(manifest_path)
.args(["--target", TARGET, "--release"]) .args(["--target", TARGET]);
.status()?; if matches!(profile, BuildProfile::Release) {
command.arg("--release");
}
let status = command.status()?;
if !status.success() { if !status.success() {
return Err(format!("component build failed with status {status}").into()); return Err(format!("component build failed with status {status}").into());
} }
@@ -443,6 +522,7 @@ fn build_component(manifest_path: &Path) -> Result<(), Box<dyn std::error::Error
fn usage() -> String { fn usage() -> String {
"usage: "usage:
wasmeld pack <Cargo.toml> [--output <file.wasmpkg>] [--no-build] [--locked] wasmeld pack <Cargo.toml> [--output <file.wasmpkg>] [--no-build] [--locked]
wasmeld dev <Cargo.toml> [--console <url>] [--id <service-id>] [--locked] [--release] [--once] [--poll-ms <milliseconds>]
wasmeld wit fetch [--manifest <wasmeld.toml>] [--locked] wasmeld wit fetch [--manifest <wasmeld.toml>] [--locked]
wasmeld wit tidy [--manifest <wasmeld.toml>] [--locked] wasmeld wit tidy [--manifest <wasmeld.toml>] [--locked]
wasmeld wit graph [--manifest <wasmeld.toml>] wasmeld wit graph [--manifest <wasmeld.toml>]
+49
View File
@@ -390,6 +390,55 @@ impl Runtime {
self.start(&key, init_config) self.start(&key, init_config)
} }
/// Stops and removes one registered service revision.
///
/// Callers must switch any external deployment reference away from this
/// revision before unregistering it.
pub fn unregister(&self, key: &ServiceKey) -> Result<(), RuntimeError> {
let _lifecycle = self
.inner
.lifecycle
.lock()
.map_err(|_| RuntimeError::LockPoisoned)?;
if !self
.inner
.services
.lock()
.map_err(|_| RuntimeError::LockPoisoned)?
.contains_key(key)
{
return Err(RuntimeError::ServiceNotRegistered(key.clone()));
}
let actor = self
.inner
.actors
.lock()
.map_err(|_| RuntimeError::LockPoisoned)?
.get(key)
.cloned();
if let Some(actor) = actor {
if actor.is_available() {
actor.stop()?;
} else if actor.is_alive() {
return Err(RuntimeError::ActorUnavailable(key.clone()));
}
self.inner
.actors
.lock()
.map_err(|_| RuntimeError::LockPoisoned)?
.remove(key);
}
self.inner
.services
.lock()
.map_err(|_| RuntimeError::LockPoisoned)?
.remove(key);
Ok(())
}
/// Returns counts without exposing internal Engine or Store state. /// Returns counts without exposing internal Engine or Store state.
pub fn stats(&self) -> Result<RuntimeStats, RuntimeError> { pub fn stats(&self) -> Result<RuntimeStats, RuntimeError> {
let registered_services = self let registered_services = self
@@ -283,6 +283,23 @@ fn kv_capability_requires_a_backend_and_enforces_limits() {
runtime.stop(&key).unwrap(); runtime.stop(&key).unwrap();
} }
#[test]
fn unregister_stops_actor_and_releases_compiled_revision() {
let runtime = Runtime::new(RuntimeConfig::default()).expect("runtime should start");
let key = register_component(&runtime, "unregister", "echo_component.wasm", test_limits());
runtime.start(&key, Vec::new()).unwrap();
runtime.unregister(&key).unwrap();
let stats = runtime.stats().unwrap();
assert_eq!(stats.registered_services, 0);
assert_eq!(stats.running_actors, 0);
assert!(matches!(
runtime.start(&key, Vec::new()),
Err(RuntimeError::ServiceNotRegistered(missing)) if missing == key
));
}
#[test] #[test]
fn registration_rejects_non_whitelisted_wasi_imports() { fn registration_rejects_non_whitelisted_wasi_imports() {
let runtime = Runtime::new(RuntimeConfig::default()).expect("runtime should start"); let runtime = Runtime::new(RuntimeConfig::default()).expect("runtime should start");