docs(package): explain packaging and dependency workflows

This commit is contained in:
Maofeng
2026-07-30 08:13:20 +08:00
parent a5718da1da
commit a1b808013a
5 changed files with 252 additions and 8 deletions
+37
View File
@@ -1,4 +1,22 @@
//! Local build, watch, and deployment loop for Component development.
//!
//! The loop fingerprints Rust/WIT inputs, materializes WIT dependencies,
//! compiles a Component, creates a content-addressed development revision,
//! registers it, and atomically switches the Console deployment. A failed
//! build or deployment never removes the previously active revision.
//!
//! Build and deployment are separate states. Once a build succeeds,
//! `pending_deployment` retains that exact package and retries network or
//! Console failures without rebuilding it. A later source change supersedes
//! the pending package and starts a new build.
//!
//! # Example
//!
//! ```text
//! cargo run -p wasmeld-package --bin wasmeld -- \
//! dev components/counter/Cargo.toml \
//! --console http://127.0.0.1:8080
//! ```
use std::{
collections::BTreeSet,
@@ -57,6 +75,9 @@ pub(crate) fn run(arguments: Vec<String>) -> Result<(), Box<dyn std::error::Erro
fs::create_dir_all(&temporary_dir)?;
let output = temporary_dir.join("component.wasmpkg");
let mut observed = None;
// Keeping this separate from `observed` lets transient deployment errors
// retry the already-built bytes. Rebuilding on every HTTP failure would
// waste time and could create a different revision unexpectedly.
let mut pending_deployment = None;
println!("watching:");
@@ -138,6 +159,10 @@ fn deploy_component(
console: &str,
packed: &PackedComponent,
) -> Result<(), Box<dyn std::error::Error>> {
// Order is intentional: register makes the immutable revision available,
// activate switches new gateway resolutions, and only then may old
// development revisions be removed. Cleanup is best-effort because a
// successful deployment must not be reported as failed due to stale files.
register(client, console, packed)?;
activate(
client,
@@ -235,6 +260,9 @@ fn register(
if response.status().is_success() {
return Ok(());
}
// Retrying a package whose registration response was lost is safe only
// when the exact immutable identity already exists. Do not treat every
// conflict as success; it may represent a different control-plane error.
if response.status() == StatusCode::CONFLICT
&& service_exists(
client,
@@ -334,6 +362,9 @@ fn watch_roots(manifest_path: &Path) -> Result<Vec<PathBuf>, Box<dyn std::error:
let module_path = component_root.join(MODULE_MANIFEST_FILE);
if module_path.is_file() {
let module = ModuleManifest::read(&module_path)?;
// Path replacements are source inputs just like the Component itself.
// Registry dependencies are immutable and represented by wit.lock, so
// they do not need independent watch roots.
for replacement in module.replacements.values() {
let path = component_root.join(&replacement.path);
if path.exists() {
@@ -351,6 +382,8 @@ fn source_fingerprint(roots: &[PathBuf]) -> Result<Vec<u8>, Box<dyn std::error::
}
let mut digest = Sha256::new();
for path in files {
// Include paths as well as bytes so file renames trigger a rebuild even
// when their contents are unchanged.
digest.update(path.to_string_lossy().as_bytes());
match fs::read(&path) {
Ok(bytes) => digest.update(bytes),
@@ -384,6 +417,8 @@ fn ignored_directory(path: &Path) -> bool {
name,
Some(".git" | ".wasmeld" | "deps" | "dist" | "node_modules" | "target")
) {
// `wit/deps` is generated by dependency sync. Watching it would make
// each build rewrite watched files and trigger an endless rebuild.
return name != Some("deps")
|| path
.parent()
@@ -395,6 +430,8 @@ fn ignored_directory(path: &Path) -> bool {
}
fn is_source_file(path: &Path) -> bool {
// The list is deliberately narrow: fingerprints should represent inputs
// to Cargo/WIT resolution, not editor state or generated artifacts.
matches!(
path.extension().and_then(|extension| extension.to_str()),
Some("rs" | "wit")
+67 -2
View File
@@ -5,6 +5,34 @@
//! containing exactly [`PACKAGE_MANIFEST_PATH`] and [`COMPONENT_PATH`]. WIT
//! packages use the Component Model binary WIT encoding implemented by the
//! [`wit_package`] module; they are not stored in `.wasmpkg` containers.
//!
//! # Example
//!
//! ```
//! use std::io::Cursor;
//! use wasmeld_package::{read_package, write_package};
//!
//! let component = b"\0asm\x0d\0\x01\0";
//! let mut archive = Cursor::new(Vec::new());
//! write_package(
//! &mut archive,
//! "image-resize",
//! "1.0.0",
//! "example:image-resize/service@1.0.0",
//! component,
//! )?;
//!
//! let decoded = read_package(archive.get_ref(), 1024 * 1024)?;
//! assert_eq!(decoded.manifest.id, "image-resize");
//! assert_eq!(decoded.component, component);
//! # Ok::<(), wasmeld_package::PackageError>(())
//! ```
//!
//! The decoder treats an uploaded archive as untrusted input: it accepts only
//! the two canonical entries, limits uncompressed sizes, validates identifiers,
//! and verifies the Component digest before returning bytes to the Runtime.
#![warn(missing_docs)]
use std::{
collections::BTreeSet,
@@ -35,16 +63,26 @@ const MAX_MANIFEST_BYTES: usize = 64 * 1024;
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(deny_unknown_fields)]
pub struct PackageManifest {
/// Manifest schema understood by this version of Wasmeld.
pub schema_version: u32,
/// Stable service identifier used in management and gateway routes.
pub id: String,
/// Immutable service revision; the same `id@revision` cannot be overwritten.
pub revision: String,
/// Fully versioned WIT world implemented by the Component.
pub world: String,
/// Archive path of the executable Component; currently always [`COMPONENT_PATH`].
pub component: String,
/// Lowercase SHA-256 digest of the uncompressed Component bytes.
pub sha256: String,
}
impl PackageManifest {
/// Creates a manifest and computes the component digest.
///
/// This constructor does not reject invalid identifiers or worlds. Call
/// [`Self::validate`] before persisting it; [`write_package`] does this
/// automatically.
pub fn new(
id: impl Into<String>,
revision: impl Into<String>,
@@ -94,31 +132,48 @@ impl PackageManifest {
/// A validated component package decoded from a `.wasmpkg` archive.
#[derive(Debug)]
pub struct ComponentPackage {
/// Validated identity, world, and integrity metadata.
pub manifest: PackageManifest,
/// Uncompressed Component bytes whose digest matches the manifest.
pub component: Vec<u8>,
}
/// Errors produced while creating or validating component packages.
#[derive(Debug, Error)]
pub enum PackageError {
/// The archive layout or a manifest value violates the package contract.
#[error("invalid component package: {0}")]
InvalidPackage(String),
/// The uncompressed Component exceeds the caller-selected upload limit.
#[error("component exceeds the {limit}-byte uncompressed limit")]
ComponentTooLarge { limit: usize },
ComponentTooLarge {
/// Maximum number of accepted uncompressed bytes.
limit: usize,
},
/// Component bytes do not match the immutable digest in `package.toml`.
#[error("component digest mismatch: expected {expected}, found {actual}")]
DigestMismatch { expected: String, actual: String },
DigestMismatch {
/// Digest declared by the package manifest.
expected: String,
/// Digest computed from the uploaded Component.
actual: String,
},
/// Reading or writing the package stream failed.
#[error("failed to read or write package: {0}")]
Io(#[from] io::Error),
/// The bytes are not a valid constrained ZIP archive.
#[error("invalid ZIP container: {0}")]
Zip(#[from] ZipError),
/// `package.toml` could not be decoded.
#[error("invalid package manifest: {0}")]
ManifestParse(#[from] toml::de::Error),
/// A generated package manifest could not be encoded.
#[error("failed to serialize package manifest: {0}")]
ManifestSerialize(#[from] toml::ser::Error),
}
@@ -127,6 +182,11 @@ pub enum PackageError {
///
/// The caller owns the destination writer. Runtime limits are intentionally not
/// included because the Console injects platform policy during registration.
///
/// # Errors
///
/// Returns an error when the identity or world is invalid, the Component is
/// empty, or the destination cannot be written.
pub fn write_package<W>(
writer: W,
id: impl Into<String>,
@@ -162,6 +222,11 @@ where
///
/// `max_component_bytes` applies to the uncompressed component so compressed
/// archives cannot bypass the platform's artifact limit.
///
/// # Errors
///
/// Returns an error for malformed ZIP data, extra or duplicate entries,
/// oversized content, invalid metadata, empty Components, or digest mismatch.
pub fn read_package(
package_bytes: &[u8],
max_component_bytes: usize,
+41
View File
@@ -1,4 +1,18 @@
//! Command-line entry point for Component packaging and WIT dependency workflows.
//!
//! Typical release packaging:
//!
//! ```text
//! wasmeld wit fetch --manifest components/counter/wasmeld.toml --locked
//! wasmeld pack components/counter/Cargo.toml --locked
//! ```
//!
//! `wit fetch` resolves registry or local `replace` sources into `wit/deps`
//! and writes `wit.lock`. `--locked` verifies that resolution matches the
//! existing lock file and is the appropriate mode for CI. A `replace` changes
//! only the development source used for a declared package identity; it does
//! not rename that package or make the local path part of the published
//! Component contract.
mod dev;
@@ -21,7 +35,9 @@ const TARGET: &str = "wasm32-wasip2";
#[derive(Clone, Copy)]
pub(crate) enum BuildProfile {
/// Fast local build with Cargo's `debug` profile.
Debug,
/// Optimized artifact from Cargo's `release` profile.
Release,
}
@@ -35,22 +51,36 @@ impl BuildProfile {
}
pub(crate) enum PackageIdentity {
/// Use `[package.metadata.wasmeld].id` and the Cargo package version.
Cargo,
/// Use a development service ID and a Component-content revision suffix.
Development { id: Option<String> },
}
pub(crate) struct PackRequest {
/// Cargo manifest for the Component crate.
pub manifest_path: PathBuf,
/// Destination package, or the workspace `dist` default.
pub output: Option<PathBuf>,
/// Reuse an artifact already present under Cargo's target directory.
///
/// The caller is responsible for ensuring it matches current sources and
/// the requested profile.
pub no_build: bool,
/// Require `wit.lock` to match dependency resolution exactly.
pub locked: bool,
/// Cargo profile used to build and locate the Component.
pub profile: BuildProfile,
/// Stable release or content-addressed development identity.
pub identity: PackageIdentity,
}
pub(crate) struct PackedComponent {
/// Validated metadata embedded in the `.wasmpkg`.
pub manifest: PackageManifest,
/// Path to the resulting `.wasmpkg`.
pub output: PathBuf,
/// Path to the raw Component produced or reused by Cargo.
pub artifact: PathBuf,
}
@@ -141,6 +171,9 @@ pub(crate) fn pack_component(
request: PackRequest,
) -> Result<PackedComponent, Box<dyn std::error::Error>> {
let manifest_path = fs::canonicalize(request.manifest_path)?;
// Materialize WIT before asking Cargo for metadata or compiling: the
// component's `wit_bindgen::generate!` reads `wit/world.wit` and
// `wit/deps` during macro expansion.
sync_component_dependencies(&manifest_path, request.locked)?;
let metadata = cargo_metadata(&manifest_path)?;
let package = metadata
@@ -189,6 +222,9 @@ pub(crate) fn pack_component(
let (id, revision) = match request.identity {
PackageIdentity::Cargo => (cargo_id, cargo_revision),
PackageIdentity::Development { id } => {
// The revision changes only when compiled Component bytes change.
// This makes registration retryable and avoids mutating an
// immutable identity while editing.
let digest = format!("{:x}", Sha256::digest(&component));
(
id.unwrap_or_else(|| format!("{cargo_id}-dev")),
@@ -316,6 +352,8 @@ fn run_wit_replace(
let manifest_path = resolve_module_manifest(manifest_path)?;
let mut manifest = ModuleManifest::read(&manifest_path)?;
// Replace is recorded in the source manifest. The next fetch/pack resolves
// and locks its contents; generated `wit/deps` must not be hand-edited.
if drop {
if !manifest.drop_replacement(&package) {
return Err(format!("replacement {package:?} does not exist").into());
@@ -407,6 +445,9 @@ fn run_wit_publish(
return Err(format!("registry returned HTTP {status}: {}", response.text()?).into());
}
let published = response.json::<WitPackageMetadata>()?;
// A successful status is insufficient: compare the server's canonical
// identity and digest to detect a proxy or Registry returning metadata for
// different bytes.
if published != package.metadata {
return Err(format!(
"registry metadata mismatch: expected {}@{} ({})",
+78 -5
View File
@@ -4,6 +4,23 @@
//! prefers root-level path replacements and otherwise downloads immutable
//! binary packages from the configured Registry. The complete transitive graph
//! is materialized under `wit/deps` and recorded in `wit.lock`.
//!
//! `wasmeld.toml` is the human-authored input; `wit.lock` is the reproducibility
//! record; and `wit/deps` is disposable generated output. Commit the first two,
//! but regenerate `wit/deps` with [`crate::module::sync_dependencies`] after
//! cloning.
//!
//! # Example
//!
//! ```no_run
//! use wasmeld_package::module::sync_dependencies;
//!
//! // CI uses locked mode so changed Registry content, replacements, or
//! // transitive dependencies fail instead of rewriting wit.lock.
//! let report = sync_dependencies("components/image-resize/wasmeld.toml", true)?;
//! println!("resolved {} packages", report.packages.len());
//! # Ok::<(), wasmeld_package::module::ModuleError>(())
//! ```
use std::{
collections::{BTreeMap, BTreeSet},
@@ -156,39 +173,79 @@ pub struct SyncReport {
/// Errors raised while parsing, resolving, locking, or materializing WIT dependencies.
#[derive(Debug, Error)]
pub enum ModuleError {
/// A manifest value, dependency identity, version, or path is invalid.
#[error("invalid WIT module: {0}")]
InvalidManifest(String),
/// A manifest, lock file, or local WIT source could not be read.
#[error("failed to read {path}: {source}")]
Read { path: PathBuf, source: io::Error },
Read {
/// File or directory involved in the failed read.
path: PathBuf,
/// Underlying filesystem error.
source: io::Error,
},
/// A lock file or materialized dependency could not be written.
#[error("failed to write {path}: {source}")]
Write { path: PathBuf, source: io::Error },
Write {
/// Destination that could not be updated.
path: PathBuf,
/// Underlying filesystem error.
source: io::Error,
},
/// TOML input could not be decoded.
#[error("failed to parse {path}: {source}")]
ParseManifest {
/// Manifest or lock file containing invalid TOML.
path: PathBuf,
/// TOML decoder error.
source: toml::de::Error,
},
/// A canonical manifest or lock file could not be encoded as TOML.
#[error("failed to serialize {path}: {source}")]
SerializeManifest {
/// Destination whose model could not be serialized.
path: PathBuf,
/// TOML encoder error.
source: toml::ser::Error,
},
/// Local or downloaded bytes are not the requested versioned WIT package.
#[error("invalid WIT package at {path}: {message}")]
InvalidWitPackage { path: PathBuf, message: String },
InvalidWitPackage {
/// Source path used while validating the package.
path: PathBuf,
/// Identity, version, parsing, or dependency validation detail.
message: String,
},
/// Locked resolution produced a graph different from `wit.lock`.
#[error("locked dependency graph differs from {path}; run `wasmeld wit tidy`")]
LockMismatch { path: PathBuf },
LockMismatch {
/// Existing lock file that must be updated outside locked mode.
path: PathBuf,
},
/// Fetching or validating an immutable Registry artifact failed.
#[error("WIT registry request to {url} failed: {message}")]
Registry { url: String, message: String },
Registry {
/// Exact Registry endpoint requested by the resolver.
url: String,
/// HTTP, size-limit, digest, or package-validation detail.
message: String,
},
}
impl ModuleManifest {
/// Reads and validates a `wasmeld.toml` manifest.
///
/// # Errors
///
/// Returns [`ModuleError::Read`], [`ModuleError::ParseManifest`], or
/// [`ModuleError::InvalidManifest`] without modifying the source file.
pub fn read(path: impl AsRef<Path>) -> Result<Self, ModuleError> {
let path = path.as_ref();
let source = fs::read_to_string(path).map_err(|source| ModuleError::Read {
@@ -205,6 +262,9 @@ impl ModuleManifest {
}
/// Validates and writes a canonical TOML manifest.
///
/// Callers editing replacements should mutate a parsed value and invoke
/// this method once, so invalid intermediate state is never persisted.
pub fn write(&self, path: impl AsRef<Path>) -> Result<(), ModuleError> {
self.validate()?;
let path = path.as_ref();
@@ -248,6 +308,9 @@ impl ModuleManifest {
}
/// Sets a package-wide or exact-version path replacement.
///
/// An exact key such as `wasmeld:kv@0.1.0` takes precedence over the
/// package-wide `wasmeld:kv` key during resolution.
pub fn set_path_replacement(
&mut self,
package: impl Into<String>,
@@ -311,6 +374,16 @@ impl ModuleLock {
/// dependencies embedded in Registry artifacts. With `locked = true`, the
/// computed graph must exactly match the existing `wit.lock`; the function does
/// not update the lock file.
///
/// The dependency directory is assembled in a temporary sibling directory and
/// swapped into place only after the complete graph validates. A failed fetch
/// therefore leaves both the lock and materialized dependencies intact.
///
/// # Errors
///
/// Returns an error when the manifest or lock is invalid, a replacement has the
/// wrong identity, a Registry package is unavailable or oversized, or two paths
/// resolve the same package version to different content.
pub fn sync_dependencies(
manifest_path: impl AsRef<Path>,
locked: bool,
+29 -1
View File
@@ -21,7 +21,9 @@ pub const WIT_PACKAGE_SCHEMA_VERSION: u32 = 1;
#[derive(Clone, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
#[serde(deny_unknown_fields)]
pub struct WitDependency {
/// Package identity in `namespace:name` form.
pub name: String,
/// Exact semantic version encoded by the dependency package.
pub version: String,
}
@@ -29,32 +31,48 @@ pub struct WitDependency {
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(deny_unknown_fields)]
pub struct WitPackageMetadata {
/// Metadata response schema used by the Registry API.
pub schema_version: u32,
/// Package identity derived from binary WIT contents.
pub name: String,
/// Explicit semantic version derived from binary WIT contents.
pub version: String,
/// SHA-256 digest of the complete binary WIT artifact.
pub sha256: String,
/// Exact direct dependencies embedded in the package.
pub dependencies: Vec<WitDependency>,
}
/// Encoded package bytes together with metadata derived from those bytes.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct BuiltWitPackage {
/// Metadata derived by decoding the bytes after encoding.
pub metadata: WitPackageMetadata,
/// Standard Component Model binary WIT package.
pub bytes: Vec<u8>,
}
/// Errors raised while parsing WIT source or decoding a binary WIT package.
#[derive(Debug, Error)]
pub enum WitPackageError {
/// WIT source could not be parsed or resolved.
#[error("failed to parse WIT package at {path}: {message}")]
Source { path: PathBuf, message: String },
Source {
/// Source file or directory passed to the parser.
path: PathBuf,
/// Parser or dependency-resolution detail.
message: String,
},
/// A resolved WIT package could not be encoded.
#[error("failed to encode WIT package: {0}")]
Encode(String),
/// Input bytes are not a standard binary WIT package.
#[error("invalid binary WIT package: {0}")]
Decode(String),
/// The package identity omits the version required by the Registry.
#[error("WIT package {0} must declare an explicit semantic version")]
MissingVersion(String),
}
@@ -64,6 +82,11 @@ pub enum WitPackageError {
/// The package must declare an explicit semantic version. Dependencies
/// available through the source path are encoded as Component Model package
/// references and reported in [`WitPackageMetadata::dependencies`].
///
/// # Errors
///
/// Returns an error when WIT parsing, dependency resolution, binary encoding,
/// or post-encode inspection fails.
pub fn build_wit_package(path: impl AsRef<Path>) -> Result<BuiltWitPackage, WitPackageError> {
let path = path.as_ref();
let mut resolve = Resolve::default();
@@ -83,6 +106,11 @@ pub fn build_wit_package(path: impl AsRef<Path>) -> Result<BuiltWitPackage, WitP
///
/// Ordinary WebAssembly Components are rejected even though both formats use a
/// Component Model binary container.
///
/// # Errors
///
/// Returns an error if the bytes are malformed, contain a Component instead of
/// a WIT package, or any package in the direct dependency set lacks a version.
pub fn inspect_wit_package(bytes: &[u8]) -> Result<WitPackageMetadata, WitPackageError> {
let decoded = decode(bytes).map_err(|error| WitPackageError::Decode(error.to_string()))?;
let DecodedWasm::WitPackage(resolve, package) = decoded else {