From c18d4270aff86057ef425c95f3ef7ac2566a8860 Mon Sep 17 00:00:00 2001 From: Paul Osborne Date: Wed, 15 Apr 2026 14:25:53 -0500 Subject: [PATCH 1/3] Write correct producers metadata to generated components These changes updates fastly-compute-py to inject appropriate producer metadata to our generated component at the top-level. Here's what we get with this change: $ wasm-tools metadata show build/bottle-app.composed.wasm --json | jq '.component.metadata.producers[]' [ "language", { "Python": "3.14" } ] [ "processed-by", { "componentize-py": "0.22.1", "fastly-compute-py": "0.1.0" } ] [ "sdk", { "fastly-compute-py": "0.1.0" } ] --- Cargo.lock | 35 +++++++ crates/fastly-compute-py/Cargo.toml | 2 + crates/fastly-compute-py/build.rs | 17 +++- crates/fastly-compute-py/src/lib.rs | 59 +++++++++++- fastly_compute/tests/test_wasm_metadata.py | 107 +++++++++++++++++++++ 5 files changed, 218 insertions(+), 2 deletions(-) create mode 100644 fastly_compute/tests/test_wasm_metadata.py diff --git a/Cargo.lock b/Cargo.lock index bf538ec..63ed84c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -279,6 +279,15 @@ version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +[[package]] +name = "camino" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e629a66d692cb9ff1a1c664e41771b3dcaf961985a9774c0eb0bd1b51cf60a48" +dependencies = [ + "serde_core", +] + [[package]] name = "cap-fs-ext" version = "3.4.5" @@ -357,6 +366,30 @@ dependencies = [ "winx", ] +[[package]] +name = "cargo-platform" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87a0c0e6148f11f01f32650a2ea02d532b2ad4e81d8bd41e6e565b5adc5e6082" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "cargo_metadata" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef987d17b0a113becdd19d3d0022d04d7ef41f9efe4f3fb63ac44ba61df3ade9" +dependencies = [ + "camino", + "cargo-platform", + "semver", + "serde", + "serde_json", + "thiserror 2.0.18", +] + [[package]] name = "cc" version = "1.2.59" @@ -852,6 +885,7 @@ name = "fastly-compute-py" version = "0.1.0" dependencies = [ "anyhow", + "cargo_metadata", "clap", "componentize-py", "env_logger 0.11.10", @@ -865,6 +899,7 @@ dependencies = [ "wac-graph", "wac-parser", "wac-types", + "wasm-metadata 0.245.1", "wit-component 0.219.2", "wit-component 0.244.0", "wit-parser 0.219.2", diff --git a/crates/fastly-compute-py/Cargo.toml b/crates/fastly-compute-py/Cargo.toml index 9132d2a..fdafb41 100644 --- a/crates/fastly-compute-py/Cargo.toml +++ b/crates/fastly-compute-py/Cargo.toml @@ -23,6 +23,7 @@ wac-graph = "0.8" wac-parser = "0.8" wit-parser = "0.244" wit-component = "0.244" +wasm-metadata = { version = "0.245", default-features = false } tempfile = "3" componentize-py = { git = "https://github.com/bytecodealliance/componentize-py" } futures = { version = "0.3", default-features = false, features = ["executor"] } @@ -35,5 +36,6 @@ indexmap = "2" [build-dependencies] anyhow = "1" +cargo_metadata = "0.23.1" wit-parser = "0.219" wit-component = "0.219" diff --git a/crates/fastly-compute-py/build.rs b/crates/fastly-compute-py/build.rs index 1babd45..557f6db 100644 --- a/crates/fastly-compute-py/build.rs +++ b/crates/fastly-compute-py/build.rs @@ -8,6 +8,7 @@ fn main() -> Result<()> { println!("cargo:rerun-if-changed=../../wit"); println!("cargo:rerun-if-changed=../../crates/wasiless"); println!("cargo:rerun-if-changed=../../wrap_app_in_wasiless.wac"); + println!("cargo:rerun-if-changed=../../Cargo.lock"); let root_dir = PathBuf::from("../../"); let wit_dir = root_dir.join("wit"); @@ -25,6 +26,20 @@ fn main() -> Result<()> { out_dir.join("wrap_app_in_wasiless.wac"), )?; + // Expose the componentize-py version for embedding in Wasm producers metadata. + let metadata = cargo_metadata::MetadataCommand::new() + .manifest_path(root_dir.join("Cargo.toml")) + .exec() + .context("Failed to run `cargo metadata`")?; + let componentize_py_version = metadata + .packages + .iter() + .find(|p| p.name == "componentize-py") + .with_context(|| "componentize-py not found in cargo metadata")? + .version + .to_string(); + println!("cargo:rustc-env=COMPONENTIZE_PY_VERSION={componentize_py_version}"); + Ok(()) } @@ -71,7 +86,7 @@ fn build_wasiless_wasm(root_dir: impl AsRef, out_dir: impl AsRef) -> anyhow::bail!("Failed to build wasiless"); } - // Transform wasiless into a component using wasm-tools compnent new + // Transform wasiless into a component using wasm-tools component new let input_wasm = target_dir.join("wasm32-unknown-unknown/release/wasiless.wasm"); let output_wasm = out_dir.as_ref().join("wasiless.wasm"); diff --git a/crates/fastly-compute-py/src/lib.rs b/crates/fastly-compute-py/src/lib.rs index 1c79dd3..f4784e2 100644 --- a/crates/fastly-compute-py/src/lib.rs +++ b/crates/fastly-compute-py/src/lib.rs @@ -10,6 +10,7 @@ use wac_graph::EncodeOptions; use wac_parser::Document; use wac_types::BorrowedPackageKey; use wac_types::Package; +use wasm_metadata::AddMetadata; pub mod cli; pub mod config; @@ -168,7 +169,10 @@ pub fn build(output: PathBuf, entry_name: String, virtualenv: Option) - let composed = compose_with_wasiless(&temp_component_wasm_path, WASILESS_WASM, WRAP_WAC, &output)?; - fs::write(&output, composed) + log::info!(" Injecting Fastly metadata..."); + let annotated = inject_fastly_metadata(composed)?; + + fs::write(&output, annotated) .with_context(|| format!("Failed to write output: {}", output.display()))?; log::debug!("Composed output: {}", output.display()); @@ -231,3 +235,56 @@ fn compose_with_wasiless( Ok(encoded) } + +/// Inject build tool metadata into the Wasm component's standard `producers` +/// custom section. +/// +/// Follows the WebAssembly [Producers Section spec] field conventions: +/// +/// - `language: Python ` — the source language and the CPython +/// version bundled by componentize-py. Update this when upgrading to a +/// componentize-py release that bundles a different CPython version. +/// - `sdk: fastly-compute-py ` — the SDK library the user's code is +/// written against, analogous to `@fastly/js-compute` for the JS SDK. +/// - `processed-by: componentize-py ` — the tool that performed the +/// core Wasm transformation. `fastly-compute-py` also adds itself here as +/// the build orchestrator. +/// +/// Note: the Fastly-proprietary `fastly.manifest.*` custom sections +/// (language, version, service_id, etc.) are **not** written here. Those are +/// injected during package ingestion, sourced from the `fastly.toml` manifest +/// that the CLI bundles alongside the Wasm in the upload package. +/// Dependency lists, build scripts, and machine info are similarly the CLI's +/// responsibility via its `fastly_data` producers entry. +/// +/// [Producers Section spec]: https://github.com/WebAssembly/tool-conventions/blob/main/ProducersSection.md +fn inject_fastly_metadata(wasm: Vec) -> Result> { + let mut add_metadata = AddMetadata::default(); + + // Source language. The version is the CPython version bundled by + // componentize-py — update this when upgrading to a componentize-py + // release that bundles a different CPython version. + add_metadata + .language + .push(("Python".to_owned(), "3.14".to_owned())); + + // The SDK the user's code is written against. + add_metadata.sdk.push(( + "fastly-compute-py".to_owned(), + env!("CARGO_PKG_VERSION").to_owned(), + )); + + // Tools that performed the Wasm transformation. + add_metadata.processed_by.push(( + "componentize-py".to_owned(), + env!("COMPONENTIZE_PY_VERSION").to_owned(), + )); + add_metadata.processed_by.push(( + "fastly-compute-py".to_owned(), + env!("CARGO_PKG_VERSION").to_owned(), + )); + + add_metadata + .to_wasm(&wasm) + .context("Failed to add producers metadata to Wasm component") +} diff --git a/fastly_compute/tests/test_wasm_metadata.py b/fastly_compute/tests/test_wasm_metadata.py new file mode 100644 index 0000000..3bd0cfe --- /dev/null +++ b/fastly_compute/tests/test_wasm_metadata.py @@ -0,0 +1,107 @@ +"""Tests for Wasm producers metadata embedded in the built component. + +Verifies that all top-level producers section fields written by +inject_fastly_metadata() are present and correct: + + - language: Python 3.14 + - sdk: fastly-compute-py 0.1.0 + - processed-by: componentize-py 0.22.1 + - processed-by: fastly-compute-py 0.1.0 + +The language version is also cross-checked against the libpython*.so name +embedded in the component tree to catch version drift when upgrading +componentize-py. +""" + +import json +import re +import subprocess +from pathlib import Path + +import pytest + +WASM_PATH = Path("build/bottle-app.composed.wasm") + +# NOTE: these will need to be updated at times but serves as a sanity check +FASTLY_COMPUTE_PY_VERSION = "0.1.0" +COMPONENTIZE_PY_VERSION = "0.22.1" +PYTHON_VERSION = "3.14" + + +@pytest.fixture(scope="module") +def metadata(): + """Return the parsed wasm-tools metadata JSON for the built component.""" + if not WASM_PATH.exists(): + pytest.fail(f"Built wasm not found at {WASM_PATH}; run `make` first") + + result = subprocess.run( + ["wasm-tools", "metadata", "show", "--json", str(WASM_PATH)], + capture_output=True, + text=True, + check=True, + ) + return json.loads(result.stdout) + + +@pytest.fixture(scope="module") +def producers(metadata): + """Return the top-level producers as a {field: {name: version}} dict.""" + raw = metadata["component"]["metadata"]["producers"] + return dict(raw) if raw else {} + + +def _libpython_version(metadata): + """Return the version from the first libpython*.so module name in the tree.""" + nodes = [metadata] + while nodes: + node = nodes.pop() + kind = next(iter(node)) + data = node[kind] + if kind == "module": + m = re.match(r"libpython(\d+\.\d+)\.so", data.get("name") or "") + if m: + return m.group(1) + else: + nodes.extend(data.get("children", [])) + return None + + +def test_language_python(producers): + assert producers.get("language", {}).get("Python") == PYTHON_VERSION + + +def test_language_python_version_matches_libpython(producers, metadata): + """Declared Python version must match the libpython*.so embedded by componentize-py. + + If this fails, update PYTHON_VERSION in this file and the hardcoded version + in crates/fastly-compute-py/src/lib.rs. This is designed to keep us honest + and ensure that we don't end up injecting the wrong version as it is + not trivial to extract this from compnentize-py directly. + """ + embedded = _libpython_version(metadata) + assert embedded is not None, "No libpython*.so module found in component tree" + assert producers["language"]["Python"] == embedded, ( + f"language: Python {producers['language']['Python']!r} does not match " + f"embedded libpython{embedded}.so — update PYTHON_VERSION in this file " + "and the hardcoded version in crates/fastly-compute-py/src/lib.rs" + ) + + +def test_sdk_fastly_compute_py(producers): + assert ( + producers.get("sdk", {}).get("fastly-compute-py") == FASTLY_COMPUTE_PY_VERSION + ) + + +def test_processed_by_componentize_py(producers): + assert ( + producers.get("processed-by", {}).get("componentize-py") + == COMPONENTIZE_PY_VERSION + ) + + +def test_processed_by_fastly_compute_py(producers): + assert ( + producers.get("processed-by", {}).get("fastly-compute-py") + == FASTLY_COMPUTE_PY_VERSION + ) From 4fd8544c3ca9b298c2bed438fe36d0ddc4d7ca70 Mon Sep 17 00:00:00 2001 From: Paul Osborne Date: Wed, 15 Apr 2026 14:27:36 -0500 Subject: [PATCH 2/3] Address non-functinoal PR nits --- fastly_compute/tests/test_wasm_metadata.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/fastly_compute/tests/test_wasm_metadata.py b/fastly_compute/tests/test_wasm_metadata.py index 3bd0cfe..6e9edf1 100644 --- a/fastly_compute/tests/test_wasm_metadata.py +++ b/fastly_compute/tests/test_wasm_metadata.py @@ -50,7 +50,7 @@ def producers(metadata): return dict(raw) if raw else {} -def _libpython_version(metadata): +def _libpython_version(metadata) -> str | None: """Return the version from the first libpython*.so module name in the tree.""" nodes = [metadata] while nodes: @@ -58,12 +58,11 @@ def _libpython_version(metadata): kind = next(iter(node)) data = node[kind] if kind == "module": - m = re.match(r"libpython(\d+\.\d+)\.so", data.get("name") or "") + m = re.match(r"libpython(\d+\.\d+)\.so", data.get("name", "")) if m: return m.group(1) else: nodes.extend(data.get("children", [])) - return None def test_language_python(producers): From 85c70df96193841baa7db05ddc5f021b5bf7f2fd Mon Sep 17 00:00:00 2001 From: Paul Osborne Date: Wed, 15 Apr 2026 14:58:02 -0500 Subject: [PATCH 3/3] More explicitly nandle case of module name being null in JSON --- fastly_compute/tests/test_wasm_metadata.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/fastly_compute/tests/test_wasm_metadata.py b/fastly_compute/tests/test_wasm_metadata.py index 6e9edf1..4a66822 100644 --- a/fastly_compute/tests/test_wasm_metadata.py +++ b/fastly_compute/tests/test_wasm_metadata.py @@ -58,9 +58,11 @@ def _libpython_version(metadata) -> str | None: kind = next(iter(node)) data = node[kind] if kind == "module": - m = re.match(r"libpython(\d+\.\d+)\.so", data.get("name", "")) - if m: - return m.group(1) + name = data.get("name") # may be None or "" + if name: + m = re.match(r"libpython(\d+\.\d+)\.so", name) + if m: + return m.group(1) else: nodes.extend(data.get("children", []))