diff --git a/backend/.sqlx/query-043cd99b3da09bca6cd2cf1e0adcd674de140b7f93897286447279e74777e99e.json b/backend/.sqlx/query-043cd99b3da09bca6cd2cf1e0adcd674de140b7f93897286447279e74777e99e.json
new file mode 100644
index 0000000000..7f14dee4e2
--- /dev/null
+++ b/backend/.sqlx/query-043cd99b3da09bca6cd2cf1e0adcd674de140b7f93897286447279e74777e99e.json
@@ -0,0 +1,28 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "\n WITH RECURSIVE chain AS (\n SELECT id, parent_workspace_id, is_dev_workspace, 0 AS depth\n FROM workspace WHERE id = $1\n UNION ALL\n SELECT w.id, w.parent_workspace_id, w.is_dev_workspace, chain.depth + 1\n FROM workspace w\n JOIN chain ON w.id = chain.parent_workspace_id\n WHERE chain.depth < 20\n )\n SELECT\n (SELECT id FROM chain\n WHERE parent_workspace_id IS NULL\n OR (is_dev_workspace AND id NOT LIKE 'wm-fork-%')\n ORDER BY depth LIMIT 1) AS id,\n (SELECT parent_workspace_id IS NOT NULL FROM chain WHERE depth = 0) AS has_parent\n ",
+ "describe": {
+ "columns": [
+ {
+ "ordinal": 0,
+ "name": "id",
+ "type_info": "Varchar"
+ },
+ {
+ "ordinal": 1,
+ "name": "has_parent",
+ "type_info": "Bool"
+ }
+ ],
+ "parameters": {
+ "Left": [
+ "Text"
+ ]
+ },
+ "nullable": [
+ null,
+ null
+ ]
+ },
+ "hash": "043cd99b3da09bca6cd2cf1e0adcd674de140b7f93897286447279e74777e99e"
+}
diff --git a/backend/.sqlx/query-0fb0fea54a1580d4b7ffd37ca43e169f51dbbdc3584d3522f25e97e180e307fd.json b/backend/.sqlx/query-0fb0fea54a1580d4b7ffd37ca43e169f51dbbdc3584d3522f25e97e180e307fd.json
new file mode 100644
index 0000000000..2afefc7ab8
--- /dev/null
+++ b/backend/.sqlx/query-0fb0fea54a1580d4b7ffd37ca43e169f51dbbdc3584d3522f25e97e180e307fd.json
@@ -0,0 +1,20 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "SELECT config FROM config WHERE name LIKE 'worker\\_\\_%'",
+ "describe": {
+ "columns": [
+ {
+ "ordinal": 0,
+ "name": "config",
+ "type_info": "Jsonb"
+ }
+ ],
+ "parameters": {
+ "Left": []
+ },
+ "nullable": [
+ true
+ ]
+ },
+ "hash": "0fb0fea54a1580d4b7ffd37ca43e169f51dbbdc3584d3522f25e97e180e307fd"
+}
diff --git a/backend/.sqlx/query-2d22fe27288580cb727e43b2abe3be2e6e6e3b03e61ce8f2879627115224c8cf.json b/backend/.sqlx/query-2d22fe27288580cb727e43b2abe3be2e6e6e3b03e61ce8f2879627115224c8cf.json
new file mode 100644
index 0000000000..95fd9a3a2f
--- /dev/null
+++ b/backend/.sqlx/query-2d22fe27288580cb727e43b2abe3be2e6e6e3b03e61ce8f2879627115224c8cf.json
@@ -0,0 +1,22 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "SELECT config FROM config WHERE name = $1 FOR UPDATE",
+ "describe": {
+ "columns": [
+ {
+ "ordinal": 0,
+ "name": "config",
+ "type_info": "Jsonb"
+ }
+ ],
+ "parameters": {
+ "Left": [
+ "Text"
+ ]
+ },
+ "nullable": [
+ true
+ ]
+ },
+ "hash": "2d22fe27288580cb727e43b2abe3be2e6e6e3b03e61ce8f2879627115224c8cf"
+}
diff --git a/backend/Cargo.lock b/backend/Cargo.lock
index f2571ecd5c..15b4e190fe 100644
--- a/backend/Cargo.lock
+++ b/backend/Cargo.lock
@@ -16229,6 +16229,7 @@ dependencies = [
"uuid",
"windmill-api-auth",
"windmill-common",
+ "windmill-queue",
]
[[package]]
diff --git a/backend/ee-repo-ref.txt b/backend/ee-repo-ref.txt
index b40437560a..b68bd04e25 100644
--- a/backend/ee-repo-ref.txt
+++ b/backend/ee-repo-ref.txt
@@ -1 +1 @@
-664708fff04d4f9e0660bcae3edb307fb2c9481b
+71e9c0de8317e839f955ebef6a819062f20ac418
diff --git a/backend/src/main.rs b/backend/src/main.rs
index fd953213a4..d5c51de819 100644
--- a/backend/src/main.rs
+++ b/backend/src/main.rs
@@ -44,7 +44,8 @@ use windmill_common::{
CRITICAL_ALERTS_ON_TOKEN_EXPIRY_SETTING, CRITICAL_ALERT_MUTE_UI_SETTING,
CRITICAL_ALERT_MUTE_ZOMBIE_JOB_RESTART_SETTING, CRITICAL_ERROR_CHANNELS_SETTING,
CUSTOM_TAGS_SETTING, DEFAULT_TAGS_PER_WORKSPACE_SETTING, DEFAULT_TAGS_WORKSPACES_SETTING,
- DEPENDENCY_JOB_TAG_SETTING, DISABLE_PASSWORD_LOGIN_SETTING, EMAIL_DOMAIN_SETTING,
+ DEPENDENCY_JOB_TAG_SETTING, DEV_WORKSPACE_ISOLATION_SETTING,
+ DEV_WORKSPACE_TAG_SUFFIX_SETTING, DISABLE_PASSWORD_LOGIN_SETTING, EMAIL_DOMAIN_SETTING,
ENV_SETTINGS, EXPOSE_DEBUG_METRICS_SETTING, EXPOSE_METRICS_SETTING,
EXTRA_PIP_INDEX_URL_SETTING, FORK_WORKSPACE_TAG_APPEND_FORK_SUFFIX_SETTING,
HTTP_ROUTE_DEFAULT_ALLOWED_ORIGINS_SETTING, HTTP_ROUTE_WORKSPACED_ROUTE_SETTING,
@@ -129,7 +130,8 @@ use windmill_worker::{
use crate::monitor::{
initial_load, load_concurrency_key_max_queued, load_dependency_job_tag,
- load_disable_password_login, load_fork_workspace_tag_append_fork_suffix, load_keep_job_dir,
+ load_dev_workspace_isolation, load_dev_workspace_tag_suffix, load_disable_password_login,
+ load_fork_workspace_tag_append_fork_suffix, load_keep_job_dir,
load_mcp_disable_token_query_param, load_metrics_debug_enabled, load_preview_tags_override,
load_require_preexisting_user, load_retention_period_overrides, load_tag_per_workspace_enabled,
load_tag_per_workspace_workspaces, load_workspace_fairness_duration_secs,
@@ -1052,12 +1054,8 @@ Windmill Community Edition {GIT_VERSION}
// Before the first load: it can sign a token for instance object storage.
#[cfg(all(feature = "enterprise", feature = "openidconnect", feature = "private"))]
- windmill_common::oidc_ee::init_oidc_signing(
- &conn,
- server_mode,
- killpill_rx.resubscribe(),
- )
- .await?;
+ windmill_common::oidc_ee::init_oidc_signing(&conn, server_mode, killpill_rx.resubscribe())
+ .await?;
initial_load(
&conn,
@@ -1881,6 +1879,16 @@ async fn process_notify_event(
tracing::error!("Error loading dependency job tag: {e:#}");
}
}
+ DEV_WORKSPACE_ISOLATION_SETTING => {
+ if let Err(e) = load_dev_workspace_isolation(db).await {
+ tracing::error!("Error loading dev workspace isolation: {e:#}");
+ }
+ }
+ DEV_WORKSPACE_TAG_SUFFIX_SETTING => match load_dev_workspace_tag_suffix(db).await {
+ Ok(true) if worker_mode => reload_worker_config(db, tx.clone(), true).await,
+ Ok(_) => (),
+ Err(e) => tracing::error!("Error loading dev workspace tag suffix: {e:#}"),
+ },
WORKSPACE_FAIRNESS_ENABLED_SETTING => {
if let Err(e) = load_workspace_fairness_enabled(db).await {
tracing::error!("Error loading workspace fairness enabled: {e:#}");
diff --git a/backend/src/monitor.rs b/backend/src/monitor.rs
index 4fae6f34ab..03728c1d7b 100644
--- a/backend/src/monitor.rs
+++ b/backend/src/monitor.rs
@@ -60,7 +60,8 @@ use windmill_common::{
CRITICAL_ALERTS_ON_DB_OVERSIZE_SETTING, CRITICAL_ALERTS_ON_TOKEN_EXPIRY_SETTING,
CRITICAL_ALERT_MUTE_UI_SETTING, CRITICAL_ALERT_MUTE_ZOMBIE_JOB_RESTART_SETTING,
CRITICAL_ERROR_CHANNELS_SETTING, CUSTOM_TAGS_SETTING, DEFAULT_TAGS_PER_WORKSPACE_SETTING,
- DEFAULT_TAGS_WORKSPACES_SETTING, DEPENDENCY_JOB_TAG_SETTING, DISABLE_PASSWORD_LOGIN,
+ DEFAULT_TAGS_WORKSPACES_SETTING, DEPENDENCY_JOB_TAG_SETTING,
+ DEV_WORKSPACE_ISOLATION_SETTING, DEV_WORKSPACE_TAG_SUFFIX_SETTING, DISABLE_PASSWORD_LOGIN,
DISABLE_PASSWORD_LOGIN_SETTING, EXPOSE_DEBUG_METRICS_SETTING, EXPOSE_METRICS_SETTING,
EXTRA_PIP_INDEX_URL_SETTING, FORK_WORKSPACE_TAG_APPEND_FORK_SUFFIX_SETTING,
HUB_API_SECRET_SETTING, HUB_BASE_URL_SETTING, INSTANCE_PYTHON_VERSION_SETTING,
@@ -89,11 +90,13 @@ use windmill_common::{
users::truncate_token,
utils::{empty_as_none, now_from_db, report_critical_error, Mode, HUB_API_SECRET},
worker::{
- load_env_vars, load_init_bash_from_env, load_periodic_bash_script_from_env,
- load_periodic_bash_script_interval_from_env, load_whitelist_env_vars_from_env,
- load_worker_config, store_pull_query, store_suspended_pull_query, Connection, WorkerConfig,
- CLOUD_HOSTED, CONCURRENCY_KEY_MAX_QUEUED, CONCURRENCY_KEY_MAX_QUEUED_DEFAULT,
- DEFAULT_TAGS_PER_WORKSPACE, DEFAULT_TAGS_WORKSPACES, DEPENDENCY_JOB_TAG,
+ is_valid_dev_workspace_tag_suffix, load_env_vars, load_init_bash_from_env,
+ load_periodic_bash_script_from_env, load_periodic_bash_script_interval_from_env,
+ load_whitelist_env_vars_from_env, load_worker_config, store_pull_query,
+ store_suspended_pull_query, Connection, WorkerConfig, CLOUD_HOSTED,
+ CONCURRENCY_KEY_MAX_QUEUED, CONCURRENCY_KEY_MAX_QUEUED_DEFAULT,
+ DEFAULT_DEV_WORKSPACE_TAG_SUFFIX, DEFAULT_TAGS_PER_WORKSPACE, DEFAULT_TAGS_WORKSPACES,
+ DEPENDENCY_JOB_TAG, DEV_WORKSPACE_ISOLATION, DEV_WORKSPACE_TAG_SUFFIX,
FORK_WORKSPACE_TAG_APPEND_FORK_SUFFIX, INDEXER_CONFIG, PREVIEW_TAGS_OVERRIDE, SMTP_CONFIG,
WINDMILL_DIR, WORKER_CONFIG, WORKER_GROUP, WORKSPACE_FAIRNESS_DURATION_SECS,
WORKSPACE_FAIRNESS_ENABLED, WORKSPACE_FAIRNESS_MAX_PERCENT, WORKSPACE_FAIRNESS_MIN_TOTAL,
@@ -321,6 +324,13 @@ pub async fn initial_load(
pass.setting(DEPENDENCY_JOB_TAG_SETTING, false, |v| async move {
apply_dependency_job_tag(v)
});
+ // Ahead of the worker config reload below, which expands the tags with it.
+ pass.setting(DEV_WORKSPACE_TAG_SUFFIX_SETTING, false, |v| async move {
+ apply_dev_workspace_tag_suffix(v);
+ });
+ pass.setting(DEV_WORKSPACE_ISOLATION_SETTING, false, |v| async move {
+ apply_dev_workspace_isolation(v)
+ });
// Load per-workspace retention overrides before the first cleanup tick so a fresh server
// never sweeps globally without honoring configured longer-retention workspaces.
@@ -1127,6 +1137,39 @@ pub fn apply_fork_workspace_tag_append_fork_suffix(value: Option error::Result {
+ let v = load_value_from_global_settings(db, DEV_WORKSPACE_TAG_SUFFIX_SETTING).await?;
+ Ok(apply_dev_workspace_tag_suffix(v))
+}
+
+pub fn apply_dev_workspace_tag_suffix(value: Option) -> bool {
+ let suffix = match value {
+ Some(serde_json::Value::String(t)) if !t.trim().is_empty() => t.trim().to_string(),
+ _ => DEFAULT_DEV_WORKSPACE_TAG_SUFFIX.to_string(),
+ };
+ if !is_valid_dev_workspace_tag_suffix(&suffix) {
+ tracing::error!("Ignoring invalid dev workspace tag suffix {suffix:?}");
+ return false;
+ }
+ let prev = DEV_WORKSPACE_TAG_SUFFIX.swap(std::sync::Arc::new(suffix.clone()));
+ *prev != suffix
+}
+
+pub async fn load_dev_workspace_isolation(db: &DB) -> error::Result<()> {
+ let v = load_value_from_global_settings(db, DEV_WORKSPACE_ISOLATION_SETTING).await?;
+ apply_dev_workspace_isolation(v);
+ Ok(())
+}
+
+pub fn apply_dev_workspace_isolation(value: Option) {
+ DEV_WORKSPACE_ISOLATION.store(
+ matches!(value, Some(serde_json::Value::Bool(true))),
+ Ordering::Relaxed,
+ );
+}
+
pub async fn reload_critical_alert_mute_ui_setting(conn: &Connection) -> error::Result<()> {
let v = load_value_from_global_settings_with_conn(conn, CRITICAL_ALERT_MUTE_UI_SETTING, true)
.await?;
diff --git a/backend/tests/dev_workspace_tag_suffix.rs b/backend/tests/dev_workspace_tag_suffix.rs
new file mode 100644
index 0000000000..0969a0e45b
--- /dev/null
+++ b/backend/tests/dev_workspace_tag_suffix.rs
@@ -0,0 +1,177 @@
+//! Sets the process-wide `DEV_WORKSPACE_ISOLATION`, so it lives in its own test binary: any other
+//! test pushing a job in the same process would land on a suffixed tag.
+
+use std::collections::HashMap;
+
+use sqlx::{Pool, Postgres};
+use windmill_common::{
+ jobs::{JobPayload, RawCode},
+ runnable_settings::{ConcurrencySettings, DebouncingSettings},
+ scripts::{ScriptHash, ScriptLang},
+ worker::DEV_WORKSPACE_ISOLATION,
+};
+use windmill_queue::{PushArgs, PushIsolationLevel};
+
+async fn pushed_tag(
+ db: &Pool,
+ workspace_id: &str,
+ payload: JobPayload,
+ tag: Option<&str>,
+) -> String {
+ try_pushed_tag(db, workspace_id, payload, tag)
+ .await
+ .expect("push must succeed")
+}
+
+async fn try_pushed_tag(
+ db: &Pool,
+ workspace_id: &str,
+ payload: JobPayload,
+ tag: Option<&str>,
+) -> Result {
+ let args = HashMap::new();
+ let (id, tx) = windmill_queue::push(
+ db,
+ PushIsolationLevel::IsolatedRoot(db.clone()),
+ workspace_id,
+ payload,
+ PushArgs::from(&args),
+ "test-user",
+ "test@windmill.dev",
+ "u/test-user".to_string(),
+ None,
+ None,
+ None,
+ None,
+ None,
+ None,
+ None,
+ None,
+ false,
+ false,
+ None,
+ true,
+ tag.map(str::to_string),
+ None,
+ None,
+ None,
+ None,
+ false,
+ None,
+ None,
+ None,
+ None,
+ )
+ .await?;
+ tx.commit().await.unwrap();
+ Ok(sqlx::query_scalar("SELECT tag FROM v2_job WHERE id = $1")
+ .bind(id)
+ .fetch_one(db)
+ .await
+ .unwrap())
+}
+
+fn preview() -> JobPayload {
+ JobPayload::Code(RawCode {
+ hash: None,
+ content: "echo hi".to_string(),
+ path: None,
+ language: ScriptLang::Bash,
+ lock: None,
+ concurrency_settings: ConcurrencySettings::default().into(),
+ debouncing_settings: DebouncingSettings::default(),
+ cache_ttl: None,
+ cache_ignore_s3_path: None,
+ dedicated_worker: None,
+ modules: None,
+ tag: None,
+ })
+}
+
+#[sqlx::test(fixtures("base"))]
+async fn dev_workspace_and_fork_jobs_run_on_suffixed_tags(db: Pool) {
+ DEV_WORKSPACE_ISOLATION.store(true, std::sync::atomic::Ordering::Relaxed);
+
+ // A dev workspace, a fork of it, and a fork whose parent was deleted (`ON DELETE SET NULL`).
+ sqlx::query(
+ "INSERT INTO workspace (id, name, owner, parent_workspace_id, is_dev_workspace) VALUES
+ ('staging', 'staging', 'test-user', 'test-workspace', true),
+ ('wm-fork-a', 'fork', 'test-user', 'staging', false),
+ ('wm-fork-orphan', 'orphan', 'test-user', NULL, false)",
+ )
+ .execute(&db)
+ .await
+ .unwrap();
+ sqlx::query(
+ "INSERT INTO workspace_settings (workspace_id)
+ VALUES ('staging'), ('wm-fork-a'), ('wm-fork-orphan')",
+ )
+ .execute(&db)
+ .await
+ .unwrap();
+
+ for (workspace, tag, expected) in [
+ ("test-workspace", None, "bash"),
+ ("test-workspace", Some("gpu"), "gpu"),
+ ("staging", None, "bash-dev"),
+ ("staging", Some("gpu"), "gpu-dev"),
+ // A tag inherited from a dev job (a flow step, an agent's tool) is not suffixed twice.
+ ("staging", Some("gpu-dev"), "gpu-dev"),
+ ("wm-fork-a", None, "bash-dev"),
+ ("wm-fork-orphan", None, "bash-dev"),
+ ] {
+ assert_eq!(
+ pushed_tag(&db, workspace, preview(), tag).await,
+ expected,
+ "{workspace} with tag {tag:?}"
+ );
+ }
+
+ // A dedicated worker tag already names the workspace, and its worker is placed by hand.
+ let dedicated = JobPayload::ScriptHash {
+ hash: ScriptHash(123412),
+ path: "f/system/hello".to_string(),
+ cache_ttl: None,
+ cache_ignore_s3_path: None,
+ dedicated_worker: Some(true),
+ language: ScriptLang::Bun,
+ priority: None,
+ apply_preprocessor: false,
+ concurrency_settings: ConcurrencySettings::default(),
+ debouncing_settings: DebouncingSettings::default(),
+ labels: None,
+ job_token_scopes: None,
+ };
+ assert_eq!(
+ pushed_tag(&db, "staging", dedicated, None).await,
+ "staging:f/system/hello"
+ );
+
+ // A hashed dedicated tag keeps only a prefix of its workspace id: a dev workspace sharing that
+ // prefix does not get to use another workspace's dedicated worker.
+ let root = "a".repeat(40);
+ let dev = format!("{root}-dev");
+ sqlx::query(
+ "INSERT INTO workspace (id, name, owner, parent_workspace_id, is_dev_workspace)
+ VALUES ($1, 'root', 'test-user', NULL, false), ($2, 'dev', 'test-user', $1, true)",
+ )
+ .bind(&root)
+ .bind(&dev)
+ .execute(&db)
+ .await
+ .unwrap();
+ sqlx::query("INSERT INTO workspace_settings (workspace_id) VALUES ($1), ($2)")
+ .bind(&root)
+ .bind(&dev)
+ .execute(&db)
+ .await
+ .unwrap();
+ let root_dedicated =
+ windmill_common::worker::dedicated_worker_tag(&root, "f/some/long/script/path");
+ assert!(
+ try_pushed_tag(&db, &dev, preview(), Some(&root_dedicated))
+ .await
+ .is_err(),
+ "{root_dedicated} must not stay unsuffixed, and suffixed it no longer fits a job tag"
+ );
+}
diff --git a/backend/tests/instance_config.rs b/backend/tests/instance_config.rs
index 24937a3ce5..18a7466637 100644
--- a/backend/tests/instance_config.rs
+++ b/backend/tests/instance_config.rs
@@ -1533,6 +1533,32 @@ async fn declarative_sync_rejects_an_unusable_instance_banner(db: Pool
);
}
+#[sqlx::test(fixtures("base"))]
+async fn declarative_sync_rejects_an_unusable_dev_workspace_tag_suffix(db: Pool) {
+ clear_settings_and_configs(&db).await;
+ let before = count_global_settings(&db).await;
+
+ let mut desired = BTreeMap::new();
+ desired.insert(
+ "dev_workspace_tag_suffix".to_string(),
+ serde_json::json!("-dev' OR '1'='1"),
+ );
+
+ let err = windmill_common::instance_config::sync_global_settings_declarative(
+ &db,
+ &BTreeMap::new(),
+ &desired,
+ &(),
+ )
+ .await
+ .expect_err("a suffix the workers would ignore must fail the sync");
+ assert!(
+ err.to_string().contains("dev_workspace_tag_suffix"),
+ "the error should name the offending setting, got: {err}"
+ );
+ assert_eq!(count_global_settings(&db).await, before);
+}
+
#[sqlx::test(fixtures("base"))]
async fn declarative_sync_rejects_a_malformed_max_token_expiration(db: Pool) {
clear_settings_and_configs(&db).await;
diff --git a/backend/windmill-api-auth/src/auth.rs b/backend/windmill-api-auth/src/auth.rs
index 720111e89d..cb799bec25 100644
--- a/backend/windmill-api-auth/src/auth.rs
+++ b/backend/windmill-api-auth/src/auth.rs
@@ -6,6 +6,7 @@ use axum::{
};
use chrono::TimeZone;
use http::{request::Parts, StatusCode};
+use itertools::Itertools;
use quick_cache::sync::Cache;
use serde::{Deserialize, Serialize};
use sqlx::FromRow;
@@ -1022,6 +1023,53 @@ where
}
}
+/// Adds the suffixed form of each tag of an `if_jobs:filter_tags:` scope: in a dev workspace a
+/// job allowed on `gpu` is queued on `gpu-dev`, and the token must still reach the jobs it starts.
+fn expand_filter_tags_with_dev_suffix(scopes: &mut [String], suffix: &str) {
+ for scope in scopes.iter_mut() {
+ if let Some(tags) = scope.strip_prefix("if_jobs:filter_tags:") {
+ let expanded = tags
+ .split(',')
+ .flat_map(|t| {
+ let suffixed = windmill_common::worker::with_dev_workspace_suffix(t, suffix);
+ [t.to_string(), suffixed]
+ })
+ .unique()
+ .join(",");
+ *scope = format!("if_jobs:filter_tags:{expanded}");
+ }
+ }
+}
+
+/// Only while isolation is on and for a dev workspace or a fork, whose jobs get the suffix then:
+/// otherwise the suffixed tags are other tags the token was not given.
+async fn expand_filter_tags_for_dev_workspace(
+ db: &DB,
+ workspace_id: &str,
+ scopes: Option<&mut Vec>,
+) {
+ let Some(scopes) = scopes else { return };
+ if !windmill_common::worker::DEV_WORKSPACE_ISOLATION.load(std::sync::atomic::Ordering::Relaxed)
+ || !scopes.iter().any(|s| s.starts_with("if_jobs:filter_tags:"))
+ {
+ return;
+ }
+ let is_dev = workspace_id.starts_with(windmill_common::workspaces::WM_FORK_PREFIX)
+ || sqlx::query_scalar::<_, bool>(
+ "SELECT parent_workspace_id IS NOT NULL FROM workspace WHERE id = $1",
+ )
+ .bind(workspace_id)
+ .fetch_optional(db)
+ .await
+ .ok()
+ .flatten()
+ .unwrap_or(false);
+ if is_dev {
+ let suffix = windmill_common::worker::DEV_WORKSPACE_TAG_SUFFIX.load();
+ expand_filter_tags_with_dev_suffix(scopes, &suffix);
+ }
+}
+
pub fn transform_old_scope_to_new_scope(scopes: Option<&mut Vec>) {
if let Some(scopes) = scopes {
for scope in scopes.iter_mut() {
@@ -1172,6 +1220,14 @@ pub async fn resolve_opt_job_authed(
let authed = &mut opt_job_authed.authed;
if authed.scopes.is_some() && !own_job_runtime_route {
transform_old_scope_to_new_scope(authed.scopes.as_mut());
+ if let Some(w_id) = workspace_id.as_deref() {
+ expand_filter_tags_for_dev_workspace(
+ &cache.db,
+ w_id,
+ authed.scopes.as_mut(),
+ )
+ .await;
+ }
if let Err(err) = crate::scopes::check_scopes_for_route(
authed.scopes.as_deref(),
@@ -1359,6 +1415,19 @@ pub async fn list_tokens_internal(
mod tests {
use super::*;
+ #[test]
+ fn filter_tags_reach_their_dev_workspace_form() {
+ let mut scopes = vec![
+ "jobs:read".to_string(),
+ "if_jobs:filter_tags:gpu,bash-dev".to_string(),
+ ];
+ expand_filter_tags_with_dev_suffix(&mut scopes, "-dev");
+ assert_eq!(
+ scopes,
+ vec!["jobs:read", "if_jobs:filter_tags:gpu,gpu-dev,bash-dev"]
+ );
+ }
+
/// Dropping the `AUTH_CACHE` entry between the two resolutions is what makes a memo
/// hit observable: only a working memo can answer the second one.
#[tokio::test]
diff --git a/backend/windmill-api-configs/src/lib.rs b/backend/windmill-api-configs/src/lib.rs
index f1f4792e71..890a561679 100644
--- a/backend/windmill-api-configs/src/lib.rs
+++ b/backend/windmill-api-configs/src/lib.rs
@@ -273,18 +273,22 @@ async fn update_config(
}
let mut tx = db.begin().await?;
- let was_paused = if name.starts_with("worker__") {
+ let previous = if name.starts_with("worker__") {
sqlx::query_scalar!(
- "SELECT COALESCE(config->'paused' = 'true'::jsonb, false) FROM config WHERE name = $1 FOR UPDATE",
+ "SELECT config FROM config WHERE name = $1 FOR UPDATE",
&name
)
.fetch_optional(&mut *tx)
.await?
.flatten()
- .unwrap_or(false)
} else {
- false
+ None
};
+ let is_set = |c: Option<&serde_json::Value>, k: &str| {
+ c.and_then(|c| c.get(k)).and_then(|v| v.as_bool()) == Some(true)
+ };
+ let was_paused = is_set(previous.as_ref(), "paused");
+ let was_serving_dev = is_set(previous.as_ref(), "serves_dev_workspaces");
sqlx::query!(
"INSERT INTO config (name, config) VALUES ($1, $2) ON CONFLICT (name) DO UPDATE SET config = EXCLUDED.config",
&name,
@@ -306,7 +310,7 @@ async fn update_config(
tx.commit().await?;
if name.starts_with("worker__") {
- let paused = config.get("paused").and_then(|v| v.as_bool()) == Some(true);
+ let paused = is_set(Some(&config), "paused");
if paused != was_paused {
windmill_common::feature_usage::log_feature_usage(
"worker_group",
@@ -314,6 +318,14 @@ async fn update_config(
if paused { "pause" } else { "resume" },
);
}
+ let serving_dev = is_set(Some(&config), "serves_dev_workspaces");
+ if serving_dev != was_serving_dev {
+ windmill_common::feature_usage::log_feature_usage(
+ "worker_group",
+ "dev_workspaces_toggle",
+ if serving_dev { "serve" } else { "stop" },
+ );
+ }
}
Ok(format!("Updated config {name}"))
}
diff --git a/backend/windmill-api-jobs/src/jobs_export.rs b/backend/windmill-api-jobs/src/jobs_export.rs
index b2b69367bc..576d33bad5 100644
--- a/backend/windmill-api-jobs/src/jobs_export.rs
+++ b/backend/windmill-api-jobs/src/jobs_export.rs
@@ -434,6 +434,7 @@ pub async fn import_completed_jobs(
pub async fn import_queued_jobs(
authed: ApiAuthed,
+ Extension(db): Extension,
Extension(user_db): Extension,
Path(w_id): Path,
Json(jobs): Json>,
@@ -468,7 +469,9 @@ pub async fn import_queued_jobs(
let mut tx = user_db.begin(&authed).await?;
- for job in jobs {
+ for mut job in jobs {
+ // A fork's admin is whoever created it, so an imported tag is as untrusted as a pushed one.
+ job.tag = windmill_queue::tags::apply_dev_workspace_suffix(job.tag, &w_id, &db).await?;
sqlx::query!(
r#"
INSERT INTO v2_job (
diff --git a/backend/windmill-api-settings/src/lib.rs b/backend/windmill-api-settings/src/lib.rs
index f9ba1ff86f..d6803dc432 100644
--- a/backend/windmill-api-settings/src/lib.rs
+++ b/backend/windmill-api-settings/src/lib.rs
@@ -916,6 +916,7 @@ pub async fn set_global_setting_internal(
run_setting_pre_write_hook(db, &key, &value).await?;
+ let turns_on = value.as_bool() == Some(true);
match value {
serde_json::Value::Null => {
if instance_config::PROTECTED_SETTINGS.contains(&key.as_str()) {
@@ -949,6 +950,14 @@ pub async fn set_global_setting_internal(
}
};
+ if key == windmill_common::global_settings::DEV_WORKSPACE_ISOLATION_SETTING {
+ windmill_common::feature_usage::log_feature_usage(
+ "dev_workspace_isolation",
+ "toggle",
+ if turns_on { "on" } else { "off" },
+ );
+ }
+
if should_bump_instance_ai_revision {
bump_instance_ai_config_revision();
}
@@ -975,6 +984,14 @@ async fn run_setting_pre_write_hook(
value: &serde_json::Value,
) -> error::Result<()> {
match key {
+ windmill_common::global_settings::DEV_WORKSPACE_TAG_SUFFIX_SETTING => match value {
+ serde_json::Value::Null => {}
+ serde_json::Value::String(s) if s.trim().is_empty() => {}
+ suffix => windmill_common::global_settings::validate_dev_workspace_tag_suffix(suffix)
+ .map_err(|e| {
+ error::Error::BadRequest(format!("The dev workspace tag suffix {e}"))
+ })?,
+ },
// The instance AI config is written as an untyped blob through this generic
// endpoint, so it never passes the typed check the workspace handler applies.
// Rates that reach a cost total unbounded would make it negative or infinite.
diff --git a/backend/windmill-api-workers/Cargo.toml b/backend/windmill-api-workers/Cargo.toml
index 8e54d84e77..24f2bc5649 100644
--- a/backend/windmill-api-workers/Cargo.toml
+++ b/backend/windmill-api-workers/Cargo.toml
@@ -11,6 +11,7 @@ path = "src/lib.rs"
[dependencies]
windmill-api-auth.workspace = true
windmill-common = { workspace = true, default-features = false }
+windmill-queue.workspace = true
axum.workspace = true
chrono.workspace = true
serde.workspace = true
diff --git a/backend/windmill-api-workers/src/lib.rs b/backend/windmill-api-workers/src/lib.rs
index 8ebee2a967..a7f488a7dd 100644
--- a/backend/windmill-api-workers/src/lib.rs
+++ b/backend/windmill-api-workers/src/lib.rs
@@ -21,7 +21,10 @@ use windmill_common::{
jobs::{HIDE_WORKERS_FOR_NON_ADMINS, TAGS_ARE_SENSITIVE},
queue_metrics::{read_queue_metrics_series, QueueMetricsSeries},
utils::{paginate, Pagination},
- worker::{ALL_TAGS, CUSTOM_TAGS_PER_WORKSPACE, DEFAULT_TAGS, DEFAULT_TAGS_PER_WORKSPACE},
+ worker::{
+ ALL_TAGS, CUSTOM_TAGS_PER_WORKSPACE, DEFAULT_TAGS, DEFAULT_TAGS_PER_WORKSPACE,
+ DEV_WORKSPACE_ISOLATION, DEV_WORKSPACE_TAG_SUFFIX,
+ },
workspaces::workspace_with_fork_ancestors,
DB,
};
@@ -37,6 +40,7 @@ pub fn global_service() -> Router {
"/is_default_tags_per_workspace",
get(get_default_tags_per_workspace),
)
+ .route("/dev_workspace_isolation", get(get_dev_workspace_isolation))
.route("/get_default_tags", get(get_default_tags))
.route("/queue_metrics", get(get_queue_metrics))
.route("/queue_metrics_series", get(get_queue_metrics_series))
@@ -186,7 +190,18 @@ async fn exists_workers_with_tags(
let chain = workspace_with_fork_ancestors(&db, workspace).await?;
let custom_tags = CUSTOM_TAGS_PER_WORKSPACE.load();
let allowed_tags = custom_tags.to_string_vec(Some(&chain));
- tags.retain(|t| allowed_tags.contains(t));
+ // A dev workspace's jobs run on the suffixed form of the tags it may use.
+ let dev_suffix = windmill_queue::tags::dev_workspace_suffix_for(workspace, &db)
+ .await
+ .ok()
+ .flatten();
+ tags.retain(|t| {
+ allowed_tags.contains(t)
+ || dev_suffix.as_deref().is_some_and(|s| {
+ t.strip_suffix(s)
+ .is_some_and(|b| allowed_tags.iter().any(|a| a == b))
+ })
+ });
} else {
// No workspace provided and not superadmin - return empty
return Ok(Json(std::collections::HashMap::new()));
@@ -259,6 +274,19 @@ async fn get_default_tags_per_workspace() -> JsonResult {
))
}
+#[derive(Serialize)]
+struct DevWorkspaceIsolation {
+ enabled: bool,
+ suffix: String,
+}
+
+async fn get_dev_workspace_isolation() -> JsonResult {
+ Ok(Json(DevWorkspaceIsolation {
+ enabled: DEV_WORKSPACE_ISOLATION.load(std::sync::atomic::Ordering::Relaxed),
+ suffix: DEV_WORKSPACE_TAG_SUFFIX.load().to_string(),
+ }))
+}
+
async fn get_default_tags() -> JsonResult> {
Ok(Json(DEFAULT_TAGS.clone()))
}
diff --git a/backend/windmill-api-workspaces/src/workspaces_extra.rs b/backend/windmill-api-workspaces/src/workspaces_extra.rs
index 67d31f8a77..dc40c72d13 100644
--- a/backend/windmill-api-workspaces/src/workspaces_extra.rs
+++ b/backend/windmill-api-workspaces/src/workspaces_extra.rs
@@ -868,11 +868,21 @@ pub(crate) async fn change_workspace_id(
"Re-pushing jobs for {} enabled schedules",
enabled_schedule_paths.len()
);
- for schedule_path in &enabled_schedule_paths {
- if let Some(schedule) = get_schedule_opt(&mut *tx, &rw.new_id, schedule_path).await? {
- tx = push_scheduled_job(&db, tx, &schedule, None, None).await?;
+ windmill_queue::tags::carry_lineage_over_rename(&old_id, &rw.new_id, &db).await?;
+ let new_id = &rw.new_id;
+ let repushed = async {
+ for schedule_path in &enabled_schedule_paths {
+ if let Some(schedule) = get_schedule_opt(&mut *tx, new_id, schedule_path).await? {
+ tx = push_scheduled_job(&db, tx, &schedule, None, None).await?;
+ }
}
+ Ok::<_, Error>(tx)
}
+ .await;
+ if repushed.is_err() {
+ windmill_queue::tags::invalidate_fork_parent_cache(new_id);
+ }
+ let mut tx = repushed?;
// After every workspace_settings write above: fork cleanup locks a settings row before the
// registry, so taking the registry first here would deadlock with it.
diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml
index 943d05dc11..1c7619e1f8 100644
--- a/backend/windmill-api/openapi.yaml
+++ b/backend/windmill-api/openapi.yaml
@@ -11413,6 +11413,30 @@ paths:
schema:
type: boolean
+ /workers/dev_workspace_isolation:
+ get:
+ summary: get whether the jobs of dev workspaces and forks run on their own worker groups
+ operationId: getDevWorkspaceIsolation
+ tags:
+ - worker
+ responses:
+ "200":
+ description: dev workspace isolation
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ enabled:
+ type: boolean
+ description: whether new jobs of dev workspaces and forks are queued on suffixed tags
+ suffix:
+ type: string
+ description: the suffix of those tags, which worker groups serving dev workspaces listen with
+ required:
+ - enabled
+ - suffix
+
/w/{workspace}/workspace_dependencies/create:
post:
summary: create workspace dependencies
diff --git a/backend/windmill-api/src/jobs.rs b/backend/windmill-api/src/jobs.rs
index c567a593d9..a7e2915571 100644
--- a/backend/windmill-api/src/jobs.rs
+++ b/backend/windmill-api/src/jobs.rs
@@ -9264,6 +9264,7 @@ async fn run_inline_preview_script(
return Err(operator_preview_refusal(job_id));
}
check_scopes(&authed, || format!("jobs:run"))?;
+ windmill_queue::tags::check_inline_run_allowed(&w_id, &db).await?;
if let Some(job_id) = job_id {
register_potential_assets_on_inline_execution(job_id, &w_id, &preview);
}
@@ -9383,6 +9384,7 @@ async fn run_inline_script_inner(
args: Option>>,
user_db: Option,
) -> error::Result {
+ windmill_queue::tags::check_inline_run_allowed(&w_id, &db).await?;
// An inline run executes with the caller's own token, so it cannot honour a script's
// `job_token_scopes`: such a script only runs as a job. A path is resolved here, once, and
// the version checked is the version run.
@@ -10183,17 +10185,22 @@ async fn add_batch_jobs(
let language = language.unwrap_or(ScriptLang::Deno);
- let tag = if let Some(dedicated_worker) = dedicated_worker {
+ let tag = match (dedicated_worker, batch_info.tag) {
// Same rule as `push`: a dedicated worker would run it with its own unscoped token.
- if dedicated_worker && path.is_some() && job_token_scopes.is_none() {
+ (Some(true), _) if path.is_some() && job_token_scopes.is_none() => {
windmill_common::worker::dedicated_worker_tag(&w_id, &path.clone().unwrap())
- } else {
- format!("{}", language.as_str())
}
- } else if let Some(tag) = batch_info.tag {
- tag
- } else {
- format!("{}", language.as_str())
+ (None, Some(tag)) => {
+ windmill_queue::tags::apply_dev_workspace_suffix(tag, &w_id, &db).await?
+ }
+ _ => {
+ windmill_queue::tags::apply_dev_workspace_suffix(
+ language.as_str().to_string(),
+ &w_id,
+ &db,
+ )
+ .await?
+ }
};
let mut tx = user_db.begin(&authed).await?;
diff --git a/backend/windmill-common/src/global_settings.rs b/backend/windmill-common/src/global_settings.rs
index 2bc303f5c2..9472655ce5 100644
--- a/backend/windmill-common/src/global_settings.rs
+++ b/backend/windmill-common/src/global_settings.rs
@@ -10,6 +10,8 @@ pub const FORK_WORKSPACE_TAG_APPEND_FORK_SUFFIX_SETTING: &str =
"fork_workspace_tag_append_fork_suffix";
pub const PREVIEW_TAGS_OVERRIDE_SETTING: &str = "preview_tags_override";
pub const DEPENDENCY_JOB_TAG_SETTING: &str = "dependency_job_tag";
+pub const DEV_WORKSPACE_ISOLATION_SETTING: &str = "dev_workspace_isolation";
+pub const DEV_WORKSPACE_TAG_SUFFIX_SETTING: &str = "dev_workspace_tag_suffix";
pub const BASE_URL_SETTING: &str = "base_url";
pub const WS_BASE_URL_SETTING: &str = "ws_base_url";
pub const OAUTH_SETTING: &str = "oauths";
@@ -315,6 +317,21 @@ pub fn validate_accent_color(value: &serde_json::Value) -> Result<(), String> {
Ok(())
}
+/// Validate a non-empty [`DEV_WORKSPACE_TAG_SUFFIX_SETTING`] value. Every writer must call
+/// it: a suffix that fails it is ignored at load, so the workers would listen on a suffix
+/// the setting does not show.
+pub fn validate_dev_workspace_tag_suffix(value: &serde_json::Value) -> Result<(), String> {
+ let s = value
+ .as_str()
+ .ok_or_else(|| "must be a string".to_string())?;
+ if !crate::worker::is_valid_dev_workspace_tag_suffix(s.trim()) {
+ return Err(
+ "may only contain letters, digits, '-', '_' and '.', up to 32 characters".to_string(),
+ );
+ }
+ Ok(())
+}
+
/// The settings every signed-in browser reads on each full page load.
#[derive(serde::Serialize, Clone, Debug, Default, PartialEq)]
pub struct InstanceUi {
diff --git a/backend/windmill-common/src/instance_config.rs b/backend/windmill-common/src/instance_config.rs
index d19de41dcc..110384a0d6 100644
--- a/backend/windmill-common/src/instance_config.rs
+++ b/backend/windmill-common/src/instance_config.rs
@@ -263,6 +263,8 @@ pub struct GlobalSettings {
#[serde(skip_serializing_if = "Option::is_none")]
pub default_tags_per_workspace: Option,
#[serde(skip_serializing_if = "Option::is_none")]
+ pub dev_workspace_isolation: Option,
+ #[serde(skip_serializing_if = "Option::is_none")]
pub preview_tags_override: Option,
#[serde(skip_serializing_if = "Option::is_none")]
pub disable_hub: Option,
@@ -279,6 +281,8 @@ pub struct GlobalSettings {
#[serde(skip_serializing_if = "Option::is_none")]
pub dependency_job_tag: Option,
#[serde(skip_serializing_if = "Option::is_none")]
+ pub dev_workspace_tag_suffix: Option,
+ #[serde(skip_serializing_if = "Option::is_none")]
pub ws_base_url: Option,
#[serde(skip_serializing_if = "Option::is_none")]
pub github_app_webhook_base_url: Option,
@@ -970,6 +974,9 @@ pub struct WorkerGroupConfig {
/// Workers of the group pull no new job while this is set; queued jobs wait.
#[serde(skip_serializing_if = "Option::is_none")]
pub paused: Option,
+ /// The group listens to its tags suffixed with the dev workspace tag suffix, and only to them.
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub serves_dev_workspaces: Option,
/// Object store this group's dependency cache uses instead of the instance one. Same shape
/// as the instance `object_store_cache_config`.
#[serde(skip_serializing_if = "Option::is_none")]
@@ -1499,6 +1506,14 @@ pub async fn sync_global_settings_declarative(
.map_err(|e| anyhow::anyhow!("{accent_key}: {e}"))?,
}
+ let suffix_key = crate::global_settings::DEV_WORKSPACE_TAG_SUFFIX_SETTING;
+ match desired.get(suffix_key) {
+ None | Some(serde_json::Value::Null) => {}
+ Some(serde_json::Value::String(s)) if s.trim().is_empty() => {}
+ Some(suffix) => crate::global_settings::validate_dev_workspace_tag_suffix(suffix)
+ .map_err(|e| anyhow::anyhow!("{suffix_key}: {e}"))?,
+ }
+
// An origin list that cannot be parsed is dropped at boot, leaving the
// empty default — which is no restriction at all. Rejecting it here is what
// keeps a typo in a ConfigMap from silently widening CORS instance-wide.
diff --git a/backend/windmill-common/src/worker.rs b/backend/windmill-common/src/worker.rs
index f5743782e3..2257327867 100644
--- a/backend/windmill-common/src/worker.rs
+++ b/backend/windmill-common/src/worker.rs
@@ -451,6 +451,13 @@ lazy_static::lazy_static! {
pub static ref FORK_WORKSPACE_TAG_APPEND_FORK_SUFFIX: AtomicBool = AtomicBool::new(false);
pub static ref PREVIEW_TAGS_OVERRIDE: AtomicBool = AtomicBool::new(false);
pub static ref DEPENDENCY_JOB_TAG: arc_swap::ArcSwap
+
+ Attaching an existing workspace copies nothing: it keeps its own resources and variables.
+ Creating a new dev workspace copies this workspace's resources and variables, credentials
+ included; point them at staging before developers use it.
+ {#if $devWorkspaceTagSuffix}
+ Either way, its jobs then run on dev workers (tags suffixed with
+ {$devWorkspaceTagSuffix}).
+ {/if}
+