Compare commits
1 Commits
v1.630.0
...
plan-2-scr
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
78bc6b498c |
18
CHANGELOG.md
18
CHANGELOG.md
@@ -1,23 +1,5 @@
|
||||
# Changelog
|
||||
|
||||
## [1.630.0](https://github.com/windmill-labs/windmill/compare/v1.629.1...v1.630.0) (2026-02-10)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add workspace search and runnable details tools to AI chat modes ([#7874](https://github.com/windmill-labs/windmill/issues/7874)) ([a7e269f](https://github.com/windmill-labs/windmill/commit/a7e269f9f3c82db0d7e6a70e174ac19d3df730d2))
|
||||
* **aiagent:** add prompt caching for Anthropic models ([#7878](https://github.com/windmill-labs/windmill/issues/7878)) ([6272cd1](https://github.com/windmill-labs/windmill/commit/6272cd17a4f1300e22e7f0ae27b1a57571deb203))
|
||||
* download encrypted usage ([#7804](https://github.com/windmill-labs/windmill/issues/7804)) ([8363ff1](https://github.com/windmill-labs/windmill/commit/8363ff1eeef06f284e6d165fbf2dfb190ead573d))
|
||||
* **mcp:** add endpoint tools for scripts, flows, apps, and jobs ([#7859](https://github.com/windmill-labs/windmill/issues/7859)) ([03eb16a](https://github.com/windmill-labs/windmill/commit/03eb16a7c6c3cd9411840814940d09e22ce23305))
|
||||
* restriction rulesets for workspaces ([#7879](https://github.com/windmill-labs/windmill/issues/7879)) ([2851b6b](https://github.com/windmill-labs/windmill/commit/2851b6b7caac4a55f5202ace82aba68fd157c52a))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **backend:** correct early return with stream + prevent delta miss ([#7872](https://github.com/windmill-labs/windmill/issues/7872)) ([1150eec](https://github.com/windmill-labs/windmill/commit/1150eec7571d5828d10b295cb61cca8edfbdffe0))
|
||||
* gate Permissions import behind #[cfg(unix)] for Windows build ([cf596f3](https://github.com/windmill-labs/windmill/commit/cf596f370ae7cc232ca63f4752d7727a74cd449b))
|
||||
* retry js eval up to 3 times on timeout from slow DB ([#7890](https://github.com/windmill-labs/windmill/issues/7890)) ([4c87e7a](https://github.com/windmill-labs/windmill/commit/4c87e7ac2e09ec83cfb998a1cebcb9b9c5ef8027))
|
||||
|
||||
## [1.629.1](https://github.com/windmill-labs/windmill/compare/v1.629.0...v1.629.1) (2026-02-10)
|
||||
|
||||
|
||||
|
||||
@@ -12,7 +12,8 @@
|
||||
"name": "native_trigger_service",
|
||||
"kind": {
|
||||
"Enum": [
|
||||
"nextcloud"
|
||||
"nextcloud",
|
||||
"google"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,7 +17,8 @@
|
||||
"name": "native_trigger_service",
|
||||
"kind": {
|
||||
"Enum": [
|
||||
"nextcloud"
|
||||
"nextcloud",
|
||||
"google"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,11 +46,11 @@
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true
|
||||
]
|
||||
|
||||
@@ -30,7 +30,8 @@
|
||||
"sqs",
|
||||
"gcp",
|
||||
"mqtt",
|
||||
"nextcloud"
|
||||
"nextcloud",
|
||||
"google"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,7 +24,8 @@
|
||||
"mqtt",
|
||||
"gcp",
|
||||
"default_email",
|
||||
"nextcloud"
|
||||
"nextcloud",
|
||||
"google"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -122,7 +122,8 @@
|
||||
"sqs",
|
||||
"gcp",
|
||||
"mqtt",
|
||||
"nextcloud"
|
||||
"nextcloud",
|
||||
"google"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,7 +24,8 @@
|
||||
"mqtt",
|
||||
"gcp",
|
||||
"default_email",
|
||||
"nextcloud"
|
||||
"nextcloud",
|
||||
"google"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,7 +40,8 @@
|
||||
"sqs",
|
||||
"gcp",
|
||||
"mqtt",
|
||||
"nextcloud"
|
||||
"nextcloud",
|
||||
"google"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,7 +34,8 @@
|
||||
"sqs",
|
||||
"gcp",
|
||||
"mqtt",
|
||||
"nextcloud"
|
||||
"nextcloud",
|
||||
"google"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -67,7 +68,8 @@
|
||||
"sqs",
|
||||
"gcp",
|
||||
"mqtt",
|
||||
"nextcloud"
|
||||
"nextcloud",
|
||||
"google"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,7 +24,8 @@
|
||||
"mqtt",
|
||||
"gcp",
|
||||
"default_email",
|
||||
"nextcloud"
|
||||
"nextcloud",
|
||||
"google"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,7 +40,8 @@
|
||||
"mqtt",
|
||||
"gcp",
|
||||
"default_email",
|
||||
"nextcloud"
|
||||
"nextcloud",
|
||||
"google"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,7 +16,8 @@
|
||||
"name": "native_trigger_service",
|
||||
"kind": {
|
||||
"Enum": [
|
||||
"nextcloud"
|
||||
"nextcloud",
|
||||
"google"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,8 @@
|
||||
"name": "native_trigger_service",
|
||||
"kind": {
|
||||
"Enum": [
|
||||
"nextcloud"
|
||||
"nextcloud",
|
||||
"google"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,8 @@
|
||||
"name": "native_trigger_service",
|
||||
"kind": {
|
||||
"Enum": [
|
||||
"nextcloud"
|
||||
"nextcloud",
|
||||
"google"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,8 @@
|
||||
"name": "native_trigger_service",
|
||||
"kind": {
|
||||
"Enum": [
|
||||
"nextcloud"
|
||||
"nextcloud",
|
||||
"google"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,8 @@
|
||||
"name": "native_trigger_service",
|
||||
"kind": {
|
||||
"Enum": [
|
||||
"nextcloud"
|
||||
"nextcloud",
|
||||
"google"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,8 @@
|
||||
"name": "native_trigger_service",
|
||||
"kind": {
|
||||
"Enum": [
|
||||
"nextcloud"
|
||||
"nextcloud",
|
||||
"google"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,7 +16,8 @@
|
||||
"name": "native_trigger_service",
|
||||
"kind": {
|
||||
"Enum": [
|
||||
"nextcloud"
|
||||
"nextcloud",
|
||||
"google"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -51,7 +52,8 @@
|
||||
"name": "native_trigger_service",
|
||||
"kind": {
|
||||
"Enum": [
|
||||
"nextcloud"
|
||||
"nextcloud",
|
||||
"google"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,7 +24,8 @@
|
||||
"mqtt",
|
||||
"gcp",
|
||||
"default_email",
|
||||
"nextcloud"
|
||||
"nextcloud",
|
||||
"google"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,7 +30,8 @@
|
||||
"mqtt",
|
||||
"gcp",
|
||||
"default_email",
|
||||
"nextcloud"
|
||||
"nextcloud",
|
||||
"google"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "DELETE FROM job_result_stream_v2\n WHERE job_id NOT IN (SELECT id FROM v2_job_queue)\n AND job_id NOT IN (\n SELECT id FROM v2_job_completed\n WHERE completed_at > NOW() - INTERVAL '60 seconds'\n )\n RETURNING job_id",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "job_id",
|
||||
"type_info": "Uuid"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": []
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "454a611a5a162b2ace137c139bd5383bc7fe142c515dac3edd47991839485e51"
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT\n workspace_id,\n name,\n rules as \"rules: ProtectionRules\",\n bypass_groups,\n bypass_users\n FROM workspace_protection_rule\n WHERE workspace_id = $1\n ORDER BY name\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "workspace_id",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "name",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "rules: ProtectionRules",
|
||||
"type_info": "Int4"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "bypass_groups",
|
||||
"type_info": "TextArray"
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "bypass_users",
|
||||
"type_info": "TextArray"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "4cfb35e423a75ca2701f03d5a30a7c0778af5e548254f3e0f29004d7f2058eef"
|
||||
}
|
||||
@@ -37,7 +37,8 @@
|
||||
"mqtt",
|
||||
"gcp",
|
||||
"default_email",
|
||||
"nextcloud"
|
||||
"nextcloud",
|
||||
"google"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,7 +32,8 @@
|
||||
"mqtt",
|
||||
"gcp",
|
||||
"default_email",
|
||||
"nextcloud"
|
||||
"nextcloud",
|
||||
"google"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -70,7 +71,8 @@
|
||||
"mqtt",
|
||||
"gcp",
|
||||
"default_email",
|
||||
"nextcloud"
|
||||
"nextcloud",
|
||||
"google"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,7 +16,8 @@
|
||||
"name": "native_trigger_service",
|
||||
"kind": {
|
||||
"Enum": [
|
||||
"nextcloud"
|
||||
"nextcloud",
|
||||
"google"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -245,7 +245,8 @@
|
||||
"sqs",
|
||||
"gcp",
|
||||
"mqtt",
|
||||
"nextcloud"
|
||||
"nextcloud",
|
||||
"google"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n UPDATE workspace_protection_rule\n SET rules = $1, bypass_groups = $2, bypass_users = $3\n WHERE workspace_id = $4 AND name = $5\n ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int4",
|
||||
"TextArray",
|
||||
"TextArray",
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "65b12bed9438900518b20dc268d71a2dba6ec66aee2971faef76b6ed56a05b6f"
|
||||
}
|
||||
@@ -35,7 +35,8 @@
|
||||
"mqtt",
|
||||
"gcp",
|
||||
"default_email",
|
||||
"nextcloud"
|
||||
"nextcloud",
|
||||
"google"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,7 +29,8 @@
|
||||
"mqtt",
|
||||
"gcp",
|
||||
"default_email",
|
||||
"nextcloud"
|
||||
"nextcloud",
|
||||
"google"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,7 +24,8 @@
|
||||
"mqtt",
|
||||
"gcp",
|
||||
"default_email",
|
||||
"nextcloud"
|
||||
"nextcloud",
|
||||
"google"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,7 +40,8 @@
|
||||
"sqs",
|
||||
"gcp",
|
||||
"mqtt",
|
||||
"nextcloud"
|
||||
"nextcloud",
|
||||
"google"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "DELETE FROM workspace_protection_rule WHERE workspace_id = $1 AND name = $2",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "7ddc2c3f1ce74f9b7acdd51b94431386e7782c52251cf094e06ddb198a958b43"
|
||||
}
|
||||
@@ -27,7 +27,8 @@
|
||||
"mqtt",
|
||||
"gcp",
|
||||
"default_email",
|
||||
"nextcloud"
|
||||
"nextcloud",
|
||||
"google"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,7 +24,8 @@
|
||||
"mqtt",
|
||||
"gcp",
|
||||
"default_email",
|
||||
"nextcloud"
|
||||
"nextcloud",
|
||||
"google"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,7 +35,8 @@
|
||||
"mqtt",
|
||||
"gcp",
|
||||
"default_email",
|
||||
"nextcloud"
|
||||
"nextcloud",
|
||||
"google"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,7 +17,8 @@
|
||||
"name": "native_trigger_service",
|
||||
"kind": {
|
||||
"Enum": [
|
||||
"nextcloud"
|
||||
"nextcloud",
|
||||
"google"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,7 +24,8 @@
|
||||
"mqtt",
|
||||
"gcp",
|
||||
"default_email",
|
||||
"nextcloud"
|
||||
"nextcloud",
|
||||
"google"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,7 +17,8 @@
|
||||
"name": "native_trigger_service",
|
||||
"kind": {
|
||||
"Enum": [
|
||||
"nextcloud"
|
||||
"nextcloud",
|
||||
"google"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,7 +32,8 @@
|
||||
"mqtt",
|
||||
"gcp",
|
||||
"default_email",
|
||||
"nextcloud"
|
||||
"nextcloud",
|
||||
"google"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,7 +30,8 @@
|
||||
"sqs",
|
||||
"gcp",
|
||||
"mqtt",
|
||||
"nextcloud"
|
||||
"nextcloud",
|
||||
"google"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -155,7 +155,8 @@
|
||||
"sqs",
|
||||
"gcp",
|
||||
"mqtt",
|
||||
"nextcloud"
|
||||
"nextcloud",
|
||||
"google"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
20
backend/.sqlx/query-a3e75f0309be42aca0fd74834f34b3f18dbb388bd8b9bc88b99aebedae9c3fec.json
generated
Normal file
20
backend/.sqlx/query-a3e75f0309be42aca0fd74834f34b3f18dbb388bd8b9bc88b99aebedae9c3fec.json
generated
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "DELETE FROM job_result_stream_v2 WHERE job_id NOT IN (SELECT id FROM v2_job_queue) RETURNING job_id",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "job_id",
|
||||
"type_info": "Uuid"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": []
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "a3e75f0309be42aca0fd74834f34b3f18dbb388bd8b9bc88b99aebedae9c3fec"
|
||||
}
|
||||
@@ -185,7 +185,8 @@
|
||||
"sqs",
|
||||
"gcp",
|
||||
"mqtt",
|
||||
"nextcloud"
|
||||
"nextcloud",
|
||||
"google"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT\n flow_version.id AS version,\n flow_version.value->>'early_return' as early_return,\n flow_version.value->>'preprocessor_module' IS NOT NULL as has_preprocessor,\n (flow_version.value->>'chat_input_enabled')::boolean as chat_input_enabled,\n flow.tag,\n flow.dedicated_worker,\n flow.on_behalf_of_email,\n flow.edited_by\n FROM\n flow_version\n INNER JOIN flow\n ON flow.path = flow_version.path AND\n flow.workspace_id = flow_version.workspace_id\n WHERE\n flow_version.workspace_id = $1 AND\n flow_version.path = $2 AND\n flow_version.id = $3\n ",
|
||||
"query": "\n SELECT\n flow_version.id AS version,\n flow_version.value->>'early_return' as early_return, \n flow_version.value->>'preprocessor_module' IS NOT NULL as has_preprocessor, \n (flow_version.value->>'chat_input_enabled')::boolean as chat_input_enabled, \n flow.tag, \n flow.dedicated_worker, \n flow.on_behalf_of_email, \n flow.edited_by\n FROM \n flow_version\n INNER JOIN flow\n ON flow.path = flow_version.path AND\n flow.workspace_id = flow_version.workspace_id\n WHERE \n flow_version.workspace_id = $1 AND\n flow_version.path = $2 AND\n flow_version.id = $3\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -62,5 +62,5 @@
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "209dc4c1b91eeab1c12ffcd9f9e16f315c689ca772c736b333dcdf07c8086087"
|
||||
"hash": "a7468e9054beed88636786c5495ac3b9d9a6086ae6212ad9237b39a0346d7d26"
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT EXISTS(SELECT 1 FROM workspace_protection_rule WHERE workspace_id = $1 AND name = $2)",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "exists",
|
||||
"type_info": "Bool"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "b1b26cb02dcc0748c63411c933e5e6bdfe82466134ba28408a530e22c66656de"
|
||||
}
|
||||
@@ -160,7 +160,8 @@
|
||||
"sqs",
|
||||
"gcp",
|
||||
"mqtt",
|
||||
"nextcloud"
|
||||
"nextcloud",
|
||||
"google"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,7 +24,8 @@
|
||||
"mqtt",
|
||||
"gcp",
|
||||
"default_email",
|
||||
"nextcloud"
|
||||
"nextcloud",
|
||||
"google"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,7 +21,8 @@
|
||||
"name": "native_trigger_service",
|
||||
"kind": {
|
||||
"Enum": [
|
||||
"nextcloud"
|
||||
"nextcloud",
|
||||
"google"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -71,7 +72,8 @@
|
||||
"name": "native_trigger_service",
|
||||
"kind": {
|
||||
"Enum": [
|
||||
"nextcloud"
|
||||
"nextcloud",
|
||||
"google"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -105,7 +105,8 @@
|
||||
"sqs",
|
||||
"gcp",
|
||||
"mqtt",
|
||||
"nextcloud"
|
||||
"nextcloud",
|
||||
"google"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,7 +31,8 @@
|
||||
"mqtt",
|
||||
"gcp",
|
||||
"default_email",
|
||||
"nextcloud"
|
||||
"nextcloud",
|
||||
"google"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,8 @@
|
||||
"name": "native_trigger_service",
|
||||
"kind": {
|
||||
"Enum": [
|
||||
"nextcloud"
|
||||
"nextcloud",
|
||||
"google"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,7 +24,8 @@
|
||||
"mqtt",
|
||||
"gcp",
|
||||
"default_email",
|
||||
"nextcloud"
|
||||
"nextcloud",
|
||||
"google"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,8 @@
|
||||
"name": "native_trigger_service",
|
||||
"kind": {
|
||||
"Enum": [
|
||||
"nextcloud"
|
||||
"nextcloud",
|
||||
"google"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -105,7 +105,8 @@
|
||||
"sqs",
|
||||
"gcp",
|
||||
"mqtt",
|
||||
"nextcloud"
|
||||
"nextcloud",
|
||||
"google"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,7 +25,8 @@
|
||||
"mqtt",
|
||||
"gcp",
|
||||
"default_email",
|
||||
"nextcloud"
|
||||
"nextcloud",
|
||||
"google"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n INSERT INTO workspace_protection_rule (workspace_id, name, rules, bypass_groups, bypass_users)\n VALUES ($1, $2, $3, $4, $5)\n ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Varchar",
|
||||
"Varchar",
|
||||
"Int4",
|
||||
"TextArray",
|
||||
"TextArray"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "dbf2982f43577999dec0c488dfb67f56e5bd0e5fbf8da5132f6fc5a282d2b0e1"
|
||||
}
|
||||
@@ -185,7 +185,8 @@
|
||||
"sqs",
|
||||
"gcp",
|
||||
"mqtt",
|
||||
"nextcloud"
|
||||
"nextcloud",
|
||||
"google"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,7 +31,8 @@
|
||||
"mqtt",
|
||||
"gcp",
|
||||
"default_email",
|
||||
"nextcloud"
|
||||
"nextcloud",
|
||||
"google"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,7 +24,8 @@
|
||||
"mqtt",
|
||||
"gcp",
|
||||
"default_email",
|
||||
"nextcloud"
|
||||
"nextcloud",
|
||||
"google"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,7 +21,8 @@
|
||||
"name": "native_trigger_service",
|
||||
"kind": {
|
||||
"Enum": [
|
||||
"nextcloud"
|
||||
"nextcloud",
|
||||
"google"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -71,7 +72,8 @@
|
||||
"name": "native_trigger_service",
|
||||
"kind": {
|
||||
"Enum": [
|
||||
"nextcloud"
|
||||
"nextcloud",
|
||||
"google"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,7 +24,8 @@
|
||||
"mqtt",
|
||||
"gcp",
|
||||
"default_email",
|
||||
"nextcloud"
|
||||
"nextcloud",
|
||||
"google"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,7 +21,8 @@
|
||||
"name": "native_trigger_service",
|
||||
"kind": {
|
||||
"Enum": [
|
||||
"nextcloud"
|
||||
"nextcloud",
|
||||
"google"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -71,7 +72,8 @@
|
||||
"name": "native_trigger_service",
|
||||
"kind": {
|
||||
"Enum": [
|
||||
"nextcloud"
|
||||
"nextcloud",
|
||||
"google"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
119
backend/Cargo.lock
generated
119
backend/Cargo.lock
generated
@@ -8960,9 +8960,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ntapi"
|
||||
version = "0.4.3"
|
||||
version = "0.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c3b335231dfd352ffb0f8017f3b6027a4917f7df785ea2143d8af2adc66980ae"
|
||||
checksum = "c70f219e21142367c70c0b30c6a9e3a14d55b4d12a204d897fbec83a0363f081"
|
||||
dependencies = [
|
||||
"winapi",
|
||||
]
|
||||
@@ -11317,9 +11317,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rmcp"
|
||||
version = "0.15.0"
|
||||
version = "0.14.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1bef41ebc9ebed2c1b1d90203e9d1756091e8a00bbc3107676151f39868ca0ee"
|
||||
checksum = "0a621b37a548ff6ab6292d57841eb25785a7f146d89391a19c9f199414bd13da"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"axum 0.8.4",
|
||||
@@ -11352,9 +11352,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rmcp-macros"
|
||||
version = "0.15.0"
|
||||
version = "0.14.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0e88ad84b8b6237a934534a62b379a5be6388915663c0cc598ceb9b3292bbbfe"
|
||||
checksum = "6b79ed92303f9262db79575aa8c3652581668e9d136be6fd0b9ededa78954c95"
|
||||
dependencies = [
|
||||
"darling 0.23.0",
|
||||
"proc-macro2",
|
||||
@@ -15725,7 +15725,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill"
|
||||
version = "1.630.0"
|
||||
version = "1.629.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-nats",
|
||||
@@ -15783,7 +15783,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api"
|
||||
version = "1.630.0"
|
||||
version = "1.629.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"argon2",
|
||||
@@ -15907,7 +15907,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-auth"
|
||||
version = "1.630.0"
|
||||
version = "1.629.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum 0.7.9",
|
||||
@@ -15933,7 +15933,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-client"
|
||||
version = "1.630.0"
|
||||
version = "1.629.1"
|
||||
dependencies = [
|
||||
"reqwest 0.12.28",
|
||||
"serde",
|
||||
@@ -15943,7 +15943,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-embeddings"
|
||||
version = "1.630.0"
|
||||
version = "1.629.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum 0.7.9",
|
||||
@@ -15966,7 +15966,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-groups"
|
||||
version = "1.630.0"
|
||||
version = "1.629.1"
|
||||
dependencies = [
|
||||
"axum 0.7.9",
|
||||
"chrono",
|
||||
@@ -15986,7 +15986,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-jobs"
|
||||
version = "1.630.0"
|
||||
version = "1.629.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum 0.7.9",
|
||||
@@ -16011,7 +16011,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-scripts"
|
||||
version = "1.630.0"
|
||||
version = "1.629.1"
|
||||
dependencies = [
|
||||
"axum 0.7.9",
|
||||
"chrono",
|
||||
@@ -16040,7 +16040,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-sse"
|
||||
version = "1.630.0"
|
||||
version = "1.629.1"
|
||||
dependencies = [
|
||||
"lazy_static",
|
||||
"serde",
|
||||
@@ -16052,7 +16052,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-users"
|
||||
version = "1.630.0"
|
||||
version = "1.629.1"
|
||||
dependencies = [
|
||||
"argon2",
|
||||
"axum 0.7.9",
|
||||
@@ -16075,7 +16075,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-workspaces"
|
||||
version = "1.630.0"
|
||||
version = "1.629.1"
|
||||
dependencies = [
|
||||
"axum 0.7.9",
|
||||
"chrono",
|
||||
@@ -16088,7 +16088,6 @@ dependencies = [
|
||||
"serde_json",
|
||||
"sha2 0.10.9",
|
||||
"sqlx",
|
||||
"strum 0.27.2",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"uuid",
|
||||
@@ -16104,7 +16103,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-audit"
|
||||
version = "1.630.0"
|
||||
version = "1.629.1"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"lazy_static",
|
||||
@@ -16118,7 +16117,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-autoscaling"
|
||||
version = "1.630.0"
|
||||
version = "1.629.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum 0.7.9",
|
||||
@@ -16137,7 +16136,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-common"
|
||||
version = "1.630.0"
|
||||
version = "1.629.1"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"anyhow",
|
||||
@@ -16236,7 +16235,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-dep-map"
|
||||
version = "1.630.0"
|
||||
version = "1.629.1"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"itertools 0.14.0",
|
||||
@@ -16255,7 +16254,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-git-sync"
|
||||
version = "1.630.0"
|
||||
version = "1.629.1"
|
||||
dependencies = [
|
||||
"regex",
|
||||
"serde",
|
||||
@@ -16270,7 +16269,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-indexer"
|
||||
version = "1.630.0"
|
||||
version = "1.629.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"astral-tokio-tar",
|
||||
@@ -16294,7 +16293,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-jseval"
|
||||
version = "1.630.0"
|
||||
version = "1.629.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"futures",
|
||||
@@ -16311,7 +16310,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-macros"
|
||||
version = "1.630.0"
|
||||
version = "1.629.1"
|
||||
dependencies = [
|
||||
"itertools 0.14.0",
|
||||
"lazy_static",
|
||||
@@ -16327,7 +16326,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-mcp"
|
||||
version = "1.630.0"
|
||||
version = "1.629.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -16348,7 +16347,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-native-triggers"
|
||||
version = "1.630.0"
|
||||
version = "1.629.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -16379,7 +16378,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-oauth"
|
||||
version = "1.630.0"
|
||||
version = "1.629.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-oauth2",
|
||||
@@ -16403,7 +16402,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser"
|
||||
version = "1.630.0"
|
||||
version = "1.629.1"
|
||||
dependencies = [
|
||||
"convert_case 0.6.0",
|
||||
"serde",
|
||||
@@ -16412,7 +16411,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-bash"
|
||||
version = "1.630.0"
|
||||
version = "1.629.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -16424,7 +16423,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-csharp"
|
||||
version = "1.630.0"
|
||||
version = "1.629.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde_json",
|
||||
@@ -16436,7 +16435,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-go"
|
||||
version = "1.630.0"
|
||||
version = "1.629.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"gosyn",
|
||||
@@ -16448,7 +16447,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-graphql"
|
||||
version = "1.630.0"
|
||||
version = "1.629.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -16460,7 +16459,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-java"
|
||||
version = "1.630.0"
|
||||
version = "1.629.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde_json",
|
||||
@@ -16472,7 +16471,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-nu"
|
||||
version = "1.630.0"
|
||||
version = "1.629.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"nu-parser",
|
||||
@@ -16483,7 +16482,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-php"
|
||||
version = "1.630.0"
|
||||
version = "1.629.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"itertools 0.14.0",
|
||||
@@ -16494,7 +16493,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-py"
|
||||
version = "1.630.0"
|
||||
version = "1.629.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"itertools 0.14.0",
|
||||
@@ -16507,7 +16506,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-py-imports"
|
||||
version = "1.630.0"
|
||||
version = "1.629.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-recursion",
|
||||
@@ -16531,7 +16530,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-ruby"
|
||||
version = "1.630.0"
|
||||
version = "1.629.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -16545,7 +16544,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-rust"
|
||||
version = "1.630.0"
|
||||
version = "1.629.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"convert_case 0.6.0",
|
||||
@@ -16562,7 +16561,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-sql"
|
||||
version = "1.630.0"
|
||||
version = "1.629.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -16576,7 +16575,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-ts"
|
||||
version = "1.630.0"
|
||||
version = "1.629.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -16595,7 +16594,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-yaml"
|
||||
version = "1.630.0"
|
||||
version = "1.629.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde",
|
||||
@@ -16606,7 +16605,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-queue"
|
||||
version = "1.630.0"
|
||||
version = "1.629.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-recursion",
|
||||
@@ -16643,7 +16642,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-runtime-nativets"
|
||||
version = "1.630.0"
|
||||
version = "1.629.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"const_format",
|
||||
@@ -16681,7 +16680,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-sql-datatype-parser-wasm"
|
||||
version = "1.630.0"
|
||||
version = "1.629.1"
|
||||
dependencies = [
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-test",
|
||||
@@ -16691,7 +16690,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-store"
|
||||
version = "1.630.0"
|
||||
version = "1.629.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-recursion",
|
||||
@@ -16720,7 +16719,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-trigger"
|
||||
version = "1.630.0"
|
||||
version = "1.629.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -16753,7 +16752,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-trigger-email"
|
||||
version = "1.630.0"
|
||||
version = "1.629.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -16773,7 +16772,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-trigger-gcp"
|
||||
version = "1.630.0"
|
||||
version = "1.629.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -16807,7 +16806,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-trigger-http"
|
||||
version = "1.630.0"
|
||||
version = "1.629.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -16841,7 +16840,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-trigger-kafka"
|
||||
version = "1.630.0"
|
||||
version = "1.629.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -16864,7 +16863,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-trigger-mqtt"
|
||||
version = "1.630.0"
|
||||
version = "1.629.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -16888,7 +16887,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-trigger-nats"
|
||||
version = "1.630.0"
|
||||
version = "1.629.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-nats",
|
||||
@@ -16912,7 +16911,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-trigger-postgres"
|
||||
version = "1.630.0"
|
||||
version = "1.629.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -16947,7 +16946,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-trigger-sqs"
|
||||
version = "1.630.0"
|
||||
version = "1.629.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -16975,7 +16974,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-trigger-websocket"
|
||||
version = "1.630.0"
|
||||
version = "1.629.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -16998,7 +16997,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-types"
|
||||
version = "1.630.0"
|
||||
version = "1.629.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
@@ -17015,7 +17014,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-worker"
|
||||
version = "1.630.0"
|
||||
version = "1.629.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-once-cell",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "windmill"
|
||||
version = "1.630.0"
|
||||
version = "1.629.1"
|
||||
authors.workspace = true
|
||||
edition.workspace = true
|
||||
|
||||
@@ -59,7 +59,7 @@ members = [
|
||||
exclude = ["./windmill-duckdb-ffi-internal"]
|
||||
|
||||
[workspace.package]
|
||||
version = "1.630.0"
|
||||
version = "1.629.1"
|
||||
authors = ["Ruben Fiszel <ruben@windmill.dev>"]
|
||||
edition = "2021"
|
||||
|
||||
|
||||
@@ -1 +1 @@
|
||||
de9ff0d258f5a1a3739d9693531bad3972ddfd22
|
||||
7596cefdba81482c0b0c0b61be26369f112d8009
|
||||
|
||||
@@ -29,39 +29,7 @@ def load_openapi_spec(file_path: str) -> Dict[str, Any]:
|
||||
print(f"Error loading OpenAPI spec: {e}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
def flatten_allof_schema(schema: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Flatten an allOf schema into a single object schema by merging all properties."""
|
||||
if 'allOf' not in schema:
|
||||
return schema
|
||||
|
||||
merged = {"type": "object", "properties": {}, "required": []}
|
||||
|
||||
def collect_from(s: Dict[str, Any]):
|
||||
if 'allOf' in s:
|
||||
for item in s['allOf']:
|
||||
if isinstance(item, dict):
|
||||
collect_from(item)
|
||||
if 'properties' in s:
|
||||
merged['properties'].update(s['properties'])
|
||||
if 'required' in s and isinstance(s['required'], list):
|
||||
merged['required'].extend(s['required'])
|
||||
if 'description' in s and 'description' not in merged:
|
||||
merged['description'] = s['description']
|
||||
|
||||
collect_from(schema)
|
||||
|
||||
# Preserve additional top-level keys from the original schema
|
||||
preserved_keys = {'additionalProperties', 'title', 'nullable', 'default', 'example'}
|
||||
for key in preserved_keys:
|
||||
if key in schema and key not in merged:
|
||||
merged[key] = schema[key]
|
||||
|
||||
if not merged['required']:
|
||||
del merged['required']
|
||||
|
||||
return merged
|
||||
|
||||
def extract_separate_schemas(parameters: List[Dict[str, Any]], request_body: Optional[Dict[str, Any]], spec: Dict[str, Any], required_fields: Optional[List[str]] = None, base_path: str = "", include_fields: Optional[List[str]] = None, opaque_fields: Optional[List[str]] = None, include_query_params: Optional[List[str]] = None) -> tuple:
|
||||
def extract_separate_schemas(parameters: List[Dict[str, Any]], request_body: Optional[Dict[str, Any]], spec: Dict[str, Any], required_fields: Optional[List[str]] = None, base_path: str = "") -> tuple:
|
||||
"""Extract separate schemas for path parameters, query parameters, and request body."""
|
||||
path_params_schema = {
|
||||
"type": "object",
|
||||
@@ -104,7 +72,7 @@ def extract_separate_schemas(parameters: List[Dict[str, Any]], request_body: Opt
|
||||
path_params_schema['properties'][param_name] = param_schema
|
||||
if param_required:
|
||||
path_params_schema['required'].append(param_name)
|
||||
elif param_in == 'query' and (include_query_params is None or param_name in include_query_params):
|
||||
elif param_in == 'query':
|
||||
query_params_schema['properties'][param_name] = param_schema
|
||||
if param_required:
|
||||
query_params_schema['required'].append(param_name)
|
||||
@@ -112,28 +80,7 @@ def extract_separate_schemas(parameters: List[Dict[str, Any]], request_body: Opt
|
||||
# Process request body if present
|
||||
if request_body:
|
||||
body_schema = extract_request_body_schema(request_body, spec, base_path)
|
||||
|
||||
# Flatten allOf schemas into a single object schema for filtering
|
||||
if body_schema and (include_fields is not None or opaque_fields):
|
||||
body_schema = flatten_allof_schema(body_schema)
|
||||
|
||||
# Apply include_fields filter: only keep listed top-level properties
|
||||
if body_schema and include_fields is not None and 'properties' in body_schema:
|
||||
body_schema['properties'] = {
|
||||
k: v for k, v in body_schema['properties'].items()
|
||||
if k in include_fields
|
||||
}
|
||||
if 'required' in body_schema:
|
||||
body_schema['required'] = [
|
||||
r for r in body_schema['required'] if r in include_fields
|
||||
]
|
||||
|
||||
# Apply opaque_fields: simplify listed properties to {"type": "object"}
|
||||
if body_schema and opaque_fields and 'properties' in body_schema:
|
||||
for field in opaque_fields:
|
||||
if field in body_schema['properties']:
|
||||
body_schema['properties'][field] = {"type": "object"}
|
||||
|
||||
|
||||
# If we have required fields specified and a body schema, update the required array
|
||||
if body_schema and required_fields:
|
||||
if 'required' not in body_schema:
|
||||
@@ -148,60 +95,11 @@ def extract_separate_schemas(parameters: List[Dict[str, Any]], request_body: Opt
|
||||
# Log warning when a required field is missing from schema properties
|
||||
print(f"Warning: Required field '{field}' not found in body schema properties", file=sys.stderr)
|
||||
|
||||
# Sanitize empty schemas for JSON Schema draft 2020-12 compliance
|
||||
path_params_schema = sanitize_empty_schemas(path_params_schema)
|
||||
query_params_schema = sanitize_empty_schemas(query_params_schema)
|
||||
body_schema = sanitize_empty_schemas(body_schema)
|
||||
|
||||
# Convert enums to descriptions for client compatibility
|
||||
path_params_schema = convert_enums_to_descriptions(path_params_schema)
|
||||
query_params_schema = convert_enums_to_descriptions(query_params_schema)
|
||||
body_schema = convert_enums_to_descriptions(body_schema)
|
||||
|
||||
# Detect overlapping property names across schemas and rename with suffixes
|
||||
path_keys = set(path_params_schema['properties'].keys()) if path_params_schema and path_params_schema.get('properties') else set()
|
||||
query_keys = set(query_params_schema['properties'].keys()) if query_params_schema and query_params_schema.get('properties') else set()
|
||||
body_keys = set(body_schema['properties'].keys()) if body_schema and body_schema.get('properties') else set()
|
||||
|
||||
conflicts = (path_keys & query_keys) | (path_keys & body_keys) | (query_keys & body_keys)
|
||||
|
||||
path_field_renames = {}
|
||||
query_field_renames = {}
|
||||
body_field_renames = {}
|
||||
|
||||
for field in conflicts:
|
||||
schemas_and_renames = [
|
||||
(path_params_schema, path_keys, '__path', path_field_renames),
|
||||
(query_params_schema, query_keys, '__query', query_field_renames),
|
||||
(body_schema, body_keys, '__body', body_field_renames),
|
||||
]
|
||||
for schema, keys, suffix, renames_map in schemas_and_renames:
|
||||
if field in keys and schema and 'properties' in schema:
|
||||
new_name = field + suffix
|
||||
# Rename in properties
|
||||
schema['properties'][new_name] = schema['properties'].pop(field)
|
||||
# Update description to clarify the renamed field
|
||||
if 'description' not in schema['properties'][new_name]:
|
||||
schema['properties'][new_name] = dict(schema['properties'][new_name])
|
||||
prop = schema['properties'][new_name]
|
||||
if isinstance(prop, dict):
|
||||
existing_desc = prop.get('description', '')
|
||||
location = suffix.lstrip('_')
|
||||
if not existing_desc:
|
||||
prop['description'] = f"({location} parameter)"
|
||||
else:
|
||||
prop['description'] = f"{existing_desc} ({location} parameter)"
|
||||
# Rename in required array
|
||||
if 'required' in schema and field in schema['required']:
|
||||
schema['required'] = [new_name if r == field else r for r in schema['required']]
|
||||
# Store the reverse mapping: renamed -> original
|
||||
renames_map[new_name] = field
|
||||
|
||||
# Return None for empty schemas
|
||||
path_params_schema = path_params_schema if path_params_schema and path_params_schema.get('properties') else None
|
||||
query_params_schema = query_params_schema if query_params_schema and query_params_schema.get('properties') else None
|
||||
|
||||
return (path_params_schema, query_params_schema, body_schema, path_field_renames, query_field_renames, body_field_renames)
|
||||
path_params_schema = path_params_schema if path_params_schema['properties'] else None
|
||||
query_params_schema = query_params_schema if query_params_schema['properties'] else None
|
||||
|
||||
return (path_params_schema, query_params_schema, body_schema)
|
||||
|
||||
# Cache for loaded external files
|
||||
_external_file_cache: Dict[str, Dict[str, Any]] = {}
|
||||
@@ -262,25 +160,18 @@ def resolve_ref(ref_path: str, spec: Dict[str, Any], base_path: str = "") -> tup
|
||||
|
||||
return (current if isinstance(current, dict) else None), spec
|
||||
|
||||
def resolve_schema_refs(schema: Dict[str, Any], spec: Dict[str, Any], base_path: str = "", _visited_refs: Optional[set] = None) -> Dict[str, Any]:
|
||||
def resolve_schema_refs(schema: Dict[str, Any], spec: Dict[str, Any], base_path: str = "") -> Dict[str, Any]:
|
||||
"""Recursively resolve all $ref references in a schema."""
|
||||
if _visited_refs is None:
|
||||
_visited_refs = set()
|
||||
|
||||
if not isinstance(schema, dict):
|
||||
return schema
|
||||
|
||||
# If this is a $ref, resolve it
|
||||
if '$ref' in schema:
|
||||
ref_path = schema['$ref']
|
||||
if ref_path in _visited_refs:
|
||||
# Circular reference detected - return empty object to break the cycle
|
||||
return {"type": "object"}
|
||||
_visited_refs = _visited_refs | {ref_path}
|
||||
resolved, resolved_spec = resolve_ref(ref_path, spec, base_path)
|
||||
if resolved:
|
||||
# Recursively resolve any refs in the resolved schema using the appropriate spec
|
||||
return resolve_schema_refs(resolved, resolved_spec, base_path, _visited_refs)
|
||||
return resolve_schema_refs(resolved, resolved_spec, base_path)
|
||||
else:
|
||||
print(f"Warning: Could not resolve $ref: {ref_path}")
|
||||
return schema
|
||||
@@ -289,10 +180,10 @@ def resolve_schema_refs(schema: Dict[str, Any], spec: Dict[str, Any], base_path:
|
||||
resolved_schema = {}
|
||||
for key, value in schema.items():
|
||||
if isinstance(value, dict):
|
||||
resolved_schema[key] = resolve_schema_refs(value, spec, base_path, _visited_refs)
|
||||
resolved_schema[key] = resolve_schema_refs(value, spec, base_path)
|
||||
elif isinstance(value, list):
|
||||
resolved_schema[key] = [
|
||||
resolve_schema_refs(item, spec, base_path, _visited_refs) if isinstance(item, dict) else item
|
||||
resolve_schema_refs(item, spec, base_path) if isinstance(item, dict) else item
|
||||
for item in value
|
||||
]
|
||||
else:
|
||||
@@ -300,56 +191,6 @@ def resolve_schema_refs(schema: Dict[str, Any], spec: Dict[str, Any], base_path:
|
||||
|
||||
return resolved_schema
|
||||
|
||||
def convert_enums_to_descriptions(schema: Any) -> Any:
|
||||
"""Recursively convert enum arrays into description text to avoid client compatibility issues."""
|
||||
if isinstance(schema, list):
|
||||
return [convert_enums_to_descriptions(item) for item in schema]
|
||||
if not isinstance(schema, dict):
|
||||
return schema
|
||||
|
||||
result = {}
|
||||
enum_value = None
|
||||
# First pass: copy all non-enum keys so 'description' is available before enum processing
|
||||
for key, value in schema.items():
|
||||
if key == 'enum':
|
||||
enum_value = value
|
||||
else:
|
||||
result[key] = convert_enums_to_descriptions(value)
|
||||
|
||||
# Second pass: process enum using the already-copied description
|
||||
if enum_value is not None:
|
||||
values_str = ', '.join(str(v) for v in enum_value)
|
||||
existing = result.get('description', '')
|
||||
enum_desc = f"Possible values: {values_str}"
|
||||
result['description'] = f"{existing}. {enum_desc}" if existing else enum_desc
|
||||
|
||||
return result
|
||||
|
||||
def sanitize_empty_schemas(schema: Any) -> Any:
|
||||
"""Replace empty {} schemas with valid JSON Schema draft 2020-12 equivalents.
|
||||
|
||||
In OpenAPI, {} means 'any value' but strict JSON Schema validators (e.g. Claude's API)
|
||||
reject empty objects. This converts them to proper schemas.
|
||||
"""
|
||||
if isinstance(schema, list):
|
||||
return [sanitize_empty_schemas(item) for item in schema]
|
||||
if not isinstance(schema, dict):
|
||||
return schema
|
||||
|
||||
result = {}
|
||||
for key, value in schema.items():
|
||||
if key == 'additionalProperties' and isinstance(value, dict) and len(value) == 0:
|
||||
result[key] = True
|
||||
elif key == 'properties' and isinstance(value, dict):
|
||||
# properties is a map of name -> schema; sanitize each property schema
|
||||
result[key] = {
|
||||
k: {"type": "object"} if isinstance(v, dict) and len(v) == 0 else sanitize_empty_schemas(v)
|
||||
for k, v in value.items()
|
||||
}
|
||||
else:
|
||||
result[key] = sanitize_empty_schemas(value)
|
||||
return result
|
||||
|
||||
def extract_request_body_schema(request_body: Dict[str, Any], spec: Dict[str, Any], base_path: str = "") -> Optional[Dict[str, Any]]:
|
||||
"""Extract request body schema from OpenAPI requestBody definition and resolve refs."""
|
||||
if not request_body:
|
||||
@@ -402,9 +243,6 @@ def find_mcp_tools(spec: Dict[str, Any]) -> List[Dict[str, Any]]:
|
||||
'parameters': operation.get('parameters', []),
|
||||
'requestBody': operation.get('requestBody'),
|
||||
'required_fields': operation.get('x-mcp-required-fields', []),
|
||||
'include_fields': operation.get('x-mcp-tool-include-fields'),
|
||||
'opaque_fields': operation.get('x-mcp-tool-opaque-fields'),
|
||||
'include_query_params': operation.get('x-mcp-tool-include-query-params'),
|
||||
}
|
||||
tools.append(tool)
|
||||
|
||||
@@ -440,18 +278,14 @@ export const mcpEndpointTools: EndpointTool[] = [];
|
||||
method = tool['method'].upper()
|
||||
|
||||
# Generate separate schemas
|
||||
path_params_schema, query_params_schema, body_schema, path_field_renames, query_field_renames, body_field_renames = extract_separate_schemas(
|
||||
tool['parameters'], tool['requestBody'], spec, tool['required_fields'], base_path,
|
||||
tool.get('include_fields'), tool.get('opaque_fields'), tool.get('include_query_params')
|
||||
path_params_schema, query_params_schema, body_schema = extract_separate_schemas(
|
||||
tool['parameters'], tool['requestBody'], spec, tool['required_fields'], base_path
|
||||
)
|
||||
|
||||
# Convert schemas to TypeScript - use 'as const' for better type inference
|
||||
path_params_ts = json.dumps(path_params_schema, indent=8) if path_params_schema else "undefined"
|
||||
query_params_ts = json.dumps(query_params_schema, indent=8) if query_params_schema else "undefined"
|
||||
body_schema_ts = json.dumps(body_schema, indent=8) if body_schema else "undefined"
|
||||
path_field_renames_ts = json.dumps(path_field_renames, indent=8) if path_field_renames else "undefined"
|
||||
query_field_renames_ts = json.dumps(query_field_renames, indent=8) if query_field_renames else "undefined"
|
||||
body_field_renames_ts = json.dumps(body_field_renames, indent=8) if body_field_renames else "undefined"
|
||||
|
||||
# Generate tool definition
|
||||
tool_def = f""" {{
|
||||
@@ -462,10 +296,7 @@ export const mcpEndpointTools: EndpointTool[] = [];
|
||||
method: "{method}",
|
||||
pathParamsSchema: {path_params_ts},
|
||||
queryParamsSchema: {query_params_ts},
|
||||
bodySchema: {body_schema_ts},
|
||||
pathFieldRenames: {path_field_renames_ts},
|
||||
queryFieldRenames: {query_field_renames_ts},
|
||||
bodyFieldRenames: {body_field_renames_ts}
|
||||
bodySchema: {body_schema_ts}
|
||||
}}"""
|
||||
tool_definitions.append(tool_def)
|
||||
|
||||
@@ -484,9 +315,6 @@ export interface EndpointTool {{
|
||||
pathParamsSchema?: object;
|
||||
queryParamsSchema?: object;
|
||||
bodySchema?: object;
|
||||
pathFieldRenames?: Record<string, string>;
|
||||
queryFieldRenames?: Record<string, string>;
|
||||
bodyFieldRenames?: Record<string, string>;
|
||||
}}
|
||||
|
||||
export const mcpEndpointTools: EndpointTool[] = [
|
||||
@@ -516,17 +344,13 @@ pub fn all_tools() -> Vec<EndpointTool> {{
|
||||
method = tool['method'].upper()
|
||||
|
||||
# Generate separate schemas
|
||||
path_params_schema, query_params_schema, body_schema, path_field_renames, query_field_renames, body_field_renames = extract_separate_schemas(
|
||||
tool['parameters'], tool['requestBody'], spec, tool['required_fields'], base_path,
|
||||
tool.get('include_fields'), tool.get('opaque_fields'), tool.get('include_query_params')
|
||||
path_params_schema, query_params_schema, body_schema = extract_separate_schemas(
|
||||
tool['parameters'], tool['requestBody'], spec, tool['required_fields'], base_path
|
||||
)
|
||||
|
||||
path_params_rust = schema_to_rust_value(path_params_schema)
|
||||
query_params_rust = schema_to_rust_value(query_params_schema)
|
||||
body_schema_rust = schema_to_rust_value(body_schema)
|
||||
path_field_renames_rust = schema_to_rust_value(path_field_renames if path_field_renames else None)
|
||||
query_field_renames_rust = schema_to_rust_value(query_field_renames if query_field_renames else None)
|
||||
body_field_renames_rust = schema_to_rust_value(body_field_renames if body_field_renames else None)
|
||||
|
||||
# Generate tool definition
|
||||
tool_def = f""" EndpointTool {{
|
||||
@@ -538,9 +362,6 @@ pub fn all_tools() -> Vec<EndpointTool> {{
|
||||
path_params_schema: {path_params_rust},
|
||||
query_params_schema: {query_params_rust},
|
||||
body_schema: {body_schema_rust},
|
||||
path_field_renames: {path_field_renames_rust},
|
||||
query_field_renames: {query_field_renames_rust},
|
||||
body_field_renames: {body_field_renames_rust},
|
||||
}}"""
|
||||
tool_definitions.append(tool_def)
|
||||
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
-- Drop the workspace_protection_rule table and its indexes
|
||||
DROP TABLE IF EXISTS workspace_protection_rule;
|
||||
@@ -1,10 +0,0 @@
|
||||
-- Add workspace_protection_rule table for fine-grained access control
|
||||
CREATE TABLE workspace_protection_rule (
|
||||
workspace_id VARCHAR(50) NOT NULL REFERENCES workspace(id) ON DELETE CASCADE,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
rules INTEGER NOT NULL,
|
||||
bypass_groups TEXT[] NOT NULL DEFAULT '{}',
|
||||
bypass_users TEXT[] NOT NULL DEFAULT '{}',
|
||||
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (workspace_id, name)
|
||||
);
|
||||
@@ -1,159 +0,0 @@
|
||||
use windmill_common::{
|
||||
error::{self, Error},
|
||||
get_database_url, DatabaseUrl,
|
||||
};
|
||||
|
||||
pub const DEFAULT_MAX_CONNECTIONS_SERVER: u32 = 50;
|
||||
pub const DEFAULT_MAX_CONNECTIONS_WORKER: u32 = 5;
|
||||
pub const DEFAULT_MAX_CONNECTIONS_INDEXER: u32 = 5;
|
||||
|
||||
pub async fn initial_connection() -> Result<sqlx::Pool<sqlx::Postgres>, error::Error> {
|
||||
let connect_options = get_database_url().await?.connect_options().await?;
|
||||
sqlx::postgres::PgPoolOptions::new()
|
||||
.max_connections(2)
|
||||
.connect_with(connect_options)
|
||||
.await
|
||||
.map_err(|err| Error::ConnectingToDatabase(err.to_string()))
|
||||
}
|
||||
|
||||
pub async fn connect_db(
|
||||
server_mode: bool,
|
||||
indexer_mode: bool,
|
||||
worker_mode: bool,
|
||||
#[cfg(feature = "private")] mut killpill_rx: tokio::sync::broadcast::Receiver<()>,
|
||||
) -> anyhow::Result<sqlx::Pool<sqlx::Postgres>> {
|
||||
use anyhow::Context;
|
||||
|
||||
let database_url = get_database_url().await?;
|
||||
|
||||
let max_connections = match std::env::var("DATABASE_CONNECTIONS") {
|
||||
Ok(n) => n.parse::<u32>().context("invalid DATABASE_CONNECTIONS")?,
|
||||
Err(_) => {
|
||||
if server_mode {
|
||||
DEFAULT_MAX_CONNECTIONS_SERVER
|
||||
} else if indexer_mode {
|
||||
DEFAULT_MAX_CONNECTIONS_INDEXER
|
||||
} else {
|
||||
DEFAULT_MAX_CONNECTIONS_WORKER
|
||||
+ std::env::var("NUM_WORKERS")
|
||||
.ok()
|
||||
.map(|x| x.parse().ok())
|
||||
.flatten()
|
||||
.unwrap_or(1)
|
||||
- 1
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let pool = connect(database_url.clone(), max_connections, worker_mode).await?;
|
||||
#[cfg(all(feature = "enterprise", feature = "private"))]
|
||||
let pool2 = pool.clone();
|
||||
#[cfg(all(feature = "enterprise", feature = "private"))]
|
||||
if let DatabaseUrl::IamRds(database_url) = database_url {
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = killpill_rx.recv() => {
|
||||
break;
|
||||
}
|
||||
_ = tokio::time::sleep(std::time::Duration::from_secs(10)) => {
|
||||
let needs_refresh = {
|
||||
let read_guard = database_url.read().await;
|
||||
read_guard.needs_refresh()
|
||||
};
|
||||
if needs_refresh {
|
||||
let new_url = tokio::time::timeout(std::time::Duration::from_secs(10), get_database_url()).await;
|
||||
match new_url {
|
||||
Ok(Ok(new_url)) => {
|
||||
match new_url.connect_options().await {
|
||||
Ok(connect_options) => {
|
||||
pool2.set_connect_options(connect_options);
|
||||
tracing::info!("Refreshed IAM RDS URL successfully");
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("Error getting IAM RDS connect options, retrying in 10s: {}", e);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(Err(e)) => {
|
||||
tracing::error!("Error refreshing IAM RDS URL, trying again in 10s: {}", e);
|
||||
continue;
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("Timeout after 10s refreshing IAM RDS URL, trying again in 10 seconds: {}", e);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Ok(pool)
|
||||
}
|
||||
|
||||
pub async fn connect(
|
||||
database_url: DatabaseUrl,
|
||||
max_connections: u32,
|
||||
worker_mode: bool,
|
||||
) -> Result<sqlx::Pool<sqlx::Postgres>, error::Error> {
|
||||
use sqlx::Executor;
|
||||
use std::time::Duration;
|
||||
let mut pool_options = sqlx::postgres::PgPoolOptions::new()
|
||||
.min_connections((max_connections / 5).clamp(1, max_connections))
|
||||
.max_connections(max_connections)
|
||||
.max_lifetime(Duration::from_secs(30 * 60)); // 30 mins
|
||||
if worker_mode {
|
||||
pool_options = pool_options.idle_timeout(Duration::from_secs(60));
|
||||
}
|
||||
pool_options
|
||||
.after_connect(move |conn, _| {
|
||||
if worker_mode {
|
||||
Box::pin(async move {
|
||||
if let Err(e) = conn
|
||||
.execute(
|
||||
r#"
|
||||
SET enable_seqscan = OFF;
|
||||
SET statement_timeout = '5min';
|
||||
SET idle_in_transaction_session_timeout = '10min';
|
||||
SET tcp_keepalives_idle = 300;
|
||||
SET tcp_keepalives_interval = 60;
|
||||
SET tcp_keepalives_count = 10;"#,
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::error!("Error setting postgres settings: {}", e);
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
} else {
|
||||
Box::pin(async move {
|
||||
if let Err(e) = conn
|
||||
.execute(
|
||||
r#"
|
||||
SET statement_timeout = '5min';
|
||||
SET idle_in_transaction_session_timeout = '10min';
|
||||
SET tcp_keepalives_idle = 300;
|
||||
SET tcp_keepalives_interval = 60;
|
||||
SET tcp_keepalives_count = 10;"#,
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::error!("Error setting postgres settings: {}", e);
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
})
|
||||
.connect_with(
|
||||
database_url
|
||||
.connect_options()
|
||||
.await?
|
||||
.statement_cache_capacity(400),
|
||||
)
|
||||
.await
|
||||
.map_err(|err| Error::ConnectingToDatabase(err.to_string()))
|
||||
}
|
||||
@@ -117,7 +117,6 @@ const BIND_ADDR_ENV: &str = "SERVER_BIND_ADDR";
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
mod cgroups;
|
||||
mod db_connect;
|
||||
#[cfg(feature = "private")]
|
||||
pub mod ee;
|
||||
mod ee_oss;
|
||||
@@ -666,7 +665,7 @@ async fn windmill_main() -> anyhow::Result<()> {
|
||||
} else {
|
||||
println!("Connecting to database...");
|
||||
|
||||
let db = crate::db_connect::initial_connection().await?;
|
||||
let db = windmill_common::initial_connection().await?;
|
||||
|
||||
let num_version = sqlx::query_scalar!("SELECT version()").fetch_one(&db).await;
|
||||
|
||||
@@ -771,12 +770,8 @@ async fn windmill_main() -> anyhow::Result<()> {
|
||||
let conn = if mode == Mode::Agent {
|
||||
conn
|
||||
} else {
|
||||
// Drop the initial connection pool before creating the main one.
|
||||
// With low PostgreSQL max_connections, both pools existing simultaneously
|
||||
// can exhaust all available connection slots, causing connect_db to hang.
|
||||
drop(conn);
|
||||
|
||||
let db = crate::db_connect::connect_db(
|
||||
// This time we use a pool of connections
|
||||
let db = windmill_common::connect_db(
|
||||
server_mode,
|
||||
indexer_mode,
|
||||
worker_mode,
|
||||
|
||||
@@ -3092,13 +3092,7 @@ RETURNING job_id"
|
||||
|
||||
async fn cleanup_job_result_stream_orphaned_jobs(db: &DB) -> error::Result<()> {
|
||||
let result = sqlx::query!(
|
||||
"DELETE FROM job_result_stream_v2
|
||||
WHERE job_id NOT IN (SELECT id FROM v2_job_queue)
|
||||
AND job_id NOT IN (
|
||||
SELECT id FROM v2_job_completed
|
||||
WHERE completed_at > NOW() - INTERVAL '60 seconds'
|
||||
)
|
||||
RETURNING job_id",
|
||||
"DELETE FROM job_result_stream_v2 WHERE job_id NOT IN (SELECT id FROM v2_job_queue) RETURNING job_id",
|
||||
)
|
||||
.fetch_all(db)
|
||||
.await?;
|
||||
|
||||
@@ -1,208 +0,0 @@
|
||||
//! Integration tests for workspace protection rulesets.
|
||||
//!
|
||||
//! Tests verify that DisableDirectDeployment protection rules correctly
|
||||
//! block/allow operations based on user permissions.
|
||||
|
||||
use serde_json::json;
|
||||
use sqlx::{Pool, Postgres};
|
||||
use windmill_common::workspaces::invalidate_protection_rules_cache;
|
||||
|
||||
mod common;
|
||||
use common::*;
|
||||
|
||||
fn client() -> reqwest::Client {
|
||||
reqwest::Client::new()
|
||||
}
|
||||
|
||||
fn authed(builder: reqwest::RequestBuilder, token: &str) -> reqwest::RequestBuilder {
|
||||
builder.header("Authorization", format!("Bearer {}", token))
|
||||
}
|
||||
|
||||
fn new_script(path: &str, summary: &str) -> serde_json::Value {
|
||||
json!({
|
||||
"path": path,
|
||||
"summary": summary,
|
||||
"description": "",
|
||||
"content": "export async function main() { return 42; }",
|
||||
"language": "deno",
|
||||
"schema": {
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
"required": []
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn new_flow(path: &str, summary: &str) -> serde_json::Value {
|
||||
json!({
|
||||
"path": path,
|
||||
"summary": summary,
|
||||
"description": "",
|
||||
"value": { "modules": [] },
|
||||
"schema": {
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
"required": []
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Comprehensive test for protection rules functionality.
|
||||
/// Tests all essential cases in a single test to avoid cache interference.
|
||||
#[sqlx::test(fixtures("base"))]
|
||||
async fn test_protection_rules(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
initialize_tracing().await;
|
||||
invalidate_protection_rules_cache("test-workspace");
|
||||
|
||||
let server = ApiServer::start(db.clone()).await?;
|
||||
let port = server.addr.port();
|
||||
let base = format!("http://localhost:{port}/api/w/test-workspace");
|
||||
|
||||
// ========================================
|
||||
// 1. Without protection rule, non-admin can create scripts and flows
|
||||
// ========================================
|
||||
|
||||
let resp = authed(client().post(format!("{base}/scripts/create")), "SECRET_TOKEN_2")
|
||||
.json(&new_script("u/test-user-2/script_no_rule", "No rule"))
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(resp.status(), 201, "Should create script without rule: {}", resp.text().await?);
|
||||
|
||||
let resp = authed(client().post(format!("{base}/flows/create")), "SECRET_TOKEN_2")
|
||||
.json(&new_flow("u/test-user-2/flow_no_rule", "No rule"))
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(resp.status(), 201, "Should create flow without rule: {}", resp.text().await?);
|
||||
|
||||
// ========================================
|
||||
// 2. Non-admin cannot create protection rules
|
||||
// ========================================
|
||||
|
||||
let resp = authed(client().post(format!("{base}/workspaces/protection_rules")), "SECRET_TOKEN_2")
|
||||
.json(&json!({
|
||||
"name": "unauthorized-rule",
|
||||
"rules": ["DisableDirectDeployment"],
|
||||
"bypass_users": [],
|
||||
"bypass_groups": []
|
||||
}))
|
||||
.send()
|
||||
.await?;
|
||||
assert!(!resp.status().is_success(), "Non-admin should not create rules: {}", resp.status());
|
||||
|
||||
// ========================================
|
||||
// 3. Admin creates protection rule
|
||||
// ========================================
|
||||
|
||||
let resp = authed(client().post(format!("{base}/workspaces/protection_rules")), "SECRET_TOKEN")
|
||||
.json(&json!({
|
||||
"name": "test-rule",
|
||||
"rules": ["DisableDirectDeployment"],
|
||||
"bypass_users": [],
|
||||
"bypass_groups": []
|
||||
}))
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(resp.status(), 200, "Admin should create rule: {}", resp.text().await?);
|
||||
|
||||
// ========================================
|
||||
// 4. With rule, non-admin is blocked from creating scripts/flows
|
||||
// ========================================
|
||||
|
||||
let resp = authed(client().post(format!("{base}/scripts/create")), "SECRET_TOKEN_2")
|
||||
.json(&new_script("u/test-user-2/blocked_script", "Blocked"))
|
||||
.send()
|
||||
.await?;
|
||||
assert!(!resp.status().is_success(), "Non-admin should be blocked from scripts: {}", resp.status());
|
||||
let body = resp.text().await?;
|
||||
assert!(body.contains("blocked") || body.contains("Blocked"), "Error should mention blocking: {}", body);
|
||||
|
||||
let resp = authed(client().post(format!("{base}/flows/create")), "SECRET_TOKEN_2")
|
||||
.json(&new_flow("u/test-user-2/blocked_flow", "Blocked"))
|
||||
.send()
|
||||
.await?;
|
||||
assert!(!resp.status().is_success(), "Non-admin should be blocked from flows: {}", resp.status());
|
||||
|
||||
// ========================================
|
||||
// 5. Admin bypasses protection rule
|
||||
// ========================================
|
||||
|
||||
let resp = authed(client().post(format!("{base}/scripts/create")), "SECRET_TOKEN")
|
||||
.json(&new_script("u/test-user/admin_script", "Admin"))
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(resp.status(), 201, "Admin should bypass rule: {}", resp.text().await?);
|
||||
|
||||
// ========================================
|
||||
// 6. Update rule to bypass test-user-2
|
||||
// ========================================
|
||||
|
||||
let resp = authed(client().post(format!("{base}/workspaces/protection_rules/test-rule")), "SECRET_TOKEN")
|
||||
.json(&json!({
|
||||
"rules": ["DisableDirectDeployment"],
|
||||
"bypass_users": ["test-user-2"],
|
||||
"bypass_groups": []
|
||||
}))
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(resp.status(), 200, "Should update rule: {}", resp.text().await?);
|
||||
|
||||
// Invalidate cache to pick up the update
|
||||
invalidate_protection_rules_cache("test-workspace");
|
||||
|
||||
// ========================================
|
||||
// 7. Bypassed user can now create
|
||||
// ========================================
|
||||
|
||||
let resp = authed(client().post(format!("{base}/scripts/create")), "SECRET_TOKEN_2")
|
||||
.json(&new_script("u/test-user-2/bypassed_script", "Bypassed"))
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(resp.status(), 201, "Bypassed user should create: {}", resp.text().await?);
|
||||
|
||||
// ========================================
|
||||
// 8. Non-bypassed user (test-user-3) is still blocked
|
||||
// ========================================
|
||||
|
||||
let resp = authed(client().post(format!("{base}/scripts/create")), "SECRET_TOKEN_3")
|
||||
.json(&new_script("u/test-user-3/still_blocked", "Blocked"))
|
||||
.send()
|
||||
.await?;
|
||||
assert!(!resp.status().is_success(), "Non-bypassed user should be blocked: {}", resp.status());
|
||||
|
||||
// ========================================
|
||||
// 9. Delete rule
|
||||
// ========================================
|
||||
|
||||
let resp = authed(client().delete(format!("{base}/workspaces/protection_rules/test-rule")), "SECRET_TOKEN")
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(resp.status(), 200, "Should delete rule: {}", resp.text().await?);
|
||||
|
||||
// Invalidate cache to pick up the deletion
|
||||
invalidate_protection_rules_cache("test-workspace");
|
||||
|
||||
// ========================================
|
||||
// 10. After deletion, non-admin can create again
|
||||
// ========================================
|
||||
|
||||
let resp = authed(client().post(format!("{base}/scripts/create")), "SECRET_TOKEN_3")
|
||||
.json(&new_script("u/test-user-3/after_delete", "After delete"))
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(resp.status(), 201, "Should create after rule deletion: {}", resp.text().await?);
|
||||
|
||||
// ========================================
|
||||
// 11. Verify rule list is empty
|
||||
// ========================================
|
||||
|
||||
let resp = authed(client().get(format!("{base}/workspaces/protection_rules")), "SECRET_TOKEN")
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(resp.status(), 200);
|
||||
let rules: Vec<serde_json::Value> = resp.json().await?;
|
||||
assert!(rules.is_empty(), "Should have no rules after deletion");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -10,11 +10,22 @@ if [[ "$(uname)" == "Darwin" ]]; then
|
||||
# Uncomment the git-based samael dependency
|
||||
sed -i '' 's/^# \(samael = { git="https:\/\/github.com\/njaremko\/samael", rev="464d015e3ae393e4b5dd00b4d6baa1b617de0dd6", features = \["xmlsec"\] }\)/\1/' Cargo.toml
|
||||
|
||||
# Run cargo sqlx prepare with deno_core_mac
|
||||
# Run cargo sqlx prepare with deno_core_mac
|
||||
echo "Running cargo sqlx prepare with deno_core_mac..."
|
||||
cargo sqlx prepare --workspace -- --all-targets --features all_sqlx_features,private,deno_core_mac
|
||||
else
|
||||
# Run cargo sqlx prepare
|
||||
echo "Running cargo sqlx prepare..."
|
||||
cargo sqlx prepare --workspace -- --all-targets --features all_sqlx_features,ee
|
||||
cargo sqlx prepare --workspace -- --all-targets --features all_sqlx_features,private
|
||||
fi
|
||||
|
||||
|
||||
|
||||
# Undo the samael changes on macOS
|
||||
if [[ "$(uname)" == "Darwin" ]]; then
|
||||
echo "Reverting samael changes..."
|
||||
# Uncomment the version-based samael dependency
|
||||
sed -i '' 's/^#samael = { version="0.0.14", features = \["xmlsec"\] }/samael = { version="0.0.14", features = ["xmlsec"] }/' Cargo.toml
|
||||
# Comment out the git-based samael dependency
|
||||
sed -i '' 's/^\(samael = { git="https:\/\/github.com\/njaremko\/samael", rev="464d015e3ae393e4b5dd00b4d6baa1b617de0dd6", features = \["xmlsec"\] }\)/# \1/' Cargo.toml
|
||||
fi
|
||||
|
||||
BIN
backend/v8.snap
Normal file
BIN
backend/v8.snap
Normal file
Binary file not shown.
@@ -16,9 +16,9 @@ use axum::{
|
||||
use lazy_static::lazy_static;
|
||||
use regex::Regex;
|
||||
use windmill_api_auth::{check_scopes, ApiAuthed, AuthCache, Tokened};
|
||||
use windmill_audit::audit_oss::{audit_log, AuditAuthorable};
|
||||
use windmill_audit::audit_oss::audit_log;
|
||||
use windmill_audit::ActionKind;
|
||||
use windmill_common::{error::Error, webhook::{WebhookMessage, WebhookShared}, workspaces::{check_user_against_rule, ProtectionRuleKind, RuleCheckResult}};
|
||||
use windmill_common::webhook::{WebhookMessage, WebhookShared};
|
||||
use windmill_common::DB;
|
||||
use windmill_common::{
|
||||
db::UserDB,
|
||||
@@ -174,19 +174,6 @@ async fn create_folder(
|
||||
Path(w_id): Path<String>,
|
||||
Json(ng): Json<NewFolder>,
|
||||
) -> Result<String> {
|
||||
if let RuleCheckResult::Blocked(msg) = check_user_against_rule(
|
||||
&w_id,
|
||||
&ProtectionRuleKind::DisableDirectDeployment,
|
||||
AuditAuthorable::username(&authed),
|
||||
&authed.groups,
|
||||
authed.is_admin,
|
||||
&db,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
return Err(Error::PermissionDenied(msg));
|
||||
}
|
||||
|
||||
let mut tx = user_db.clone().begin(&authed).await?;
|
||||
|
||||
if !VALID_FOLDER_NAME.is_match(&ng.name) {
|
||||
@@ -328,19 +315,6 @@ async fn update_folder(
|
||||
) -> Result<String> {
|
||||
use sql_builder::prelude::*;
|
||||
|
||||
if let RuleCheckResult::Blocked(msg) = check_user_against_rule(
|
||||
&w_id,
|
||||
&ProtectionRuleKind::DisableDirectDeployment,
|
||||
AuditAuthorable::username(&authed),
|
||||
&authed.groups,
|
||||
authed.is_admin,
|
||||
&db,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
return Err(Error::PermissionDenied(msg));
|
||||
}
|
||||
|
||||
let mut sqlb = SqlBuilder::update_table("folder");
|
||||
sqlb.and_where_eq("name", "?".bind(&name));
|
||||
sqlb.and_where_eq("workspace_id", "?".bind(&w_id));
|
||||
@@ -630,18 +604,6 @@ async fn delete_folder(
|
||||
Extension(webhook): Extension<WebhookShared>,
|
||||
Path((w_id, name)): Path<(String, String)>,
|
||||
) -> Result<String> {
|
||||
if let RuleCheckResult::Blocked(msg) = check_user_against_rule(
|
||||
&w_id,
|
||||
&ProtectionRuleKind::DisableDirectDeployment,
|
||||
AuditAuthorable::username(&authed),
|
||||
&authed.groups,
|
||||
authed.is_admin,
|
||||
&db,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
return Err(Error::PermissionDenied(msg));
|
||||
}
|
||||
let mut tx = user_db.begin(&authed).await?;
|
||||
|
||||
not_found_if_none(get_folderopt(&mut tx, &w_id, &name).await?, "Folder", &name)?;
|
||||
|
||||
@@ -18,7 +18,6 @@ use windmill_api_auth::{
|
||||
auth::{list_tokens_internal, TruncatedTokenWithEmail},
|
||||
check_scopes, maybe_refresh_folders, require_owner_of_path, ApiAuthed,
|
||||
};
|
||||
use windmill_common::workspaces::{check_user_against_rule, ProtectionRuleKind, RuleCheckResult};
|
||||
use windmill_common::{
|
||||
utils::{WithStarredInfoQuery, HTTP_CLIENT},
|
||||
webhook::{WebhookMessage, WebhookShared},
|
||||
@@ -30,7 +29,7 @@ use hyper::StatusCode;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sql_builder::prelude::*;
|
||||
use sqlx::{FromRow, Postgres, Transaction};
|
||||
use windmill_audit::audit_oss::{audit_log, AuditAuthorable};
|
||||
use windmill_audit::audit_oss::audit_log;
|
||||
use windmill_audit::ActionKind;
|
||||
use windmill_common::assets::{clear_static_asset_usage, AssetUsageKind};
|
||||
use windmill_common::min_version::{
|
||||
@@ -430,20 +429,6 @@ async fn create_flow(
|
||||
));
|
||||
}
|
||||
check_scopes(&authed, || format!("flows:write:{}", nf.path))?;
|
||||
|
||||
if let RuleCheckResult::Blocked(msg) = check_user_against_rule(
|
||||
&w_id,
|
||||
&ProtectionRuleKind::DisableDirectDeployment,
|
||||
AuditAuthorable::username(&authed),
|
||||
&authed.groups,
|
||||
authed.is_admin,
|
||||
&db,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
return Err(Error::PermissionDenied(msg));
|
||||
}
|
||||
|
||||
validate_flow(&nf).await?;
|
||||
if *CLOUD_HOSTED {
|
||||
let nb_flows =
|
||||
@@ -878,20 +863,6 @@ async fn update_flow(
|
||||
}
|
||||
let flow_path = flow_path.to_path();
|
||||
check_scopes(&authed, || format!("flows:write:{}", flow_path))?;
|
||||
|
||||
if let RuleCheckResult::Blocked(msg) = check_user_against_rule(
|
||||
&w_id,
|
||||
&ProtectionRuleKind::DisableDirectDeployment,
|
||||
AuditAuthorable::username(&authed),
|
||||
&authed.groups,
|
||||
authed.is_admin,
|
||||
&db,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
return Err(Error::PermissionDenied(msg));
|
||||
}
|
||||
|
||||
validate_flow(&nf).await?;
|
||||
|
||||
let authed = maybe_refresh_folders(&flow_path, &w_id, authed, &db).await;
|
||||
@@ -1446,18 +1417,6 @@ async fn archive_flow_by_path(
|
||||
) -> Result<String> {
|
||||
let path = path.to_path();
|
||||
check_scopes(&authed, || format!("flows:write:{}", path))?;
|
||||
if let RuleCheckResult::Blocked(msg) = check_user_against_rule(
|
||||
&w_id,
|
||||
&ProtectionRuleKind::DisableDirectDeployment,
|
||||
AuditAuthorable::username(&authed),
|
||||
&authed.groups,
|
||||
authed.is_admin,
|
||||
&db,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
return Err(Error::PermissionDenied(msg));
|
||||
}
|
||||
let mut tx = user_db.begin(&authed).await?;
|
||||
|
||||
sqlx::query!(
|
||||
@@ -1563,18 +1522,6 @@ async fn delete_flow_by_path(
|
||||
) -> Result<String> {
|
||||
let path = path.to_path();
|
||||
check_scopes(&authed, || format!("flows:write:{}", path))?;
|
||||
if let RuleCheckResult::Blocked(msg) = check_user_against_rule(
|
||||
&w_id,
|
||||
&ProtectionRuleKind::DisableDirectDeployment,
|
||||
AuditAuthorable::username(&authed),
|
||||
&authed.groups,
|
||||
authed.is_admin,
|
||||
&db,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
return Err(Error::PermissionDenied(msg));
|
||||
}
|
||||
let mut tx = user_db.begin(&authed).await?;
|
||||
|
||||
sqlx::query!(
|
||||
|
||||
@@ -12,7 +12,9 @@ use windmill_api_auth::{
|
||||
check_scopes, maybe_refresh_folders, require_owner_of_path, ApiAuthed,
|
||||
};
|
||||
use windmill_common::{
|
||||
utils::{BulkDeleteRequest, WithStarredInfoQuery, HTTP_CLIENT}, webhook::{WebhookMessage, WebhookShared}, workspaces::{check_user_against_rule, ProtectionRuleKind, RuleCheckResult}, DB
|
||||
utils::{BulkDeleteRequest, WithStarredInfoQuery, HTTP_CLIENT},
|
||||
webhook::{WebhookMessage, WebhookShared},
|
||||
DB,
|
||||
};
|
||||
use windmill_queue::schedule::clear_schedule;
|
||||
|
||||
@@ -33,7 +35,7 @@ use serde_json::value::RawValue;
|
||||
use sql_builder::prelude::*;
|
||||
use sqlx::{FromRow, Postgres, Transaction};
|
||||
use std::{collections::HashMap, sync::Arc};
|
||||
use windmill_audit::audit_oss::{audit_log, AuditAuthorable};
|
||||
use windmill_audit::audit_oss::audit_log;
|
||||
use windmill_audit::ActionKind;
|
||||
use windmill_dep_map::process_relative_imports;
|
||||
use windmill_dep_map::scoped_dependency_map::ScopedDependencyMap;
|
||||
@@ -546,18 +548,6 @@ async fn create_script(
|
||||
Path(w_id): Path<String>,
|
||||
Json(ns): Json<NewScript>,
|
||||
) -> Result<(StatusCode, String)> {
|
||||
if let RuleCheckResult::Blocked(msg) = check_user_against_rule(
|
||||
&w_id,
|
||||
&ProtectionRuleKind::DisableDirectDeployment,
|
||||
AuditAuthorable::username(&authed),
|
||||
&authed.groups,
|
||||
authed.is_admin,
|
||||
&db,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
return Err(Error::PermissionDenied(msg));
|
||||
}
|
||||
let (hash, tx, hdm) =
|
||||
create_script_internal(ns, w_id, authed, db.clone(), user_db, webhook).await?;
|
||||
tx.commit().await?;
|
||||
@@ -1916,18 +1906,6 @@ async fn archive_script_by_path(
|
||||
}
|
||||
let path = path.to_path();
|
||||
check_scopes(&authed, || format!("scripts:write:{}", path))?;
|
||||
if let RuleCheckResult::Blocked(msg) = check_user_against_rule(
|
||||
&w_id,
|
||||
&ProtectionRuleKind::DisableDirectDeployment,
|
||||
AuditAuthorable::username(&authed),
|
||||
&authed.groups,
|
||||
authed.is_admin,
|
||||
&db,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
return Err(Error::PermissionDenied(msg));
|
||||
}
|
||||
let mut tx = user_db.begin(&authed).await?;
|
||||
|
||||
require_owner_of_path(&authed, path)?;
|
||||
@@ -1995,18 +1973,6 @@ async fn archive_script_by_hash(
|
||||
"Operators cannot archive scripts for security reasons".to_string(),
|
||||
));
|
||||
}
|
||||
if let RuleCheckResult::Blocked(msg) = check_user_against_rule(
|
||||
&w_id,
|
||||
&ProtectionRuleKind::DisableDirectDeployment,
|
||||
AuditAuthorable::username(&authed),
|
||||
&authed.groups,
|
||||
authed.is_admin,
|
||||
&db,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
return Err(Error::PermissionDenied(msg));
|
||||
}
|
||||
let mut tx = user_db.begin(&authed).await?;
|
||||
|
||||
let script = sqlx::query_as::<_, Script<ScriptRunnableSettingsHandle>>(
|
||||
@@ -2052,20 +2018,9 @@ async fn delete_script_by_hash(
|
||||
Extension(db): Extension<DB>,
|
||||
Path((w_id, hash)): Path<(String, ScriptHash)>,
|
||||
) -> JsonResult<Script<ScriptRunnableSettingsInline>> {
|
||||
require_admin(authed.is_admin, &authed.username)?;
|
||||
if let RuleCheckResult::Blocked(msg) = check_user_against_rule(
|
||||
&w_id,
|
||||
&ProtectionRuleKind::DisableDirectDeployment,
|
||||
AuditAuthorable::username(&authed),
|
||||
&authed.groups,
|
||||
authed.is_admin,
|
||||
&db,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
return Err(Error::PermissionDenied(msg));
|
||||
}
|
||||
let mut tx = user_db.begin(&authed).await?;
|
||||
|
||||
require_admin(authed.is_admin, &authed.username)?;
|
||||
let script = sqlx::query_as::<_, Script<ScriptRunnableSettingsHandle>>(
|
||||
"UPDATE script SET content = '', archived = true, deleted = true, lock = '', schema = null WHERE hash = $1 AND \
|
||||
workspace_id = $2 RETURNING *",
|
||||
@@ -2123,19 +2078,6 @@ async fn delete_script_by_path(
|
||||
));
|
||||
}
|
||||
|
||||
if let RuleCheckResult::Blocked(msg) = check_user_against_rule(
|
||||
&w_id,
|
||||
&ProtectionRuleKind::DisableDirectDeployment,
|
||||
AuditAuthorable::username(&authed),
|
||||
&authed.groups,
|
||||
authed.is_admin,
|
||||
&db,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
return Err(Error::PermissionDenied(msg));
|
||||
}
|
||||
|
||||
let mut tx = user_db.begin(&authed).await?;
|
||||
|
||||
let draft_only = sqlx::query_scalar!(
|
||||
@@ -2262,19 +2204,6 @@ async fn delete_scripts_bulk(
|
||||
));
|
||||
}
|
||||
|
||||
if let RuleCheckResult::Blocked(msg) = check_user_against_rule(
|
||||
&w_id,
|
||||
&ProtectionRuleKind::DisableDirectDeployment,
|
||||
AuditAuthorable::username(&authed),
|
||||
&authed.groups,
|
||||
authed.is_admin,
|
||||
&db,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
return Err(Error::PermissionDenied(msg));
|
||||
}
|
||||
|
||||
let mut tx = db.begin().await?;
|
||||
|
||||
let mut deleted_paths = sqlx::query_scalar!(
|
||||
|
||||
@@ -39,4 +39,3 @@ sqlx.workspace = true
|
||||
tokio.workspace = true
|
||||
tracing.workspace = true
|
||||
uuid.workspace = true
|
||||
strum.workspace = true
|
||||
|
||||
@@ -26,8 +26,7 @@ use hex;
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use uuid::Uuid;
|
||||
use strum::{IntoEnumIterator};
|
||||
use windmill_audit::audit_oss::{audit_log, AuditAuthorable};
|
||||
use windmill_audit::audit_oss::audit_log;
|
||||
use windmill_audit::ActionKind;
|
||||
use windmill_common::db::UserDB;
|
||||
use windmill_common::s3_helpers::LargeFileStorage;
|
||||
@@ -39,7 +38,8 @@ use windmill_common::workspaces::GitRepositorySettings;
|
||||
#[cfg(feature = "enterprise")]
|
||||
use windmill_common::workspaces::WorkspaceDeploymentUISettings;
|
||||
use windmill_common::workspaces::{
|
||||
check_user_against_rule, get_datatable_resource_from_db_unchecked, DataTable, DataTableCatalogResourceType, ProtectionRuleKind, ProtectionRules, ProtectionRuleset, RuleCheckResult, WorkspaceGitSyncSettings
|
||||
get_datatable_resource_from_db_unchecked, DataTable, DataTableCatalogResourceType,
|
||||
WorkspaceGitSyncSettings,
|
||||
};
|
||||
use windmill_common::workspaces::{Ducklake, DucklakeCatalogResourceType};
|
||||
use windmill_common::PgDatabase;
|
||||
@@ -147,12 +147,6 @@ pub fn workspaced_service() -> Router {
|
||||
post(reset_workspace_diffs),
|
||||
)
|
||||
.route("/compare/:target_workspace_id", get(compare_workspaces))
|
||||
.route("/protection_rules", get(list_protection_rules))
|
||||
.route("/protection_rules", post(create_protection_rule))
|
||||
.route(
|
||||
"/protection_rules/:rule_name",
|
||||
post(update_protection_rule).delete(delete_protection_rule),
|
||||
)
|
||||
}
|
||||
pub fn global_service() -> Router {
|
||||
Router::new()
|
||||
@@ -3388,18 +3382,6 @@ async fn create_workspace_fork_branch(
|
||||
if *DISABLE_WORKSPACE_FORK {
|
||||
require_super_admin(&db, &authed.email).await?;
|
||||
}
|
||||
if let RuleCheckResult::Blocked(msg) = check_user_against_rule(
|
||||
&w_id,
|
||||
&ProtectionRuleKind::DisableWorkspaceForking,
|
||||
AuditAuthorable::username(&authed),
|
||||
&authed.groups,
|
||||
authed.is_admin,
|
||||
&db,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
return Err(Error::PermissionDenied(msg));
|
||||
}
|
||||
|
||||
Ok(Json(
|
||||
handle_fork_branch_creation(&authed.email, &authed.username, &db, &w_id, &nw.id).await?,
|
||||
@@ -3421,18 +3403,6 @@ async fn create_workspace_fork(
|
||||
if *DISABLE_WORKSPACE_FORK {
|
||||
require_super_admin(&db, &authed.email).await?;
|
||||
}
|
||||
if let RuleCheckResult::Blocked(msg) = check_user_against_rule(
|
||||
&parent_workspace_id,
|
||||
&ProtectionRuleKind::DisableWorkspaceForking,
|
||||
AuditAuthorable::username(&authed),
|
||||
&authed.groups,
|
||||
authed.is_admin,
|
||||
&db,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
return Err(Error::PermissionDenied(msg));
|
||||
}
|
||||
|
||||
let mut tx: Transaction<'_, Postgres> = db.begin().await?;
|
||||
|
||||
@@ -4315,268 +4285,6 @@ async fn update_operator_settings(
|
||||
Ok("Operator settings updated successfully".to_string())
|
||||
}
|
||||
|
||||
// Protection Rules API endpoints
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct CreateProtectionRuleRequest {
|
||||
name: String,
|
||||
rules: Vec<ProtectionRuleKind>,
|
||||
bypass_groups: Vec<String>,
|
||||
bypass_users: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct UpdateProtectionRuleRequest {
|
||||
rules: Vec<ProtectionRuleKind>,
|
||||
bypass_groups: Vec<String>,
|
||||
bypass_users: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct ProtectionRulesetResponse {
|
||||
pub workspace_id: String,
|
||||
pub name: String,
|
||||
pub rules: Vec<ProtectionRuleKind>,
|
||||
pub bypass_groups: Vec<String>,
|
||||
pub bypass_users: Vec<String>,
|
||||
}
|
||||
|
||||
impl From<ProtectionRuleset> for ProtectionRulesetResponse {
|
||||
fn from(value: ProtectionRuleset) -> Self {
|
||||
let mut rules = vec![];
|
||||
|
||||
for rule in ProtectionRuleKind::iter() {
|
||||
if value.rules.contains(rule.flag()) {
|
||||
rules.push(rule)
|
||||
}
|
||||
}
|
||||
|
||||
ProtectionRulesetResponse {
|
||||
rules,
|
||||
workspace_id: value.workspace_id,
|
||||
name: value.name,
|
||||
bypass_groups: value.bypass_groups,
|
||||
bypass_users: value.bypass_users,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// List all protection rules for a workspace
|
||||
async fn list_protection_rules(
|
||||
Extension(db): Extension<DB>,
|
||||
Path(w_id): Path<String>,
|
||||
) -> JsonResult<Vec<ProtectionRulesetResponse>> {
|
||||
let rules =
|
||||
(*windmill_common::workspaces::get_protection_rules(&w_id, &db).await?).clone();
|
||||
Ok(Json(rules.into_iter().map(ProtectionRulesetResponse::from).collect()))
|
||||
}
|
||||
|
||||
/// Create a new protection rule
|
||||
async fn create_protection_rule(
|
||||
authed: ApiAuthed,
|
||||
Extension(db): Extension<DB>,
|
||||
Path(w_id): Path<String>,
|
||||
Json(req): Json<CreateProtectionRuleRequest>,
|
||||
) -> Result<String> {
|
||||
require_admin(authed.is_admin, &authed.username)?;
|
||||
|
||||
let mut tx = db.begin().await?;
|
||||
|
||||
// Check if rule with this name already exists
|
||||
let exists = sqlx::query_scalar!(
|
||||
"SELECT EXISTS(SELECT 1 FROM workspace_protection_rule WHERE workspace_id = $1 AND name = $2)",
|
||||
&w_id,
|
||||
&req.name
|
||||
)
|
||||
.fetch_one(&mut *tx)
|
||||
.await?
|
||||
.unwrap_or(false);
|
||||
|
||||
if exists {
|
||||
return Err(Error::BadRequest(format!(
|
||||
"Protection rule with name '{}' already exists",
|
||||
req.name
|
||||
)));
|
||||
}
|
||||
|
||||
// Insert the new rule
|
||||
sqlx::query!(
|
||||
r#"
|
||||
INSERT INTO workspace_protection_rule (workspace_id, name, rules, bypass_groups, bypass_users)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
"#,
|
||||
&w_id,
|
||||
&req.name,
|
||||
ProtectionRules::from(&req.rules).bits(),
|
||||
&req.bypass_groups,
|
||||
&req.bypass_users,
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
audit_log(
|
||||
&mut *tx,
|
||||
&authed,
|
||||
"workspaces.create_protection_rule",
|
||||
ActionKind::Create,
|
||||
&w_id,
|
||||
Some(&req.name),
|
||||
Some([("name", &req.name[..])].into()),
|
||||
)
|
||||
.await?;
|
||||
|
||||
tx.commit().await?;
|
||||
|
||||
// Invalidate cache
|
||||
windmill_common::workspaces::invalidate_protection_rules_cache(&w_id);
|
||||
|
||||
handle_deployment_metadata(
|
||||
&authed.email,
|
||||
&authed.username,
|
||||
&db,
|
||||
&w_id,
|
||||
DeployedObject::Settings { setting_type: format!("protection_rule_{}", req.name) },
|
||||
None,
|
||||
false,
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(format!("Created protection rule '{}'", req.name))
|
||||
}
|
||||
|
||||
/// Update an existing protection rule
|
||||
async fn update_protection_rule(
|
||||
authed: ApiAuthed,
|
||||
Extension(db): Extension<DB>,
|
||||
Path((w_id, rule_name)): Path<(String, String)>,
|
||||
Json(req): Json<UpdateProtectionRuleRequest>,
|
||||
) -> Result<String> {
|
||||
require_admin(authed.is_admin, &authed.username)?;
|
||||
|
||||
let mut tx = db.begin().await?;
|
||||
|
||||
// Check if rule exists
|
||||
let exists = sqlx::query_scalar!(
|
||||
"SELECT EXISTS(SELECT 1 FROM workspace_protection_rule WHERE workspace_id = $1 AND name = $2)",
|
||||
&w_id,
|
||||
&rule_name
|
||||
)
|
||||
.fetch_one(&mut *tx)
|
||||
.await?
|
||||
.unwrap_or(false);
|
||||
|
||||
if !exists {
|
||||
return Err(Error::NotFound(format!(
|
||||
"Protection rule '{}' not found",
|
||||
rule_name
|
||||
)));
|
||||
}
|
||||
|
||||
// Update the rule
|
||||
sqlx::query!(
|
||||
r#"
|
||||
UPDATE workspace_protection_rule
|
||||
SET rules = $1, bypass_groups = $2, bypass_users = $3
|
||||
WHERE workspace_id = $4 AND name = $5
|
||||
"#,
|
||||
ProtectionRules::from(&req.rules).bits(),
|
||||
&req.bypass_groups,
|
||||
&req.bypass_users,
|
||||
&w_id,
|
||||
&rule_name
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
audit_log(
|
||||
&mut *tx,
|
||||
&authed,
|
||||
"workspaces.update_protection_rule",
|
||||
ActionKind::Update,
|
||||
&w_id,
|
||||
Some(&rule_name),
|
||||
Some([("name", &rule_name[..])].into()),
|
||||
)
|
||||
.await?;
|
||||
|
||||
tx.commit().await?;
|
||||
|
||||
// Invalidate cache
|
||||
windmill_common::workspaces::invalidate_protection_rules_cache(&w_id);
|
||||
|
||||
handle_deployment_metadata(
|
||||
&authed.email,
|
||||
&authed.username,
|
||||
&db,
|
||||
&w_id,
|
||||
DeployedObject::Settings { setting_type: format!("protection_rule_{}", rule_name) },
|
||||
None,
|
||||
false,
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(format!("Updated protection rule '{}'", rule_name))
|
||||
}
|
||||
|
||||
/// Delete a protection rule
|
||||
async fn delete_protection_rule(
|
||||
authed: ApiAuthed,
|
||||
Extension(db): Extension<DB>,
|
||||
Path((w_id, rule_name)): Path<(String, String)>,
|
||||
) -> Result<String> {
|
||||
require_admin(authed.is_admin, &authed.username)?;
|
||||
|
||||
let mut tx = db.begin().await?;
|
||||
|
||||
// Delete the rule
|
||||
let result = sqlx::query!(
|
||||
"DELETE FROM workspace_protection_rule WHERE workspace_id = $1 AND name = $2",
|
||||
&w_id,
|
||||
&rule_name
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
if result.rows_affected() == 0 {
|
||||
return Err(Error::NotFound(format!(
|
||||
"Protection rule '{}' not found",
|
||||
rule_name
|
||||
)));
|
||||
}
|
||||
|
||||
audit_log(
|
||||
&mut *tx,
|
||||
&authed,
|
||||
"workspaces.delete_protection_rule",
|
||||
ActionKind::Delete,
|
||||
&w_id,
|
||||
Some(&rule_name),
|
||||
Some([("name", &rule_name[..])].into()),
|
||||
)
|
||||
.await?;
|
||||
|
||||
tx.commit().await?;
|
||||
|
||||
// Invalidate cache
|
||||
windmill_common::workspaces::invalidate_protection_rules_cache(&w_id);
|
||||
|
||||
handle_deployment_metadata(
|
||||
&authed.email,
|
||||
&authed.username,
|
||||
&db,
|
||||
&w_id,
|
||||
DeployedObject::Settings { setting_type: format!("protection_rule_{}", rule_name) },
|
||||
None,
|
||||
false,
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(format!("Deleted protection rule '{}'", rule_name))
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct WorkspaceComparison {
|
||||
pub all_ahead_items_visible: bool,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
openapi: "3.0.3"
|
||||
|
||||
info:
|
||||
version: 1.630.0
|
||||
version: 1.629.1
|
||||
title: Windmill API
|
||||
|
||||
contact:
|
||||
@@ -4169,126 +4169,6 @@ paths:
|
||||
type: string
|
||||
example: "Updated mute critical alert UI settings for workspace: workspace_id"
|
||||
|
||||
/w/{workspace}/workspaces/protection_rules:
|
||||
get:
|
||||
summary: list all protection rules for a workspace
|
||||
operationId: listProtectionRules
|
||||
tags:
|
||||
- workspace
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/WorkspaceId"
|
||||
responses:
|
||||
"200":
|
||||
description: list of protection rules
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/ProtectionRuleset"
|
||||
post:
|
||||
summary: create a new protection rule
|
||||
operationId: createProtectionRule
|
||||
tags:
|
||||
- workspace
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/WorkspaceId"
|
||||
requestBody:
|
||||
description: New protection rule configuration
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required:
|
||||
- name
|
||||
- rules
|
||||
- bypass_groups
|
||||
- bypass_users
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
description: Unique name for the protection rule
|
||||
example: "Production Protection"
|
||||
rules:
|
||||
$ref: "#/components/schemas/ProtectionRules"
|
||||
bypass_groups:
|
||||
$ref: "#/components/schemas/RuleBypasserGroups"
|
||||
bypass_users:
|
||||
$ref: "#/components/schemas/RuleBypasserUsers"
|
||||
responses:
|
||||
"200":
|
||||
description: protection rule created successfully
|
||||
content:
|
||||
text/plain:
|
||||
schema:
|
||||
type: string
|
||||
"400":
|
||||
description: rule with this name already exists
|
||||
|
||||
/w/{workspace}/workspaces/protection_rules/{rule_name}:
|
||||
post:
|
||||
summary: update an existing protection rule
|
||||
operationId: updateProtectionRule
|
||||
tags:
|
||||
- workspace
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/WorkspaceId"
|
||||
- name: rule_name
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
description: Name of the protection rule to update
|
||||
requestBody:
|
||||
description: Updated protection rule configuration
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required:
|
||||
- rules
|
||||
- bypass_groups
|
||||
- bypass_users
|
||||
properties:
|
||||
rules:
|
||||
$ref: "#/components/schemas/ProtectionRules"
|
||||
bypass_groups:
|
||||
$ref: "#/components/schemas/RuleBypasserGroups"
|
||||
bypass_users:
|
||||
$ref: "#/components/schemas/RuleBypasserUsers"
|
||||
responses:
|
||||
"200":
|
||||
description: protection rule updated successfully
|
||||
content:
|
||||
text/plain:
|
||||
schema:
|
||||
type: string
|
||||
"404":
|
||||
description: protection rule not found
|
||||
delete:
|
||||
summary: delete a protection rule
|
||||
operationId: deleteProtectionRule
|
||||
tags:
|
||||
- workspace
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/WorkspaceId"
|
||||
- name: rule_name
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
description: Name of the protection rule to delete
|
||||
responses:
|
||||
"200":
|
||||
description: protection rule deleted successfully
|
||||
content:
|
||||
text/plain:
|
||||
schema:
|
||||
type: string
|
||||
"404":
|
||||
description: protection rule not found
|
||||
/w/{workspace}/workspaces/public_app_rate_limit:
|
||||
post:
|
||||
summary: Set public app rate limit for this workspace
|
||||
@@ -6052,17 +5932,6 @@ paths:
|
||||
post:
|
||||
summary: create script
|
||||
operationId: createScript
|
||||
x-mcp-tool: true
|
||||
x-mcp-instructions: "To create a script, specify the path (e.g., 'f/my_folder/my_script'), the content (source code), and the language. For TypeScript, use 'bun' unless deno-specific APIs are needed."
|
||||
x-mcp-tool-include-fields:
|
||||
- path
|
||||
- content
|
||||
- language
|
||||
- summary
|
||||
- description
|
||||
- kind
|
||||
- tag
|
||||
- deployment_message
|
||||
tags:
|
||||
- script
|
||||
parameters:
|
||||
@@ -6339,7 +6208,6 @@ paths:
|
||||
post:
|
||||
summary: delete script by hash (erase content but keep hash, require admin)
|
||||
operationId: deleteScriptByHash
|
||||
x-mcp-tool: true
|
||||
tags:
|
||||
- script
|
||||
parameters:
|
||||
@@ -6357,7 +6225,6 @@ paths:
|
||||
post:
|
||||
summary: delete script at a given path (require admin)
|
||||
operationId: deleteScriptByPath
|
||||
x-mcp-tool: true
|
||||
tags:
|
||||
- script
|
||||
parameters:
|
||||
@@ -6748,9 +6615,6 @@ paths:
|
||||
post:
|
||||
summary: run script by path
|
||||
operationId: runScriptByPath
|
||||
x-mcp-tool: true
|
||||
x-mcp-instructions: "You should first use getScriptByPath to retrieve the script's schema and understand what arguments are expected."
|
||||
x-mcp-tool-include-query-params: []
|
||||
tags:
|
||||
- job
|
||||
parameters:
|
||||
@@ -7693,18 +7557,6 @@ paths:
|
||||
post:
|
||||
summary: create flow
|
||||
operationId: createFlow
|
||||
x-mcp-tool: true
|
||||
x-mcp-tool-include-fields:
|
||||
- path
|
||||
- summary
|
||||
- description
|
||||
- value
|
||||
- schema
|
||||
- tag
|
||||
- deployment_message
|
||||
x-mcp-tool-opaque-fields:
|
||||
- value
|
||||
- schema
|
||||
tags:
|
||||
- flow
|
||||
parameters:
|
||||
@@ -7735,18 +7587,6 @@ paths:
|
||||
post:
|
||||
summary: update flow
|
||||
operationId: updateFlow
|
||||
x-mcp-tool: true
|
||||
x-mcp-tool-include-fields:
|
||||
- path
|
||||
- summary
|
||||
- description
|
||||
- value
|
||||
- schema
|
||||
- tag
|
||||
- deployment_message
|
||||
x-mcp-tool-opaque-fields:
|
||||
- value
|
||||
- schema
|
||||
tags:
|
||||
- flow
|
||||
parameters:
|
||||
@@ -7804,7 +7644,6 @@ paths:
|
||||
delete:
|
||||
summary: delete flow by path
|
||||
operationId: deleteFlowByPath
|
||||
x-mcp-tool: true
|
||||
tags:
|
||||
- flow
|
||||
parameters:
|
||||
@@ -8047,16 +7886,6 @@ paths:
|
||||
post:
|
||||
summary: create app
|
||||
operationId: createApp
|
||||
x-mcp-tool: true
|
||||
x-mcp-tool-include-fields:
|
||||
- path
|
||||
- value
|
||||
- summary
|
||||
- policy
|
||||
- deployment_message
|
||||
x-mcp-tool-opaque-fields:
|
||||
- value
|
||||
- policy
|
||||
tags:
|
||||
- app
|
||||
parameters:
|
||||
@@ -8404,16 +8233,6 @@ paths:
|
||||
post:
|
||||
summary: update app
|
||||
operationId: updateApp
|
||||
x-mcp-tool: true
|
||||
x-mcp-tool-include-fields:
|
||||
- path
|
||||
- value
|
||||
- summary
|
||||
- policy
|
||||
- deployment_message
|
||||
x-mcp-tool-opaque-fields:
|
||||
- value
|
||||
- policy
|
||||
tags:
|
||||
- app
|
||||
parameters:
|
||||
@@ -8695,9 +8514,6 @@ paths:
|
||||
post:
|
||||
summary: run flow by path
|
||||
operationId: runFlowByPath
|
||||
x-mcp-tool: true
|
||||
x-mcp-instructions: "You should first use getFlowByPath to retrieve the flow's schema and understand what arguments are expected."
|
||||
x-mcp-tool-include-query-params: []
|
||||
tags:
|
||||
- job
|
||||
parameters:
|
||||
@@ -18093,7 +17909,7 @@ components:
|
||||
ScriptArgs:
|
||||
type: object
|
||||
description: The arguments to pass to the script or flow
|
||||
additionalProperties: true
|
||||
additionalProperties: {}
|
||||
|
||||
Input:
|
||||
type: object
|
||||
@@ -22100,7 +21916,8 @@ components:
|
||||
created_at:
|
||||
type: string
|
||||
format: date-time
|
||||
value: {}
|
||||
value:
|
||||
type: object
|
||||
policy:
|
||||
$ref: "#/components/schemas/Policy"
|
||||
execution_mode:
|
||||
@@ -22649,7 +22466,8 @@ components:
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
value: {}
|
||||
value:
|
||||
type: object
|
||||
required:
|
||||
- name
|
||||
- value
|
||||
@@ -23103,47 +22921,7 @@ components:
|
||||
kind:
|
||||
$ref: "#/components/schemas/AssetKind"
|
||||
required: [path, kind]
|
||||
ProtectionRuleset:
|
||||
type: object
|
||||
description: A workspace protection rule defining restrictions and bypass permissions
|
||||
required:
|
||||
- name
|
||||
- rules
|
||||
- bypass_groups
|
||||
- bypass_users
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
description: Unique name for the protection rule
|
||||
example: "Production Protection"
|
||||
workspace_id:
|
||||
type: string
|
||||
rules:
|
||||
$ref: "#/components/schemas/ProtectionRules"
|
||||
bypass_groups:
|
||||
$ref: "#/components/schemas/RuleBypasserGroups"
|
||||
bypass_users:
|
||||
$ref: "#/components/schemas/RuleBypasserUsers"
|
||||
ProtectionRules:
|
||||
type: array
|
||||
description: Configuration of protection restrictions
|
||||
items:
|
||||
$ref: "#/components/schemas/ProtectionRuleKind"
|
||||
ProtectionRuleKind:
|
||||
type: string
|
||||
enum:
|
||||
- DisableDirectDeployment
|
||||
- DisableWorkspaceForking
|
||||
RuleBypasserGroups:
|
||||
type: array
|
||||
description: Groups that can bypass this ruleset
|
||||
items:
|
||||
type: string
|
||||
RuleBypasserUsers:
|
||||
type: array
|
||||
description: Users that can bypass this ruleset
|
||||
items:
|
||||
type: string
|
||||
|
||||
NativeServiceName:
|
||||
type: string
|
||||
enum:
|
||||
|
||||
@@ -49,7 +49,7 @@ use sha2::{Digest, Sha256};
|
||||
use sql_builder::{bind::Bind, SqlBuilder};
|
||||
use sqlx::{types::Uuid, FromRow};
|
||||
use std::str;
|
||||
use windmill_audit::audit_oss::{audit_log, AuditAuthorable};
|
||||
use windmill_audit::audit_oss::audit_log;
|
||||
use windmill_audit::ActionKind;
|
||||
use windmill_common::{
|
||||
apps::{AppScriptId, ListAppQuery, APP_WORKSPACED_ROUTE},
|
||||
@@ -65,7 +65,6 @@ use windmill_common::{
|
||||
},
|
||||
variables::{build_crypt, build_crypt_with_key_suffix, encrypt},
|
||||
worker::{to_raw_value, CLOUD_HOSTED},
|
||||
workspaces::{check_user_against_rule, ProtectionRuleKind, RuleCheckResult},
|
||||
HUB_BASE_URL,
|
||||
};
|
||||
|
||||
@@ -1050,20 +1049,6 @@ async fn create_app_raw<'a>(
|
||||
"Operators cannot create apps for security reasons".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
if let RuleCheckResult::Blocked(msg) = check_user_against_rule(
|
||||
&w_id,
|
||||
&ProtectionRuleKind::DisableDirectDeployment,
|
||||
AuditAuthorable::username(&authed),
|
||||
&authed.groups,
|
||||
authed.is_admin,
|
||||
&db,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
return Err(Error::PermissionDenied(msg));
|
||||
}
|
||||
|
||||
let (path, _id) = process_app_multipart!(
|
||||
authed,
|
||||
user_db,
|
||||
@@ -1124,19 +1109,6 @@ async fn create_app(
|
||||
let path = app.path.clone();
|
||||
check_scopes(&authed, || format!("apps:write:{}", &path))?;
|
||||
|
||||
if let RuleCheckResult::Blocked(msg) = check_user_against_rule(
|
||||
&w_id,
|
||||
&ProtectionRuleKind::DisableDirectDeployment,
|
||||
AuditAuthorable::username(&authed),
|
||||
&authed.groups,
|
||||
authed.is_admin,
|
||||
&db,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
return Err(Error::PermissionDenied(msg));
|
||||
}
|
||||
|
||||
let (new_tx, _path, _id) = create_app_internal(authed, db, user_db, &w_id, false, app).await?;
|
||||
|
||||
new_tx.commit().await?;
|
||||
@@ -1375,19 +1347,6 @@ async fn delete_app(
|
||||
));
|
||||
}
|
||||
|
||||
if let RuleCheckResult::Blocked(msg) = check_user_against_rule(
|
||||
&w_id,
|
||||
&ProtectionRuleKind::DisableDirectDeployment,
|
||||
AuditAuthorable::username(&authed),
|
||||
&authed.groups,
|
||||
authed.is_admin,
|
||||
&db,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
return Err(Error::PermissionDenied(msg));
|
||||
}
|
||||
|
||||
// Check if it's a raw app before deletion
|
||||
let is_raw_app = sqlx::query_scalar!(
|
||||
"SELECT app_version.raw_app FROM app
|
||||
@@ -1493,21 +1452,6 @@ async fn update_app(
|
||||
// create_app_internal(authed, user_db, db, &w_id, &mut app).await?;
|
||||
let path = path.to_path();
|
||||
check_scopes(&authed, || format!("apps:write:{}", path))?;
|
||||
|
||||
|
||||
if let RuleCheckResult::Blocked(msg) = check_user_against_rule(
|
||||
&w_id,
|
||||
&ProtectionRuleKind::DisableDirectDeployment,
|
||||
AuditAuthorable::username(&authed),
|
||||
&authed.groups,
|
||||
authed.is_admin,
|
||||
&db,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
return Err(Error::PermissionDenied(msg));
|
||||
}
|
||||
|
||||
let opath = path.to_string();
|
||||
let (new_tx, npath, _v_id) =
|
||||
update_app_internal(authed, db, user_db, &w_id, path, false, ns).await?;
|
||||
@@ -1538,21 +1482,6 @@ async fn update_app_raw<'a>(
|
||||
"Operators cannot update apps for security reasons".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
if let RuleCheckResult::Blocked(msg) = check_user_against_rule(
|
||||
&w_id,
|
||||
&ProtectionRuleKind::DisableDirectDeployment,
|
||||
AuditAuthorable::username(&authed),
|
||||
&authed.groups,
|
||||
authed.is_admin,
|
||||
&db,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
return Err(Error::PermissionDenied(msg));
|
||||
}
|
||||
|
||||
let path = path.to_path();
|
||||
check_scopes(&authed, || format!("apps:write:{}", path))?;
|
||||
let opath = path.to_string();
|
||||
|
||||
@@ -114,10 +114,7 @@ use windmill_common::{
|
||||
get_latest_flow_version_info_for_path, get_script_info_for_hash, utils::empty_as_none,
|
||||
ScriptHashInfo, BASE_URL,
|
||||
};
|
||||
use windmill_queue::{
|
||||
get_result_and_success_by_id_from_flow, job_is_complete, push, PushArgs, PushArgsOwned,
|
||||
PushIsolationLevel,
|
||||
};
|
||||
use windmill_queue::{job_is_complete, push, PushArgs, PushArgsOwned, PushIsolationLevel};
|
||||
|
||||
pub fn workspaced_service() -> Router {
|
||||
let cors = CorsLayer::new()
|
||||
@@ -3474,11 +3471,8 @@ pub async fn run_workflow_as_code(
|
||||
let JobExtended { inner: job, raw_code, raw_lock, .. } = job;
|
||||
|
||||
let (_debouncing_settings, concurrency_settings) =
|
||||
windmill_common::runnable_settings::prefetch_cached_from_handle(
|
||||
job.runnable_settings_handle,
|
||||
&db,
|
||||
)
|
||||
.await?;
|
||||
windmill_common::runnable_settings::prefetch_cached_from_handle(job.runnable_settings_handle, &db)
|
||||
.await?;
|
||||
|
||||
let (job_payload, tag, _delete_after_use, timeout, on_behalf_of) = match job.job_kind {
|
||||
JobKind::Preview => (
|
||||
@@ -4238,7 +4232,7 @@ pub async fn stream_job(
|
||||
};
|
||||
|
||||
let poll_delay_ms = run_query.poll_delay_ms;
|
||||
let (uuid, early_return) = match runnable_id {
|
||||
let uuid = match runnable_id {
|
||||
RunnableId::ScriptId(ScriptId::ScriptPath(script_path))
|
||||
| RunnableId::HubScript(script_path) => {
|
||||
let (uuid, _, _) = push_script_job_by_path_into_queue(
|
||||
@@ -4253,10 +4247,10 @@ pub async fn stream_job(
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
(uuid, None)
|
||||
uuid
|
||||
}
|
||||
RunnableId::ScriptId(ScriptId::ScriptHash(script_hash)) => {
|
||||
let (uuid, _) = run_job_by_hash_inner(
|
||||
run_job_by_hash_inner(
|
||||
authed.clone(),
|
||||
db.clone(),
|
||||
user_db,
|
||||
@@ -4266,11 +4260,11 @@ pub async fn stream_job(
|
||||
args,
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
(uuid, None)
|
||||
.await?
|
||||
.0
|
||||
}
|
||||
RunnableId::FlowId(FlowId::FlowPath(flow_path)) => {
|
||||
let (uuid, early_return, _) = push_flow_job_by_path_into_queue(
|
||||
push_flow_job_by_path_into_queue(
|
||||
authed.clone(),
|
||||
db.clone(),
|
||||
None,
|
||||
@@ -4281,11 +4275,11 @@ pub async fn stream_job(
|
||||
args,
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
(uuid, early_return)
|
||||
.await?
|
||||
.0
|
||||
}
|
||||
RunnableId::FlowId(FlowId::FlowVersion(version)) => {
|
||||
let (uuid, early_return) = run_flow_by_version_inner(
|
||||
run_flow_by_version_inner(
|
||||
authed.clone(),
|
||||
db.clone(),
|
||||
user_db,
|
||||
@@ -4295,8 +4289,8 @@ pub async fn stream_job(
|
||||
args,
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
(uuid, early_return)
|
||||
.await?
|
||||
.0
|
||||
}
|
||||
};
|
||||
|
||||
@@ -4327,7 +4321,6 @@ pub async fn stream_job(
|
||||
None,
|
||||
tx,
|
||||
poll_delay_ms,
|
||||
early_return,
|
||||
);
|
||||
|
||||
let body = axum::body::Body::from_stream(stream.map(Result::<_, std::convert::Infallible>::Ok));
|
||||
@@ -4650,19 +4643,12 @@ fn register_potential_assets_on_inline_execution(
|
||||
let columns = asset.columns.as_ref().map(|cols| {
|
||||
cols.iter()
|
||||
.map(|(col_name, col_access_type)| {
|
||||
(
|
||||
col_name.clone(),
|
||||
windmill_common::assets::asset_access_type_from_parser(
|
||||
*col_access_type,
|
||||
),
|
||||
)
|
||||
(col_name.clone(), windmill_common::assets::asset_access_type_from_parser(*col_access_type))
|
||||
})
|
||||
.collect()
|
||||
});
|
||||
register_runtime_asset(InsertRuntimeAssetParams {
|
||||
access_type: asset
|
||||
.access_type
|
||||
.map(windmill_common::assets::asset_access_type_from_parser),
|
||||
access_type: asset.access_type.map(windmill_common::assets::asset_access_type_from_parser),
|
||||
asset_kind: windmill_common::assets::asset_kind_from_parser(asset.kind),
|
||||
asset_path: asset.path,
|
||||
columns,
|
||||
@@ -5839,7 +5825,6 @@ async fn get_job_update(
|
||||
no_logs,
|
||||
is_flow,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await?,
|
||||
))
|
||||
@@ -5880,7 +5865,6 @@ async fn get_job_update_sse(
|
||||
is_flow,
|
||||
tx,
|
||||
poll_delay_ms,
|
||||
None,
|
||||
);
|
||||
|
||||
let stream = tokio_stream::wrappers::ReceiverStream::new(rx).map(|x| {
|
||||
@@ -5918,7 +5902,6 @@ pub fn start_job_update_sse_stream(
|
||||
is_flow: Option<bool>,
|
||||
tx: tokio::sync::mpsc::Sender<JobUpdateSSEStream>,
|
||||
poll_delay_ms: Option<u64>,
|
||||
early_return: Option<String>,
|
||||
) -> () {
|
||||
tokio::spawn(async move {
|
||||
let mut log_offset = initial_log_offset;
|
||||
@@ -5946,7 +5929,6 @@ pub fn start_job_update_sse_stream(
|
||||
no_logs,
|
||||
is_flow,
|
||||
flow_stream_job_id,
|
||||
early_return.as_deref(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
@@ -6064,7 +6046,6 @@ pub fn start_job_update_sse_stream(
|
||||
no_logs,
|
||||
is_flow,
|
||||
flow_stream_job_id,
|
||||
early_return.as_deref(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
@@ -6195,7 +6176,6 @@ async fn get_job_update_data(
|
||||
no_logs: Option<bool>,
|
||||
is_flow: Option<bool>,
|
||||
flow_stream_job_id: Option<Uuid>,
|
||||
early_return: Option<&str>,
|
||||
) -> error::Result<JobUpdate> {
|
||||
let tags = if log_view {
|
||||
log_job_view(
|
||||
@@ -6359,16 +6339,6 @@ async fn get_job_update_data(
|
||||
|
||||
let flow_stream_job_id = flow_stream_job_id.or(new_flow_stream_job_id);
|
||||
|
||||
let result = if let Some(early_return) = early_return {
|
||||
match get_result_and_success_by_id_from_flow(db, w_id, job_id, early_return, None).await
|
||||
{
|
||||
Ok((early_result, _)) => Some(early_result),
|
||||
Err(_) => result,
|
||||
}
|
||||
} else {
|
||||
result
|
||||
};
|
||||
|
||||
let flow_stream_delta =
|
||||
get_flow_stream_delta(db, flow_stream_job_id, stream_offset).await?;
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -323,25 +323,16 @@ impl McpBackend for WindmillBackend {
|
||||
workspace_id,
|
||||
args_map,
|
||||
&endpoint_tool.path_params_schema,
|
||||
&endpoint_tool.path_field_renames,
|
||||
)?;
|
||||
let query_string = build_query_string(
|
||||
args_map,
|
||||
&endpoint_tool.query_params_schema,
|
||||
&endpoint_tool.query_field_renames,
|
||||
);
|
||||
let query_string = build_query_string(args_map, &endpoint_tool.query_params_schema);
|
||||
let full_url = format!(
|
||||
"{}/api{}{}",
|
||||
self.base_internal_url, path_template, query_string
|
||||
);
|
||||
|
||||
// Prepare request body
|
||||
let body_json = build_request_body(
|
||||
&endpoint_tool.method,
|
||||
args_map,
|
||||
&endpoint_tool.body_schema,
|
||||
&endpoint_tool.body_field_renames,
|
||||
);
|
||||
let body_json =
|
||||
build_request_body(&endpoint_tool.method, args_map, &endpoint_tool.body_schema);
|
||||
|
||||
// Create and execute request
|
||||
let response = create_http_request(
|
||||
|
||||
@@ -242,34 +242,19 @@ pub async fn get_hub_script_schema(path: &str, db: &DB) -> Result<Option<Schema>
|
||||
// HTTP request utilities for endpoint tools
|
||||
// ============================================================================
|
||||
|
||||
/// Look up the original field name from a field_renames map.
|
||||
/// field_renames maps renamed_key -> original_key (e.g. {"path__path": "path"}).
|
||||
fn get_original_name(renamed_key: &str, field_renames: &Option<Value>) -> String {
|
||||
field_renames
|
||||
.as_ref()
|
||||
.and_then(|v| v.as_object())
|
||||
.and_then(|m| m.get(renamed_key))
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string())
|
||||
.unwrap_or_else(|| renamed_key.to_string())
|
||||
}
|
||||
|
||||
/// Substitute path parameters in the URL template
|
||||
pub fn substitute_path_params(
|
||||
path: &str,
|
||||
workspace_id: &str,
|
||||
args_map: &serde_json::Map<String, Value>,
|
||||
path_schema: &Option<Value>,
|
||||
path_field_renames: &Option<Value>,
|
||||
) -> BackendResult<String> {
|
||||
let mut path_template = path.replace("{workspace}", workspace_id);
|
||||
|
||||
if let Some(schema) = path_schema {
|
||||
if let Some(props) = schema.get("properties").and_then(|p| p.as_object()) {
|
||||
for (param_name, _) in props {
|
||||
// param_name may be renamed (e.g. "path__path"), get original for URL placeholder
|
||||
let original_name = get_original_name(param_name, path_field_renames);
|
||||
let placeholder = format!("{{{}}}", original_name);
|
||||
let placeholder = format!("{{{}}}", param_name);
|
||||
match args_map.get(param_name) {
|
||||
Some(param_value) => {
|
||||
if let Some(str_val) = param_value.as_str() {
|
||||
@@ -295,7 +280,6 @@ pub fn substitute_path_params(
|
||||
pub fn build_query_string(
|
||||
args_map: &serde_json::Map<String, Value>,
|
||||
query_schema: &Option<Value>,
|
||||
query_field_renames: &Option<Value>,
|
||||
) -> String {
|
||||
let Some(schema) = query_schema else {
|
||||
return String::new();
|
||||
@@ -311,13 +295,11 @@ pub fn build_query_string(
|
||||
.get(param_name)
|
||||
.filter(|v| !v.is_null())
|
||||
.map(|value| {
|
||||
// Use the original name for the query parameter key
|
||||
let original_name = get_original_name(param_name, query_field_renames);
|
||||
let value_str = value.to_string();
|
||||
let str_val = value_str.trim_matches('"');
|
||||
format!(
|
||||
"{}={}",
|
||||
urlencoding::encode(&original_name),
|
||||
urlencoding::encode(param_name),
|
||||
urlencoding::encode(str_val)
|
||||
)
|
||||
})
|
||||
@@ -336,7 +318,6 @@ pub fn build_request_body(
|
||||
method: &str,
|
||||
args_map: &serde_json::Map<String, Value>,
|
||||
body_schema: &Option<Value>,
|
||||
body_field_renames: &Option<Value>,
|
||||
) -> Option<Value> {
|
||||
if method == "GET" {
|
||||
return None;
|
||||
@@ -348,11 +329,9 @@ pub fn build_request_body(
|
||||
let body_map: serde_json::Map<String, Value> = props
|
||||
.keys()
|
||||
.filter_map(|param_name| {
|
||||
args_map.get(param_name).map(|value| {
|
||||
// Use the original name as the key in the request body
|
||||
let original_name = get_original_name(param_name, body_field_renames);
|
||||
(original_name, value.clone())
|
||||
})
|
||||
args_map
|
||||
.get(param_name)
|
||||
.map(|value| (param_name.clone(), value.clone()))
|
||||
})
|
||||
.collect();
|
||||
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
pub use windmill_store::resources::*;
|
||||
|
||||
#[cfg(feature = "mcp")]
|
||||
use axum::{ extract::{Extension, Path}, routing::get,
|
||||
use axum::{
|
||||
extract::{Extension, Path},
|
||||
routing::get,
|
||||
Json, Router,
|
||||
};
|
||||
#[cfg(feature = "mcp")]
|
||||
|
||||
@@ -505,7 +505,7 @@ async fn route_job(
|
||||
match trigger.request_type {
|
||||
RequestType::SyncSse => {
|
||||
// Trigger the job (always async when streaming)
|
||||
let (uuid, _, early_return, _) = trigger_runnable_inner(
|
||||
let (uuid, _, _, _) = trigger_runnable_inner(
|
||||
&db,
|
||||
None,
|
||||
Some(user_db.clone()),
|
||||
@@ -553,7 +553,6 @@ async fn route_job(
|
||||
None,
|
||||
tx,
|
||||
None,
|
||||
early_return,
|
||||
);
|
||||
|
||||
let body = axum::body::Body::from_stream(
|
||||
|
||||
@@ -3,20 +3,7 @@ use crate::{
|
||||
DB,
|
||||
};
|
||||
use serde::Serialize;
|
||||
use std::collections::HashSet;
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||
use std::sync::Arc;
|
||||
use tokio::time::Instant;
|
||||
use uuid::Uuid;
|
||||
|
||||
static BENCHMARK_INITIALIZED: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
static SHARED_BENCH_ITERS: std::sync::LazyLock<Arc<AtomicU64>> =
|
||||
std::sync::LazyLock::new(|| Arc::new(AtomicU64::new(0)));
|
||||
|
||||
pub fn shared_bench_iters() -> Arc<AtomicU64> {
|
||||
SHARED_BENCH_ITERS.clone()
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct PoolStats {
|
||||
@@ -49,10 +36,6 @@ pub struct BenchmarkInfo {
|
||||
pub start: Instant,
|
||||
#[serde(skip)]
|
||||
pub iters: u64,
|
||||
#[serde(skip)]
|
||||
seen_top_level: HashSet<Uuid>,
|
||||
#[serde(skip)]
|
||||
pub shared_iters: Arc<AtomicU64>,
|
||||
timings: Vec<BenchmarkIter>,
|
||||
pub iter_durations: Vec<u64>,
|
||||
pub total_duration: Option<u64>,
|
||||
@@ -60,11 +43,9 @@ pub struct BenchmarkInfo {
|
||||
}
|
||||
|
||||
impl BenchmarkInfo {
|
||||
pub fn new(shared_iters: Arc<AtomicU64>) -> Self {
|
||||
pub fn new() -> Self {
|
||||
BenchmarkInfo {
|
||||
iters: 0,
|
||||
seen_top_level: HashSet::new(),
|
||||
shared_iters,
|
||||
timings: vec![],
|
||||
start: Instant::now(),
|
||||
iter_durations: vec![],
|
||||
@@ -83,23 +64,13 @@ impl BenchmarkInfo {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn count_top_level(&mut self, job_id: Uuid) -> bool {
|
||||
if self.seen_top_level.insert(job_id) {
|
||||
self.iters += 1;
|
||||
return true;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
pub fn add_iter(&mut self, bench: BenchmarkIter, job_id: Uuid, is_top_level: bool) -> bool {
|
||||
let newly_counted = is_top_level && self.seen_top_level.insert(job_id);
|
||||
if newly_counted {
|
||||
pub fn add_iter(&mut self, bench: BenchmarkIter, inc_iters: bool) {
|
||||
if inc_iters {
|
||||
self.iters += 1;
|
||||
}
|
||||
let elapsed_total = bench.start.elapsed().as_nanos() as u64;
|
||||
self.timings.push(bench);
|
||||
self.iter_durations.push(elapsed_total);
|
||||
newly_counted
|
||||
}
|
||||
|
||||
pub fn write_to_file(&mut self, path: &str) -> anyhow::Result<()> {
|
||||
@@ -147,120 +118,12 @@ impl BenchmarkIter {
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn benchmark_verify(benchmark_jobs: i32, db: &DB) {
|
||||
let benchmark_kind = std::env::var("BENCHMARK_KIND").unwrap_or("noop".to_string());
|
||||
|
||||
if benchmark_jobs <= 0 || benchmark_kind == "none" {
|
||||
return;
|
||||
}
|
||||
|
||||
// For flows, child jobs are created dynamically so only check top-level (parent_job IS NULL).
|
||||
// "parallelflow" inserts only 1 top-level flow regardless of benchmark_jobs.
|
||||
let expected_top_level = match benchmark_kind.as_str() {
|
||||
"parallelflow" => 1i64,
|
||||
_ => benchmark_jobs as i64,
|
||||
};
|
||||
|
||||
let row = sqlx::query!(
|
||||
"SELECT
|
||||
COUNT(*) FILTER (WHERE status = 'success') AS succeeded,
|
||||
COUNT(*) FILTER (WHERE status = 'failure') AS failed,
|
||||
COUNT(*) FILTER (WHERE status = 'canceled') AS canceled
|
||||
FROM v2_job_completed
|
||||
JOIN v2_job USING (id)
|
||||
WHERE v2_job.workspace_id = 'admins' AND v2_job.parent_job IS NULL",
|
||||
)
|
||||
.fetch_one(db)
|
||||
.await
|
||||
.expect("benchmark verify query failed");
|
||||
|
||||
let succeeded = row.succeeded.unwrap_or(0);
|
||||
let failed = row.failed.unwrap_or(0);
|
||||
let canceled = row.canceled.unwrap_or(0);
|
||||
let total = succeeded + failed + canceled;
|
||||
|
||||
let remaining_in_queue = sqlx::query_scalar!(
|
||||
"SELECT COUNT(*) FROM v2_job_queue WHERE workspace_id = 'admins'",
|
||||
)
|
||||
.fetch_one(db)
|
||||
.await
|
||||
.expect("benchmark verify queue query failed")
|
||||
.unwrap_or(0);
|
||||
|
||||
println!("=== BENCHMARK VERIFICATION ===");
|
||||
println!(" kind: {benchmark_kind}");
|
||||
println!(" expected top-level: {expected_top_level}");
|
||||
println!(" completed total: {total}");
|
||||
println!(" succeeded: {succeeded}");
|
||||
println!(" failed: {failed}");
|
||||
println!(" canceled: {canceled}");
|
||||
println!(" still in queue: {remaining_in_queue}");
|
||||
|
||||
if failed > 0 || canceled > 0 {
|
||||
tracing::error!(
|
||||
"BENCHMARK VERIFICATION FAILED: {failed} failed, {canceled} canceled out of {total} completed"
|
||||
);
|
||||
}
|
||||
if remaining_in_queue > 0 {
|
||||
tracing::warn!(
|
||||
"BENCHMARK VERIFICATION: {remaining_in_queue} jobs still in queue after benchmark"
|
||||
);
|
||||
}
|
||||
if succeeded != expected_top_level {
|
||||
tracing::error!(
|
||||
"BENCHMARK VERIFICATION FAILED: expected {expected_top_level} succeeded top-level jobs, got {succeeded}"
|
||||
);
|
||||
} else if failed == 0 && canceled == 0 && remaining_in_queue == 0 {
|
||||
println!(" result: ALL PASSED");
|
||||
}
|
||||
println!("==============================");
|
||||
}
|
||||
|
||||
pub async fn benchmark_init(benchmark_jobs: i32, db: &DB) {
|
||||
use crate::{jobs::JobKind, scripts::ScriptLang};
|
||||
|
||||
// Only the first worker to reach this point runs init
|
||||
if BENCHMARK_INITIALIZED.swap(true, Ordering::SeqCst) {
|
||||
return;
|
||||
}
|
||||
|
||||
let benchmark_kind = std::env::var("BENCHMARK_KIND").unwrap_or("noop".to_string());
|
||||
|
||||
if benchmark_jobs > 0 {
|
||||
// Clean up data from previous benchmark runs
|
||||
sqlx::query!("DELETE FROM v2_job_completed WHERE workspace_id = 'admins'")
|
||||
.execute(db)
|
||||
.await
|
||||
.unwrap_or_else(|e| panic!("failed to clean up v2_job_completed: {e:#}"));
|
||||
sqlx::query!("DELETE FROM v2_job_queue WHERE workspace_id = 'admins'")
|
||||
.execute(db)
|
||||
.await
|
||||
.unwrap_or_else(|e| panic!("failed to clean up v2_job_queue: {e:#}"));
|
||||
sqlx::query!("DELETE FROM v2_job_status WHERE id IN (SELECT id FROM v2_job WHERE workspace_id = 'admins')")
|
||||
.execute(db)
|
||||
.await
|
||||
.unwrap_or_else(|e| panic!("failed to clean up v2_job_status: {e:#}"));
|
||||
sqlx::query!("DELETE FROM v2_job_runtime WHERE id IN (SELECT id FROM v2_job WHERE workspace_id = 'admins')")
|
||||
.execute(db)
|
||||
.await
|
||||
.unwrap_or_else(|e| panic!("failed to clean up v2_job_runtime: {e:#}"));
|
||||
sqlx::query("DELETE FROM job_perms WHERE workspace_id = 'admins'")
|
||||
.execute(db)
|
||||
.await
|
||||
.unwrap_or_else(|e| panic!("failed to clean up job_perms: {e:#}"));
|
||||
sqlx::query!("DELETE FROM concurrency_key WHERE key LIKE 'bench_%' OR key LIKE 'u/admin/bench_%'")
|
||||
.execute(db)
|
||||
.await
|
||||
.unwrap_or_else(|e| panic!("failed to clean up concurrency_key: {e:#}"));
|
||||
sqlx::query!("DELETE FROM concurrency_counter WHERE concurrency_id LIKE 'bench_%' OR concurrency_id LIKE 'u/admin/bench_%'")
|
||||
.execute(db)
|
||||
.await
|
||||
.unwrap_or_else(|e| panic!("failed to clean up concurrency_counter: {e:#}"));
|
||||
sqlx::query!("DELETE FROM v2_job WHERE workspace_id = 'admins'")
|
||||
.execute(db)
|
||||
.await
|
||||
.unwrap_or_else(|e| panic!("failed to clean up v2_job: {e:#}"));
|
||||
|
||||
let mut tx = db.begin().await.unwrap();
|
||||
match benchmark_kind.as_str() {
|
||||
"dedicated" => {
|
||||
@@ -397,477 +260,6 @@ pub async fn benchmark_init(benchmark_jobs: i32, db: &DB) {
|
||||
.await
|
||||
.unwrap_or_else(|_e| panic!("failed to insert parallelflow jobs (4)"));
|
||||
}
|
||||
"sequentialflow" => {
|
||||
let uuids = sqlx::query_scalar!("INSERT INTO v2_job (id, runnable_id, runnable_path, kind, script_lang, tag, created_by, permissioned_as, permissioned_as_email, workspace_id, raw_flow) (SELECT gen_random_uuid(), $1, $2, $3, $4, $5, $6, $7, $8, $9, $10 FROM generate_series(1, $11)) RETURNING id",
|
||||
None::<i64>,
|
||||
None::<String>,
|
||||
JobKind::FlowPreview as JobKind,
|
||||
ScriptLang::Deno as ScriptLang,
|
||||
"flow",
|
||||
"admin",
|
||||
"u/admin",
|
||||
"admin@windmill.dev",
|
||||
"admins",
|
||||
serde_json::from_str::<serde_json::Value>(r#"
|
||||
{
|
||||
"modules": [
|
||||
{
|
||||
"id": "a",
|
||||
"value": {
|
||||
"type": "rawscript",
|
||||
"content": "export async function main() { return 'a'; }",
|
||||
"language": "deno",
|
||||
"input_transforms": {}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "b",
|
||||
"value": {
|
||||
"type": "rawscript",
|
||||
"content": "export async function main() { return 'b'; }",
|
||||
"language": "deno",
|
||||
"input_transforms": {}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "c",
|
||||
"value": {
|
||||
"type": "rawscript",
|
||||
"content": "export async function main() { return 'c'; }",
|
||||
"language": "deno",
|
||||
"input_transforms": {}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "d",
|
||||
"value": {
|
||||
"type": "rawscript",
|
||||
"content": "export async function main() { return 'd'; }",
|
||||
"language": "deno",
|
||||
"input_transforms": {}
|
||||
}
|
||||
}
|
||||
],
|
||||
"preprocessor_module": null
|
||||
}
|
||||
"#).unwrap(),
|
||||
benchmark_jobs,
|
||||
)
|
||||
.fetch_all(&mut *tx)
|
||||
.await.unwrap_or_else(|_e| panic!("failed to insert sequentialflow jobs (1)"));
|
||||
sqlx::query!("INSERT INTO v2_job_queue (id, workspace_id, scheduled_for, tag) SELECT unnest($1::uuid[]), $2, now(), $3", &uuids, "admins", "flow")
|
||||
.execute(&mut *tx)
|
||||
.await.unwrap_or_else(|_e| panic!("failed to insert sequentialflow jobs (2)"));
|
||||
sqlx::query!(
|
||||
"INSERT INTO v2_job_runtime (id) SELECT unnest($1::uuid[])",
|
||||
&uuids
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.unwrap_or_else(|_e| panic!("failed to insert sequentialflow jobs (3)"));
|
||||
sqlx::query!(
|
||||
"INSERT INTO v2_job_status (id, flow_status) SELECT unnest($1::uuid[]), $2",
|
||||
&uuids,
|
||||
serde_json::from_str::<serde_json::Value>(
|
||||
r#"
|
||||
{
|
||||
"step": 0,
|
||||
"modules": [
|
||||
{ "id": "a", "type": "WaitingForPriorSteps" },
|
||||
{ "id": "b", "type": "WaitingForPriorSteps" },
|
||||
{ "id": "c", "type": "WaitingForPriorSteps" },
|
||||
{ "id": "d", "type": "WaitingForPriorSteps" }
|
||||
],
|
||||
"cleanup_module": {},
|
||||
"failure_module": {
|
||||
"id": "failure",
|
||||
"type": "WaitingForPriorSteps"
|
||||
},
|
||||
"preprocessor_module": null
|
||||
}
|
||||
"#
|
||||
)
|
||||
.unwrap()
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.unwrap_or_else(|_e| panic!("failed to insert sequentialflow jobs (4)"));
|
||||
}
|
||||
"scriptlogs" => {
|
||||
let uuids = sqlx::query_scalar!("INSERT INTO v2_job (id, runnable_id, runnable_path, kind, script_lang, tag, created_by, permissioned_as, permissioned_as_email, workspace_id, raw_code) (SELECT gen_random_uuid(), $1, $2, $3, $4, $5, $6, $7, $8, $9, $10 FROM generate_series(1, $11)) RETURNING id",
|
||||
None::<i64>,
|
||||
None::<String>,
|
||||
JobKind::Preview as JobKind,
|
||||
ScriptLang::Deno as ScriptLang,
|
||||
"deno",
|
||||
"admin",
|
||||
"u/admin",
|
||||
"admin@windmill.dev",
|
||||
"admins",
|
||||
"export async function main() { for (let i = 0; i < 1000; i++) { console.log('benchmark log line ' + i); } return 'done'; }",
|
||||
benchmark_jobs,
|
||||
)
|
||||
.fetch_all(&mut *tx)
|
||||
.await.unwrap_or_else(|_e| panic!("failed to insert scriptlogs jobs (1)"));
|
||||
sqlx::query!("INSERT INTO v2_job_queue (id, workspace_id, scheduled_for, tag) SELECT unnest($1::uuid[]), $2, now(), $3", &uuids, "admins", "deno")
|
||||
.execute(&mut *tx)
|
||||
.await.unwrap_or_else(|_e| panic!("failed to insert scriptlogs jobs (2)"));
|
||||
sqlx::query!(
|
||||
"INSERT INTO v2_job_runtime (id) SELECT unnest($1::uuid[])",
|
||||
&uuids
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.unwrap_or_else(|_e| panic!("failed to insert scriptlogs jobs (3)"));
|
||||
}
|
||||
"concurrencylimit" => {
|
||||
let uuids = sqlx::query_scalar!("INSERT INTO v2_job (id, runnable_id, runnable_path, kind, script_lang, tag, created_by, permissioned_as, permissioned_as_email, workspace_id, raw_code, concurrent_limit, concurrency_time_window_s) (SELECT gen_random_uuid(), $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12 FROM generate_series(1, $13)) RETURNING id",
|
||||
None::<i64>,
|
||||
Some("u/admin/bench_conclimit"),
|
||||
JobKind::Preview as JobKind,
|
||||
ScriptLang::Deno as ScriptLang,
|
||||
"deno",
|
||||
"admin",
|
||||
"u/admin",
|
||||
"admin@windmill.dev",
|
||||
"admins",
|
||||
"export async function main() { return 'done'; }",
|
||||
2i32,
|
||||
0i32,
|
||||
benchmark_jobs,
|
||||
)
|
||||
.fetch_all(&mut *tx)
|
||||
.await.unwrap_or_else(|_e| panic!("failed to insert concurrencylimit jobs (1)"));
|
||||
sqlx::query!("INSERT INTO v2_job_queue (id, workspace_id, scheduled_for, tag) SELECT unnest($1::uuid[]), $2, now(), $3", &uuids, "admins", "deno")
|
||||
.execute(&mut *tx)
|
||||
.await.unwrap_or_else(|_e| panic!("failed to insert concurrencylimit jobs (2)"));
|
||||
sqlx::query!(
|
||||
"INSERT INTO v2_job_runtime (id) SELECT unnest($1::uuid[])",
|
||||
&uuids
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.unwrap_or_else(|_e| panic!("failed to insert concurrencylimit jobs (3)"));
|
||||
let concurrency_id = "u/admin/bench_conclimit";
|
||||
sqlx::query!(
|
||||
"INSERT INTO concurrency_counter (concurrency_id, job_uuids) VALUES ($1, '{}'::jsonb) ON CONFLICT (concurrency_id) DO NOTHING",
|
||||
concurrency_id,
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.unwrap_or_else(|_e| panic!("failed to insert concurrencylimit counter"));
|
||||
for uuid in &uuids {
|
||||
sqlx::query!(
|
||||
"INSERT INTO concurrency_key (key, ended_at, job_id) VALUES ($1, now() - INTERVAL '1 hour', $2)",
|
||||
concurrency_id,
|
||||
uuid,
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.unwrap_or_else(|_e| panic!("failed to insert concurrencylimit key"));
|
||||
}
|
||||
}
|
||||
"concurrencykey" => {
|
||||
let uuids = sqlx::query_scalar!("INSERT INTO v2_job (id, runnable_id, runnable_path, kind, script_lang, tag, created_by, permissioned_as, permissioned_as_email, workspace_id, raw_code, concurrent_limit, concurrency_time_window_s) (SELECT gen_random_uuid(), $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12 FROM generate_series(1, $13)) RETURNING id",
|
||||
None::<i64>,
|
||||
Some("u/admin/bench_conckey"),
|
||||
JobKind::Preview as JobKind,
|
||||
ScriptLang::Deno as ScriptLang,
|
||||
"deno",
|
||||
"admin",
|
||||
"u/admin",
|
||||
"admin@windmill.dev",
|
||||
"admins",
|
||||
"export async function main() { return 'done'; }",
|
||||
1i32,
|
||||
0i32,
|
||||
benchmark_jobs,
|
||||
)
|
||||
.fetch_all(&mut *tx)
|
||||
.await.unwrap_or_else(|_e| panic!("failed to insert concurrencykey jobs (1)"));
|
||||
sqlx::query!("INSERT INTO v2_job_queue (id, workspace_id, scheduled_for, tag) SELECT unnest($1::uuid[]), $2, now(), $3", &uuids, "admins", "deno")
|
||||
.execute(&mut *tx)
|
||||
.await.unwrap_or_else(|_e| panic!("failed to insert concurrencykey jobs (2)"));
|
||||
sqlx::query!(
|
||||
"INSERT INTO v2_job_runtime (id) SELECT unnest($1::uuid[])",
|
||||
&uuids
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.unwrap_or_else(|_e| panic!("failed to insert concurrencykey jobs (3)"));
|
||||
let concurrency_id = "bench_shared_concurrency_key";
|
||||
sqlx::query!(
|
||||
"INSERT INTO concurrency_counter (concurrency_id, job_uuids) VALUES ($1, '{}'::jsonb) ON CONFLICT (concurrency_id) DO NOTHING",
|
||||
concurrency_id,
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.unwrap_or_else(|_e| panic!("failed to insert concurrencykey counter"));
|
||||
for uuid in &uuids {
|
||||
sqlx::query!(
|
||||
"INSERT INTO concurrency_key (key, ended_at, job_id) VALUES ($1, now() - INTERVAL '1 hour', $2)",
|
||||
concurrency_id,
|
||||
uuid,
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.unwrap_or_else(|_e| panic!("failed to insert concurrencykey key"));
|
||||
}
|
||||
}
|
||||
"mixed" => {
|
||||
let portion = benchmark_jobs / 5;
|
||||
let remainder = benchmark_jobs % 5;
|
||||
|
||||
// 1) noop jobs
|
||||
let noop_count = portion + remainder;
|
||||
let noop_uuids = sqlx::query_scalar!("INSERT INTO v2_job (id, runnable_id, runnable_path, kind, script_lang, tag, created_by, permissioned_as, permissioned_as_email, workspace_id) (SELECT gen_random_uuid(), $1, $2, $3, $4, $5, $6, $7, $8, $9 FROM generate_series(1, $10)) RETURNING id",
|
||||
None::<i64>,
|
||||
None::<String>,
|
||||
JobKind::Noop as JobKind,
|
||||
ScriptLang::Deno as ScriptLang,
|
||||
"deno",
|
||||
"admin",
|
||||
"u/admin",
|
||||
"admin@windmill.dev",
|
||||
"admins",
|
||||
noop_count,
|
||||
)
|
||||
.fetch_all(&mut *tx)
|
||||
.await.unwrap_or_else(|_e| panic!("failed to insert mixed noop jobs"));
|
||||
sqlx::query!("INSERT INTO v2_job_queue (id, workspace_id, scheduled_for, tag) SELECT unnest($1::uuid[]), $2, now(), $3", &noop_uuids, "admins", "deno")
|
||||
.execute(&mut *tx)
|
||||
.await.unwrap_or_else(|_e| panic!("failed to insert mixed noop queue"));
|
||||
sqlx::query!("INSERT INTO v2_job_runtime (id) SELECT unnest($1::uuid[])", &noop_uuids)
|
||||
.execute(&mut *tx)
|
||||
.await.unwrap_or_else(|_e| panic!("failed to insert mixed noop runtime"));
|
||||
|
||||
// 2) sequentialflow jobs
|
||||
if portion > 0 {
|
||||
let sf_uuids = sqlx::query_scalar!("INSERT INTO v2_job (id, runnable_id, runnable_path, kind, script_lang, tag, created_by, permissioned_as, permissioned_as_email, workspace_id, raw_flow) (SELECT gen_random_uuid(), $1, $2, $3, $4, $5, $6, $7, $8, $9, $10 FROM generate_series(1, $11)) RETURNING id",
|
||||
None::<i64>,
|
||||
None::<String>,
|
||||
JobKind::FlowPreview as JobKind,
|
||||
ScriptLang::Deno as ScriptLang,
|
||||
"flow",
|
||||
"admin",
|
||||
"u/admin",
|
||||
"admin@windmill.dev",
|
||||
"admins",
|
||||
serde_json::from_str::<serde_json::Value>(r#"{"modules":[{"id":"a","value":{"type":"rawscript","content":"export async function main() { return 'a'; }","language":"deno","input_transforms":{}}},{"id":"b","value":{"type":"rawscript","content":"export async function main() { return 'b'; }","language":"deno","input_transforms":{}}},{"id":"c","value":{"type":"rawscript","content":"export async function main() { return 'c'; }","language":"deno","input_transforms":{}}},{"id":"d","value":{"type":"rawscript","content":"export async function main() { return 'd'; }","language":"deno","input_transforms":{}}}],"preprocessor_module":null}"#).unwrap(),
|
||||
portion,
|
||||
)
|
||||
.fetch_all(&mut *tx)
|
||||
.await.unwrap_or_else(|_e| panic!("failed to insert mixed sequentialflow jobs"));
|
||||
sqlx::query!("INSERT INTO v2_job_queue (id, workspace_id, scheduled_for, tag) SELECT unnest($1::uuid[]), $2, now(), $3", &sf_uuids, "admins", "flow")
|
||||
.execute(&mut *tx)
|
||||
.await.unwrap_or_else(|_e| panic!("failed to insert mixed sequentialflow queue"));
|
||||
sqlx::query!("INSERT INTO v2_job_runtime (id) SELECT unnest($1::uuid[])", &sf_uuids)
|
||||
.execute(&mut *tx)
|
||||
.await.unwrap_or_else(|_e| panic!("failed to insert mixed sequentialflow runtime"));
|
||||
sqlx::query!(
|
||||
"INSERT INTO v2_job_status (id, flow_status) SELECT unnest($1::uuid[]), $2",
|
||||
&sf_uuids,
|
||||
serde_json::from_str::<serde_json::Value>(r#"{"step":0,"modules":[{"id":"a","type":"WaitingForPriorSteps"},{"id":"b","type":"WaitingForPriorSteps"},{"id":"c","type":"WaitingForPriorSteps"},{"id":"d","type":"WaitingForPriorSteps"}],"cleanup_module":{},"failure_module":{"id":"failure","type":"WaitingForPriorSteps"},"preprocessor_module":null}"#).unwrap()
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await.unwrap_or_else(|_e| panic!("failed to insert mixed sequentialflow status"));
|
||||
}
|
||||
|
||||
// 3) scriptlogs jobs
|
||||
if portion > 0 {
|
||||
let sl_uuids = sqlx::query_scalar!("INSERT INTO v2_job (id, runnable_id, runnable_path, kind, script_lang, tag, created_by, permissioned_as, permissioned_as_email, workspace_id, raw_code) (SELECT gen_random_uuid(), $1, $2, $3, $4, $5, $6, $7, $8, $9, $10 FROM generate_series(1, $11)) RETURNING id",
|
||||
None::<i64>,
|
||||
None::<String>,
|
||||
JobKind::Preview as JobKind,
|
||||
ScriptLang::Deno as ScriptLang,
|
||||
"deno",
|
||||
"admin",
|
||||
"u/admin",
|
||||
"admin@windmill.dev",
|
||||
"admins",
|
||||
"export async function main() { for (let i = 0; i < 1000; i++) { console.log('benchmark log line ' + i); } return 'done'; }",
|
||||
portion,
|
||||
)
|
||||
.fetch_all(&mut *tx)
|
||||
.await.unwrap_or_else(|_e| panic!("failed to insert mixed scriptlogs jobs"));
|
||||
sqlx::query!("INSERT INTO v2_job_queue (id, workspace_id, scheduled_for, tag) SELECT unnest($1::uuid[]), $2, now(), $3", &sl_uuids, "admins", "deno")
|
||||
.execute(&mut *tx)
|
||||
.await.unwrap_or_else(|_e| panic!("failed to insert mixed scriptlogs queue"));
|
||||
sqlx::query!("INSERT INTO v2_job_runtime (id) SELECT unnest($1::uuid[])", &sl_uuids)
|
||||
.execute(&mut *tx)
|
||||
.await.unwrap_or_else(|_e| panic!("failed to insert mixed scriptlogs runtime"));
|
||||
}
|
||||
|
||||
// 4) concurrencylimit jobs
|
||||
if portion > 0 {
|
||||
let cl_uuids = sqlx::query_scalar!("INSERT INTO v2_job (id, runnable_id, runnable_path, kind, script_lang, tag, created_by, permissioned_as, permissioned_as_email, workspace_id, raw_code, concurrent_limit, concurrency_time_window_s) (SELECT gen_random_uuid(), $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12 FROM generate_series(1, $13)) RETURNING id",
|
||||
None::<i64>,
|
||||
Some("u/admin/bench_conclimit"),
|
||||
JobKind::Preview as JobKind,
|
||||
ScriptLang::Deno as ScriptLang,
|
||||
"deno",
|
||||
"admin",
|
||||
"u/admin",
|
||||
"admin@windmill.dev",
|
||||
"admins",
|
||||
"export async function main() { return 'done'; }",
|
||||
2i32,
|
||||
0i32,
|
||||
portion,
|
||||
)
|
||||
.fetch_all(&mut *tx)
|
||||
.await.unwrap_or_else(|_e| panic!("failed to insert mixed concurrencylimit jobs"));
|
||||
sqlx::query!("INSERT INTO v2_job_queue (id, workspace_id, scheduled_for, tag) SELECT unnest($1::uuid[]), $2, now(), $3", &cl_uuids, "admins", "deno")
|
||||
.execute(&mut *tx)
|
||||
.await.unwrap_or_else(|_e| panic!("failed to insert mixed concurrencylimit queue"));
|
||||
sqlx::query!("INSERT INTO v2_job_runtime (id) SELECT unnest($1::uuid[])", &cl_uuids)
|
||||
.execute(&mut *tx)
|
||||
.await.unwrap_or_else(|_e| panic!("failed to insert mixed concurrencylimit runtime"));
|
||||
let cl_concurrency_id = "u/admin/bench_conclimit";
|
||||
sqlx::query!(
|
||||
"INSERT INTO concurrency_counter (concurrency_id, job_uuids) VALUES ($1, '{}'::jsonb) ON CONFLICT (concurrency_id) DO NOTHING",
|
||||
cl_concurrency_id,
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await.unwrap_or_else(|_e| panic!("failed to insert mixed concurrencylimit counter"));
|
||||
for uuid in &cl_uuids {
|
||||
sqlx::query!(
|
||||
"INSERT INTO concurrency_key (key, ended_at, job_id) VALUES ($1, now() - INTERVAL '1 hour', $2)",
|
||||
cl_concurrency_id,
|
||||
uuid,
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await.unwrap_or_else(|_e| panic!("failed to insert mixed concurrencylimit key"));
|
||||
}
|
||||
}
|
||||
|
||||
// 5) concurrencykey jobs
|
||||
if portion > 0 {
|
||||
let ck_uuids = sqlx::query_scalar!("INSERT INTO v2_job (id, runnable_id, runnable_path, kind, script_lang, tag, created_by, permissioned_as, permissioned_as_email, workspace_id, raw_code, concurrent_limit, concurrency_time_window_s) (SELECT gen_random_uuid(), $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12 FROM generate_series(1, $13)) RETURNING id",
|
||||
None::<i64>,
|
||||
Some("u/admin/bench_conckey"),
|
||||
JobKind::Preview as JobKind,
|
||||
ScriptLang::Deno as ScriptLang,
|
||||
"deno",
|
||||
"admin",
|
||||
"u/admin",
|
||||
"admin@windmill.dev",
|
||||
"admins",
|
||||
"export async function main() { return 'done'; }",
|
||||
1i32,
|
||||
0i32,
|
||||
portion,
|
||||
)
|
||||
.fetch_all(&mut *tx)
|
||||
.await.unwrap_or_else(|_e| panic!("failed to insert mixed concurrencykey jobs"));
|
||||
sqlx::query!("INSERT INTO v2_job_queue (id, workspace_id, scheduled_for, tag) SELECT unnest($1::uuid[]), $2, now(), $3", &ck_uuids, "admins", "deno")
|
||||
.execute(&mut *tx)
|
||||
.await.unwrap_or_else(|_e| panic!("failed to insert mixed concurrencykey queue"));
|
||||
sqlx::query!("INSERT INTO v2_job_runtime (id) SELECT unnest($1::uuid[])", &ck_uuids)
|
||||
.execute(&mut *tx)
|
||||
.await.unwrap_or_else(|_e| panic!("failed to insert mixed concurrencykey runtime"));
|
||||
let ck_concurrency_id = "bench_shared_concurrency_key";
|
||||
sqlx::query!(
|
||||
"INSERT INTO concurrency_counter (concurrency_id, job_uuids) VALUES ($1, '{}'::jsonb) ON CONFLICT (concurrency_id) DO NOTHING",
|
||||
ck_concurrency_id,
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await.unwrap_or_else(|_e| panic!("failed to insert mixed concurrencykey counter"));
|
||||
for uuid in &ck_uuids {
|
||||
sqlx::query!(
|
||||
"INSERT INTO concurrency_key (key, ended_at, job_id) VALUES ($1, now() - INTERVAL '1 hour', $2)",
|
||||
ck_concurrency_id,
|
||||
uuid,
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await.unwrap_or_else(|_e| panic!("failed to insert mixed concurrencykey key"));
|
||||
}
|
||||
}
|
||||
}
|
||||
"mixed_no_cc" => {
|
||||
let portion = benchmark_jobs / 3;
|
||||
let remainder = benchmark_jobs % 3;
|
||||
|
||||
// 1) noop jobs
|
||||
let noop_count = portion + remainder;
|
||||
let noop_uuids = sqlx::query_scalar!("INSERT INTO v2_job (id, runnable_id, runnable_path, kind, script_lang, tag, created_by, permissioned_as, permissioned_as_email, workspace_id) (SELECT gen_random_uuid(), $1, $2, $3, $4, $5, $6, $7, $8, $9 FROM generate_series(1, $10)) RETURNING id",
|
||||
None::<i64>,
|
||||
None::<String>,
|
||||
JobKind::Noop as JobKind,
|
||||
ScriptLang::Deno as ScriptLang,
|
||||
"deno",
|
||||
"admin",
|
||||
"u/admin",
|
||||
"admin@windmill.dev",
|
||||
"admins",
|
||||
noop_count,
|
||||
)
|
||||
.fetch_all(&mut *tx)
|
||||
.await.unwrap_or_else(|_e| panic!("failed to insert mixed_no_cc noop jobs"));
|
||||
sqlx::query!("INSERT INTO v2_job_queue (id, workspace_id, scheduled_for, tag) SELECT unnest($1::uuid[]), $2, now(), $3", &noop_uuids, "admins", "deno")
|
||||
.execute(&mut *tx)
|
||||
.await.unwrap_or_else(|_e| panic!("failed to insert mixed_no_cc noop queue"));
|
||||
sqlx::query!("INSERT INTO v2_job_runtime (id) SELECT unnest($1::uuid[])", &noop_uuids)
|
||||
.execute(&mut *tx)
|
||||
.await.unwrap_or_else(|_e| panic!("failed to insert mixed_no_cc noop runtime"));
|
||||
|
||||
// 2) sequentialflow jobs
|
||||
if portion > 0 {
|
||||
let sf_uuids = sqlx::query_scalar!("INSERT INTO v2_job (id, runnable_id, runnable_path, kind, script_lang, tag, created_by, permissioned_as, permissioned_as_email, workspace_id, raw_flow) (SELECT gen_random_uuid(), $1, $2, $3, $4, $5, $6, $7, $8, $9, $10 FROM generate_series(1, $11)) RETURNING id",
|
||||
None::<i64>,
|
||||
None::<String>,
|
||||
JobKind::FlowPreview as JobKind,
|
||||
ScriptLang::Deno as ScriptLang,
|
||||
"flow",
|
||||
"admin",
|
||||
"u/admin",
|
||||
"admin@windmill.dev",
|
||||
"admins",
|
||||
serde_json::from_str::<serde_json::Value>(r#"{"modules":[{"id":"a","value":{"type":"rawscript","content":"export async function main() { return 'a'; }","language":"deno","input_transforms":{}}},{"id":"b","value":{"type":"rawscript","content":"export async function main() { return 'b'; }","language":"deno","input_transforms":{}}},{"id":"c","value":{"type":"rawscript","content":"export async function main() { return 'c'; }","language":"deno","input_transforms":{}}},{"id":"d","value":{"type":"rawscript","content":"export async function main() { return 'd'; }","language":"deno","input_transforms":{}}}],"preprocessor_module":null}"#).unwrap(),
|
||||
portion,
|
||||
)
|
||||
.fetch_all(&mut *tx)
|
||||
.await.unwrap_or_else(|_e| panic!("failed to insert mixed_no_cc sequentialflow jobs"));
|
||||
sqlx::query!("INSERT INTO v2_job_queue (id, workspace_id, scheduled_for, tag) SELECT unnest($1::uuid[]), $2, now(), $3", &sf_uuids, "admins", "flow")
|
||||
.execute(&mut *tx)
|
||||
.await.unwrap_or_else(|_e| panic!("failed to insert mixed_no_cc sequentialflow queue"));
|
||||
sqlx::query!("INSERT INTO v2_job_runtime (id) SELECT unnest($1::uuid[])", &sf_uuids)
|
||||
.execute(&mut *tx)
|
||||
.await.unwrap_or_else(|_e| panic!("failed to insert mixed_no_cc sequentialflow runtime"));
|
||||
sqlx::query!(
|
||||
"INSERT INTO v2_job_status (id, flow_status) SELECT unnest($1::uuid[]), $2",
|
||||
&sf_uuids,
|
||||
serde_json::from_str::<serde_json::Value>(r#"{"step":0,"modules":[{"id":"a","type":"WaitingForPriorSteps"},{"id":"b","type":"WaitingForPriorSteps"},{"id":"c","type":"WaitingForPriorSteps"},{"id":"d","type":"WaitingForPriorSteps"}],"cleanup_module":{},"failure_module":{"id":"failure","type":"WaitingForPriorSteps"},"preprocessor_module":null}"#).unwrap()
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await.unwrap_or_else(|_e| panic!("failed to insert mixed_no_cc sequentialflow status"));
|
||||
}
|
||||
|
||||
// 3) scriptlogs jobs
|
||||
if portion > 0 {
|
||||
let sl_uuids = sqlx::query_scalar!("INSERT INTO v2_job (id, runnable_id, runnable_path, kind, script_lang, tag, created_by, permissioned_as, permissioned_as_email, workspace_id, raw_code) (SELECT gen_random_uuid(), $1, $2, $3, $4, $5, $6, $7, $8, $9, $10 FROM generate_series(1, $11)) RETURNING id",
|
||||
None::<i64>,
|
||||
None::<String>,
|
||||
JobKind::Preview as JobKind,
|
||||
ScriptLang::Deno as ScriptLang,
|
||||
"deno",
|
||||
"admin",
|
||||
"u/admin",
|
||||
"admin@windmill.dev",
|
||||
"admins",
|
||||
"export async function main() { for (let i = 0; i < 1000; i++) { console.log('benchmark log line ' + i); } return 'done'; }",
|
||||
portion,
|
||||
)
|
||||
.fetch_all(&mut *tx)
|
||||
.await.unwrap_or_else(|_e| panic!("failed to insert mixed_no_cc scriptlogs jobs"));
|
||||
sqlx::query!("INSERT INTO v2_job_queue (id, workspace_id, scheduled_for, tag) SELECT unnest($1::uuid[]), $2, now(), $3", &sl_uuids, "admins", "deno")
|
||||
.execute(&mut *tx)
|
||||
.await.unwrap_or_else(|_e| panic!("failed to insert mixed_no_cc scriptlogs queue"));
|
||||
sqlx::query!("INSERT INTO v2_job_runtime (id) SELECT unnest($1::uuid[])", &sl_uuids)
|
||||
.execute(&mut *tx)
|
||||
.await.unwrap_or_else(|_e| panic!("failed to insert mixed_no_cc scriptlogs runtime"));
|
||||
}
|
||||
}
|
||||
"none" => {}
|
||||
_ => {
|
||||
let uuids = sqlx::query_scalar!("INSERT INTO v2_job (id, runnable_id, runnable_path, kind, script_lang, tag, created_by, permissioned_as, permissioned_as_email, workspace_id) (SELECT gen_random_uuid(), $1, $2, $3, $4, $5, $6, $7, $8, $9 FROM generate_series(1, $10)) RETURNING id",
|
||||
@@ -896,16 +288,6 @@ pub async fn benchmark_init(benchmark_jobs: i32, db: &DB) {
|
||||
.unwrap_or_else(|_e| panic!("failed to insert noop jobs (3)"));
|
||||
}
|
||||
}
|
||||
// Insert job_perms for all benchmark jobs so workers don't fall back to slow permission lookups
|
||||
sqlx::query(
|
||||
"INSERT INTO job_perms (job_id, email, username, is_admin, is_operator, groups, folders, workspace_id)
|
||||
SELECT id, 'admin@windmill.dev', 'admin', true, false, ARRAY['all']::text[], ARRAY[]::jsonb[], 'admins'
|
||||
FROM v2_job WHERE workspace_id = 'admins'"
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.unwrap_or_else(|e| panic!("failed to insert job_perms: {e:#}"));
|
||||
|
||||
tx.commit().await.unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -664,6 +664,153 @@ pub async fn get_database_url() -> Result<DatabaseUrl, Error> {
|
||||
Ok(database_url.clone())
|
||||
}
|
||||
|
||||
pub async fn initial_connection() -> Result<sqlx::Pool<sqlx::Postgres>, error::Error> {
|
||||
let connect_options = get_database_url().await?.connect_options().await?;
|
||||
sqlx::postgres::PgPoolOptions::new()
|
||||
.max_connections(2)
|
||||
.connect_with(connect_options)
|
||||
.await
|
||||
.map_err(|err| Error::ConnectingToDatabase(err.to_string()))
|
||||
}
|
||||
|
||||
pub async fn connect_db(
|
||||
server_mode: bool,
|
||||
indexer_mode: bool,
|
||||
worker_mode: bool,
|
||||
#[cfg(feature = "private")] mut killpill_rx: tokio::sync::broadcast::Receiver<()>,
|
||||
) -> anyhow::Result<sqlx::Pool<sqlx::Postgres>> {
|
||||
use anyhow::Context;
|
||||
|
||||
let database_url = get_database_url().await?;
|
||||
|
||||
let max_connections = match std::env::var("DATABASE_CONNECTIONS") {
|
||||
Ok(n) => n.parse::<u32>().context("invalid DATABASE_CONNECTIONS")?,
|
||||
Err(_) => {
|
||||
if server_mode {
|
||||
DEFAULT_MAX_CONNECTIONS_SERVER
|
||||
} else if indexer_mode {
|
||||
DEFAULT_MAX_CONNECTIONS_INDEXER
|
||||
} else {
|
||||
DEFAULT_MAX_CONNECTIONS_WORKER
|
||||
+ std::env::var("NUM_WORKERS")
|
||||
.ok()
|
||||
.map(|x| x.parse().ok())
|
||||
.flatten()
|
||||
.unwrap_or(1)
|
||||
- 1
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let pool = connect(database_url.clone(), max_connections, worker_mode).await?;
|
||||
#[cfg(all(feature = "enterprise", feature = "private"))]
|
||||
let pool2 = pool.clone();
|
||||
#[cfg(all(feature = "enterprise", feature = "private"))]
|
||||
if let DatabaseUrl::IamRds(database_url) = database_url {
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = killpill_rx.recv() => {
|
||||
break;
|
||||
}
|
||||
_ = tokio::time::sleep(std::time::Duration::from_secs(10)) => {
|
||||
let needs_refresh = {
|
||||
let read_guard = database_url.read().await;
|
||||
read_guard.needs_refresh()
|
||||
};
|
||||
if needs_refresh {
|
||||
let new_url = tokio::time::timeout(std::time::Duration::from_secs(10), get_database_url()).await;
|
||||
match new_url {
|
||||
Ok(Ok(new_url)) => {
|
||||
match new_url.connect_options().await {
|
||||
Ok(connect_options) => {
|
||||
pool2.set_connect_options(connect_options);
|
||||
tracing::info!("Refreshed IAM RDS URL successfully");
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("Error getting IAM RDS connect options, retrying in 10s: {}", e);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(Err(e)) => {
|
||||
tracing::error!("Error refreshing IAM RDS URL, trying again in 10s: {}", e);
|
||||
continue;
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("Timeout after 10s refreshing IAM RDS URL, trying again in 10 seconds: {}", e);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Ok(pool)
|
||||
}
|
||||
|
||||
pub async fn connect(
|
||||
database_url: DatabaseUrl,
|
||||
max_connections: u32,
|
||||
worker_mode: bool,
|
||||
) -> Result<sqlx::Pool<sqlx::Postgres>, error::Error> {
|
||||
use sqlx::Executor;
|
||||
use std::time::Duration;
|
||||
sqlx::postgres::PgPoolOptions::new()
|
||||
.min_connections((max_connections / 5).clamp(3, max_connections))
|
||||
.max_connections(max_connections)
|
||||
.max_lifetime(Duration::from_secs(30 * 60)) // 30 mins
|
||||
.after_connect(move |conn, _| {
|
||||
if worker_mode {
|
||||
Box::pin(async move {
|
||||
if let Err(e) = conn
|
||||
.execute(
|
||||
r#"
|
||||
SET enable_seqscan = OFF;
|
||||
SET statement_timeout = '5min';
|
||||
SET idle_in_transaction_session_timeout = '10min';
|
||||
SET tcp_keepalives_idle = 300;
|
||||
SET tcp_keepalives_interval = 60;
|
||||
SET tcp_keepalives_count = 10;"#,
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::error!("Error setting postgres settings: {}", e);
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
} else {
|
||||
Box::pin(async move {
|
||||
if let Err(e) = conn
|
||||
.execute(
|
||||
r#"
|
||||
SET statement_timeout = '5min';
|
||||
SET idle_in_transaction_session_timeout = '10min';
|
||||
SET tcp_keepalives_idle = 300;
|
||||
SET tcp_keepalives_interval = 60;
|
||||
SET tcp_keepalives_count = 10;"#,
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::error!("Error setting postgres settings: {}", e);
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
})
|
||||
.connect_with(
|
||||
database_url
|
||||
.connect_options()
|
||||
.await?
|
||||
.statement_cache_capacity(400),
|
||||
)
|
||||
.await
|
||||
.map_err(|err| Error::ConnectingToDatabase(err.to_string()))
|
||||
}
|
||||
|
||||
type Tag = String;
|
||||
|
||||
pub use db::DB;
|
||||
@@ -705,9 +852,11 @@ impl ScriptHashInfo<ScriptRunnableSettingsHandle> {
|
||||
self,
|
||||
db: &DB,
|
||||
) -> error::Result<ScriptHashInfo<ScriptRunnableSettingsInline>> {
|
||||
let rs =
|
||||
runnable_settings::from_handle(self.runnable_settings.runnable_settings_handle, db)
|
||||
.await?;
|
||||
let rs = runnable_settings::from_handle(
|
||||
self.runnable_settings.runnable_settings_handle,
|
||||
db,
|
||||
)
|
||||
.await?;
|
||||
let (debouncing_settings, concurrency_settings) =
|
||||
runnable_settings::prefetch_cached(&rs, db).await?;
|
||||
|
||||
@@ -1027,25 +1176,25 @@ pub fn get_flow_version_info_from_version<
|
||||
_ => {
|
||||
tracing::debug!("Fetching flow version info for {version} ({path})");
|
||||
let mut conn = db.acquire().await?;
|
||||
let flow_info =
|
||||
let flow_info =
|
||||
sqlx::query_as!(
|
||||
FlowVersionInfo,
|
||||
r#"
|
||||
SELECT
|
||||
flow_version.id AS version,
|
||||
flow_version.value->>'early_return' as early_return,
|
||||
flow_version.value->>'preprocessor_module' IS NOT NULL as has_preprocessor,
|
||||
(flow_version.value->>'chat_input_enabled')::boolean as chat_input_enabled,
|
||||
flow.tag,
|
||||
flow.dedicated_worker,
|
||||
flow.on_behalf_of_email,
|
||||
flow_version.value->>'early_return' as early_return,
|
||||
flow_version.value->>'preprocessor_module' IS NOT NULL as has_preprocessor,
|
||||
(flow_version.value->>'chat_input_enabled')::boolean as chat_input_enabled,
|
||||
flow.tag,
|
||||
flow.dedicated_worker,
|
||||
flow.on_behalf_of_email,
|
||||
flow.edited_by
|
||||
FROM
|
||||
FROM
|
||||
flow_version
|
||||
INNER JOIN flow
|
||||
ON flow.path = flow_version.path AND
|
||||
flow.workspace_id = flow_version.workspace_id
|
||||
WHERE
|
||||
WHERE
|
||||
flow_version.workspace_id = $1 AND
|
||||
flow_version.path = $2 AND
|
||||
flow_version.id = $3
|
||||
|
||||
@@ -145,13 +145,6 @@ lazy_static::lazy_static! {
|
||||
tracing::info!("Mode not specified, defaulting to standalone");
|
||||
Mode::Standalone
|
||||
});
|
||||
#[cfg(feature = "benchmark")]
|
||||
let mode = {
|
||||
if mode != Mode::Worker {
|
||||
println!("Benchmark mode: forcing MODE=worker");
|
||||
}
|
||||
Mode::Worker
|
||||
};
|
||||
ModeAndAddons {
|
||||
indexer: search_addon,
|
||||
mode,
|
||||
|
||||
@@ -13,103 +13,6 @@ use crate::{
|
||||
PgDatabase, DB,
|
||||
};
|
||||
|
||||
macro_rules! sqlx_bitflags {
|
||||
(
|
||||
$flags:ty => $repr:ty
|
||||
) => {
|
||||
// ---- Type ----
|
||||
impl sqlx::Type<sqlx::Postgres> for $flags {
|
||||
fn type_info() -> sqlx::postgres::PgTypeInfo {
|
||||
<$repr as sqlx::Type<sqlx::Postgres>>::type_info()
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Encode ----
|
||||
impl<'q> sqlx::Encode<'q, sqlx::Postgres> for $flags {
|
||||
fn encode_by_ref(
|
||||
&self,
|
||||
buf: &mut sqlx::postgres::PgArgumentBuffer,
|
||||
) -> std::result::Result<sqlx::encode::IsNull, Box<dyn std::error::Error + Send + Sync>>
|
||||
{
|
||||
let bits: $repr = self.bits();
|
||||
<$repr as sqlx::Encode<sqlx::Postgres>>::encode(bits, buf)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Decode ----
|
||||
impl<'r> sqlx::Decode<'r, sqlx::Postgres> for $flags {
|
||||
fn decode(
|
||||
value: sqlx::postgres::PgValueRef<'r>,
|
||||
) -> std::result::Result<Self, Box<dyn std::error::Error + Send + Sync>> {
|
||||
let bits = <$repr as sqlx::Decode<sqlx::Postgres>>::decode(value)?;
|
||||
<$flags>::from_bits(bits)
|
||||
.ok_or_else(|| "invalid bitflags value from database".into())
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Protection Rules - for fine-grained workspace access control
|
||||
|
||||
/// API representation of a protection rule
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ProtectionRuleset {
|
||||
pub workspace_id: String,
|
||||
pub name: String,
|
||||
pub rules: ProtectionRules,
|
||||
pub bypass_groups: Vec<String>,
|
||||
pub bypass_users: Vec<String>,
|
||||
}
|
||||
|
||||
bitflags::bitflags! {
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
// #[sqlx(transparent)]
|
||||
pub struct ProtectionRules: i32 {
|
||||
const DISABLE_DIRECT_DEPLOYMENT = 1 << 0;
|
||||
const DISABLE_WORKSPACE_FORKING = 1 << 1;
|
||||
}
|
||||
}
|
||||
|
||||
sqlx_bitflags!(ProtectionRules => i32);
|
||||
|
||||
#[derive(Serialize, Deserialize, strum_macros::EnumIter)]
|
||||
pub enum ProtectionRuleKind {
|
||||
DisableDirectDeployment,
|
||||
DisableWorkspaceForking,
|
||||
}
|
||||
|
||||
impl ProtectionRuleKind {
|
||||
pub const fn flag(&self) -> ProtectionRules {
|
||||
match self {
|
||||
ProtectionRuleKind::DisableDirectDeployment => {
|
||||
ProtectionRules::DISABLE_DIRECT_DEPLOYMENT
|
||||
}
|
||||
ProtectionRuleKind::DisableWorkspaceForking => {
|
||||
ProtectionRules::DISABLE_WORKSPACE_FORKING
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub const fn msg(&self) -> &str {
|
||||
match self {
|
||||
ProtectionRuleKind::DisableDirectDeployment => {
|
||||
"Cannot directly deploy in this workspace. Fork or Pull request required."
|
||||
}
|
||||
ProtectionRuleKind::DisableWorkspaceForking => "Forking this workspace is forbidden",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&Vec<ProtectionRuleKind>> for ProtectionRules {
|
||||
fn from(value: &Vec<ProtectionRuleKind>) -> Self {
|
||||
let mut r = ProtectionRules::empty();
|
||||
for rule in value {
|
||||
r = r | rule.flag();
|
||||
}
|
||||
r
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Default)]
|
||||
pub struct WorkspaceGitSyncSettings {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
@@ -273,111 +176,6 @@ pub async fn get_team_plan_status(_db: &crate::DB, _w_id: &str) -> Result<TeamPl
|
||||
Ok(team_plan_info)
|
||||
}
|
||||
|
||||
// Protection Rules Cache
|
||||
|
||||
lazy_static::lazy_static! {
|
||||
pub static ref PROTECTION_RULES_CACHE: Cache<String, (std::sync::Arc<Vec<ProtectionRuleset>>, i64)> = Cache::new(100);
|
||||
}
|
||||
|
||||
/// Get all protection rules for a workspace with caching (60s TTL)
|
||||
pub async fn get_protection_rules(
|
||||
workspace_id: &str,
|
||||
db: &DB,
|
||||
) -> Result<std::sync::Arc<Vec<ProtectionRuleset>>> {
|
||||
let now = chrono::Utc::now().timestamp();
|
||||
|
||||
// Check cache and expiry
|
||||
if let Some((cached_rules, expiry)) = PROTECTION_RULES_CACHE.get(workspace_id) {
|
||||
if expiry > now {
|
||||
return Ok(cached_rules);
|
||||
}
|
||||
}
|
||||
|
||||
// Query database
|
||||
let rulesets = sqlx::query_as!(
|
||||
ProtectionRuleset,
|
||||
r#"
|
||||
SELECT
|
||||
workspace_id,
|
||||
name,
|
||||
rules as "rules: ProtectionRules",
|
||||
bypass_groups,
|
||||
bypass_users
|
||||
FROM workspace_protection_rule
|
||||
WHERE workspace_id = $1
|
||||
ORDER BY name
|
||||
"#,
|
||||
workspace_id
|
||||
)
|
||||
.fetch_all(db)
|
||||
.await
|
||||
.map_err(|e| Error::internal_err(format!("Failed to fetch protection rules: {}", e)))?;
|
||||
|
||||
// Cache with 60s TTL
|
||||
let arc_rules = std::sync::Arc::new(rulesets);
|
||||
let expiry = now + 60;
|
||||
PROTECTION_RULES_CACHE.insert(workspace_id.to_string(), (arc_rules.clone(), expiry));
|
||||
|
||||
Ok(arc_rules)
|
||||
}
|
||||
|
||||
/// Invalidate the protection rules cache for a workspace
|
||||
pub fn invalidate_protection_rules_cache(workspace_id: &str) {
|
||||
PROTECTION_RULES_CACHE.remove(workspace_id);
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum RuleCheckResult {
|
||||
Allowed,
|
||||
Blocked(String),
|
||||
}
|
||||
|
||||
/// Check if a user can bypass a protection rule
|
||||
///
|
||||
/// Returns `Allowed` if:
|
||||
/// - User is in the rule's bypass users list (u/<username>)
|
||||
/// - User's group is in the rule's bypass groups list (g/<groupname>)
|
||||
///
|
||||
/// Returns `Blocked` if:
|
||||
/// - User is not in bypass lists
|
||||
///
|
||||
/// Returns `Err` if the rule is not found
|
||||
pub async fn check_user_against_rule(
|
||||
workspace_id: &str,
|
||||
rule: &ProtectionRuleKind,
|
||||
username: &str,
|
||||
user_groups: &[String],
|
||||
is_admin: bool,
|
||||
db: &DB,
|
||||
) -> Result<RuleCheckResult> {
|
||||
if is_admin {
|
||||
return Ok(RuleCheckResult::Allowed);
|
||||
}
|
||||
|
||||
let rulesets = get_protection_rules(workspace_id, db).await?;
|
||||
|
||||
for ruleset in rulesets.iter() {
|
||||
if ruleset.rules.contains(rule.flag()) {
|
||||
if ruleset.bypass_users.iter().any(|u| u == username)
|
||||
|| ruleset
|
||||
.bypass_groups
|
||||
.iter()
|
||||
.any(|g| user_groups.contains(g))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
return Ok(RuleCheckResult::Blocked(format!(
|
||||
"Ruleset {} of {} blocked this action: {}",
|
||||
ruleset.name,
|
||||
workspace_id,
|
||||
rule.msg()
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(RuleCheckResult::Allowed)
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, Debug)]
|
||||
pub struct DataTable {
|
||||
pub database: DataTableDatabase,
|
||||
|
||||
@@ -19,8 +19,6 @@
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
pub const EVAL_TIMEOUT_MS: u64 = 20000;
|
||||
|
||||
use lazy_static::lazy_static;
|
||||
use regex::Regex;
|
||||
#[cfg(feature = "quickjs")]
|
||||
@@ -276,7 +274,7 @@ pub async fn eval_timeout_quickjs(
|
||||
|
||||
// Run the QuickJS evaluation with a timeout
|
||||
tokio::time::timeout(
|
||||
std::time::Duration::from_millis(EVAL_TIMEOUT_MS),
|
||||
std::time::Duration::from_millis(10000),
|
||||
tokio::task::spawn_blocking(move || {
|
||||
// Create a new tokio runtime for async operations within the blocking context
|
||||
let rt = tokio::runtime::Builder::new_current_thread()
|
||||
@@ -300,7 +298,7 @@ pub async fn eval_timeout_quickjs(
|
||||
)
|
||||
.await
|
||||
.map_err(|_| {
|
||||
anyhow::anyhow!("The expression evaluation `{expr}` took too long to execute (>{EVAL_TIMEOUT_MS}ms)")
|
||||
anyhow::anyhow!("The expression evaluation `{expr}` took too long to execute (>10000ms)")
|
||||
})??
|
||||
}
|
||||
|
||||
@@ -788,7 +786,7 @@ pub async fn eval_simple_js(
|
||||
globals: HashMap<String, serde_json::Value>,
|
||||
) -> anyhow::Result<Box<RawValue>> {
|
||||
tokio::time::timeout(
|
||||
std::time::Duration::from_millis(EVAL_TIMEOUT_MS),
|
||||
std::time::Duration::from_millis(10000),
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let rt = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
@@ -821,7 +819,7 @@ pub async fn eval_simple_js(
|
||||
)
|
||||
.await
|
||||
.map_err(|_| {
|
||||
anyhow::anyhow!("The expression evaluation took too long to execute (>{EVAL_TIMEOUT_MS}ms)")
|
||||
anyhow::anyhow!("The expression evaluation took too long to execute (>10000ms)")
|
||||
})??
|
||||
}
|
||||
|
||||
|
||||
@@ -21,9 +21,6 @@ pub struct EndpointTool {
|
||||
pub path_params_schema: Option<serde_json::Value>,
|
||||
pub query_params_schema: Option<serde_json::Value>,
|
||||
pub body_schema: Option<serde_json::Value>,
|
||||
pub path_field_renames: Option<serde_json::Value>,
|
||||
pub query_field_renames: Option<serde_json::Value>,
|
||||
pub body_field_renames: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
/// Convert a single endpoint tool to MCP tool
|
||||
@@ -103,9 +100,7 @@ fn merge_schema_into(
|
||||
|
||||
if let Some(required) = schema.get("required").and_then(|r| r.as_array()) {
|
||||
for req in required.iter().filter_map(|r| r.as_str()) {
|
||||
if !combined_required.contains(&req.to_string()) {
|
||||
combined_required.push(req.to_string());
|
||||
}
|
||||
combined_required.push(req.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,6 @@ use windmill_api_auth::{
|
||||
Tokened,
|
||||
};
|
||||
use windmill_common::db::DB;
|
||||
use windmill_common::workspaces::{check_user_against_rule, ProtectionRuleKind, RuleCheckResult};
|
||||
|
||||
use crate::secret_backend_ext::rename_vault_secret;
|
||||
use crate::var_resource_cache::{cache_resource, get_cached_resource};
|
||||
@@ -684,18 +683,6 @@ async fn create_resource(
|
||||
Json(resource): Json<CreateResource>,
|
||||
) -> Result<(StatusCode, String)> {
|
||||
check_scopes(&authed, || format!("resources:write:{}", resource.path))?;
|
||||
if let RuleCheckResult::Blocked(msg) = check_user_against_rule(
|
||||
&w_id,
|
||||
&ProtectionRuleKind::DisableDirectDeployment,
|
||||
AuditAuthorable::username(&authed),
|
||||
&authed.groups,
|
||||
authed.is_admin,
|
||||
&db,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
return Err(Error::PermissionDenied(msg));
|
||||
}
|
||||
if *CLOUD_HOSTED {
|
||||
let nb_resources = sqlx::query_scalar!(
|
||||
"SELECT COUNT(*) FROM resource WHERE workspace_id = $1",
|
||||
@@ -806,18 +793,6 @@ async fn delete_resource(
|
||||
let path = path.to_path();
|
||||
|
||||
check_scopes(&authed, || format!("resources:write:{}", path))?;
|
||||
if let RuleCheckResult::Blocked(msg) = check_user_against_rule(
|
||||
&w_id,
|
||||
&ProtectionRuleKind::DisableDirectDeployment,
|
||||
AuditAuthorable::username(&authed),
|
||||
&authed.groups,
|
||||
authed.is_admin,
|
||||
&db,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
return Err(Error::PermissionDenied(msg));
|
||||
}
|
||||
let mut tx = user_db.begin(&authed).await?;
|
||||
|
||||
let deleted_path = sqlx::query_scalar!(
|
||||
@@ -879,19 +854,6 @@ async fn delete_resources_bulk(
|
||||
check_scopes(&authed, || format!("resources:write:{}", path))?;
|
||||
}
|
||||
|
||||
if let RuleCheckResult::Blocked(msg) = check_user_against_rule(
|
||||
&w_id,
|
||||
&ProtectionRuleKind::DisableDirectDeployment,
|
||||
AuditAuthorable::username(&authed),
|
||||
&authed.groups,
|
||||
authed.is_admin,
|
||||
&db,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
return Err(Error::PermissionDenied(msg));
|
||||
}
|
||||
|
||||
let mut tx = user_db.begin(&authed).await?;
|
||||
|
||||
let deleted_paths = sqlx::query_scalar!(
|
||||
@@ -954,18 +916,6 @@ async fn update_resource(
|
||||
|
||||
let path = path.to_path();
|
||||
check_scopes(&authed, || format!("resources:write:{}", path))?;
|
||||
if let RuleCheckResult::Blocked(msg) = check_user_against_rule(
|
||||
&w_id,
|
||||
&ProtectionRuleKind::DisableDirectDeployment,
|
||||
AuditAuthorable::username(&authed),
|
||||
&authed.groups,
|
||||
authed.is_admin,
|
||||
&db,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
return Err(Error::PermissionDenied(msg));
|
||||
}
|
||||
|
||||
let mut sqlb = SqlBuilder::update_table("resource");
|
||||
sqlb.and_where_eq("path", "?".bind(&path));
|
||||
@@ -1092,18 +1042,6 @@ async fn update_resource_value(
|
||||
) -> Result<String> {
|
||||
let path = path.to_path();
|
||||
check_scopes(&authed, || format!("resources:write:{}", path))?;
|
||||
if let RuleCheckResult::Blocked(msg) = check_user_against_rule(
|
||||
&w_id,
|
||||
&ProtectionRuleKind::DisableDirectDeployment,
|
||||
AuditAuthorable::username(&authed),
|
||||
&authed.groups,
|
||||
authed.is_admin,
|
||||
&db,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
return Err(Error::PermissionDenied(msg));
|
||||
}
|
||||
let mut tx = user_db.begin(&authed).await?;
|
||||
|
||||
sqlx::query!(
|
||||
@@ -1255,19 +1193,6 @@ async fn create_resource_type(
|
||||
Path(w_id): Path<String>,
|
||||
Json(resource_type): Json<CreateResourceType>,
|
||||
) -> Result<(StatusCode, String)> {
|
||||
if let RuleCheckResult::Blocked(msg) = check_user_against_rule(
|
||||
&w_id,
|
||||
&ProtectionRuleKind::DisableDirectDeployment,
|
||||
AuditAuthorable::username(&authed),
|
||||
&authed.groups,
|
||||
authed.is_admin,
|
||||
&db,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
return Err(Error::PermissionDenied(msg));
|
||||
}
|
||||
|
||||
let mut tx = user_db.begin(&authed).await?;
|
||||
|
||||
check_rt_path_conflict(&mut tx, &w_id, &resource_type.name).await?;
|
||||
@@ -1354,18 +1279,6 @@ async fn delete_resource_type(
|
||||
Path((w_id, name)): Path<(String, String)>,
|
||||
) -> Result<String> {
|
||||
require_admin(authed.is_admin, &authed.username)?;
|
||||
if let RuleCheckResult::Blocked(msg) = check_user_against_rule(
|
||||
&w_id,
|
||||
&ProtectionRuleKind::DisableDirectDeployment,
|
||||
AuditAuthorable::username(&authed),
|
||||
&authed.groups,
|
||||
authed.is_admin,
|
||||
&db,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
return Err(Error::PermissionDenied(msg));
|
||||
}
|
||||
|
||||
let mut tx = user_db.begin(&authed).await?;
|
||||
|
||||
@@ -1420,18 +1333,6 @@ async fn update_resource_type(
|
||||
Json(ns): Json<EditResourceType>,
|
||||
) -> Result<String> {
|
||||
use sql_builder::prelude::*;
|
||||
if let RuleCheckResult::Blocked(msg) = check_user_against_rule(
|
||||
&w_id,
|
||||
&ProtectionRuleKind::DisableDirectDeployment,
|
||||
AuditAuthorable::username(&authed),
|
||||
&authed.groups,
|
||||
authed.is_admin,
|
||||
&db,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
return Err(Error::PermissionDenied(msg));
|
||||
}
|
||||
|
||||
let mut sqlb = SqlBuilder::update_table("resource_type");
|
||||
sqlb.and_where_eq("name", "?".bind(&name));
|
||||
|
||||
@@ -8,7 +8,6 @@
|
||||
|
||||
use windmill_api_auth::{check_scopes, maybe_refresh_folders, require_owner_of_path, ApiAuthed};
|
||||
use windmill_common::db::DB;
|
||||
use windmill_common::workspaces::{check_user_against_rule, ProtectionRuleKind, RuleCheckResult};
|
||||
|
||||
use crate::secret_backend_ext::{
|
||||
delete_secret_from_backend, get_secret_value, is_vault_stored_value, rename_vault_secret,
|
||||
@@ -340,19 +339,6 @@ async fn create_variable(
|
||||
Json(variable): Json<CreateVariable>,
|
||||
) -> Result<(StatusCode, String)> {
|
||||
check_scopes(&authed, || format!("variables:write:{}", variable.path))?;
|
||||
if let RuleCheckResult::Blocked(msg) = check_user_against_rule(
|
||||
&w_id,
|
||||
&ProtectionRuleKind::DisableDirectDeployment,
|
||||
AuditAuthorable::username(&authed),
|
||||
&authed.groups,
|
||||
authed.is_admin,
|
||||
&db,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
return Err(Error::PermissionDenied(msg));
|
||||
}
|
||||
|
||||
if *CLOUD_HOSTED {
|
||||
let nb_variables = sqlx::query_scalar!(
|
||||
"SELECT COUNT(*) FROM variable WHERE workspace_id = $1",
|
||||
@@ -452,18 +438,6 @@ async fn delete_variable(
|
||||
let path = path.to_path();
|
||||
|
||||
check_scopes(&authed, || format!("variables:write:{}", path))?;
|
||||
if let RuleCheckResult::Blocked(msg) = check_user_against_rule(
|
||||
&w_id,
|
||||
&ProtectionRuleKind::DisableDirectDeployment,
|
||||
AuditAuthorable::username(&authed),
|
||||
&authed.groups,
|
||||
authed.is_admin,
|
||||
&db,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
return Err(Error::PermissionDenied(msg));
|
||||
}
|
||||
|
||||
// Check if variable is a secret before deleting (for Vault cleanup)
|
||||
let is_secret = sqlx::query_scalar!(
|
||||
@@ -541,19 +515,6 @@ async fn delete_variables_bulk(
|
||||
check_scopes(&authed, || format!("variables:write:{}", path))?;
|
||||
}
|
||||
|
||||
if let RuleCheckResult::Blocked(msg) = check_user_against_rule(
|
||||
&w_id,
|
||||
&ProtectionRuleKind::DisableDirectDeployment,
|
||||
AuditAuthorable::username(&authed),
|
||||
&authed.groups,
|
||||
authed.is_admin,
|
||||
&db,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
return Err(Error::PermissionDenied(msg));
|
||||
}
|
||||
|
||||
// Query which paths are secrets before deletion (for Vault cleanup)
|
||||
let secret_paths: Vec<String> = sqlx::query_scalar!(
|
||||
"SELECT path FROM variable WHERE path = ANY($1) AND workspace_id = $2 AND is_secret = true",
|
||||
@@ -652,19 +613,6 @@ async fn update_variable(
|
||||
) -> Result<String> {
|
||||
use sql_builder::prelude::*;
|
||||
|
||||
if let RuleCheckResult::Blocked(msg) = check_user_against_rule(
|
||||
&w_id,
|
||||
&ProtectionRuleKind::DisableDirectDeployment,
|
||||
AuditAuthorable::username(&authed),
|
||||
&authed.groups,
|
||||
authed.is_admin,
|
||||
&db,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
return Err(Error::PermissionDenied(msg));
|
||||
}
|
||||
|
||||
let path = path.to_path();
|
||||
check_scopes(&authed, || format!("variables:write:{}", path))?;
|
||||
let authed = maybe_refresh_folders(&path, &w_id, authed, &db).await;
|
||||
|
||||
@@ -16,17 +16,6 @@ const ANTHROPIC_VERSION_STANDARD: &str = "2023-06-01";
|
||||
/// Anthropic API version for Google Vertex AI
|
||||
const ANTHROPIC_VERSION_VERTEX: &str = "vertex-2023-10-16";
|
||||
|
||||
#[derive(Serialize, Debug, Clone)]
|
||||
pub struct CacheControl {
|
||||
pub r#type: String,
|
||||
}
|
||||
|
||||
impl CacheControl {
|
||||
pub fn ephemeral() -> Self {
|
||||
Self { r#type: "ephemeral".to_string() }
|
||||
}
|
||||
}
|
||||
|
||||
/// Custom tool for Anthropic native API (flat structure with type: "custom")
|
||||
#[derive(Serialize, Debug)]
|
||||
pub struct AnthropicCustomTool {
|
||||
@@ -35,8 +24,6 @@ pub struct AnthropicCustomTool {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub description: Option<String>,
|
||||
pub input_schema: Box<RawValue>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub cache_control: Option<CacheControl>,
|
||||
}
|
||||
|
||||
impl From<&ToolDef> for AnthropicCustomTool {
|
||||
@@ -46,7 +33,6 @@ impl From<&ToolDef> for AnthropicCustomTool {
|
||||
name: tool.function.name.clone(),
|
||||
description: tool.function.description.clone(),
|
||||
input_schema: tool.function.parameters.clone(),
|
||||
cache_control: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -84,22 +70,13 @@ impl AnthropicToolChoice {
|
||||
#[serde(tag = "type")]
|
||||
pub enum AnthropicRequestContent {
|
||||
#[serde(rename = "text")]
|
||||
Text {
|
||||
text: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
cache_control: Option<CacheControl>,
|
||||
},
|
||||
Text { text: String },
|
||||
#[serde(rename = "image")]
|
||||
Image { source: AnthropicImageSource },
|
||||
#[serde(rename = "tool_use")]
|
||||
ToolUse { id: String, name: String, input: Box<RawValue> },
|
||||
#[serde(rename = "tool_result")]
|
||||
ToolResult {
|
||||
tool_use_id: String,
|
||||
content: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
cache_control: Option<CacheControl>,
|
||||
},
|
||||
ToolResult { tool_use_id: String, content: String },
|
||||
}
|
||||
|
||||
/// Image source for Anthropic API
|
||||
@@ -115,8 +92,6 @@ pub struct AnthropicImageSource {
|
||||
pub struct AnthropicSystemContent {
|
||||
pub r#type: String,
|
||||
pub text: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub cache_control: Option<CacheControl>,
|
||||
}
|
||||
|
||||
/// Message format for Anthropic API requests
|
||||
@@ -188,7 +163,7 @@ fn convert_messages_to_anthropic(messages: &[OpenAIMessage]) -> Vec<AnthropicMes
|
||||
if let Some(ref c) = msg.content {
|
||||
let text = extract_text_content(c);
|
||||
if !text.is_empty() {
|
||||
content.push(AnthropicRequestContent::Text { text, cache_control: None });
|
||||
content.push(AnthropicRequestContent::Text { text });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -227,7 +202,6 @@ fn convert_messages_to_anthropic(messages: &[OpenAIMessage]) -> Vec<AnthropicMes
|
||||
content: vec![AnthropicRequestContent::ToolResult {
|
||||
tool_use_id: tool_call_id.clone(),
|
||||
content: content_text,
|
||||
cache_control: None,
|
||||
}],
|
||||
});
|
||||
}
|
||||
@@ -252,7 +226,7 @@ fn convert_content_to_anthropic(content: &Option<OpenAIContent>) -> Vec<Anthropi
|
||||
if text.is_empty() {
|
||||
Vec::new()
|
||||
} else {
|
||||
vec![AnthropicRequestContent::Text { text: text.clone(), cache_control: None }]
|
||||
vec![AnthropicRequestContent::Text { text: text.clone() }]
|
||||
}
|
||||
}
|
||||
OpenAIContent::Parts(parts) => {
|
||||
@@ -261,10 +235,7 @@ fn convert_content_to_anthropic(content: &Option<OpenAIContent>) -> Vec<Anthropi
|
||||
match part {
|
||||
ContentPart::Text { text } => {
|
||||
if !text.is_empty() {
|
||||
result.push(AnthropicRequestContent::Text {
|
||||
text: text.clone(),
|
||||
cache_control: None,
|
||||
});
|
||||
result.push(AnthropicRequestContent::Text { text: text.clone() });
|
||||
}
|
||||
}
|
||||
ContentPart::ImageUrl { image_url } => {
|
||||
@@ -376,7 +347,7 @@ impl AnthropicQueryBuilder {
|
||||
prepare_messages_for_api(args.messages, client, workspace_id).await?;
|
||||
|
||||
// Convert to Anthropic native message format
|
||||
let mut anthropic_messages = convert_messages_to_anthropic(&prepared_messages);
|
||||
let anthropic_messages = convert_messages_to_anthropic(&prepared_messages);
|
||||
|
||||
// Build tools array using typed structs
|
||||
let mut tools: Vec<AnthropicTool> = Vec::new();
|
||||
@@ -401,11 +372,6 @@ impl AnthropicQueryBuilder {
|
||||
Some(s) if !s.is_empty() => Some(vec![AnthropicSystemContent {
|
||||
r#type: "text".to_string(),
|
||||
text: s.to_string(),
|
||||
cache_control: if self.is_vertex() {
|
||||
None
|
||||
} else {
|
||||
Some(CacheControl::ephemeral())
|
||||
},
|
||||
}]),
|
||||
_ => None,
|
||||
};
|
||||
@@ -427,35 +393,9 @@ impl AnthropicQueryBuilder {
|
||||
None
|
||||
};
|
||||
|
||||
let mut tools_option = if tools.is_empty() { None } else { Some(tools) };
|
||||
let tools_option = if tools.is_empty() { None } else { Some(tools) };
|
||||
let max_tokens = Some(args.max_tokens.unwrap_or(64000));
|
||||
|
||||
// Apply cache_control on the last custom tool
|
||||
if !self.is_vertex() {
|
||||
if let Some(ref mut tools_vec) = tools_option {
|
||||
if let Some(AnthropicTool::Custom(ref mut custom)) = tools_vec.last_mut() {
|
||||
custom.cache_control = Some(CacheControl::ephemeral());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Apply cache_control on the last content block of the last message
|
||||
if !self.is_vertex() {
|
||||
if let Some(last_msg) = anthropic_messages.last_mut() {
|
||||
if let Some(last_block) = last_msg.content.last_mut() {
|
||||
match last_block {
|
||||
AnthropicRequestContent::Text { cache_control, .. } => {
|
||||
*cache_control = Some(CacheControl::ephemeral());
|
||||
}
|
||||
AnthropicRequestContent::ToolResult { cache_control, .. } => {
|
||||
*cache_control = Some(CacheControl::ephemeral());
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Build request based on platform
|
||||
if self.is_vertex() {
|
||||
// For Vertex AI: no model field, anthropic_version in body
|
||||
|
||||
@@ -78,33 +78,16 @@ pub async fn eval_timeout(
|
||||
}
|
||||
}
|
||||
|
||||
let mut attempts = 0;
|
||||
loop {
|
||||
let result = windmill_jseval::eval_timeout_quickjs(
|
||||
expr.clone(),
|
||||
transform_context.clone(),
|
||||
flow_input.clone(),
|
||||
flow_env,
|
||||
authed_client,
|
||||
by_id,
|
||||
ctx.clone(),
|
||||
)
|
||||
.await;
|
||||
|
||||
match result {
|
||||
Ok(v) => return Ok(v),
|
||||
Err(e) if attempts < 2 && e.to_string().contains("took too long") => {
|
||||
attempts += 1;
|
||||
tracing::warn!(
|
||||
"js eval timed out (attempt {}/3), retrying in 5s: {}",
|
||||
attempts,
|
||||
expr
|
||||
);
|
||||
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
|
||||
}
|
||||
Err(e) => return Err(e),
|
||||
}
|
||||
}
|
||||
windmill_jseval::eval_timeout_quickjs(
|
||||
expr,
|
||||
transform_context,
|
||||
flow_input,
|
||||
flow_env,
|
||||
authed_client,
|
||||
by_id,
|
||||
ctx,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[cfg(feature = "deno_core")]
|
||||
|
||||
@@ -68,7 +68,6 @@ async fn process_jc(
|
||||
stats_map: &JobStatsMap,
|
||||
killpill_rx: &tokio::sync::broadcast::Receiver<()>,
|
||||
#[cfg(feature = "benchmark")] bench: &mut BenchmarkIter,
|
||||
#[cfg(feature = "benchmark")] bench_infos: &mut BenchmarkInfo,
|
||||
) {
|
||||
let success: bool = jc.success;
|
||||
|
||||
@@ -167,13 +166,6 @@ async fn process_jc(
|
||||
|
||||
if let Some(root_job) = root_job {
|
||||
add_root_flow_job_to_otlp(&root_job, success);
|
||||
|
||||
#[cfg(feature = "benchmark")]
|
||||
if bench_infos.count_top_level(root_job.id) {
|
||||
bench_infos
|
||||
.shared_iters
|
||||
.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
// Accumulate job stats if duration is available
|
||||
@@ -217,7 +209,7 @@ pub fn start_background_processor(
|
||||
let JobCompletedReceiver { bounded_rx, mut killpill_rx, unbounded_rx } = job_completed_rx;
|
||||
|
||||
#[cfg(feature = "benchmark")]
|
||||
let mut infos = BenchmarkInfo::new(windmill_common::bench::shared_bench_iters());
|
||||
let mut infos = BenchmarkInfo::new();
|
||||
|
||||
// Start periodic stats flush task
|
||||
let db_clone = db.clone();
|
||||
@@ -286,10 +278,6 @@ pub fn start_background_processor(
|
||||
jc.job.kind,
|
||||
JobKind::Dependencies | JobKind::FlowDependencies
|
||||
);
|
||||
#[cfg(feature = "benchmark")]
|
||||
let bench_job_id = jc.job.id;
|
||||
#[cfg(feature = "benchmark")]
|
||||
let is_top_level_job = jc.job.parent_job.is_none();
|
||||
|
||||
process_jc(
|
||||
jc,
|
||||
@@ -303,8 +291,6 @@ pub fn start_background_processor(
|
||||
&killpill_rx,
|
||||
#[cfg(feature = "benchmark")]
|
||||
&mut bench,
|
||||
#[cfg(feature = "benchmark")]
|
||||
&mut infos,
|
||||
)
|
||||
.warn_after_seconds(10)
|
||||
.await;
|
||||
@@ -329,9 +315,7 @@ pub fn start_background_processor(
|
||||
|
||||
#[cfg(feature = "benchmark")]
|
||||
{
|
||||
if infos.add_iter(bench, bench_job_id, is_top_level_job) {
|
||||
infos.shared_iters.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
infos.add_iter(bench, true);
|
||||
}
|
||||
last_processing_duration
|
||||
.store(time.elapsed().as_secs() as u16, Ordering::SeqCst);
|
||||
@@ -381,12 +365,6 @@ pub fn start_background_processor(
|
||||
{
|
||||
tracing::error!("Error updating flow status after job completion for {flow} on {worker_name}: {e:#}");
|
||||
}
|
||||
#[cfg(feature = "benchmark")]
|
||||
{
|
||||
if infos.add_iter(bench, flow, true) {
|
||||
infos.shared_iters.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
last_processing_duration
|
||||
.store(time.elapsed().as_secs() as u16, Ordering::SeqCst);
|
||||
}
|
||||
|
||||
@@ -188,7 +188,7 @@ use crate::mssql_executor::do_mssql;
|
||||
use crate::bigquery_executor::do_bigquery;
|
||||
|
||||
#[cfg(feature = "benchmark")]
|
||||
use windmill_common::bench::{benchmark_init, benchmark_verify, BenchmarkInfo, BenchmarkIter};
|
||||
use windmill_common::bench::{benchmark_init, BenchmarkInfo, BenchmarkIter};
|
||||
|
||||
use windmill_common::add_time;
|
||||
|
||||
@@ -1109,60 +1109,51 @@ fn start_interactive_worker_shell(
|
||||
if let Ok(_) = killpill_rx.try_recv() {
|
||||
tracing::info!("Received killpill, exiting worker shell");
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
let pulled_job = match &conn {
|
||||
Connection::Sql(db) => {
|
||||
let common_worker_prefix = retrieve_common_worker_prefix(&worker_name);
|
||||
let query = ("".to_string(), make_pull_query(&[common_worker_prefix]));
|
||||
#[cfg(feature = "benchmark")]
|
||||
let mut bench = windmill_common::bench::BenchmarkIter::new();
|
||||
|
||||
let pulled_job = tokio::select! {
|
||||
_ = killpill_rx.recv() => {
|
||||
tracing::info!("Received killpill during pull, exiting worker shell");
|
||||
break;
|
||||
}
|
||||
result = async {
|
||||
match &conn {
|
||||
Connection::Sql(db) => {
|
||||
let common_worker_prefix = retrieve_common_worker_prefix(&worker_name);
|
||||
let query = ("".to_string(), make_pull_query(&[common_worker_prefix]));
|
||||
let job = pull(
|
||||
&db,
|
||||
false,
|
||||
&worker_name,
|
||||
Some(&query),
|
||||
#[cfg(feature = "benchmark")]
|
||||
let mut bench = windmill_common::bench::BenchmarkIter::new();
|
||||
&mut bench,
|
||||
)
|
||||
.await;
|
||||
|
||||
let job = pull(
|
||||
&db,
|
||||
false,
|
||||
&worker_name,
|
||||
Some(&query),
|
||||
#[cfg(feature = "benchmark")]
|
||||
&mut bench,
|
||||
)
|
||||
.await;
|
||||
|
||||
use PulledJobResultToJobErr::*;
|
||||
match job {
|
||||
Ok(j) => match j.to_pulled_job() {
|
||||
Ok(j) => Ok(j
|
||||
.clone()
|
||||
.map(|job| NextJob::Sql { flow_runners: None, job })),
|
||||
Err(MissingConcurrencyKey(jc))
|
||||
| Err(ErrorWhilePreprocessing(jc)) => {
|
||||
if let Err(err) = job_completed_tx.send_job(jc, true).await {
|
||||
tracing::error!(
|
||||
"An error occurred while sending job completed: {:#?}",
|
||||
err
|
||||
)
|
||||
}
|
||||
Ok(None)
|
||||
use PulledJobResultToJobErr::*;
|
||||
match job {
|
||||
Ok(j) => match j.to_pulled_job() {
|
||||
Ok(j) => Ok(j
|
||||
.clone()
|
||||
.map(|job| NextJob::Sql { flow_runners: None, job })),
|
||||
Err(MissingConcurrencyKey(jc))
|
||||
| Err(ErrorWhilePreprocessing(jc)) => {
|
||||
if let Err(err) = job_completed_tx.send_job(jc, true).await {
|
||||
tracing::error!(
|
||||
"An error occurred while sending job completed: {:#?}",
|
||||
err
|
||||
)
|
||||
}
|
||||
},
|
||||
Err(err) => Err(err),
|
||||
}
|
||||
}
|
||||
Connection::Http(client) => {
|
||||
crate::agent_workers::pull_job(&client, None, Some(true))
|
||||
.await
|
||||
.map_err(|e| error::Error::InternalErr(e.to_string()))
|
||||
.map(|x| x.map(|y| NextJob::Http(y)))
|
||||
Ok(None)
|
||||
}
|
||||
},
|
||||
Err(err) => Err(err),
|
||||
}
|
||||
}
|
||||
} => result,
|
||||
};
|
||||
Connection::Http(client) => {
|
||||
crate::agent_workers::pull_job(&client, None, Some(true))
|
||||
.await
|
||||
.map_err(|e| error::Error::InternalErr(e.to_string()))
|
||||
.map(|x| x.map(|y| NextJob::Http(y)))
|
||||
}
|
||||
};
|
||||
|
||||
match pulled_job {
|
||||
Ok(Some(job)) => {
|
||||
@@ -1242,6 +1233,7 @@ fn start_interactive_worker_shell(
|
||||
tokio::time::sleep(Duration::from_millis(*SLEEP_QUEUE * 20)).await;
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1579,7 +1571,6 @@ pub async fn run_worker(
|
||||
// This is used to wake up the background processor when main loop is done and just waiting for new same workers jobs, and that bg processor is also not processing any jobs, bg processing can exit if no more same worker jobs
|
||||
let wake_up_notify = Arc::new(tokio::sync::Notify::new());
|
||||
let stats_map = JobStatsMap::default();
|
||||
|
||||
let send_result = match (conn, job_completed_rx) {
|
||||
(Connection::Sql(db), Some(job_completed_receiver)) => Some(start_background_processor(
|
||||
job_completed_receiver,
|
||||
@@ -1624,10 +1615,7 @@ pub async fn run_worker(
|
||||
let mut started = false;
|
||||
|
||||
#[cfg(feature = "benchmark")]
|
||||
let mut infos = BenchmarkInfo::new(windmill_common::bench::shared_bench_iters());
|
||||
|
||||
#[cfg(feature = "benchmark")]
|
||||
let mut bench_empty_queue_count: u64 = 0;
|
||||
let mut infos = BenchmarkInfo::new();
|
||||
|
||||
#[cfg(feature = "benchmark")]
|
||||
if let Some(db) = conn.as_sql() {
|
||||
@@ -1819,60 +1807,15 @@ pub async fn run_worker(
|
||||
// }
|
||||
|
||||
#[cfg(feature = "benchmark")]
|
||||
{
|
||||
let total_iters = infos.shared_iters.load(std::sync::atomic::Ordering::Relaxed);
|
||||
if benchmark_jobs > 0 && total_iters >= benchmark_jobs as u64 {
|
||||
tracing::info!("benchmark finished, exiting (total iters: {}, worker iters: {})", total_iters, infos.iters);
|
||||
job_completed_tx
|
||||
.kill()
|
||||
.await
|
||||
.expect("send kill to job completed tx");
|
||||
killpill_tx.send();
|
||||
break;
|
||||
} else if benchmark_jobs > 0 && bench_empty_queue_count > 2000 {
|
||||
tracing::warn!(
|
||||
"benchmark stalled: no jobs in queue for 2000 polls, exiting (total iters: {}, worker iters: {}/{})",
|
||||
total_iters,
|
||||
infos.iters,
|
||||
benchmark_jobs
|
||||
);
|
||||
job_completed_tx
|
||||
.kill()
|
||||
.await
|
||||
.expect("send kill to job completed tx");
|
||||
killpill_tx.send();
|
||||
break;
|
||||
} else if bench_empty_queue_count % 100 == 0 {
|
||||
if let Some(db) = conn.as_sql() {
|
||||
let remaining = sqlx::query_as::<_, (uuid::Uuid, String, bool, Option<String>, Option<uuid::Uuid>)>(
|
||||
"SELECT q.id, q.tag, q.running, j.kind::text, j.parent_job
|
||||
FROM v2_job_queue q JOIN v2_job j ON q.id = j.id
|
||||
WHERE q.workspace_id = 'admins' LIMIT 10"
|
||||
)
|
||||
.fetch_all(db)
|
||||
.await;
|
||||
match remaining {
|
||||
Ok(rows) => {
|
||||
let total_remaining = sqlx::query_scalar::<_, i64>(
|
||||
"SELECT COUNT(*) FROM v2_job_queue WHERE workspace_id = 'admins'"
|
||||
).fetch_one(db).await.unwrap_or(0);
|
||||
for (id, tag, running, kind, parent) in &rows {
|
||||
tracing::info!(
|
||||
" pending job: id={id}, tag={tag}, running={running}, kind={}, parent={:?}",
|
||||
kind.as_deref().unwrap_or("?"), parent
|
||||
);
|
||||
}
|
||||
tracing::info!(
|
||||
"benchmark not finished (total: {}, worker: {}, queue: {})",
|
||||
total_iters, infos.iters, total_remaining
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::info!("benchmark not finished (total: {}, worker: {}), queue query err: {e}", total_iters, infos.iters);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if benchmark_jobs > 0 && infos.iters == benchmark_jobs as u64 {
|
||||
tracing::info!("benchmark finished, exiting");
|
||||
job_completed_tx
|
||||
.kill()
|
||||
.await
|
||||
.expect("send kill to job completed tx");
|
||||
break;
|
||||
} else {
|
||||
tracing::info!("benchmark not finished, still pulling jobs {}", infos.iters);
|
||||
}
|
||||
|
||||
let next_job = {
|
||||
@@ -2086,15 +2029,6 @@ pub async fn run_worker(
|
||||
|
||||
match next_job {
|
||||
Ok(Some(job)) => {
|
||||
#[cfg(feature = "benchmark")]
|
||||
{
|
||||
bench_empty_queue_count = 0;
|
||||
}
|
||||
#[cfg(feature = "benchmark")]
|
||||
let is_top_level_job = job.parent_job.is_none() && !job.kind.is_flow();
|
||||
#[cfg(feature = "benchmark")]
|
||||
let bench_job_id = job.id;
|
||||
|
||||
#[cfg(feature = "prometheus")]
|
||||
if let Some(wb) = worker_busy.as_ref() {
|
||||
wb.set(1);
|
||||
@@ -2140,7 +2074,7 @@ pub async fn run_worker(
|
||||
if let Some(db) = conn.as_sql() {
|
||||
infos.sample_pool(db.size(), db.num_idle() as u32);
|
||||
}
|
||||
infos.add_iter(bench, bench_job_id, is_top_level_job);
|
||||
infos.add_iter(bench, true);
|
||||
}
|
||||
|
||||
continue;
|
||||
@@ -2213,7 +2147,7 @@ pub async fn run_worker(
|
||||
if let Some(db) = conn.as_sql() {
|
||||
infos.sample_pool(db.size(), db.num_idle() as u32);
|
||||
}
|
||||
infos.add_iter(bench, bench_job_id, is_top_level_job);
|
||||
infos.add_iter(bench, true);
|
||||
}
|
||||
|
||||
continue;
|
||||
@@ -2516,7 +2450,7 @@ pub async fn run_worker(
|
||||
if let Some(db) = conn.as_sql() {
|
||||
infos.sample_pool(db.size(), db.num_idle() as u32);
|
||||
}
|
||||
infos.add_iter(bench, bench_job_id, is_top_level_job);
|
||||
infos.add_iter(bench, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2543,12 +2477,11 @@ pub async fn run_worker(
|
||||
|
||||
#[cfg(feature = "benchmark")]
|
||||
{
|
||||
bench_empty_queue_count += 1;
|
||||
add_time!(bench, "sleep because empty job queue");
|
||||
if let Some(db) = conn.as_sql() {
|
||||
infos.sample_pool(db.size(), db.num_idle() as u32);
|
||||
}
|
||||
infos.add_iter(bench, uuid::Uuid::nil(), false);
|
||||
infos.add_iter(bench, false);
|
||||
}
|
||||
#[cfg(feature = "prometheus")]
|
||||
_timer.map(|timer| {
|
||||
@@ -2577,6 +2510,13 @@ pub async fn run_worker(
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "benchmark")]
|
||||
{
|
||||
infos
|
||||
.write_to_file("profiling_main.json")
|
||||
.expect("write to file profiling");
|
||||
}
|
||||
|
||||
drop(dedicated_workers);
|
||||
|
||||
let has_dedicated_workers = !dedicated_handles.is_empty();
|
||||
@@ -2597,17 +2537,6 @@ pub async fn run_worker(
|
||||
tracing::error!("error in awaiting send_result process: {e:?}")
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "benchmark")]
|
||||
{
|
||||
infos
|
||||
.write_to_file("profiling_main.json")
|
||||
.expect("write to file profiling");
|
||||
|
||||
if let Some(db) = conn.as_sql() {
|
||||
benchmark_verify(benchmark_jobs, db).await;
|
||||
}
|
||||
}
|
||||
tracing::info!(worker = %worker_name, hostname = %hostname, "waiting for interactive_shell to finish");
|
||||
if let Some(interactive_shell) = interactive_shell {
|
||||
match tokio::time::timeout(Duration::from_secs(10), interactive_shell).await {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user