diff --git a/backend/windmill-api/src/resources.rs b/backend/windmill-api/src/resources.rs index 503c60e1f8b38..5d5996c4e8ac8 100644 --- a/backend/windmill-api/src/resources.rs +++ b/backend/windmill-api/src/resources.rs @@ -43,6 +43,13 @@ use windmill_common::{ worker::{CLOUD_HOSTED, TMP_DIR}, workspaces::get_ducklake_instance_pg_catalog_password, }; +use regex::Regex; + +lazy_static::lazy_static! { + static ref RE_RES_VAR: Regex = Regex::new(r#"\$(?:var|res)\:"#).unwrap(); + static ref RE_VAR_PATTERN: Regex = Regex::new(r#"\$var:([^\s"'\},\)\]&;]+)"#).unwrap(); + static ref RE_RES_PATTERN: Regex = Regex::new(r#"\$res:([^\s"'\},\)\]&;]+)"#).unwrap(); +} pub fn workspaced_service() -> Router { Router::new() @@ -534,7 +541,8 @@ pub async fn transform_json_value<'c>( token: &str, ) -> Result { match v { - Value::String(y) if y.starts_with("$var:") => { + Value::String(y) if y.starts_with("$var:") && !y.contains(' ') => { + // Exact match: entire string is a variable reference let path = y.strip_prefix("$var:").unwrap(); let userdb_authed = UserDbWithOptAuthed { authed: authed, user_db: user_db.clone(), db: db.clone() }; @@ -558,7 +566,8 @@ pub async fn transform_json_value<'c>( .await?; Ok(Value::String(v)) } - Value::String(y) if y.starts_with("$res:") => { + Value::String(y) if y.starts_with("$res:") && !y.contains(' ') => { + // Exact match: entire string is a resource reference let path = y.strip_prefix("$res:").unwrap(); if path.split("/").count() < 2 { return Err(Error::internal_err(format!( @@ -582,6 +591,98 @@ pub async fn transform_json_value<'c>( Ok(Value::Null) } } + Value::String(y) if (*RE_RES_VAR).is_match(&y) => { + // String interpolation: contains variable/resource references + let mut result = y.clone(); + let userdb_authed = + UserDbWithOptAuthed { authed: authed, user_db: user_db.clone(), db: db.clone() }; + + // Replace $var: references + for cap in (*RE_VAR_PATTERN).captures_iter(&y) { + let full_match = cap.get(0).unwrap().as_str(); + let path = cap.get(1).unwrap().as_str(); + + match crate::variables::get_value_internal( + &userdb_authed, + db, + workspace, + path, + &user_db + .clone() + .map(|_| authed.into()) + .unwrap_or(AuditAuthor { + email: "backend".to_string(), + username: "backend".to_string(), + username_override: None, + token_prefix: None, + }), + false, + ) + .await + { + Ok(value) => { + result = result.replace(full_match, &value); + } + Err(e) => { + return Err(Error::NotFound(format!( + "Variable {path} not found in string interpolation: {e:#}" + ))); + } + } + } + + // Replace $res: references + for cap in (*RE_RES_PATTERN).captures_iter(&y) { + let full_match = cap.get(0).unwrap().as_str(); + let path = cap.get(1).unwrap().as_str(); + + if path.split("/").count() < 2 { + return Err(Error::internal_err(format!( + "String contains invalid resource path: {path}" + ))); + } + + let mut tx: Transaction<'_, Postgres> = + authed_transaction_or_default(authed, user_db.clone(), db).await?; + let v = sqlx::query_scalar!( + "SELECT value from resource WHERE path = $1 AND workspace_id = $2", + path, + &workspace + ) + .fetch_optional(&mut *tx) + .await?; + tx.commit().await?; + + if let Some(Some(v)) = v { + let value_str = if v.is_string() { + v.as_str().unwrap().to_string() + } else if v.is_number() { + v.to_string() + } else if v.is_boolean() { + v.to_string() + } else if v.is_null() { + String::new() + } else { + // Object or Array - not suitable for string interpolation + return Err(Error::BadRequest(format!( + "Cannot interpolate resource '$res:{path}' into string. \ + The resource contains an object or array with multiple fields. \ + \nTo fix this:\n\ + 1. Use the resource as a separate parameter (e.g., set the whole parameter to '$res:{path}'), OR\n\ + 2. Create a simpler variable/resource with just the single value you need, OR\n\ + 3. Extract the field in your script code after receiving the full resource object." + ))); + }; + result = result.replace(full_match, &value_str); + } else { + return Err(Error::NotFound(format!( + "Resource {path} not found in string interpolation" + ))); + } + } + + Ok(Value::String(result)) + } Value::String(y) if y.starts_with("$") && job_id.is_some() => { let mut tx = authed_transaction_or_default(authed, user_db.clone(), db).await?; let job_id = job_id.unwrap(); diff --git a/backend/windmill-worker/src/common.rs b/backend/windmill-worker/src/common.rs index 4856e030f7fbc..6a97825dd2bbf 100644 --- a/backend/windmill-worker/src/common.rs +++ b/backend/windmill-worker/src/common.rs @@ -129,6 +129,9 @@ pub async fn write_file_binary(dir: &str, path: &str, content: &[u8]) -> error:: lazy_static::lazy_static! { static ref RE_RES_VAR: Regex = Regex::new(r#"\$(?:var|res|encrypted)\:"#).unwrap(); + static ref RE_VAR_PATTERN: Regex = Regex::new(r#"\$var:([^\s"'\},\)\]&;]+)"#).unwrap(); + static ref RE_RES_PATTERN: Regex = Regex::new(r#"\$res:([^\s"'\},\)\]&;]+)"#).unwrap(); + static ref RE_ENCRYPTED_PATTERN: Regex = Regex::new(r#"\$encrypted:([^\s"'\},\)\]&;]+)"#).unwrap(); } pub async fn transform_json<'a>( @@ -225,7 +228,8 @@ pub async fn transform_json_value( conn: &Connection, ) -> error::Result { match v { - Value::String(y) if y.starts_with("$var:") => { + Value::String(y) if y.starts_with("$var:") && !y.contains(' ') => { + // Exact match: entire string is a variable reference let path = y.strip_prefix("$var:").unwrap(); client .get_variable_value(path) @@ -235,7 +239,8 @@ pub async fn transform_json_value( Error::NotFound(format!("Variable {path} not found for `{name}`: {e:#}")) }) } - Value::String(y) if y.starts_with("$res:") => { + Value::String(y) if y.starts_with("$res:") && !y.contains(' ') => { + // Exact match: entire string is a resource reference let path = y.strip_prefix("$res:").unwrap(); if path.split("/").count() < 2 && !path.starts_with("INSTANCE_DUCKLAKE_CATALOG/") { @@ -253,7 +258,8 @@ pub async fn transform_json_value( Error::NotFound(format!("Resource {path} not found for `{name}`: {e:#}")) }) } - Value::String(y) if y.starts_with("$encrypted:") => { + Value::String(y) if y.starts_with("$encrypted:") && !y.contains(' ') => { + // Exact match: entire string is an encrypted reference match conn { Connection::Sql(db) => { let encrypted = y.strip_prefix("$encrypted:").unwrap(); @@ -277,8 +283,104 @@ pub async fn transform_json_value( Err(Error::NotFound("Http connection not supported".to_string())) } } + } + Value::String(y) if (*RE_RES_VAR).is_match(&y) => { + // String interpolation: contains variable/resource/encrypted references + let mut result = y.clone(); + + // Replace $var: references + for cap in (*RE_VAR_PATTERN).captures_iter(&y) { + let full_match = cap.get(0).unwrap().as_str(); + let path = cap.get(1).unwrap().as_str(); + + match client.get_variable_value(path).await { + Ok(value) => { + result = result.replace(full_match, &value); + } + Err(e) => { + return Err(Error::NotFound(format!( + "Variable {path} not found in string interpolation for `{name}`: {e:#}" + ))); + } + } + } + + // Replace $res: references + for cap in (*RE_RES_PATTERN).captures_iter(&y) { + let full_match = cap.get(0).unwrap().as_str(); + let path = cap.get(1).unwrap().as_str(); + + if path.split("/").count() < 2 && !path.starts_with("INSTANCE_DUCKLAKE_CATALOG/") { + return Err(Error::internal_err(format!( + "Argument `{name}` contains invalid resource path: {path}", + ))); + } + + match client + .get_resource_value_interpolated::( + path, + Some(job.id.to_string()), + ) + .await + { + Ok(value) => { + let value_str = if value.is_string() { + value.as_str().unwrap().to_string() + } else if value.is_number() { + value.to_string() + } else if value.is_boolean() { + value.to_string() + } else if value.is_null() { + String::new() + } else { + // Object or Array - not suitable for string interpolation + return Err(Error::BadRequest(format!( + "Cannot interpolate resource '$res:{path}' into string for argument `{name}`. \ + The resource contains an object or array with multiple fields. \ + \nTo fix this:\n\ + 1. Use the resource as a separate parameter (e.g., set the whole parameter to '$res:{path}'), OR\n\ + 2. Create a simpler variable/resource with just the single value you need, OR\n\ + 3. Extract the field in your script code after receiving the full resource object." + ))); + }; + result = result.replace(full_match, &value_str); + } + Err(e) => { + return Err(Error::NotFound(format!( + "Resource {path} not found in string interpolation for `{name}`: {e:#}" + ))); + } + } + } + + // Replace $encrypted: references + if let Connection::Sql(db) = conn { + for cap in (*RE_ENCRYPTED_PATTERN).captures_iter(&y) { + let full_match = cap.get(0).unwrap().as_str(); + let encrypted = cap.get(1).unwrap().as_str(); + + let root_job_id = get_root_job_id(&job); + let mc = build_crypt_with_key_suffix( + db, + &job.workspace_id, + &root_job_id.to_string(), + ) + .await?; + + match decrypt(&mc, encrypted.to_string()) { + Ok(decrypted) => { + result = result.replace(full_match, &decrypted); + } + Err(e) => { + return Err(Error::internal_err(format!( + "Failed to decrypt '$encrypted:' value in string interpolation: {e}" + ))); + } + } + } + } - // let path = y.strip_prefix("$res:").unwrap(); + Ok(json!(result)) } Value::String(y) if y.starts_with("$") => { let variables = get_reserved_variables(job, &client.token, conn, None).await?;