//! The native `cargo` actor: builds Rust source folders into `.wasm` files. //! //! Request: `{"type": "build", "source": "/actor//public/"}` //! Replies: `{"type": "built", "wasm": "/actor/cargo/public/builds//.wasm", ...}` //! `{"type": "build_failed", "errors": "..."}` //! //! The broker knows nothing about Rust. This actor is one way to get a //! `.wasm` file. Other actors can add other languages. //! //! Actors depend on the SDK with `sausage-actor = "0.1"`. The SDK is not //! published: this actor copies the SDK of this host to `public/sdk/` (so //! that every actor can read it), and points the dependency there in the //! build copy of `Cargo.toml`. use std::path::{Path, PathBuf}; use std::sync::Arc; use std::time::Instant; use anyhow::{Context, Result, anyhow, bail}; use serde_json::{Value, json}; use tokio::process::Command; use tokio::sync::mpsc; use include_dir::{Dir, include_dir}; use crate::broker::{Broker, Envelope}; use crate::world::{self, World}; pub const ID: &str = "cargo"; pub const TARGET: &str = "wasm32-wasip2"; /// The source of the `sausage-actor` SDK. static SDK: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/../sdk"); pub struct BuildOutput { /// Host path of the published `.wasm` file. pub wasm: PathBuf, pub seconds: f64, pub warnings: String, } /// All builds share one target folder, so they run one at a time. pub struct Cargo { world: World, } impl Cargo { /// Create the actor and install the SDK of this host into `public/sdk/`. pub fn new(world: World) -> Result { write_dir(&SDK, &world.public_dir(ID).join("sdk"))?; Ok(Self { world }) } /// Build the crate in `source` (a host path) and publish the `.wasm` file /// under `/actor/cargo/public/builds//`. pub async fn build(&self, source: &Path) -> Result { if !source.join("Cargo.toml").is_file() { bail!("{} has no Cargo.toml", source.display()); } let started = Instant::now(); let data = self.world.data_dir(ID); let build_dir = data.join("work"); world::replace_source(source, &build_dir)?; let manifest = build_dir.join("Cargo.toml"); std::fs::write(&manifest, prepare_manifest(&std::fs::read_to_string(&manifest)?, Some(&self.world.public_dir(ID).join("sdk")))?)?; let mut cmd = Command::new("cargo"); cmd.arg("build") .arg("--release") .arg("--target") .arg(TARGET) .arg("--message-format=json-render-diagnostics") .arg("--target-dir") .arg(data.join("target")) .current_dir(&build_dir) .kill_on_drop(true); let out = cmd.output().await.context("cannot run cargo (is it on PATH, with the wasm32-wasip2 target?)")?; let stderr = String::from_utf8_lossy(&out.stderr).to_string(); if !out.status.success() { bail!("{}", strip_progress(&stderr)); } let mut artifact = None; for line in String::from_utf8_lossy(&out.stdout).lines() { if let Ok(v) = serde_json::from_str::(line) && v["reason"] == "compiler-artifact" && let Some(exe) = v["executable"].as_str() { artifact = Some(PathBuf::from(exe)); } } let artifact = artifact.ok_or_else(|| anyhow!("cargo built no binary. Does the crate have a src/main.rs?"))?; let n = next_build_number(&self.world.public_dir(ID).join("builds"))?; let dest_dir = self.world.public_dir(ID).join("builds").join(n.to_string()); let name = artifact.file_name().context("artifact has no file name")?; let dest = dest_dir.join(name); world::write_atomic(&dest, &std::fs::read(&artifact)?)?; Ok(BuildOutput { wasm: dest, seconds: started.elapsed().as_secs_f64(), warnings: strip_progress(&stderr) }) } /// The native `cargo` actor. Builds run one at a time, in order. /// Replies (messages with `reply_to`) get no answer. pub async fn run_actor(self, broker: Arc, mut mailbox: mpsc::Receiver) { while let Some(env) = mailbox.recv().await { if env.reply_to.is_some() { continue; } let body = self.handle(&broker, &env).await.unwrap_or_else(|e| { json!({"type": "build_failed", "source": env.body.get("source"), "errors": format!("{e:#}")}) }); broker.send(ID, Some(&env.from), None, Some(&env.id), body); } } async fn handle(&self, broker: &Broker, env: &Envelope) -> Result { match env.body.get("type").and_then(Value::as_str) { Some("build") => { let source_s = env.body.get("source").and_then(Value::as_str).ok_or_else(|| anyhow!("missing \"source\""))?; let source = broker.world.resolve(&env.from, source_s)?; let out = self.build(&source).await?; let wasm = self.world.global_path(&out.wasm).unwrap_or_else(|| out.wasm.display().to_string()); Ok(json!({ "type": "built", "wasm": wasm, "source": source_s, "built_by": ID, "seconds": (out.seconds * 10.0).round() / 10.0, "warnings": out.warnings, })) } other => bail!("unknown request {other:?}. Known: build"), } } } /// Point a `sausage-actor` dependency at `sdk` (keeping its features), and /// add an empty `[workspace]`, so the crate never joins a parent workspace. fn prepare_manifest(text: &str, sdk: Option<&Path>) -> Result { let mut doc: toml_edit::DocumentMut = text.parse()?; if let Some(sdk) = sdk { for section in ["dependencies", "dev-dependencies", "build-dependencies"] { let Some(dep) = doc.get_mut(section).and_then(|t| t.get_mut("sausage-actor")) else { continue }; let mut table = dep.as_inline_table().cloned().unwrap_or_default(); for key in ["version", "path", "git", "branch", "tag", "rev", "registry"] { table.remove(key); } table.insert("path", sdk.display().to_string().into()); *dep = toml_edit::value(table); } } if doc.get("workspace").is_none() { doc["workspace"] = toml_edit::table(); } Ok(doc.to_string()) } /// Write the files of `dir` below `to`. Skip files that did not change, so /// that cargo does not rebuild the SDK at every start. fn write_dir(dir: &Dir<'_>, to: &Path) -> Result<()> { for file in dir.files() { let dest = to.join(file.path()); let contents = match file.path().file_name() { Some(n) if n == "Cargo.toml" => prepare_manifest(file.contents_utf8().context("Cargo.toml")?, None)?.into_bytes(), _ => file.contents().to_vec(), }; if std::fs::read(&dest).ok() != Some(contents.clone()) { std::fs::create_dir_all(dest.parent().context("no parent")?)?; std::fs::write(&dest, contents)?; } } for sub in dir.dirs() { write_dir(sub, to)?; } Ok(()) } fn next_build_number(dir: &Path) -> Result { std::fs::create_dir_all(dir)?; let max = std::fs::read_dir(dir)? .filter_map(|e| e.ok()?.file_name().to_str()?.parse::().ok()) .max() .unwrap_or(0); Ok(max + 1) } /// Remove cargo's "Compiling ..." progress lines. Keep diagnostics. fn strip_progress(stderr: &str) -> String { stderr .lines() .filter(|l| { let t = l.trim_start(); !(t.starts_with("Compiling ") || t.starts_with("Downloaded ") || t.starts_with("Downloading ") || t.starts_with("Updating ") || t.starts_with("Locking ") || t.starts_with("Adding ") || t.starts_with("Blocking ")) }) .collect::>() .join("\n") }