feat(security): unshare pid of worker job process (#7106)

* feat(security): unbind pid for worker jobs to create extra process isolation

* review

* simplify

* cleanup + compose files

* re-add removed comments from nu executor

* simplify

* fail immediately

* updates

* update ping backend

* nsjail / unshare in workers page

* migrations

* frontend + sqlx

* frontend

* frontend

* fix error message

* undo example changes
This commit is contained in:
Alexander Petric
2025-11-18 18:04:31 -05:00
committed by GitHub
parent 8ae266b6a9
commit 5aa251a2d2
30 changed files with 536 additions and 232 deletions
@@ -46,11 +46,11 @@
]
},
"nullable": [
false,
false,
false,
false,
false,
true,
true,
true,
true,
true,
true,
true
]
@@ -59,7 +59,9 @@
"failure",
"command",
"approval",
"preprocessor"
"preprocessor",
"schedule_handler_old",
"dynamic_skip"
]
}
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE worker_ping SET ping_at = now(), current_job_id = $1, current_job_workspace_id = $2, memory_usage = $3, wm_memory_usage = $4,\n occupancy_rate = $6, occupancy_rate_15s = $7, occupancy_rate_5m = $8, occupancy_rate_30m = $9 WHERE worker = $5",
"query": "UPDATE worker_ping SET ping_at = now(), current_job_id = $1, current_job_workspace_id = $2, memory_usage = $3, wm_memory_usage = $4,\n occupancy_rate = $6, occupancy_rate_15s = $7, occupancy_rate_5m = $8, occupancy_rate_30m = $9, job_isolation = $10 WHERE worker = $5",
"describe": {
"columns": [],
"parameters": {
@@ -13,10 +13,11 @@
"Float4",
"Float4",
"Float4",
"Float4"
"Float4",
"Text"
]
},
"nullable": []
},
"hash": "506066203c49424e9944eb3948dc1657d3d796e6233e9f0ec925879c705d4773"
"hash": "76a7ad0588afcb4e9f0b876c50e203c36be56b2b2b08ca787417a9822ef56f64"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "SELECT worker, worker_instance, EXTRACT(EPOCH FROM (now() - ping_at))::integer as last_ping, started_at, ip, jobs_executed,\n CASE WHEN $4 IS TRUE THEN current_job_id ELSE NULL END as last_job_id, CASE WHEN $4 IS TRUE THEN current_job_workspace_id ELSE NULL END as last_job_workspace_id, \n custom_tags, worker_group, wm_version, occupancy_rate, occupancy_rate_15s, occupancy_rate_5m, occupancy_rate_30m, memory, vcpus, memory_usage, wm_memory_usage\n FROM worker_ping\n WHERE ($1::integer IS NULL AND ping_at > now() - interval '5 minute') OR (ping_at > now() - ($1 || ' seconds')::interval)\n ORDER BY ping_at desc LIMIT $2 OFFSET $3",
"query": "SELECT worker, worker_instance, EXTRACT(EPOCH FROM (now() - ping_at))::integer as last_ping, started_at, ip, jobs_executed,\n CASE WHEN $4 IS TRUE THEN current_job_id ELSE NULL END as last_job_id, CASE WHEN $4 IS TRUE THEN current_job_workspace_id ELSE NULL END as last_job_workspace_id,\n custom_tags, worker_group, wm_version, occupancy_rate, occupancy_rate_15s, occupancy_rate_5m, occupancy_rate_30m, memory, vcpus, memory_usage, wm_memory_usage, job_isolation\n FROM worker_ping\n WHERE ($1::integer IS NULL AND ping_at > now() - interval '5 minute') OR (ping_at > now() - ($1 || ' seconds')::interval)\n ORDER BY ping_at desc LIMIT $2 OFFSET $3",
"describe": {
"columns": [
{
@@ -97,6 +97,11 @@
"ordinal": 18,
"name": "wm_memory_usage",
"type_info": "Int8"
},
{
"ordinal": 19,
"name": "job_isolation",
"type_info": "Text"
}
],
"parameters": {
@@ -126,8 +131,9 @@
true,
true,
true,
true,
true
]
},
"hash": "6a497334c98bfaf70be44fced572a1cc0dde4141aa4c5002765a95432d0101ab"
"hash": "771a858a4b7ca41b6787e61f5a4a5c9c4d48fd213852e2f997cd4b2420580d30"
}
@@ -18,8 +18,8 @@
"Left": []
},
"nullable": [
false,
true
true,
false
]
},
"hash": "b3dbdfb50ee8118bdaed3164b210cb549a34b96554ae1872355b90304f5dcb76"
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO worker_ping (worker_instance, worker, ip, custom_tags, worker_group, dedicated_worker, wm_version, vcpus, memory) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) ON CONFLICT (worker) \n DO UPDATE set ip = EXCLUDED.ip, custom_tags = EXCLUDED.custom_tags, worker_group = EXCLUDED.worker_group",
"query": "INSERT INTO worker_ping (worker_instance, worker, ip, custom_tags, worker_group, dedicated_worker, wm_version, vcpus, memory, job_isolation) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) ON CONFLICT (worker)\n DO UPDATE set ip = EXCLUDED.ip, custom_tags = EXCLUDED.custom_tags, worker_group = EXCLUDED.worker_group",
"describe": {
"columns": [],
"parameters": {
@@ -13,10 +13,11 @@
"Varchar",
"Varchar",
"Int8",
"Int8"
"Int8",
"Text"
]
},
"nullable": []
},
"hash": "e5f8830450e90f678494ae2b0f86b0692a02dec4c8415fd7d364b0cc088905c2"
"hash": "ed90b9fcc57f530bab2d2d7426795bb358c91ebdd9dab50d7e3d2fdce63b947c"
}
@@ -0,0 +1,2 @@
-- Rollback: Remove job_isolation column from worker_ping table
ALTER TABLE worker_ping DROP COLUMN IF EXISTS job_isolation;
@@ -0,0 +1,4 @@
-- Add job_isolation column to worker_ping table
-- This tracks which job isolation method the worker is using: 'nsjail', 'unshare', or 'none'
-- Nullable for backwards compatibility - old workers will report NULL
ALTER TABLE worker_ping ADD COLUMN job_isolation TEXT;
+2
View File
@@ -18802,6 +18802,8 @@ components:
type: number
wm_memory_usage:
type: number
job_isolation:
type: string
required:
- worker
- worker_instance
+4 -2
View File
@@ -69,6 +69,8 @@ struct WorkerPing {
memory_usage: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
wm_memory_usage: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
job_isolation: Option<String>,
}
// #[derive(Serialize, Deserialize)]
@@ -97,8 +99,8 @@ async fn list_worker_pings(
let rows = sqlx::query_as!(
WorkerPing,
"SELECT worker, worker_instance, EXTRACT(EPOCH FROM (now() - ping_at))::integer as last_ping, started_at, ip, jobs_executed,
CASE WHEN $4 IS TRUE THEN current_job_id ELSE NULL END as last_job_id, CASE WHEN $4 IS TRUE THEN current_job_workspace_id ELSE NULL END as last_job_workspace_id,
custom_tags, worker_group, wm_version, occupancy_rate, occupancy_rate_15s, occupancy_rate_5m, occupancy_rate_30m, memory, vcpus, memory_usage, wm_memory_usage
CASE WHEN $4 IS TRUE THEN current_job_id ELSE NULL END as last_job_id, CASE WHEN $4 IS TRUE THEN current_job_workspace_id ELSE NULL END as last_job_workspace_id,
custom_tags, worker_group, wm_version, occupancy_rate, occupancy_rate_15s, occupancy_rate_5m, occupancy_rate_30m, memory, vcpus, memory_usage, wm_memory_usage, job_isolation
FROM worker_ping
WHERE ($1::integer IS NULL AND ping_at > now() - interval '5 minute') OR (ping_at > now() - ($1 || ' seconds')::interval)
ORDER BY ping_at desc LIMIT $2 OFFSET $3",
+10 -3
View File
@@ -1301,6 +1301,7 @@ pub struct Ping {
pub occupancy_rate_15s: Option<f32>,
pub occupancy_rate_5m: Option<f32>,
pub occupancy_rate_30m: Option<f32>,
pub job_isolation: Option<String>,
pub ping_type: PingType,
}
pub async fn update_ping_http(
@@ -1348,6 +1349,7 @@ pub async fn update_ping_http(
&insert_ping.version.unwrap(),
insert_ping.vcpus,
insert_ping.memory,
insert_ping.job_isolation,
db,
)
.await?;
@@ -1363,6 +1365,7 @@ pub async fn update_ping_http(
insert_ping.occupancy_rate_15s,
insert_ping.occupancy_rate_5m,
insert_ping.occupancy_rate_30m,
insert_ping.job_isolation,
db,
)
.await?;
@@ -1476,10 +1479,11 @@ pub async fn insert_ping_query(
version: &str,
vcpus: Option<i64>,
memory: Option<i64>,
job_isolation: Option<String>,
db: &DB,
) -> anyhow::Result<()> {
sqlx::query!(
"INSERT INTO worker_ping (worker_instance, worker, ip, custom_tags, worker_group, dedicated_worker, wm_version, vcpus, memory) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) ON CONFLICT (worker)
"INSERT INTO worker_ping (worker_instance, worker, ip, custom_tags, worker_group, dedicated_worker, wm_version, vcpus, memory, job_isolation) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) ON CONFLICT (worker)
DO UPDATE set ip = EXCLUDED.ip, custom_tags = EXCLUDED.custom_tags, worker_group = EXCLUDED.worker_group",
worker_instance,
worker_name,
@@ -1489,7 +1493,8 @@ pub async fn insert_ping_query(
dw,
version,
vcpus,
memory
memory,
job_isolation.as_deref()
)
.execute(db)
.await?;
@@ -1506,11 +1511,12 @@ pub async fn update_worker_ping_from_job_query(
occupancy_rate_15s: Option<f32>,
occupancy_rate_5m: Option<f32>,
occupancy_rate_30m: Option<f32>,
job_isolation: Option<String>,
db: &DB,
) -> anyhow::Result<()> {
sqlx::query!(
"UPDATE worker_ping SET ping_at = now(), current_job_id = $1, current_job_workspace_id = $2, memory_usage = $3, wm_memory_usage = $4,
occupancy_rate = $6, occupancy_rate_15s = $7, occupancy_rate_5m = $8, occupancy_rate_30m = $9 WHERE worker = $5",
occupancy_rate = $6, occupancy_rate_15s = $7, occupancy_rate_5m = $8, occupancy_rate_30m = $9, job_isolation = $10 WHERE worker = $5",
job_id,
w_id,
memory_usage,
@@ -1520,6 +1526,7 @@ pub async fn update_worker_ping_from_job_query(
occupancy_rate_15s,
occupancy_rate_5m,
occupancy_rate_30m,
job_isolation,
)
.execute(db)
.await?;
@@ -29,13 +29,13 @@ use windmill_queue::{append_logs, CanceledBy};
use crate::{
bash_executor::BIN_BASH,
common::{
check_executor_binary_exists, get_reserved_variables, read_and_check_result,
start_child_process, transform_json, OccupancyMetrics,
build_command_with_isolation, check_executor_binary_exists, get_reserved_variables,
read_and_check_result, start_child_process, transform_json, OccupancyMetrics,
},
handle_child::handle_child,
python_executor::{create_dependencies_dir, handle_python_reqs, uv_pip_compile},
PyVAlias, DISABLE_NSJAIL, DISABLE_NUSER, GIT_PATH, HOME_ENV, NSJAIL_PATH, PATH_ENV, PROXY_ENVS,
PY_INSTALL_DIR, TZ_ENV,
DISABLE_NSJAIL, DISABLE_NUSER, GIT_PATH, HOME_ENV, NSJAIL_PATH, PATH_ENV,
PROXY_ENVS, PY_INSTALL_DIR, PyVAlias, TZ_ENV,
};
use windmill_common::client::AuthedClient;
@@ -1240,7 +1240,11 @@ fi
.stderr(Stdio::piped());
start_child_process(nsjail_cmd, NSJAIL_PATH.as_str(), false).await?
} else {
let mut ansible_cmd = Command::new(ANSIBLE_PLAYBOOK_PATH.as_str());
let ansible_args: Vec<&str> = cmd_args.iter().map(|s| s.as_ref()).collect();
let mut ansible_cmd = build_command_with_isolation(
ANSIBLE_PLAYBOOK_PATH.as_str(),
&ansible_args,
);
ansible_cmd
.current_dir(job_dir)
.env_clear()
@@ -1250,7 +1254,6 @@ fi
.env("TZ", TZ_ENV.as_str())
.env("BASE_INTERNAL_URL", base_internal_url)
.env("HOME", HOME_ENV.as_str())
.args(cmd_args)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
+5 -3
View File
@@ -35,7 +35,7 @@ use crate::handle_child::run_future_with_polling_update_job_poller;
use crate::{
common::{
build_args_map, get_reserved_variables, read_file, read_file_content, start_child_process,
build_args_map, build_command_with_isolation, get_reserved_variables, read_file, read_file_content, start_child_process,
OccupancyMetrics,
},
handle_child::handle_child,
@@ -200,7 +200,10 @@ exit $exit_status
} else {
let mut cmd_args = vec!["wrapper.sh"];
cmd_args.extend(&args);
let mut bash_cmd = Command::new(BIN_BASH.as_str());
let mut bash_cmd = build_command_with_isolation(
BIN_BASH.as_str(),
&cmd_args.iter().map(|s| s.as_ref()).collect::<Vec<&str>>(),
);
bash_cmd
.current_dir(job_dir)
.env_clear()
@@ -209,7 +212,6 @@ exit $exit_status
.env("PATH", PATH_ENV.as_str())
.env("BASE_INTERNAL_URL", base_internal_url)
.env("HOME", HOME_ENV.as_str())
.args(cmd_args)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
+11 -16
View File
@@ -17,7 +17,7 @@ use crate::common::build_envs_map;
use crate::{
common::{
create_args_and_out_file, get_reserved_variables, parse_npm_config, read_file,
read_file_content, read_result, start_child_process, write_file_binary, OccupancyMetrics,
build_command_with_isolation, read_file_content, read_result, start_child_process, write_file_binary, OccupancyMetrics,
StreamNotifier,
},
handle_child::handle_child,
@@ -1430,15 +1430,15 @@ try {{
} else {
let cmd = if annotation.nodejs {
let script_path = format!("{job_dir}/wrapper.mjs");
let args = vec!["--preserve-symlinks", script_path.as_str()];
let mut bun_cmd = Command::new(&*NODE_BIN_PATH);
let mut bun_cmd = build_command_with_isolation(&*NODE_BIN_PATH, &args);
bun_cmd
.current_dir(job_dir)
.env_clear()
.envs(envs)
.envs(reserved_variables)
.envs(common_bun_proc_envs)
.args(vec!["--preserve-symlinks", &script_path])
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
@@ -1450,8 +1450,7 @@ try {{
} else {
let script_path = format!("{job_dir}/wrapper.mjs");
let mut bun_cmd = Command::new(&*BUN_PATH);
let args = if codebase.is_some() || has_bundle_cache {
let args: Vec<&str> = if codebase.is_some() || has_bundle_cache {
vec!["run", &script_path]
} else {
vec![
@@ -1463,13 +1462,13 @@ try {{
&script_path,
]
};
let mut bun_cmd = build_command_with_isolation(&*BUN_PATH, &args);
bun_cmd
.current_dir(job_dir)
.env_clear()
.envs(envs)
.envs(reserved_variables)
.envs(common_bun_proc_envs)
.args(args)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
@@ -1480,16 +1479,12 @@ try {{
bun_cmd
};
start_child_process(
cmd,
if annotation.nodejs {
&*NODE_BIN_PATH
} else {
&*BUN_PATH
},
false,
)
.await?
let executable = if annotation.nodejs {
&*NODE_BIN_PATH
} else {
&*BUN_PATH
};
start_child_process(cmd, executable, false).await?
};
let stream_notifier = StreamNotifier::new(conn, job);
+33
View File
@@ -528,6 +528,7 @@ pub async fn update_worker_ping_for_failed_init_script(
memory: None,
memory_usage: None,
wm_memory_usage: None,
job_isolation: None,
ping_type: PingType::InitScript,
},
)
@@ -628,6 +629,38 @@ lazy_static! {
static ref DISABLE_PROCESS_GROUP: bool = std::env::var("DISABLE_PROCESS_GROUP").is_ok();
}
pub fn build_command_with_isolation(
program: &str,
args: &[&str],
) -> Command {
use tokio::process::Command;
if *crate::ENABLE_UNSHARE_PID {
if let Some(unshare_path) = crate::UNSHARE_PATH.as_ref() {
let mut cmd = Command::new(unshare_path);
let flags = crate::UNSHARE_ISOLATION_FLAGS.as_str();
for flag in flags.split_whitespace() {
cmd.arg(flag);
}
cmd.arg("--");
cmd.arg(program);
cmd.args(args);
cmd
} else {
panic!(
"BUG: ENABLE_UNSHARE_PID is true but UNSHARE_PATH is None. \
This should have been caught at worker startup."
);
}
} else {
let mut cmd = Command::new(program);
cmd.args(args);
cmd
}
}
pub async fn start_child_process(
cmd: Command,
executable: &str,
@@ -27,12 +27,12 @@ use windmill_queue::CanceledBy;
#[cfg(feature = "csharp")]
use crate::{
common::{
check_executor_binary_exists, create_args_and_out_file, get_reserved_variables,
read_result, start_child_process,
build_command_with_isolation, check_executor_binary_exists, create_args_and_out_file,
get_reserved_variables, read_result, start_child_process,
},
handle_child::handle_child,
CSHARP_CACHE_DIR, DISABLE_NSJAIL, DISABLE_NUSER, DOTNET_PATH, HOME_ENV, NSJAIL_PATH,
NUGET_CONFIG, PATH_ENV, TZ_ENV,
CSHARP_CACHE_DIR, DISABLE_NSJAIL, DISABLE_NUSER, DOTNET_PATH, HOME_ENV,
NSJAIL_PATH, NUGET_CONFIG, PATH_ENV, TZ_ENV,
};
use crate::common::OccupancyMetrics;
@@ -588,7 +588,8 @@ pub async fn handle_csharp_job(
} else {
format!("{job_dir}/Main.exe")
};
let mut run_csharp = Command::new(&compiled_executable_name);
let mut run_csharp = build_command_with_isolation(&compiled_executable_name, &[]);
run_csharp
.current_dir(job_dir)
.env_clear()
+8 -5
View File
@@ -7,7 +7,7 @@ use windmill_queue::{append_logs, CanceledBy, MiniPulledJob};
use crate::{
common::{
create_args_and_out_file, get_reserved_variables, parse_npm_config, read_file, read_result,
build_command_with_isolation, create_args_and_out_file, get_reserved_variables, parse_npm_config, read_file, read_result,
start_child_process, OccupancyMetrics, StreamNotifier,
},
handle_child::handle_child,
@@ -406,14 +406,17 @@ try {{
args.push("-A");
}
args.push(&script_path);
let mut deno_cmd = Command::new(DENO_PATH.as_str());
let mut deno_cmd = build_command_with_isolation(
DENO_PATH.as_str(),
&args.iter().map(|s| s.as_ref()).collect::<Vec<&str>>(),
);
deno_cmd
.current_dir(job_dir)
.env_clear()
.envs(envs)
.envs(reserved_variables)
.envs(common_deno_proc_envs)
.args(args)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
@@ -589,7 +592,7 @@ BigInt.prototype.toJSON = function () {{
{dates}
console.log('start\n');
console.log('start\n');
const decoder = new TextDecoder();
for await (const chunk of Deno.stdin.readable) {{
@@ -601,7 +604,7 @@ for await (const chunk of Deno.stdin.readable) {{
break;
}}
try {{
let {{ {spread} }} = JSON.parse(line)
let {{ {spread} }} = JSON.parse(line)
{dates}
let res: any = await main(...[ {spread} ]);
console.log("wm_res[success]:" + JSON.stringify(res ?? null, (key, value) => typeof value === 'undefined' ? null : value) + '\n');
+2 -5
View File
@@ -19,7 +19,7 @@ use windmill_queue::{append_logs, CanceledBy, MiniPulledJob};
use crate::{
common::{
capitalize, create_args_and_out_file, get_reserved_variables, read_result,
build_command_with_isolation, capitalize, create_args_and_out_file, get_reserved_variables, read_result,
start_child_process, OccupancyMetrics,
},
handle_child::handle_child,
@@ -371,10 +371,7 @@ func Run(req Req) (interface{{}}, error){{
#[cfg(windows)]
let compiled_executable_name = format!("{}/main.exe", job_dir);
#[cfg(unix)]
let mut run_go = Command::new(&compiled_executable_name);
#[cfg(windows)]
let mut run_go = Command::new(&compiled_executable_name);
let mut run_go = build_command_with_isolation(&compiled_executable_name, &[]);
run_go
.current_dir(job_dir)
+10 -8
View File
@@ -21,13 +21,13 @@ use windmill_queue::{append_logs, CanceledBy, MiniPulledJob};
use crate::{
common::{
create_args_and_out_file, get_reserved_variables, read_result, start_child_process,
OccupancyMetrics,
build_command_with_isolation, create_args_and_out_file, get_reserved_variables,
read_result, start_child_process, OccupancyMetrics,
},
handle_child,
universal_pkg_installer::{par_install_language_dependencies_all_at_once, RequiredDependency},
COURSIER_CACHE_DIR, DISABLE_NSJAIL, DISABLE_NUSER, JAVA_CACHE_DIR, JAVA_REPOSITORY_DIR,
MAVEN_REPOS, NO_DEFAULT_MAVEN, NSJAIL_PATH, PATH_ENV, PROXY_ENVS,
COURSIER_CACHE_DIR, DISABLE_NSJAIL, DISABLE_NUSER, JAVA_CACHE_DIR,
JAVA_REPOSITORY_DIR, MAVEN_REPOS, NO_DEFAULT_MAVEN, NSJAIL_PATH, PATH_ENV, PROXY_ENVS,
};
use windmill_common::client::AuthedClient;
@@ -653,11 +653,13 @@ async fn run<'a>(
)
.await;
let mut cmd = Command::new(if cfg!(windows) {
let java_executable = if cfg!(windows) {
"java"
} else {
JAVA_PATH.as_str()
});
};
let mut cmd = build_command_with_isolation(java_executable, &[]);
cmd.env_clear()
.current_dir(job_dir.to_owned())
.env("PATH", PATH_ENV.as_str())
@@ -703,7 +705,7 @@ async fn run<'a>(
std::env::var("TMP").unwrap_or_else(|_| String::from("/tmp")),
);
}
start_child_process(cmd, "java", false).await?
start_child_process(cmd, java_executable, false).await?
};
handle_child::handle_child(
&job.id,
@@ -836,7 +838,7 @@ fn wrap(inner_content: &str) -> Result<String, Error> {
})
.collect_vec()
.join(" ");
Ok(r#"
Ok(r#"
package net.script;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.io.FileInputStream;
+23 -23
View File
@@ -13,10 +13,11 @@ use windmill_queue::{append_logs, CanceledBy, MiniPulledJob};
use crate::{
common::{
create_args_and_out_file, get_reserved_variables, read_result, start_child_process,
OccupancyMetrics,
build_command_with_isolation, create_args_and_out_file, get_reserved_variables,
read_result, start_child_process, OccupancyMetrics,
},
handle_child, DISABLE_NSJAIL, DISABLE_NUSER, NSJAIL_PATH, PATH_ENV, PROXY_ENVS,
handle_child, DISABLE_NSJAIL, DISABLE_NUSER, NSJAIL_PATH, PATH_ENV,
PROXY_ENVS,
};
use windmill_common::client::AuthedClient;
@@ -181,7 +182,7 @@ fn wrap(inner_content: &str) -> Result<String, Error> {
.collect_vec()
.join(" ");
Ok(
r#"
r#"
$env.config.table.mode = 'basic'
def nullguard [ name: string ] {
@@ -194,11 +195,11 @@ def nullguard [ name: string ] {
# TODO: Probably needs rework in order for LSP to work
def get_variable [ pat ] {
let addr = $"($env.BASE_INTERNAL_URL)/api/w/($env.WM_WORKSPACE)/variables/get_value/($pat)" ;
http get -H ["Authorization", $"Bearer ($env.WM_TOKEN)"] $addr | return $in
http get -H ["Authorization", $"Bearer ($env.WM_TOKEN)"] $addr | return $in
}
def get_resource [ pat ] {
let addr = $"($env.BASE_INTERNAL_URL)/api/w/($env.WM_WORKSPACE)/resources/get_value_interpolated/($pat)" ;
http get -H ["Authorization", $"Bearer ($env.WM_TOKEN)"] $addr | return $in
http get -H ["Authorization", $"Bearer ($env.WM_TOKEN)"] $addr | return $in
}
def 'main --wrapped' [] {
@@ -285,11 +286,14 @@ async fn run<'a>(
// let plugin_registry = format!("{job_dir}/plugin-registry");
// File::create(&plugin_registry).await?;
//
let mut cmd = Command::new(if cfg!(windows) {
let nu_executable = if cfg!(windows) {
"nu"
} else {
NU_PATH.as_str()
});
};
let args = vec!["main.nu", "--wrapped"];
let mut cmd = build_command_with_isolation(nu_executable, &args);
cmd.env_clear()
.current_dir(job_dir.to_owned())
.env("PATH", PATH_ENV.as_str())
@@ -297,20 +301,16 @@ async fn run<'a>(
.envs(envs)
.envs(reserved_variables)
.envs(PROXY_ENVS.clone())
.args(&[
"main.nu",
"--wrapped",
// TODO(v1):
// "--plugins",
// &format!(
// "[{}]",
// plugins
// .into_iter()
// .map(|pl| format!("{NU_CACHE_DIR}/plugins/bin/nu_plugin_{pl}"))
// .collect_vec()
// .join(",")
// ),
])
// TODO(v1):
// "--plugins",
// &format!(
// "[{}]",
// plugins
// .into_iter()
// .map(|pl| format!("{NU_CACHE_DIR}/plugins/bin/nu_plugin_{pl}"))
// .collect_vec()
// .join(",")
// ),
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
@@ -324,7 +324,7 @@ async fn run<'a>(
std::env::var("TMP").unwrap_or_else(|_| String::from("/tmp")),
);
}
start_child_process(cmd, "nu", false).await?
start_child_process(cmd, nu_executable, false).await?
};
handle_child::handle_child(
&job.id,
+19 -21
View File
@@ -16,11 +16,12 @@ use windmill_queue::{append_logs, CanceledBy};
use crate::{
common::{
check_executor_binary_exists, create_args_and_out_file, get_reserved_variables,
read_result, start_child_process, OccupancyMetrics,
build_command_with_isolation, check_executor_binary_exists, create_args_and_out_file,
get_reserved_variables, read_result, start_child_process, OccupancyMetrics,
},
handle_child::handle_child,
COMPOSER_CACHE_DIR, COMPOSER_PATH, DISABLE_NSJAIL, DISABLE_NUSER, NSJAIL_PATH, PHP_PATH,
COMPOSER_CACHE_DIR, COMPOSER_PATH, DISABLE_NSJAIL, DISABLE_NUSER,
NSJAIL_PATH, PHP_PATH,
};
use windmill_common::client::AuthedClient;
@@ -309,25 +310,22 @@ try {{
.stderr(Stdio::piped());
start_child_process(nsjail_cmd, NSJAIL_PATH.as_str(), false).await?
} else {
let cmd = {
let script_path = format!("{job_dir}/wrapper.php");
let script_path = format!("{job_dir}/wrapper.php");
let args = vec![script_path.as_str()];
let mut php_cmd = Command::new(&*PHP_PATH);
let args = vec![&script_path];
php_cmd
.current_dir(job_dir)
.env_clear()
.envs(envs)
.envs(reserved_variables)
.env("COMPOSER_HOME", &*COMPOSER_CACHE_DIR)
.env("BASE_INTERNAL_URL", base_internal_url)
.args(args)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
php_cmd
};
start_child_process(cmd, &*PHP_PATH, false).await?
let mut php_cmd = build_command_with_isolation(&*PHP_PATH, &args);
php_cmd
.current_dir(job_dir)
.env_clear()
.envs(envs)
.envs(reserved_variables)
.env("COMPOSER_HOME", &*COMPOSER_CACHE_DIR)
.env("BASE_INTERNAL_URL", base_internal_url)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
start_child_process(php_cmd, &*PHP_PATH, false).await?
};
handle_child(
+17 -16
View File
@@ -23,12 +23,13 @@ lazy_static::lazy_static! {
use crate::{
common::{
build_args_map, get_reserved_variables, read_file, read_file_content, start_child_process,
OccupancyMetrics,
build_args_map, build_command_with_isolation, get_reserved_variables, read_file,
read_file_content, start_child_process, OccupancyMetrics,
},
handle_child::handle_child,
DISABLE_NSJAIL, DISABLE_NUSER, HOME_ENV, NSJAIL_PATH, PATH_ENV, POWERSHELL_CACHE_DIR,
POWERSHELL_PATH, POWERSHELL_REPO_PAT, POWERSHELL_REPO_URL, PROXY_ENVS, TZ_ENV,
DISABLE_NSJAIL, DISABLE_NUSER, HOME_ENV, NSJAIL_PATH, PATH_ENV,
POWERSHELL_CACHE_DIR, POWERSHELL_PATH, POWERSHELL_REPO_PAT, POWERSHELL_REPO_URL, PROXY_ENVS,
TZ_ENV,
};
fn val_to_pwsh_param(v: serde_json::Value) -> String {
@@ -81,14 +82,14 @@ $credentials = $null
if ($hasPrivateRepo) {
$repoName = "windmill-private-$jobId"
$repoUri = "$privateRepoUrl"
# Create PSCredential for authentication
$username = "token"
$patToken = ConvertTo-SecureString $privateRepoPat -AsPlainText -Force
$credentials = New-Object System.Management.Automation.PSCredential($username, $patToken)
Write-Host "Registering temporary repository: $repoName"
# Remove repository if it already exists
Unregister-PSResourceRepository -Name $repoName -ErrorAction SilentlyContinue
Register-PSResourceRepository -Name $repoName -Uri $repoUri -Trusted
@@ -99,7 +100,7 @@ try {
foreach ($moduleRequest in $moduleRequests) {
$moduleName = $moduleRequest.Name
$requiredVersion = $moduleRequest.Version
# Check if module is already installed with the required version (case-insensitive)
$isInstalled = $false
if ($requiredVersion) {
@@ -107,32 +108,32 @@ try {
} else {
$isInstalled = $availableModules | Where-Object { $_.Name -eq $moduleName }
}
if (-not $isInstalled) {
$moduleFound = $false
# First try private repository if configured
if ($hasPrivateRepo) {
$findParams = @{ Name = $moduleName; Repository = $repoName; ErrorAction = 'SilentlyContinue'; Credential = $credentials }
if ($requiredVersion) { $findParams.Version = $requiredVersion }
$privateModule = Find-PSResource @findParams
if ($privateModule) {
$moduleFound = $true
$versionInfo = if ($requiredVersion) { " version $requiredVersion" } else { "" }
Write-Host "Found module $moduleName$versionInfo in private repository, installing from there..."
$saveParams = @{ Name = $moduleName; Path = $path; Repository = $repoName; Credential = $credentials }
if ($requiredVersion) { $saveParams.Version = $requiredVersion }
Save-PSResource @saveParams
}
}
# If not found in private repo (or no private repo configured), try all repositories
if (-not $moduleFound) {
$versionInfo = if ($requiredVersion) { " version $requiredVersion" } else { "" }
Write-Host "Installing module $moduleName$versionInfo from public repositories..."
$saveParams = @{ Name = $moduleName; Path = $path; TrustRepository = $true }
if ($requiredVersion) { $saveParams.Version = $requiredVersion }
Save-PSResource @saveParams
@@ -526,7 +527,6 @@ $env:PSModulePath = \"{};$PSModulePathBackup\"",
start_child_process(cmd, NSJAIL_PATH.as_str(), false).await?
} else {
let mut cmd = Command::new(POWERSHELL_PATH.as_str());
let cmd_args;
#[cfg(unix)]
@@ -539,6 +539,8 @@ $env:PSModulePath = \"{};$PSModulePathBackup\"",
cmd_args = vec![r".\wrapper.ps1"];
}
let mut cmd = build_command_with_isolation(POWERSHELL_PATH.as_str(), &cmd_args);
cmd.current_dir(job_dir)
.env_clear()
.envs(envs)
@@ -547,7 +549,6 @@ $env:PSModulePath = \"{};$PSModulePathBackup\"",
.env("PATH", PATH_ENV.as_str())
.env("BASE_INTERNAL_URL", base_internal_url)
.env("HOME", HOME_ENV.as_str())
.args(&cmd_args)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
@@ -120,7 +120,7 @@ use windmill_common::s3_helpers::OBJECT_STORE_SETTINGS;
use crate::{
common::{
create_args_and_out_file, get_reserved_variables, read_file, read_result,
build_command_with_isolation, create_args_and_out_file, get_reserved_variables, read_file, read_result,
start_child_process, OccupancyMetrics, StreamNotifier,
},
handle_child::handle_child,
@@ -835,9 +835,12 @@ mount {{
.stderr(Stdio::piped());
start_child_process(nsjail_cmd, NSJAIL_PATH.as_str(), false).await?
} else {
let mut python_cmd = Command::new(&python_path);
let args = vec!["-u", "-m", "wrapper"];
let mut python_cmd = build_command_with_isolation(
&python_path,
&args,
);
python_cmd
.current_dir(job_dir)
.env_clear()
@@ -847,7 +850,6 @@ mount {{
.env("TZ", TZ_ENV.as_str())
.env("BASE_INTERNAL_URL", base_internal_url)
.env("HOME", HOME_ENV.as_str())
.args(args)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
+23 -20
View File
@@ -23,12 +23,13 @@ use windmill_queue::{append_logs, CanceledBy, MiniPulledJob};
use crate::{
common::{
create_args_and_out_file, get_reserved_variables, read_result, start_child_process,
OccupancyMetrics,
build_command_with_isolation, create_args_and_out_file, get_reserved_variables,
read_result, start_child_process, OccupancyMetrics,
},
handle_child::{self},
universal_pkg_installer::{par_install_language_dependencies_seq, RequiredDependency},
DISABLE_NSJAIL, DISABLE_NUSER, NSJAIL_PATH, PATH_ENV, PROXY_ENVS, RUBY_CACHE_DIR, RUBY_REPOS,
DISABLE_NSJAIL, DISABLE_NUSER, NSJAIL_PATH, PATH_ENV, PROXY_ENVS,
RUBY_CACHE_DIR, RUBY_REPOS,
};
lazy_static::lazy_static! {
static ref RUBY_CONCURRENT_DOWNLOADS: usize = std::env::var("RUBY_CONCURRENT_DOWNLOADS").ok().map(|flag| flag.parse().unwrap_or(20)).unwrap_or(20);
@@ -140,7 +141,7 @@ pub async fn prepare<'a>(
File::create(format!("{}/inline.rb", &mini_wm_path))
.await?
.write_all(&wrap(
r#"
r#"
class GemfileProxy
def initialize
@gem_calls = []
@@ -213,7 +214,7 @@ end
.await?
.write_all(
&wrap(
r##"
r##"
require 'net/http'
require 'uri'
require 'json'
@@ -223,17 +224,17 @@ def get_variable(path)
base_url = ENV['BASE_INTERNAL_URL']
workspace = ENV['WM_WORKSPACE']
token = ENV['WM_TOKEN']
uri = URI("#{base_url}/api/w/#{workspace}/variables/get_value/#{path}")
http = Net::HTTP.new(uri.host, uri.port)
http.use_ssl = uri.scheme == 'https'
request = Net::HTTP::Get.new(uri)
request['Authorization'] = "Bearer #{token}"
response = http.request(request)
if response.code == '200'
JSON.parse(response.body)
else
@@ -245,17 +246,17 @@ def get_resource(path)
base_url = ENV['BASE_INTERNAL_URL']
workspace = ENV['WM_WORKSPACE']
token = ENV['WM_TOKEN']
uri = URI("#{base_url}/api/w/#{workspace}/resources/get_value_interpolated/#{path}")
http = Net::HTTP.new(uri.host, uri.port)
http.use_ssl = uri.scheme == 'https'
request = Net::HTTP::Get.new(uri)
request['Authorization'] = "Bearer #{token}"
response = http.request(request)
if response.code == '200'
JSON.parse(response.body)
else
@@ -823,11 +824,14 @@ mount {{
)
.await;
let mut cmd = Command::new(if cfg!(windows) {
let ruby_executable = if cfg!(windows) {
"ruby.exe"
} else {
RUBY_PATH.as_str()
});
};
let args = vec!["main.rb"];
let mut cmd = build_command_with_isolation(ruby_executable, &args);
#[cfg(windows)]
let rubylib = rubylib.replace(":", ";");
@@ -842,8 +846,7 @@ mount {{
.envs(PROXY_ENVS.clone())
.envs(envs);
cmd.args(&["main.rb"])
.stdin(Stdio::null())
cmd.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
@@ -856,7 +859,7 @@ mount {{
std::env::var("TMP").unwrap_or_else(|_| String::from("/tmp")),
);
}
start_child_process(cmd, "ruby", false).await?
start_child_process(cmd, ruby_executable, false).await?
};
handle_child::handle_child(
&job.id,
+2 -2
View File
@@ -19,7 +19,7 @@ use windmill_queue::{append_logs, CanceledBy};
use crate::{
common::{
check_executor_binary_exists, create_args_and_out_file, get_reserved_variables,
build_command_with_isolation, check_executor_binary_exists, create_args_and_out_file, get_reserved_variables,
read_result, start_child_process, OccupancyMetrics,
},
handle_child::handle_child,
@@ -567,7 +567,7 @@ pub async fn handle_rust_job(
start_child_process(nsjail_cmd, NSJAIL_PATH.as_str(), false).await?
} else {
let compiled_executable_name = "./main";
let mut run_rust = Command::new(compiled_executable_name);
let mut run_rust = build_command_with_isolation(compiled_executable_name, &[]);
run_rust
.current_dir(job_dir)
.env_clear()
+135
View File
@@ -303,6 +303,128 @@ lazy_static::lazy_static! {
.and_then(|x| x.parse::<bool>().ok())
.unwrap_or(true);
pub static ref ENABLE_UNSHARE_PID: bool = std::env::var("ENABLE_UNSHARE_PID")
.ok()
.and_then(|x| x.parse::<bool>().ok())
.unwrap_or(false);
pub static ref UNSHARE_ISOLATION_FLAGS: String = {
std::env::var("UNSHARE_ISOLATION_FLAGS")
.unwrap_or_else(|_| "--user --map-root-user --pid --fork --mount-proc".to_string())
};
pub static ref UNSHARE_PATH: Option<String> = {
let flags = UNSHARE_ISOLATION_FLAGS.as_str();
let mut test_cmd_args: Vec<&str> = flags.split_whitespace().collect();
test_cmd_args.push("--");
test_cmd_args.push("true");
let test_result = std::process::Command::new("unshare")
.args(&test_cmd_args)
.output();
match test_result {
Ok(output) if output.status.success() => {
tracing::info!("PID namespace isolation enabled. Flags: {}", flags);
Some("unshare".to_string())
},
Ok(output) => {
let stderr = String::from_utf8_lossy(&output.stderr);
if *ENABLE_UNSHARE_PID {
panic!(
"ENABLE_UNSHARE_PID is set but unshare test failed.\n\
Error: {}\n\
Flags: {}\n\
\n\
Solutions:\n\
• Check if user namespaces are enabled: 'sysctl kernel.unprivileged_userns_clone'\n\
• For Docker: Requires 'privileged: true' in docker-compose for --mount-proc flag\n\
• For Kubernetes: Requires 'privileged: true' in securityContext for --mount-proc flag\n\
• Try different flags via UNSHARE_ISOLATION_FLAGS env var (remove --mount-proc if privileged mode not possible)\n\
• Alternative: Use NSJAIL instead\n\
• Disable: Set ENABLE_UNSHARE_PID=false",
stderr.trim(),
flags
);
}
tracing::warn!(
"unshare test failed: {}. Flags: {}. Set ENABLE_UNSHARE_PID=true to fail on error.",
stderr.trim(),
flags
);
None
},
Err(e) => {
if *ENABLE_UNSHARE_PID {
if e.kind() == std::io::ErrorKind::NotFound {
panic!(
"ENABLE_UNSHARE_PID is set but unshare binary not found.\n\
Install util-linux package or set ENABLE_UNSHARE_PID=false"
);
} else {
panic!(
"ENABLE_UNSHARE_PID is set but failed to test unshare: {}",
e
);
}
}
if e.kind() == std::io::ErrorKind::NotFound {
tracing::debug!("unshare binary not found");
} else {
tracing::warn!("Failed to test unshare: {}", e);
}
None
}
}
};
pub static ref NSJAIL_AVAILABLE: Option<String> = {
if *DISABLE_NSJAIL {
None
} else {
let nsjail_path = NSJAIL_PATH.as_str();
let test_result = std::process::Command::new(nsjail_path)
.arg("--help")
.output();
match test_result {
Ok(output) if output.status.success() => {
tracing::info!("NSJAIL sandboxing available at: {}", nsjail_path);
Some(nsjail_path.to_string())
},
Ok(output) => {
let stderr = String::from_utf8_lossy(&output.stderr);
tracing::warn!(
"nsjail test failed: {}. Jobs will run without nsjail sandboxing. \
To enable nsjail: install nsjail binary or use windmill image with -nsjail suffix",
stderr.trim()
);
None
},
Err(e) => {
if e.kind() == std::io::ErrorKind::NotFound {
tracing::warn!(
"nsjail not found at '{}'. Jobs will run without nsjail sandboxing. \
To enable nsjail: install nsjail binary or use windmill image with -nsjail suffix",
nsjail_path
);
} else {
tracing::warn!(
"Failed to test nsjail at '{}': {}. Jobs will run without nsjail sandboxing.",
nsjail_path,
e
);
}
None
}
}
}
};
pub static ref KEEP_JOB_DIR: AtomicBool = AtomicBool::new(std::env::var("KEEP_JOB_DIR")
.ok()
.and_then(|x| x.parse::<bool>().ok())
@@ -981,6 +1103,19 @@ pub async fn run_worker(
);
}
// Force UNSHARE_PATH initialization now to fail-fast if unshare doesn't work
// This ensures we panic at startup rather than lazily when first accessed during job execution
if *ENABLE_UNSHARE_PID {
// Access UNSHARE_PATH to trigger lazy_static initialization and test
let _ = &*UNSHARE_PATH;
tracing::info!(
worker = %worker_name, hostname = %hostname,
"PID namespace isolation enabled via unshare with flags: {}",
UNSHARE_ISOLATION_FLAGS.as_str()
);
}
let start_time = Instant::now();
let worker_dir = format!("{TMP_DIR}/{worker_name}");
@@ -143,6 +143,7 @@ async fn update_worker_ping_full_inner(
memory: memory,
memory_usage: get_worker_memory_usage(),
wm_memory_usage: get_windmill_memory_usage(),
job_isolation: None,
ping_type: PingType::MainLoop,
},
)
@@ -171,6 +172,15 @@ pub async fn insert_ping(
let vcpus = get_vcpus();
let memory = get_memory();
// Determine job isolation method
let job_isolation = if crate::NSJAIL_AVAILABLE.is_some() {
Some("nsjail".to_string())
} else if *crate::ENABLE_UNSHARE_PID && crate::UNSHARE_PATH.is_some() {
Some("unshare".to_string())
} else {
Some("none".to_string())
};
match db {
Connection::Sql(db) => {
insert_ping_query(
@@ -183,6 +193,7 @@ pub async fn insert_ping(
windmill_common::utils::GIT_VERSION,
vcpus,
memory,
job_isolation,
db,
)
.await?;
@@ -209,6 +220,7 @@ pub async fn insert_ping(
memory: memory,
memory_usage: get_worker_memory_usage(),
wm_memory_usage: get_windmill_memory_usage(),
job_isolation,
ping_type: PingType::Initial,
},
)
@@ -231,6 +243,15 @@ pub async fn update_worker_ping_from_job(
let occupancy_rate_15s = occupancy.as_ref().and_then(|x| x.occupancy_rate_15s);
let occupancy_rate_5m = occupancy.as_ref().and_then(|x| x.occupancy_rate_5m);
let occupancy_rate_30m = occupancy.as_ref().and_then(|x| x.occupancy_rate_30m);
let job_isolation = if crate::NSJAIL_AVAILABLE.is_some() {
Some("nsjail".to_string())
} else if *crate::ENABLE_UNSHARE_PID && crate::UNSHARE_PATH.is_some() {
Some("unshare".to_string())
} else {
Some("none".to_string())
};
match conn.clone() {
Connection::Sql(ref db) => {
update_worker_ping_from_job_query(
@@ -243,6 +264,7 @@ pub async fn update_worker_ping_from_job(
occupancy_rate_15s,
occupancy_rate_5m,
occupancy_rate_30m,
job_isolation,
db,
)
.await?;
@@ -270,6 +292,7 @@ pub async fn update_worker_ping_from_job(
occupancy_rate_15s: occupancy_rate_15s,
occupancy_rate_5m: occupancy_rate_5m,
occupancy_rate_30m: occupancy_rate_30m,
job_isolation,
},
)
.await?;
+18
View File
@@ -60,10 +60,16 @@ services:
memory: 2048M
# for GB, use syntax '2Gi'
restart: unless-stopped
# Uncomment to enable PID namespace isolation (recommended for security)
# Requires privileged mode for --mount-proc flag
# See: https://www.windmill.dev/docs/advanced/security_isolation
# privileged: true
environment:
- DATABASE_URL=${DATABASE_URL}
- MODE=worker
- WORKER_GROUP=default
# Uncomment to enable PID namespace isolation (requires privileged: true above)
# - ENABLE_UNSHARE_PID=true
depends_on:
db:
condition: service_healthy
@@ -89,12 +95,18 @@ services:
memory: 2048M
# for GB, use syntax '2Gi'
restart: unless-stopped
# Uncomment to enable PID namespace isolation (recommended for security)
# Requires privileged mode for --mount-proc flag
# See: https://www.windmill.dev/docs/advanced/security_isolation
# privileged: true
environment:
- DATABASE_URL=${DATABASE_URL}
- MODE=worker
- WORKER_GROUP=native
- NUM_WORKERS=8
- SLEEP_QUEUE=200
# Uncomment to enable PID namespace isolation (requires privileged: true above)
# - ENABLE_UNSHARE_PID=true
depends_on:
db:
condition: service_healthy
@@ -113,10 +125,16 @@ services:
# memory: 2048M
# # for GB, use syntax '2Gi'
# restart: unless-stopped
# # Uncomment to enable PID namespace isolation (recommended for security)
# # Requires privileged mode for --mount-proc flag
# # See: https://www.windmill.dev/docs/advanced/security_isolation
# # privileged: true
# environment:
# - DATABASE_URL=${DATABASE_URL}
# - MODE=worker
# - WORKER_GROUP=reports
# # Uncomment to enable PID namespace isolation (requires privileged: true above)
# # - ENABLE_UNSHARE_PID=true
# depends_on:
# db:
# condition: service_healthy
+137 -76
View File
@@ -1,6 +1,8 @@
<script lang="ts">
import { Copy, Plus, RefreshCcwIcon, Settings, Trash, X } from 'lucide-svelte'
import { Alert, Badge, Button, Drawer } from './common'
import { AlertTriangle, Copy, Plus, RefreshCcwIcon, Settings, Trash, X } from 'lucide-svelte'
import { Alert, Button, Drawer } from './common'
import Badge from './common/badge/Badge.svelte'
import Popover from './meltComponents/Popover.svelte'
import ToggleButton from './common/toggleButton-v2/ToggleButton.svelte'
import ToggleButtonGroup from './common/toggleButton-v2/ToggleButtonGroup.svelte'
import { ConfigService, WorkspaceService, type WorkerPing, type Workspace } from '$lib/gen'
@@ -40,6 +42,16 @@
return { vcpus, memory }
}
function hasWorkersWithoutIsolation(workers: [string, WorkerPing[]][]): boolean {
return workers.some(([_, pings]) => pings.some((w) => !w.job_isolation || w.job_isolation === 'none'))
}
function getWorkersWithoutIsolation(workers: [string, WorkerPing[]][]): WorkerPing[] {
return workers.flatMap(([_, pings]) =>
pings.filter((w) => !w.job_isolation || w.job_isolation === 'none')
)
}
let nconfig: {
dedicated_worker?: string
worker_tags?: string[]
@@ -896,90 +908,139 @@
</DrawerContent>
</Drawer>
<div class=" flex items-center justify-between pt-1">
<div class="text-xs"
>{pluralize(activeWorkers, 'worker')}
{#if vcpus_memory?.vcpus}
- {(vcpus_memory?.vcpus / 100000).toFixed(2)} vCPUs{/if}
{#if vcpus_memory?.memory}
- {((vcpus_memory?.memory * 1.0) / 1024 / 1024 / 1024).toFixed(2)} GB{/if}</div
>
<div class="flex gap-2 items-center justify-end flex-row my-2">
{#if $superadmin}
<Button
variant="subtle"
unifiedSize="md"
on:click={() => {
dirty = false
loadNConfig()
drawer?.openDrawer()
}}
startIcon={{ icon: config == undefined ? Plus : Settings }}
<div class="flex flex-col gap-2">
{#if hasWorkersWithoutIsolation(workers)}
{@const unsafeWorkers = getWorkersWithoutIsolation(workers)}
<div class="flex justify-end">
<Popover
placement="bottom"
closeButton
containerClasses="border rounded-lg shadow-lg bg-surface"
>
<div class="flex flex-row gap-1 items-center">
{config == undefined ? 'Create' : 'Edit'} config
</div>
</Button>
{#snippet trigger()}
<Button
nonCaptureEvent
startIcon={{ icon: AlertTriangle, classes: 'text-yellow-600' }}
unifiedSize="md"
variant="subtle"
btnClasses="text-3xs"
>
Workers without isolation
</Button>
{/snippet}
{#snippet content()}
<div class="flex flex-col gap-2 text-sm max-w-md p-4">
<div class="font-semibold">Workers without job isolation</div>
<p class="text-secondary">
{unsafeWorkers.length}
{unsafeWorkers.length === 1 ? 'worker' : 'workers'} in this group
{unsafeWorkers.length === 1 ? 'is' : 'are'} running without job isolation (nsjail/unshare).
</p>
<div class="flex flex-wrap gap-1">
{#each unsafeWorkers as worker}
<Badge color="orange" verySmall={true}>
{worker.worker}
</Badge>
{/each}
</div>
<a
href="https://www.windmill.dev/docs/advanced/security_isolation"
target="_blank"
class="text-blue-600 hover:underline text-xs"
>
Learn more about job isolation →
</a>
</div>
{/snippet}
</Popover>
</div>
{/if}
<Button
unifiedSize="md"
variant="subtle"
on:click={() => {
navigator.clipboard.writeText(
YAML.stringify({
name,
...config
})
)
sendUserToast('Worker config copied to clipboard as YAML')
}}
startIcon={{ icon: Copy }}
>
Copy config
</Button>
<div class="flex items-center justify-between">
<div class="text-xs"
>{pluralize(activeWorkers, 'worker')}
{#if vcpus_memory?.vcpus}
- {(vcpus_memory?.vcpus / 100000).toFixed(2)} vCPUs{/if}
{#if vcpus_memory?.memory}
- {((vcpus_memory?.memory * 1.0) / 1024 / 1024 / 1024).toFixed(2)} GB{/if}</div
>
<div class="flex gap-2 items-center justify-end flex-row">
{#if $superadmin}
<Button
variant="subtle"
unifiedSize="md"
on:click={() => {
dirty = false
loadNConfig()
drawer?.openDrawer()
}}
startIcon={{ icon: config == undefined ? Plus : Settings }}
>
<div class="flex flex-row gap-1 items-center">
{config == undefined ? 'Create' : 'Edit'} config
</div>
</Button>
{#if config}
<Button
unifiedSize="md"
variant="subtle"
on:click={() => {
if (!$enterpriseLicense) {
sendUserToast('Worker Management UI is an EE feature', true)
} else {
openDelete = true
}
navigator.clipboard.writeText(
YAML.stringify({
name,
...config
})
)
sendUserToast('Worker config copied to clipboard as YAML')
}}
startIcon={{ icon: Trash }}
btnClasses="text-red-400"
startIcon={{ icon: Copy }}
>
Delete config
Copy config
</Button>
{#if config}
<Button
unifiedSize="md"
variant="subtle"
on:click={() => {
if (!$enterpriseLicense) {
sendUserToast('Worker Management UI is an EE feature', true)
} else {
openDelete = true
}
}}
startIcon={{ icon: Trash }}
btnClasses="text-red-400"
>
Delete config
</Button>
{/if}
<Button
unifiedSize="md"
variant="subtle"
on:click={() => {
loadNConfig()
openClean = true
}}
btnClasses="text-red-400"
startIcon={{ icon: RefreshCcwIcon }}
>
Clean cache
</Button>
{:else if config}
<Button
unifiedSize="md"
variant="accent"
on:click={() => {
loadNConfig()
drawer?.openDrawer()
}}
>
Config
</Button>
{/if}
<Button
unifiedSize="md"
variant="subtle"
on:click={() => {
loadNConfig()
openClean = true
}}
btnClasses="text-red-400"
startIcon={{ icon: RefreshCcwIcon }}
>
Clean cache
</Button>
{:else if config}
<Button
unifiedSize="md"
variant="accent"
on:click={() => {
loadNConfig()
drawer?.openDrawer()
}}
>
Config
</Button>
{/if}
</div>
</div>
</div>
@@ -2,8 +2,8 @@
import AssignableTags from '$lib/components/AssignableTags.svelte'
import CenteredPage from '$lib/components/CenteredPage.svelte'
import { Alert, Button, Skeleton, Tab, Tabs } from '$lib/components/common'
import Popover from '$lib/components/meltComponents/Popover.svelte'
import Badge from '$lib/components/common/badge/Badge.svelte'
import Popover from '$lib/components/meltComponents/Popover.svelte'
import Drawer from '$lib/components/common/drawer/Drawer.svelte'
import DrawerContent from '$lib/components/common/drawer/DrawerContent.svelte'
import DefaultTags from '$lib/components/DefaultTags.svelte'