mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-18 08:01:26 +00:00
fix(debugger): pass python index settings to prepare-deps and report failures (#10533)
* fix: honor python index settings in prepare-deps and report install failures * fix: forward python registry env to the debugger's prepare-deps * fix: scope registry credentials to the prepare-deps subprocess * fix: install python debug dependencies from the service, not the session * fix: bound the debugger dependency install and keep the proxy bypass default * docs: name the nsjail config that isolates debug sessions
This commit is contained in:
@@ -12,7 +12,11 @@ use regex::Regex;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tokio::process::Command;
|
||||
|
||||
use crate::{BUN_CACHE_DIR, BUN_PATH, HOME_ENV, PATH_ENV, PROXY_ENVS, UV_CACHE_DIR};
|
||||
use crate::worker::non_empty_env;
|
||||
use crate::{
|
||||
BUN_CACHE_DIR, BUN_PATH, HOME_ENV, INDEX_CERT, NATIVE_CERT, PATH_ENV, PROXY_ENVS, TRUSTED_HOST,
|
||||
UV_CACHE_DIR, UV_HTTP_TIMEOUT,
|
||||
};
|
||||
use windmill_common::worker::write_file;
|
||||
|
||||
const LOADER_BUILDER_CONTENT: &str = include_str!("../loader_builder.bun.js");
|
||||
@@ -87,6 +91,15 @@ lazy_static::lazy_static! {
|
||||
|
||||
/// UV binary path
|
||||
static ref UV_PATH: String = std::env::var("UV_PATH").unwrap_or_else(|_| "/usr/local/bin/uv".to_string());
|
||||
|
||||
/// This process has no database, so the `pip_index_url` / `pip_extra_index_url` instance
|
||||
/// settings the job path resolves are unreachable here: their env-var equivalents are the
|
||||
/// only registry configuration the debugger can see.
|
||||
static ref PY_INDEX_URL: Option<String> = non_empty_env("PY_INDEX_URL").or_else(|| non_empty_env("PIP_INDEX_URL"));
|
||||
static ref PY_EXTRA_INDEX_URL: Option<String> = non_empty_env("PY_EXTRA_INDEX_URL").or_else(|| non_empty_env("PIP_EXTRA_INDEX_URL"));
|
||||
/// uv defaults to `first-index`; the job path overrides it so a package missing from the
|
||||
/// first index is still resolved from the others. Same default here.
|
||||
static ref PY_INDEX_STRATEGY: String = non_empty_env("UV_INDEX_STRATEGY").unwrap_or_else(|| "unsafe-best-match".to_string());
|
||||
}
|
||||
|
||||
/// Simple loader that doesn't require Windmill API for relative imports
|
||||
@@ -114,6 +127,11 @@ pub struct PrepareResponse {
|
||||
pub job_dir: String,
|
||||
pub success: bool,
|
||||
pub error: Option<String>,
|
||||
/// Raw stderr of the dependency installer when it exited non-zero, so a caller can show the
|
||||
/// registry/TLS failure verbatim instead of the bare ModuleNotFoundError that follows.
|
||||
/// Omitted from the JSON when absent, so callers that only know `success`/`error` are unaffected.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub install_stderr: Option<String>,
|
||||
}
|
||||
|
||||
/// Parse Python imports and return a list of package names that need to be installed.
|
||||
@@ -160,6 +178,28 @@ fn get_proc_envs(cache_env: Option<(&str, &str)>) -> HashMap<String, String> {
|
||||
envs
|
||||
}
|
||||
|
||||
/// uv registry arguments, mirroring what the job path passes in `python_executor`.
|
||||
fn uv_registry_args() -> Vec<String> {
|
||||
let mut args: Vec<String> = vec![];
|
||||
if let Some(urls) = PY_EXTRA_INDEX_URL.as_ref() {
|
||||
for url in urls.split(',') {
|
||||
args.extend(["--extra-index-url".to_string(), url.to_string()]);
|
||||
}
|
||||
}
|
||||
if let Some(url) = PY_INDEX_URL.as_ref() {
|
||||
args.extend(["--index-url".to_string(), url.to_string()]);
|
||||
}
|
||||
if let Some(hosts) = TRUSTED_HOST.as_ref() {
|
||||
for host in hosts.split_whitespace() {
|
||||
args.extend(["--trusted-host".to_string(), host.to_string()]);
|
||||
}
|
||||
}
|
||||
if *NATIVE_CERT {
|
||||
args.push("--native-tls".to_string());
|
||||
}
|
||||
args
|
||||
}
|
||||
|
||||
/// Prepare Python dependencies using uv
|
||||
async fn prepare_python_deps_standalone(code: &str) -> PrepareResponse {
|
||||
// Parse imports from the code
|
||||
@@ -172,6 +212,7 @@ async fn prepare_python_deps_standalone(code: &str) -> PrepareResponse {
|
||||
job_dir: String::new(),
|
||||
success: true,
|
||||
error: None,
|
||||
install_stderr: None,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -189,17 +230,37 @@ async fn prepare_python_deps_standalone(code: &str) -> PrepareResponse {
|
||||
job_dir: job_dir.clone(),
|
||||
success: false,
|
||||
error: Some(format!("Failed to create job directory: {}", e)),
|
||||
install_stderr: None,
|
||||
};
|
||||
}
|
||||
|
||||
let common_uv_envs = get_proc_envs(Some(("UV_CACHE_DIR", &UV_CACHE_DIR)));
|
||||
let mut common_uv_envs = get_proc_envs(Some(("UV_CACHE_DIR", &UV_CACHE_DIR)));
|
||||
common_uv_envs.insert(
|
||||
"UV_INDEX_STRATEGY".to_string(),
|
||||
PY_INDEX_STRATEGY.to_string(),
|
||||
);
|
||||
if let Some(timeout) = UV_HTTP_TIMEOUT.as_ref() {
|
||||
common_uv_envs.insert("UV_HTTP_TIMEOUT".to_string(), timeout.to_string());
|
||||
}
|
||||
if let Some(cert_path) = INDEX_CERT.as_ref() {
|
||||
// uv has no `--cert` on `venv`/`pip install` (astral-sh/uv#6715), so a custom CA bundle
|
||||
// reaches it through SSL_CERT_FILE, as in the job path.
|
||||
common_uv_envs.insert("SSL_CERT_FILE".to_string(), cert_path.to_string());
|
||||
}
|
||||
|
||||
let registry_args = uv_registry_args();
|
||||
|
||||
// Step 1: Create virtual environment using uv
|
||||
// `--seed` resolves pip/setuptools from the index, so the venv also needs the registry
|
||||
// arguments: on a network that only reaches a private mirror it fails without them.
|
||||
let mut venv_args = vec!["venv".to_string(), venv_dir.clone(), "--seed".to_string()];
|
||||
venv_args.extend(registry_args.iter().cloned());
|
||||
|
||||
let output = Command::new(UV_PATH.as_str())
|
||||
.current_dir(&job_dir)
|
||||
.env_clear()
|
||||
.envs(common_uv_envs.clone())
|
||||
.args(["venv", &venv_dir, "--seed"])
|
||||
.args(&venv_args)
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.output()
|
||||
@@ -212,26 +273,33 @@ async fn prepare_python_deps_standalone(code: &str) -> PrepareResponse {
|
||||
job_dir: job_dir.clone(),
|
||||
success: false,
|
||||
error: Some(format!("Failed to create venv: {}", e)),
|
||||
install_stderr: None,
|
||||
};
|
||||
}
|
||||
|
||||
let out = output.unwrap();
|
||||
if !out.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
let stderr = String::from_utf8_lossy(&out.stderr).to_string();
|
||||
return PrepareResponse {
|
||||
node_modules_path: None,
|
||||
venv_path: None,
|
||||
job_dir: job_dir.clone(),
|
||||
success: false,
|
||||
error: Some(format!("uv venv failed: {}", stderr)),
|
||||
install_stderr: Some(stderr),
|
||||
};
|
||||
}
|
||||
|
||||
// Step 2: Install packages using uv pip install
|
||||
let python_path = format!("{}/bin/python", venv_dir);
|
||||
let mut args = vec!["pip", "install", "--python", &python_path];
|
||||
let package_refs: Vec<&str> = packages.iter().map(|s| s.as_str()).collect();
|
||||
args.extend(package_refs.iter());
|
||||
let mut args = vec![
|
||||
"pip".to_string(),
|
||||
"install".to_string(),
|
||||
"--python".to_string(),
|
||||
python_path,
|
||||
];
|
||||
args.extend(packages.iter().cloned());
|
||||
args.extend(registry_args);
|
||||
|
||||
let output = Command::new(UV_PATH.as_str())
|
||||
.current_dir(&job_dir)
|
||||
@@ -246,11 +314,19 @@ async fn prepare_python_deps_standalone(code: &str) -> PrepareResponse {
|
||||
match output {
|
||||
Ok(out) => {
|
||||
if !out.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
// Installation might fail for some packages (e.g., wrong package name)
|
||||
// Log the error but continue - the script might still work if the
|
||||
// package is actually installed elsewhere or the import is optional
|
||||
tracing::warn!("uv pip install warning: {}", stderr);
|
||||
// uv installs the whole set atomically, so a failure here means an empty venv:
|
||||
// returning success would leave the caller with a bare ModuleNotFoundError and
|
||||
// no way to see the registry/TLS/package-name error that caused it.
|
||||
let stderr = String::from_utf8_lossy(&out.stderr).to_string();
|
||||
tracing::warn!("uv pip install failed: {}", stderr);
|
||||
return PrepareResponse {
|
||||
node_modules_path: None,
|
||||
venv_path: None,
|
||||
job_dir: job_dir.clone(),
|
||||
success: false,
|
||||
error: Some(format!("uv pip install failed: {}", stderr)),
|
||||
install_stderr: Some(stderr),
|
||||
};
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
@@ -260,6 +336,7 @@ async fn prepare_python_deps_standalone(code: &str) -> PrepareResponse {
|
||||
job_dir: job_dir.clone(),
|
||||
success: false,
|
||||
error: Some(format!("Failed to run uv pip install: {}", e)),
|
||||
install_stderr: None,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -292,6 +369,7 @@ async fn prepare_python_deps_standalone(code: &str) -> PrepareResponse {
|
||||
job_dir,
|
||||
success: true,
|
||||
error: None,
|
||||
install_stderr: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -321,6 +399,7 @@ pub async fn prepare_deps_standalone(code: &str, language: &str) -> PrepareRespo
|
||||
"Unsupported language for dependency preparation: {}",
|
||||
language
|
||||
)),
|
||||
install_stderr: None,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -336,6 +415,7 @@ pub async fn prepare_deps_standalone(code: &str, language: &str) -> PrepareRespo
|
||||
job_dir: job_dir.clone(),
|
||||
success: false,
|
||||
error: Some(format!("Failed to create job directory: {}", e)),
|
||||
install_stderr: None,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -347,6 +427,7 @@ pub async fn prepare_deps_standalone(code: &str, language: &str) -> PrepareRespo
|
||||
job_dir: job_dir.clone(),
|
||||
success: false,
|
||||
error: Some(format!("Failed to write main.ts: {}", e)),
|
||||
install_stderr: None,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -366,6 +447,7 @@ pub async fn prepare_deps_standalone(code: &str, language: &str) -> PrepareRespo
|
||||
job_dir: job_dir.clone(),
|
||||
success: false,
|
||||
error: Some(format!("Failed to write build.js: {}", e)),
|
||||
install_stderr: None,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -398,6 +480,7 @@ pub async fn prepare_deps_standalone(code: &str, language: &str) -> PrepareRespo
|
||||
job_dir: job_dir.clone(),
|
||||
success: false,
|
||||
error: Some(format!("Failed to write empty package.json: {}", e)),
|
||||
install_stderr: None,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -411,6 +494,7 @@ pub async fn prepare_deps_standalone(code: &str, language: &str) -> PrepareRespo
|
||||
job_dir: job_dir.clone(),
|
||||
success: false,
|
||||
error: Some(format!("Failed to run build.js: {}", e)),
|
||||
install_stderr: None,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -427,6 +511,7 @@ pub async fn prepare_deps_standalone(code: &str, language: &str) -> PrepareRespo
|
||||
job_dir: job_dir.clone(),
|
||||
success: true,
|
||||
error: None,
|
||||
install_stderr: None,
|
||||
};
|
||||
}
|
||||
};
|
||||
@@ -441,6 +526,7 @@ pub async fn prepare_deps_standalone(code: &str, language: &str) -> PrepareRespo
|
||||
job_dir: job_dir.clone(),
|
||||
success: false,
|
||||
error: Some(format!("Failed to parse package.json: {}", e)),
|
||||
install_stderr: None,
|
||||
};
|
||||
}
|
||||
};
|
||||
@@ -454,6 +540,7 @@ pub async fn prepare_deps_standalone(code: &str, language: &str) -> PrepareRespo
|
||||
job_dir: job_dir.clone(),
|
||||
success: true,
|
||||
error: None,
|
||||
install_stderr: None,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -471,13 +558,14 @@ pub async fn prepare_deps_standalone(code: &str, language: &str) -> PrepareRespo
|
||||
match output {
|
||||
Ok(out) => {
|
||||
if !out.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
let stderr = String::from_utf8_lossy(&out.stderr).to_string();
|
||||
return PrepareResponse {
|
||||
node_modules_path: None,
|
||||
venv_path: None,
|
||||
job_dir: job_dir.clone(),
|
||||
success: false,
|
||||
error: Some(format!("bun install failed: {}", stderr)),
|
||||
install_stderr: Some(stderr),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -488,6 +576,7 @@ pub async fn prepare_deps_standalone(code: &str, language: &str) -> PrepareRespo
|
||||
job_dir: job_dir.clone(),
|
||||
success: false,
|
||||
error: Some(format!("Failed to run bun install: {}", e)),
|
||||
install_stderr: None,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -500,6 +589,7 @@ pub async fn prepare_deps_standalone(code: &str, language: &str) -> PrepareRespo
|
||||
job_dir,
|
||||
success: true,
|
||||
error: None,
|
||||
install_stderr: None,
|
||||
}
|
||||
} else {
|
||||
PrepareResponse {
|
||||
@@ -508,6 +598,7 @@ pub async fn prepare_deps_standalone(code: &str, language: &str) -> PrepareRespo
|
||||
job_dir,
|
||||
success: true,
|
||||
error: None,
|
||||
install_stderr: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -531,6 +622,7 @@ pub async fn run_prepare_deps_cli() -> anyhow::Result<()> {
|
||||
job_dir: String::new(),
|
||||
success: false,
|
||||
error: Some(format!("Failed to read stdin: {}", e)),
|
||||
install_stderr: None,
|
||||
};
|
||||
println!("{}", serde_json::to_string(&response)?);
|
||||
return Ok(());
|
||||
@@ -550,6 +642,7 @@ pub async fn run_prepare_deps_cli() -> anyhow::Result<()> {
|
||||
"Failed to parse JSON input: {}. Expected {{\"code\": \"...\", \"language\": \"bun\" or \"python3\"}}",
|
||||
e
|
||||
)),
|
||||
install_stderr: None,
|
||||
};
|
||||
println!("{}", serde_json::to_string(&response)?);
|
||||
return Ok(());
|
||||
@@ -561,3 +654,43 @@ pub async fn run_prepare_deps_cli() -> anyhow::Result<()> {
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::PrepareResponse;
|
||||
|
||||
/// The debugger (`debugger/dap_websocket_server.py`) parses this JSON out of the CLI's
|
||||
/// stdout, so `install_stderr` has to stay additive: a response without an install failure
|
||||
/// must serialize to the shape callers already know.
|
||||
#[test]
|
||||
fn test_install_stderr_is_additive() {
|
||||
let ok = PrepareResponse {
|
||||
node_modules_path: None,
|
||||
venv_path: Some("/tmp/windmill-deps/x/venv".to_string()),
|
||||
job_dir: "/tmp/windmill-deps/x".to_string(),
|
||||
success: true,
|
||||
error: None,
|
||||
install_stderr: None,
|
||||
};
|
||||
assert_eq!(
|
||||
serde_json::to_value(&ok).unwrap(),
|
||||
serde_json::json!({
|
||||
"node_modules_path": null,
|
||||
"venv_path": "/tmp/windmill-deps/x/venv",
|
||||
"job_dir": "/tmp/windmill-deps/x",
|
||||
"success": true,
|
||||
"error": null,
|
||||
})
|
||||
);
|
||||
|
||||
let failed = PrepareResponse {
|
||||
install_stderr: Some("error: no such package".to_string()),
|
||||
success: false,
|
||||
error: Some("uv pip install failed: error: no such package".to_string()),
|
||||
..ok
|
||||
};
|
||||
let failed = serde_json::to_value(&failed).unwrap();
|
||||
assert_eq!(failed["install_stderr"], "error: no such package");
|
||||
assert_eq!(failed["success"], false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,20 +62,8 @@ lazy_static::lazy_static! {
|
||||
static ref PY_CONCURRENT_DOWNLOADS: usize =
|
||||
var("PY_CONCURRENT_DOWNLOADS").ok().map(|flag| flag.parse().unwrap_or(20)).unwrap_or(20);
|
||||
|
||||
// uv's HTTP request timeout (seconds). spawn_uv_install uses env_clear(), so a
|
||||
// UV_HTTP_TIMEOUT set on the worker is dropped unless forwarded explicitly.
|
||||
// Only forwarded when set; otherwise uv keeps its own default. Lets operators
|
||||
// raise it for slow/contended private registries ("operation timed out").
|
||||
static ref UV_HTTP_TIMEOUT: Option<String> =
|
||||
var("UV_HTTP_TIMEOUT").ok().filter(|v| !v.is_empty());
|
||||
|
||||
|
||||
static ref NON_ALPHANUM_CHAR: Regex = regex::Regex::new(r"[^0-9A-Za-z=.-]").unwrap();
|
||||
|
||||
static ref TRUSTED_HOST: Option<String> = var("PY_TRUSTED_HOST").ok().or(var("PIP_TRUSTED_HOST").ok());
|
||||
pub static ref INDEX_CERT: Option<String> = var("PY_INDEX_CERT").ok().or(var("PIP_INDEX_CERT").ok());
|
||||
pub static ref NATIVE_CERT: bool = var("PY_NATIVE_CERT").ok().or(var("UV_NATIVE_TLS").ok()).map(|flag| flag == "true").unwrap_or(false);
|
||||
|
||||
static ref RELATIVE_IMPORT_REGEX: Regex = Regex::new(r#"(import|from)\s(((u|f)\.)|\.)"#).unwrap();
|
||||
|
||||
static ref EPHEMERAL_TOKEN_CMD: Option<String> = var("EPHEMERAL_TOKEN_CMD").ok();
|
||||
@@ -163,9 +151,10 @@ use crate::{
|
||||
handle_child::handle_child,
|
||||
is_sandboxing_enabled, read_ee_registry_with_workspace_override,
|
||||
worker_utils::ping_job_status,
|
||||
PyV, DISABLE_NUSER, HOME_ENV, NSJAIL_AVAILABLE, NSJAIL_PATH, NSJAIL_PY_RLIMIT_AS_MB, PATH_ENV,
|
||||
PIP_EXTRA_INDEX_URL, PIP_INDEX_URL, PROXY_ENVS, PY_INSTALL_DIR, TRACING_PROXY_CA_CERT_PATH,
|
||||
TZ_ENV, UV_CACHE_DIR, UV_EXCLUDE_NEWER, UV_INDEX_STRATEGY, UV_PYTHON_INSTALL_MIRROR,
|
||||
PyV, DISABLE_NUSER, HOME_ENV, INDEX_CERT, NATIVE_CERT, NSJAIL_AVAILABLE, NSJAIL_PATH,
|
||||
NSJAIL_PY_RLIMIT_AS_MB, PATH_ENV, PIP_EXTRA_INDEX_URL, PIP_INDEX_URL, PROXY_ENVS,
|
||||
PY_INSTALL_DIR, TRACING_PROXY_CA_CERT_PATH, TRUSTED_HOST, TZ_ENV, UV_CACHE_DIR,
|
||||
UV_EXCLUDE_NEWER, UV_HTTP_TIMEOUT, UV_INDEX_STRATEGY, UV_PYTHON_INSTALL_MIRROR,
|
||||
};
|
||||
use windmill_common::client::AuthedClient;
|
||||
|
||||
|
||||
@@ -23,9 +23,9 @@ use crate::python_executor::UV_PATH;
|
||||
use crate::{
|
||||
common::{start_child_process, OccupancyMetrics},
|
||||
handle_child::handle_child,
|
||||
python_executor::{INDEX_CERT, NATIVE_CERT, PYTHON_PATH},
|
||||
HOME_ENV, INSTANCE_PYTHON_VERSION, PATH_ENV, PROXY_ENVS, PY_INSTALL_DIR, UV_CACHE_DIR,
|
||||
UV_PYTHON_INSTALL_MIRROR, WIN_ENVS,
|
||||
python_executor::PYTHON_PATH,
|
||||
HOME_ENV, INDEX_CERT, INSTANCE_PYTHON_VERSION, NATIVE_CERT, PATH_ENV, PROXY_ENVS,
|
||||
PY_INSTALL_DIR, UV_CACHE_DIR, UV_PYTHON_INSTALL_MIRROR, WIN_ENVS,
|
||||
};
|
||||
|
||||
impl From<PyV> for PyVAlias {
|
||||
|
||||
@@ -704,6 +704,26 @@ lazy_static::lazy_static! {
|
||||
pub static ref FLOW_RUNNER_RUNNING: Mutex<bool> = Mutex::new(false);
|
||||
}
|
||||
|
||||
lazy_static::lazy_static! {
|
||||
/// Registry TLS/timeout settings for uv. Env-only (they have no instance setting), and read
|
||||
/// both by the job path and by the DB-less `prepare-deps` CLI, which has no other source of
|
||||
/// registry configuration.
|
||||
pub static ref TRUSTED_HOST: Option<String> = non_empty_env("PY_TRUSTED_HOST").or_else(|| non_empty_env("PIP_TRUSTED_HOST"));
|
||||
pub static ref INDEX_CERT: Option<String> = non_empty_env("PY_INDEX_CERT").or_else(|| non_empty_env("PIP_INDEX_CERT"));
|
||||
pub static ref NATIVE_CERT: bool = non_empty_env("PY_NATIVE_CERT").or_else(|| non_empty_env("UV_NATIVE_TLS")).map(|flag| flag == "true").unwrap_or(false);
|
||||
/// uv's HTTP request timeout (seconds). The uv invocations use env_clear(), so a
|
||||
/// UV_HTTP_TIMEOUT set on the worker is dropped unless forwarded explicitly.
|
||||
/// Only forwarded when set; otherwise uv keeps its own default. Lets operators
|
||||
/// raise it for slow/contended private registries ("operation timed out").
|
||||
pub static ref UV_HTTP_TIMEOUT: Option<String> = non_empty_env("UV_HTTP_TIMEOUT");
|
||||
}
|
||||
|
||||
/// A variable declared but left empty (a common shape in compose/k8s manifests) must not
|
||||
/// shadow the fallback name it is checked against.
|
||||
pub(crate) fn non_empty_env(key: &str) -> Option<String> {
|
||||
std::env::var(key).ok().filter(|v| !v.is_empty())
|
||||
}
|
||||
|
||||
lazy_static::lazy_static! {
|
||||
/// Optional override for the size of the `/tmp` tmpfs mount in nsjail sandboxes (in megabytes).
|
||||
/// When `None` (or non-positive), executors fall back to the unified
|
||||
|
||||
@@ -75,6 +75,48 @@ Options:
|
||||
| `DAP_NSJAIL_PATH` | nsjail binary path | nsjail |
|
||||
| `DAP_NSJAIL_CONFIG` | nsjail config file path | - |
|
||||
|
||||
### Python dependency preparation
|
||||
|
||||
Before debugging a Python script, its imports are installed through `windmill prepare-deps`, which
|
||||
runs `uv` without a database connection. It cannot read the instance settings, so it takes its
|
||||
registry configuration from the environment of the debug service instead, and the Python server is
|
||||
handed the resulting venv with `--venv-path`. The install runs in the service rather than in the
|
||||
session because a private index URL usually embeds credentials and the Python server executes the
|
||||
debugged script inside its own interpreter, where anything it holds is readable by that script.
|
||||
|
||||
Set these on the debug service. Where two names are listed the first wins; a worker reads the
|
||||
`PIP_*` / `PY_*` names in the same way, except for the index URLs, whose worker env fallbacks are
|
||||
only `PIP_INDEX_URL` / `PIP_EXTRA_INDEX_URL` (the `PY_*` spellings are accepted here for symmetry
|
||||
with the other settings):
|
||||
|
||||
| Variable | Description | Default |
|
||||
|----------|-------------|---------|
|
||||
| `PY_INDEX_URL` / `PIP_INDEX_URL` | Package index (`--index-url`) | PyPI |
|
||||
| `PY_EXTRA_INDEX_URL` / `PIP_EXTRA_INDEX_URL` | Extra indexes, comma-separated (`--extra-index-url`) | - |
|
||||
| `PY_TRUSTED_HOST` / `PIP_TRUSTED_HOST` | Hosts to trust, whitespace-separated (`--trusted-host`) | - |
|
||||
| `PY_INDEX_CERT` / `PIP_INDEX_CERT` | CA bundle for the index, passed to uv as `SSL_CERT_FILE` | - |
|
||||
| `PY_NATIVE_CERT` / `UV_NATIVE_TLS` | `true` to also trust the platform certificate store (`--native-tls`) | false |
|
||||
| `UV_INDEX_STRATEGY` | uv index strategy | unsafe-best-match |
|
||||
| `UV_HTTP_TIMEOUT` | uv HTTP request timeout, in seconds | uv's own default |
|
||||
| `DAP_PREPARE_DEPS_TIMEOUT_MS` | How long to wait for the install before starting the session without it | 120000 |
|
||||
|
||||
When the install fails, the CLI answers `success: false` and carries the installer's stderr in both
|
||||
`error` and `install_stderr`; the service reports it to the client as an `output` event, so the
|
||||
reason (unreachable mirror, untrusted certificate, unknown package) reaches the user instead of a
|
||||
bare `ModuleNotFoundError` at the first import.
|
||||
|
||||
Proxy variables (`HTTP_PROXY` / `HTTPS_PROXY` / `NO_PROXY`, in either case) are forwarded from the
|
||||
service into each session, since the debugged script needs them for its own outbound calls, exactly
|
||||
as a job's script does on a worker. When a proxy is set without a bypass list, `NO_PROXY` defaults
|
||||
to `localhost,127.0.0.1` so calls to `BASE_INTERNAL_URL` are not proxied.
|
||||
|
||||
Keeping the settings out of the session's environment only bounds what the debugged script can read
|
||||
from itself. An unsandboxed session runs under the same user as the service and can still read the
|
||||
service's environment through `/proc`, the same way a job can read a worker's when the worker runs
|
||||
unsandboxed. Isolating sessions from the service takes `--nsjail --nsjail-config
|
||||
nsjail.debug.config.proto`: it is that config's PID namespace and `mount_proc` that put the service
|
||||
out of reach, not the flag on its own.
|
||||
|
||||
### Frontend Integration
|
||||
|
||||
```svelte
|
||||
|
||||
@@ -352,6 +352,45 @@ interface SpawnOptions {
|
||||
stderr?: 'pipe' | 'inherit'
|
||||
}
|
||||
|
||||
/**
|
||||
* Proxy settings forwarded to a debug session, matching what a worker gives a job's script.
|
||||
* spawnProcess intentionally does not inherit this process's environment, so an outbound proxy
|
||||
* is unreachable from a session unless these are passed explicitly. Registry settings are
|
||||
* deliberately absent: they carry credentials and are consumed by the service itself (see
|
||||
* PythonDebugSession.prepareDependencies).
|
||||
*/
|
||||
const SESSION_PROXY_ENV_VARS = [
|
||||
'HTTP_PROXY',
|
||||
'HTTPS_PROXY',
|
||||
'NO_PROXY',
|
||||
// The lowercase spellings take precedence in the worker, so forward both.
|
||||
'http_proxy',
|
||||
'https_proxy',
|
||||
'no_proxy'
|
||||
]
|
||||
|
||||
/**
|
||||
* How long `windmill prepare-deps` may take before the session gives up on it and starts without
|
||||
* the dependencies. Raise it for slow private mirrors, where a large install can outlast the default.
|
||||
*/
|
||||
const PREPARE_DEPS_TIMEOUT_MS = Number(process.env.DAP_PREPARE_DEPS_TIMEOUT_MS) || 120_000
|
||||
|
||||
function sessionProxyEnv(): Record<string, string> {
|
||||
const env: Record<string, string> = {}
|
||||
for (const key of SESSION_PROXY_ENV_VARS) {
|
||||
const value = process.env[key]
|
||||
if (value) {
|
||||
env[key] = value
|
||||
}
|
||||
}
|
||||
// A proxy without a bypass list would send the script's calls to BASE_INTERNAL_URL through it;
|
||||
// the worker defaults the same way (PROXY_ENVS in windmill-worker).
|
||||
if (!env.NO_PROXY && !env.no_proxy && (env.HTTP_PROXY || env.http_proxy || env.HTTPS_PROXY || env.https_proxy)) {
|
||||
env.NO_PROXY = 'localhost,127.0.0.1'
|
||||
}
|
||||
return env
|
||||
}
|
||||
|
||||
/**
|
||||
* Spawn a process, optionally wrapped with nsjail.
|
||||
* This is the key function for sandboxed execution.
|
||||
@@ -502,6 +541,7 @@ class PythonDebugSession extends BaseDebugSession {
|
||||
private scriptResult: unknown = undefined
|
||||
private envVars: Record<string, string> = {}
|
||||
private windmillPath?: string
|
||||
private venvPath?: string
|
||||
private debugMode: boolean
|
||||
|
||||
constructor(ws: { send: (data: string) => void; close: () => void }, windmillPath?: string, debugMode = false) {
|
||||
@@ -627,6 +667,89 @@ class PythonDebugSession extends BaseDebugSession {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Install the script's imports through `windmill prepare-deps` and return the venv to add to
|
||||
* the debugged script's sys.path.
|
||||
*
|
||||
* This runs here rather than in the Python server because the registry settings the CLI reads
|
||||
* (`PY_INDEX_URL` and friends) routinely embed private-registry credentials, and the Python
|
||||
* server executes the submitted script inside its own interpreter: anything in that process is
|
||||
* recoverable by the script. The service never executes user code, so the credentials stop here.
|
||||
*
|
||||
* The trade-off is that the install itself is not jailed, so a source distribution's build
|
||||
* backend runs outside nsjail, as it already does for Bun sessions.
|
||||
*/
|
||||
private async prepareDependencies(code: string): Promise<string | null> {
|
||||
if (!this.windmillPath) {
|
||||
logger.info('No windmill binary path configured, skipping dependency preparation')
|
||||
return null
|
||||
}
|
||||
|
||||
const warn = (reason: string): null => {
|
||||
logger.error(`prepare-deps failed: ${reason}`)
|
||||
this.sendEvent('output', {
|
||||
category: 'stderr',
|
||||
output: `Failed to prepare dependencies: ${reason}\n`
|
||||
})
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
const proc = spawn({
|
||||
cmd: [this.windmillPath, 'prepare-deps'],
|
||||
stdin: new Blob([JSON.stringify({ code, language: 'python3' }) + '\n']),
|
||||
stdout: 'pipe',
|
||||
stderr: 'pipe'
|
||||
})
|
||||
|
||||
// The launch response is already sent, so an install that never returns would leave the
|
||||
// client waiting on a session that never starts, with nothing on screen. The deadline
|
||||
// races the read rather than only killing the child: a grandchild holding the pipe open
|
||||
// keeps the read pending long after the child itself is gone.
|
||||
let timer: ReturnType<typeof setTimeout> | undefined
|
||||
const read = (async () => ({
|
||||
output: await new Response(proc.stdout).text(),
|
||||
stderr: await new Response(proc.stderr).text()
|
||||
}))()
|
||||
const result = await Promise.race([
|
||||
read,
|
||||
new Promise<null>((resolve) => {
|
||||
timer = setTimeout(() => resolve(null), PREPARE_DEPS_TIMEOUT_MS)
|
||||
})
|
||||
])
|
||||
clearTimeout(timer)
|
||||
|
||||
if (!result) {
|
||||
proc.kill()
|
||||
return warn(
|
||||
`dependency installation timed out after ${PREPARE_DEPS_TIMEOUT_MS / 1000}s`
|
||||
)
|
||||
}
|
||||
const { output, stderr } = result
|
||||
|
||||
const lastLine = output.trim().split('\n').pop() || ''
|
||||
if (!lastLine.startsWith('{')) {
|
||||
return warn(stderr.trim() || 'windmill binary produced no response')
|
||||
}
|
||||
|
||||
const response = JSON.parse(lastLine)
|
||||
if (!response.success) {
|
||||
// install_stderr is the installer's raw output; `error` already contains it, so
|
||||
// prefer whichever the CLI version at hand provides.
|
||||
return warn(response.install_stderr || response.error || 'unknown error')
|
||||
}
|
||||
|
||||
if (response.venv_path) {
|
||||
logger.info(`Dependencies installed at: ${response.venv_path}`)
|
||||
} else {
|
||||
logger.info('No external dependencies to install')
|
||||
}
|
||||
return response.venv_path || null
|
||||
} catch (error) {
|
||||
return warn(String(error))
|
||||
}
|
||||
}
|
||||
|
||||
private async startPythonProcess(cwd: string): Promise<void> {
|
||||
if (!this.scriptPath) {
|
||||
throw new Error('No script path')
|
||||
@@ -648,10 +771,11 @@ class PythonDebugSession extends BaseDebugSession {
|
||||
'--host', '127.0.0.1'
|
||||
]
|
||||
|
||||
// Pass windmill path for dependency auto-installation if configured
|
||||
if (this.windmillPath) {
|
||||
cmd.push('--windmill', this.windmillPath)
|
||||
logger.info(`Python session: autoinstall enabled with windmill at ${this.windmillPath}`)
|
||||
// Dependencies are installed by the service (see prepareDependencies), so the server is
|
||||
// handed the resulting venv instead of the windmill binary it would install with.
|
||||
if (this.venvPath) {
|
||||
cmd.push('--venv-path', this.venvPath)
|
||||
logger.info(`Python session: using dependencies at ${this.venvPath}`)
|
||||
}
|
||||
|
||||
// Pass debug flag to Python subprocess
|
||||
@@ -662,7 +786,7 @@ class PythonDebugSession extends BaseDebugSession {
|
||||
this.process = spawnProcess({
|
||||
cmd,
|
||||
cwd,
|
||||
env: { PYTHONUNBUFFERED: '1', ...this.envVars }
|
||||
env: { PYTHONUNBUFFERED: '1', ...sessionProxyEnv(), ...this.envVars }
|
||||
})
|
||||
|
||||
// Read stderr to capture startup messages
|
||||
@@ -987,6 +1111,10 @@ sys.stdout.flush()
|
||||
this.sendResponse(request)
|
||||
|
||||
try {
|
||||
if (code) {
|
||||
this.venvPath = (await this.prepareDependencies(code)) ?? undefined
|
||||
}
|
||||
|
||||
await this.startPythonProcess(cwd)
|
||||
|
||||
// Re-apply breakpoints to the Python server using the actual script path
|
||||
|
||||
@@ -280,9 +280,10 @@ class WindmillDebugger(bdb.Bdb):
|
||||
class DebugSession:
|
||||
"""Manages a single debug session."""
|
||||
|
||||
def __init__(self, websocket, windmill_path: str | None = None):
|
||||
def __init__(self, websocket, windmill_path: str | None = None, prepared_venv_path: str | None = None):
|
||||
self.websocket = websocket
|
||||
self.windmill_path = windmill_path
|
||||
self._prepared_venv_path = prepared_venv_path
|
||||
self.seq = 1
|
||||
self.initialized = False
|
||||
self.configured = False
|
||||
@@ -309,6 +310,12 @@ class DebugSession:
|
||||
Prepare Python dependencies by calling the windmill CLI.
|
||||
Returns the path to the venv's site-packages directory, or None if no dependencies needed.
|
||||
"""
|
||||
if self._prepared_venv_path:
|
||||
# The debug service installs dependencies itself so that the registry credentials
|
||||
# the CLI needs never enter this interpreter, which executes the debugged script.
|
||||
logger.info(f"Using dependencies prepared by the debug service: {self._prepared_venv_path}")
|
||||
return self._prepared_venv_path
|
||||
|
||||
if not self.windmill_path:
|
||||
logger.info("No windmill binary path configured, skipping dependency preparation")
|
||||
return None
|
||||
@@ -894,13 +901,17 @@ class DebugSession:
|
||||
)
|
||||
|
||||
|
||||
# Module-level variable to store windmill binary path
|
||||
# Module-level variables to store the windmill binary path and, when the debug service
|
||||
# already installed the script's dependencies, the venv to use instead of installing here.
|
||||
_windmill_path: str | None = None
|
||||
_prepared_venv_path: str | None = None
|
||||
|
||||
|
||||
async def handle_connection(websocket) -> None:
|
||||
"""Handle a WebSocket connection."""
|
||||
session = DebugSession(websocket, windmill_path=_windmill_path)
|
||||
session = DebugSession(
|
||||
websocket, windmill_path=_windmill_path, prepared_venv_path=_prepared_venv_path
|
||||
)
|
||||
logger.info(f"New connection from {websocket.remote_address}")
|
||||
|
||||
try:
|
||||
@@ -924,13 +935,21 @@ async def handle_connection(websocket) -> None:
|
||||
session._cleanup_temp_file()
|
||||
|
||||
|
||||
async def main(host: str = "localhost", port: int = 5679, windmill_path: str | None = None) -> None:
|
||||
async def main(
|
||||
host: str = "localhost",
|
||||
port: int = 5679,
|
||||
windmill_path: str | None = None,
|
||||
prepared_venv_path: str | None = None,
|
||||
) -> None:
|
||||
"""Start the DAP WebSocket server."""
|
||||
global _windmill_path
|
||||
global _windmill_path, _prepared_venv_path
|
||||
_windmill_path = windmill_path
|
||||
_prepared_venv_path = prepared_venv_path
|
||||
|
||||
if windmill_path:
|
||||
logger.info(f"Windmill binary path: {windmill_path}")
|
||||
if prepared_venv_path:
|
||||
logger.info(f"Dependencies prepared by the debug service: {prepared_venv_path}")
|
||||
logger.info(f"Starting DAP WebSocket server on ws://{host}:{port}")
|
||||
|
||||
async with serve(handle_connection, host, port):
|
||||
@@ -944,6 +963,7 @@ if __name__ == "__main__":
|
||||
parser.add_argument("--host", default="localhost", help="Host to bind to")
|
||||
parser.add_argument("--port", type=int, default=5679, help="Port to listen on")
|
||||
parser.add_argument("--windmill", help="Path to windmill binary for dependency preparation (or set WINDMILL_PATH env var)")
|
||||
parser.add_argument("--venv-path", help="Site-packages directory of a venv the caller already prepared; skips dependency installation")
|
||||
parser.add_argument("--debug", action="store_true", help="Enable debug logging")
|
||||
args = parser.parse_args()
|
||||
|
||||
@@ -957,6 +977,6 @@ if __name__ == "__main__":
|
||||
windmill_path = args.windmill or os.environ.get("WINDMILL_PATH")
|
||||
|
||||
try:
|
||||
asyncio.run(main(args.host, args.port, windmill_path))
|
||||
asyncio.run(main(args.host, args.port, windmill_path, args.venv_path))
|
||||
except KeyboardInterrupt:
|
||||
logger.info("Server stopped")
|
||||
|
||||
Reference in New Issue
Block a user