Files
windmill/backend/windmill-common/src/schema.rs
T
hugocasa 8a986500b9 feat: WM_TESTED_RUNNABLE env var + wildcards in test: annotation (#8926)
* feat: WM_TESTED_RUNNABLE env var + wildcards in test: annotation

Extends the CI test feature so a single test script can cover multiple
runnables and branch on which one triggered it.

- test: annotation now supports glob wildcards: `*` matches one path
  segment, `**` matches any depth. A new `ci_test_path_matches` helper
  in windmill-common compiles patterns to anchored regexes with a small
  quick_cache LRU.
- New migration adds a Postgres GENERATED `has_wildcard` column + partial
  index on ci_test_reference so exact-match lookups keep using the
  primary index and only wildcard rows are scanned for regex matching.
- ci_test trigger query and the UI `ci_test_results` / `ci_test_results_batch`
  endpoints split into exact + wildcard paths; the batch endpoint now
  issues one query per distinct kind instead of one per item.
- Worker injects `WM_TESTED_RUNNABLE={kind}/{path}` into CI test jobs,
  derived from the trigger metadata stored at push time.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: scope CI test job lookup by trigger + populate WM_TESTED_RUNNABLE in resource interpolation

Scope the ci_test_results LATERAL lookup by v2_job.trigger so multi-target
tests (via wildcards or multiple exact annotations) report the correct job
per target. Also pass the tested runnable through transform_json_value in
resources.rs for consistency with schedule_path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore: update ee-repo-ref to 489eb0d89702e5d1cc7c6e0f9ba9e0c8e5063741

This commit updates the EE repository reference after PR #546 was merged in windmill-ee-private.

Previous ee-repo-ref: e7534bcafcd8c27fcf870b2ea868e901b00b7960

New ee-repo-ref: 489eb0d89702e5d1cc7c6e0f9ba9e0c8e5063741

Automated by sync-ee-ref workflow.

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
2026-04-24 12:52:25 +00:00

819 lines
28 KiB
Rust

use anyhow::anyhow;
use itertools::Itertools;
use quick_cache::sync::Cache;
use regex::Regex;
use serde::{Deserialize, Serialize};
use std::{collections::HashMap, str::FromStr, sync::Arc};
use serde_json::{value::RawValue, Value};
use crate::{error::Error, scripts::ScriptLang};
#[derive(PartialEq, Serialize, Deserialize, Debug, Clone)]
pub enum JsonPrimitiveType {
String,
Number,
Integer,
Object,
Array,
Boolean,
Null,
}
#[derive(PartialEq, Serialize, Deserialize, Debug, Clone)]
pub enum SchemaValidationRule {
StrictEnum(Vec<Value>),
IsNull,
IsInteger,
IsString,
IsBool,
IsDatetime,
IsNumber,
IsEmail,
IsObject(Vec<(String, Vec<SchemaValidationRule>)>),
IsArray(Vec<SchemaValidationRule>),
IsUnionType(Vec<Vec<SchemaValidationRule>>),
IsOneOf(HashMap<String, Vec<SchemaValidationRule>>),
IsBytes,
}
impl SchemaValidationRule {
fn from_primitive(p: &JsonPrimitiveType, val: &Value) -> Result<Vec<Self>, anyhow::Error> {
let mut schema_rules = vec![];
match p {
JsonPrimitiveType::String => {
schema_rules.push(SchemaValidationRule::IsString);
if let Some(format) = val.get("format").and_then(|f| f.as_str()) {
if format == "date" || format == "date-time" {
schema_rules.push(SchemaValidationRule::IsDatetime);
}
if format == "email" {
schema_rules.push(SchemaValidationRule::IsEmail);
}
}
if let Some(encoding) = val.get("contentEncoding").and_then(|e| e.as_str()) {
if encoding == "base64" {
schema_rules.push(SchemaValidationRule::IsBytes);
}
}
}
JsonPrimitiveType::Number => {
schema_rules.push(SchemaValidationRule::IsNumber);
}
JsonPrimitiveType::Integer => {
schema_rules.push(SchemaValidationRule::IsInteger);
}
JsonPrimitiveType::Object => {
let mut obj_rules = vec![];
if let Some(properties) = val.get("properties") {
let properties = properties
.as_object()
.ok_or(anyhow!("Field properties should be an object"))?;
for (key, v) in properties {
obj_rules.push((key.clone(), SchemaValidationRule::from_value(v)?))
}
schema_rules.push(SchemaValidationRule::IsObject(obj_rules));
} else if let Some(one_of) = val.get("oneOf") {
let one_of = one_of
.as_array()
.ok_or(anyhow!("`oneOf` needs to be an array"))?;
let mut rules_map: HashMap<String, Vec<SchemaValidationRule>> = HashMap::new();
for variant in one_of {
let variant_label = variant
.get("title")
.ok_or(anyhow!(
"oneOf variant definition should have a `title` field"
))?
.as_str()
.ok_or(anyhow!(
"oneOf variant definition `title` field should be a string"
))?;
if !rules_map.contains_key(variant_label) {
rules_map.insert(
variant_label.to_string(),
SchemaValidationRule::from_value(variant)?,
);
} else {
return Err(anyhow!(
"oneOf definition has a duplicate variant `{variant_label}`"
));
}
}
schema_rules.push(SchemaValidationRule::IsOneOf(rules_map))
} else {
let is_resource = val
.get("format")
.and_then(|f| f.as_str())
.map(|f| f.starts_with("resource"))
.unwrap_or(false);
if !is_resource {
return Err(anyhow!(
"Object type should have a `properties` or `anyOf` field, or be a resource"
));
}
}
}
JsonPrimitiveType::Array => {
let items = val
.get("items")
.ok_or(anyhow!("Array type should have field `items`"))?;
let arr_rules = SchemaValidationRule::from_value(items)?;
schema_rules.push(SchemaValidationRule::IsArray(arr_rules));
}
JsonPrimitiveType::Boolean => {
schema_rules.push(SchemaValidationRule::IsBool);
}
JsonPrimitiveType::Null => {
schema_rules.push(SchemaValidationRule::IsNull);
}
}
Ok(schema_rules)
}
fn from_value(val: &Value) -> Result<Vec<Self>, Error> {
if let Some(any_of) = val.get("anyOf").and_then(|any_of| any_of.as_array()) {
let mut r = vec![];
for variant in any_of {
r.push(SchemaValidationRule::from_value(variant)?);
}
return Ok(vec![SchemaValidationRule::IsUnionType(r)]);
}
let mut schema_rules = vec![];
let typ = val.get("type").ok_or(anyhow!("Missing `type` field"))?;
if let Some(typ) = typ.as_str() {
schema_rules.append(&mut SchemaValidationRule::from_primitive(
&JsonPrimitiveType::from_str(typ)?,
val,
)?);
} else if let Some(typ_arr) = typ.as_array() {
let typ_arr = typ_arr
.into_iter()
.map(|v| {
SchemaValidationRule::from_primitive(
&JsonPrimitiveType::from_str(
v.as_str()
.ok_or(anyhow!("Expected array of strings for `type` field"))?,
)?,
v,
)
})
.collect::<Result<Vec<Vec<SchemaValidationRule>>, anyhow::Error>>()?;
schema_rules.push(SchemaValidationRule::IsUnionType(typ_arr));
} else {
return Err(anyhow!(
"Unsupported value for type field, expected string or string array"
)
.into());
}
if let Some(enum_variants) = val.get("enum") {
let variants = enum_variants
.as_array()
.ok_or(anyhow!("enum variants are not in an array"))?
.clone();
schema_rules.push(SchemaValidationRule::StrictEnum(variants));
}
Ok(schema_rules)
}
fn apply_rule(&self, key: &str, val: &Value, required: bool) -> Result<(), Error> {
if val.is_null() {
if !required {
return Ok(());
}
return Err(Error::ArgumentErr(format!("Argument {key} cannot be null")));
}
match self {
SchemaValidationRule::IsNull => {
if !val.is_null() {
return Err(Error::ArgumentErr(format!(
"Argument `{key}` should be null"
)));
}
}
SchemaValidationRule::StrictEnum(vec) => {
if !vec.contains(val) {
let options = vec.iter().map(|s| s.to_string()).join(", ");
return Err(Error::ArgumentErr(format!(
"Enum type argument `{key}` expected one of `[{options}]` but received {}",
val.to_string()
)));
}
}
SchemaValidationRule::IsNumber => {
if !val.is_number() {
return Err(Error::ArgumentErr(format!(
"Argument `{key}` should be a numeric value"
)));
}
}
SchemaValidationRule::IsInteger => {
if !val.is_i64() && !val.is_u64() {
return Err(Error::ArgumentErr(format!(
"Argument `{key}` should be an integer"
)));
}
}
SchemaValidationRule::IsString => {
if !val.is_string() {
return Err(Error::ArgumentErr(format!(
"Argument `{key}` should be a string"
)));
}
}
SchemaValidationRule::IsBool => {
if !val.is_boolean() {
return Err(Error::ArgumentErr(format!(
"Argument `{key}` should be a boolean"
)));
}
}
SchemaValidationRule::IsObject(o) => {
if !val.is_object() {
return Err(Error::ArgumentErr(format!(
"Argument `{key}` should be an object"
)));
}
for (s, rules) in o {
let v = val
.get(&s)
.ok_or(Error::ArgumentErr(format!("Missing field {s} in {key}")))?;
for r in rules {
r.apply_rule(&format!("{key}.{s}"), v, true)?;
}
}
}
SchemaValidationRule::IsArray(vec) => {
if let Some(arr) = val.as_array() {
for (i, el) in arr.iter().enumerate() {
for r in vec {
r.apply_rule(&format!("{key}[{i}]"), el, true)?;
}
}
} else {
return Err(Error::ArgumentErr(format!(
"Argument `{key}` should be an array"
)));
}
}
// TODO: For better error messages on OneOf, make a dedicated OneOf type that matches the label instead of trying the whole type.
SchemaValidationRule::IsUnionType(vec) => {
let mut match_count = 0;
let mut errors = String::new();
for typ in vec {
if let Some(e) = typ
.iter()
.map(|r| r.apply_rule(key, val, true))
.find_map(Result::err)
{
errors.push_str(&format!("- {e}\n"));
} else {
match_count += 1;
}
}
if match_count == 0 {
return Err(Error::ArgumentErr(format!(
"Argument `{key}` is not valid, failed matching to one of the expected types. Here is a list of possible errors:\n{errors}"
)));
}
}
SchemaValidationRule::IsOneOf(vec) => {
let variant_label = val
.get("label")
.ok_or(Error::ArgumentErr(format!(
"oneOf Variant for argument `{key}` should have a label field"
)))?
.as_str()
.ok_or(Error::ArgumentErr(format!(
"Argument `{key}` of type oneOf expected the label to be a string"
)))?;
let variant_rules = vec
.get(variant_label)
.ok_or_else(|| Error::ArgumentErr(format!(
"Argument `{key}` of type oneOf expected one of the following variants {}, but received `{variant_label}`", vec.keys().join(", ")
)))?;
for r in variant_rules {
r.apply_rule(key, val, true).map_err(|e| Error::ArgumentErr(format!("Argument `{key}`: The schema for the selected oneOf variant `{variant_label}` was not respected: {e}")))?;
}
}
// TODO: Implement validation on these
SchemaValidationRule::IsDatetime => (),
SchemaValidationRule::IsEmail => (),
SchemaValidationRule::IsBytes => (),
}
Ok(())
}
}
fn find_annotation(comm_lit: &str, annotation: &str, code: &str) -> bool {
let a = format!("{comm_lit} {annotation}");
for l in code.lines() {
if !l.starts_with(comm_lit) {
break;
}
if l.trim_end() == a {
return true;
}
}
false
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CiTestedItem {
pub path: String,
pub kind: String,
}
/// Parse a CI test annotation from the top of a script.
///
/// Multi-line format:
/// ```
/// // test:
/// // script/u/admin/my_script
/// // flow/u/admin/my_flow
/// // resource/u/admin/my_resource
/// ```
///
/// One-line format:
/// ```
/// // test: script/u/admin/my_script
/// ```
pub fn parse_ci_test_annotation(code: &str, comment_prefix: &str) -> Option<Vec<CiTestedItem>> {
let test_marker = format!("{comment_prefix} test:");
let mut lines = code.lines();
let first = lines.next()?;
let first_trimmed = first.trim_end();
if !first_trimmed.starts_with(&test_marker) {
return None;
}
let mut items = Vec::new();
// Check for one-line format: "// test: script/path"
let inline = first_trimmed[test_marker.len()..].trim();
if !inline.is_empty() {
if let Some(item) = parse_ci_test_item(inline) {
items.push(item);
}
}
for line in lines {
let Some(after_prefix) = line.strip_prefix(comment_prefix) else {
break;
};
let stripped = after_prefix.trim();
if stripped.is_empty() {
continue;
}
if let Some(item) = parse_ci_test_item(stripped) {
items.push(item);
} else {
break;
}
}
if items.is_empty() {
None
} else {
Some(items)
}
}
fn parse_ci_test_item(s: &str) -> Option<CiTestedItem> {
if let Some(path) = s.strip_prefix("script/") {
Some(CiTestedItem { path: path.trim().to_string(), kind: "script".to_string() })
} else if let Some(path) = s.strip_prefix("flow/") {
Some(CiTestedItem { path: path.trim().to_string(), kind: "flow".to_string() })
} else if let Some(path) = s.strip_prefix("resource/") {
Some(CiTestedItem { path: path.trim().to_string(), kind: "resource".to_string() })
} else {
None
}
}
lazy_static::lazy_static! {
static ref CI_TEST_PATTERN_CACHE: Cache<String, Arc<Regex>> = Cache::new(256);
}
/// Does `path` match an annotation pattern?
/// - `**` matches any characters including `/`
/// - `*` matches any characters within a single path segment (no `/`)
/// - All other regex metacharacters are treated literally
pub fn ci_test_path_matches(path: &str, pattern: &str) -> bool {
// Fast path: literal pattern with no wildcard.
if !pattern.contains('*') {
return path == pattern;
}
let regex = if let Some(cached) = CI_TEST_PATTERN_CACHE.get(pattern) {
cached
} else {
let mut re = String::with_capacity(pattern.len() + 4);
re.push('^');
let mut chars = pattern.chars().peekable();
while let Some(c) = chars.next() {
if c == '*' {
if chars.peek() == Some(&'*') {
chars.next();
re.push_str(".*");
} else {
re.push_str("[^/]*");
}
} else {
// regex::escape would reallocate per char; inline the ASCII metachar set.
if matches!(
c,
'.' | '+' | '?' | '(' | ')' | '[' | ']' | '{' | '}' | '|' | '^' | '$' | '\\'
) {
re.push('\\');
}
re.push(c);
}
}
re.push('$');
match Regex::new(&re) {
Ok(r) => {
let arc = Arc::new(r);
CI_TEST_PATTERN_CACHE.insert(pattern.to_string(), arc.clone());
arc
}
Err(e) => {
tracing::warn!(%e, pattern, "invalid ci test pattern, skipping");
return false;
}
}
};
regex.is_match(path)
}
pub fn should_validate_schema(code: &str, lang: &ScriptLang) -> bool {
let annotation = "schema_validation";
find_annotation(&lang.as_comment_lit(), annotation, code)
}
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct SchemaValidator {
pub required: Vec<String>,
pub rules: Vec<(String, Vec<SchemaValidationRule>)>,
}
impl SchemaValidator {
pub fn validate(&self, args: &HashMap<String, Box<RawValue>>) -> Result<(), Error> {
for key in &self.required {
if !args.contains_key(key) {
return Err(Error::ArgumentErr(format!("Argument {key} is required")));
}
}
for (key, rules) in &self.rules {
if let Some(raw_val) = args.get(key) {
let parsed_val = Value::from_str(raw_val.get()).map_err(|e| {
Error::ArgumentErr(format!("Failed to parse `{key}` argument: {e}"))
})?;
for rule in rules {
rule.apply_rule(key, &parsed_val, self.required.contains(key))?;
}
}
}
Ok(())
}
pub fn from_schema(schema: &str) -> Result<Self, Error> {
let schema: Value = serde_json::from_str(schema)?;
if let Some(draft_version) = schema.get("$schema") {
match draft_version.as_str() {
Some("https://json-schema.org/draft/2020-12/schema") => (),
_ => return Err(anyhow!("Supplied schema draft version is unsuported").into()),
}
} else {
return Err(anyhow!("No draft version supplied").into());
}
let required: Vec<String> = schema
.get("required")
.ok_or(anyhow!("Missing `required` field on schema"))?
.as_array()
.ok_or(anyhow!("`required` field should be an array of strings"))?
.into_iter()
.map(|v| {
v.as_str()
.map(|s| s.to_string())
.ok_or(anyhow!("required field key is not a string"))
})
.collect::<Result<Vec<String>, anyhow::Error>>()?;
let properties = schema
.get("properties")
.ok_or(anyhow!("Missing `properties` field on schema"))?
.as_object()
.ok_or(anyhow!("`properties` field should be an object"))?;
let mut rules = vec![];
for (key, val) in properties {
rules.push((
key.clone(),
SchemaValidationRule::from_value(val)
.map_err(|e| anyhow!("Problem making rule for {key}: {e}"))?,
));
}
Ok(Self { required, rules })
}
}
impl JsonPrimitiveType {
fn from_str(typ: &str) -> Result<Self, anyhow::Error> {
match typ {
"string" => {
return Ok(JsonPrimitiveType::String);
}
"number" => {
return Ok(JsonPrimitiveType::Number);
}
"integer" => {
return Ok(JsonPrimitiveType::Integer);
}
"object" => {
return Ok(JsonPrimitiveType::Object);
}
"array" => {
return Ok(JsonPrimitiveType::Array);
}
"boolean" => {
return Ok(JsonPrimitiveType::Boolean);
}
"null" => {
return Ok(JsonPrimitiveType::Null);
}
other => return Err(anyhow!("Received unsupported type `{other}`").into()),
}
}
}
#[cfg(test)]
mod tests {
use serde_json::json;
use super::*;
fn value_to_rawvalue_map(
value: Value,
) -> Result<HashMap<String, Box<RawValue>>, anyhow::Error> {
match value {
Value::Object(map) => {
let mut result = HashMap::new();
for (key, val) in map {
let raw = serde_json::to_string(&val)?; // Serialize the Value to a string
let raw_value: Box<RawValue> = serde_json::from_str(&raw)?; // Convert string to Box<RawValue>
result.insert(key, raw_value);
}
Ok(result)
}
_ => Err(anyhow!("Expected a JSON object")),
}
}
#[test]
fn test_parse_and_validate_schema() {
let schema = r#"{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"properties": {
"a": {
"contentEncoding": "base64",
"default": null,
"description": "",
"originalType": "bytes",
"type": "string"
},
"b": {
"default": null,
"description": "",
"enum": [
"my",
"enum"
],
"originalType": "enum",
"type": "string"
},
"e": {
"default": "inferred type string from default arg",
"description": "",
"originalType": "string",
"type": "string"
},
"f": {
"default": {
"nested": "object"
},
"description": "",
"properties": {
"nested": {
"description": "",
"type": "string",
"originalType": "string"
}
},
"type": "object"
},
"g": {
"default": null,
"description": "",
"oneOf": [
{
"type": "object",
"title": "Variant 1",
"properties": {
"label": {
"description": "",
"type": "string",
"originalType": "enum",
"enum": [
"Variant 1"
]
},
"foo": {
"description": "",
"type": "string",
"originalType": "string"
}
}
},
{
"type": "object",
"title": "Variant 2",
"properties": {
"label": {
"description": "",
"type": "string",
"originalType": "enum",
"enum": [
"Variant 2"
]
},
"bar": {
"description": "",
"type": "number"
}
}
}
],
"type": "object"
}
},
"required": [
"a",
"b",
"g"
],
"type": "object"
}
"#;
let validator = SchemaValidator::from_schema(schema)
.expect("Schema couldn't be built from a valid schema");
let args = json!(
{
"g": {
"label": "Variant 1",
"foo": ""
},
"f": {
"nested": "object"
},
"e": "inferred type string from default arg",
"b": "my",
"a": null
}
);
validator
.validate(&value_to_rawvalue_map(args).unwrap())
.err()
.expect("Validation should not work for this");
let args = json!(
{
"g": {
"label": "Variant 1",
"foo": ""
},
"f": {
"nested": "object"
},
"e": "inferred type string from default arg",
"b": "not_enum",
"a": "123"
}
);
validator
.validate(&value_to_rawvalue_map(args).unwrap())
.err()
.expect("Validation should not work for this");
let args = json!(
{
"g": {
"label": "Variant 1",
"foo": ""
},
"f": {
"nested": "object"
},
"e": "inferred type string from default arg",
"b": "my",
"a": "123"
}
);
validator
.validate(&value_to_rawvalue_map(args).unwrap())
.expect("Validation should work for this");
}
#[test]
fn ci_test_path_matches_literal() {
assert!(ci_test_path_matches("u/user/foo", "u/user/foo"));
assert!(!ci_test_path_matches("u/user/foo", "u/user/bar"));
assert!(!ci_test_path_matches("u/user/foo/deep", "u/user/foo"));
}
#[test]
fn ci_test_path_matches_single_star_stays_in_segment() {
assert!(ci_test_path_matches("u/user/foo", "u/user/*"));
assert!(ci_test_path_matches("u/user/bar", "u/user/*"));
assert!(!ci_test_path_matches("u/user/foo/deep", "u/user/*"));
assert!(!ci_test_path_matches("u/user", "u/user/*"));
}
#[test]
fn ci_test_path_matches_double_star_crosses_slashes() {
assert!(ci_test_path_matches("u/user/foo", "u/user/**"));
assert!(ci_test_path_matches("u/user/foo/deep", "u/user/**"));
assert!(ci_test_path_matches("u/user/a/b/c", "u/user/**"));
assert!(!ci_test_path_matches("u/other/foo", "u/user/**"));
}
#[test]
fn ci_test_path_matches_escapes_regex_metachars() {
// `.` is a regex metachar; must be matched literally.
assert!(ci_test_path_matches("u/user/file.txt", "u/user/file.txt"));
assert!(!ci_test_path_matches("u/user/fileXtxt", "u/user/file.txt"));
// `+` / `?` / parens must also match literally.
assert!(ci_test_path_matches("u/user/a+b", "u/user/a+b"));
assert!(ci_test_path_matches("u/user/a(b)", "u/user/a(b)"));
}
#[test]
fn ci_test_path_matches_star_at_boundaries() {
assert!(ci_test_path_matches("u/user/foo", "*/user/foo"));
assert!(ci_test_path_matches("u/user/foo", "u/*/foo"));
assert!(ci_test_path_matches("prefix_suffix", "prefix_*"));
assert!(ci_test_path_matches("", ""));
assert!(!ci_test_path_matches("u/user/a/b", "u/user/a"));
}
#[test]
fn ci_test_path_matches_underscore_is_literal() {
// Underscore is a SQL LIKE metachar but plain text for regex; must match literally.
assert!(ci_test_path_matches("u/user/my_script", "u/user/my_script"));
assert!(!ci_test_path_matches(
"u/user/myXscript",
"u/user/my_script"
));
}
}