Files
windmill/backend/windmill-worker/src/php_executor.rs
Ruben Fiszel b656dc6cdc feat(nsjail): optional disk-backed /tmp via instance setting (#9272)
* feat(nsjail): optional disk-backed /tmp via instance setting

* test(nsjail): unit-test tmp mount resolver and narrow visibility

* refactor(nsjail): switch tmp backing to select + conditional UI

* ui(nsjail): make tmpfs the visible default in /tmp backing select

* fix(nsjail): refuse preexisting jail_tmp to block symlink escape

* fix(nsjail): allow jail_tmp reuse on sequential nsjail calls

Codex flagged that python/ruby/rust executors invoke nsjail twice per
job_dir (install then run). The previous resolver treated any preexisting
jail_tmp as hostile and silently fell back to tmpfs on the second call,
so disk-backed mode never reached the main script run for those langs.

Use symlink_metadata().is_dir() to distinguish a real directory left by
an earlier call in the same job_dir (safe to reuse) from a symlink or
other entity (still refused, as the codebase-tar escape requires).

Also loosen the frontend visibility predicate: only hide nsjail settings
when job_isolation is explicitly 'none' or 'unshare', so deployments
that enable nsjail via DISABLE_NSJAIL=false with no DB setting can
still see the controls.
2026-05-21 15:34:49 +00:00

494 lines
16 KiB
Rust

use convert_case::{Case, Casing};
use itertools::Itertools;
use regex::Regex;
use serde_json::value::RawValue;
use std::{collections::HashMap, process::Stdio};
use tokio::{fs::File, io::AsyncReadExt, process::Command};
use uuid::Uuid;
use windmill_common::{
error::{self, to_anyhow, Result},
scripts::ScriptLang,
utils::calculate_hash,
worker::{write_file, Connection},
workspace_dependencies::clean_lock_from_annotations,
};
use windmill_queue::MiniPulledJob;
use windmill_parser::Typ;
use windmill_queue::{append_logs, CanceledBy};
use crate::{
common::{
build_command_with_isolation, check_executor_binary_exists, create_args_and_out_file,
get_reserved_variables, read_result, resolve_nsjail_timeout, resolve_nsjail_tmp_mount_block,
start_child_process, MaybeLock, OccupancyMetrics,
},
handle_child::handle_child,
is_sandboxing_enabled, COMPOSER_CACHE_DIR, COMPOSER_PATH, DISABLE_NUSER, NSJAIL_PATH, PHP_PATH,
};
use windmill_common::client::AuthedClient;
const NSJAIL_CONFIG_RUN_PHP_CONTENT: &str = include_str!("../nsjail/run.php.config.proto");
lazy_static::lazy_static! {
static ref RE: Regex = Regex::new(r"^//\s?(\S+)\s*$").unwrap();
// Set COMPOSER_VENDOR_CACHE_DISABLED=1 to always run `composer install`
// instead of reusing a previously cached vendor directory.
pub static ref COMPOSER_VENDOR_CACHE_DISABLED: bool =
std::env::var("COMPOSER_VENDOR_CACHE_DISABLED").is_ok();
}
const COMPOSER_LOCK_SPLIT: &str = "\nLOCK\n";
pub fn parse_php_imports(code: &str) -> anyhow::Result<Option<String>> {
let find_requirements = code
.lines()
.find_position(|x| x.starts_with("//require:") || x.starts_with("// require:"));
if let Some((pos, _)) = find_requirements {
let requirements = code
.lines()
.skip(pos + 1)
.map_while(|x| {
RE.captures(x).map(|x| {
match x.get(1).unwrap().as_str().split("@").collect_vec()[..] {
[path, version] => (path.to_string(), version.to_string()),
[path] | [path, ..] => (path.to_string(), "*".to_string()),
[] => unreachable!(),
}
})
})
.collect::<HashMap<_, _>>();
let composer_json =
serde_json::to_string_pretty(&HashMap::from([("require", requirements)]))
.map_err(to_anyhow)?;
Ok(Some(composer_json))
} else {
Ok(None)
}
}
pub async fn composer_install(
mem_peak: &mut i32,
canceled_by: &mut Option<CanceledBy>,
job_id: &Uuid,
w_id: &str,
conn: &Connection,
job_dir: &str,
worker_name: &str,
requirements: String,
lock: Option<String>,
occupancy_metrics: &mut OccupancyMetrics,
) -> Result<String> {
check_executor_binary_exists("php", PHP_PATH.as_str(), "php")?;
// When no lock is provided (previews), try to reuse a previously resolved
// lockfile from the DB so we can hit the same vendor cache as deployed scripts.
let lock = if lock.is_none() && !*COMPOSER_VENDOR_CACHE_DISABLED {
let req_hash = format!("composer-{}", calculate_hash(&requirements));
if let Some(db) = conn.as_sql() {
sqlx::query_scalar!(
"SELECT lockfile FROM pip_resolution_cache WHERE hash = $1",
req_hash
)
.fetch_optional(db)
.await
.ok()
.flatten()
} else {
None
}
} else {
lock
};
// Cache the installed vendor/ directory keyed by requirements + lock content.
// Set COMPOSER_VENDOR_CACHE_DISABLED=1 to opt out.
let vendor_cache_hit = if !*COMPOSER_VENDOR_CACHE_DISABLED {
if let Some(ref lock_content) = lock {
let hash = calculate_hash(&format!("{requirements}{lock_content}"));
let vendor_cache_path = format!("{}/vendor/{hash}", *COMPOSER_CACHE_DIR);
let remote_path = format!("composer/vendor/{hash}");
let (hit, cache_logs) =
crate::global_cache::load_cache(&vendor_cache_path, &remote_path, true).await;
if hit {
append_logs(
job_id,
w_id,
format!("vendor cache hit, skipping composer install\n{cache_logs}"),
conn,
)
.await;
#[cfg(unix)]
std::os::unix::fs::symlink(&vendor_cache_path, format!("{job_dir}/vendor"))
.map_err(|e| {
error::Error::ExecutionErr(format!(
"could not symlink cached vendor dir: {e}"
))
})?;
#[cfg(not(unix))]
windmill_common::worker::copy_dir_recursively(
&std::path::PathBuf::from(&vendor_cache_path),
&std::path::PathBuf::from(&format!("{job_dir}/vendor")),
)?;
true
} else {
false
}
} else {
false
}
} else {
false
};
if vendor_cache_hit {
return Ok(match lock {
Some(l) => format!("{requirements}{COMPOSER_LOCK_SPLIT}{l}"),
None => unreachable!("cache hit requires a lock"),
});
}
write_file(job_dir, "composer.json", &requirements)?;
if let Some(lock) = lock.as_ref() {
write_file(job_dir, "composer.lock", lock)?;
}
let mut child_cmd = Command::new(&*COMPOSER_PATH);
let args = vec!["install", "--no-dev", "--no-progress"];
child_cmd
.current_dir(job_dir)
.env("COMPOSER_HOME", &*COMPOSER_CACHE_DIR)
.args(args)
.stdout(Stdio::piped())
.stderr(Stdio::piped());
let child_process = start_child_process(child_cmd, &*COMPOSER_PATH, false).await?;
handle_child(
job_id,
conn,
mem_peak,
canceled_by,
child_process,
false,
worker_name,
w_id,
"composer install",
None,
false,
&mut Some(occupancy_metrics),
None,
None,
)
.await?;
// lock was `None` means composer resolved deps from scratch (no lock from
// caller or DB). This is the only case where we should update the DB cache.
let freshly_resolved = lock.is_none();
let resolved_lock = match lock {
Some(l) => l,
None => {
let mut lock_content = String::new();
let mut lock_file = File::open(format!("{job_dir}/composer.lock")).await?;
lock_file.read_to_string(&mut lock_content).await?;
lock_content
}
};
// Save the freshly installed vendor/ to the cache for future executions.
if !*COMPOSER_VENDOR_CACHE_DISABLED {
let hash = calculate_hash(&format!("{requirements}{resolved_lock}"));
let vendor_cache_path = format!("{}/vendor/{hash}", *COMPOSER_CACHE_DIR);
if let Err(e) = crate::global_cache::save_cache(
&vendor_cache_path,
&format!("composer/vendor/{hash}"),
&format!("{job_dir}/vendor"),
true,
)
.await
{
tracing::warn!("Could not save composer vendor dir to cache: {e:?}");
}
// Cache the resolved lockfile in the DB so future previews (which lack a
// lock file) can look it up by requirements hash and hit the same vendor
// cache. TTL of 7 days keeps previews reasonably fresh.
// Only write when composer resolved from scratch (no lock from caller or
// DB) to avoid endlessly refreshing the TTL on stale resolutions.
if freshly_resolved {
let req_hash = format!("composer-{}", calculate_hash(&requirements));
if let Some(db) = conn.as_sql() {
if let Err(e) = sqlx::query!(
"INSERT INTO pip_resolution_cache (hash, lockfile, expiration) VALUES ($1, $2, now() + ('7 days')::interval) ON CONFLICT (hash) DO UPDATE SET lockfile = EXCLUDED.lockfile, expiration = EXCLUDED.expiration",
req_hash,
&resolved_lock
)
.execute(db)
.await
{
tracing::warn!("Could not cache composer lockfile resolution: {e:?}");
}
}
}
}
Ok(format!(
"{requirements}{COMPOSER_LOCK_SPLIT}{resolved_lock}"
))
}
fn generate_resource_class(rt_name: &str, arg_name: &str) -> String {
let rt_name = rt_name.to_case(Case::Pascal);
format!(
"#[AllowDynamicProperties]
class {rt_name} {{
public function __construct($data) {{
foreach ($data AS $key => $value) $this->{{$key}} = $value;
}}
}}
$args->{arg_name} = new {rt_name}($args->{arg_name});"
)
}
fn split_reqs_and_lock(content: &String) -> error::Result<(Option<String>, Option<String>)> {
let splitted = content.split(COMPOSER_LOCK_SPLIT).collect_vec();
if splitted.len() != 2 {
return Err(error::Error::ExecutionErr(format!(
"Invalid requirements, expected to find LOCK split pattern in reqs. Found: |{content}|"
)));
}
Ok((
Some(clean_lock_from_annotations(splitted[0], ScriptLang::Php)),
Some(splitted[1].to_string()),
))
}
#[tracing::instrument(level = "trace", skip_all)]
pub async fn handle_php_job(
maybe_lock: MaybeLock,
mem_peak: &mut i32,
canceled_by: &mut Option<CanceledBy>,
job: &MiniPulledJob,
conn: &Connection,
client: &AuthedClient,
parent_runnable_path: Option<String>,
job_dir: &str,
inner_content: &String,
base_internal_url: &str,
worker_name: &str,
envs: HashMap<String, String>,
shared_mount: &str,
occupancy_metrics: &mut OccupancyMetrics,
) -> error::Result<Box<RawValue>> {
check_executor_binary_exists("php", PHP_PATH.as_str(), "php")?;
let (composer_json, composer_lock) = match &maybe_lock {
MaybeLock::Resolved { lock } if !lock.is_empty() => split_reqs_and_lock(lock)?,
MaybeLock::Unresolved { workspace_dependencies } => (
workspace_dependencies
.get_php()?
.or(parse_php_imports(inner_content)?),
None,
),
_ => (parse_php_imports(inner_content)?, None),
};
let autoload_line = if let Some(composer_json) = composer_json {
let logs1 = "\n\n--- COMPOSER INSTALL ---\n".to_string();
append_logs(&job.id, &job.workspace_id, logs1, conn).await;
composer_install(
mem_peak,
canceled_by,
&job.id,
&job.workspace_id,
conn,
job_dir,
worker_name,
composer_json,
composer_lock,
occupancy_metrics,
)
.await?;
"require './vendor/autoload.php';"
} else {
""
};
let init_logs = "\n\n--- PHP CODE EXECUTION ---\n".to_string();
append_logs(&job.id, job.workspace_id.to_string(), init_logs, conn).await;
let _ = write_file(job_dir, "main.php", inner_content)?;
let main_override = job.script_entrypoint_override.as_deref();
let write_wrapper_f = async {
let args = windmill_parser_php::parse_php_signature(
inner_content,
main_override.map(ToString::to_string),
)?
.args;
let args_to_include = args
.iter()
.filter(|x| {
!x.has_default || job.args.as_ref().is_some_and(|a| a.contains_key(&x.name))
})
.collect::<Vec<_>>();
let func_args_str = args_to_include
.iter()
.map(|x| format!("$args->{}", x.name))
.collect::<Vec<String>>()
.join(",");
let resource_classes = args_to_include
.iter()
.filter_map(|x| match &x.typ {
Typ::Resource(name) => Some((name, &x.name)),
_ => None,
})
.unique()
.map(|(rt_name, arg_name)| generate_resource_class(rt_name, arg_name))
.collect::<Vec<_>>()
.join("\n\n");
let main_name = main_override.unwrap_or("main");
let wrapper_content: String = format!(
r#"
<?php
{autoload_line}
require './main.php';
$args_str = file_get_contents('./args.json');
$args = json_decode($args_str);
{resource_classes}
try {{
$res = {main_name}({func_args_str});
file_put_contents("result.json", json_encode($res));
}} catch (Exception $e) {{
$err = [
"message" => $e->getMessage(),
"name" => get_class($e),
"stack" => $e->getTraceAsString()
];
$step_id = getenv('WM_FLOW_STEP_ID');
if ($step_id) {{
$err["step_id"] = $step_id;
}}
file_put_contents("result.json", json_encode($err));
exit(1);
}}
"#,
);
write_file(job_dir, "wrapper.php", &wrapper_content)?;
Ok(()) as error::Result<()>
};
let reserved_variables_args_out_f = async {
let args_and_out_f = async {
create_args_and_out_file(&client, job, job_dir, conn).await?;
Ok(()) as Result<()>
};
let reserved_variables_f = async {
let vars =
get_reserved_variables(job, &client.token, conn, parent_runnable_path.clone())
.await?;
Ok(vars) as Result<HashMap<String, String>>
};
let (_, reserved_variables) = tokio::try_join!(args_and_out_f, reserved_variables_f)?;
Ok(reserved_variables) as error::Result<HashMap<String, String>>
};
let (reserved_variables, _) = tokio::try_join!(reserved_variables_args_out_f, write_wrapper_f)?;
let child = if is_sandboxing_enabled() {
let nsjail_timeout =
resolve_nsjail_timeout(conn, &job.workspace_id, job.id, job.timeout).await;
let _ = write_file(
job_dir,
"run.config.proto",
&NSJAIL_CONFIG_RUN_PHP_CONTENT
.replace("{JOB_DIR}", job_dir)
.replace("{CLONE_NEWUSER}", &(!*DISABLE_NUSER).to_string())
.replace("{SHARED_MOUNT}", shared_mount)
.replace(
"{TMP_MOUNT_BLOCK}",
&resolve_nsjail_tmp_mount_block(job_dir).await,
)
.replace("{TIMEOUT}", &nsjail_timeout),
)?;
let mut nsjail_cmd = Command::new(NSJAIL_PATH.as_str());
let args = vec![
"--config",
"run.config.proto",
"--",
&PHP_PATH,
"/tmp/wrapper.php",
];
nsjail_cmd
.current_dir(job_dir)
.env_clear()
.envs(envs)
.envs(reserved_variables)
.envs(crate::get_otel_context_envs(&job.id))
.env("COMPOSER_HOME", &*COMPOSER_CACHE_DIR)
.env("BASE_INTERNAL_URL", base_internal_url)
.args(args)
.stdout(Stdio::piped())
.stderr(Stdio::piped());
start_child_process(nsjail_cmd, NSJAIL_PATH.as_str(), false).await?
} else {
let script_path = format!("{job_dir}/wrapper.php");
let args = vec![script_path.as_str()];
let mut php_cmd = build_command_with_isolation(&*PHP_PATH, &args);
php_cmd
.current_dir(job_dir)
.env_clear()
.envs(envs)
.envs(reserved_variables)
.envs(crate::get_otel_context_envs(&job.id))
.env("COMPOSER_HOME", &*COMPOSER_CACHE_DIR)
.env("BASE_INTERNAL_URL", base_internal_url)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
start_child_process(php_cmd, &*PHP_PATH, false).await?
};
handle_child(
&job.id,
conn,
mem_peak,
canceled_by,
child,
is_sandboxing_enabled(),
worker_name,
&job.workspace_id,
"php run",
job.timeout,
false,
&mut Some(occupancy_metrics),
None,
None,
)
.await?;
read_result(job_dir, None).await
}