Source commit: ff7f847f143dc67864bed68a86cf259114384bd5 Public tree identity: sha256:bb7dcd2ac78bdbad2f4eba0f49be649d446d67f96f4eb2796941c026412fc032
322 lines
11 KiB
Rust
322 lines
11 KiB
Rust
use std::path::{Path, PathBuf};
|
|
use std::process::Command;
|
|
|
|
use anyhow::{bail, Context, Result};
|
|
use clusterflux_core::Digest;
|
|
use serde_json::{json, Value};
|
|
use wasmparser::{Parser, Payload};
|
|
|
|
use crate::errors::cli_error_summary_for_category;
|
|
use crate::{bundle_inspection, BuildArgs, BundleInspectArgs};
|
|
|
|
pub(crate) fn build_report(args: BuildArgs, cwd: PathBuf) -> Result<Value> {
|
|
let inspection = bundle_inspection(
|
|
BundleInspectArgs {
|
|
project: args.project.clone(),
|
|
source_provider: args.source_provider.clone(),
|
|
disabled_source_providers: args.disabled_source_providers.clone(),
|
|
json: true,
|
|
},
|
|
cwd,
|
|
)?;
|
|
let diagnostics = inspection.pre_schedule_diagnostics.clone();
|
|
let blocking_diagnostic = diagnostics
|
|
.iter()
|
|
.find(|diagnostic| diagnostic.severity == "error")
|
|
.cloned();
|
|
if let Some(diagnostic) = blocking_diagnostic {
|
|
let machine_category = match diagnostic.category.as_str() {
|
|
"environment" => "environment",
|
|
"source_provider" | "capability" => "capability",
|
|
_ => "unknown",
|
|
};
|
|
return Ok(json!({
|
|
"command": "build",
|
|
"status": "blocked_before_schedule",
|
|
"bundle": inspection,
|
|
"diagnostics": diagnostics,
|
|
"contains_full_repository_upload": false,
|
|
"content_addressed": false,
|
|
"debug_metadata_available": false,
|
|
"scheduled_work": false,
|
|
"machine_error": cli_error_summary_for_category(machine_category, &diagnostic.message),
|
|
}));
|
|
}
|
|
|
|
let mut wasm = compile_project_wasm(&inspection.project)?;
|
|
let environment_manifest = serde_json::to_vec(&inspection.metadata.environments)?;
|
|
append_custom_section(
|
|
&mut wasm.bytes,
|
|
"clusterflux.environments",
|
|
&environment_manifest,
|
|
);
|
|
let bundle_digest = Digest::sha256(&wasm.bytes);
|
|
let task_descriptors = descriptor_records(&wasm.bytes, "clusterflux.tasks")?;
|
|
let entrypoint_descriptors = descriptor_records(&wasm.bytes, "clusterflux.entrypoints")?;
|
|
if task_descriptors.is_empty() {
|
|
bail!(
|
|
"compiled Wasm module contains no #[clusterflux::task] descriptors; annotate at least one exported task"
|
|
);
|
|
}
|
|
if entrypoint_descriptors.is_empty() {
|
|
bail!(
|
|
"compiled Wasm module contains no #[clusterflux::main] descriptors; annotate at least one entrypoint"
|
|
);
|
|
}
|
|
let output = args.output.unwrap_or_else(|| {
|
|
inspection.project.join(".clusterflux/build").join(
|
|
bundle_digest
|
|
.as_str()
|
|
.trim_start_matches("sha256:")
|
|
.get(..16)
|
|
.unwrap_or("bundle"),
|
|
)
|
|
});
|
|
let bundle_artifact = write_bundle(
|
|
&output,
|
|
&wasm,
|
|
&bundle_digest,
|
|
&inspection,
|
|
&task_descriptors,
|
|
&entrypoint_descriptors,
|
|
)?;
|
|
|
|
Ok(json!({
|
|
"command": "build",
|
|
"status": "built",
|
|
"bundle": inspection,
|
|
"bundle_artifact": bundle_artifact,
|
|
"diagnostics": diagnostics,
|
|
"contains_full_repository_upload": false,
|
|
"content_addressed": true,
|
|
"debug_metadata_available": true,
|
|
"scheduled_work": false,
|
|
}))
|
|
}
|
|
|
|
fn append_custom_section(module: &mut Vec<u8>, name: &str, data: &[u8]) {
|
|
let mut section = Vec::new();
|
|
encode_unsigned_leb(name.len() as u64, &mut section);
|
|
section.extend_from_slice(name.as_bytes());
|
|
section.extend_from_slice(data);
|
|
module.push(0);
|
|
encode_unsigned_leb(section.len() as u64, module);
|
|
module.extend_from_slice(§ion);
|
|
}
|
|
|
|
fn encode_unsigned_leb(mut value: u64, output: &mut Vec<u8>) {
|
|
loop {
|
|
let mut byte = (value & 0x7f) as u8;
|
|
value >>= 7;
|
|
if value != 0 {
|
|
byte |= 0x80;
|
|
}
|
|
output.push(byte);
|
|
if value == 0 {
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
struct CompiledWasm {
|
|
bytes: Vec<u8>,
|
|
package: String,
|
|
target: String,
|
|
source_path: PathBuf,
|
|
}
|
|
|
|
fn compile_project_wasm(project: &Path) -> Result<CompiledWasm> {
|
|
let manifest = project.join("Cargo.toml");
|
|
let metadata_output = Command::new("cargo")
|
|
.args([
|
|
"metadata",
|
|
"--format-version",
|
|
"1",
|
|
"--no-deps",
|
|
"--manifest-path",
|
|
])
|
|
.arg(&manifest)
|
|
.output()
|
|
.with_context(|| format!("failed to run cargo metadata for {}", manifest.display()))?;
|
|
if !metadata_output.status.success() {
|
|
bail!(
|
|
"cargo metadata failed for {}: {}",
|
|
manifest.display(),
|
|
String::from_utf8_lossy(&metadata_output.stderr).trim()
|
|
);
|
|
}
|
|
let metadata: Value = serde_json::from_slice(&metadata_output.stdout)?;
|
|
let canonical_manifest = std::fs::canonicalize(&manifest)?;
|
|
let package = metadata["packages"]
|
|
.as_array()
|
|
.and_then(|packages| {
|
|
packages.iter().find(|package| {
|
|
package["manifest_path"]
|
|
.as_str()
|
|
.and_then(|path| std::fs::canonicalize(path).ok())
|
|
.as_ref()
|
|
== Some(&canonical_manifest)
|
|
})
|
|
})
|
|
.context("cargo metadata did not return the requested project package")?;
|
|
let package_name = package["name"]
|
|
.as_str()
|
|
.context("cargo package name missing")?;
|
|
let target = package["targets"]
|
|
.as_array()
|
|
.and_then(|targets| {
|
|
targets.iter().find(|target| {
|
|
target["crate_types"]
|
|
.as_array()
|
|
.is_some_and(|types| types.iter().any(|kind| kind == "cdylib"))
|
|
})
|
|
})
|
|
.context("Clusterflux project library must include crate-type = [\"cdylib\"]")?;
|
|
let target_name = target["name"]
|
|
.as_str()
|
|
.context("cargo target name missing")?;
|
|
let build = Command::new("cargo")
|
|
// The MVP transports one Wasm bundle in a bounded control frame. These are
|
|
// ordinary Cargo release-profile settings, applied only to the guest build,
|
|
// that keep the product SDK/runtime inside that accepted boundary.
|
|
.env("CARGO_PROFILE_RELEASE_OPT_LEVEL", "z")
|
|
.env("CARGO_PROFILE_RELEASE_LTO", "thin")
|
|
.env("CARGO_PROFILE_RELEASE_CODEGEN_UNITS", "1")
|
|
.args([
|
|
"build",
|
|
"--quiet",
|
|
"--release",
|
|
"--target",
|
|
"wasm32-unknown-unknown",
|
|
"--lib",
|
|
"--manifest-path",
|
|
])
|
|
.arg(&manifest)
|
|
.output()
|
|
.with_context(|| format!("failed to compile {} to Wasm", manifest.display()))?;
|
|
if !build.status.success() {
|
|
bail!(
|
|
"Wasm bundle compilation failed for {}: {}",
|
|
manifest.display(),
|
|
String::from_utf8_lossy(&build.stderr).trim()
|
|
);
|
|
}
|
|
let target_directory = metadata["target_directory"]
|
|
.as_str()
|
|
.context("cargo target_directory missing")?;
|
|
let source_path = Path::new(target_directory)
|
|
.join("wasm32-unknown-unknown/release")
|
|
.join(format!("{}.wasm", target_name.replace('-', "_")));
|
|
let bytes = std::fs::read(&source_path)
|
|
.with_context(|| format!("compiled Wasm module missing at {}", source_path.display()))?;
|
|
Ok(CompiledWasm {
|
|
bytes,
|
|
package: package_name.to_owned(),
|
|
target: target_name.to_owned(),
|
|
source_path,
|
|
})
|
|
}
|
|
|
|
fn descriptor_records(module: &[u8], section_name: &str) -> Result<Vec<Value>> {
|
|
let mut records: Vec<Value> = Vec::new();
|
|
for payload in Parser::new(0).parse_all(module) {
|
|
let Payload::CustomSection(section) = payload? else {
|
|
continue;
|
|
};
|
|
if section.name() != section_name {
|
|
continue;
|
|
}
|
|
for record in section
|
|
.data()
|
|
.split(|byte| *byte == b'\n' || *byte == 0)
|
|
.filter(|record| !record.is_empty())
|
|
{
|
|
records.push(serde_json::from_slice(record).with_context(|| {
|
|
format!("invalid descriptor record in Wasm custom section {section_name}")
|
|
})?);
|
|
}
|
|
}
|
|
records.sort_by(|left, right| left["name"].as_str().cmp(&right["name"].as_str()));
|
|
Ok(records)
|
|
}
|
|
|
|
fn write_bundle(
|
|
output: &Path,
|
|
wasm: &CompiledWasm,
|
|
bundle_digest: &Digest,
|
|
inspection: &crate::bundle::BundleInspection,
|
|
tasks: &[Value],
|
|
entrypoints: &[Value],
|
|
) -> Result<Value> {
|
|
std::fs::create_dir_all(output)?;
|
|
let module_path = output.join("module.wasm");
|
|
let task_path = output.join("task-descriptors.json");
|
|
let entrypoint_path = output.join("entrypoints.json");
|
|
let environment_path = output.join("environments.json");
|
|
let source_path = output.join("source-provider.json");
|
|
let vfs_path = output.join("vfs-seed.json");
|
|
let debug_path = output.join("debug-metadata.json");
|
|
let manifest_path = output.join("manifest.json");
|
|
std::fs::write(&module_path, &wasm.bytes)?;
|
|
write_json(&task_path, &json!(tasks))?;
|
|
write_json(&entrypoint_path, &json!(entrypoints))?;
|
|
write_json(&environment_path, &json!(inspection.metadata.environments))?;
|
|
write_json(&source_path, &json!(inspection.source_provider_manifest))?;
|
|
write_json(
|
|
&vfs_path,
|
|
&json!({
|
|
"epoch": 0,
|
|
"mounts": ["/vfs/artifacts", "/vfs/sources", "/vfs/blobs"],
|
|
"large_bytes_embedded": false,
|
|
}),
|
|
)?;
|
|
write_json(&debug_path, &json!(inspection.metadata.debug_metadata))?;
|
|
let manifest = json!({
|
|
"kind": "clusterflux-bundle",
|
|
"format_version": 1,
|
|
"package": wasm.package,
|
|
"target": wasm.target,
|
|
"bundle_digest": bundle_digest,
|
|
"module": "module.wasm",
|
|
"module_size_bytes": wasm.bytes.len(),
|
|
"task_descriptors": "task-descriptors.json",
|
|
"entrypoints": "entrypoints.json",
|
|
"environments": "environments.json",
|
|
"source_provider": "source-provider.json",
|
|
"vfs_seed": "vfs-seed.json",
|
|
"debug_metadata": "debug-metadata.json",
|
|
"required_capabilities": tasks.iter().flat_map(|task| {
|
|
task["required_capabilities"].as_array().into_iter().flatten().cloned()
|
|
}).collect::<Vec<_>>(),
|
|
"metadata_identity": inspection.metadata.identity,
|
|
"coordinator_receives_source_bytes_by_default": false,
|
|
"embeds_full_repository": false,
|
|
});
|
|
write_json(&manifest_path, &manifest)?;
|
|
Ok(json!({
|
|
"directory": output,
|
|
"manifest": manifest_path,
|
|
"module": module_path,
|
|
"compiled_module_source": wasm.source_path,
|
|
"bundle_digest": bundle_digest,
|
|
"module_size_bytes": wasm.bytes.len(),
|
|
"task_descriptor_count": tasks.len(),
|
|
"entrypoint_count": entrypoints.len(),
|
|
"files": [
|
|
"manifest.json",
|
|
"module.wasm",
|
|
"task-descriptors.json",
|
|
"entrypoints.json",
|
|
"environments.json",
|
|
"source-provider.json",
|
|
"vfs-seed.json",
|
|
"debug-metadata.json",
|
|
],
|
|
}))
|
|
}
|
|
|
|
fn write_json(path: &Path, value: &Value) -> Result<()> {
|
|
let mut bytes = serde_json::to_vec_pretty(value)?;
|
|
bytes.push(b'\n');
|
|
std::fs::write(path, bytes).with_context(|| format!("failed to write {}", path.display()))
|
|
}
|