Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2c6e761288 | |||
| 839874fb56 | |||
| 201d99c7e0 | |||
| d0d2e06f68 | |||
| 778fbd2a06 |
@@ -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
@@ -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;
|
||||||
|
|||||||
@@ -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" },
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 { .. })
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(¤t) {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>]
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
Reference in New Issue
Block a user