mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-18 08:01:26 +00:00
feat(debugger): install debug session deps from the instance registry settings (#10550)
* feat(debugger): install debug session deps from the instance registry settings Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(debugger): keep install-time registry credentials out of the session-visible tree Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * docs: drop em dashes from the debugger registry docs and comments Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(debugger): stop installing for a session that went away during the settings fetch Also serves nativets sessions the npm settings their installer reads. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
+28
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT name, value FROM global_settings WHERE name = ANY($1)",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "name",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "value",
|
||||
"type_info": "Jsonb"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"TextArray"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "47f34b18306f043bdbd7dc74c3c83ac80b669a910eada527c2a75f5cbf7d830d"
|
||||
}
|
||||
@@ -8,6 +8,10 @@ edition.workspace = true
|
||||
name = "windmill_api_debug"
|
||||
path = "src/lib.rs"
|
||||
|
||||
[features]
|
||||
default = []
|
||||
enterprise = []
|
||||
|
||||
[dependencies]
|
||||
windmill-api-auth.workspace = true
|
||||
windmill-common = { workspace = true, default-features = false }
|
||||
|
||||
@@ -20,15 +20,21 @@
|
||||
//! - A job entry in v2_job (kind=preview) for traceability
|
||||
//! - A completed job entry in v2_job_completed
|
||||
//! - An audit log entry identical to script preview runs
|
||||
//!
|
||||
//! The same signature is what authorizes the debugger's requests back to the API:
|
||||
//! /api/debug/registry_config serves the instance's dependency-registry settings, which the
|
||||
//! debugger cannot read for itself, to sessions whose token carries the `registry_config`
|
||||
//! claim.
|
||||
|
||||
use axum::{
|
||||
extract::Path,
|
||||
http::HeaderMap,
|
||||
routing::{get, post},
|
||||
Extension, Json, Router,
|
||||
};
|
||||
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
|
||||
use chrono::Utc;
|
||||
use ed25519_dalek::{Signer, SigningKey};
|
||||
use ed25519_dalek::{Signature, Signer, SigningKey};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
use sqlx::types::Json as SqlxJson;
|
||||
@@ -37,8 +43,18 @@ use tokio::sync::RwLock;
|
||||
use uuid::Uuid;
|
||||
use windmill_audit::{audit_oss::audit_log, ActionKind};
|
||||
use windmill_common::{
|
||||
db::UserDB, error::JsonResult, jobs::JobKind, jwt::JWT_SECRET, scripts::ScriptLang,
|
||||
db::UserDB,
|
||||
error::{Error, JsonResult},
|
||||
global_settings::{
|
||||
BUNFIG_INSTALL_SCOPES_SETTING, EXTRA_PIP_INDEX_URL_SETTING, NPMRC_SETTING,
|
||||
NPM_CONFIG_REGISTRY_SETTING, PIP_INDEX_URL_SETTING, UV_INDEX_STRATEGY_SETTING,
|
||||
WORKSPACE_REGISTRIES_SETTING,
|
||||
},
|
||||
jobs::JobKind,
|
||||
jwt::JWT_SECRET,
|
||||
scripts::ScriptLang,
|
||||
users::username_to_permissioned_as,
|
||||
DB,
|
||||
};
|
||||
|
||||
use windmill_api_auth::ApiAuthed;
|
||||
@@ -112,7 +128,9 @@ pub async fn reload_debug_signing_key() {
|
||||
}
|
||||
|
||||
pub fn global_service() -> Router {
|
||||
Router::new().route("/jwks", get(get_jwks))
|
||||
Router::new()
|
||||
.route("/jwks", get(get_jwks))
|
||||
.route("/registry_config", get(get_registry_config))
|
||||
}
|
||||
|
||||
pub fn workspaced_service() -> Router {
|
||||
@@ -168,6 +186,220 @@ async fn get_jwks() -> JsonResult<DebugJwks> {
|
||||
}))
|
||||
}
|
||||
|
||||
/// The instance's dependency-registry configuration, as `windmill prepare-deps` consumes it.
|
||||
/// Field names are the `global_settings` keys, so the debug service forwards this object to
|
||||
/// the CLI as-is.
|
||||
#[derive(Serialize, Default)]
|
||||
pub struct DebugRegistryConfig {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub npm_config_registry: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub npmrc: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub bunfig_install_scopes: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub pip_index_url: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub pip_extra_index_url: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub uv_index_strategy: Option<String>,
|
||||
/// Why configured settings were withheld, for the debug service to show the user.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub message: Option<String>,
|
||||
}
|
||||
|
||||
/// Placeholder an EE instance puts in an index URL for a token minted per install by
|
||||
/// `EPHEMERAL_TOKEN_CMD` (`windmill-worker`'s `handle_ephemeral_token`).
|
||||
const EPHEMERAL_TOKEN_MARKER: &str = "EPHEMERAL_TOKEN";
|
||||
|
||||
/// Every `global_settings` key [`get_registry_config`] reads, in one query. A setting
|
||||
/// resolved there but missing here reads as unset, whatever the instance has stored.
|
||||
const REGISTRY_SETTINGS: [&str; 7] = [
|
||||
NPM_CONFIG_REGISTRY_SETTING,
|
||||
NPMRC_SETTING,
|
||||
BUNFIG_INSTALL_SCOPES_SETTING,
|
||||
PIP_INDEX_URL_SETTING,
|
||||
EXTRA_PIP_INDEX_URL_SETTING,
|
||||
UV_INDEX_STRATEGY_SETTING,
|
||||
WORKSPACE_REGISTRIES_SETTING,
|
||||
];
|
||||
|
||||
/// Which half of the settings a session installs with, from the language its token was signed
|
||||
/// for: a session is served only what its own installer runs on, so a token minted for one
|
||||
/// language cannot be replayed to read the other's credentials. Every language the debugger
|
||||
/// accepts (`isDebuggableLanguage` in the frontend) has to appear here, or its sessions
|
||||
/// silently install from the public registries.
|
||||
fn registry_settings_for_language(language: &str) -> (bool, bool) {
|
||||
match language {
|
||||
"bun" | "typescript" | "deno" | "nativets" => (true, false),
|
||||
"python3" | "python" => (false, true),
|
||||
_ => (false, false),
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve one setting the way a worker resolves it: a workspace override wins over the
|
||||
/// instance value, which is `FORCE_<env>` > `global_settings` > `<env>` (the server's
|
||||
/// `load_option_setting_value`). A blank value means unset from either source, so a
|
||||
/// workspace can blank one out.
|
||||
fn resolve_registry_setting(
|
||||
stored: &std::collections::HashMap<String, serde_json::Value>,
|
||||
workspace_overrides: Option<&serde_json::Value>,
|
||||
key: &str,
|
||||
env_var: &str,
|
||||
) -> Option<String> {
|
||||
let as_str = |v: Option<&serde_json::Value>| v.and_then(|v| v.as_str()).map(|s| s.to_string());
|
||||
let instance_value = std::env::var(format!("FORCE_{env_var}"))
|
||||
.ok()
|
||||
.or_else(|| as_str(stored.get(key)))
|
||||
.or_else(|| std::env::var(env_var).ok());
|
||||
as_str(workspace_overrides.and_then(|w| w.get(key)))
|
||||
.or(instance_value)
|
||||
.filter(|v| !v.trim().is_empty())
|
||||
}
|
||||
|
||||
/// Serve the dependency-registry settings to the debug service.
|
||||
///
|
||||
/// `windmill prepare-deps` installs a debug session's imports without a database
|
||||
/// connection, so the service fetches the settings here and passes them down over the
|
||||
/// CLI's stdin request. They stop there: a private index URL embeds credentials and the
|
||||
/// debugged script can read its own process, so nothing served here reaches the session's
|
||||
/// environment (see `debugger/README.md`).
|
||||
///
|
||||
/// Authorized by the launch token the service verified for that session, and only when the
|
||||
/// token carries the `registry_config` claim (see [`sign_debug_request`] for what it means).
|
||||
async fn get_registry_config(
|
||||
Extension(db): Extension<DB>,
|
||||
headers: HeaderMap,
|
||||
) -> JsonResult<DebugRegistryConfig> {
|
||||
let token = headers
|
||||
.get("authorization")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|v| v.strip_prefix("Bearer "))
|
||||
.ok_or_else(|| Error::NotAuthorized("Missing debug token".to_string()))?;
|
||||
|
||||
let claims = verify_debug_token(token).await?;
|
||||
if !claims.registry_config {
|
||||
return Err(Error::NotAuthorized(
|
||||
"This debug session is not allowed to read the registry configuration".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let names = REGISTRY_SETTINGS.map(String::from);
|
||||
let stored = sqlx::query!(
|
||||
"SELECT name, value FROM global_settings WHERE name = ANY($1)",
|
||||
&names[..]
|
||||
)
|
||||
.fetch_all(&db)
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(|r| (r.name, r.value))
|
||||
.collect::<std::collections::HashMap<_, _>>();
|
||||
|
||||
let workspace_overrides = stored
|
||||
.get(WORKSPACE_REGISTRIES_SETTING)
|
||||
.and_then(|v| v.get(&claims.workspace_id));
|
||||
let (npm, python) = registry_settings_for_language(&claims.language);
|
||||
let resolve = |serve: bool, key: &str, env_var: &str| {
|
||||
serve
|
||||
.then(|| resolve_registry_setting(&stored, workspace_overrides, key, env_var))
|
||||
.flatten()
|
||||
};
|
||||
|
||||
let mut config = DebugRegistryConfig {
|
||||
npm_config_registry: resolve(npm, NPM_CONFIG_REGISTRY_SETTING, "NPM_CONFIG_REGISTRY"),
|
||||
npmrc: resolve(npm, NPMRC_SETTING, "NPMRC"),
|
||||
bunfig_install_scopes: resolve(
|
||||
npm,
|
||||
BUNFIG_INSTALL_SCOPES_SETTING,
|
||||
"BUNFIG_INSTALL_SCOPES",
|
||||
),
|
||||
pip_index_url: resolve(python, PIP_INDEX_URL_SETTING, "PIP_INDEX_URL"),
|
||||
pip_extra_index_url: resolve(python, EXTRA_PIP_INDEX_URL_SETTING, "PIP_EXTRA_INDEX_URL"),
|
||||
// Not a private-registry setting: a worker reads it on any edition, so it is
|
||||
// served below the Enterprise gate too.
|
||||
uv_index_strategy: resolve(python, UV_INDEX_STRATEGY_SETTING, "UV_INDEX_STRATEGY"),
|
||||
message: None,
|
||||
};
|
||||
|
||||
if cfg!(feature = "enterprise") {
|
||||
// A worker substitutes this marker with the output of `EPHEMERAL_TOKEN_CMD`
|
||||
// (`handle_ephemeral_token`), a command the debug service has no way to run. Serving
|
||||
// the placeholder would install with a literal token, so the value is withheld and
|
||||
// the service falls back to the index URL in its own environment.
|
||||
let ephemeral = |url: &Option<String>| {
|
||||
url.as_ref()
|
||||
.is_some_and(|u| u.contains(EPHEMERAL_TOKEN_MARKER))
|
||||
};
|
||||
if ephemeral(&config.pip_index_url) || ephemeral(&config.pip_extra_index_url) {
|
||||
config.pip_index_url = None;
|
||||
config.pip_extra_index_url = None;
|
||||
config.message = Some(format!(
|
||||
"Python index configuration ignored: an {EPHEMERAL_TOKEN_MARKER} index URL can only be resolved on a worker"
|
||||
));
|
||||
}
|
||||
} else {
|
||||
// A private registry is an Enterprise feature, and `read_ee_registry` drops these
|
||||
// same settings on a CE worker, so a CE debug session installs from the public registries
|
||||
// and says why, instead of gaining a capability jobs on that instance don't have.
|
||||
let configured = config.npm_config_registry.is_some()
|
||||
|| config.npmrc.is_some()
|
||||
|| config.bunfig_install_scopes.is_some()
|
||||
|| config.pip_index_url.is_some()
|
||||
|| config.pip_extra_index_url.is_some();
|
||||
config.npm_config_registry = None;
|
||||
config.npmrc = None;
|
||||
config.bunfig_install_scopes = None;
|
||||
config.pip_index_url = None;
|
||||
config.pip_extra_index_url = None;
|
||||
if configured {
|
||||
config.message = Some(
|
||||
"Private registry configuration ignored: this feature requires Windmill Enterprise Edition"
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(Json(config))
|
||||
}
|
||||
|
||||
/// Verify a token minted by [`sign_debug_request`] and return its claims.
|
||||
///
|
||||
/// The debug service verifies the same token itself against the JWKS public key; this is
|
||||
/// the server-side half, for the requests the service makes back on a session's behalf.
|
||||
async fn verify_debug_token(token: &str) -> Result<DebugTokenClaims, Error> {
|
||||
let key_guard = DEBUG_SIGNING_KEY.read().await;
|
||||
let signing_key = key_guard
|
||||
.as_ref()
|
||||
.ok_or_else(|| Error::InternalErr("Debug signing key not initialized".to_string()))?;
|
||||
|
||||
let invalid = || Error::NotAuthorized("Invalid debug token".to_string());
|
||||
let mut parts = token.split('.');
|
||||
let (header_b64, claims_b64, signature_b64) =
|
||||
match (parts.next(), parts.next(), parts.next(), parts.next()) {
|
||||
(Some(header), Some(claims), Some(signature), None) => (header, claims, signature),
|
||||
_ => return Err(invalid()),
|
||||
};
|
||||
|
||||
let signature = Signature::from_slice(
|
||||
&URL_SAFE_NO_PAD
|
||||
.decode(signature_b64)
|
||||
.map_err(|_| invalid())?,
|
||||
)
|
||||
.map_err(|_| invalid())?;
|
||||
signing_key
|
||||
.verifying_key()
|
||||
.verify_strict(format!("{header_b64}.{claims_b64}").as_bytes(), &signature)
|
||||
.map_err(|_| invalid())?;
|
||||
|
||||
let claims: DebugTokenClaims =
|
||||
serde_json::from_slice(&URL_SAFE_NO_PAD.decode(claims_b64).map_err(|_| invalid())?)
|
||||
.map_err(|_| invalid())?;
|
||||
if Utc::now().timestamp() > claims.exp {
|
||||
return Err(Error::NotAuthorized("Debug token expired".to_string()));
|
||||
}
|
||||
Ok(claims)
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct SignDebugRequest {
|
||||
/// The code to be debugged
|
||||
@@ -193,6 +425,11 @@ pub struct DebugTokenClaims {
|
||||
pub exp: i64,
|
||||
/// Job ID for traceability
|
||||
pub job_id: String,
|
||||
/// Whether this session may be served the instance's dependency-registry settings
|
||||
/// (see [`get_registry_config`]). Defaults to `false` so a token that predates the
|
||||
/// claim is refused rather than silently trusted.
|
||||
#[serde(default)]
|
||||
pub registry_config: bool,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
@@ -248,6 +485,14 @@ async fn sign_debug_request(
|
||||
iat: now_ts,
|
||||
exp,
|
||||
job_id: job_id.to_string(),
|
||||
// The registry settings embed credentials, and the token reaches the browser, so they
|
||||
// are only served for a session whose author can already install with them: someone
|
||||
// who can run a preview job. For npm that discloses nothing new, since a worker leaves
|
||||
// the same `.npmrc` / `bunfig.toml` in the directory the previewed script runs in; the
|
||||
// Python index URL only ever appears as uv's argv, so serving it here does widen what
|
||||
// a member of the workspace can read. Operators cannot run previews at all, so their
|
||||
// sessions install from the public registries.
|
||||
registry_config: !authed.is_operator,
|
||||
};
|
||||
|
||||
// Create JWT manually with Ed25519 signature
|
||||
@@ -504,3 +749,107 @@ async fn sign_multiplayer(
|
||||
|
||||
Ok(Json(SignedMultiplayerPayload { token }))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Sign `claims` the way [`sign_debug_request`] does, with a key only this test knows.
|
||||
async fn signed(claims: &DebugTokenClaims) -> String {
|
||||
let key = derive_signing_key_from_jwt_secret("test-secret");
|
||||
*DEBUG_SIGNING_KEY.write().await = Some(key.clone());
|
||||
let header = URL_SAFE_NO_PAD.encode(r#"{"alg":"EdDSA","typ":"JWT"}"#);
|
||||
let payload = URL_SAFE_NO_PAD.encode(serde_json::to_string(claims).unwrap());
|
||||
let message = format!("{header}.{payload}");
|
||||
let signature = URL_SAFE_NO_PAD.encode(key.sign(message.as_bytes()).to_bytes());
|
||||
format!("{message}.{signature}")
|
||||
}
|
||||
|
||||
fn claims(exp_in: i64) -> DebugTokenClaims {
|
||||
DebugTokenClaims {
|
||||
code_hash: "0".repeat(32),
|
||||
language: "bun".to_string(),
|
||||
workspace_id: "test".to_string(),
|
||||
email: "user@windmill.dev".to_string(),
|
||||
iat: Utc::now().timestamp(),
|
||||
exp: Utc::now().timestamp() + exp_in,
|
||||
job_id: Uuid::nil().to_string(),
|
||||
registry_config: true,
|
||||
}
|
||||
}
|
||||
|
||||
/// The registry settings are credentials, and this signature is the only thing standing
|
||||
/// between them and any caller of `/api/debug/registry_config`. Each rejection here is a
|
||||
/// way in if it stops being checked: an edited claim, a session whose author may not read
|
||||
/// them, or a token replayed long after its session.
|
||||
#[tokio::test]
|
||||
async fn only_an_unexpired_token_with_the_claim_verifies() {
|
||||
let token = signed(&claims(60)).await;
|
||||
assert!(verify_debug_token(&token).await.is_ok());
|
||||
|
||||
let no_claim = signed(&DebugTokenClaims { registry_config: false, ..claims(60) }).await;
|
||||
assert!(!verify_debug_token(&no_claim).await.unwrap().registry_config);
|
||||
|
||||
let expired = signed(&claims(-1)).await;
|
||||
assert!(verify_debug_token(&expired).await.is_err());
|
||||
|
||||
// Re-signing is the only way to change a claim: swapping the payload of a valid token
|
||||
// for one that grants itself the claim must not verify.
|
||||
let (header, rest) = token.split_once('.').unwrap();
|
||||
let (_, signature) = rest.split_once('.').unwrap();
|
||||
let forged = URL_SAFE_NO_PAD.encode(
|
||||
serde_json::to_string(&DebugTokenClaims { exp: i64::MAX, ..claims(60) }).unwrap(),
|
||||
);
|
||||
assert!(
|
||||
verify_debug_token(&format!("{header}.{forged}.{signature}"))
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
/// Every language the debugger can start a session for installs dependencies, so each one
|
||||
/// has to name the settings its installer reads. A language missing here is not a refusal:
|
||||
/// its sessions quietly install from the public registries instead.
|
||||
#[test]
|
||||
fn every_debuggable_language_is_served_its_own_settings() {
|
||||
// Mirrors `isDebuggableLanguage` in frontend/src/lib/components/debug/debugUtils.ts.
|
||||
for language in ["bun", "typescript", "deno", "nativets"] {
|
||||
assert_eq!(registry_settings_for_language(language), (true, false));
|
||||
}
|
||||
assert_eq!(registry_settings_for_language("python3"), (false, true));
|
||||
assert_eq!(registry_settings_for_language("go"), (false, false));
|
||||
}
|
||||
|
||||
/// A debug session must resolve a registry setting to what a job in the same workspace
|
||||
/// resolves it to (`read_ee_registry_with_workspace_override`): the workspace override
|
||||
/// replaces the instance value, and a blank value from either source means unset, which
|
||||
/// is how a workspace opts out of an instance-wide registry.
|
||||
#[test]
|
||||
fn workspace_override_replaces_the_instance_value() {
|
||||
let stored = [(
|
||||
NPM_CONFIG_REGISTRY_SETTING.to_string(),
|
||||
serde_json::json!("https://instance.example/"),
|
||||
)]
|
||||
.into_iter()
|
||||
.collect::<std::collections::HashMap<_, _>>();
|
||||
// Never set, so the environment fallback stays out of the comparison.
|
||||
let env_var = "WM_TEST_DEBUG_REGISTRY_UNSET";
|
||||
let resolve = |overrides: Option<&serde_json::Value>| {
|
||||
resolve_registry_setting(&stored, overrides, NPM_CONFIG_REGISTRY_SETTING, env_var)
|
||||
};
|
||||
|
||||
assert_eq!(resolve(None).as_deref(), Some("https://instance.example/"));
|
||||
let workspace = serde_json::json!({ "npm_config_registry": "https://workspace.example/" });
|
||||
assert_eq!(
|
||||
resolve(Some(&workspace)).as_deref(),
|
||||
Some("https://workspace.example/")
|
||||
);
|
||||
let blanked = serde_json::json!({ "npm_config_registry": " " });
|
||||
assert_eq!(resolve(Some(&blanked)), None);
|
||||
let unrelated = serde_json::json!({ "npmrc": "//other/:_authToken=x" });
|
||||
assert_eq!(
|
||||
resolve(Some(&unrelated)).as_deref(),
|
||||
Some("https://instance.example/")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ path = "src/lib.rs"
|
||||
[features]
|
||||
default = []
|
||||
private = ["windmill-audit/private", "windmill-common/private", "windmill-api-auth/private", "windmill-store/private", "windmill-api-users/private", "windmill-api-workspaces/private", "windmill-api-groups/private", "windmill-api-configs/private", "windmill-api-settings/private", "windmill-api-assets/private", "windmill-api-agent-workers?/private", "windmill-trigger-kafka?/private", "windmill-trigger-postgres?/private", "windmill-trigger-mqtt?/private", "windmill-trigger-amqp?/private", "windmill-trigger-websocket?/private", "windmill-trigger-nats?/private", "windmill-trigger-sqs?/private", "windmill-trigger-gcp?/private", "windmill-trigger-azure?/private", "windmill-trigger-email?/private", "windmill-git-sync/private", "windmill-autoscaling?/private", "windmill-object-store/private"]
|
||||
enterprise = ["windmill-queue/enterprise", "windmill-audit/enterprise", "windmill-git-sync/enterprise", "windmill-common/enterprise", "windmill-worker?/enterprise", "windmill-api-auth/enterprise", "windmill-store/enterprise", "windmill-api-jobs/enterprise", "windmill-api-scripts/enterprise", "windmill-api-flows/enterprise", "windmill-api-users/enterprise", "windmill-api-workspaces/enterprise", "windmill-api-groups/enterprise", "windmill-api-configs/enterprise", "windmill-api-settings/enterprise", "windmill-api-schedule/enterprise", "windmill-api-agent-workers?/enterprise", "windmill-trigger/enterprise", "windmill-trigger-kafka?/enterprise", "windmill-trigger-postgres?/enterprise", "windmill-trigger-mqtt?/enterprise", "windmill-trigger-amqp?/enterprise", "windmill-trigger-websocket?/enterprise", "windmill-trigger-email?/enterprise", "windmill-trigger-nats?/enterprise", "windmill-trigger-sqs?/enterprise", "windmill-trigger-gcp?/enterprise", "windmill-trigger-azure?/enterprise", "windmill-trigger-http?/enterprise", "windmill-native-triggers?/enterprise", "dep:windmill-autoscaling", "windmill-autoscaling/enterprise", "license"]
|
||||
enterprise = ["windmill-queue/enterprise", "windmill-audit/enterprise", "windmill-git-sync/enterprise", "windmill-common/enterprise", "windmill-worker?/enterprise", "windmill-api-auth/enterprise", "windmill-store/enterprise", "windmill-api-jobs/enterprise", "windmill-api-scripts/enterprise", "windmill-api-flows/enterprise", "windmill-api-users/enterprise", "windmill-api-workspaces/enterprise", "windmill-api-groups/enterprise", "windmill-api-configs/enterprise", "windmill-api-settings/enterprise", "windmill-api-schedule/enterprise", "windmill-api-debug/enterprise", "windmill-api-agent-workers?/enterprise", "windmill-trigger/enterprise", "windmill-trigger-kafka?/enterprise", "windmill-trigger-postgres?/enterprise", "windmill-trigger-mqtt?/enterprise", "windmill-trigger-amqp?/enterprise", "windmill-trigger-websocket?/enterprise", "windmill-trigger-email?/enterprise", "windmill-trigger-nats?/enterprise", "windmill-trigger-sqs?/enterprise", "windmill-trigger-gcp?/enterprise", "windmill-trigger-azure?/enterprise", "windmill-trigger-http?/enterprise", "windmill-native-triggers?/enterprise", "dep:windmill-autoscaling", "windmill-autoscaling/enterprise", "license"]
|
||||
stripe = []
|
||||
run_inline = ["dep:windmill-worker", "windmill-api-configs/run_inline"]
|
||||
agent_worker_server = ["dep:windmill-worker", "dep:windmill-api-agent-workers"]
|
||||
|
||||
@@ -571,12 +571,8 @@ async fn gen_bunfig(
|
||||
NPMRC.read().await.clone()
|
||||
};
|
||||
|
||||
if let Some(ref npmrc_content) = npmrc {
|
||||
if !npmrc_content.trim().is_empty() {
|
||||
tracing::debug!("Writing .npmrc for bun from npmrc setting");
|
||||
write_file(job_dir, ".npmrc", npmrc_content)?;
|
||||
return Ok(());
|
||||
}
|
||||
if npmrc.as_ref().is_some_and(|c| !c.trim().is_empty()) {
|
||||
return write_bun_registry_config(job_dir, npmrc, None, None);
|
||||
}
|
||||
|
||||
let (registry, bunfig_install_scopes) = if let Some(conn) = db {
|
||||
@@ -606,6 +602,35 @@ async fn gen_bunfig(
|
||||
BUNFIG_INSTALL_SCOPES.read().await.clone(),
|
||||
)
|
||||
};
|
||||
write_bun_registry_config(job_dir, None, registry, bunfig_install_scopes)
|
||||
}
|
||||
|
||||
/// The files [`write_bun_registry_config`] may create in the directory bun installs from.
|
||||
/// Both can hold a registry auth token, so `prepare-deps` deletes them by these names once
|
||||
/// the install is over.
|
||||
pub(crate) const BUN_NPMRC_FILE: &str = ".npmrc";
|
||||
pub(crate) const BUN_CONFIG_FILE: &str = "bunfig.toml";
|
||||
|
||||
/// Write the registry configuration `bun install` picks up from its working directory: the
|
||||
/// `npmrc` setting verbatim as `.npmrc` when set, otherwise a `bunfig.toml` holding the
|
||||
/// registry URL, its auth token and the install scopes.
|
||||
///
|
||||
/// Shared with the debugger's `prepare-deps`, which resolves the same settings without a
|
||||
/// database (see `prepare_deps.rs`), so the two install paths configure bun identically.
|
||||
pub(crate) fn write_bun_registry_config(
|
||||
job_dir: &str,
|
||||
npmrc: Option<String>,
|
||||
registry: Option<String>,
|
||||
bunfig_install_scopes: Option<String>,
|
||||
) -> Result<()> {
|
||||
if let Some(ref npmrc_content) = npmrc {
|
||||
if !npmrc_content.trim().is_empty() {
|
||||
tracing::debug!("Writing .npmrc for bun from npmrc setting");
|
||||
write_file(job_dir, BUN_NPMRC_FILE, npmrc_content)?;
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
if registry.is_some() || bunfig_install_scopes.is_some() {
|
||||
let (url, token_opt) = if let Some(ref s) = registry {
|
||||
let url = s.trim();
|
||||
@@ -635,7 +660,7 @@ registry = {}
|
||||
.unwrap_or("".to_string())
|
||||
);
|
||||
tracing::debug!("Writing following bunfig.toml: {bunfig_toml}");
|
||||
let _ = write_file(&job_dir, "bunfig.toml", &bunfig_toml)?;
|
||||
let _ = write_file(&job_dir, BUN_CONFIG_FILE, &bunfig_toml)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ use regex::Regex;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tokio::process::Command;
|
||||
|
||||
use crate::bun_executor::{write_bun_registry_config, BUN_CONFIG_FILE, BUN_NPMRC_FILE};
|
||||
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,
|
||||
@@ -92,9 +93,10 @@ 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.
|
||||
/// Fallbacks for the registry settings, used when the caller sends none: this process has
|
||||
/// no database, so the instance settings only reach it through the request (see
|
||||
/// [`RegistryConfig`]), and a debug service that cannot fetch them still configures the
|
||||
/// installer from its own environment.
|
||||
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
|
||||
@@ -112,6 +114,25 @@ const p = {
|
||||
};
|
||||
"#;
|
||||
|
||||
/// The registry settings resolved from the instance settings by the caller.
|
||||
///
|
||||
/// This process runs without a database, so `GET /api/debug/registry_config` is where the
|
||||
/// debug service reads them and this request field is how they get here. They are consumed
|
||||
/// to configure the installer and never handed to the debug session itself: an index URL
|
||||
/// embeds credentials and the session executes user-supplied code.
|
||||
///
|
||||
/// Field names are the `global_settings` keys, so the service forwards the endpoint's
|
||||
/// response verbatim.
|
||||
#[derive(Deserialize, Default)]
|
||||
pub struct RegistryConfig {
|
||||
pub npm_config_registry: Option<String>,
|
||||
pub npmrc: Option<String>,
|
||||
pub bunfig_install_scopes: Option<String>,
|
||||
pub pip_index_url: Option<String>,
|
||||
pub pip_extra_index_url: Option<String>,
|
||||
pub uv_index_strategy: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct PrepareRequest {
|
||||
pub code: String,
|
||||
@@ -121,6 +142,125 @@ pub struct PrepareRequest {
|
||||
/// extension built for another version is simply invisible there.
|
||||
#[serde(default)]
|
||||
pub python_path: Option<String>,
|
||||
#[serde(default)]
|
||||
pub registry: RegistryConfig,
|
||||
}
|
||||
|
||||
/// A blank value means unset, as it does for the same setting on a worker.
|
||||
fn configured(value: &Option<String>) -> Option<String> {
|
||||
value.clone().filter(|v| !v.trim().is_empty())
|
||||
}
|
||||
|
||||
/// Where the registry configuration for one `bun install` is written.
|
||||
///
|
||||
/// Deliberately not the install directory: the debug session resolves its `node_modules`
|
||||
/// symlink into that directory, and the sandbox bind-mounts all of `/tmp` into every session,
|
||||
/// so a concurrent session could read the credentials of an install in flight. `/var/tmp` is a
|
||||
/// tmpfs private to each jail (`debugger/nsjail.debug.config.proto`), which also takes the
|
||||
/// credentials with it when a jailed install is killed. `bun install` reads them from here
|
||||
/// through `--config` and `HOME`.
|
||||
const REGISTRY_CONFIG_ROOT: &str = "/var/tmp/windmill-debug-registry";
|
||||
|
||||
/// How long a configuration directory may survive before the next install treats it as debris.
|
||||
/// The caller kills an install with SIGKILL, leaving nothing able to clean up after it, and an
|
||||
/// unjailed install is the only one that writes somewhere outliving the process at all. Well
|
||||
/// past any install: the caller's own timeout is two minutes by default.
|
||||
const REGISTRY_CONFIG_MAX_AGE: std::time::Duration = std::time::Duration::from_secs(60 * 60);
|
||||
|
||||
/// Removes the registry configuration when the install ends, whichever way it ends.
|
||||
struct RegistryConfigDir(Option<String>);
|
||||
|
||||
impl Drop for RegistryConfigDir {
|
||||
fn drop(&mut self) {
|
||||
if let Some(dir) = self.0.as_deref() {
|
||||
remove_registry_config_dir(dir);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Write the registry configuration for one install and return the directory holding it, empty
|
||||
/// when there is nothing to write.
|
||||
///
|
||||
/// Falls back to the install directory if the private root is not writable: an install that
|
||||
/// reaches its registry matters more than the isolation above, which only holds for sessions
|
||||
/// that run under nsjail in the first place.
|
||||
fn write_registry_config_dir(
|
||||
job_id: &uuid::Uuid,
|
||||
job_dir: &str,
|
||||
registry: &RegistryConfig,
|
||||
) -> anyhow::Result<RegistryConfigDir> {
|
||||
let npmrc = configured(®istry.npmrc);
|
||||
let npm_config_registry = configured(®istry.npm_config_registry);
|
||||
let bunfig_install_scopes = configured(®istry.bunfig_install_scopes);
|
||||
if npmrc.is_none() && npm_config_registry.is_none() && bunfig_install_scopes.is_none() {
|
||||
return Ok(RegistryConfigDir(None));
|
||||
}
|
||||
|
||||
let dir = format!("{}/{}", REGISTRY_CONFIG_ROOT, job_id);
|
||||
let dir = match create_private_dir(&dir) {
|
||||
Ok(()) => dir,
|
||||
Err(e) => {
|
||||
tracing::warn!("Could not create {dir} ({e}), keeping the registry configuration in the install directory");
|
||||
job_dir.to_string()
|
||||
}
|
||||
};
|
||||
// Claimed before anything is written to it, so a failure below still takes it down.
|
||||
let held = RegistryConfigDir(Some(dir.clone()));
|
||||
write_bun_registry_config(&dir, npmrc, npm_config_registry, bunfig_install_scopes)?;
|
||||
Ok(held)
|
||||
}
|
||||
|
||||
fn create_private_dir(dir: &str) -> std::io::Result<()> {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::DirBuilderExt;
|
||||
std::fs::create_dir_all(REGISTRY_CONFIG_ROOT)?;
|
||||
sweep_stale_registry_config();
|
||||
std::fs::DirBuilder::new().mode(0o700).create(dir)
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
sweep_stale_registry_config();
|
||||
std::fs::create_dir_all(dir)
|
||||
}
|
||||
}
|
||||
|
||||
/// Drop what an install that was killed could not clean up itself.
|
||||
fn sweep_stale_registry_config() {
|
||||
let Ok(entries) = std::fs::read_dir(REGISTRY_CONFIG_ROOT) else {
|
||||
return;
|
||||
};
|
||||
for entry in entries.flatten() {
|
||||
let stale = entry
|
||||
.metadata()
|
||||
.and_then(|m| m.modified())
|
||||
.is_ok_and(|m| m.elapsed().is_ok_and(|age| age > REGISTRY_CONFIG_MAX_AGE));
|
||||
if stale {
|
||||
let _ = std::fs::remove_dir_all(entry.path());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Delete the registry configuration once the install that needed it is over. A failure to
|
||||
/// remove credentials has to be visible.
|
||||
fn remove_registry_config_dir(dir: &str) {
|
||||
if dir.starts_with(REGISTRY_CONFIG_ROOT) {
|
||||
if let Err(e) = std::fs::remove_dir_all(dir) {
|
||||
if e.kind() != std::io::ErrorKind::NotFound {
|
||||
tracing::error!("Failed to remove registry configuration {dir}: {e}");
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
// The fallback path above put them in the install directory, which has to survive.
|
||||
for file in [BUN_NPMRC_FILE, BUN_CONFIG_FILE] {
|
||||
let path = format!("{}/{}", dir, file);
|
||||
if let Err(e) = std::fs::remove_file(&path) {
|
||||
if e.kind() != std::io::ErrorKind::NotFound {
|
||||
tracing::error!("Failed to remove registry configuration {path}: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
@@ -195,14 +335,18 @@ fn index_ca_bundle() -> Option<String> {
|
||||
}
|
||||
|
||||
/// uv registry arguments, mirroring what the job path passes in `python_executor`.
|
||||
fn uv_registry_args() -> Vec<String> {
|
||||
fn uv_registry_args(registry: &RegistryConfig) -> Vec<String> {
|
||||
let index_url = configured(®istry.pip_index_url).or_else(|| PY_INDEX_URL.clone());
|
||||
let extra_index_url =
|
||||
configured(®istry.pip_extra_index_url).or_else(|| PY_EXTRA_INDEX_URL.clone());
|
||||
|
||||
let mut args: Vec<String> = vec![];
|
||||
if let Some(urls) = PY_EXTRA_INDEX_URL.as_ref() {
|
||||
if let Some(urls) = 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() {
|
||||
if let Some(url) = index_url.as_ref() {
|
||||
args.extend(["--index-url".to_string(), url.to_string()]);
|
||||
}
|
||||
if let Some(hosts) = TRUSTED_HOST.as_ref() {
|
||||
@@ -217,7 +361,11 @@ fn uv_registry_args() -> Vec<String> {
|
||||
}
|
||||
|
||||
/// Prepare Python dependencies using uv
|
||||
async fn prepare_python_deps_standalone(code: &str, python_path: Option<&str>) -> PrepareResponse {
|
||||
async fn prepare_python_deps_standalone(
|
||||
code: &str,
|
||||
python_path: Option<&str>,
|
||||
registry: &RegistryConfig,
|
||||
) -> PrepareResponse {
|
||||
// Parse imports from the code
|
||||
let packages = parse_python_imports(code);
|
||||
|
||||
@@ -253,7 +401,7 @@ async fn prepare_python_deps_standalone(code: &str, python_path: Option<&str>) -
|
||||
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(),
|
||||
configured(®istry.uv_index_strategy).unwrap_or_else(|| 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());
|
||||
@@ -270,7 +418,7 @@ async fn prepare_python_deps_standalone(code: &str, python_path: Option<&str>) -
|
||||
common_uv_envs.insert("SSL_CERT_DIR".to_string(), cert_dir);
|
||||
}
|
||||
|
||||
let registry_args = uv_registry_args();
|
||||
let registry_args = uv_registry_args(registry);
|
||||
|
||||
// Step 1: Create virtual environment using uv
|
||||
// `--seed` resolves pip/setuptools from the index, so the venv also needs the registry
|
||||
@@ -418,11 +566,12 @@ pub async fn prepare_deps_standalone(
|
||||
code: &str,
|
||||
language: &str,
|
||||
python_path: Option<&str>,
|
||||
registry: &RegistryConfig,
|
||||
) -> PrepareResponse {
|
||||
// Route to the appropriate handler based on language
|
||||
match language {
|
||||
"python3" | "python" => {
|
||||
return prepare_python_deps_standalone(code, python_path).await;
|
||||
return prepare_python_deps_standalone(code, python_path, registry).await;
|
||||
}
|
||||
"bun" | "typescript" | "deno" => {
|
||||
// Continue with JS/TS handling below
|
||||
@@ -489,7 +638,7 @@ pub async fn prepare_deps_standalone(
|
||||
};
|
||||
}
|
||||
|
||||
let common_bun_proc_envs = get_simple_bun_proc_envs();
|
||||
let mut common_bun_proc_envs = get_simple_bun_proc_envs();
|
||||
|
||||
// Step 1: Run build.js to generate package.json
|
||||
let output = Command::new(&*BUN_PATH)
|
||||
@@ -582,17 +731,43 @@ pub async fn prepare_deps_standalone(
|
||||
};
|
||||
}
|
||||
|
||||
// Step 2: Run bun install
|
||||
// Step 2: Run bun install, from the same registry configuration a job installs with.
|
||||
let mut args = vec!["install".to_string()];
|
||||
let registry_config_dir = match write_registry_config_dir(&job_id, &job_dir, registry) {
|
||||
Ok(dir) => dir,
|
||||
Err(e) => {
|
||||
return PrepareResponse {
|
||||
node_modules_path: None,
|
||||
venv_path: None,
|
||||
job_dir: job_dir.clone(),
|
||||
success: false,
|
||||
error: Some(format!("Failed to write registry configuration: {}", e)),
|
||||
install_stderr: None,
|
||||
};
|
||||
}
|
||||
};
|
||||
if let Some(dir) = registry_config_dir.0.as_ref() {
|
||||
// Only one of the two files exists: `.npmrc` is read from the installer's home,
|
||||
// `bunfig.toml` only from the path named here.
|
||||
common_bun_proc_envs.insert("HOME".to_string(), dir.to_string());
|
||||
let bunfig = format!("{}/{}", dir, BUN_CONFIG_FILE);
|
||||
if std::path::Path::new(&bunfig).exists() {
|
||||
args.push(format!("--config={}", bunfig));
|
||||
}
|
||||
}
|
||||
|
||||
let output = Command::new(&*BUN_PATH)
|
||||
.current_dir(&job_dir)
|
||||
.env_clear()
|
||||
.envs(common_bun_proc_envs)
|
||||
.args(vec!["install"])
|
||||
.args(args)
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.output()
|
||||
.await;
|
||||
|
||||
drop(registry_config_dir);
|
||||
|
||||
match output {
|
||||
Ok(out) => {
|
||||
if !out.status.success() {
|
||||
@@ -691,6 +866,7 @@ pub async fn run_prepare_deps_cli() -> anyhow::Result<()> {
|
||||
&request.code,
|
||||
&request.language,
|
||||
request.python_path.as_deref(),
|
||||
&request.registry,
|
||||
)
|
||||
.await;
|
||||
println!("{}", serde_json::to_string(&response)?);
|
||||
@@ -700,7 +876,27 @@ pub async fn run_prepare_deps_cli() -> anyhow::Result<()> {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::PrepareResponse;
|
||||
use super::{PrepareRequest, PrepareResponse};
|
||||
|
||||
/// The debug service and this CLI are deployed as separate images, and the service
|
||||
/// forwards `GET /api/debug/registry_config` verbatim, `message` field included. So a
|
||||
/// request from an older service carries no `registry` at all, and one from a newer
|
||||
/// service carries fields this binary does not know.
|
||||
#[test]
|
||||
fn test_registry_is_optional_and_tolerates_unknown_fields() {
|
||||
let without: PrepareRequest =
|
||||
serde_json::from_str(r#"{"code": "import lodash", "language": "bun"}"#).unwrap();
|
||||
assert!(without.registry.npm_config_registry.is_none());
|
||||
|
||||
let with: PrepareRequest = serde_json::from_str(
|
||||
r#"{"code": "", "language": "bun", "registry": {"npm_config_registry": "https://npm.example", "message": "for the user"}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
with.registry.npm_config_registry.as_deref(),
|
||||
Some("https://npm.example")
|
||||
);
|
||||
}
|
||||
|
||||
/// 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
|
||||
|
||||
@@ -48,6 +48,7 @@ COPY dap_debug_service.ts .
|
||||
COPY dap_websocket_server_bun.ts .
|
||||
COPY env_passthrough.ts .
|
||||
COPY dap_websocket_server.py .
|
||||
COPY registry_config.ts .
|
||||
|
||||
# Expose the default port
|
||||
EXPOSE 5679
|
||||
|
||||
+64
-19
@@ -75,36 +75,81 @@ Options:
|
||||
| `DAP_NSJAIL_PATH` | nsjail binary path | nsjail |
|
||||
| `DAP_NSJAIL_CONFIG` | nsjail config file path | - |
|
||||
|
||||
### Python dependency preparation
|
||||
### 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.
|
||||
Before debugging a script, its imports are installed through `windmill prepare-deps`, which runs
|
||||
`uv` (Python) or `bun install` (TypeScript) without a database connection. The install runs in the
|
||||
service rather than in the session because the registry configuration usually embeds credentials
|
||||
and a debug server executes the submitted script inside a process the script can read; the Python
|
||||
server is handed only the resulting venv, with `--venv-path`, and a Bun session only the resulting
|
||||
`node_modules`.
|
||||
|
||||
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):
|
||||
`DAP_PREPARE_DEPS_TIMEOUT_MS` bounds the install (default 120000); past it the session starts
|
||||
without its dependencies. 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.
|
||||
|
||||
### Registry configuration
|
||||
|
||||
Because `prepare-deps` has no database, the service reads the instance settings for it from
|
||||
`GET /api/debug/registry_config` on `WINDMILL_BASE_URL` and passes them down over the CLI's stdin
|
||||
request. It is authorized by the launch token of the session being started, and serves only the
|
||||
settings that session's own installer runs on, so a TypeScript session's token cannot be used to
|
||||
read the Python index credentials.
|
||||
|
||||
The token also reaches the browser, so what it can fetch is what a workspace member can fetch.
|
||||
Sessions started by an operator are refused outright, since an operator cannot run a preview job
|
||||
either; for a member who can, the npm settings are already exposed by a preview (a worker leaves
|
||||
the same `.npmrc` / `bunfig.toml` in the directory the previewed script runs in), while the Python
|
||||
index URL, which otherwise only appears as uv's argv, becomes readable where it was not before.
|
||||
|
||||
These settings are Enterprise-only, exactly as they are for jobs, and a CE instance reports that in
|
||||
the session's output rather than applying them:
|
||||
|
||||
| Setting | Applies to |
|
||||
|---------|------------|
|
||||
| `npm_config_registry` | `bun install` registry and its `:_authToken=` |
|
||||
| `npmrc` | written verbatim as `.npmrc`, taking precedence over `npm_config_registry` |
|
||||
| `bunfig_install_scopes` | `[install.scopes]` in the generated `bunfig.toml` |
|
||||
| `pip_index_url` | `uv --index-url` |
|
||||
| `pip_extra_index_url` | `uv --extra-index-url`, comma-separated |
|
||||
|
||||
`uv_index_strategy` is served on any edition, like it is to a worker. An index URL holding the
|
||||
`EPHEMERAL_TOKEN` placeholder is not served at all: only a worker can run the command that
|
||||
substitutes it.
|
||||
|
||||
The credential-bearing files (`.npmrc`, `bunfig.toml`) are written under
|
||||
`/var/tmp/windmill-debug-registry`, not into the directory the install runs in, and are deleted
|
||||
when the install ends. That directory is not private to the install: a session resolves its
|
||||
`node_modules` symlink back into it, and `nsjail.debug.config.proto` bind-mounts the whole of
|
||||
`/tmp` into every session, so credentials left there would be readable by a concurrent session.
|
||||
`/var/tmp` is a tmpfs in that same config, one instance per jail, so a session sees an empty one
|
||||
and a jailed install's credentials go away with the jail even when it is killed (the service kills
|
||||
an install with SIGKILL, which no cleanup in the installer can survive). An install running
|
||||
unjailed writes to the host's `/var/tmp` instead, where a directory a kill left behind is removed
|
||||
by the next install; a session running unjailed is unconfined anyway and sees the whole filesystem,
|
||||
as it already does the rest of the service's state.
|
||||
|
||||
The rest of the registry configuration has no instance setting and is read from the environment of
|
||||
the debug service. Where two names are listed the first wins; a worker reads the same names:
|
||||
|
||||
| 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`. Falls back to `SSL_CERT_FILE`, then `REQUESTS_CA_BUNDLE`, then `CURL_CA_BUNDLE`, so a host that configures its CA under any of those names is picked up. Whichever is used **replaces** uv's own roots rather than adding to them, so it has to be a complete bundle: one holding only a private CA leaves every public index untrusted. `bun install` gets the same bundle as `NODE_EXTRA_CA_CERTS`, the only spelling Bun reads | - |
|
||||
| `SSL_CERT_DIR` | Directory of certificates, forwarded to uv as-is. Replaces uv's roots the same way the bundle does, so a directory holding only a private CA leaves public indexes untrusted | - |
|
||||
| `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 |
|
||||
| `DAP_REGISTRY_CONFIG_TIMEOUT_MS` | How long to wait on the settings fetch before installing without it | 10000 |
|
||||
|
||||
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.
|
||||
`PY_INDEX_URL` / `PIP_INDEX_URL` and `PY_EXTRA_INDEX_URL` / `PIP_EXTRA_INDEX_URL`, along with
|
||||
`UV_INDEX_STRATEGY`, are still read from the same environment whenever the fetch yields no index:
|
||||
because the instance has none set, because this is a CE instance, or because the session was not
|
||||
allowed the settings. A Python debug service configured that way therefore keeps working, but setting
|
||||
them is an instance-wide decision to install Python dependencies from that index, independent of who
|
||||
opened the session; leave them unset to let the instance settings alone decide. The npm settings have
|
||||
no such fallback: the instance settings are the only source.
|
||||
|
||||
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
|
||||
|
||||
@@ -47,6 +47,7 @@ import {
|
||||
type NsjailConfig
|
||||
} from './dap_websocket_server_bun'
|
||||
import { sessionEnv } from './env_passthrough'
|
||||
import { fetchRegistryConfig, type RegistryConfig } from './registry_config'
|
||||
|
||||
// ============================================================================
|
||||
// Configuration
|
||||
@@ -650,17 +651,17 @@ 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.
|
||||
* This runs here rather than in the Python server because the registry settings the CLI is
|
||||
* given 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.
|
||||
*
|
||||
* It still goes through spawnProcess so nsjail confines it on the same terms as the debuggee:
|
||||
* `uv pip install` builds source distributions, which executes their build backend's arbitrary
|
||||
* Python. Those same credentials are what `inheritEnv` is for — the jail config keeps the
|
||||
* environment across the boundary, so nothing else has to carry them in.
|
||||
*/
|
||||
private async prepareDependencies(code: string): Promise<string | null> {
|
||||
private async prepareDependencies(code: string, registry: RegistryConfig): Promise<string | null> {
|
||||
if (!this.windmillPath) {
|
||||
logger.info('No windmill binary path configured, skipping dependency preparation')
|
||||
return null
|
||||
@@ -688,7 +689,12 @@ class PythonDebugSession extends BaseDebugSession {
|
||||
// site-packages goes on that interpreter's sys.path, and uv otherwise picks its
|
||||
// own, which silently leaves compiled extensions unimportable.
|
||||
stdin: new Blob([
|
||||
JSON.stringify({ code, language: 'python3', python_path: config.pythonPath }) + '\n'
|
||||
JSON.stringify({
|
||||
code,
|
||||
language: 'python3',
|
||||
python_path: config.pythonPath,
|
||||
registry
|
||||
}) + '\n'
|
||||
]),
|
||||
inheritEnv: true,
|
||||
detached: true
|
||||
@@ -1042,6 +1048,8 @@ class PythonDebugSession extends BaseDebugSession {
|
||||
this.callMain = (args.callMain as boolean) || false
|
||||
this.mainArgs = (args.args as Record<string, unknown>) || {}
|
||||
this.envVars = (args.env as Record<string, string>) || {}
|
||||
// Also what authorizes the registry configuration fetch below.
|
||||
const token = args.token as string | undefined
|
||||
|
||||
// Enforce signing on every launch. The token is passed in the launch
|
||||
// arguments and is verified against the inline `code` (see windmill-api-debug).
|
||||
@@ -1055,7 +1063,6 @@ class PythonDebugSession extends BaseDebugSession {
|
||||
return
|
||||
}
|
||||
|
||||
const token = args.token as string | undefined
|
||||
if (!token) {
|
||||
logger.error('No debug token provided but signed requests are required')
|
||||
this.sendResponse(request, false, {}, 'Debug token required. Ensure the debug session was signed by the backend.')
|
||||
@@ -1118,7 +1125,19 @@ sys.stdout.flush()
|
||||
|
||||
try {
|
||||
if (code) {
|
||||
this.venvPath = (await this.prepareDependencies(code)) ?? undefined
|
||||
const registry = await fetchRegistryConfig(token, logger)
|
||||
// A round trip of its own, during which the client can give up: the installer runs
|
||||
// a source distribution's build backend, so starting one for a session that is
|
||||
// already gone executes package code nobody is waiting for.
|
||||
if (this.disposed) {
|
||||
logger.info('Session torn down during the registry configuration fetch, not installing')
|
||||
await this.cleanup()
|
||||
return
|
||||
}
|
||||
if (registry.message) {
|
||||
this.sendEvent('output', { category: 'console', output: `${registry.message}\n` })
|
||||
}
|
||||
this.venvPath = (await this.prepareDependencies(code, registry)) ?? undefined
|
||||
}
|
||||
|
||||
// Installing takes long enough for the client to give up meanwhile, and cleanup() has
|
||||
|
||||
@@ -27,6 +27,7 @@ import { mkdtemp, writeFile, unlink, rmdir, symlink } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { sessionEnv } from './env_passthrough'
|
||||
import { fetchRegistryConfig, type RegistryConfig } from './registry_config'
|
||||
|
||||
// Types for V8 Inspector Protocol
|
||||
interface V8Message {
|
||||
@@ -1512,6 +1513,8 @@ export class DebugSession {
|
||||
this.callMain = (args.callMain as boolean) || false
|
||||
this.mainArgs = (args.args as Record<string, unknown>) || {}
|
||||
this.envVars = (args.env as Record<string, string>) || {}
|
||||
// Also what authorizes the registry configuration fetch below.
|
||||
const token = args.token as string | undefined
|
||||
|
||||
// Enforce signing on every launch. The token is passed in the launch
|
||||
// arguments and is verified against the inline `code` (see windmill-api-debug).
|
||||
@@ -1525,7 +1528,6 @@ export class DebugSession {
|
||||
return
|
||||
}
|
||||
|
||||
const token = args.token as string | undefined
|
||||
if (!token) {
|
||||
logger.error('No debug token provided but signed requests are required')
|
||||
this.sendResponse(request, false, {}, 'Debug token required. Ensure the debug session was signed by the backend.')
|
||||
@@ -1557,7 +1559,19 @@ export class DebugSession {
|
||||
// Prepare dependencies using the original code (before any modifications)
|
||||
// This analyzes imports and installs required npm packages
|
||||
if (code) {
|
||||
this.nodeModulesPath = await this.prepareDependencies(code) || undefined
|
||||
const registry = await fetchRegistryConfig(token, logger)
|
||||
// A round trip of its own, during which the client can give up: the installer runs the
|
||||
// packages' postinstall scripts, so starting one for a session that is already gone
|
||||
// executes package code nobody is waiting for.
|
||||
if (this.disposed) {
|
||||
logger.info('Session was torn down during the registry configuration fetch, not installing')
|
||||
this.sendResponse(request, false, {}, 'Session terminated during dependency preparation')
|
||||
return
|
||||
}
|
||||
if (registry.message) {
|
||||
this.sendEvent('output', { category: 'console', output: `${registry.message}\n` })
|
||||
}
|
||||
this.nodeModulesPath = await this.prepareDependencies(code, registry) || undefined
|
||||
|
||||
// Installing takes long enough for the client to give up meanwhile, and cleanup() has
|
||||
// then already run: starting the debuggee now would leak a process nothing owns.
|
||||
@@ -1653,10 +1667,18 @@ export class DebugSession {
|
||||
*
|
||||
* Jailed on the same terms as the debuggee: `bun install` runs the packages' postinstall
|
||||
* scripts, which is user-supplied code executing next to the other services in the container.
|
||||
* Its environment is inherited rather than filtered, which is what carries the registry and
|
||||
* CA settings into the installer (the jail keeps the environment across the boundary).
|
||||
* Its environment is inherited rather than filtered, which is what carries the CA settings
|
||||
* into the installer (the jail keeps the environment across the boundary).
|
||||
*
|
||||
* The CLI has no database, so `registry` carries the instance's registry settings down to it
|
||||
* instead. They configure `bun install` and nothing else: the debugged script never gets
|
||||
* them, since it could read them back out of the process it runs in.
|
||||
*/
|
||||
private async prepareDependencies(code: string, language: string = 'bun'): Promise<string | null> {
|
||||
private async prepareDependencies(
|
||||
code: string,
|
||||
registry: RegistryConfig,
|
||||
language: string = 'bun'
|
||||
): Promise<string | null> {
|
||||
if (!this.windmillPath) {
|
||||
logger.info('No windmill binary path configured, skipping dependency preparation')
|
||||
return null
|
||||
@@ -1679,7 +1701,7 @@ export class DebugSession {
|
||||
let timedOut = false
|
||||
|
||||
try {
|
||||
const input = JSON.stringify({ code, language }) + '\n'
|
||||
const input = JSON.stringify({ code, language, registry }) + '\n'
|
||||
logger.info(`prepare-deps input length: ${input.length}`)
|
||||
|
||||
// Spawn the windmill binary with prepare-deps command. Its environment is inherited
|
||||
|
||||
@@ -63,6 +63,15 @@ mount {
|
||||
rw: true
|
||||
}
|
||||
|
||||
# Private scratch, one instance per jail. `windmill prepare-deps` writes the registry
|
||||
# credentials here rather than into its install directory under the shared /tmp above, so no
|
||||
# other session can read them, and they go away with the jail even when it is killed.
|
||||
mount {
|
||||
dst: "/var/tmp"
|
||||
fstype: "tmpfs"
|
||||
rw: true
|
||||
}
|
||||
|
||||
# Debugger scripts directory (for Python debugger server)
|
||||
mount {
|
||||
src: "/debugger"
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* Dependency-registry settings for a debug session's install.
|
||||
*
|
||||
* `windmill prepare-deps` installs a session's imports with no database connection, so the
|
||||
* instance settings that point at a private npm or pip registry cannot be read there. They
|
||||
* are fetched here instead, from the backend that signed the session's launch token, and
|
||||
* passed down to the CLI over its stdin request.
|
||||
*
|
||||
* They stop at the installer. A registry URL usually embeds credentials and a debugged
|
||||
* script can read whatever the process running it holds, so none of these values are ever
|
||||
* put in a session's environment (see README.md, "Registry configuration").
|
||||
*/
|
||||
|
||||
export interface RegistryConfig {
|
||||
npm_config_registry?: string
|
||||
npmrc?: string
|
||||
bunfig_install_scopes?: string
|
||||
pip_index_url?: string
|
||||
pip_extra_index_url?: string
|
||||
uv_index_strategy?: string
|
||||
/** Why the instance's settings are not in this response, for the user to see. */
|
||||
message?: string
|
||||
}
|
||||
|
||||
const WINDMILL_BASE_URL = process.env.WINDMILL_BASE_URL || process.env.BASE_INTERNAL_URL
|
||||
|
||||
/**
|
||||
* Bounds how long a launch waits on the backend. The session can still start without the
|
||||
* settings, it just installs from the public registries, so an unreachable backend must
|
||||
* not hold it up for longer than the install itself would take.
|
||||
*/
|
||||
const FETCH_TIMEOUT_MS = Number(process.env.DAP_REGISTRY_CONFIG_TIMEOUT_MS) || 10_000
|
||||
|
||||
/**
|
||||
* Fetch the registry settings for a session, authorized by its launch token.
|
||||
*
|
||||
* Never throws and never blocks a launch: on any failure it returns a config carrying only
|
||||
* a `message`, so the session starts against the public registries and the user is told why
|
||||
* instead of being left with an unexplained "package not found".
|
||||
*/
|
||||
export async function fetchRegistryConfig(
|
||||
token: string | undefined,
|
||||
logger: { info: (...args: unknown[]) => void; warn: (...args: unknown[]) => void }
|
||||
): Promise<RegistryConfig> {
|
||||
if (!token || !WINDMILL_BASE_URL) {
|
||||
return {}
|
||||
}
|
||||
|
||||
const url = `${WINDMILL_BASE_URL.replace(/\/$/, '')}/api/debug/registry_config`
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS)
|
||||
})
|
||||
if (response.status === 401 || response.status === 403 || response.status === 404) {
|
||||
// Expected answers, not something the user can act on: a session that may not read
|
||||
// the settings (an operator's) is refused, and a backend older than this image has
|
||||
// no such route at all. Both install from the public registries.
|
||||
logger.info(`Registry configuration not served for this session (${response.status})`)
|
||||
return {}
|
||||
}
|
||||
if (!response.ok) {
|
||||
const detail = (await response.text().catch(() => '')).trim()
|
||||
return {
|
||||
message: `Could not read the registry configuration (${response.status}): ${detail || response.statusText}`
|
||||
}
|
||||
}
|
||||
|
||||
const config: RegistryConfig = await response.json()
|
||||
// The values carry registry credentials, so only their names are logged.
|
||||
const configured = Object.entries(config)
|
||||
.filter(([key, value]) => key !== 'message' && value)
|
||||
.map(([key]) => key)
|
||||
logger.info(
|
||||
configured.length > 0
|
||||
? `Registry configuration from instance settings: ${configured.join(', ')}`
|
||||
: 'No registry configuration set on the instance'
|
||||
)
|
||||
return config
|
||||
} catch (error) {
|
||||
logger.warn(`Failed to fetch registry configuration: ${error}`)
|
||||
return { message: `Could not read the registry configuration: ${error}` }
|
||||
}
|
||||
}
|
||||
@@ -96,6 +96,7 @@ COPY debugger/dap_debug_service.ts .
|
||||
COPY debugger/dap_websocket_server_bun.ts .
|
||||
COPY debugger/env_passthrough.ts .
|
||||
COPY debugger/dap_websocket_server.py .
|
||||
COPY debugger/registry_config.ts .
|
||||
COPY debugger/nsjail.debug.config.proto .
|
||||
|
||||
# Install Python debugger dependencies using uv
|
||||
|
||||
Reference in New Issue
Block a user