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 { 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, 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) { 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, package: String, target: String, source_path: PathBuf, } fn compile_project_wasm(project: &Path) -> Result { 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> { let mut records: Vec = 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 { 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::>(), "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())) }