Files
windmill/backend/windmill-api/src/apps_raw_bundle.rs
Ruben Fiszel 81ba9611eb feat: deploy a raw app from its sources, bundling them on a worker (#10500)
* feat: deploy a raw app from its sources, bundling them on a worker

* refactor: bundle raw app sources with the wmill CLI instead of a second bundler

* fix: address review findings on the raw app source deploy

* fix: bound bundle decompression, drop the npm dependency on slim workers

* fix: stop minting jobs:run for the source deploy, share the decode budget

* feat: let an MCP token grant the scopes its selected tools require

* fix: carry a caller-held extra scope through the MCP proxy

* fix: confine the run scope to the proxied request instead of the token

* fix: mint the run scope only for a token that names the tool

* fix: require write access before compiling, and state the grant where it is granted

* fix: let the database decide write access instead of restating its policies

* fix: answer a write denial with 403, not 401
2026-08-04 14:29:13 +00:00

257 lines
9.1 KiB
Rust

//! Server-side bundling of a raw app's sources.
//!
//! A raw app is served as a compiled js/css bundle, so every deploy path has to
//! compile first: the editor bundles in a browser iframe, the CLI bundles on the
//! developer's machine. Neither is reachable from a plain API call, which is why
//! `/apps/update_raw` takes the bundle as multipart and why there was no way to
//! deploy a raw app from an API client (an MCP agent, most of all — its only
//! app-write tool was the low-code one, which converted the app instead).
//!
//! The compile runs as a normal bun job on a worker: no new job kind, no new
//! executor, and the build's logs, timeout, cancellation and attribution are the
//! ones every other job gets. The build itself is `wmill app bundle`, so this
//! adds no bundler of its own to keep in step with the CLI's and the editor's.
use std::collections::HashMap;
use std::io::Read;
use base64::Engine;
use serde::Deserialize;
use uuid::Uuid;
use windmill_common::{
db::UserDB,
error::{Error, Result},
jobs::{JobPayload, RawCode},
scripts::ScriptLang,
worker::to_raw_value,
DB,
};
use windmill_queue::{push, PushArgs, PushIsolationLevel};
use crate::db::ApiAuthed;
/// The bundle job's script, as its own file so it stays readable TypeScript.
const BUNDLER_TS: &str = include_str!("apps_raw_bundler.ts");
/// Cap the job so a pathological `package.json` can't sit on a worker forever.
/// This bounds the *run*, not the wait: see `wait_for_bundle`.
const BUNDLE_TIMEOUT_SECS: i32 = 300;
#[derive(Deserialize)]
struct BundleResult {
js_gz: String,
css_gz: String,
}
/// This server's release, without the git describe suffix an off-tag build
/// carries — the CLI is published per release, so that is the version to ask npm
/// for, and the one an installed CLI must report to be used instead.
fn release_version() -> String {
let v = &*windmill_common::utils::GIT_SEM_VERSION;
format!("{}.{}.{}", v.major, v.minor, v.patch)
}
/// The build command the job falls back to when the worker has no usable `wmill`
/// installed: the CLI for this server's release, fetched on the spot. A dev
/// server is off-tag and so asks for the last release; to build with an
/// unreleased CLI set `WM_RAW_APP_BUNDLER_CLI` to the whole command, e.g.
/// `bun run /path/to/cli/src/main.ts app bundle` — which also stops the job from
/// preferring an installed `wmill`.
fn bundler_cli_command() -> Vec<String> {
match std::env::var("WM_RAW_APP_BUNDLER_CLI") {
Ok(cmd) if !cmd.trim().is_empty() => {
cmd.split_whitespace().map(|s| s.to_string()).collect()
}
// `bun x`, not `bunx`: the images copy the `bun` binary alone, so the
// `bunx` entry point isn't on a worker's PATH.
_ => vec![
"bun".to_string(),
"x".to_string(),
"--bun".to_string(),
format!("windmill-cli@{}", release_version()),
"app".to_string(),
"bundle".to_string(),
],
}
}
/// Compile `files` into the js/css a deployed raw app serves. Returns the
/// build's own error when it fails, so the caller sees the compile error rather
/// than a generic failure.
///
/// This makes a worker run a build on caller-supplied sources, so it requires
/// `jobs:run` here rather than trusting each caller to have checked: a token
/// that can't run jobs must not gain that by writing an app.
pub(crate) async fn bundle_raw_app_sources(
db: &DB,
user_db: &UserDB,
authed: &ApiAuthed,
w_id: &str,
files: &HashMap<String, String>,
) -> Result<(String, String)> {
crate::utils::check_scopes(authed, || "jobs:run".to_string())?;
if files.is_empty() {
return Err(Error::BadRequest(
"app value has no `files` to bundle".to_string(),
));
}
// A queued bundle holds this request open until it runs, so refuse early
// rather than pile up connections waiting behind a backlog.
windmill_api_jobs::execution::check_queue_too_long(
db,
*windmill_api_jobs::execution::QUEUE_LIMIT_WAIT_RESULT,
)
.await?;
let shared_ui = shared_ui_files(user_db, authed, w_id).await?;
let mut args: HashMap<String, Box<serde_json::value::RawValue>> = HashMap::new();
args.insert("files".to_string(), to_raw_value(files));
args.insert("shared_ui".to_string(), to_raw_value(&shared_ui));
let overridden = std::env::var("WM_RAW_APP_BUNDLER_CLI").is_ok_and(|c| !c.trim().is_empty());
args.insert(
"cli_command".to_string(),
to_raw_value(&bundler_cli_command()),
);
args.insert(
"prefer_installed_cli".to_string(),
to_raw_value(&!overridden),
);
let tx = PushIsolationLevel::Isolated(user_db.clone(), authed.clone().into());
let (uuid, tx) = push(
db,
tx,
w_id,
JobPayload::Code(RawCode {
hash: None,
content: BUNDLER_TS.to_string(),
path: Some("bundle raw app".to_string()),
language: ScriptLang::Bun,
lock: None,
concurrency_settings: Default::default(),
debouncing_settings: Default::default(),
cache_ttl: None,
cache_ignore_s3_path: None,
dedicated_worker: None,
modules: None,
tag: None,
}),
PushArgs { args: &args, extra: None },
authed.display_username(),
&authed.email,
windmill_common::users::username_to_permissioned_as(&authed.username),
authed.token_prefix.as_deref(),
authed.username_override.as_deref(),
None,
None,
None,
None,
None,
None,
false,
false,
None,
true,
None,
Some(BUNDLE_TIMEOUT_SECS),
None,
None,
Some(&authed.clone().into()),
false,
None,
authed.trigger_or_fallback(None),
None,
)
.await?;
tx.commit().await?;
wait_for_bundle(db, w_id, uuid, authed).await
}
/// Waits for the bundle job. The wait itself is bounded by
/// `TIMEOUT_WAIT_RESULT`, not by `BUNDLE_TIMEOUT_SECS` — the job's timeout only
/// starts once a worker picks it up.
async fn wait_for_bundle(
db: &DB,
w_id: &str,
uuid: Uuid,
authed: &ApiAuthed,
) -> Result<(String, String)> {
let (result, success) = windmill_api_jobs::execution::run_wait_result_internal(
db,
uuid,
w_id,
None,
false,
&authed.username,
)
.await?;
if !success {
// The job's error is the compile error the caller needs to act on.
return Err(Error::BadRequest(format!(
"raw app bundling failed (job {uuid}): {}",
result.get()
)));
}
let bundle: BundleResult = serde_json::from_str(result.get()).map_err(|e| {
Error::internal_err(format!(
"unexpected raw app bundler result (job {uuid}): {e}"
))
})?;
// One budget across both, so the pair can't hold twice the limit in memory.
let limit = *crate::REQUEST_SIZE_LIMIT.read().await * 5;
let js = gunzip_b64(&bundle.js_gz, limit)?;
let css = gunzip_b64(&bundle.css_gz, limit - js.len())?;
Ok((js, css))
}
/// Bounded: what the job returns is compressed, so the result-size cap says
/// nothing about what it expands to, and the sources that produced it came from
/// the caller. The budget is shared by the js and css of one bundle, and matches
/// what `/apps/update_raw` accepts as a whole body.
fn gunzip_b64(b64: &str, limit: usize) -> Result<String> {
let compressed = base64::engine::general_purpose::STANDARD
.decode(b64)
.map_err(|e| Error::internal_err(format!("raw app bundle is not valid base64: {e}")))?;
// Bytes, not a String: over the limit the read stops mid-stream, and
// read_to_string would report that as invalid utf-8 rather than as the size
// it is. One byte past, so a bundle that just fits is told from one cut short.
let mut out = Vec::new();
flate2::read::GzDecoder::new(&compressed[..])
.take(limit as u64 + 1)
.read_to_end(&mut out)
.map_err(|e| Error::internal_err(format!("raw app bundle is not valid gzip: {e}")))?;
if out.len() > limit {
// `limit` is what is left of the budget, not the whole of it, so say so
// rather than report a nearly-exhausted budget as the limit itself.
return Err(Error::BadRequest(format!(
"raw app bundle is too large: {limit} bytes left of the budget the js and css share"
)));
}
String::from_utf8(out)
.map_err(|e| Error::internal_err(format!("raw app bundle is not valid utf-8: {e}")))
}
async fn shared_ui_files(
user_db: &UserDB,
authed: &ApiAuthed,
w_id: &str,
) -> Result<HashMap<String, String>> {
let mut tx = user_db.clone().begin(authed).await?;
let files = sqlx::query_scalar!(
"SELECT files FROM workspace_shared_ui WHERE workspace_id = $1",
w_id
)
.fetch_optional(&mut *tx)
.await?;
tx.commit().await?;
Ok(files
.and_then(|f| serde_json::from_value::<HashMap<String, String>>(f).ok())
.unwrap_or_default())
}