Compare commits
5 Commits
71654a8bcf
...
2c6e761288
| Author | SHA1 | Date | |
|---|---|---|---|
| 2c6e761288 | |||
| 839874fb56 | |||
| 201d99c7e0 | |||
| d0d2e06f68 | |||
| 778fbd2a06 |
@@ -56,6 +56,24 @@ cd console
|
||||
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
|
||||
|
||||
+1
-1
@@ -35,7 +35,7 @@ export type BackendService = {
|
||||
export type BackendEvent = {
|
||||
id: number;
|
||||
timestamp_ms: number;
|
||||
kind: "registered" | "deployed" | "started" | "stopped" | "invoked" | "failed";
|
||||
kind: "registered" | "unregistered" | "deployed" | "started" | "stopped" | "invoked" | "failed";
|
||||
service_id: string | null;
|
||||
revision: string | null;
|
||||
message: string;
|
||||
|
||||
@@ -135,6 +135,7 @@ function toEvent(event: BackendEvent): RuntimeEvent {
|
||||
event.service_id && event.revision ? `${event.service_id}@${event.revision}` : "Wasmeld";
|
||||
const meta: Record<BackendEvent["kind"], { label: string; tone: EventTone }> = {
|
||||
registered: { label: "已注册", tone: "success" },
|
||||
unregistered: { label: "已注销", tone: "neutral" },
|
||||
deployed: { label: "部署切换", tone: "success" },
|
||||
started: { label: "已启动", tone: "success" },
|
||||
stopped: { label: "已停止", tone: "neutral" },
|
||||
|
||||
@@ -60,6 +60,7 @@ WIT Registry 路由。
|
||||
| `POST` | `/api/v1/runtime/restart` | 重建 Runtime,并重新创建活动 Deployment 的 Actor |
|
||||
| `GET` | `/api/v1/services` | 服务版本及精确 Host Capability 列表 |
|
||||
| `POST` | `/api/v1/services` | multipart 上传单个 `package` 字段(`.wasmpkg`) |
|
||||
| `DELETE` | `/api/v1/services/{id}/{revision}` | 注销非活动 Revision 并删除制品 |
|
||||
| `POST` | `/api/v1/services/{id}/{revision}/start` | 启动 Actor |
|
||||
| `POST` | `/api/v1/services/{id}/{revision}/stop` | 停止 Actor |
|
||||
| `POST` | `/api/v1/services/{id}/{revision}/restart` | 重启 Actor |
|
||||
@@ -73,6 +74,9 @@ WIT Registry 路由。
|
||||
| `GET` | `/api/v1/wit/packages/{namespace}/{name}/{version}` | WIT 包元数据 |
|
||||
| `GET` | `/api/v1/wit/packages/{namespace}/{name}/{version}/content` | 下载 WIT 包 |
|
||||
|
||||
活动 Deployment 不能直接注销。开发工具会先完成新 Revision 的编译、注册和切换,
|
||||
再注销上一份开发 Revision,因此失败的构建不会影响当前可调用版本。
|
||||
|
||||
激活已注册版本:
|
||||
|
||||
```bash
|
||||
|
||||
@@ -24,7 +24,7 @@ use axum::{
|
||||
extract::{DefaultBodyLimit, Multipart, Path as AxumPath, State, rejection::BytesRejection},
|
||||
http::{HeaderMap, HeaderValue, Method, StatusCode, header},
|
||||
response::{IntoResponse, Response},
|
||||
routing::{get, post},
|
||||
routing::{delete, get, post},
|
||||
};
|
||||
use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64};
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -218,6 +218,7 @@ pub struct EventView {
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum EventKind {
|
||||
Registered,
|
||||
Unregistered,
|
||||
Deployed,
|
||||
Started,
|
||||
Stopped,
|
||||
@@ -229,6 +230,7 @@ impl EventKind {
|
||||
fn as_database_value(self) -> &'static str {
|
||||
match self {
|
||||
Self::Registered => "registered",
|
||||
Self::Unregistered => "unregistered",
|
||||
Self::Deployed => "deployed",
|
||||
Self::Started => "started",
|
||||
Self::Stopped => "stopped",
|
||||
@@ -240,6 +242,7 @@ impl EventKind {
|
||||
fn from_database_value(value: &str) -> Result<Self, ConsoleError> {
|
||||
match value {
|
||||
"registered" => Ok(Self::Registered),
|
||||
"unregistered" => Ok(Self::Unregistered),
|
||||
"deployed" => Ok(Self::Deployed),
|
||||
"started" => Ok(Self::Started),
|
||||
"stopped" => Ok(Self::Stopped),
|
||||
@@ -351,6 +354,9 @@ pub enum ConsoleError {
|
||||
#[error("service {0} has no active deployment")]
|
||||
DeploymentNotFound(String),
|
||||
|
||||
#[error("service revision {0} is the active deployment")]
|
||||
ActiveDeployment(ServiceKey),
|
||||
|
||||
#[error("Wasmeld Runtime is not running")]
|
||||
RuntimeNotRunning,
|
||||
|
||||
@@ -790,6 +796,50 @@ impl Console {
|
||||
Ok(view)
|
||||
}
|
||||
|
||||
fn unregister(&self, key: &ServiceKey) -> Result<ServiceView, ConsoleError> {
|
||||
let _lifecycle = self
|
||||
.lifecycle
|
||||
.lock()
|
||||
.map_err(|_| ConsoleError::LockPoisoned)?;
|
||||
let view = {
|
||||
let state = self.state()?;
|
||||
if state
|
||||
.deployments
|
||||
.get(key.id())
|
||||
.is_some_and(|deployment| deployment.active_revision == key.revision())
|
||||
{
|
||||
return Err(ConsoleError::ActiveDeployment(key.clone()));
|
||||
}
|
||||
state
|
||||
.services
|
||||
.get(key)
|
||||
.map(ServiceRecord::view)
|
||||
.ok_or_else(|| ConsoleError::ServiceNotFound(key.clone()))?
|
||||
};
|
||||
|
||||
let runtime = self.runtime()?;
|
||||
let runtime = runtime.as_ref().ok_or(ConsoleError::RuntimeNotRunning)?;
|
||||
runtime.unregister(key)?;
|
||||
self.state()?.services.remove(key);
|
||||
|
||||
for path in [
|
||||
self.artifact_dir.join(format!("{key}.wasm")),
|
||||
self.artifact_dir.join(format!("{key}.toml")),
|
||||
] {
|
||||
if let Err(error) = fs::remove_file(&path)
|
||||
&& error.kind() != io::ErrorKind::NotFound
|
||||
{
|
||||
tracing::warn!(path = %path.display(), %error, "failed to remove unregistered artifact");
|
||||
}
|
||||
}
|
||||
self.push_event(
|
||||
EventKind::Unregistered,
|
||||
Some(key),
|
||||
"component revision unregistered".to_owned(),
|
||||
)?;
|
||||
Ok(view)
|
||||
}
|
||||
|
||||
fn start(&self, key: &ServiceKey, init_config: Vec<u8>) -> Result<ServiceView, ConsoleError> {
|
||||
let runtime = self.runtime()?;
|
||||
let runtime = runtime.as_ref().ok_or(ConsoleError::RuntimeNotRunning)?;
|
||||
@@ -1221,7 +1271,7 @@ pub fn app(console: Arc<Console>, allowed_origins: Vec<HeaderValue>) -> Router {
|
||||
.max(console.max_wit_package_bytes())
|
||||
.saturating_add(1024 * 1024);
|
||||
let mut cors = CorsLayer::new()
|
||||
.allow_methods([Method::GET, Method::POST])
|
||||
.allow_methods([Method::GET, Method::POST, Method::DELETE])
|
||||
.allow_headers([header::CONTENT_TYPE]);
|
||||
if !allowed_origins.is_empty() {
|
||||
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/restart", post(restart_runtime))
|
||||
.route("/api/v1/services", get(list_services).post(register))
|
||||
.route(
|
||||
"/api/v1/services/{id}/{revision}",
|
||||
delete(unregister_service),
|
||||
)
|
||||
.route("/api/v1/deployments", get(list_deployments))
|
||||
.route("/api/v1/deployments/{id}", get(get_deployment))
|
||||
.route(
|
||||
@@ -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(
|
||||
State(console): State<Arc<Console>>,
|
||||
AxumPath((id, revision)): AxumPath<(String, String)>,
|
||||
@@ -1766,6 +1830,7 @@ impl IntoResponse for GatewayError {
|
||||
| RuntimeError::ComponentError { .. },
|
||||
) => (StatusCode::BAD_GATEWAY, "service_error", error.to_string()),
|
||||
ConsoleError::ArtifactTooLarge { .. }
|
||||
| ConsoleError::ActiveDeployment(_)
|
||||
| ConsoleError::Package(_)
|
||||
| ConsoleError::WitRegistry(_)
|
||||
| ConsoleError::Storage { .. }
|
||||
@@ -1818,6 +1883,7 @@ impl IntoResponse for ApiError {
|
||||
ConsoleError::InvalidRequest(_) => (StatusCode::BAD_REQUEST, "invalid_request"),
|
||||
ConsoleError::ServiceNotFound(_) => (StatusCode::NOT_FOUND, "service_not_found"),
|
||||
ConsoleError::DeploymentNotFound(_) => (StatusCode::NOT_FOUND, "deployment_not_found"),
|
||||
ConsoleError::ActiveDeployment(_) => (StatusCode::CONFLICT, "active_deployment"),
|
||||
ConsoleError::RuntimeNotRunning => (StatusCode::CONFLICT, "runtime_not_running"),
|
||||
ConsoleError::ArtifactTooLarge { .. }
|
||||
| ConsoleError::Package(PackageError::ComponentTooLarge { .. })
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
//! history, and service-scoped Host KV entries.
|
||||
|
||||
use std::{
|
||||
collections::BTreeSet,
|
||||
path::Path,
|
||||
sync::Arc,
|
||||
time::{SystemTime, UNIX_EPOCH},
|
||||
@@ -130,6 +131,15 @@ impl Persistence {
|
||||
let mut db = self.db.lock().await;
|
||||
let mut tx = db.transaction().await?;
|
||||
|
||||
let service_keys = services
|
||||
.iter()
|
||||
.map(|service| service.service_key.clone())
|
||||
.collect::<BTreeSet<_>>();
|
||||
for stored in StoredService::all().exec(&mut tx).await? {
|
||||
if !service_keys.contains(&stored.service_key) {
|
||||
StoredService::delete_by_service_key(&mut tx, &stored.service_key).await?;
|
||||
}
|
||||
}
|
||||
for service in services {
|
||||
StoredService::upsert_by_service_key(service.service_key)
|
||||
.manifest_toml(service.manifest_toml)
|
||||
@@ -159,6 +169,15 @@ impl Persistence {
|
||||
.await?;
|
||||
}
|
||||
|
||||
let deployment_ids = deployments
|
||||
.iter()
|
||||
.map(|deployment| deployment.service_id.clone())
|
||||
.collect::<BTreeSet<_>>();
|
||||
for stored in StoredDeployment::all().exec(&mut tx).await? {
|
||||
if !deployment_ids.contains(&stored.service_id) {
|
||||
StoredDeployment::delete_by_service_id(&mut tx, &stored.service_id).await?;
|
||||
}
|
||||
}
|
||||
for deployment in deployments {
|
||||
StoredDeployment::upsert_by_service_id(deployment.service_id)
|
||||
.active_revision(deployment.active_revision)
|
||||
|
||||
@@ -261,6 +261,74 @@ async fn routes_raw_gateway_calls_through_the_active_deployment() {
|
||||
assert_eq!(deployments["deployments"][0]["active_revision"], "0.2.0");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unregisters_only_inactive_service_revisions_and_prunes_persistence() {
|
||||
let artifact_dir = TempDir::new().expect("temporary artifact directory");
|
||||
let component = fs::read(echo_component()).expect("echo component should be readable");
|
||||
|
||||
{
|
||||
let application = test_app(artifact_dir.path()).await;
|
||||
for revision in ["0.1.0", "0.2.0"] {
|
||||
let response = application
|
||||
.clone()
|
||||
.oneshot(package_request("dev-echo", revision, &component))
|
||||
.await
|
||||
.expect("register request should complete");
|
||||
assert_eq!(response.status(), StatusCode::CREATED);
|
||||
}
|
||||
|
||||
let response = application
|
||||
.clone()
|
||||
.oneshot(json_request(
|
||||
"/api/v1/deployments/dev-echo/activate",
|
||||
json!({ "revision": "0.1.0" }),
|
||||
))
|
||||
.await
|
||||
.expect("activation request should complete");
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
|
||||
let response = application
|
||||
.clone()
|
||||
.oneshot(delete_request("/api/v1/services/dev-echo/0.1.0"))
|
||||
.await
|
||||
.expect("active unregister request should complete");
|
||||
assert_eq!(response.status(), StatusCode::CONFLICT);
|
||||
assert_eq!(
|
||||
response_json(response).await["error"]["code"],
|
||||
"active_deployment"
|
||||
);
|
||||
|
||||
let response = application
|
||||
.clone()
|
||||
.oneshot(json_request(
|
||||
"/api/v1/deployments/dev-echo/activate",
|
||||
json!({ "revision": "0.2.0" }),
|
||||
))
|
||||
.await
|
||||
.expect("replacement activation should complete");
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
|
||||
let response = application
|
||||
.clone()
|
||||
.oneshot(delete_request("/api/v1/services/dev-echo/0.1.0"))
|
||||
.await
|
||||
.expect("inactive unregister request should complete");
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
assert!(!artifact_dir.path().join("dev-echo@0.1.0.wasm").exists());
|
||||
assert!(!artifact_dir.path().join("dev-echo@0.1.0.toml").exists());
|
||||
}
|
||||
|
||||
let application = test_app(artifact_dir.path()).await;
|
||||
let response = application
|
||||
.oneshot(get_request("/api/v1/services"))
|
||||
.await
|
||||
.expect("restored service list request should complete");
|
||||
let body = response_json(response).await;
|
||||
let services = body["services"].as_array().unwrap();
|
||||
assert_eq!(services.len(), 1);
|
||||
assert_eq!(services[0]["revision"], "0.2.0");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn restores_the_active_deployment_with_a_fresh_actor() {
|
||||
let artifact_dir = TempDir::new().expect("temporary artifact directory");
|
||||
@@ -888,6 +956,14 @@ fn empty_post(uri: &str) -> Request<Body> {
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn delete_request(uri: &str) -> Request<Body> {
|
||||
Request::builder()
|
||||
.method("DELETE")
|
||||
.uri(uri)
|
||||
.body(Body::empty())
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn get_request(uri: &str) -> Request<Body> {
|
||||
Request::builder().uri(uri).body(Body::empty()).unwrap()
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
mod dev;
|
||||
|
||||
use std::{
|
||||
env, fs,
|
||||
path::{Path, PathBuf},
|
||||
@@ -7,6 +9,7 @@ use std::{
|
||||
};
|
||||
|
||||
use serde::Deserialize;
|
||||
use sha2::{Digest, Sha256};
|
||||
use wasmeld_package::{
|
||||
PackageManifest,
|
||||
module::{MODULE_MANIFEST_FILE, ModuleManifest, find_module_manifest, sync_dependencies},
|
||||
@@ -16,6 +19,41 @@ use wasmeld_package::{
|
||||
|
||||
const TARGET: &str = "wasm32-wasip2";
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
pub(crate) enum BuildProfile {
|
||||
Debug,
|
||||
Release,
|
||||
}
|
||||
|
||||
impl BuildProfile {
|
||||
fn directory(self) -> &'static str {
|
||||
match self {
|
||||
Self::Debug => "debug",
|
||||
Self::Release => "release",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) enum PackageIdentity {
|
||||
Cargo,
|
||||
Development { id: Option<String> },
|
||||
}
|
||||
|
||||
pub(crate) struct PackRequest {
|
||||
pub manifest_path: PathBuf,
|
||||
pub output: Option<PathBuf>,
|
||||
pub no_build: bool,
|
||||
pub locked: bool,
|
||||
pub profile: BuildProfile,
|
||||
pub identity: PackageIdentity,
|
||||
}
|
||||
|
||||
pub(crate) struct PackedComponent {
|
||||
pub manifest: PackageManifest,
|
||||
pub output: PathBuf,
|
||||
pub artifact: PathBuf,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct CargoMetadata {
|
||||
packages: Vec<CargoPackage>,
|
||||
@@ -51,6 +89,7 @@ fn run() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let mut arguments = env::args().skip(1);
|
||||
match arguments.next().as_deref() {
|
||||
Some("pack") => run_pack(arguments.collect()),
|
||||
Some("dev") => dev::run(arguments.collect()),
|
||||
Some("wit") => run_wit(arguments.collect()),
|
||||
_ => Err(usage().into()),
|
||||
}
|
||||
@@ -78,69 +117,102 @@ fn run_pack(arguments: Vec<String>) -> Result<(), Box<dyn std::error::Error>> {
|
||||
}
|
||||
}
|
||||
|
||||
let manifest_path = fs::canonicalize(manifest_path)?;
|
||||
sync_component_dependencies(&manifest_path, locked)?;
|
||||
let packed = pack_component(PackRequest {
|
||||
manifest_path: PathBuf::from(manifest_path),
|
||||
output,
|
||||
no_build,
|
||||
locked,
|
||||
profile: BuildProfile::Release,
|
||||
identity: PackageIdentity::Cargo,
|
||||
})?;
|
||||
|
||||
println!(
|
||||
"packed {}@{} -> {}",
|
||||
packed.manifest.id,
|
||||
packed.manifest.revision,
|
||||
packed.output.display()
|
||||
);
|
||||
println!("component: {}", packed.artifact.display());
|
||||
println!("sha256: {}", packed.manifest.sha256);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn pack_component(
|
||||
request: PackRequest,
|
||||
) -> Result<PackedComponent, Box<dyn std::error::Error>> {
|
||||
let manifest_path = fs::canonicalize(request.manifest_path)?;
|
||||
sync_component_dependencies(&manifest_path, request.locked)?;
|
||||
let metadata = cargo_metadata(&manifest_path)?;
|
||||
let package = metadata
|
||||
.packages
|
||||
.iter()
|
||||
.find(|package| package.manifest_path == manifest_path)
|
||||
.ok_or("Cargo metadata did not contain the requested package")?;
|
||||
let id = package
|
||||
let cargo_id = package
|
||||
.metadata
|
||||
.get("wasmeld")
|
||||
.and_then(|metadata| metadata.get("id"))
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.ok_or("[package.metadata.wasmeld].id is required")?;
|
||||
.ok_or("[package.metadata.wasmeld].id is required")?
|
||||
.to_owned();
|
||||
let world = package
|
||||
.metadata
|
||||
.get("wasmeld")
|
||||
.and_then(|metadata| metadata.get("world"))
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.ok_or("[package.metadata.wasmeld].world is required")?;
|
||||
PackageManifest::new(id, &package.version, world, b"identity-validation").validate()?;
|
||||
.ok_or("[package.metadata.wasmeld].world is required")?
|
||||
.to_owned();
|
||||
let cargo_revision = package.version.clone();
|
||||
let target = package
|
||||
.targets
|
||||
.iter()
|
||||
.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 {
|
||||
build_component(&manifest_path)?;
|
||||
if !request.no_build {
|
||||
build_component(&manifest_path, request.profile)?;
|
||||
}
|
||||
|
||||
let artifact = metadata
|
||||
.target_directory
|
||||
.join(TARGET)
|
||||
.join("release")
|
||||
.join(format!("{}.wasm", target.name.replace('-', "_")));
|
||||
.join(request.profile.directory())
|
||||
.join(format!("{}.wasm", target.replace('-', "_")));
|
||||
let component = fs::read(&artifact).map_err(|error| {
|
||||
format!(
|
||||
"failed to read built component {}: {error}",
|
||||
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
|
||||
.workspace_root
|
||||
.join("dist")
|
||||
.join(format!("{id}-{}.wasmpkg", package.version))
|
||||
.join(format!("{id}-{revision}.wasmpkg"))
|
||||
});
|
||||
if let Some(parent) = output.parent() {
|
||||
fs::create_dir_all(parent)?;
|
||||
}
|
||||
let file = fs::File::create(&output)?;
|
||||
let manifest = write_package(file, id, &package.version, world, &component)?;
|
||||
|
||||
println!(
|
||||
"packed {}@{} -> {}",
|
||||
manifest.id,
|
||||
manifest.revision,
|
||||
output.display()
|
||||
);
|
||||
println!("component: {}", artifact.display());
|
||||
println!("sha256: {}", manifest.sha256);
|
||||
Ok(())
|
||||
let manifest = write_package(file, id, revision, world, &component)?;
|
||||
Ok(PackedComponent {
|
||||
manifest,
|
||||
output,
|
||||
artifact,
|
||||
})
|
||||
}
|
||||
|
||||
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)?)
|
||||
}
|
||||
|
||||
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 status = Command::new("rustup")
|
||||
let mut command = Command::new("rustup");
|
||||
command
|
||||
.args(["run", &toolchain, "cargo", "build"])
|
||||
.arg("--manifest-path")
|
||||
.arg(manifest_path)
|
||||
.args(["--target", TARGET, "--release"])
|
||||
.status()?;
|
||||
.args(["--target", TARGET]);
|
||||
if matches!(profile, BuildProfile::Release) {
|
||||
command.arg("--release");
|
||||
}
|
||||
let status = command.status()?;
|
||||
if !status.success() {
|
||||
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 {
|
||||
"usage:
|
||||
wasmeld pack <Cargo.toml> [--output <file.wasmpkg>] [--no-build] [--locked]
|
||||
wasmeld dev <Cargo.toml> [--console <url>] [--id <service-id>] [--locked] [--release] [--once] [--poll-ms <milliseconds>]
|
||||
wasmeld wit fetch [--manifest <wasmeld.toml>] [--locked]
|
||||
wasmeld wit tidy [--manifest <wasmeld.toml>] [--locked]
|
||||
wasmeld wit graph [--manifest <wasmeld.toml>]
|
||||
|
||||
@@ -390,6 +390,55 @@ impl Runtime {
|
||||
self.start(&key, init_config)
|
||||
}
|
||||
|
||||
/// Stops and removes one registered service revision.
|
||||
///
|
||||
/// Callers must switch any external deployment reference away from this
|
||||
/// revision before unregistering it.
|
||||
pub fn unregister(&self, key: &ServiceKey) -> Result<(), RuntimeError> {
|
||||
let _lifecycle = self
|
||||
.inner
|
||||
.lifecycle
|
||||
.lock()
|
||||
.map_err(|_| RuntimeError::LockPoisoned)?;
|
||||
|
||||
if !self
|
||||
.inner
|
||||
.services
|
||||
.lock()
|
||||
.map_err(|_| RuntimeError::LockPoisoned)?
|
||||
.contains_key(key)
|
||||
{
|
||||
return Err(RuntimeError::ServiceNotRegistered(key.clone()));
|
||||
}
|
||||
|
||||
let actor = self
|
||||
.inner
|
||||
.actors
|
||||
.lock()
|
||||
.map_err(|_| RuntimeError::LockPoisoned)?
|
||||
.get(key)
|
||||
.cloned();
|
||||
if let Some(actor) = actor {
|
||||
if actor.is_available() {
|
||||
actor.stop()?;
|
||||
} else if actor.is_alive() {
|
||||
return Err(RuntimeError::ActorUnavailable(key.clone()));
|
||||
}
|
||||
self.inner
|
||||
.actors
|
||||
.lock()
|
||||
.map_err(|_| RuntimeError::LockPoisoned)?
|
||||
.remove(key);
|
||||
}
|
||||
|
||||
self.inner
|
||||
.services
|
||||
.lock()
|
||||
.map_err(|_| RuntimeError::LockPoisoned)?
|
||||
.remove(key);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Returns counts without exposing internal Engine or Store state.
|
||||
pub fn stats(&self) -> Result<RuntimeStats, RuntimeError> {
|
||||
let registered_services = self
|
||||
|
||||
@@ -283,6 +283,23 @@ fn kv_capability_requires_a_backend_and_enforces_limits() {
|
||||
runtime.stop(&key).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unregister_stops_actor_and_releases_compiled_revision() {
|
||||
let runtime = Runtime::new(RuntimeConfig::default()).expect("runtime should start");
|
||||
let key = register_component(&runtime, "unregister", "echo_component.wasm", test_limits());
|
||||
runtime.start(&key, Vec::new()).unwrap();
|
||||
|
||||
runtime.unregister(&key).unwrap();
|
||||
|
||||
let stats = runtime.stats().unwrap();
|
||||
assert_eq!(stats.registered_services, 0);
|
||||
assert_eq!(stats.running_actors, 0);
|
||||
assert!(matches!(
|
||||
runtime.start(&key, Vec::new()),
|
||||
Err(RuntimeError::ServiceNotRegistered(missing)) if missing == key
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn registration_rejects_non_whitelisted_wasi_imports() {
|
||||
let runtime = Runtime::new(RuntimeConfig::default()).expect("runtime should start");
|
||||
|
||||
Reference in New Issue
Block a user