Compare commits
53 Commits
scim-handl
...
fix-identi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9a0c108360 | ||
|
|
bc7007bb42 | ||
|
|
99b0ebd677 | ||
|
|
5fd2c1a129 | ||
|
|
70f3ee5ed4 | ||
|
|
8df1d8ec17 | ||
|
|
2f32675801 | ||
|
|
ab868e9ebc | ||
|
|
ad19ac9b37 | ||
|
|
0fb115304a | ||
|
|
79cc4a92d8 | ||
|
|
943fe9c6cc | ||
|
|
e15bfbf91e | ||
|
|
d06b42613f | ||
|
|
0389d9601c | ||
|
|
9e235937ce | ||
|
|
e2cc6e4709 | ||
|
|
c0aafee9a9 | ||
|
|
264fa33917 | ||
|
|
d760ea5eaf | ||
|
|
8866bd44cf | ||
|
|
71549c3db0 | ||
|
|
1fa4d919b3 | ||
|
|
1a73012e07 | ||
|
|
e44504c6e9 | ||
|
|
d7f4b950ce | ||
|
|
f6208af673 | ||
|
|
55ad0ff5c4 | ||
|
|
0885d8c986 | ||
|
|
69ce946241 | ||
|
|
cc67fd9e46 | ||
|
|
6620f5513c | ||
|
|
82f2a3902f | ||
|
|
167084a0eb | ||
|
|
935fb44c84 | ||
|
|
cb8b264dee | ||
|
|
9b3e558d84 | ||
|
|
36a81004dc | ||
|
|
b7475c7309 | ||
|
|
5501b7a729 | ||
|
|
2e2dd511f7 | ||
|
|
1ff14e3f45 | ||
|
|
9e8d4af458 | ||
|
|
ead1ea73af | ||
|
|
0bd756839c | ||
|
|
7f48704cfd | ||
|
|
c28314f424 | ||
|
|
4c8edd5e94 | ||
|
|
8a32322c18 | ||
|
|
0317668089 | ||
|
|
34cf0a0324 | ||
|
|
0904d7fffe | ||
|
|
520706b640 |
44
.github/workflows/benchmark.yml
vendored
44
.github/workflows/benchmark.yml
vendored
@@ -290,6 +290,49 @@ jobs:
|
||||
path: |
|
||||
*.json
|
||||
|
||||
benchmark_wac:
|
||||
runs-on: ubicloud-standard-8
|
||||
services:
|
||||
postgres:
|
||||
image: postgres
|
||||
env:
|
||||
POSTGRES_DB: windmill
|
||||
POSTGRES_PASSWORD: changeme
|
||||
POSTGRES_INITDB_ARGS: "-c shared_buffers=2GB -c work_mem=32MB -c effective_cache_size=4GB"
|
||||
options: >-
|
||||
--health-cmd pg_isready --health-interval 10s --health-timeout 5s
|
||||
--health-retries 5
|
||||
--shm-size=2g
|
||||
windmill:
|
||||
image: ghcr.io/windmill-labs/windmill-ee:main
|
||||
env:
|
||||
DATABASE_URL: postgres://postgres:changeme@postgres:5432/windmill
|
||||
LICENSE_KEY: ${{ secrets.WM_LICENSE_KEY_CI }}
|
||||
WORKER_GROUP: main
|
||||
WORKER_TAGS: deno,bun,go,python3,bash,dependency,flow,nativets
|
||||
options: >-
|
||||
--pull always --health-interval 10s --health-timeout 5s
|
||||
--health-retries 5 --health-cmd "curl
|
||||
http://localhost:8000/api/version"
|
||||
ports:
|
||||
- 8000:8000
|
||||
steps:
|
||||
- uses: denoland/setup-deno@v2
|
||||
with:
|
||||
deno-version: v2.x
|
||||
- name: benchmark
|
||||
timeout-minutes: 30
|
||||
run: deno run -A -r
|
||||
https://raw.githubusercontent.com/windmill-labs/windmill/${GITHUB_REF##ref/head/}/benchmarks/benchmark_suite.ts
|
||||
-c
|
||||
https://raw.githubusercontent.com/windmill-labs/windmill/${GITHUB_REF##ref/head/}/benchmarks/suite_wac.json
|
||||
- name: Save benchmark results
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: benchmark_wac
|
||||
path: |
|
||||
*.json
|
||||
|
||||
benchmark_graphs:
|
||||
runs-on: ubicloud
|
||||
needs:
|
||||
@@ -297,6 +340,7 @@ jobs:
|
||||
- benchmark_dedicated
|
||||
- benchmark_4workers
|
||||
- benchmark_8workers
|
||||
- benchmark_wac
|
||||
steps:
|
||||
- uses: denoland/setup-deno@v2
|
||||
with:
|
||||
|
||||
5
.github/workflows/rust-client-check.yml
vendored
5
.github/workflows/rust-client-check.yml
vendored
@@ -18,10 +18,7 @@ jobs:
|
||||
runs-on: ubicloud-standard-8
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: cachix/install-nix-action@v20
|
||||
with:
|
||||
extra_nix_config: |
|
||||
experimental-features = nix-command flakes
|
||||
- uses: cachix/install-nix-action@v31
|
||||
- name: Check rust client builds
|
||||
run: cd rust-client && nix develop ../ --command ./dev.nu --check
|
||||
timeout-minutes: 16
|
||||
5
.github/workflows/rust_on_release.yml
vendored
5
.github/workflows/rust_on_release.yml
vendored
@@ -10,10 +10,7 @@ jobs:
|
||||
runs-on: ubicloud-standard-8
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: cachix/install-nix-action@v20
|
||||
with:
|
||||
extra_nix_config: |
|
||||
experimental-features = nix-command flakes
|
||||
- uses: cachix/install-nix-action@v31
|
||||
- run: cd rust-client && nix develop ../ --command ./dev.nu --check --publish
|
||||
env:
|
||||
CRATES_IO_TOKEN: ${{ secrets.CRATES_IO_TOKEN }}
|
||||
|
||||
@@ -55,11 +55,13 @@ profiles:
|
||||
- id: backend
|
||||
kind: command
|
||||
split: right
|
||||
command: ROOT="$(git rev-parse --show-toplevel)"; cd "$ROOT/backend" && cargo watch -x "run ${CARGO_FEATURES:+--features $CARGO_FEATURES}"
|
||||
workingDir: backend
|
||||
command: PORT=${BACKEND_PORT:-8000} cargo watch -x "run ${CARGO_FEATURES:+--features $CARGO_FEATURES}"
|
||||
- id: frontend
|
||||
kind: command
|
||||
split: bottom
|
||||
command: ROOT="$(git rev-parse --show-toplevel)"; cd "$ROOT/frontend" && npm run generate-backend-client && npm run dev -- --host 0.0.0.0
|
||||
workingDir: frontend
|
||||
command: npm run generate-backend-client && REMOTE=${REMOTE:-http://localhost:${BACKEND_PORT:-8000}} npm run dev -- --port ${FRONTEND_PORT:-3000} --host 0.0.0.0
|
||||
|
||||
frontendOnly:
|
||||
runtime: host
|
||||
@@ -82,7 +84,8 @@ profiles:
|
||||
- id: frontend
|
||||
kind: command
|
||||
split: right
|
||||
command: ROOT="$(git rev-parse --show-toplevel)"; cd "$ROOT/frontend" && npm run generate-backend-client && npm run dev -- --host 0.0.0.0
|
||||
workingDir: frontend
|
||||
command: npm run generate-backend-client && npm run dev -- --port ${FRONTEND_PORT:-3000} --host 0.0.0.0
|
||||
|
||||
agentOnly:
|
||||
runtime: host
|
||||
|
||||
42
CHANGELOG.md
42
CHANGELOG.md
@@ -1,5 +1,47 @@
|
||||
# Changelog
|
||||
|
||||
## [1.666.0](https://github.com/windmill-labs/windmill/compare/v1.665.0...v1.666.0) (2026-03-26)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add PDF input support to AI agent ([#8525](https://github.com/windmill-labs/windmill/issues/8525)) ([e44504c](https://github.com/windmill-labs/windmill/commit/e44504c6e93e7a4ee94ced03ab626b79a4fd0754))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* add relative imports to the dependency list in deploymentUI ([#8548](https://github.com/windmill-labs/windmill/issues/8548)) ([d760ea5](https://github.com/windmill-labs/windmill/commit/d760ea5eaf4dc33007f1fd3e5e07b86925a0aa11))
|
||||
* filter null entries in FileUpload initialValue to prevent s3 access error ([#8544](https://github.com/windmill-labs/windmill/issues/8544)) ([1a73012](https://github.com/windmill-labs/windmill/commit/1a73012e0737a6ebea8307013dc0f79982269d91))
|
||||
* pass pre-bound TcpListener to run_server to fix Windows CI test race ([#8542](https://github.com/windmill-labs/windmill/issues/8542)) ([d7f4b95](https://github.com/windmill-labs/windmill/commit/d7f4b950ce6e966ed1b410e03d48fe96bc036e73))
|
||||
* resolve parent_hash race condition in sync push with auto_parent ([#8545](https://github.com/windmill-labs/windmill/issues/8545)) ([71549c3](https://github.com/windmill-labs/windmill/commit/71549c3db053bcc209c7065ac8cd42f1e8047cc3))
|
||||
* upload_s3_file not working in VS Code extension ([#8547](https://github.com/windmill-labs/windmill/issues/8547)) ([1fa4d91](https://github.com/windmill-labs/windmill/commit/1fa4d919b30ac9eff2d1789fba2695450ba115e7))
|
||||
|
||||
## [1.665.0](https://github.com/windmill-labs/windmill/compare/v1.664.0...v1.665.0) (2026-03-26)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add instance setting to enforce workspace prefix for HTTP routes ([#8528](https://github.com/windmill-labs/windmill/issues/8528)) ([9b3e558](https://github.com/windmill-labs/windmill/commit/9b3e558d84f15052e9c32695a467f8ef7e4ad1f5))
|
||||
* add trashbin system for soft-deleting items ([#8519](https://github.com/windmill-labs/windmill/issues/8519)) ([69ce946](https://github.com/windmill-labs/windmill/commit/69ce946241d98ea90bc7135d44ca0c87f928be88))
|
||||
* mask sensitive values in job logs ([#8520](https://github.com/windmill-labs/windmill/issues/8520)) ([0885d8c](https://github.com/windmill-labs/windmill/commit/0885d8c986f13ac210e4db3ad38febe9be391ba4))
|
||||
* move basic git sync from EE to CE with runtime user count gating ([#8493](https://github.com/windmill-labs/windmill/issues/8493)) ([79d2bd5](https://github.com/windmill-labs/windmill/commit/79d2bd51a00654162754046308d7670242120df6))
|
||||
* runner groups for shared-process multi-script dedicated workers ([#8434](https://github.com/windmill-labs/windmill/issues/8434)) ([c28314f](https://github.com/windmill-labs/windmill/commit/c28314f424ea0e04b86565ce88e6c91e0df1a0cf))
|
||||
* SCIM user deprovisioning (active:false) + instance-level user disable ([#8484](https://github.com/windmill-labs/windmill/issues/8484)) ([0bd7568](https://github.com/windmill-labs/windmill/commit/0bd756839c0261f255111d62088bdaaecb838085))
|
||||
* show groups and notes in flow status viewer ([#8535](https://github.com/windmill-labs/windmill/issues/8535)) ([167084a](https://github.com/windmill-labs/windmill/commit/167084a0ebe73384fa0d31f0b24017a47686a072))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* auto-generate datatable SDK reference for app mode system prompt ([#8522](https://github.com/windmill-labs/windmill/issues/8522)) ([8a32322](https://github.com/windmill-labs/windmill/commit/8a32322c187ccc60ec7eafb61a9678f267a82282))
|
||||
* consider wmill.yaml environments alias in git sync ([#8532](https://github.com/windmill-labs/windmill/issues/8532)) ([b7475c7](https://github.com/windmill-labs/windmill/commit/b7475c73094a28f520f798f6cb1a0c6b4807ccb7))
|
||||
* GitHub Enterprise Server support for self-managed GitHub Apps ([#8507](https://github.com/windmill-labs/windmill/issues/8507)) ([935fb44](https://github.com/windmill-labs/windmill/commit/935fb44c848b8bf9430b5600dd3c3bedb2f89efd))
|
||||
* raw apps bundle not found during deployment error ([#8515](https://github.com/windmill-labs/windmill/issues/8515)) ([34e3115](https://github.com/windmill-labs/windmill/commit/34e3115bcbd19a8e0b6f483435586a2ab43d0a8e))
|
||||
* require admin for workspace encryption key export ([#8523](https://github.com/windmill-labs/windmill/issues/8523)) ([0317668](https://github.com/windmill-labs/windmill/commit/031766808945aefc926f0836d011c0b2a5d2243d))
|
||||
* restrict logout redirect to whitelisted domains ([#8524](https://github.com/windmill-labs/windmill/issues/8524)) ([4c8edd5](https://github.com/windmill-labs/windmill/commit/4c8edd5e944d77ed2d41c2b87171c1115c0fdcdc))
|
||||
* serve index disk storage sizes from /srch/ endpoint ([#8511](https://github.com/windmill-labs/windmill/issues/8511)) ([e3620e0](https://github.com/windmill-labs/windmill/commit/e3620e074e1bdb46b2b8d732f35a91d300589663))
|
||||
* use /apps_raw/get/ redirect URL for raw apps set as workspace default ([#8508](https://github.com/windmill-labs/windmill/issues/8508)) ([85c52e2](https://github.com/windmill-labs/windmill/commit/85c52e2cded10606cc895d0d3b717e13c69bc9b3))
|
||||
* use resource-level scope overrides during OAuth2 token refresh ([#8540](https://github.com/windmill-labs/windmill/issues/8540)) ([55ad0ff](https://github.com/windmill-labs/windmill/commit/55ad0ff5c499c33b766f47c6f32ba5d3eeb14763))
|
||||
|
||||
## [1.664.0](https://github.com/windmill-labs/windmill/compare/v1.663.0...v1.664.0) (2026-03-24)
|
||||
|
||||
|
||||
|
||||
14
backend/.sqlx/query-08522e494e34f4ecae21460262bf0ed3c5a197dd744c87cb760aaf47001febbd.json
generated
Normal file
14
backend/.sqlx/query-08522e494e34f4ecae21460262bf0ed3c5a197dd744c87cb760aaf47001febbd.json
generated
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "DELETE FROM trashbin WHERE id = $1",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "08522e494e34f4ecae21460262bf0ed3c5a197dd744c87cb760aaf47001febbd"
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT email, login_type::text, verified, super_admin, devops, name, company, username, NULL::bool as operator_only, first_time_user, role_source FROM password ORDER BY super_admin DESC, devops DESC, email LIMIT $1 OFFSET $2",
|
||||
"query": "SELECT email, login_type::text, verified, super_admin, devops, name, company, username, NULL::bool as operator_only, first_time_user, role_source, disabled FROM password ORDER BY super_admin DESC, devops DESC, email LIMIT $1 OFFSET $2",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -57,6 +57,11 @@
|
||||
"ordinal": 10,
|
||||
"name": "role_source",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 11,
|
||||
"name": "disabled",
|
||||
"type_info": "Bool"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
@@ -76,8 +81,9 @@
|
||||
true,
|
||||
null,
|
||||
false,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "05027983ffdb11824190543754d0be922e1463d2046753cf80377369a90013ab"
|
||||
"hash": "115a9cb44d0a41952c08dc36e0331410d32a8d672cfa4929e9e3763c51daa1bc"
|
||||
}
|
||||
14
backend/.sqlx/query-192ddae8c3c82a8f099a4944483024d9826a328bf0416c22daf06fff5ced08f6.json
generated
Normal file
14
backend/.sqlx/query-192ddae8c3c82a8f099a4944483024d9826a328bf0416c22daf06fff5ced08f6.json
generated
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "DELETE FROM token WHERE email = $1",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "192ddae8c3c82a8f099a4944483024d9826a328bf0416c22daf06fff5ced08f6"
|
||||
}
|
||||
15
backend/.sqlx/query-1d995dd5a094631ae96c16d68026fdeb22714af38162e87c02b052a5b8ec2645.json
generated
Normal file
15
backend/.sqlx/query-1d995dd5a094631ae96c16d68026fdeb22714af38162e87c02b052a5b8ec2645.json
generated
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "DELETE FROM trashbin WHERE workspace_id = $1 AND id = $2",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Int8"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "1d995dd5a094631ae96c16d68026fdeb22714af38162e87c02b052a5b8ec2645"
|
||||
}
|
||||
28
backend/.sqlx/query-23b9c862d050b00aaa332527b62ef901cd3c417b9f3af03f35009213143bd443.json
generated
Normal file
28
backend/.sqlx/query-23b9c862d050b00aaa332527b62ef901cd3c417b9f3af03f35009213143bd443.json
generated
Normal file
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT email, disabled FROM password WHERE email = $1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "email",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "disabled",
|
||||
"type_info": "Bool"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "23b9c862d050b00aaa332527b62ef901cd3c417b9f3af03f35009213143bd443"
|
||||
}
|
||||
12
backend/.sqlx/query-3453c0b7dd3c4d2c9bc639f379901741955502c9345e82a9b7fbbf3d3c7ab517.json
generated
Normal file
12
backend/.sqlx/query-3453c0b7dd3c4d2c9bc639f379901741955502c9345e82a9b7fbbf3d3c7ab517.json
generated
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "DELETE FROM trashbin WHERE expires_at <= now()",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": []
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "3453c0b7dd3c4d2c9bc639f379901741955502c9345e82a9b7fbbf3d3c7ab517"
|
||||
}
|
||||
65
backend/.sqlx/query-446404eda9b9632c9a1384af6bf2f88594825dbaa647290a58bd63df61b531a7.json
generated
Normal file
65
backend/.sqlx/query-446404eda9b9632c9a1384af6bf2f88594825dbaa647290a58bd63df61b531a7.json
generated
Normal file
@@ -0,0 +1,65 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT id, workspace_id, item_kind, item_path, item_data, deleted_by, deleted_at, expires_at\n FROM trashbin\n WHERE workspace_id = $1 AND id = $2",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "workspace_id",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "item_kind",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "item_path",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "item_data",
|
||||
"type_info": "Jsonb"
|
||||
},
|
||||
{
|
||||
"ordinal": 5,
|
||||
"name": "deleted_by",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 6,
|
||||
"name": "deleted_at",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 7,
|
||||
"name": "expires_at",
|
||||
"type_info": "Timestamptz"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Int8"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "446404eda9b9632c9a1384af6bf2f88594825dbaa647290a58bd63df61b531a7"
|
||||
}
|
||||
61
backend/.sqlx/query-51c3274a8092d80503a6b97ef3896cc3ba1957042a48ac5f9629ada25b3e78ef.json
generated
Normal file
61
backend/.sqlx/query-51c3274a8092d80503a6b97ef3896cc3ba1957042a48ac5f9629ada25b3e78ef.json
generated
Normal file
@@ -0,0 +1,61 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT id, workspace_id, item_kind, item_path, deleted_by, deleted_at, expires_at\n FROM trashbin\n WHERE workspace_id = $1 AND item_kind = $2\n ORDER BY deleted_at DESC\n LIMIT $3 OFFSET $4",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "workspace_id",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "item_kind",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "item_path",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "deleted_by",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 5,
|
||||
"name": "deleted_at",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 6,
|
||||
"name": "expires_at",
|
||||
"type_info": "Timestamptz"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text",
|
||||
"Int8",
|
||||
"Int8"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "51c3274a8092d80503a6b97ef3896cc3ba1957042a48ac5f9629ada25b3e78ef"
|
||||
}
|
||||
23
backend/.sqlx/query-52d765c87cb8da0ca71fb53156820e383a998a54c95355bb85fe7e762a0d9765.json
generated
Normal file
23
backend/.sqlx/query-52d765c87cb8da0ca71fb53156820e383a998a54c95355bb85fe7e762a0d9765.json
generated
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT DISTINCT imported_path as \"imported_path!\"\n FROM dependency_map\n WHERE workspace_id = $1\n AND importer_path = $2\n AND imported_path NOT LIKE 'dependencies/%'\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "imported_path!",
|
||||
"type_info": "Varchar"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "52d765c87cb8da0ca71fb53156820e383a998a54c95355bb85fe7e762a0d9765"
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT client, refresh_token, grant_type, cc_client_id, cc_client_secret, cc_token_url FROM account WHERE workspace_id = $1 AND id = $2",
|
||||
"query": "SELECT client, refresh_token, grant_type, cc_client_id, cc_client_secret, cc_token_url, scopes FROM account WHERE workspace_id = $1 AND id = $2",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -32,6 +32,11 @@
|
||||
"ordinal": 5,
|
||||
"name": "cc_token_url",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 6,
|
||||
"name": "scopes",
|
||||
"type_info": "TextArray"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
@@ -46,8 +51,9 @@
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "cc269052ffc1e613d7edc31f0f7bb84f6e6301ad1afb028813105a121a69fa7e"
|
||||
"hash": "63c48fde8c0c0fff9abffc3be27e9948556b636b70b818cc31c2d50921a27366"
|
||||
}
|
||||
23
backend/.sqlx/query-6d070f476538aa6fcd6227fe5312561a7d098f2af5287e1e6c339e15080378be.json
generated
Normal file
23
backend/.sqlx/query-6d070f476538aa6fcd6227fe5312561a7d098f2af5287e1e6c339e15080378be.json
generated
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT pg_advisory_xact_lock(hashtext($1 || '/' || $2))",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "pg_advisory_xact_lock",
|
||||
"type_info": "Void"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "6d070f476538aa6fcd6227fe5312561a7d098f2af5287e1e6c339e15080378be"
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "INSERT INTO account (workspace_id, client, expires_at, refresh_token, grant_type, cc_client_id, cc_client_secret, cc_token_url, mcp_server_url) VALUES ($1, $2, now() + ($3 || ' seconds')::interval, $4, $5, $6, $7, $8, $9) RETURNING id",
|
||||
"query": "INSERT INTO account (workspace_id, client, expires_at, refresh_token, grant_type, cc_client_id, cc_client_secret, cc_token_url, mcp_server_url, scopes) VALUES ($1, $2, now() + ($3 || ' seconds')::interval, $4, $5, $6, $7, $8, $9, $10) RETURNING id",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -19,12 +19,13 @@
|
||||
"Varchar",
|
||||
"Varchar",
|
||||
"Varchar",
|
||||
"Text"
|
||||
"Text",
|
||||
"TextArray"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "b1bd088c2e1aca3104bede7d0953369b6b17ad3ad62692ae6f2303be890e6391"
|
||||
"hash": "870e1c3f0dc1aaa07ac74a2e37721ce352ad4fb67d36c19dce09d841e36f85dd"
|
||||
}
|
||||
32
backend/.sqlx/query-87ee10d8ba5ba281781f23e5390190fb90df980a19c900452f9b1a19c3620e30.json
generated
Normal file
32
backend/.sqlx/query-87ee10d8ba5ba281781f23e5390190fb90df980a19c900452f9b1a19c3620e30.json
generated
Normal file
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT\n route_path,\n workspace_id,\n http_method::TEXT AS \"http_method!\"\n FROM\n http_trigger\n WHERE\n workspaced_route IS FALSE\n AND route_path_key IN (\n SELECT\n route_path_key\n FROM\n http_trigger\n WHERE\n workspaced_route IS FALSE\n GROUP BY\n route_path_key, http_method\n HAVING COUNT(*) > 1\n )\n ORDER BY route_path_key\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "route_path",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "workspace_id",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "http_method!",
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": []
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "87ee10d8ba5ba281781f23e5390190fb90df980a19c900452f9b1a19c3620e30"
|
||||
}
|
||||
26
backend/.sqlx/query-8b25c4252da77cd2fe1b3916b518251dbb3c6d4c095efa015823f0324ab27d7f.json
generated
Normal file
26
backend/.sqlx/query-8b25c4252da77cd2fe1b3916b518251dbb3c6d4c095efa015823f0324ab27d7f.json
generated
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "INSERT INTO trashbin (workspace_id, item_kind, item_path, item_data, deleted_by)\n VALUES ($1, $2, $3, $4, $5) RETURNING id",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": "Int8"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Varchar",
|
||||
"Varchar",
|
||||
"Text",
|
||||
"Jsonb",
|
||||
"Varchar"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "8b25c4252da77cd2fe1b3916b518251dbb3c6d4c095efa015823f0324ab27d7f"
|
||||
}
|
||||
15
backend/.sqlx/query-8bd266705fc8272f3d8941922ad7d18161eb6f5ec1ba9f1b55feffe8b6518c67.json
generated
Normal file
15
backend/.sqlx/query-8bd266705fc8272f3d8941922ad7d18161eb6f5ec1ba9f1b55feffe8b6518c67.json
generated
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "UPDATE password SET disabled = $1 WHERE email = $2",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Bool",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "8bd266705fc8272f3d8941922ad7d18161eb6f5ec1ba9f1b55feffe8b6518c67"
|
||||
}
|
||||
60
backend/.sqlx/query-92fb6afe3b7041b2954340094c08e702fc1577d3fa4ff1ff2f1e089971ff5e32.json
generated
Normal file
60
backend/.sqlx/query-92fb6afe3b7041b2954340094c08e702fc1577d3fa4ff1ff2f1e089971ff5e32.json
generated
Normal file
@@ -0,0 +1,60 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT id, workspace_id, item_kind, item_path, deleted_by, deleted_at, expires_at\n FROM trashbin\n WHERE workspace_id = $1\n ORDER BY deleted_at DESC\n LIMIT $2 OFFSET $3",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "workspace_id",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "item_kind",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "item_path",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "deleted_by",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 5,
|
||||
"name": "deleted_at",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 6,
|
||||
"name": "expires_at",
|
||||
"type_info": "Timestamptz"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Int8",
|
||||
"Int8"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "92fb6afe3b7041b2954340094c08e702fc1577d3fa4ff1ff2f1e089971ff5e32"
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT args as \"args: sqlx::types::Json<Box<RawValue>>\"\n FROM v2_job\n WHERE id = $1",
|
||||
"query": "SELECT args as \"args: sqlx::types::Json<Box<RawValue>>\"\n FROM v2_job\n WHERE id = $1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -18,5 +18,5 @@
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "d1dcc7fc8a1e1bc4dad263ec5163a94fca9dd95cc3b26b33611eab9d2a261141"
|
||||
"hash": "97cf826b271cf064182382c924188fee392ed9cff6ae446abc86170984304a25"
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "WITH active_users AS (SELECT distinct username as email FROM (SELECT username, timestamp, operation FROM audit_partitioned UNION ALL SELECT username, timestamp, operation FROM audit) AS a WHERE timestamp > NOW() - INTERVAL '1 month' AND (operation = 'users.login' OR operation = 'oauth.login' OR operation = 'users.token.refresh')),\n authors as (SELECT distinct email FROM usr WHERE usr.operator IS false)\n SELECT email, email NOT IN (SELECT email FROM authors) as operator_only, login_type::text, verified, super_admin, devops, name, company, username, first_time_user, role_source\n FROM password\n WHERE email IN (SELECT email FROM active_users)\n ORDER BY super_admin DESC, devops DESC\n LIMIT $1 OFFSET $2",
|
||||
"query": "WITH active_users AS (SELECT distinct username as email FROM (SELECT username, timestamp, operation FROM audit_partitioned UNION ALL SELECT username, timestamp, operation FROM audit) AS a WHERE timestamp > NOW() - INTERVAL '1 month' AND (operation = 'users.login' OR operation = 'oauth.login' OR operation = 'users.token.refresh')),\n authors as (SELECT distinct email FROM usr WHERE usr.operator IS false)\n SELECT email, email NOT IN (SELECT email FROM authors) as operator_only, login_type::text, verified, super_admin, devops, name, company, username, first_time_user, role_source, disabled\n FROM password\n WHERE email IN (SELECT email FROM active_users)\n ORDER BY super_admin DESC, devops DESC\n LIMIT $1 OFFSET $2",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -57,6 +57,11 @@
|
||||
"ordinal": 10,
|
||||
"name": "role_source",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 11,
|
||||
"name": "disabled",
|
||||
"type_info": "Bool"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
@@ -76,8 +81,9 @@
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "60118de85463098220b1c74f667b6fedb0f3f0040844c3774145e8f1f4c023ce"
|
||||
"hash": "a5fd115e7be5129d623543bbfa7b5b31f0efc6d8ef73f691009c73f833dcee10"
|
||||
}
|
||||
14
backend/.sqlx/query-bae31609123da68d16bea8e0f1c4624403b6f97e13f13f056501fe2f4efb0f06.json
generated
Normal file
14
backend/.sqlx/query-bae31609123da68d16bea8e0f1c4624403b6f97e13f13f056501fe2f4efb0f06.json
generated
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "DELETE FROM trashbin WHERE workspace_id = $1",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "bae31609123da68d16bea8e0f1c4624403b6f97e13f13f056501fe2f4efb0f06"
|
||||
}
|
||||
23
backend/.sqlx/query-c1fd495abb4353b46361ec94fd4ae8d224457171b1b73fe145d28e67f1fe03af.json
generated
Normal file
23
backend/.sqlx/query-c1fd495abb4353b46361ec94fd4ae8d224457171b1b73fe145d28e67f1fe03af.json
generated
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT EXISTS(SELECT 1 FROM script WHERE path = $1 AND workspace_id = $2)",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "exists",
|
||||
"type_info": "Bool"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "c1fd495abb4353b46361ec94fd4ae8d224457171b1b73fe145d28e67f1fe03af"
|
||||
}
|
||||
22
backend/.sqlx/query-ccc49a2a6e11f874825365de758bdc0e1934d67d3f2b14047d434b77d370af21.json
generated
Normal file
22
backend/.sqlx/query-ccc49a2a6e11f874825365de758bdc0e1934d67d3f2b14047d434b77d370af21.json
generated
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT super_admin FROM password WHERE email = $1 AND disabled = false",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "super_admin",
|
||||
"type_info": "Bool"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "ccc49a2a6e11f874825365de758bdc0e1934d67d3f2b14047d434b77d370af21"
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "INSERT INTO password (email, login_type, verified, username, name) VALUES ($1, 'saml', true, $2, $3) ON CONFLICT DO NOTHING",
|
||||
"query": "INSERT INTO password (email, login_type, verified, username, name) VALUES ($1, 'saml', true, $2, $3) ON CONFLICT (email) DO UPDATE SET disabled = false",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
@@ -12,5 +12,5 @@
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "638d3c2ba1198dce5b5b0e47df59a92ff8011e19fbefcc3960d6f0fe167e55b6"
|
||||
"hash": "daa1a6bf3d4a1001da88301932a7ac9019767074158e0c027988e5b0d51a3656"
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT email, login_type::TEXT, super_admin, devops, verified, name, company, username, NULL::bool as operator_only, first_time_user, role_source FROM password WHERE email = $1",
|
||||
"query": "SELECT email, login_type::TEXT, super_admin, devops, verified, name, company, username, NULL::bool as operator_only, first_time_user, role_source, disabled FROM password WHERE email = $1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -57,6 +57,11 @@
|
||||
"ordinal": 10,
|
||||
"name": "role_source",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 11,
|
||||
"name": "disabled",
|
||||
"type_info": "Bool"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
@@ -75,8 +80,9 @@
|
||||
true,
|
||||
null,
|
||||
false,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "65c59e224e460351c2f88261f8b1b1e7ce2bb160270b59c0f359b7952453b2b9"
|
||||
"hash": "f0c9c54740cc1c0c2a6fa4e79d4d504b7b5cb7a39538ab9abeb44f781c711493"
|
||||
}
|
||||
104
backend/.sqlx/query-f2fa27ed5020aa9c085176b25466be1bceb79c82e7b5542f17b23b6d70cc02d6.json
generated
Normal file
104
backend/.sqlx/query-f2fa27ed5020aa9c085176b25466be1bceb79c82e7b5542f17b23b6d70cc02d6.json
generated
Normal file
@@ -0,0 +1,104 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT DISTINCT ON (path) path, language AS \"language: ScriptLang\", content FROM script\n WHERE workspace_id = $1\n AND archived = false\n AND dedicated_worker = true\n AND language = ANY($2::SCRIPT_LANG[])\n ORDER BY path, created_at DESC",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "path",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "language: ScriptLang",
|
||||
"type_info": {
|
||||
"Custom": {
|
||||
"name": "script_lang",
|
||||
"kind": {
|
||||
"Enum": [
|
||||
"python3",
|
||||
"deno",
|
||||
"go",
|
||||
"bash",
|
||||
"postgresql",
|
||||
"nativets",
|
||||
"bun",
|
||||
"mysql",
|
||||
"bigquery",
|
||||
"snowflake",
|
||||
"graphql",
|
||||
"powershell",
|
||||
"mssql",
|
||||
"php",
|
||||
"bunnative",
|
||||
"rust",
|
||||
"ansible",
|
||||
"csharp",
|
||||
"oracledb",
|
||||
"nu",
|
||||
"java",
|
||||
"duckdb",
|
||||
"ruby"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "content",
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
{
|
||||
"Custom": {
|
||||
"name": "script_lang[]",
|
||||
"kind": {
|
||||
"Array": {
|
||||
"Custom": {
|
||||
"name": "script_lang",
|
||||
"kind": {
|
||||
"Enum": [
|
||||
"python3",
|
||||
"deno",
|
||||
"go",
|
||||
"bash",
|
||||
"postgresql",
|
||||
"nativets",
|
||||
"bun",
|
||||
"mysql",
|
||||
"bigquery",
|
||||
"snowflake",
|
||||
"graphql",
|
||||
"powershell",
|
||||
"mssql",
|
||||
"php",
|
||||
"bunnative",
|
||||
"rust",
|
||||
"ansible",
|
||||
"csharp",
|
||||
"oracledb",
|
||||
"nu",
|
||||
"java",
|
||||
"duckdb",
|
||||
"ruby"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "f2fa27ed5020aa9c085176b25466be1bceb79c82e7b5542f17b23b6d70cc02d6"
|
||||
}
|
||||
22
backend/.sqlx/query-fc6c6310ae8ac5eb351d7e2af1678447d0aa3d143e94e49924ff7ac8b7abf924.json
generated
Normal file
22
backend/.sqlx/query-fc6c6310ae8ac5eb351d7e2af1678447d0aa3d143e94e49924ff7ac8b7abf924.json
generated
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT disabled FROM password WHERE email = $1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "disabled",
|
||||
"type_info": "Bool"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "fc6c6310ae8ac5eb351d7e2af1678447d0aa3d143e94e49924ff7ac8b7abf924"
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "INSERT INTO token\n (token_hash, token_prefix, token, label, super_admin, email)\n VALUES ($1, $2, $3, $4, $5, $6)",
|
||||
"query": "INSERT INTO token\n (token_hash, token_prefix, token, label, super_admin, email)\n VALUES ($1, $2, $3, $4, $5, $6)",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
@@ -15,5 +15,5 @@
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "d05f20431cd08f737bfbf904efedfdf104e3d77b0725c5355305d19f67359e90"
|
||||
"hash": "fd4c5391107af34a3bf9b83b0c3f7d5ee9490240a627b20a1037444845e39c5f"
|
||||
}
|
||||
609
backend/Cargo.lock
generated
609
backend/Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "windmill"
|
||||
version = "1.664.0"
|
||||
version = "1.666.0"
|
||||
authors.workspace = true
|
||||
edition.workspace = true
|
||||
|
||||
@@ -82,7 +82,7 @@ members = [
|
||||
exclude = ["./windmill-duckdb-ffi-internal"]
|
||||
|
||||
[workspace.package]
|
||||
version = "1.664.0"
|
||||
version = "1.666.0"
|
||||
authors = ["Ruben Fiszel <ruben@windmill.dev>"]
|
||||
edition = "2021"
|
||||
|
||||
@@ -362,7 +362,7 @@ reqwest-middleware = { version = "^0", features = ["json"] }
|
||||
|
||||
bitflags = "2.9.4"
|
||||
memchr = "2.7.4"
|
||||
axum = { version = "^0.7", features = ["multipart", "macros"] }
|
||||
axum = { version = "^0.8", features = ["multipart", "macros"] }
|
||||
headers = "^0"
|
||||
hyper = { version = "^1", features = ["full"] }
|
||||
hyper-tls = "^0.6"
|
||||
@@ -371,7 +371,7 @@ tokio = { version = "=1.46.1", features = ["full", "tracing", "time"] }
|
||||
tokio-stream = { version = "0.1.17" }
|
||||
tower = "^0"
|
||||
tower-http = { version = "^0.6", features = ["trace", "cors", "catch-panic"] }
|
||||
tower-cookies = "^0.10"
|
||||
tower-cookies = "^0.11"
|
||||
#stuck because of swc for now
|
||||
serde = "=1.0.220"
|
||||
serde_json = { version = "^1", features = ["preserve_order", "raw_value"] }
|
||||
@@ -386,7 +386,7 @@ tracing = "^0"
|
||||
tracing-subscriber = { version = "^0", features = ["env-filter", "json"] }
|
||||
tracing-appender = "^0"
|
||||
prometheus = { version = "^0", default-features = false }
|
||||
cookie = { version = "0.17.0" }
|
||||
cookie = { version = "0.18.0" }
|
||||
phf = { version = "0.11", features = ["macros"] }
|
||||
rust-embed = { version = "^6", features = ["interpolate-folder-path"] }
|
||||
mime_guess = "^2"
|
||||
@@ -566,18 +566,18 @@ flate2 = "^1"
|
||||
http = "^1"
|
||||
async-stream = "^0"
|
||||
|
||||
opentelemetry = "0.27.0"
|
||||
tracing-opentelemetry = "0.28.0"
|
||||
opentelemetry_sdk = { version = "0.27.1", features = ["rt-tokio"] }
|
||||
opentelemetry-otlp = { version = "0.27.0", features = ["grpc-tonic", "tls"] }
|
||||
opentelemetry-appender-tracing = "0.27.0"
|
||||
opentelemetry-semantic-conventions = { version = "0.27.0", features = ["semconv_experimental"] }
|
||||
opentelemetry-proto = { version = "0.29.0", features = ["with-serde", "gen-tonic"] }
|
||||
opentelemetry = "0.30.0"
|
||||
tracing-opentelemetry = "0.31.0"
|
||||
opentelemetry_sdk = { version = "0.30.0", features = ["rt-tokio"] }
|
||||
opentelemetry-otlp = { version = "0.30.0", features = ["grpc-tonic", "tls"] }
|
||||
opentelemetry-appender-tracing = "0.30.0"
|
||||
opentelemetry-semantic-conventions = { version = "0.30.0", features = ["semconv_experimental"] }
|
||||
opentelemetry-proto = { version = "0.30.0", features = ["with-serde", "gen-tonic"] }
|
||||
prost = "0.13"
|
||||
|
||||
bollard = "0.18.1"
|
||||
|
||||
tonic = { version = "=0.12.3", features = ["tls-native-roots"] }
|
||||
tonic = { version = "^0.13", features = ["tls-native-roots"] }
|
||||
byteorder = "1.5.0"
|
||||
|
||||
tikv-jemallocator = { version = "0.5" }
|
||||
|
||||
@@ -1 +1 @@
|
||||
b5c8af4df9ba2c39fdd494d7a40f9a92fbff8abc
|
||||
61ae055ea31481f1899953e9d5f65566b8c707b1
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE password DROP COLUMN IF EXISTS disabled;
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE password ADD COLUMN disabled BOOLEAN NOT NULL DEFAULT false;
|
||||
@@ -0,0 +1,3 @@
|
||||
-- Revoke grants for app_bundles table
|
||||
REVOKE ALL ON app_bundles FROM windmill_user;
|
||||
REVOKE ALL ON app_bundles FROM windmill_admin;
|
||||
@@ -0,0 +1,3 @@
|
||||
-- Add grants for app_bundles table
|
||||
GRANT ALL ON app_bundles TO windmill_user;
|
||||
GRANT ALL ON app_bundles TO windmill_admin;
|
||||
1
backend/migrations/20260326000000_trashbin.down.sql
Normal file
1
backend/migrations/20260326000000_trashbin.down.sql
Normal file
@@ -0,0 +1 @@
|
||||
DROP TABLE IF EXISTS trashbin;
|
||||
16
backend/migrations/20260326000000_trashbin.up.sql
Normal file
16
backend/migrations/20260326000000_trashbin.up.sql
Normal file
@@ -0,0 +1,16 @@
|
||||
CREATE TABLE trashbin (
|
||||
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
workspace_id VARCHAR(50) NOT NULL REFERENCES workspace(id) ON DELETE CASCADE,
|
||||
item_kind VARCHAR(50) NOT NULL,
|
||||
item_path TEXT NOT NULL,
|
||||
item_data JSONB NOT NULL,
|
||||
deleted_by VARCHAR(255) NOT NULL,
|
||||
deleted_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
expires_at TIMESTAMPTZ NOT NULL DEFAULT now() + INTERVAL '3 days'
|
||||
);
|
||||
|
||||
CREATE INDEX idx_trashbin_expires_at ON trashbin(expires_at);
|
||||
CREATE INDEX idx_trashbin_workspace_kind ON trashbin(workspace_id, item_kind);
|
||||
|
||||
GRANT ALL ON trashbin TO windmill_user;
|
||||
GRANT ALL ON trashbin TO windmill_admin;
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE account DROP COLUMN IF EXISTS scopes;
|
||||
1
backend/migrations/20260326100000_account_scopes.up.sql
Normal file
1
backend/migrations/20260326100000_account_scopes.up.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE account ADD COLUMN scopes TEXT[];
|
||||
@@ -44,17 +44,18 @@ use windmill_common::{
|
||||
CRITICAL_ERROR_CHANNELS_SETTING, CUSTOM_TAGS_SETTING, DEFAULT_TAGS_PER_WORKSPACE_SETTING,
|
||||
DEFAULT_TAGS_WORKSPACES_SETTING, EMAIL_DOMAIN_SETTING, ENV_SETTINGS,
|
||||
EXPOSE_DEBUG_METRICS_SETTING, EXPOSE_METRICS_SETTING, EXTRA_PIP_INDEX_URL_SETTING,
|
||||
HUB_API_SECRET_SETTING, HUB_BASE_URL_SETTING, INDEXER_SETTING,
|
||||
INSTANCE_EVENTS_WEBHOOK_SETTING, INSTANCE_PYTHON_VERSION_SETTING,
|
||||
HTTP_ROUTE_WORKSPACED_ROUTE_SETTING, HUB_API_SECRET_SETTING, HUB_BASE_URL_SETTING,
|
||||
INDEXER_SETTING, INSTANCE_EVENTS_WEBHOOK_SETTING, INSTANCE_PYTHON_VERSION_SETTING,
|
||||
JOB_DEFAULT_TIMEOUT_SECS_SETTING, JOB_ISOLATION_SETTING, JWT_SECRET_SETTING,
|
||||
KEEP_JOB_DIR_SETTING, LICENSE_KEY_SETTING, MAVEN_REPOS_SETTING, MAVEN_SETTINGS_XML_SETTING,
|
||||
MONITOR_LOGS_ON_OBJECT_STORE_SETTING, NO_DEFAULT_MAVEN_SETTING,
|
||||
NPM_CONFIG_REGISTRY_SETTING, NUGET_CONFIG_SETTING, OAUTH_SETTING, OTEL_SETTING,
|
||||
OTEL_TRACING_PROXY_SETTING, PIP_INDEX_URL_SETTING, POWERSHELL_REPO_PAT_SETTING,
|
||||
POWERSHELL_REPO_URL_SETTING, REQUEST_SIZE_LIMIT_SETTING,
|
||||
REQUIRE_PREEXISTING_USER_FOR_OAUTH_SETTING, RETENTION_PERIOD_SECS_SETTING,
|
||||
RUBY_REPOS_SETTING, SAML_METADATA_SETTING, SCIM_TOKEN_SETTING, SMTP_SETTING, TEAMS_SETTING,
|
||||
TIMEOUT_WAIT_RESULT_SETTING, UV_INDEX_STRATEGY_SETTING, WORKSPACE_REGISTRIES_SETTING,
|
||||
REQUIRE_PREEXISTING_USER_FOR_OAUTH_SETTING, RESTART_COORDINATION_SETTING,
|
||||
RETENTION_PERIOD_SECS_SETTING, RUBY_REPOS_SETTING, SAML_METADATA_SETTING,
|
||||
SCIM_TOKEN_SETTING, SMTP_SETTING, TEAMS_SETTING, TIMEOUT_WAIT_RESULT_SETTING,
|
||||
UV_INDEX_STRATEGY_SETTING, WORKSPACE_REGISTRIES_SETTING,
|
||||
},
|
||||
scripts::ScriptLang,
|
||||
stats_oss::schedule_stats,
|
||||
@@ -67,7 +68,7 @@ use windmill_common::{
|
||||
is_native_mode_from_env, reload_custom_tags_setting, Connection, HUB_CACHE_DIR,
|
||||
HUB_RT_CACHE_DIR, NATIVE_MODE_RESOLVED, TMP_LOGS_DIR, WINDMILL_DIR, WORKER_GROUP,
|
||||
},
|
||||
KillpillSender, DEFAULT_HUB_BASE_URL, METRICS_ENABLED,
|
||||
KillpillSender, DEFAULT_HUB_BASE_URL, INSTANCE_NAME, METRICS_ENABLED,
|
||||
};
|
||||
|
||||
#[cfg(feature = "enterprise")]
|
||||
@@ -104,10 +105,10 @@ use crate::monitor::{
|
||||
reload_base_url_setting, reload_bunfig_install_scopes_setting,
|
||||
reload_critical_alert_mute_ui_setting, reload_critical_alerts_on_token_expiry_setting,
|
||||
reload_critical_error_channels_setting, reload_extra_pip_index_url_setting,
|
||||
reload_hub_api_secret_setting, reload_hub_base_url_setting,
|
||||
reload_instance_events_webhook_setting, reload_job_default_timeout_setting,
|
||||
reload_job_isolation_setting, reload_jwt_secret_setting, reload_license_key,
|
||||
reload_npm_config_registry_setting, reload_otel_tracing_proxy_setting,
|
||||
reload_http_route_workspaced_route_setting, reload_hub_api_secret_setting,
|
||||
reload_hub_base_url_setting, reload_instance_events_webhook_setting,
|
||||
reload_job_default_timeout_setting, reload_job_isolation_setting, reload_jwt_secret_setting,
|
||||
reload_license_key, reload_npm_config_registry_setting, reload_otel_tracing_proxy_setting,
|
||||
reload_pip_index_url_setting, reload_retention_period_setting, reload_scim_token_setting,
|
||||
reload_smtp_config, reload_uv_index_strategy_setting, reload_worker_config, MonitorIteration,
|
||||
};
|
||||
@@ -1099,6 +1100,9 @@ Windmill Community Edition {GIT_VERSION}
|
||||
}
|
||||
|
||||
let addr = SocketAddr::from((server_bind_address, port));
|
||||
let listener = tokio::net::TcpListener::bind(addr)
|
||||
.await
|
||||
.context("binding main windmill server")?;
|
||||
|
||||
let (base_internal_tx, base_internal_rx) = tokio::sync::oneshot::channel::<String>();
|
||||
|
||||
@@ -1232,7 +1236,7 @@ Windmill Community Edition {GIT_VERSION}
|
||||
db.clone(),
|
||||
index_reader,
|
||||
log_index_reader,
|
||||
addr,
|
||||
listener,
|
||||
server_killpill_rx,
|
||||
base_internal_tx,
|
||||
server_mode,
|
||||
@@ -1788,7 +1792,8 @@ async fn process_notify_event(
|
||||
reload_otel_tracing_proxy_setting(conn).await;
|
||||
if worker_mode {
|
||||
tracing::info!("OTEL tracing proxy setting changed, restarting worker");
|
||||
send_delayed_killpill(tx, 4, "OTEL tracing proxy setting change").await;
|
||||
spawn_graceful_killpill(tx, db, 10, "OTEL tracing proxy setting change")
|
||||
.await;
|
||||
}
|
||||
}
|
||||
REQUIRE_PREEXISTING_USER_FOR_OAUTH_SETTING => {
|
||||
@@ -1796,12 +1801,12 @@ async fn process_notify_event(
|
||||
}
|
||||
EXPOSE_METRICS_SETTING => {
|
||||
tracing::info!("Metrics setting changed, restarting");
|
||||
send_delayed_killpill(tx, 40, "metrics setting change").await;
|
||||
spawn_graceful_killpill(tx, db, 10, "metrics setting change").await;
|
||||
}
|
||||
EMAIL_DOMAIN_SETTING => {
|
||||
tracing::info!("Email domain setting changed");
|
||||
if server_mode {
|
||||
send_delayed_killpill(tx, 4, "email domain setting change").await;
|
||||
spawn_graceful_killpill(tx, db, 10, "email domain setting change").await;
|
||||
}
|
||||
}
|
||||
EXPOSE_DEBUG_METRICS_SETTING => {
|
||||
@@ -1814,25 +1819,42 @@ async fn process_notify_event(
|
||||
tracing::error!(error = %e, "Could not reload app workspaced route setting");
|
||||
}
|
||||
}
|
||||
HTTP_ROUTE_WORKSPACED_ROUTE_SETTING => {
|
||||
if let Err(e) = reload_http_route_workspaced_route_setting(db).await {
|
||||
tracing::error!(error = %e, "Could not reload http route workspaced route setting");
|
||||
}
|
||||
#[cfg(feature = "http_trigger")]
|
||||
match windmill_api::triggers::http::refresh_routers(db).await {
|
||||
Ok((true, _)) => {
|
||||
tracing::info!(
|
||||
"Refreshed HTTP routers (http workspaced route setting change)"
|
||||
);
|
||||
}
|
||||
Err(err) => {
|
||||
tracing::error!("Error refreshing HTTP routers (http workspaced route setting change): {err:#}");
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
AI_CONFIG_SETTING => {
|
||||
tracing::info!("AI config setting changed, bumping instance AI cache revision");
|
||||
bump_instance_ai_config_revision();
|
||||
}
|
||||
OTEL_SETTING => {
|
||||
tracing::info!("OTEL setting changed, restarting");
|
||||
send_delayed_killpill(tx, 4, "OTEL setting change").await;
|
||||
spawn_graceful_killpill(tx, db, 10, "OTEL setting change").await;
|
||||
}
|
||||
REQUEST_SIZE_LIMIT_SETTING => {
|
||||
if server_mode {
|
||||
tracing::info!("Request limit size change detected, killing server expecting to be restarted");
|
||||
send_delayed_killpill(tx, 4, "request size limit change").await;
|
||||
spawn_graceful_killpill(tx, db, 10, "request size limit change").await;
|
||||
}
|
||||
}
|
||||
SAML_METADATA_SETTING => {
|
||||
tracing::info!(
|
||||
"SAML metadata change detected, killing server expecting to be restarted"
|
||||
);
|
||||
send_delayed_killpill(tx, 0, "SAML metadata change").await;
|
||||
spawn_graceful_killpill(tx, db, 10, "SAML metadata change").await;
|
||||
}
|
||||
HUB_BASE_URL_SETTING => {
|
||||
if let Err(e) = reload_hub_base_url_setting(conn, server_mode).await {
|
||||
@@ -1881,6 +1903,9 @@ async fn process_notify_event(
|
||||
.unwrap_or(false);
|
||||
tracing::info!("Workspace telemetry setting changed: enabled={}", enabled);
|
||||
}
|
||||
RESTART_COORDINATION_SETTING => {
|
||||
// Internal coordination key for staggered restarts, no action needed
|
||||
}
|
||||
_ => {
|
||||
tracing::info!("Unrecognized Global Setting Change Payload: {:?}", payload);
|
||||
}
|
||||
@@ -2022,14 +2047,145 @@ pub async fn run_workers(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn send_delayed_killpill(tx: &KillpillSender, mut max_delay_secs: u64, context: &str) {
|
||||
if max_delay_secs == 0 {
|
||||
max_delay_secs = 1;
|
||||
}
|
||||
// Random delay to avoid all servers/workers shutting down simultaneously
|
||||
let rd_delay = rand::rng().random_range(0..max_delay_secs);
|
||||
tracing::info!("Scheduling {context} shutdown in {rd_delay}s");
|
||||
tokio::time::sleep(Duration::from_secs(rd_delay)).await;
|
||||
/// Schedule a graceful restart with DB-coordinated staggering.
|
||||
///
|
||||
/// Uses a PostgreSQL advisory lock to serialize restart scheduling across server instances.
|
||||
/// Each instance records its planned restart time in the `_restart_coordination` global setting;
|
||||
/// subsequent instances read existing schedules and shift their restart to maintain at least
|
||||
/// `safety_margin_secs` between consecutive restarts (must exceed the server startup time).
|
||||
///
|
||||
/// Every server waits at least `DRAIN_DELAY_SECS` to let in-flight requests complete.
|
||||
/// Each subsequent server waits an additional `safety_margin_secs` after the previous one,
|
||||
/// guaranteeing zero downtime overlap.
|
||||
///
|
||||
/// The DB coordination is done synchronously (fast, ~ms) to reserve our restart slot,
|
||||
/// then the sleep+kill is spawned in the background so the notification handler is not blocked.
|
||||
///
|
||||
/// Falls back to drain-only delay if DB coordination fails.
|
||||
async fn spawn_graceful_killpill(
|
||||
tx: &KillpillSender,
|
||||
db: &Pool<Postgres>,
|
||||
safety_margin_secs: u64,
|
||||
context: &str,
|
||||
) {
|
||||
// Minimum delay before any restart to let in-flight requests drain
|
||||
const DRAIN_DELAY_SECS: u64 = 3;
|
||||
|
||||
tx.send();
|
||||
let delay = match coordinate_restart_delay(db, safety_margin_secs, DRAIN_DELAY_SECS).await {
|
||||
Ok(d) => d,
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
"Failed to coordinate restart for {context}: {e:#}, \
|
||||
falling back to drain delay of {DRAIN_DELAY_SECS}s"
|
||||
);
|
||||
DRAIN_DELAY_SECS
|
||||
}
|
||||
};
|
||||
|
||||
tracing::info!("Scheduling {context} graceful shutdown in {delay}s");
|
||||
let tx = tx.clone();
|
||||
tokio::spawn(async move {
|
||||
tokio::time::sleep(Duration::from_secs(delay)).await;
|
||||
tx.send();
|
||||
});
|
||||
}
|
||||
|
||||
/// Coordinate a restart delay with other instances via the DB.
|
||||
///
|
||||
/// Returns the delay (in seconds from now) at which this instance should restart.
|
||||
/// The first server gets `drain_delay_secs` (to let in-flight requests complete).
|
||||
/// Each subsequent server is spaced `safety_margin_secs` after the latest scheduled restart.
|
||||
async fn coordinate_restart_delay(
|
||||
db: &Pool<Postgres>,
|
||||
safety_margin_secs: u64,
|
||||
drain_delay_secs: u64,
|
||||
) -> anyhow::Result<u64> {
|
||||
const RESTART_LOCK_ID: i64 = 737_483_920;
|
||||
// Stale threshold: ignore coordination entries older than this
|
||||
const STALE_THRESHOLD_SECS: i64 = 120;
|
||||
|
||||
let now = chrono::Utc::now();
|
||||
|
||||
let mut tx = db.begin().await.context("begin restart coordination tx")?;
|
||||
|
||||
// Serialize access across all instances
|
||||
sqlx::query("SELECT pg_advisory_xact_lock($1)")
|
||||
.bind(RESTART_LOCK_ID)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.context("acquire restart coordination lock")?;
|
||||
|
||||
// Read existing coordination record
|
||||
let existing: Option<serde_json::Value> =
|
||||
sqlx::query_scalar("SELECT value FROM global_settings WHERE name = $1")
|
||||
.bind(RESTART_COORDINATION_SETTING)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await
|
||||
.context("read restart coordination")?;
|
||||
|
||||
// Parse existing scheduled restarts, filtering out stale entries
|
||||
// Each entry is (instance_name, restart_at)
|
||||
let mut scheduled: Vec<(String, chrono::DateTime<chrono::Utc>)> = Vec::new();
|
||||
if let Some(val) = &existing {
|
||||
if let Some(arr) = val.get("restarts").and_then(|v| v.as_array()) {
|
||||
for entry in arr {
|
||||
let instance = entry
|
||||
.get("instance")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("unknown")
|
||||
.to_string();
|
||||
if let Some(ts_str) = entry.get("restart_at").and_then(|v| v.as_str()) {
|
||||
if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(ts_str) {
|
||||
let dt = dt.with_timezone(&chrono::Utc);
|
||||
let stale_cutoff = now - chrono::Duration::seconds(STALE_THRESHOLD_SECS);
|
||||
if dt > stale_cutoff {
|
||||
scheduled.push((instance, dt));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Find the latest scheduled restart
|
||||
let latest = scheduled.iter().map(|(_, dt)| *dt).max();
|
||||
let earliest_allowed = now + chrono::Duration::seconds(drain_delay_secs as i64);
|
||||
|
||||
// Our restart time: drain_delay from now, or safety_margin after the latest existing restart
|
||||
let our_restart = match latest {
|
||||
Some(last) => {
|
||||
let after_last = last + chrono::Duration::seconds(safety_margin_secs as i64);
|
||||
// Use whichever is later: drain delay or staggered position
|
||||
earliest_allowed.max(after_last)
|
||||
}
|
||||
None => earliest_allowed,
|
||||
};
|
||||
|
||||
// Record our restart time (deduplicate: remove any prior entry for this instance)
|
||||
scheduled.retain(|(inst, _)| inst != &*INSTANCE_NAME);
|
||||
scheduled.push((INSTANCE_NAME.clone(), our_restart));
|
||||
let new_value = serde_json::json!({
|
||||
"restarts": scheduled.iter().map(|(inst, dt)| {
|
||||
serde_json::json!({
|
||||
"instance": inst,
|
||||
"restart_at": dt.to_rfc3339()
|
||||
})
|
||||
}).collect::<Vec<_>>()
|
||||
});
|
||||
|
||||
sqlx::query(
|
||||
"INSERT INTO global_settings (name, value, updated_at) \
|
||||
VALUES ($1, $2, now()) \
|
||||
ON CONFLICT (name) DO UPDATE SET value = $2, updated_at = now()",
|
||||
)
|
||||
.bind(RESTART_COORDINATION_SETTING)
|
||||
.bind(&new_value)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.context("write restart coordination")?;
|
||||
|
||||
tx.commit().await.context("commit restart coordination")?;
|
||||
|
||||
let delay = (our_restart - now).num_seconds().max(0) as u64;
|
||||
Ok(delay)
|
||||
}
|
||||
|
||||
@@ -88,7 +88,13 @@ use windmill_common::{
|
||||
MONITOR_LOGS_ON_OBJECT_STORE, OTEL_LOGS_ENABLED, OTEL_METRICS_ENABLED, OTEL_TRACING_ENABLED,
|
||||
SERVICE_LOG_RETENTION_SECS,
|
||||
};
|
||||
use windmill_common::{client::AuthedClient, global_settings::APP_WORKSPACED_ROUTE_SETTING};
|
||||
use windmill_common::{
|
||||
client::AuthedClient,
|
||||
global_settings::{
|
||||
APP_WORKSPACED_ROUTE_SETTING, HTTP_ROUTE_WORKSPACED_ROUTE,
|
||||
HTTP_ROUTE_WORKSPACED_ROUTE_SETTING,
|
||||
},
|
||||
};
|
||||
#[cfg(feature = "parquet")]
|
||||
use windmill_object_store::reload_object_store_setting;
|
||||
use windmill_queue::{cancel_job, get_queued_job_v2, SameWorkerPayload};
|
||||
@@ -163,6 +169,8 @@ lazy_static::lazy_static! {
|
||||
|
||||
static ref QUEUE_COUNT_TAGS: Arc<RwLock<Vec<String>>> = Arc::new(RwLock::new(Vec::new()));
|
||||
static ref QUEUE_RUNNING_COUNT_TAGS: Arc<RwLock<Vec<String>>> = Arc::new(RwLock::new(Vec::new()));
|
||||
static ref OTEL_QUEUE_COUNT_TAGS: Arc<RwLock<Vec<String>>> = Arc::new(RwLock::new(Vec::new()));
|
||||
static ref OTEL_QUEUE_RUNNING_COUNT_TAGS: Arc<RwLock<Vec<String>>> = Arc::new(RwLock::new(Vec::new()));
|
||||
static ref DISABLE_CONCURRENCY_LIMIT: bool = std::env::var("DISABLE_CONCURRENCY_LIMIT").is_ok_and(|s| s == "true");
|
||||
|
||||
//legacy typo
|
||||
@@ -296,6 +304,10 @@ pub async fn initial_load(
|
||||
if let Err(e) = reload_app_workspaced_route_setting(db).await {
|
||||
tracing::error!("Error reloading app workspaced route: {:?}", e)
|
||||
}
|
||||
|
||||
if let Err(e) = reload_http_route_workspaced_route_setting(db).await {
|
||||
tracing::error!("Error reloading http route workspaced route: {:?}", e)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "parquet")]
|
||||
@@ -1168,6 +1180,15 @@ pub async fn delete_expired_items(db: &DB) -> () {
|
||||
tracing::error!("Error deleting custom concurrency key: {:?}", e);
|
||||
}
|
||||
}
|
||||
|
||||
match windmill_common::trashbin::delete_expired_trash(db).await {
|
||||
Ok(count) => {
|
||||
if count > 0 {
|
||||
tracing::info!("deleted {} expired trash items", count);
|
||||
}
|
||||
}
|
||||
Err(e) => tracing::error!("Error deleting expired trash items: {}", e.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn check_expiring_tokens(db: &DB) {
|
||||
@@ -2353,8 +2374,20 @@ pub async fn expose_queue_metrics(db: &Pool<Postgres>) {
|
||||
}
|
||||
}
|
||||
|
||||
let otel_enabled = OTEL_METRICS_ENABLED.load(Ordering::Relaxed);
|
||||
|
||||
if otel_enabled {
|
||||
for q in OTEL_QUEUE_COUNT_TAGS.read().await.iter() {
|
||||
if queue_counts.get(q).is_none() {
|
||||
otel_set_queue_count(q, 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(unused_mut)]
|
||||
let mut tags_to_watch = vec![];
|
||||
#[allow(unused_mut)]
|
||||
let mut otel_tags_to_watch = vec![];
|
||||
for q in queue_counts {
|
||||
let count = q.1;
|
||||
let tag = q.0;
|
||||
@@ -2366,6 +2399,9 @@ pub async fn expose_queue_metrics(db: &Pool<Postgres>) {
|
||||
tags_to_watch.push(tag.to_string());
|
||||
}
|
||||
|
||||
if otel_enabled {
|
||||
otel_tags_to_watch.push(tag.to_string());
|
||||
}
|
||||
otel_set_queue_count(&tag, count as i64);
|
||||
|
||||
// save queue_count and delay metrics per tag
|
||||
@@ -2400,9 +2436,13 @@ pub async fn expose_queue_metrics(db: &Pool<Postgres>) {
|
||||
let mut w = QUEUE_COUNT_TAGS.write().await;
|
||||
*w = tags_to_watch;
|
||||
}
|
||||
if otel_enabled {
|
||||
let mut w = OTEL_QUEUE_COUNT_TAGS.write().await;
|
||||
*w = otel_tags_to_watch;
|
||||
}
|
||||
|
||||
// Single DB query for running counts, shared by Prometheus and OTel
|
||||
let otel_running = OTEL_METRICS_ENABLED.load(Ordering::Relaxed);
|
||||
let otel_running = otel_enabled;
|
||||
#[cfg(feature = "prometheus")]
|
||||
let need_running_counts = metrics_enabled || otel_running;
|
||||
#[cfg(not(feature = "prometheus"))]
|
||||
@@ -2420,8 +2460,18 @@ pub async fn expose_queue_metrics(db: &Pool<Postgres>) {
|
||||
}
|
||||
}
|
||||
|
||||
if otel_running {
|
||||
for q in OTEL_QUEUE_RUNNING_COUNT_TAGS.read().await.iter() {
|
||||
if queue_running_counts.get(q).is_none() {
|
||||
otel_set_queue_running_count(q, 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(unused_mut, unused_variables)]
|
||||
let mut running_tags_to_watch: Vec<String> = vec![];
|
||||
#[allow(unused_mut, unused_variables)]
|
||||
let mut otel_running_tags_to_watch: Vec<String> = vec![];
|
||||
for (tag, count) in &queue_running_counts {
|
||||
#[cfg(feature = "prometheus")]
|
||||
if metrics_enabled {
|
||||
@@ -2432,6 +2482,7 @@ pub async fn expose_queue_metrics(db: &Pool<Postgres>) {
|
||||
|
||||
if otel_running {
|
||||
otel_set_queue_running_count(tag, *count as i64);
|
||||
otel_running_tags_to_watch.push(tag.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2440,6 +2491,10 @@ pub async fn expose_queue_metrics(db: &Pool<Postgres>) {
|
||||
let mut w = QUEUE_RUNNING_COUNT_TAGS.write().await;
|
||||
*w = running_tags_to_watch;
|
||||
}
|
||||
if otel_running {
|
||||
let mut w = OTEL_QUEUE_RUNNING_COUNT_TAGS.write().await;
|
||||
*w = otel_running_tags_to_watch;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3390,6 +3445,39 @@ pub async fn reload_app_workspaced_route_setting(conn: &DB) -> error::Result<()>
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn reload_http_route_workspaced_route_setting(conn: &DB) -> error::Result<()> {
|
||||
let http_route_workspaced_route =
|
||||
load_value_from_global_settings(conn, HTTP_ROUTE_WORKSPACED_ROUTE_SETTING).await?;
|
||||
|
||||
let ws_route = match http_route_workspaced_route {
|
||||
Some(serde_json::Value::Bool(ws_route)) => ws_route,
|
||||
None => false,
|
||||
_ => {
|
||||
tracing::error!(
|
||||
"Expected {} to be a boolean got: {:?}. Defaulting to false",
|
||||
HTTP_ROUTE_WORKSPACED_ROUTE_SETTING,
|
||||
http_route_workspaced_route
|
||||
);
|
||||
false
|
||||
}
|
||||
};
|
||||
|
||||
let mut l = HTTP_ROUTE_WORKSPACED_ROUTE.write().await;
|
||||
|
||||
if *l != ws_route {
|
||||
*l = ws_route;
|
||||
drop(l);
|
||||
// Bump the HTTP trigger version so the route cache is rebuilt with
|
||||
// the updated workspaced_route behavior on the next request.
|
||||
sqlx::query!("SELECT nextval('http_trigger_version_seq')")
|
||||
.fetch_one(conn)
|
||||
.await?;
|
||||
} else {
|
||||
*l = ws_route;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn reload_critical_alerts_on_db_oversize(conn: &DB) -> error::Result<()> {
|
||||
#[derive(Deserialize)]
|
||||
struct DBOversize {
|
||||
|
||||
@@ -33,7 +33,7 @@ workspace_key_kind: cloud
|
||||
|
||||
## Tables
|
||||
_sqlx_migrations: version(bigint), description(text), installed_on(ts), success(bool), checksum(bytes), execution_time(bigint)
|
||||
account: workspace_id(char), id(int), expires_at(ts), refresh_token(char), client(char), refresh_error(text), grant_type(char), cc_client_id(char), cc_client_secret(char), cc_token_url(char), mcp_server_url(text)
|
||||
account: workspace_id(char), id(int), expires_at(ts), refresh_token(char), client(char), refresh_error(text), grant_type(char), cc_client_id(char), cc_client_secret(char), cc_token_url(char), mcp_server_url(text), scopes(text[])
|
||||
FK: (workspace_id) -> workspace(id)
|
||||
agent_token_blacklist: token(char), expires_at(ts), blacklisted_at(ts), blacklisted_by(char)
|
||||
ai_agent_memory: workspace_id(char), conversation_id(uuid), step_id(char), messages(jsonb), created_at(ts), updated_at(ts)
|
||||
@@ -151,6 +151,9 @@ script: workspace_id(char), hash(bigint), path(char), parent_hashes(bigint[]), s
|
||||
skip_workspace_diff_tally: workspace_id(char), added_at(ts)
|
||||
sqs_trigger: path(char), queue_url(char), aws_resource_path(char), message_attributes(text[]), script_path(char), is_flow(bool), workspace_id(char), edited_by(char), email(char), edited_at(ts), extra_perms(jsonb), error(text), server_id(char), last_server_ping(ts), aws_auth_resource_type(aws_auth_resource_type), error_handler_path(char), error_handler_args(jsonb), retry(jsonb), mode(trigger_mode)
|
||||
FK: (workspace_id) -> workspace(id)
|
||||
trashbin: id(bigint), workspace_id(char), item_kind(char), item_path(char), item_data(jsonb), deleted_by(char), deleted_at(ts), expires_at(ts)
|
||||
FK: (workspace_id) -> workspace(id)
|
||||
INDEX: idx_trashbin_expires_at (expires_at), idx_trashbin_workspace_kind (workspace_id, item_kind)
|
||||
token: token_hash(char), token_prefix(char), token(char), label(char), expiration(ts), workspace_id(char), owner(char), email(char), super_admin(bool), created_at(ts), last_used_at(ts), scopes(text[]), job(uuid)
|
||||
FK: (workspace_id) -> workspace(id)
|
||||
token_expiry_notification: token_hash(char), expiration(ts)
|
||||
|
||||
@@ -891,25 +891,34 @@ mod dedicated_worker_protocol {
|
||||
use std::process::{Command, Stdio};
|
||||
use windmill_test_utils::{parse_dedicated_worker_line, DedicatedWorkerResult};
|
||||
use windmill_worker::{
|
||||
build_loader, generate_dedicated_worker_wrapper, LoaderMode, BUN_DEDICATED_WORKER_ARGS,
|
||||
BUN_PATH, NODE_BIN_PATH,
|
||||
build_loader, compute_ts_codegen, generate_multi_script_wrapper, LoaderMode, TsScriptEntry,
|
||||
BUN_DEDICATED_WORKER_ARGS, BUN_PATH, NODE_BIN_PATH,
|
||||
};
|
||||
|
||||
const TEST_SCRIPT_PATH: &str = "f/test/script";
|
||||
|
||||
/// Creates test worker files and optionally bundles for Node.js (like production)
|
||||
/// Returns the path to the wrapper file to execute
|
||||
fn create_test_worker_files(
|
||||
dir: &std::path::Path,
|
||||
script: &str,
|
||||
arg_names: &[&str],
|
||||
bundle_for_node: bool,
|
||||
) -> std::path::PathBuf {
|
||||
let dir_str = dir.to_str().unwrap();
|
||||
// Write main.ts at root (like production single-script)
|
||||
std::fs::write(dir.join("main.ts"), script).unwrap();
|
||||
|
||||
let codegen = compute_ts_codegen(script);
|
||||
let ext = if bundle_for_node { "js" } else { "ts" };
|
||||
let scripts = [TsScriptEntry {
|
||||
import_name: "main",
|
||||
original_path: TEST_SCRIPT_PATH,
|
||||
codegen: &codegen,
|
||||
}];
|
||||
let wrapper = generate_multi_script_wrapper(&scripts, ext);
|
||||
|
||||
if bundle_for_node {
|
||||
// For Node.js: bundle to JavaScript first (like production's build_loader with LoaderMode::Node)
|
||||
let wrapper = generate_dedicated_worker_wrapper(arg_names, "./main.js", None, None);
|
||||
std::fs::write(dir.join("wrapper.mjs"), wrapper).unwrap();
|
||||
std::fs::write(dir.join("wrapper.mjs"), &wrapper).unwrap();
|
||||
|
||||
// Use the exact same build_loader function as production
|
||||
tokio::runtime::Runtime::new()
|
||||
@@ -919,7 +928,7 @@ mod dedicated_worker_protocol {
|
||||
"http://localhost:8000",
|
||||
"test_token",
|
||||
"test-workspace",
|
||||
"f/test/script",
|
||||
TEST_SCRIPT_PATH,
|
||||
LoaderMode::Node,
|
||||
&None,
|
||||
))
|
||||
@@ -945,10 +954,8 @@ mod dedicated_worker_protocol {
|
||||
std::fs::rename(&bundled_path, &output_path).unwrap();
|
||||
output_path
|
||||
} else {
|
||||
// For Bun: use TypeScript directly (like production)
|
||||
let wrapper = generate_dedicated_worker_wrapper(arg_names, "./main.ts", None, None);
|
||||
let wrapper_path = dir.join("wrapper.mjs");
|
||||
std::fs::write(&wrapper_path, wrapper).unwrap();
|
||||
std::fs::write(&wrapper_path, &wrapper).unwrap();
|
||||
wrapper_path
|
||||
}
|
||||
}
|
||||
@@ -957,14 +964,12 @@ mod dedicated_worker_protocol {
|
||||
fn run_worker_test(
|
||||
runtime: &str,
|
||||
script: &str,
|
||||
arg_names: &[&str],
|
||||
jobs: Vec<serde_json::Value>,
|
||||
) -> Vec<Result<serde_json::Value, String>> {
|
||||
let temp_dir = tempfile::tempdir().unwrap();
|
||||
|
||||
// Create files and get the wrapper path (bundled for node, raw for bun)
|
||||
let wrapper_path =
|
||||
create_test_worker_files(temp_dir.path(), script, arg_names, runtime == "node");
|
||||
let wrapper_path = create_test_worker_files(temp_dir.path(), script, runtime == "node");
|
||||
let wrapper_str = wrapper_path.to_str().unwrap();
|
||||
|
||||
// Build args matching production behavior
|
||||
@@ -1008,7 +1013,8 @@ mod dedicated_worker_protocol {
|
||||
let mut results = Vec::new();
|
||||
|
||||
for job_args in jobs {
|
||||
writeln!(stdin, "{}", job_args.to_string()).unwrap();
|
||||
// Protocol: exec:<script_path>:<json_args>
|
||||
writeln!(stdin, "exec:{}:{}", TEST_SCRIPT_PATH, job_args.to_string()).unwrap();
|
||||
stdin.flush().unwrap();
|
||||
|
||||
let mut response = String::new();
|
||||
@@ -1043,12 +1049,7 @@ export function main(x: number, y: number): number {
|
||||
return x + y;
|
||||
}
|
||||
"#;
|
||||
let results = run_worker_test(
|
||||
"node",
|
||||
script,
|
||||
&["x", "y"],
|
||||
vec![serde_json::json!({"x": 5, "y": 3})],
|
||||
);
|
||||
let results = run_worker_test("node", script, vec![serde_json::json!({"x": 5, "y": 3})]);
|
||||
|
||||
assert_eq!(results.len(), 1);
|
||||
assert_eq!(results[0], Ok(serde_json::json!(8)));
|
||||
@@ -1062,7 +1063,7 @@ export function main(n: number): number {
|
||||
}
|
||||
"#;
|
||||
let jobs: Vec<serde_json::Value> = (1..=5).map(|i| serde_json::json!({"n": i})).collect();
|
||||
let results = run_worker_test("node", script, &["n"], jobs);
|
||||
let results = run_worker_test("node", script, jobs);
|
||||
|
||||
assert_eq!(results.len(), 5);
|
||||
for (i, result) in results.iter().enumerate() {
|
||||
@@ -1081,7 +1082,6 @@ export function main(msg: string): never {
|
||||
let results = run_worker_test(
|
||||
"node",
|
||||
script,
|
||||
&["msg"],
|
||||
vec![serde_json::json!({"msg": "test error"})],
|
||||
);
|
||||
|
||||
@@ -1099,12 +1099,7 @@ export function main(x: number, y: number): number {
|
||||
return x + y;
|
||||
}
|
||||
"#;
|
||||
let results = run_worker_test(
|
||||
"bun",
|
||||
script,
|
||||
&["x", "y"],
|
||||
vec![serde_json::json!({"x": 5, "y": 3})],
|
||||
);
|
||||
let results = run_worker_test("bun", script, vec![serde_json::json!({"x": 5, "y": 3})]);
|
||||
|
||||
assert_eq!(results.len(), 1);
|
||||
assert_eq!(results[0], Ok(serde_json::json!(8)));
|
||||
@@ -1118,7 +1113,7 @@ export function main(n: number): number {
|
||||
}
|
||||
"#;
|
||||
let jobs: Vec<serde_json::Value> = (1..=5).map(|i| serde_json::json!({"n": i})).collect();
|
||||
let results = run_worker_test("bun", script, &["n"], jobs);
|
||||
let results = run_worker_test("bun", script, jobs);
|
||||
|
||||
assert_eq!(results.len(), 5);
|
||||
for (i, result) in results.iter().enumerate() {
|
||||
@@ -1137,7 +1132,6 @@ export function main(msg: string): never {
|
||||
let results = run_worker_test(
|
||||
"bun",
|
||||
script,
|
||||
&["msg"],
|
||||
vec![serde_json::json!({"msg": "test error"})],
|
||||
);
|
||||
|
||||
@@ -1145,6 +1139,721 @@ export function main(msg: string): never {
|
||||
assert!(results[0].is_err());
|
||||
assert_eq!(results[0], Err("test error".to_string()));
|
||||
}
|
||||
|
||||
// ==================== Multi-Script (Runner Group) Tests ====================
|
||||
|
||||
/// Job to send to a specific script in a multi-script wrapper
|
||||
struct MultiScriptJob {
|
||||
script_path: String,
|
||||
args: serde_json::Value,
|
||||
}
|
||||
|
||||
/// Creates a multi-script wrapper with multiple scripts as flat files, returns the wrapper path
|
||||
fn create_multi_script_worker_files(
|
||||
dir: &std::path::Path,
|
||||
scripts: &[(&str, &str)], // (original_path, script_content)
|
||||
) -> std::path::PathBuf {
|
||||
let mut entries_data = Vec::new();
|
||||
for (path, content) in scripts {
|
||||
let safe_name = format!("_wm_{}", path.replace('/', "__"));
|
||||
std::fs::write(dir.join(format!("{safe_name}.ts")), content).unwrap();
|
||||
entries_data.push((safe_name, path.to_string(), compute_ts_codegen(content)));
|
||||
}
|
||||
|
||||
let entries: Vec<TsScriptEntry<'_>> = entries_data
|
||||
.iter()
|
||||
.map(|(safe, path, cg)| TsScriptEntry {
|
||||
import_name: safe.as_str(),
|
||||
original_path: path.as_str(),
|
||||
codegen: cg,
|
||||
})
|
||||
.collect();
|
||||
|
||||
let wrapper = generate_multi_script_wrapper(&entries, "ts");
|
||||
let wrapper_path = dir.join("wrapper.mjs");
|
||||
std::fs::write(&wrapper_path, &wrapper).unwrap();
|
||||
wrapper_path
|
||||
}
|
||||
|
||||
/// Helper to run a multi-script dedicated worker test
|
||||
fn run_multi_script_worker_test(
|
||||
scripts: &[(&str, &str)],
|
||||
jobs: Vec<MultiScriptJob>,
|
||||
) -> Vec<Result<serde_json::Value, String>> {
|
||||
let temp_dir = tempfile::tempdir().unwrap();
|
||||
let wrapper_path = create_multi_script_worker_files(temp_dir.path(), scripts);
|
||||
let wrapper_str = wrapper_path.to_str().unwrap();
|
||||
|
||||
let mut cmd_args: Vec<&str> = BUN_DEDICATED_WORKER_ARGS.to_vec();
|
||||
cmd_args.push(wrapper_str);
|
||||
|
||||
let mut child = Command::new(BUN_PATH.as_str())
|
||||
.args(cmd_args)
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.current_dir(temp_dir.path())
|
||||
.spawn()
|
||||
.expect("Failed to spawn worker process");
|
||||
|
||||
let mut stdin = child.stdin.take().unwrap();
|
||||
let stdout = child.stdout.take().unwrap();
|
||||
let mut reader = BufReader::new(stdout);
|
||||
|
||||
// Wait for "start" signal
|
||||
let mut start_line = String::new();
|
||||
reader.read_line(&mut start_line).unwrap();
|
||||
assert_eq!(
|
||||
parse_dedicated_worker_line(start_line.trim()),
|
||||
DedicatedWorkerResult::Start,
|
||||
"Expected 'start', got: {}",
|
||||
start_line.trim()
|
||||
);
|
||||
|
||||
let mut results = Vec::new();
|
||||
|
||||
for job in &jobs {
|
||||
writeln!(stdin, "exec:{}:{}", job.script_path, job.args.to_string()).unwrap();
|
||||
stdin.flush().unwrap();
|
||||
|
||||
let mut response = String::new();
|
||||
reader.read_line(&mut response).unwrap();
|
||||
|
||||
match parse_dedicated_worker_line(response.trim()) {
|
||||
DedicatedWorkerResult::Success(value) => results.push(Ok(value)),
|
||||
DedicatedWorkerResult::Error(err) => {
|
||||
let msg = err["message"]
|
||||
.as_str()
|
||||
.unwrap_or("Unknown error")
|
||||
.to_string();
|
||||
results.push(Err(msg));
|
||||
}
|
||||
other => panic!("Unexpected response: {:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
writeln!(stdin, "end").unwrap();
|
||||
stdin.flush().unwrap();
|
||||
let _ = child.wait().expect("Worker process failed to exit");
|
||||
|
||||
results
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_multi_script_routing_basic() {
|
||||
let script_add = r#"
|
||||
export function main(a: number, b: number): number {
|
||||
return a + b;
|
||||
}
|
||||
"#;
|
||||
let script_mul = r#"
|
||||
export function main(x: number, y: number): number {
|
||||
return x * y;
|
||||
}
|
||||
"#;
|
||||
let results = run_multi_script_worker_test(
|
||||
&[("f/math/add", script_add), ("f/math/mul", script_mul)],
|
||||
vec![
|
||||
MultiScriptJob {
|
||||
script_path: "f/math/add".to_string(),
|
||||
args: serde_json::json!({"a": 3, "b": 4}),
|
||||
},
|
||||
MultiScriptJob {
|
||||
script_path: "f/math/mul".to_string(),
|
||||
args: serde_json::json!({"x": 5, "y": 6}),
|
||||
},
|
||||
// Route back to add
|
||||
MultiScriptJob {
|
||||
script_path: "f/math/add".to_string(),
|
||||
args: serde_json::json!({"a": 10, "b": 20}),
|
||||
},
|
||||
],
|
||||
);
|
||||
|
||||
assert_eq!(results.len(), 3);
|
||||
assert_eq!(results[0], Ok(serde_json::json!(7))); // 3 + 4
|
||||
assert_eq!(results[1], Ok(serde_json::json!(30))); // 5 * 6
|
||||
assert_eq!(results[2], Ok(serde_json::json!(30))); // 10 + 20
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_multi_script_interleaved_jobs() {
|
||||
let script_upper = r#"
|
||||
export function main(s: string): string {
|
||||
return s.toUpperCase();
|
||||
}
|
||||
"#;
|
||||
let script_len = r#"
|
||||
export function main(s: string): number {
|
||||
return s.length;
|
||||
}
|
||||
"#;
|
||||
let results = run_multi_script_worker_test(
|
||||
&[("f/str/upper", script_upper), ("f/str/len", script_len)],
|
||||
vec![
|
||||
MultiScriptJob {
|
||||
script_path: "f/str/upper".to_string(),
|
||||
args: serde_json::json!({"s": "hello"}),
|
||||
},
|
||||
MultiScriptJob {
|
||||
script_path: "f/str/len".to_string(),
|
||||
args: serde_json::json!({"s": "hello"}),
|
||||
},
|
||||
MultiScriptJob {
|
||||
script_path: "f/str/upper".to_string(),
|
||||
args: serde_json::json!({"s": "world"}),
|
||||
},
|
||||
MultiScriptJob {
|
||||
script_path: "f/str/len".to_string(),
|
||||
args: serde_json::json!({"s": "ab"}),
|
||||
},
|
||||
],
|
||||
);
|
||||
|
||||
assert_eq!(results.len(), 4);
|
||||
assert_eq!(results[0], Ok(serde_json::json!("HELLO")));
|
||||
assert_eq!(results[1], Ok(serde_json::json!(5)));
|
||||
assert_eq!(results[2], Ok(serde_json::json!("WORLD")));
|
||||
assert_eq!(results[3], Ok(serde_json::json!(2)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_multi_script_unknown_path_error() {
|
||||
let script = r#"
|
||||
export function main(x: number): number {
|
||||
return x;
|
||||
}
|
||||
"#;
|
||||
let results = run_multi_script_worker_test(
|
||||
&[("f/known", script)],
|
||||
vec![MultiScriptJob {
|
||||
script_path: "f/unknown".to_string(),
|
||||
args: serde_json::json!({"x": 1}),
|
||||
}],
|
||||
);
|
||||
|
||||
assert_eq!(results.len(), 1);
|
||||
assert!(results[0].is_err());
|
||||
assert!(results[0]
|
||||
.as_ref()
|
||||
.unwrap_err()
|
||||
.contains("Script not found"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_multi_script_error_doesnt_break_other_scripts() {
|
||||
let script_ok = r#"
|
||||
export function main(x: number): number {
|
||||
return x * 2;
|
||||
}
|
||||
"#;
|
||||
let script_err = r#"
|
||||
export function main(msg: string): never {
|
||||
throw new Error(msg);
|
||||
}
|
||||
"#;
|
||||
let results = run_multi_script_worker_test(
|
||||
&[("f/ok", script_ok), ("f/err", script_err)],
|
||||
vec![
|
||||
MultiScriptJob {
|
||||
script_path: "f/ok".to_string(),
|
||||
args: serde_json::json!({"x": 5}),
|
||||
},
|
||||
MultiScriptJob {
|
||||
script_path: "f/err".to_string(),
|
||||
args: serde_json::json!({"msg": "boom"}),
|
||||
},
|
||||
// Should still work after error in other script
|
||||
MultiScriptJob {
|
||||
script_path: "f/ok".to_string(),
|
||||
args: serde_json::json!({"x": 10}),
|
||||
},
|
||||
],
|
||||
);
|
||||
|
||||
assert_eq!(results.len(), 3);
|
||||
assert_eq!(results[0], Ok(serde_json::json!(10)));
|
||||
assert!(results[1].is_err());
|
||||
assert_eq!(results[1], Err("boom".to_string()));
|
||||
assert_eq!(results[2], Ok(serde_json::json!(20)));
|
||||
}
|
||||
|
||||
// ==================== exec_preprocess Tests ====================
|
||||
|
||||
/// Raw protocol command to send to a dedicated worker
|
||||
enum ProtocolCmd {
|
||||
Exec { path: String, args: serde_json::Value },
|
||||
ExecPreprocess { path: String, args: serde_json::Value },
|
||||
}
|
||||
|
||||
/// Run a multi-script worker test with raw protocol commands, returning all protocol lines
|
||||
fn run_raw_protocol_test(
|
||||
scripts: &[(&str, &str)],
|
||||
commands: Vec<ProtocolCmd>,
|
||||
) -> Vec<DedicatedWorkerResult> {
|
||||
let temp_dir = tempfile::tempdir().unwrap();
|
||||
let wrapper_path = create_multi_script_worker_files(temp_dir.path(), scripts);
|
||||
let wrapper_str = wrapper_path.to_str().unwrap();
|
||||
|
||||
let mut cmd_args: Vec<&str> = BUN_DEDICATED_WORKER_ARGS.to_vec();
|
||||
cmd_args.push(wrapper_str);
|
||||
|
||||
let mut child = Command::new(BUN_PATH.as_str())
|
||||
.args(cmd_args)
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.current_dir(temp_dir.path())
|
||||
.spawn()
|
||||
.expect("Failed to spawn worker process");
|
||||
|
||||
let mut stdin = child.stdin.take().unwrap();
|
||||
let stdout = child.stdout.take().unwrap();
|
||||
let mut reader = BufReader::new(stdout);
|
||||
|
||||
let mut start_line = String::new();
|
||||
reader.read_line(&mut start_line).unwrap();
|
||||
assert_eq!(
|
||||
parse_dedicated_worker_line(start_line.trim()),
|
||||
DedicatedWorkerResult::Start,
|
||||
);
|
||||
|
||||
let mut results = Vec::new();
|
||||
|
||||
for cmd in &commands {
|
||||
let line = match cmd {
|
||||
ProtocolCmd::Exec { path, args } => format!("exec:{}:{}", path, args),
|
||||
ProtocolCmd::ExecPreprocess { path, args } => {
|
||||
format!("exec_preprocess:{}:{}", path, args)
|
||||
}
|
||||
};
|
||||
writeln!(stdin, "{}", line).unwrap();
|
||||
stdin.flush().unwrap();
|
||||
|
||||
// exec_preprocess produces 2 response lines (preprocessed_args + success/error)
|
||||
// exec produces 1 response line (success/error)
|
||||
let expected_lines = match cmd {
|
||||
ProtocolCmd::ExecPreprocess { .. } => 2,
|
||||
ProtocolCmd::Exec { .. } => 1,
|
||||
};
|
||||
|
||||
for _ in 0..expected_lines {
|
||||
let mut response = String::new();
|
||||
reader.read_line(&mut response).unwrap();
|
||||
let parsed = parse_dedicated_worker_line(response.trim());
|
||||
// If it's an error, stop reading more lines for this command
|
||||
if matches!(parsed, DedicatedWorkerResult::Error(_)) {
|
||||
results.push(parsed);
|
||||
break;
|
||||
}
|
||||
results.push(parsed);
|
||||
}
|
||||
}
|
||||
|
||||
writeln!(stdin, "end").unwrap();
|
||||
stdin.flush().unwrap();
|
||||
let _ = child.wait().expect("Worker process failed to exit");
|
||||
|
||||
results
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bun_exec_preprocess() {
|
||||
let script = r#"
|
||||
export function preprocessor(x: number) {
|
||||
return { x: x * 10 };
|
||||
}
|
||||
export function main(x: number): number {
|
||||
return x + 1;
|
||||
}
|
||||
"#;
|
||||
let results = run_raw_protocol_test(
|
||||
&[("f/test/pre", script)],
|
||||
vec![ProtocolCmd::ExecPreprocess {
|
||||
path: "f/test/pre".to_string(),
|
||||
args: serde_json::json!({"x": 5}),
|
||||
}],
|
||||
);
|
||||
// Should get preprocessed_args then success
|
||||
assert_eq!(results.len(), 2);
|
||||
assert_eq!(
|
||||
results[0],
|
||||
DedicatedWorkerResult::PreprocessedArgs(serde_json::json!({"x": 50}))
|
||||
);
|
||||
// main(50) => 51
|
||||
assert_eq!(
|
||||
results[1],
|
||||
DedicatedWorkerResult::Success(serde_json::json!(51))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bun_exec_preprocess_missing_preprocessor() {
|
||||
let script = r#"
|
||||
export function main(x: number): number {
|
||||
return x;
|
||||
}
|
||||
"#;
|
||||
let results = run_raw_protocol_test(
|
||||
&[("f/test/nopre", script)],
|
||||
vec![ProtocolCmd::ExecPreprocess {
|
||||
path: "f/test/nopre".to_string(),
|
||||
args: serde_json::json!({"x": 5}),
|
||||
}],
|
||||
);
|
||||
assert_eq!(results.len(), 1);
|
||||
assert!(matches!(results[0], DedicatedWorkerResult::Error(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bun_exec_preprocess_then_exec() {
|
||||
let script = r#"
|
||||
export function preprocessor(x: number) {
|
||||
return { x: x * 2 };
|
||||
}
|
||||
export function main(x: number): number {
|
||||
return x + 100;
|
||||
}
|
||||
"#;
|
||||
let results = run_raw_protocol_test(
|
||||
&[("f/test/mixed", script)],
|
||||
vec![
|
||||
ProtocolCmd::ExecPreprocess {
|
||||
path: "f/test/mixed".to_string(),
|
||||
args: serde_json::json!({"x": 5}),
|
||||
},
|
||||
ProtocolCmd::Exec {
|
||||
path: "f/test/mixed".to_string(),
|
||||
args: serde_json::json!({"x": 7}),
|
||||
},
|
||||
],
|
||||
);
|
||||
// preprocess: preprocessor(5) => {"x":10}, main(10) => 110
|
||||
// exec: main(7) => 107
|
||||
assert_eq!(results.len(), 3);
|
||||
assert_eq!(
|
||||
results[0],
|
||||
DedicatedWorkerResult::PreprocessedArgs(serde_json::json!({"x": 10}))
|
||||
);
|
||||
assert_eq!(
|
||||
results[1],
|
||||
DedicatedWorkerResult::Success(serde_json::json!(110))
|
||||
);
|
||||
assert_eq!(
|
||||
results[2],
|
||||
DedicatedWorkerResult::Success(serde_json::json!(107))
|
||||
);
|
||||
}
|
||||
|
||||
// ==================== Argument Transformation Tests ====================
|
||||
|
||||
#[test]
|
||||
fn test_bun_date_arg_transformation() {
|
||||
let script = r#"
|
||||
export function main(d: Date): string {
|
||||
return d instanceof Date ? d.toISOString() : typeof d;
|
||||
}
|
||||
"#;
|
||||
let results = run_worker_test(
|
||||
"bun",
|
||||
script,
|
||||
vec![serde_json::json!({"d": "2024-01-15T10:30:00.000Z"})],
|
||||
);
|
||||
assert_eq!(results.len(), 1);
|
||||
assert_eq!(
|
||||
results[0],
|
||||
Ok(serde_json::json!("2024-01-15T10:30:00.000Z"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bun_null_and_undefined_args() {
|
||||
let script = r#"
|
||||
export function main(x?: number): string {
|
||||
return x === null ? "null" : x === undefined ? "undefined" : String(x);
|
||||
}
|
||||
"#;
|
||||
let results = run_worker_test(
|
||||
"bun",
|
||||
script,
|
||||
vec![
|
||||
serde_json::json!({"x": null}),
|
||||
serde_json::json!({"x": 42}),
|
||||
serde_json::json!({}),
|
||||
],
|
||||
);
|
||||
assert_eq!(results.len(), 3);
|
||||
assert_eq!(results[0], Ok(serde_json::json!("null")));
|
||||
assert_eq!(results[1], Ok(serde_json::json!("42")));
|
||||
// Missing arg should be undefined
|
||||
assert_eq!(results[2], Ok(serde_json::json!("undefined")));
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Deno Dedicated Worker Protocol Tests
|
||||
// ============================================================================
|
||||
|
||||
mod dedicated_worker_protocol_deno {
|
||||
use std::io::{BufRead, BufReader, Write};
|
||||
use std::process::{Command, Stdio};
|
||||
use windmill_test_utils::{parse_dedicated_worker_line, DedicatedWorkerResult};
|
||||
use windmill_worker::{generate_deno_dedicated_worker_wrapper, DENO_PATH};
|
||||
|
||||
const TEST_SCRIPT_PATH: &str = "f/test/script";
|
||||
|
||||
fn run_deno_worker_test(
|
||||
script: &str,
|
||||
jobs: Vec<serde_json::Value>,
|
||||
) -> Vec<Result<serde_json::Value, String>> {
|
||||
let temp_dir = tempfile::tempdir().unwrap();
|
||||
std::fs::write(temp_dir.path().join("main.ts"), script).unwrap();
|
||||
|
||||
let wrapper = generate_deno_dedicated_worker_wrapper(script).unwrap();
|
||||
std::fs::write(temp_dir.path().join("wrapper.ts"), &wrapper).unwrap();
|
||||
|
||||
let mut child = Command::new(DENO_PATH.as_str())
|
||||
.args([
|
||||
"run",
|
||||
"--no-check",
|
||||
"--unstable-unsafe-proto",
|
||||
"--unstable-bare-node-builtins",
|
||||
"-A",
|
||||
"wrapper.ts",
|
||||
])
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.current_dir(temp_dir.path())
|
||||
.spawn()
|
||||
.expect("Failed to spawn deno process");
|
||||
|
||||
let mut stdin = child.stdin.take().unwrap();
|
||||
let stdout = child.stdout.take().unwrap();
|
||||
let mut reader = BufReader::new(stdout);
|
||||
|
||||
// Wait for "start" — deno outputs 'start\n' via console.log which adds
|
||||
// its own newline, producing double newlines. Skip empty lines.
|
||||
loop {
|
||||
let mut line = String::new();
|
||||
reader.read_line(&mut line).unwrap();
|
||||
if line.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
assert_eq!(
|
||||
parse_dedicated_worker_line(line.trim()),
|
||||
DedicatedWorkerResult::Start,
|
||||
"Expected 'start', got: {}",
|
||||
line.trim()
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
let mut results = Vec::new();
|
||||
for job_args in jobs {
|
||||
writeln!(stdin, "exec:{}:{}", TEST_SCRIPT_PATH, job_args.to_string()).unwrap();
|
||||
stdin.flush().unwrap();
|
||||
|
||||
loop {
|
||||
let mut response = String::new();
|
||||
reader.read_line(&mut response).unwrap();
|
||||
let trimmed = response.trim();
|
||||
if trimmed.is_empty() {
|
||||
continue;
|
||||
}
|
||||
match parse_dedicated_worker_line(trimmed) {
|
||||
DedicatedWorkerResult::Success(value) => results.push(Ok(value)),
|
||||
DedicatedWorkerResult::Error(err) => {
|
||||
let msg = err["message"]
|
||||
.as_str()
|
||||
.unwrap_or("Unknown error")
|
||||
.to_string();
|
||||
results.push(Err(msg));
|
||||
}
|
||||
other => panic!("Unexpected response: {:?}", other),
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
writeln!(stdin, "end").unwrap();
|
||||
stdin.flush().unwrap();
|
||||
let _ = child.wait().expect("Worker process failed to exit");
|
||||
results
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_deno_dedicated_worker_simple() {
|
||||
let script = r#"
|
||||
export function main(x: number, y: number): number {
|
||||
return x + y;
|
||||
}
|
||||
"#;
|
||||
let results = run_deno_worker_test(script, vec![serde_json::json!({"x": 5, "y": 3})]);
|
||||
assert_eq!(results.len(), 1);
|
||||
assert_eq!(results[0], Ok(serde_json::json!(8)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_deno_dedicated_worker_multiple_jobs() {
|
||||
let script = r#"
|
||||
export function main(n: number): number {
|
||||
return n * 2;
|
||||
}
|
||||
"#;
|
||||
let jobs: Vec<serde_json::Value> = (1..=5).map(|i| serde_json::json!({"n": i})).collect();
|
||||
let results = run_deno_worker_test(script, jobs);
|
||||
assert_eq!(results.len(), 5);
|
||||
for (i, result) in results.iter().enumerate() {
|
||||
assert_eq!(*result, Ok(serde_json::json!(((i + 1) * 2) as i64)));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_deno_dedicated_worker_error() {
|
||||
let script = r#"
|
||||
export function main(msg: string): never {
|
||||
throw new Error(msg);
|
||||
}
|
||||
"#;
|
||||
let results = run_deno_worker_test(script, vec![serde_json::json!({"msg": "test error"})]);
|
||||
assert_eq!(results.len(), 1);
|
||||
assert!(results[0].is_err());
|
||||
assert_eq!(results[0], Err("test error".to_string()));
|
||||
}
|
||||
|
||||
// ==================== exec_preprocess Tests ====================
|
||||
|
||||
/// Run a raw deno protocol test, reading all output lines per command
|
||||
fn run_deno_raw_protocol_test(
|
||||
script: &str,
|
||||
commands: Vec<(&str, serde_json::Value)>, // ("exec" or "exec_preprocess", args)
|
||||
) -> Vec<DedicatedWorkerResult> {
|
||||
let temp_dir = tempfile::tempdir().unwrap();
|
||||
std::fs::write(temp_dir.path().join("main.ts"), script).unwrap();
|
||||
|
||||
let wrapper = generate_deno_dedicated_worker_wrapper(script).unwrap();
|
||||
std::fs::write(temp_dir.path().join("wrapper.ts"), &wrapper).unwrap();
|
||||
|
||||
let mut child = Command::new(DENO_PATH.as_str())
|
||||
.args([
|
||||
"run",
|
||||
"--no-check",
|
||||
"--unstable-unsafe-proto",
|
||||
"--unstable-bare-node-builtins",
|
||||
"-A",
|
||||
"wrapper.ts",
|
||||
])
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.current_dir(temp_dir.path())
|
||||
.spawn()
|
||||
.expect("Failed to spawn deno process");
|
||||
|
||||
let mut stdin = child.stdin.take().unwrap();
|
||||
let stdout = child.stdout.take().unwrap();
|
||||
let mut reader = BufReader::new(stdout);
|
||||
|
||||
// Wait for start, skip empty lines
|
||||
loop {
|
||||
let mut line = String::new();
|
||||
reader.read_line(&mut line).unwrap();
|
||||
if line.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
assert_eq!(
|
||||
parse_dedicated_worker_line(line.trim()),
|
||||
DedicatedWorkerResult::Start,
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
let mut results = Vec::new();
|
||||
|
||||
for (cmd, args) in &commands {
|
||||
writeln!(stdin, "{}:{}:{}", cmd, TEST_SCRIPT_PATH, args).unwrap();
|
||||
stdin.flush().unwrap();
|
||||
|
||||
let expected_lines = if *cmd == "exec_preprocess" { 2 } else { 1 };
|
||||
|
||||
for _ in 0..expected_lines {
|
||||
loop {
|
||||
let mut response = String::new();
|
||||
reader.read_line(&mut response).unwrap();
|
||||
if response.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
let parsed = parse_dedicated_worker_line(response.trim());
|
||||
if matches!(parsed, DedicatedWorkerResult::Error(_)) {
|
||||
results.push(parsed);
|
||||
break;
|
||||
}
|
||||
results.push(parsed);
|
||||
break;
|
||||
}
|
||||
// If last result was an error, don't read more lines for this command
|
||||
if matches!(results.last(), Some(DedicatedWorkerResult::Error(_))) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
writeln!(stdin, "end").unwrap();
|
||||
stdin.flush().unwrap();
|
||||
let _ = child.wait().expect("Worker process failed to exit");
|
||||
|
||||
results
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_deno_exec_preprocess() {
|
||||
let script = r#"
|
||||
export function preprocessor(x: number) {
|
||||
return { x: x * 10 };
|
||||
}
|
||||
export function main(x: number): number {
|
||||
return x + 1;
|
||||
}
|
||||
"#;
|
||||
let results = run_deno_raw_protocol_test(
|
||||
script,
|
||||
vec![("exec_preprocess", serde_json::json!({"x": 5}))],
|
||||
);
|
||||
assert_eq!(results.len(), 2);
|
||||
assert_eq!(
|
||||
results[0],
|
||||
DedicatedWorkerResult::PreprocessedArgs(serde_json::json!({"x": 50}))
|
||||
);
|
||||
assert_eq!(
|
||||
results[1],
|
||||
DedicatedWorkerResult::Success(serde_json::json!(51))
|
||||
);
|
||||
}
|
||||
|
||||
// Note: no "missing preprocessor" test for Deno because the wrapper only generates
|
||||
// the exec_preprocess handler when the script actually has a preprocessor function.
|
||||
// Without one, exec_preprocess messages are unrecognized (by design — Rust never sends them).
|
||||
|
||||
// ==================== Argument Transformation Tests ====================
|
||||
|
||||
#[test]
|
||||
fn test_deno_date_arg_transformation() {
|
||||
let script = r#"
|
||||
export function main(d: Date): string {
|
||||
return d instanceof Date ? d.toISOString() : typeof d;
|
||||
}
|
||||
"#;
|
||||
let results = run_deno_worker_test(
|
||||
script,
|
||||
vec![serde_json::json!({"d": "2024-01-15T10:30:00.000Z"})],
|
||||
);
|
||||
assert_eq!(results.len(), 1);
|
||||
assert_eq!(
|
||||
results[0],
|
||||
Ok(serde_json::json!("2024-01-15T10:30:00.000Z"))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
|
||||
@@ -1,9 +1,470 @@
|
||||
use serde_json::json;
|
||||
#[cfg(feature = "python")]
|
||||
use sqlx::postgres::Postgres;
|
||||
#[cfg(feature = "python")]
|
||||
use sqlx::Pool;
|
||||
#[cfg(feature = "python")]
|
||||
use windmill_common::scripts::ScriptLang;
|
||||
use windmill_test_utils::*;
|
||||
|
||||
// ============================================================================
|
||||
// Dedicated Worker Protocol Tests (Python)
|
||||
// ============================================================================
|
||||
|
||||
#[cfg(feature = "python")]
|
||||
mod dedicated_worker_protocol_python {
|
||||
use std::io::{BufRead, BufReader, Write};
|
||||
use std::process::{Command, Stdio};
|
||||
use windmill_test_utils::{parse_dedicated_worker_line, DedicatedWorkerResult};
|
||||
use windmill_worker::{compute_py_codegen, generate_py_multi_script_wrapper, PyScriptEntry};
|
||||
|
||||
struct MultiScriptJob {
|
||||
script_path: String,
|
||||
args: serde_json::Value,
|
||||
}
|
||||
|
||||
/// Creates a multi-script Python wrapper, writes scripts to proper module paths
|
||||
fn create_py_worker_files(
|
||||
dir: &std::path::Path,
|
||||
scripts: &[(&str, &str)], // (original_path, content)
|
||||
) -> std::path::PathBuf {
|
||||
let mut codegens = Vec::new();
|
||||
for (path, content) in scripts {
|
||||
let cg = compute_py_codegen(content, path);
|
||||
let module_dir = dir.join(&cg.dirs);
|
||||
std::fs::create_dir_all(&module_dir).unwrap();
|
||||
std::fs::write(module_dir.join(format!("{}.py", cg.module_name)), content).unwrap();
|
||||
codegens.push((path.to_string(), cg));
|
||||
}
|
||||
|
||||
let entries: Vec<PyScriptEntry<'_>> = codegens
|
||||
.iter()
|
||||
.map(|(path, cg)| PyScriptEntry { original_path: path.as_str(), codegen: cg })
|
||||
.collect();
|
||||
|
||||
let wrapper = generate_py_multi_script_wrapper(&entries, false, false);
|
||||
let wrapper_path = dir.join("wrapper.py");
|
||||
std::fs::write(&wrapper_path, &wrapper).unwrap();
|
||||
wrapper_path
|
||||
}
|
||||
|
||||
fn run_py_multi_script_test(
|
||||
scripts: &[(&str, &str)],
|
||||
jobs: Vec<MultiScriptJob>,
|
||||
) -> Vec<Result<serde_json::Value, String>> {
|
||||
let temp_dir = tempfile::tempdir().unwrap();
|
||||
create_py_worker_files(temp_dir.path(), scripts);
|
||||
|
||||
let mut child = Command::new("python3")
|
||||
.args(["-u", "-m", "wrapper"])
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.current_dir(temp_dir.path())
|
||||
.spawn()
|
||||
.expect("Failed to spawn python3 process");
|
||||
|
||||
let mut stdin = child.stdin.take().unwrap();
|
||||
let stdout = child.stdout.take().unwrap();
|
||||
let mut reader = BufReader::new(stdout);
|
||||
|
||||
let mut start_line = String::new();
|
||||
reader.read_line(&mut start_line).unwrap();
|
||||
assert_eq!(
|
||||
parse_dedicated_worker_line(start_line.trim()),
|
||||
DedicatedWorkerResult::Start,
|
||||
"Expected 'start', got: {}",
|
||||
start_line.trim()
|
||||
);
|
||||
|
||||
let mut results = Vec::new();
|
||||
for job in &jobs {
|
||||
writeln!(stdin, "exec:{}:{}", job.script_path, job.args.to_string()).unwrap();
|
||||
stdin.flush().unwrap();
|
||||
|
||||
let mut response = String::new();
|
||||
reader.read_line(&mut response).unwrap();
|
||||
|
||||
match parse_dedicated_worker_line(response.trim()) {
|
||||
DedicatedWorkerResult::Success(value) => results.push(Ok(value)),
|
||||
DedicatedWorkerResult::Error(err) => {
|
||||
let msg = err["message"]
|
||||
.as_str()
|
||||
.unwrap_or("Unknown error")
|
||||
.to_string();
|
||||
results.push(Err(msg));
|
||||
}
|
||||
other => panic!("Unexpected response: {:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
writeln!(stdin, "end").unwrap();
|
||||
stdin.flush().unwrap();
|
||||
let _ = child.wait().expect("Worker process failed to exit");
|
||||
results
|
||||
}
|
||||
|
||||
fn run_py_single_script_test(
|
||||
script_path: &str,
|
||||
content: &str,
|
||||
jobs: Vec<serde_json::Value>,
|
||||
) -> Vec<Result<serde_json::Value, String>> {
|
||||
run_py_multi_script_test(
|
||||
&[(script_path, content)],
|
||||
jobs.into_iter()
|
||||
.map(|args| MultiScriptJob { script_path: script_path.to_string(), args })
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_python_dedicated_worker_simple() {
|
||||
let results = run_py_single_script_test(
|
||||
"f/test/add",
|
||||
"def main(a: int, b: int):\n return a + b\n",
|
||||
vec![serde_json::json!({"a": 3, "b": 4})],
|
||||
);
|
||||
assert_eq!(results.len(), 1);
|
||||
assert_eq!(results[0], Ok(serde_json::json!(7)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_python_dedicated_worker_multiple_jobs() {
|
||||
let results = run_py_single_script_test(
|
||||
"f/test/double",
|
||||
"def main(n: int):\n return n * 2\n",
|
||||
(1..=5).map(|i| serde_json::json!({"n": i})).collect(),
|
||||
);
|
||||
assert_eq!(results.len(), 5);
|
||||
for (i, result) in results.iter().enumerate() {
|
||||
assert_eq!(*result, Ok(serde_json::json!(((i + 1) * 2) as i64)));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_python_multi_script_routing() {
|
||||
let results = run_py_multi_script_test(
|
||||
&[
|
||||
(
|
||||
"f/math/add",
|
||||
"def main(a: int, b: int):\n return a + b\n",
|
||||
),
|
||||
(
|
||||
"f/math/mul",
|
||||
"def main(x: int, y: int):\n return x * y\n",
|
||||
),
|
||||
],
|
||||
vec![
|
||||
MultiScriptJob {
|
||||
script_path: "f/math/add".to_string(),
|
||||
args: serde_json::json!({"a": 3, "b": 4}),
|
||||
},
|
||||
MultiScriptJob {
|
||||
script_path: "f/math/mul".to_string(),
|
||||
args: serde_json::json!({"x": 5, "y": 6}),
|
||||
},
|
||||
MultiScriptJob {
|
||||
script_path: "f/math/add".to_string(),
|
||||
args: serde_json::json!({"a": 10, "b": 20}),
|
||||
},
|
||||
],
|
||||
);
|
||||
assert_eq!(results.len(), 3);
|
||||
assert_eq!(results[0], Ok(serde_json::json!(7)));
|
||||
assert_eq!(results[1], Ok(serde_json::json!(30)));
|
||||
assert_eq!(results[2], Ok(serde_json::json!(30)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_python_multi_script_error_isolation() {
|
||||
let results = run_py_multi_script_test(
|
||||
&[
|
||||
("f/ok", "def main(x: int):\n return x * 2\n"),
|
||||
("f/err", "def main(msg: str):\n raise Exception(msg)\n"),
|
||||
],
|
||||
vec![
|
||||
MultiScriptJob {
|
||||
script_path: "f/ok".to_string(),
|
||||
args: serde_json::json!({"x": 5}),
|
||||
},
|
||||
MultiScriptJob {
|
||||
script_path: "f/err".to_string(),
|
||||
args: serde_json::json!({"msg": "boom"}),
|
||||
},
|
||||
MultiScriptJob {
|
||||
script_path: "f/ok".to_string(),
|
||||
args: serde_json::json!({"x": 10}),
|
||||
},
|
||||
],
|
||||
);
|
||||
assert_eq!(results.len(), 3);
|
||||
assert_eq!(results[0], Ok(serde_json::json!(10)));
|
||||
assert!(results[1].is_err());
|
||||
assert_eq!(results[1], Err("boom".to_string()));
|
||||
assert_eq!(results[2], Ok(serde_json::json!(20)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_python_multi_script_unknown_path() {
|
||||
let results = run_py_multi_script_test(
|
||||
&[("f/known", "def main(x: int):\n return x\n")],
|
||||
vec![MultiScriptJob {
|
||||
script_path: "f/unknown".to_string(),
|
||||
args: serde_json::json!({"x": 1}),
|
||||
}],
|
||||
);
|
||||
assert_eq!(results.len(), 1);
|
||||
assert!(results[0].is_err());
|
||||
assert!(results[0]
|
||||
.as_ref()
|
||||
.unwrap_err()
|
||||
.contains("Script not found"));
|
||||
}
|
||||
|
||||
// ==================== exec_preprocess Tests ====================
|
||||
|
||||
/// Raw protocol command for Python
|
||||
enum ProtocolCmd {
|
||||
Exec { path: String, args: serde_json::Value },
|
||||
ExecPreprocess { path: String, args: serde_json::Value },
|
||||
}
|
||||
|
||||
/// Run a Python worker test with raw protocol commands
|
||||
fn run_py_raw_protocol_test(
|
||||
scripts: &[(&str, &str)],
|
||||
commands: Vec<ProtocolCmd>,
|
||||
) -> Vec<DedicatedWorkerResult> {
|
||||
let temp_dir = tempfile::tempdir().unwrap();
|
||||
create_py_worker_files(temp_dir.path(), scripts);
|
||||
|
||||
let mut child = Command::new("python3")
|
||||
.args(["-u", "-m", "wrapper"])
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.current_dir(temp_dir.path())
|
||||
.spawn()
|
||||
.expect("Failed to spawn python3 process");
|
||||
|
||||
let mut stdin = child.stdin.take().unwrap();
|
||||
let stdout = child.stdout.take().unwrap();
|
||||
let mut reader = BufReader::new(stdout);
|
||||
|
||||
let mut start_line = String::new();
|
||||
reader.read_line(&mut start_line).unwrap();
|
||||
assert_eq!(
|
||||
parse_dedicated_worker_line(start_line.trim()),
|
||||
DedicatedWorkerResult::Start,
|
||||
);
|
||||
|
||||
let mut results = Vec::new();
|
||||
|
||||
for cmd in &commands {
|
||||
let line = match cmd {
|
||||
ProtocolCmd::Exec { path, args } => format!("exec:{}:{}", path, args),
|
||||
ProtocolCmd::ExecPreprocess { path, args } => {
|
||||
format!("exec_preprocess:{}:{}", path, args)
|
||||
}
|
||||
};
|
||||
writeln!(stdin, "{}", line).unwrap();
|
||||
stdin.flush().unwrap();
|
||||
|
||||
let expected_lines = match cmd {
|
||||
ProtocolCmd::ExecPreprocess { .. } => 2,
|
||||
ProtocolCmd::Exec { .. } => 1,
|
||||
};
|
||||
|
||||
for _ in 0..expected_lines {
|
||||
let mut response = String::new();
|
||||
reader.read_line(&mut response).unwrap();
|
||||
let parsed = parse_dedicated_worker_line(response.trim());
|
||||
if matches!(parsed, DedicatedWorkerResult::Error(_)) {
|
||||
results.push(parsed);
|
||||
break;
|
||||
}
|
||||
results.push(parsed);
|
||||
}
|
||||
}
|
||||
|
||||
writeln!(stdin, "end").unwrap();
|
||||
stdin.flush().unwrap();
|
||||
let _ = child.wait().expect("Worker process failed to exit");
|
||||
|
||||
results
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_python_exec_preprocess() {
|
||||
let script = r#"
|
||||
def preprocessor(x: int):
|
||||
return {"x": x * 10}
|
||||
|
||||
def main(x: int):
|
||||
return x + 1
|
||||
"#;
|
||||
let results = run_py_raw_protocol_test(
|
||||
&[("f/test/pre", script)],
|
||||
vec![ProtocolCmd::ExecPreprocess {
|
||||
path: "f/test/pre".to_string(),
|
||||
args: serde_json::json!({"x": 5}),
|
||||
}],
|
||||
);
|
||||
assert_eq!(results.len(), 2);
|
||||
assert_eq!(
|
||||
results[0],
|
||||
DedicatedWorkerResult::PreprocessedArgs(serde_json::json!({"x": 50}))
|
||||
);
|
||||
// main(50) => 51
|
||||
assert_eq!(
|
||||
results[1],
|
||||
DedicatedWorkerResult::Success(serde_json::json!(51))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_python_exec_preprocess_missing_preprocessor() {
|
||||
let script = "def main(x: int):\n return x\n";
|
||||
let results = run_py_raw_protocol_test(
|
||||
&[("f/test/nopre", script)],
|
||||
vec![ProtocolCmd::ExecPreprocess {
|
||||
path: "f/test/nopre".to_string(),
|
||||
args: serde_json::json!({"x": 5}),
|
||||
}],
|
||||
);
|
||||
assert_eq!(results.len(), 1);
|
||||
assert!(matches!(results[0], DedicatedWorkerResult::Error(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_python_exec_preprocess_then_exec() {
|
||||
let script = r#"
|
||||
def preprocessor(x: int):
|
||||
return {"x": x * 2}
|
||||
|
||||
def main(x: int):
|
||||
return x + 100
|
||||
"#;
|
||||
let results = run_py_raw_protocol_test(
|
||||
&[("f/test/mixed", script)],
|
||||
vec![
|
||||
ProtocolCmd::ExecPreprocess {
|
||||
path: "f/test/mixed".to_string(),
|
||||
args: serde_json::json!({"x": 5}),
|
||||
},
|
||||
ProtocolCmd::Exec {
|
||||
path: "f/test/mixed".to_string(),
|
||||
args: serde_json::json!({"x": 7}),
|
||||
},
|
||||
],
|
||||
);
|
||||
// preprocess: preprocessor(5) => {"x":10}, main(10) => 110
|
||||
// exec: main(7) => 107
|
||||
assert_eq!(results.len(), 3);
|
||||
assert_eq!(
|
||||
results[0],
|
||||
DedicatedWorkerResult::PreprocessedArgs(serde_json::json!({"x": 10}))
|
||||
);
|
||||
assert_eq!(
|
||||
results[1],
|
||||
DedicatedWorkerResult::Success(serde_json::json!(110))
|
||||
);
|
||||
assert_eq!(
|
||||
results[2],
|
||||
DedicatedWorkerResult::Success(serde_json::json!(107))
|
||||
);
|
||||
}
|
||||
|
||||
// ==================== Argument Transformation Tests ====================
|
||||
|
||||
#[test]
|
||||
fn test_python_datetime_arg_transformation() {
|
||||
let script = r#"
|
||||
from datetime import datetime
|
||||
|
||||
def main(d: datetime):
|
||||
return d.isoformat()
|
||||
"#;
|
||||
let results = run_py_single_script_test(
|
||||
"f/test/dt",
|
||||
script,
|
||||
vec![serde_json::json!({"d": "2024-01-15T10:30:00+00:00"})],
|
||||
);
|
||||
assert_eq!(results.len(), 1);
|
||||
assert_eq!(
|
||||
results[0],
|
||||
Ok(serde_json::json!("2024-01-15T10:30:00+00:00"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_python_bytes_arg_transformation() {
|
||||
let script = r#"
|
||||
def main(data: bytes):
|
||||
return len(data)
|
||||
"#;
|
||||
// base64 of "hello" is "aGVsbG8="
|
||||
let results = run_py_single_script_test(
|
||||
"f/test/bytes",
|
||||
script,
|
||||
vec![serde_json::json!({"data": "aGVsbG8="})],
|
||||
);
|
||||
assert_eq!(results.len(), 1);
|
||||
assert_eq!(results[0], Ok(serde_json::json!(5)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_python_kwargs_filtering() {
|
||||
// Test that extra kwargs are filtered out and only declared args are passed
|
||||
let script = "def main(a: int, b: int):\n return a + b\n";
|
||||
let results = run_py_single_script_test(
|
||||
"f/test/kwargs",
|
||||
script,
|
||||
vec![serde_json::json!({"a": 1, "b": 2, "extra": 99})],
|
||||
);
|
||||
assert_eq!(results.len(), 1);
|
||||
assert_eq!(results[0], Ok(serde_json::json!(3)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_python_function_call_sentinel_removal() {
|
||||
// Test that '<function call>' sentinel values are removed from args
|
||||
let script = "def main(a: int, b: int = 10):\n return a + b\n";
|
||||
let results = run_py_single_script_test(
|
||||
"f/test/sentinel",
|
||||
script,
|
||||
vec![serde_json::json!({"a": 5, "b": "<function call>"})],
|
||||
);
|
||||
assert_eq!(results.len(), 1);
|
||||
// b should be removed (sentinel), default 10 used
|
||||
assert_eq!(results[0], Ok(serde_json::json!(15)));
|
||||
}
|
||||
|
||||
// ==================== Relative Import Tests ====================
|
||||
|
||||
#[test]
|
||||
fn test_python_dedicated_worker_with_relative_import_detection() {
|
||||
// Test that the wrapper includes 'import loader' when scripts have relative imports
|
||||
let script_with_relative = "from f.helper import util\ndef main(x: int):\n return x\n";
|
||||
let cg = compute_py_codegen(script_with_relative, "f/test/rel");
|
||||
let entries = [PyScriptEntry { original_path: "f/test/rel", codegen: &cg }];
|
||||
let wrapper = generate_py_multi_script_wrapper(&entries, false, true);
|
||||
assert!(
|
||||
wrapper.contains("import loader"),
|
||||
"wrapper should contain 'import loader' when any_relative_imports=true"
|
||||
);
|
||||
|
||||
// Without relative imports
|
||||
let script_no_relative = "def main(x: int):\n return x\n";
|
||||
let cg2 = compute_py_codegen(script_no_relative, "f/test/norel");
|
||||
let entries2 = [PyScriptEntry { original_path: "f/test/norel", codegen: &cg2 }];
|
||||
let wrapper2 = generate_py_multi_script_wrapper(&entries2, false, false);
|
||||
assert!(
|
||||
!wrapper2.contains("import loader"),
|
||||
"wrapper should NOT contain 'import loader' when any_relative_imports=false"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "python")]
|
||||
#[sqlx::test(fixtures("base", "lockfile_python"))]
|
||||
async fn test_requirements_python(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
#[cfg(feature = "enterprise")]
|
||||
use crate::ee_oss::ExternalJwks;
|
||||
use axum::{
|
||||
async_trait,
|
||||
extract::{FromRequestParts, OriginalUri, Query},
|
||||
Extension, Json,
|
||||
};
|
||||
@@ -451,7 +450,11 @@ pub(crate) async fn extract_token<S: Send + Sync>(parts: &mut Parts, state: &S)
|
||||
None => Extension::<Cookies>::from_request_parts(parts, state)
|
||||
.await
|
||||
.ok()
|
||||
.and_then(|cookies| cookies.get(COOKIE_NAME).map(|c| c.value().to_owned())),
|
||||
.and_then(|cookies| {
|
||||
cookies
|
||||
.get(COOKIE_NAME)
|
||||
.map(|c| c.value_trimmed().to_owned())
|
||||
}),
|
||||
};
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -504,7 +507,6 @@ impl BruteForceCounter {
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl<S> FromRequestParts<S> for Tokened
|
||||
where
|
||||
S: Send + Sync,
|
||||
@@ -535,7 +537,6 @@ where
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl<S> FromRequestParts<S> for OptTokened
|
||||
where
|
||||
S: Send + Sync,
|
||||
|
||||
@@ -12,8 +12,7 @@ pub mod ee;
|
||||
pub mod ee_oss;
|
||||
pub mod scopes;
|
||||
|
||||
use axum::async_trait;
|
||||
use axum::extract::FromRequestParts;
|
||||
use axum::extract::{FromRequestParts, OptionalFromRequestParts};
|
||||
use http::request::Parts;
|
||||
|
||||
use windmill_audit::audit_oss::AuditAuthorable;
|
||||
@@ -345,7 +344,6 @@ pub async fn maybe_refresh_folders(
|
||||
|
||||
// ------------ FromRequestParts impls (direct call to auth module) ------------
|
||||
|
||||
#[async_trait]
|
||||
impl<S> FromRequestParts<S> for ApiAuthed
|
||||
where
|
||||
S: Send + Sync,
|
||||
@@ -361,7 +359,24 @@ where
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl<S> OptionalFromRequestParts<S> for ApiAuthed
|
||||
where
|
||||
S: Send + Sync,
|
||||
{
|
||||
type Rejection = std::convert::Infallible;
|
||||
|
||||
async fn from_request_parts(
|
||||
parts: &mut Parts,
|
||||
state: &S,
|
||||
) -> std::result::Result<Option<Self>, Self::Rejection> {
|
||||
Ok(
|
||||
<Self as FromRequestParts<S>>::from_request_parts(parts, state)
|
||||
.await
|
||||
.ok(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl<S> FromRequestParts<S> for OptJobAuthed
|
||||
where
|
||||
S: Send + Sync,
|
||||
@@ -397,7 +412,6 @@ fn empty_parts() -> Parts {
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct OptAuthed(pub Option<ApiAuthed>);
|
||||
|
||||
#[async_trait]
|
||||
impl<S> FromRequestParts<S> for OptAuthed
|
||||
where
|
||||
S: Send + Sync,
|
||||
@@ -408,7 +422,7 @@ where
|
||||
parts: &mut Parts,
|
||||
state: &S,
|
||||
) -> std::result::Result<Self, Self::Rejection> {
|
||||
ApiAuthed::from_request_parts(parts, state)
|
||||
<ApiAuthed as FromRequestParts<S>>::from_request_parts(parts, state)
|
||||
.await
|
||||
.map(|authed| Self(Some(authed)))
|
||||
.or_else(|_| Ok(Self(None)))
|
||||
|
||||
@@ -28,11 +28,11 @@ use windmill_api_auth::{require_devops_role, ApiAuthed};
|
||||
pub fn global_service() -> Router {
|
||||
Router::new()
|
||||
.route("/list_worker_groups", get(list_worker_groups))
|
||||
.route("/update/:name", post(update_config).delete(delete_config))
|
||||
.route("/get/:name", get(get_config))
|
||||
.route("/update/{name}", post(update_config).delete(delete_config))
|
||||
.route("/get/{name}", get(get_config))
|
||||
.route("/list", get(list_configs))
|
||||
.route(
|
||||
"/list_autoscaling_events/:worker_group",
|
||||
"/list_autoscaling_events/{worker_group}",
|
||||
get(list_autoscaling_events),
|
||||
)
|
||||
.route(
|
||||
|
||||
@@ -87,6 +87,7 @@ pub fn workspaced_service() -> Router {
|
||||
Router::new()
|
||||
.route("/sign", post(sign_debug_request))
|
||||
.route("/sign_expression", post(sign_expression))
|
||||
.route("/sign_multiplayer", post(sign_multiplayer))
|
||||
}
|
||||
|
||||
/// JWKS response containing the public key for debug token verification
|
||||
@@ -416,3 +417,62 @@ async fn sign_expression(
|
||||
|
||||
Ok(Json(SignedExpressionPayload { token }))
|
||||
}
|
||||
|
||||
/// JWT claims for multiplayer session tokens
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct MultiplayerTokenClaims {
|
||||
/// Workspace ID
|
||||
pub workspace_id: String,
|
||||
/// User email
|
||||
pub email: String,
|
||||
/// Issued at (Unix timestamp)
|
||||
pub iat: i64,
|
||||
/// Expiration (Unix timestamp)
|
||||
pub exp: i64,
|
||||
/// Token purpose (always "multiplayer")
|
||||
pub purpose: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct SignedMultiplayerPayload {
|
||||
pub token: String,
|
||||
}
|
||||
|
||||
/// Sign a multiplayer session request.
|
||||
///
|
||||
/// Returns a JWT that the multiplayer server will verify using the public key from /api/debug/jwks.
|
||||
async fn sign_multiplayer(
|
||||
authed: ApiAuthed,
|
||||
Path(w_id): Path<String>,
|
||||
) -> JsonResult<SignedMultiplayerPayload> {
|
||||
let key_guard = DEBUG_SIGNING_KEY.read().await;
|
||||
let signing_key = key_guard.as_ref().ok_or_else(|| {
|
||||
windmill_common::error::Error::InternalErr("Debug signing key not initialized".to_string())
|
||||
})?;
|
||||
|
||||
let now_ts = Utc::now().timestamp();
|
||||
let exp = now_ts + DEBUG_TOKEN_TTL_SECS;
|
||||
|
||||
let claims = MultiplayerTokenClaims {
|
||||
workspace_id: w_id,
|
||||
email: authed.email,
|
||||
iat: now_ts,
|
||||
exp,
|
||||
purpose: "multiplayer".to_string(),
|
||||
};
|
||||
|
||||
let header = serde_json::json!({
|
||||
"alg": "EdDSA",
|
||||
"typ": "JWT"
|
||||
});
|
||||
let header_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_string(&header).unwrap());
|
||||
let claims_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_string(&claims).unwrap());
|
||||
let message = format!("{}.{}", header_b64, claims_b64);
|
||||
|
||||
let signature = signing_key.sign(message.as_bytes());
|
||||
let signature_b64 = URL_SAFE_NO_PAD.encode(signature.to_bytes());
|
||||
|
||||
let token = format!("{}.{}", message, signature_b64);
|
||||
|
||||
Ok(Json(SignedMultiplayerPayload { token }))
|
||||
}
|
||||
|
||||
@@ -21,8 +21,8 @@ use windmill_common::{
|
||||
pub fn workspaced_service() -> Router {
|
||||
Router::new()
|
||||
.route("/list", get(list_conversations))
|
||||
.route("/delete/:conversation_id", delete(delete_conversation))
|
||||
.route("/:conversation_id/messages", get(list_messages))
|
||||
.route("/delete/{conversation_id}", delete(delete_conversation))
|
||||
.route("/{conversation_id}/messages", get(list_messages))
|
||||
}
|
||||
|
||||
#[derive(Serialize, FromRow, Debug)]
|
||||
|
||||
@@ -61,26 +61,26 @@ pub fn workspaced_service() -> Router {
|
||||
.route("/list", get(list_flows))
|
||||
.route("/list_search", get(list_search_flows))
|
||||
.route("/create", post(create_flow))
|
||||
.route("/update/*path", post(update_flow))
|
||||
.route("/archive/*path", post(archive_flow_by_path))
|
||||
.route("/delete/*path", delete(delete_flow_by_path))
|
||||
.route("/list_tokens/*path", get(list_tokens))
|
||||
.route("/get/*path", get(get_flow_by_path))
|
||||
.route("/deployment_status/p/*path", get(get_deployment_status))
|
||||
.route("/get/draft/*path", get(get_flow_by_path_w_draft))
|
||||
.route("/exists/*path", get(exists_flow_by_path))
|
||||
.route("/update/{*path}", post(update_flow))
|
||||
.route("/archive/{*path}", post(archive_flow_by_path))
|
||||
.route("/delete/{*path}", delete(delete_flow_by_path))
|
||||
.route("/list_tokens/{*path}", get(list_tokens))
|
||||
.route("/get/{*path}", get(get_flow_by_path))
|
||||
.route("/deployment_status/p/{*path}", get(get_deployment_status))
|
||||
.route("/get/draft/{*path}", get(get_flow_by_path_w_draft))
|
||||
.route("/exists/{*path}", get(exists_flow_by_path))
|
||||
.route("/list_paths", get(list_paths))
|
||||
.route("/history/p/*path", get(get_flow_history))
|
||||
.route("/get_latest_version/*path", get(get_latest_version))
|
||||
.route("/history/p/{*path}", get(get_flow_history))
|
||||
.route("/get_latest_version/{*path}", get(get_latest_version))
|
||||
.route(
|
||||
"/list_paths_from_workspace_runnable/:runnable_kind/*path",
|
||||
"/list_paths_from_workspace_runnable/{runnable_kind}/{*path}",
|
||||
get(list_paths_from_workspace_runnable),
|
||||
)
|
||||
.route("/history_update/v/:version", post(update_flow_history))
|
||||
.route("/get/v/:version", get(get_flow_version_by_id))
|
||||
.route("/get/v/:version/p/*path", get(get_flow_version))
|
||||
.route("/history_update/v/{version}", post(update_flow_history))
|
||||
.route("/get/v/{version}", get(get_flow_version_by_id))
|
||||
.route("/get/v/{version}/p/{*path}", get(get_flow_version))
|
||||
.route(
|
||||
"/toggle_workspace_error_handler/*path",
|
||||
"/toggle_workspace_error_handler/{*path}",
|
||||
post(toggle_workspace_error_handler),
|
||||
)
|
||||
}
|
||||
@@ -88,7 +88,7 @@ pub fn workspaced_service() -> Router {
|
||||
pub fn global_service() -> Router {
|
||||
Router::new()
|
||||
.route("/hub/list", get(list_hub_flows))
|
||||
.route("/hub/get/:id", get(get_hub_flow_by_id))
|
||||
.route("/hub/get/{id}", get(get_hub_flow_by_id))
|
||||
}
|
||||
|
||||
#[derive(Serialize, FromRow)]
|
||||
@@ -1657,6 +1657,38 @@ async fn delete_flow_by_path(
|
||||
}
|
||||
let mut tx = user_db.begin(&authed).await?;
|
||||
|
||||
// Capture all related data for trashbin before deleting (CASCADE will remove flow_version, flow_node)
|
||||
let trash_flow: Option<serde_json::Value> =
|
||||
sqlx::query_scalar("SELECT to_jsonb(t) FROM flow t WHERE path = $1 AND workspace_id = $2")
|
||||
.bind(path)
|
||||
.bind(&w_id)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?;
|
||||
|
||||
let trash_flow_versions: Vec<serde_json::Value> = sqlx::query_scalar(
|
||||
"SELECT to_jsonb(t) FROM flow_version t WHERE path = $1 AND workspace_id = $2",
|
||||
)
|
||||
.bind(path)
|
||||
.bind(&w_id)
|
||||
.fetch_all(&mut *tx)
|
||||
.await?;
|
||||
|
||||
let trash_flow_nodes: Vec<serde_json::Value> = sqlx::query_scalar(
|
||||
"SELECT to_jsonb(t) FROM flow_node t WHERE path = $1 AND workspace_id = $2",
|
||||
)
|
||||
.bind(path)
|
||||
.bind(&w_id)
|
||||
.fetch_all(&mut *tx)
|
||||
.await?;
|
||||
|
||||
let trash_drafts: Vec<serde_json::Value> = sqlx::query_scalar(
|
||||
"SELECT to_jsonb(t) FROM draft t WHERE path = $1 AND workspace_id = $2 AND typ = 'flow'",
|
||||
)
|
||||
.bind(path)
|
||||
.bind(&w_id)
|
||||
.fetch_all(&mut *tx)
|
||||
.await?;
|
||||
|
||||
sqlx::query!(
|
||||
"DELETE FROM draft WHERE path = $1 AND workspace_id = $2 AND typ = 'flow'",
|
||||
path,
|
||||
@@ -1673,6 +1705,28 @@ async fn delete_flow_by_path(
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
if let Some(flow_data) = trash_flow {
|
||||
let mut trash_data = serde_json::json!({"row": flow_data});
|
||||
if !trash_flow_versions.is_empty() {
|
||||
trash_data["flow_versions"] = serde_json::Value::Array(trash_flow_versions);
|
||||
}
|
||||
if !trash_flow_nodes.is_empty() {
|
||||
trash_data["flow_nodes"] = serde_json::Value::Array(trash_flow_nodes);
|
||||
}
|
||||
if !trash_drafts.is_empty() {
|
||||
trash_data["drafts"] = serde_json::Value::Array(trash_drafts);
|
||||
}
|
||||
windmill_common::trashbin::move_to_trash(
|
||||
&mut *tx,
|
||||
&w_id,
|
||||
"flow",
|
||||
path,
|
||||
trash_data,
|
||||
&authed.username,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
if !query.keep_captures.unwrap_or(false) {
|
||||
sqlx::query!(
|
||||
"DELETE FROM capture_config WHERE path = $1 AND workspace_id = $2 AND is_flow IS TRUE",
|
||||
|
||||
@@ -22,7 +22,7 @@ use serde::Serialize;
|
||||
use sqlx::FromRow;
|
||||
|
||||
pub fn workspaced_service() -> Router {
|
||||
Router::new().route("/get/:name", get(get_folder_permission_history))
|
||||
Router::new().route("/get/{name}", get(get_folder_permission_history))
|
||||
}
|
||||
|
||||
#[derive(Serialize, FromRow)]
|
||||
|
||||
@@ -40,14 +40,14 @@ pub fn workspaced_service() -> Router {
|
||||
.route("/list", get(list_folders))
|
||||
.route("/listnames", get(list_foldernames))
|
||||
.route("/create", post(create_folder))
|
||||
.route("/get/:name", get(get_folder))
|
||||
.route("/exists/:name", get(exists_folder))
|
||||
.route("/update/:name", post(update_folder))
|
||||
.route("/getusage/:name", get(get_folder_usage))
|
||||
.route("/delete/:name", delete(delete_folder))
|
||||
.route("/addowner/:name", post(add_owner))
|
||||
.route("/removeowner/:name", post(remove_owner))
|
||||
.route("/is_owner/*path", get(is_owner_api))
|
||||
.route("/get/{name}", get(get_folder))
|
||||
.route("/exists/{name}", get(exists_folder))
|
||||
.route("/update/{name}", post(update_folder))
|
||||
.route("/getusage/{name}", get(get_folder_usage))
|
||||
.route("/delete/{name}", delete(delete_folder))
|
||||
.route("/addowner/{name}", post(add_owner))
|
||||
.route("/removeowner/{name}", post(remove_owner))
|
||||
.route("/is_owner/{*path}", get(is_owner_api))
|
||||
}
|
||||
|
||||
#[derive(FromRow, Serialize, Deserialize, Clone)]
|
||||
|
||||
@@ -48,9 +48,9 @@ const KINDS: [&str; 19] = [
|
||||
|
||||
pub fn workspaced_service() -> Router {
|
||||
Router::new()
|
||||
.route("/get/*path", get(get_granular_acls))
|
||||
.route("/add/*path", post(add_granular_acl))
|
||||
.route("/remove/*path", post(remove_granular_acl))
|
||||
.route("/get/{*path}", get(get_granular_acls))
|
||||
.route("/add/{*path}", post(add_granular_acl))
|
||||
.route("/remove/{*path}", post(remove_granular_acl))
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
|
||||
@@ -33,24 +33,24 @@ pub fn workspaced_service() -> Router {
|
||||
.route("/list", get(list_groups))
|
||||
.route("/listnames", get(list_group_names))
|
||||
.route("/create", post(create_group))
|
||||
.route("/get/:name", get(get_group))
|
||||
.route("/update/:name", post(update_group))
|
||||
.route("/delete/:name", delete(delete_group))
|
||||
.route("/adduser/:name", post(add_user))
|
||||
.route("/removeuser/:name", post(remove_user))
|
||||
.route("/is_owner/:name", get(is_owner))
|
||||
.route("/get/{name}", get(get_group))
|
||||
.route("/update/{name}", post(update_group))
|
||||
.route("/delete/{name}", delete(delete_group))
|
||||
.route("/adduser/{name}", post(add_user))
|
||||
.route("/removeuser/{name}", post(remove_user))
|
||||
.route("/is_owner/{name}", get(is_owner))
|
||||
}
|
||||
|
||||
pub fn global_service() -> Router {
|
||||
Router::new()
|
||||
.route("/list", get(list_igroups))
|
||||
.route("/list_with_workspaces", get(list_igroups_with_workspaces))
|
||||
.route("/get/:name", get(get_igroup))
|
||||
.route("/get/{name}", get(get_igroup))
|
||||
.route("/create", post(create_igroup))
|
||||
.route("/update/:name", post(update_igroup))
|
||||
.route("/delete/:name", delete(delete_igroup))
|
||||
.route("/adduser/:name", post(add_user_igroup))
|
||||
.route("/removeuser/:name", post(remove_user_igroup))
|
||||
.route("/update/{name}", post(update_igroup))
|
||||
.route("/delete/{name}", delete(delete_igroup))
|
||||
.route("/adduser/{name}", post(add_user_igroup))
|
||||
.route("/removeuser/{name}", post(remove_user_igroup))
|
||||
.route("/export", get(export_igroups))
|
||||
.route("/overwrite", post(overwrite_igroups))
|
||||
}
|
||||
@@ -656,6 +656,7 @@ async fn delete_group(
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
audit_log(
|
||||
&mut *tx,
|
||||
&authed,
|
||||
|
||||
@@ -33,9 +33,9 @@ pub fn workspaced_service() -> Router {
|
||||
.route("/list", get(list_saved_inputs))
|
||||
.route("/create", post(create_input))
|
||||
.route("/update", post(update_input))
|
||||
.route("/delete/:id", post(delete_input))
|
||||
.route("/delete/{id}", post(delete_input))
|
||||
.route(
|
||||
"/:job_or_input_id/args",
|
||||
"/{job_or_input_id}/args",
|
||||
get(get_args_from_history_or_saved_input),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -31,6 +31,7 @@ reqwest.workspace = true
|
||||
tokio.workspace = true
|
||||
anyhow.workspace = true
|
||||
uuid.workspace = true
|
||||
futures.workspace = true
|
||||
rand.workspace = true
|
||||
rumqttc.workspace = true
|
||||
rdkafka.workspace = true
|
||||
@@ -39,3 +40,4 @@ aws-config = { workspace = true, optional = true }
|
||||
aws-credential-types = { workspace = true, optional = true }
|
||||
aws-sdk-sqs = { workspace = true, optional = true }
|
||||
base64 = { workspace = true, optional = true }
|
||||
axum.workspace = true
|
||||
|
||||
106
backend/windmill-api-integration-tests/tests/ai_routes.rs
Normal file
106
backend/windmill-api-integration-tests/tests/ai_routes.rs
Normal file
@@ -0,0 +1,106 @@
|
||||
use serde_json::json;
|
||||
use sqlx::{Pool, Postgres};
|
||||
use windmill_test_utils::*;
|
||||
|
||||
fn client() -> reqwest::Client {
|
||||
reqwest::Client::new()
|
||||
}
|
||||
|
||||
fn authed(builder: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
|
||||
builder.header("Authorization", "Bearer SECRET_TOKEN")
|
||||
}
|
||||
|
||||
fn assert_2xx(status: u16, body: &str, endpoint: &str) {
|
||||
assert!(
|
||||
(200..300).contains(&status),
|
||||
"{endpoint} returned {status}: {body}",
|
||||
);
|
||||
}
|
||||
|
||||
/// Start a mock AI API that echoes back a valid chat completion response.
|
||||
async fn start_mock_ai_api() -> u16 {
|
||||
use axum::{routing::post, Json, Router};
|
||||
|
||||
let app = Router::new().fallback(post(|| async {
|
||||
Json(json!({
|
||||
"id": "chatcmpl-test",
|
||||
"object": "chat.completion",
|
||||
"choices": [{"message": {"role": "assistant", "content": "hello"}}]
|
||||
}))
|
||||
}));
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let port = listener.local_addr().unwrap().port();
|
||||
tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
|
||||
port
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
|
||||
async fn test_ai_proxy_endpoints(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
initialize_tracing().await;
|
||||
let server = ApiServer::start(db.clone()).await?;
|
||||
let port = server.addr.port();
|
||||
|
||||
// Start mock AI API
|
||||
let mock_port = start_mock_ai_api().await;
|
||||
let mock_url = format!("http://127.0.0.1:{mock_port}/v1");
|
||||
|
||||
// Create an openai resource pointing to the mock
|
||||
let resp = authed(
|
||||
client()
|
||||
.post(format!(
|
||||
"http://localhost:{port}/api/w/test-workspace/resources/create"
|
||||
))
|
||||
.json(&json!({
|
||||
"path": "f/ai/openai_config",
|
||||
"resource_type": "openai",
|
||||
"value": {
|
||||
"api_key": "test-key",
|
||||
"base_url": mock_url
|
||||
}
|
||||
})),
|
||||
)
|
||||
.send()
|
||||
.await?;
|
||||
assert_2xx(
|
||||
resp.status().as_u16(),
|
||||
&resp.text().await?,
|
||||
"create openai resource",
|
||||
);
|
||||
|
||||
// Set ai_config on workspace_settings directly via SQL
|
||||
sqlx::query(
|
||||
"UPDATE workspace_settings SET ai_config = $1::jsonb WHERE workspace_id = 'test-workspace'",
|
||||
)
|
||||
.bind(json!({
|
||||
"providers": {
|
||||
"openai": {
|
||||
"resource_path": "f/ai/openai_config",
|
||||
"models": ["gpt-4"]
|
||||
}
|
||||
}
|
||||
}))
|
||||
.execute(&db)
|
||||
.await?;
|
||||
|
||||
// POST /w/{ws}/ai/proxy/chat/completions
|
||||
let resp = authed(
|
||||
client()
|
||||
.post(format!(
|
||||
"http://localhost:{port}/api/w/test-workspace/ai/proxy/chat/completions"
|
||||
))
|
||||
.header("X-Provider", "openai")
|
||||
.json(&json!({
|
||||
"model": "gpt-4",
|
||||
"messages": [{"role": "user", "content": "hi"}]
|
||||
})),
|
||||
)
|
||||
.send()
|
||||
.await?;
|
||||
assert_2xx(
|
||||
resp.status().as_u16(),
|
||||
&resp.text().await?,
|
||||
"POST /ai/proxy/chat/completions",
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
35
backend/windmill-api-integration-tests/tests/audit.rs
Normal file
35
backend/windmill-api-integration-tests/tests/audit.rs
Normal file
@@ -0,0 +1,35 @@
|
||||
use sqlx::{Pool, Postgres};
|
||||
use windmill_test_utils::*;
|
||||
|
||||
fn client() -> reqwest::Client {
|
||||
reqwest::Client::new()
|
||||
}
|
||||
|
||||
fn authed(builder: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
|
||||
builder.header("Authorization", "Bearer SECRET_TOKEN")
|
||||
}
|
||||
|
||||
fn assert_2xx(status: u16, body: &str, endpoint: &str) {
|
||||
assert!(
|
||||
(200..300).contains(&status),
|
||||
"{endpoint} returned {status}: {body}",
|
||||
);
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
|
||||
async fn test_audit_endpoints(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
initialize_tracing().await;
|
||||
let server = ApiServer::start(db.clone()).await?;
|
||||
let port = server.addr.port();
|
||||
let base = format!("http://localhost:{port}/api/w/test-workspace/audit");
|
||||
|
||||
// GET /list returns 200 (empty array)
|
||||
let resp = authed(client().get(format!("{base}/list"))).send().await?;
|
||||
assert_2xx(
|
||||
resp.status().as_u16(),
|
||||
&resp.text().await?,
|
||||
"GET /audit/list",
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
use serde_json::json;
|
||||
use sqlx::{Pool, Postgres};
|
||||
use windmill_test_utils::*;
|
||||
|
||||
fn client() -> reqwest::Client {
|
||||
reqwest::Client::new()
|
||||
}
|
||||
|
||||
fn authed(builder: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
|
||||
builder.header("Authorization", "Bearer SECRET_TOKEN")
|
||||
}
|
||||
|
||||
fn assert_2xx(status: u16, body: &str, endpoint: &str) {
|
||||
assert!(
|
||||
(200..300).contains(&status),
|
||||
"{endpoint} returned {status}: {body}",
|
||||
);
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
|
||||
async fn test_capture_endpoints(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
initialize_tracing().await;
|
||||
let server = ApiServer::start(db.clone()).await?;
|
||||
let port = server.addr.port();
|
||||
|
||||
// POST /capture/set_config → 200 (authed)
|
||||
let resp = authed(
|
||||
client()
|
||||
.post(format!(
|
||||
"http://localhost:{port}/api/w/test-workspace/capture/set_config"
|
||||
))
|
||||
.json(&json!({
|
||||
"trigger_kind": "webhook",
|
||||
"path": "u/test-user/test_capture",
|
||||
"is_flow": false
|
||||
})),
|
||||
)
|
||||
.send()
|
||||
.await?;
|
||||
let status = resp.status().as_u16();
|
||||
let body = resp.text().await?;
|
||||
assert_2xx(status, &body, "POST /capture/set_config");
|
||||
|
||||
// GET /capture/list/{...} → 200 (authed)
|
||||
let resp = authed(client().get(format!(
|
||||
"http://localhost:{port}/api/w/test-workspace/capture/list/script/u/test-user/test_capture"
|
||||
)))
|
||||
.send()
|
||||
.await?;
|
||||
let status = resp.status().as_u16();
|
||||
let body = resp.text().await?;
|
||||
assert_2xx(
|
||||
status,
|
||||
&body,
|
||||
"GET /capture/list/script/u/test-user/test_capture",
|
||||
);
|
||||
|
||||
// POST /capture/ping_config/{trigger_kind}/{runnable_kind}/{*path} → 200
|
||||
let resp = authed(client().post(format!(
|
||||
"http://localhost:{port}/api/w/test-workspace/capture/ping_config/webhook/script/u/test-user/test_capture"
|
||||
)))
|
||||
.send()
|
||||
.await?;
|
||||
assert_2xx(
|
||||
resp.status().as_u16(),
|
||||
&resp.text().await?,
|
||||
"POST /capture/ping_config",
|
||||
);
|
||||
|
||||
// GET /capture/get_configs/{runnable_kind}/{*path} → 200
|
||||
let resp = authed(client().get(format!(
|
||||
"http://localhost:{port}/api/w/test-workspace/capture/get_configs/script/u/test-user/test_capture"
|
||||
)))
|
||||
.send()
|
||||
.await?;
|
||||
assert_2xx(
|
||||
resp.status().as_u16(),
|
||||
&resp.text().await?,
|
||||
"GET /capture/get_configs",
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
use sqlx::{Pool, Postgres};
|
||||
use windmill_test_utils::*;
|
||||
|
||||
fn client() -> reqwest::Client {
|
||||
reqwest::Client::new()
|
||||
}
|
||||
|
||||
fn authed(builder: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
|
||||
builder.header("Authorization", "Bearer SECRET_TOKEN")
|
||||
}
|
||||
|
||||
fn assert_2xx(status: u16, body: &str, endpoint: &str) {
|
||||
assert!(
|
||||
(200..300).contains(&status),
|
||||
"{endpoint} returned {status}: {body}",
|
||||
);
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
|
||||
async fn test_concurrency_groups_2xx(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
initialize_tracing().await;
|
||||
let server = ApiServer::start(db.clone()).await?;
|
||||
let port = server.addr.port();
|
||||
|
||||
let resp = authed(client().get(format!(
|
||||
"http://localhost:{port}/api/concurrency_groups/list"
|
||||
)))
|
||||
.send()
|
||||
.await?;
|
||||
assert_2xx(
|
||||
resp.status().as_u16(),
|
||||
&resp.text().await?,
|
||||
"GET /api/concurrency_groups/list",
|
||||
);
|
||||
|
||||
let resp = authed(client().get(format!(
|
||||
"http://localhost:{port}/api/w/test-workspace/concurrency_groups/list_jobs"
|
||||
)))
|
||||
.send()
|
||||
.await?;
|
||||
assert_2xx(
|
||||
resp.status().as_u16(),
|
||||
&resp.text().await?,
|
||||
"GET /api/w/test-workspace/concurrency_groups/list_jobs",
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
72
backend/windmill-api-integration-tests/tests/favorites.rs
Normal file
72
backend/windmill-api-integration-tests/tests/favorites.rs
Normal file
@@ -0,0 +1,72 @@
|
||||
use serde_json::json;
|
||||
use sqlx::{Pool, Postgres};
|
||||
use windmill_test_utils::*;
|
||||
|
||||
fn client() -> reqwest::Client {
|
||||
reqwest::Client::new()
|
||||
}
|
||||
|
||||
fn authed(builder: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
|
||||
builder.header("Authorization", "Bearer SECRET_TOKEN")
|
||||
}
|
||||
|
||||
fn assert_2xx(status: u16, body: &str, endpoint: &str) {
|
||||
assert!(
|
||||
(200..300).contains(&status),
|
||||
"{endpoint} returned {status}: {body}",
|
||||
);
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
|
||||
async fn test_favorites_endpoints(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
initialize_tracing().await;
|
||||
let server = ApiServer::start(db.clone()).await?;
|
||||
let port = server.addr.port();
|
||||
let ws = format!("http://localhost:{port}/api/w/test-workspace");
|
||||
|
||||
// Setup: create a script to favorite
|
||||
let resp = authed(client().post(format!("{ws}/scripts/create")))
|
||||
.json(&json!({
|
||||
"path": "u/test-user/test_fav_script",
|
||||
"summary": "test",
|
||||
"description": "",
|
||||
"content": "export function main() { return 1; }",
|
||||
"language": "deno",
|
||||
"schema": {
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
"required": []
|
||||
}
|
||||
}))
|
||||
.send()
|
||||
.await?;
|
||||
let status = resp.status().as_u16();
|
||||
let body = resp.text().await?;
|
||||
assert_2xx(status, &body, "POST /scripts/create (setup)");
|
||||
|
||||
let fav_body = json!({
|
||||
"favorite_kind": "script",
|
||||
"path": "u/test-user/test_fav_script"
|
||||
});
|
||||
|
||||
// POST /favorites/star → 200
|
||||
let resp = authed(client().post(format!("{ws}/favorites/star")))
|
||||
.json(&fav_body)
|
||||
.send()
|
||||
.await?;
|
||||
let status = resp.status().as_u16();
|
||||
let body = resp.text().await?;
|
||||
assert_2xx(status, &body, "POST /favorites/star");
|
||||
|
||||
// POST /favorites/unstar → 200
|
||||
let resp = authed(client().post(format!("{ws}/favorites/unstar")))
|
||||
.json(&fav_body)
|
||||
.send()
|
||||
.await?;
|
||||
let status = resp.status().as_u16();
|
||||
let body = resp.text().await?;
|
||||
assert_2xx(status, &body, "POST /favorites/unstar");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
use serde_json::json;
|
||||
use sqlx::{Pool, Postgres};
|
||||
use windmill_test_utils::*;
|
||||
|
||||
fn client() -> reqwest::Client {
|
||||
reqwest::Client::new()
|
||||
}
|
||||
|
||||
fn authed(builder: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
|
||||
builder.header("Authorization", "Bearer SECRET_TOKEN")
|
||||
}
|
||||
|
||||
fn assert_2xx(status: u16, body: &str, endpoint: &str) {
|
||||
assert!(
|
||||
(200..300).contains(&status),
|
||||
"{endpoint} returned {status}: {body}",
|
||||
);
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
|
||||
async fn test_folder_history_endpoints(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
initialize_tracing().await;
|
||||
let server = ApiServer::start(db.clone()).await?;
|
||||
let port = server.addr.port();
|
||||
|
||||
// Create a folder first
|
||||
let resp = authed(
|
||||
client()
|
||||
.post(format!(
|
||||
"http://localhost:{port}/api/w/test-workspace/folders/create"
|
||||
))
|
||||
.json(&json!({"name": "test_hist_folder", "owners": ["u/test-user"]})),
|
||||
)
|
||||
.send()
|
||||
.await?;
|
||||
let status = resp.status().as_u16();
|
||||
let body = resp.text().await?;
|
||||
assert_2xx(status, &body, "POST /folders/create");
|
||||
|
||||
// GET /folders_history/get/{folder} → 200 (empty array)
|
||||
let resp = authed(client().get(format!(
|
||||
"http://localhost:{port}/api/w/test-workspace/folders_history/get/test_hist_folder"
|
||||
)))
|
||||
.send()
|
||||
.await?;
|
||||
let status = resp.status().as_u16();
|
||||
let body = resp.text().await?;
|
||||
assert_2xx(status, &body, "GET /folders_history/get/test_hist_folder");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
use serde_json::json;
|
||||
use sqlx::{Pool, Postgres};
|
||||
use windmill_test_utils::*;
|
||||
|
||||
fn client() -> reqwest::Client {
|
||||
reqwest::Client::new()
|
||||
}
|
||||
|
||||
fn authed(builder: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
|
||||
builder.header("Authorization", "Bearer SECRET_TOKEN")
|
||||
}
|
||||
|
||||
fn assert_2xx(status: u16, body: &str, endpoint: &str) {
|
||||
assert!(
|
||||
(200..300).contains(&status),
|
||||
"{endpoint} returned {status}: {body}",
|
||||
);
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
|
||||
async fn test_granular_acls_endpoints(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
initialize_tracing().await;
|
||||
let server = ApiServer::start(db.clone()).await?;
|
||||
let port = server.addr.port();
|
||||
let base = format!("http://localhost:{port}/api/w/test-workspace/acls");
|
||||
|
||||
// GET /acls/get/group_/all → 200
|
||||
let resp = authed(client().get(format!("{base}/get/group_/all")))
|
||||
.send()
|
||||
.await?;
|
||||
let status = resp.status().as_u16();
|
||||
let body = resp.text().await?;
|
||||
assert_2xx(status, &body, "GET /acls/get/group_/all");
|
||||
|
||||
// POST /acls/add/group_/all → 200
|
||||
let resp = authed(client().post(format!("{base}/add/group_/all")))
|
||||
.json(&json!({"owner": "u/test-user-2", "write": true}))
|
||||
.send()
|
||||
.await?;
|
||||
let status = resp.status().as_u16();
|
||||
let body = resp.text().await?;
|
||||
assert_2xx(status, &body, "POST /acls/add/group_/all");
|
||||
|
||||
// POST /acls/remove/group_/all → 200
|
||||
let resp = authed(client().post(format!("{base}/remove/group_/all")))
|
||||
.json(&json!({"owner": "u/test-user-2"}))
|
||||
.send()
|
||||
.await?;
|
||||
let status = resp.status().as_u16();
|
||||
let body = resp.text().await?;
|
||||
assert_2xx(status, &body, "POST /acls/remove/group_/all");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
use sqlx::{Pool, Postgres};
|
||||
use windmill_test_utils::*;
|
||||
|
||||
fn client() -> reqwest::Client {
|
||||
reqwest::Client::new()
|
||||
}
|
||||
|
||||
fn authed(builder: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
|
||||
builder.header("Authorization", "Bearer SECRET_TOKEN")
|
||||
}
|
||||
|
||||
fn assert_2xx(status: u16, body: &str, endpoint: &str) {
|
||||
assert!(
|
||||
(200..300).contains(&status),
|
||||
"{endpoint} returned {status}: {body}",
|
||||
);
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
|
||||
async fn test_group_history_2xx(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
initialize_tracing().await;
|
||||
let server = ApiServer::start(db.clone()).await?;
|
||||
let port = server.addr.port();
|
||||
let base = format!("http://localhost:{port}/api/w/test-workspace/groups_history");
|
||||
|
||||
let resp = authed(client().get(format!("{base}/get/all")))
|
||||
.send()
|
||||
.await?;
|
||||
assert_2xx(resp.status().as_u16(), &resp.text().await?, "GET /get/all");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
41
backend/windmill-api-integration-tests/tests/health.rs
Normal file
41
backend/windmill-api-integration-tests/tests/health.rs
Normal file
@@ -0,0 +1,41 @@
|
||||
use sqlx::{Pool, Postgres};
|
||||
use windmill_test_utils::*;
|
||||
|
||||
fn client() -> reqwest::Client {
|
||||
reqwest::Client::new()
|
||||
}
|
||||
|
||||
fn authed(builder: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
|
||||
builder.header("Authorization", "Bearer SECRET_TOKEN")
|
||||
}
|
||||
|
||||
fn assert_2xx(status: u16, body: &str, endpoint: &str) {
|
||||
assert!(
|
||||
(200..300).contains(&status),
|
||||
"{endpoint} returned {status}: {body}",
|
||||
);
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
|
||||
async fn test_health_endpoints(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
initialize_tracing().await;
|
||||
let server = ApiServer::start(db.clone()).await?;
|
||||
let port = server.addr.port();
|
||||
let base = format!("http://localhost:{port}/api/health");
|
||||
|
||||
// GET /health/status → 200 (no auth required)
|
||||
let resp = client().get(format!("{base}/status")).send().await?;
|
||||
let status = resp.status().as_u16();
|
||||
let body = resp.text().await?;
|
||||
assert_2xx(status, &body, "GET /health/status");
|
||||
|
||||
// GET /health/detailed → 200 (authed)
|
||||
let resp = authed(client().get(format!("{base}/detailed")))
|
||||
.send()
|
||||
.await?;
|
||||
let status = resp.status().as_u16();
|
||||
let body = resp.text().await?;
|
||||
assert_2xx(status, &body, "GET /health/detailed");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
76
backend/windmill-api-integration-tests/tests/inputs.rs
Normal file
76
backend/windmill-api-integration-tests/tests/inputs.rs
Normal file
@@ -0,0 +1,76 @@
|
||||
use serde_json::json;
|
||||
use sqlx::{Pool, Postgres};
|
||||
use windmill_test_utils::*;
|
||||
|
||||
fn client() -> reqwest::Client {
|
||||
reqwest::Client::new()
|
||||
}
|
||||
|
||||
fn authed(builder: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
|
||||
builder.header("Authorization", "Bearer SECRET_TOKEN")
|
||||
}
|
||||
|
||||
fn assert_2xx(status: u16, body: &str, endpoint: &str) {
|
||||
assert!(
|
||||
(200..300).contains(&status),
|
||||
"{endpoint} returned {status}: {body}",
|
||||
);
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
|
||||
async fn test_inputs_endpoints(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
initialize_tracing().await;
|
||||
let server = ApiServer::start(db.clone()).await?;
|
||||
let port = server.addr.port();
|
||||
let base = format!("http://localhost:{port}/api/w/test-workspace/inputs");
|
||||
|
||||
// GET /history with fake runnable → 200 empty array
|
||||
let resp = authed(client().get(format!(
|
||||
"{base}/history?runnable_id=u/test-user/test&runnable_type=ScriptPath"
|
||||
)))
|
||||
.send()
|
||||
.await?;
|
||||
let status = resp.status().as_u16();
|
||||
let body = resp.text().await?;
|
||||
assert_2xx(status, &body, "GET /inputs/history");
|
||||
|
||||
// GET /list with fake runnable → 200 empty array
|
||||
let resp = authed(client().get(format!(
|
||||
"{base}/list?runnable_id=u/test-user/test&runnable_type=ScriptPath"
|
||||
)))
|
||||
.send()
|
||||
.await?;
|
||||
let status = resp.status().as_u16();
|
||||
let body = resp.text().await?;
|
||||
assert_2xx(status, &body, "GET /inputs/list");
|
||||
|
||||
// POST /create → 200, returns UUID
|
||||
let resp = authed(client().post(format!(
|
||||
"{base}/create?runnable_id=u/test-user/test&runnable_type=ScriptPath"
|
||||
)))
|
||||
.json(&json!({"name": "test_input", "args": {}}))
|
||||
.send()
|
||||
.await?;
|
||||
let status = resp.status().as_u16();
|
||||
let body = resp.text().await?;
|
||||
assert_2xx(status, &body, "POST /inputs/create");
|
||||
let input_id: String = serde_json::from_str(&body)?;
|
||||
|
||||
// GET /{id}/args → 200
|
||||
let resp = authed(client().get(format!("{base}/{input_id}/args")))
|
||||
.send()
|
||||
.await?;
|
||||
let status = resp.status().as_u16();
|
||||
let body = resp.text().await?;
|
||||
assert_2xx(status, &body, "GET /inputs/{id}/args");
|
||||
|
||||
// POST /delete/{id} → 200
|
||||
let resp = authed(client().post(format!("{base}/delete/{input_id}")))
|
||||
.send()
|
||||
.await?;
|
||||
let status = resp.status().as_u16();
|
||||
let body = resp.text().await?;
|
||||
assert_2xx(status, &body, "POST /inputs/delete/{id}");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
59
backend/windmill-api-integration-tests/tests/job_metrics.rs
Normal file
59
backend/windmill-api-integration-tests/tests/job_metrics.rs
Normal file
@@ -0,0 +1,59 @@
|
||||
use serde_json::json;
|
||||
use sqlx::{Pool, Postgres};
|
||||
use windmill_test_utils::*;
|
||||
|
||||
fn client() -> reqwest::Client {
|
||||
reqwest::Client::new()
|
||||
}
|
||||
|
||||
fn authed(builder: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
|
||||
builder.header("Authorization", "Bearer SECRET_TOKEN")
|
||||
}
|
||||
|
||||
fn assert_2xx(status: u16, body: &str, endpoint: &str) {
|
||||
assert!(
|
||||
(200..300).contains(&status),
|
||||
"{endpoint} returned {status}: {body}",
|
||||
);
|
||||
}
|
||||
|
||||
const FAKE_UUID: &str = "00000000-0000-0000-0000-000000000000";
|
||||
|
||||
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
|
||||
async fn test_job_metrics_2xx(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
initialize_tracing().await;
|
||||
let server = ApiServer::start(db.clone()).await?;
|
||||
let port = server.addr.port();
|
||||
let base = format!("http://localhost:{port}/api/w/test-workspace/job_metrics");
|
||||
|
||||
let resp = authed(client().post(format!("{base}/get/{FAKE_UUID}")))
|
||||
.json(&json!({}))
|
||||
.send()
|
||||
.await?;
|
||||
assert_2xx(
|
||||
resp.status().as_u16(),
|
||||
&resp.text().await?,
|
||||
"POST /get/{id}",
|
||||
);
|
||||
|
||||
let resp = authed(client().post(format!("{base}/set_progress/{FAKE_UUID}")))
|
||||
.json(&json!({"percent": 50}))
|
||||
.send()
|
||||
.await?;
|
||||
assert_2xx(
|
||||
resp.status().as_u16(),
|
||||
&resp.text().await?,
|
||||
"POST /set_progress/{id}",
|
||||
);
|
||||
|
||||
let resp = authed(client().get(format!("{base}/get_progress/{FAKE_UUID}")))
|
||||
.send()
|
||||
.await?;
|
||||
assert_2xx(
|
||||
resp.status().as_u16(),
|
||||
&resp.text().await?,
|
||||
"GET /get_progress/{id}",
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
309
backend/windmill-api-integration-tests/tests/jobs_authed.rs
Normal file
309
backend/windmill-api-integration-tests/tests/jobs_authed.rs
Normal file
@@ -0,0 +1,309 @@
|
||||
use serde_json::json;
|
||||
use sqlx::{Pool, Postgres};
|
||||
use uuid::Uuid;
|
||||
use windmill_test_utils::*;
|
||||
|
||||
fn client() -> reqwest::Client {
|
||||
reqwest::Client::new()
|
||||
}
|
||||
|
||||
fn authed(builder: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
|
||||
builder.header("Authorization", "Bearer SECRET_TOKEN")
|
||||
}
|
||||
|
||||
fn assert_2xx(status: u16, body: &str, endpoint: &str) {
|
||||
assert!(
|
||||
(200..300).contains(&status),
|
||||
"{endpoint} returned {status}: {body}",
|
||||
);
|
||||
}
|
||||
|
||||
fn assert_route_reachable(status: u16, body: &str, endpoint: &str) {
|
||||
assert!(
|
||||
status != 404 || !body.is_empty(),
|
||||
"Router-level 404 for {endpoint}",
|
||||
);
|
||||
}
|
||||
|
||||
async fn insert_completed_job(db: &Pool<Postgres>) -> Uuid {
|
||||
let id = Uuid::new_v4();
|
||||
sqlx::query(
|
||||
"INSERT INTO v2_job (id, workspace_id, created_by, permissioned_as, kind, tag, args)
|
||||
VALUES ($1, 'test-workspace', 'test-user', 'u/test-user', 'script', 'deno', '{}'::jsonb)",
|
||||
)
|
||||
.bind(id)
|
||||
.execute(db)
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query(
|
||||
"INSERT INTO v2_job_completed (id, workspace_id, duration_ms, result, status)
|
||||
VALUES ($1, 'test-workspace', 100, '42'::jsonb, 'success')",
|
||||
)
|
||||
.bind(id)
|
||||
.execute(db)
|
||||
.await
|
||||
.unwrap();
|
||||
id
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
async fn create_script(port: u16) -> String {
|
||||
let base = format!("http://localhost:{port}/api/w/test-workspace/scripts");
|
||||
let resp = authed(client().post(format!("{base}/create")))
|
||||
.json(&json!({
|
||||
"path": "u/test-user/test_job_script",
|
||||
"summary": "test",
|
||||
"description": "",
|
||||
"content": "export function main() { return 42; }",
|
||||
"language": "deno",
|
||||
"schema": {
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
"required": []
|
||||
}
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(
|
||||
resp.status().is_success(),
|
||||
"create script: {}",
|
||||
resp.status()
|
||||
);
|
||||
"u/test-user/test_job_script".to_string()
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
|
||||
async fn test_jobs_authed_list_and_count(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
initialize_tracing().await;
|
||||
let server = ApiServer::start(db.clone()).await?;
|
||||
let port = server.addr.port();
|
||||
let base = format!("http://localhost:{port}/api/w/test-workspace/jobs");
|
||||
|
||||
// --- List/count endpoints (2xx with empty results) ---
|
||||
|
||||
let resp = authed(client().get(format!("{base}/list"))).send().await?;
|
||||
assert_2xx(
|
||||
resp.status().as_u16(),
|
||||
&resp.text().await?,
|
||||
"GET /jobs/list",
|
||||
);
|
||||
|
||||
let resp = authed(client().get(format!("{base}/queue/list")))
|
||||
.send()
|
||||
.await?;
|
||||
assert_2xx(
|
||||
resp.status().as_u16(),
|
||||
&resp.text().await?,
|
||||
"GET /jobs/queue/list",
|
||||
);
|
||||
|
||||
let resp = authed(client().get(format!("{base}/queue/count")))
|
||||
.send()
|
||||
.await?;
|
||||
assert_2xx(
|
||||
resp.status().as_u16(),
|
||||
&resp.text().await?,
|
||||
"GET /jobs/queue/count",
|
||||
);
|
||||
|
||||
let resp = authed(client().get(format!("{base}/completed/list")))
|
||||
.send()
|
||||
.await?;
|
||||
assert_2xx(
|
||||
resp.status().as_u16(),
|
||||
&resp.text().await?,
|
||||
"GET /jobs/completed/list",
|
||||
);
|
||||
|
||||
let resp = authed(client().get(format!("{base}/completed/count")))
|
||||
.send()
|
||||
.await?;
|
||||
assert_2xx(
|
||||
resp.status().as_u16(),
|
||||
&resp.text().await?,
|
||||
"GET /jobs/completed/count",
|
||||
);
|
||||
|
||||
// --- Global endpoints ---
|
||||
|
||||
let resp = client()
|
||||
.get(format!("http://localhost:{port}/api/jobs/db_clock"))
|
||||
.send()
|
||||
.await?;
|
||||
assert_2xx(
|
||||
resp.status().as_u16(),
|
||||
&resp.text().await?,
|
||||
"GET /jobs/db_clock",
|
||||
);
|
||||
|
||||
let resp = authed(client().get(format!(
|
||||
"http://localhost:{port}/api/jobs/completed/count_by_tag"
|
||||
)))
|
||||
.send()
|
||||
.await?;
|
||||
assert_2xx(
|
||||
resp.status().as_u16(),
|
||||
&resp.text().await?,
|
||||
"GET /jobs/completed/count_by_tag",
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
|
||||
async fn test_jobs_authed_completed_endpoints(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
initialize_tracing().await;
|
||||
let server = ApiServer::start(db.clone()).await?;
|
||||
let port = server.addr.port();
|
||||
let base = format!("http://localhost:{port}/api/w/test-workspace/jobs");
|
||||
|
||||
let job_id = insert_completed_job(&db).await;
|
||||
|
||||
let resp = authed(client().get(format!("{base}/completed/get/{job_id}")))
|
||||
.send()
|
||||
.await?;
|
||||
assert_2xx(
|
||||
resp.status().as_u16(),
|
||||
&resp.text().await?,
|
||||
"GET /jobs/completed/get",
|
||||
);
|
||||
|
||||
let resp = authed(client().get(format!("{base}/completed/get_result/{job_id}")))
|
||||
.send()
|
||||
.await?;
|
||||
assert_2xx(
|
||||
resp.status().as_u16(),
|
||||
&resp.text().await?,
|
||||
"GET /jobs/completed/get_result",
|
||||
);
|
||||
|
||||
let resp = authed(client().get(format!("{base}/completed/get_result_maybe/{job_id}")))
|
||||
.send()
|
||||
.await?;
|
||||
assert_2xx(
|
||||
resp.status().as_u16(),
|
||||
&resp.text().await?,
|
||||
"GET /jobs/completed/get_result_maybe",
|
||||
);
|
||||
|
||||
let resp = authed(client().get(format!("{base}/completed/get_timing/{job_id}")))
|
||||
.send()
|
||||
.await?;
|
||||
assert_2xx(
|
||||
resp.status().as_u16(),
|
||||
&resp.text().await?,
|
||||
"GET /jobs/completed/get_timing",
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
|
||||
async fn test_jobs_authed_run_endpoints(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
initialize_tracing().await;
|
||||
let server = ApiServer::start(db.clone()).await?;
|
||||
let port = server.addr.port();
|
||||
let base = format!("http://localhost:{port}/api/w/test-workspace/jobs");
|
||||
|
||||
// Run preview — no pre-existing script needed
|
||||
let resp = authed(client().post(format!("{base}/run/preview")))
|
||||
.json(&json!({
|
||||
"content": "export function main() { return 1; }",
|
||||
"language": "deno",
|
||||
"args": {}
|
||||
}))
|
||||
.send()
|
||||
.await?;
|
||||
assert_2xx(
|
||||
resp.status().as_u16(),
|
||||
&resp.text().await?,
|
||||
"POST /jobs/run/preview",
|
||||
);
|
||||
|
||||
// Run preview flow
|
||||
let resp = authed(client().post(format!("{base}/run/preview_flow")))
|
||||
.json(&json!({
|
||||
"value": {"modules": []},
|
||||
"args": {}
|
||||
}))
|
||||
.send()
|
||||
.await?;
|
||||
assert_2xx(
|
||||
resp.status().as_u16(),
|
||||
&resp.text().await?,
|
||||
"POST /jobs/run/preview_flow",
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
|
||||
async fn test_jobs_authed_reachability(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
initialize_tracing().await;
|
||||
let server = ApiServer::start(db.clone()).await?;
|
||||
let port = server.addr.port();
|
||||
let base = format!("http://localhost:{port}/api/w/test-workspace/jobs");
|
||||
let fake = Uuid::nil();
|
||||
|
||||
// These need complex runtime but should hit the handler (not 404)
|
||||
|
||||
let resp = authed(client().post(format!("{base}/flow/resume/{fake}")))
|
||||
.json(&json!({}))
|
||||
.send()
|
||||
.await?;
|
||||
assert_route_reachable(
|
||||
resp.status().as_u16(),
|
||||
&resp.text().await?,
|
||||
"POST /jobs/flow/resume",
|
||||
);
|
||||
|
||||
let resp = authed(client().get(format!("{base}/job_signature/{fake}/1")))
|
||||
.send()
|
||||
.await?;
|
||||
assert_route_reachable(
|
||||
resp.status().as_u16(),
|
||||
&resp.text().await?,
|
||||
"GET /jobs/job_signature",
|
||||
);
|
||||
|
||||
let resp = authed(client().get(format!("{base}/resume_urls/{fake}/1")))
|
||||
.send()
|
||||
.await?;
|
||||
assert_route_reachable(
|
||||
resp.status().as_u16(),
|
||||
&resp.text().await?,
|
||||
"GET /jobs/resume_urls",
|
||||
);
|
||||
|
||||
let resp = authed(client().get(format!("{base}/result_by_id/{fake}/step1")))
|
||||
.send()
|
||||
.await?;
|
||||
assert_route_reachable(
|
||||
resp.status().as_u16(),
|
||||
&resp.text().await?,
|
||||
"GET /jobs/result_by_id",
|
||||
);
|
||||
|
||||
let resp = authed(client().post(format!("{base}/restart/f/{fake}")))
|
||||
.send()
|
||||
.await?;
|
||||
assert_route_reachable(
|
||||
resp.status().as_u16(),
|
||||
&resp.text().await?,
|
||||
"POST /jobs/restart/f",
|
||||
);
|
||||
|
||||
let resp = authed(client().post(format!("{base}/run/workflow_as_code/{fake}/main")))
|
||||
.json(&json!({}))
|
||||
.send()
|
||||
.await?;
|
||||
assert_route_reachable(
|
||||
resp.status().as_u16(),
|
||||
&resp.text().await?,
|
||||
"POST /jobs/run/workflow_as_code",
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
250
backend/windmill-api-integration-tests/tests/jobs_unauthed.rs
Normal file
250
backend/windmill-api-integration-tests/tests/jobs_unauthed.rs
Normal file
@@ -0,0 +1,250 @@
|
||||
use serde_json::json;
|
||||
use sqlx::{Pool, Postgres};
|
||||
use uuid::Uuid;
|
||||
use windmill_test_utils::*;
|
||||
|
||||
fn client() -> reqwest::Client {
|
||||
reqwest::Client::new()
|
||||
}
|
||||
|
||||
fn authed(builder: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
|
||||
builder.header("Authorization", "Bearer SECRET_TOKEN")
|
||||
}
|
||||
|
||||
fn assert_2xx(status: u16, body: &str, endpoint: &str) {
|
||||
assert!(
|
||||
(200..300).contains(&status),
|
||||
"{endpoint} returned {status}: {body}",
|
||||
);
|
||||
}
|
||||
|
||||
/// Insert a minimal completed job directly into the database for testing.
|
||||
async fn insert_completed_job(db: &Pool<Postgres>) -> Uuid {
|
||||
let id = Uuid::new_v4();
|
||||
sqlx::query(
|
||||
"INSERT INTO v2_job (id, workspace_id, created_by, permissioned_as, kind, tag, args)
|
||||
VALUES ($1, 'test-workspace', 'test-user', 'u/test-user', 'script', 'deno', '{}'::jsonb)",
|
||||
)
|
||||
.bind(id)
|
||||
.execute(db)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
sqlx::query(
|
||||
"INSERT INTO v2_job_completed (id, workspace_id, duration_ms, result, status)
|
||||
VALUES ($1, 'test-workspace', 100, '42'::jsonb, 'success')",
|
||||
)
|
||||
.bind(id)
|
||||
.execute(db)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
id
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
|
||||
async fn test_jobs_unauthed_endpoints(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
initialize_tracing().await;
|
||||
let server = ApiServer::start(db.clone()).await?;
|
||||
let port = server.addr.port();
|
||||
let base = format!("http://localhost:{port}/api/w/test-workspace/jobs_u");
|
||||
|
||||
let job_id = insert_completed_job(&db).await;
|
||||
|
||||
// --- No-data endpoints ---
|
||||
|
||||
let resp = authed(client().post(format!("{base}/queue/get_started_at_by_ids")))
|
||||
.json(&json!([]))
|
||||
.send()
|
||||
.await?;
|
||||
assert_2xx(
|
||||
resp.status().as_u16(),
|
||||
&resp.text().await?,
|
||||
"POST /queue/get_started_at_by_ids",
|
||||
);
|
||||
|
||||
// --- Completed job endpoints (unauthed service, with auth header) ---
|
||||
|
||||
let resp = authed(client().get(format!("{base}/get/{job_id}")))
|
||||
.send()
|
||||
.await?;
|
||||
assert_2xx(resp.status().as_u16(), &resp.text().await?, "GET /get");
|
||||
|
||||
let resp = authed(client().get(format!("{base}/get_logs/{job_id}")))
|
||||
.send()
|
||||
.await?;
|
||||
assert_2xx(resp.status().as_u16(), &resp.text().await?, "GET /get_logs");
|
||||
|
||||
let resp = authed(client().get(format!("{base}/get_completed_logs_tail/{job_id}")))
|
||||
.send()
|
||||
.await?;
|
||||
assert_2xx(
|
||||
resp.status().as_u16(),
|
||||
&resp.text().await?,
|
||||
"GET /get_completed_logs_tail",
|
||||
);
|
||||
|
||||
let resp = authed(client().get(format!("{base}/get_args/{job_id}")))
|
||||
.send()
|
||||
.await?;
|
||||
assert_2xx(resp.status().as_u16(), &resp.text().await?, "GET /get_args");
|
||||
|
||||
let resp = authed(client().get(format!("{base}/completed/get/{job_id}")))
|
||||
.send()
|
||||
.await?;
|
||||
assert_2xx(
|
||||
resp.status().as_u16(),
|
||||
&resp.text().await?,
|
||||
"GET /completed/get",
|
||||
);
|
||||
|
||||
let resp = authed(client().get(format!("{base}/completed/get_result/{job_id}")))
|
||||
.send()
|
||||
.await?;
|
||||
assert_2xx(
|
||||
resp.status().as_u16(),
|
||||
&resp.text().await?,
|
||||
"GET /completed/get_result",
|
||||
);
|
||||
|
||||
let resp = authed(client().get(format!("{base}/completed/get_result_maybe/{job_id}")))
|
||||
.send()
|
||||
.await?;
|
||||
assert_2xx(
|
||||
resp.status().as_u16(),
|
||||
&resp.text().await?,
|
||||
"GET /completed/get_result_maybe",
|
||||
);
|
||||
|
||||
let resp = authed(client().get(format!("{base}/completed/get_timing/{job_id}")))
|
||||
.send()
|
||||
.await?;
|
||||
assert_2xx(
|
||||
resp.status().as_u16(),
|
||||
&resp.text().await?,
|
||||
"GET /completed/get_timing",
|
||||
);
|
||||
|
||||
let resp = authed(client().get(format!("{base}/getupdate/{job_id}")))
|
||||
.send()
|
||||
.await?;
|
||||
assert_2xx(
|
||||
resp.status().as_u16(),
|
||||
&resp.text().await?,
|
||||
"GET /getupdate",
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
const FAKE_UUID: &str = "00000000-0000-0000-0000-000000000000";
|
||||
const FAKE_SECRET: &str = "aabb";
|
||||
|
||||
/// Reachability tests for endpoints that need complex runtime.
|
||||
/// These just verify the route matches (handler runs), not 2xx.
|
||||
fn assert_route_reachable(status: u16, body: &str, endpoint: &str) {
|
||||
assert!(
|
||||
status != 404 || !body.is_empty(),
|
||||
"Router-level 404 for {endpoint}",
|
||||
);
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
|
||||
async fn test_jobs_unauthed_complex_reachability(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
initialize_tracing().await;
|
||||
let server = ApiServer::start(db.clone()).await?;
|
||||
let port = server.addr.port();
|
||||
let base = format!("http://localhost:{port}/api/w/test-workspace/jobs_u");
|
||||
|
||||
let resp = authed(client().get(format!("{base}/resume/{FAKE_UUID}/1/{FAKE_SECRET}")))
|
||||
.send()
|
||||
.await?;
|
||||
assert_route_reachable(resp.status().as_u16(), &resp.text().await?, "GET /resume");
|
||||
|
||||
let resp = authed(client().post(format!("{base}/cancel/{FAKE_UUID}/1/{FAKE_SECRET}")))
|
||||
.send()
|
||||
.await?;
|
||||
assert_route_reachable(resp.status().as_u16(), &resp.text().await?, "POST /cancel");
|
||||
|
||||
let resp = authed(client().get(format!("{base}/get_flow/{FAKE_UUID}/1/{FAKE_SECRET}")))
|
||||
.send()
|
||||
.await?;
|
||||
assert_route_reachable(resp.status().as_u16(), &resp.text().await?, "GET /get_flow");
|
||||
|
||||
let resp = authed(client().post(format!("{base}/queue/cancel/{FAKE_UUID}")))
|
||||
.json(&serde_json::json!({"reason": "test"}))
|
||||
.send()
|
||||
.await?;
|
||||
assert_route_reachable(
|
||||
resp.status().as_u16(),
|
||||
&resp.text().await?,
|
||||
"POST /queue/cancel",
|
||||
);
|
||||
|
||||
let resp = authed(client().post(format!("{base}/queue/force_cancel/{FAKE_UUID}")))
|
||||
.json(&serde_json::json!({"reason": "test"}))
|
||||
.send()
|
||||
.await?;
|
||||
assert_route_reachable(
|
||||
resp.status().as_u16(),
|
||||
&resp.text().await?,
|
||||
"POST /queue/force_cancel",
|
||||
);
|
||||
|
||||
let resp = authed(client().post(format!("{base}/flow/resume_suspended/{FAKE_UUID}")))
|
||||
.send()
|
||||
.await?;
|
||||
assert_route_reachable(
|
||||
resp.status().as_u16(),
|
||||
&resp.text().await?,
|
||||
"POST /flow/resume_suspended",
|
||||
);
|
||||
|
||||
let resp = authed(client().get(format!("{base}/flow/approval_info/{FAKE_UUID}")))
|
||||
.send()
|
||||
.await?;
|
||||
assert_route_reachable(
|
||||
resp.status().as_u16(),
|
||||
&resp.text().await?,
|
||||
"GET /flow/approval_info",
|
||||
);
|
||||
|
||||
let resp = authed(client().get(format!("{base}/get_root_job_id/{FAKE_UUID}")))
|
||||
.send()
|
||||
.await?;
|
||||
assert_route_reachable(
|
||||
resp.status().as_u16(),
|
||||
&resp.text().await?,
|
||||
"GET /get_root_job_id",
|
||||
);
|
||||
|
||||
let resp = authed(client().get(format!("{base}/get_flow_debug_info/{FAKE_UUID}")))
|
||||
.send()
|
||||
.await?;
|
||||
assert_route_reachable(
|
||||
resp.status().as_u16(),
|
||||
&resp.text().await?,
|
||||
"GET /get_flow_debug_info",
|
||||
);
|
||||
|
||||
let resp = authed(client().get(format!("{base}/get_log_file/{FAKE_UUID}/test.txt")))
|
||||
.send()
|
||||
.await?;
|
||||
assert_route_reachable(
|
||||
resp.status().as_u16(),
|
||||
&resp.text().await?,
|
||||
"GET /get_log_file",
|
||||
);
|
||||
|
||||
let resp = authed(client().post(format!("{base}/queue/cancel_persistent/u/test-user/fake")))
|
||||
.json(&serde_json::json!({"reason": "test"}))
|
||||
.send()
|
||||
.await?;
|
||||
assert_route_reachable(
|
||||
resp.status().as_u16(),
|
||||
&resp.text().await?,
|
||||
"POST /queue/cancel_persistent",
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
85
backend/windmill-api-integration-tests/tests/npm_proxy.rs
Normal file
85
backend/windmill-api-integration-tests/tests/npm_proxy.rs
Normal file
@@ -0,0 +1,85 @@
|
||||
use serde_json::json;
|
||||
use sqlx::{Pool, Postgres};
|
||||
use windmill_test_utils::*;
|
||||
|
||||
fn client() -> reqwest::Client {
|
||||
reqwest::Client::new()
|
||||
}
|
||||
|
||||
fn authed(builder: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
|
||||
builder.header("Authorization", "Bearer SECRET_TOKEN")
|
||||
}
|
||||
|
||||
fn assert_2xx(status: u16, body: &str, endpoint: &str) {
|
||||
assert!(
|
||||
(200..300).contains(&status),
|
||||
"{endpoint} returned {status}: {body}",
|
||||
);
|
||||
}
|
||||
|
||||
/// Start a mock npm registry that returns valid JSON for any GET request.
|
||||
async fn start_mock_registry() -> u16 {
|
||||
use axum::{routing::get, Json, Router};
|
||||
|
||||
let app = Router::new().fallback(get(|| async {
|
||||
Json(json!({
|
||||
"name": "test-package",
|
||||
"versions": {"1.0.0": {"name": "test-package", "version": "1.0.0"}},
|
||||
"dist-tags": {"latest": "1.0.0"}
|
||||
}))
|
||||
}));
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let port = listener.local_addr().unwrap().port();
|
||||
tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
|
||||
port
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
|
||||
async fn test_npm_proxy_endpoints(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
initialize_tracing().await;
|
||||
let server = ApiServer::start(db.clone()).await?;
|
||||
let port = server.addr.port();
|
||||
let base = format!("http://localhost:{port}/api/w/test-workspace/npm_proxy");
|
||||
|
||||
// Start mock npm registry
|
||||
let mock_port = start_mock_registry().await;
|
||||
let mock_url = format!("http://127.0.0.1:{mock_port}");
|
||||
|
||||
// Configure the npm registry to point to our mock
|
||||
let resp = authed(
|
||||
client()
|
||||
.post(format!(
|
||||
"http://localhost:{port}/api/settings/global/npm_config_registry"
|
||||
))
|
||||
.json(&json!({"value": mock_url})),
|
||||
)
|
||||
.send()
|
||||
.await?;
|
||||
assert_2xx(
|
||||
resp.status().as_u16(),
|
||||
&resp.text().await?,
|
||||
"POST /settings/global/npm_config_registry",
|
||||
);
|
||||
|
||||
// GET /metadata/{package}
|
||||
let resp = authed(client().get(format!("{base}/metadata/lodash")))
|
||||
.send()
|
||||
.await?;
|
||||
assert_2xx(
|
||||
resp.status().as_u16(),
|
||||
&resp.text().await?,
|
||||
"GET /npm_proxy/metadata/lodash",
|
||||
);
|
||||
|
||||
// GET /resolve/{package}
|
||||
let resp = authed(client().get(format!("{base}/resolve/lodash")))
|
||||
.send()
|
||||
.await?;
|
||||
assert_2xx(
|
||||
resp.status().as_u16(),
|
||||
&resp.text().await?,
|
||||
"GET /npm_proxy/resolve/lodash",
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
33
backend/windmill-api-integration-tests/tests/raw_apps.rs
Normal file
33
backend/windmill-api-integration-tests/tests/raw_apps.rs
Normal file
@@ -0,0 +1,33 @@
|
||||
use sqlx::{Pool, Postgres};
|
||||
use windmill_test_utils::*;
|
||||
|
||||
fn client() -> reqwest::Client {
|
||||
reqwest::Client::new()
|
||||
}
|
||||
|
||||
fn authed(builder: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
|
||||
builder.header("Authorization", "Bearer SECRET_TOKEN")
|
||||
}
|
||||
|
||||
fn assert_2xx(status: u16, body: &str, endpoint: &str) {
|
||||
assert!(
|
||||
(200..300).contains(&status),
|
||||
"{endpoint} returned {status}: {body}",
|
||||
);
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
|
||||
async fn test_raw_apps_endpoints(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
initialize_tracing().await;
|
||||
let server = ApiServer::start(db.clone()).await?;
|
||||
let port = server.addr.port();
|
||||
let base = format!("http://localhost:{port}/api/w/test-workspace/raw_apps");
|
||||
|
||||
// GET /raw_apps/list → 200 (empty array)
|
||||
let resp = authed(client().get(format!("{base}/list"))).send().await?;
|
||||
let status = resp.status().as_u16();
|
||||
let body = resp.text().await?;
|
||||
assert_2xx(status, &body, "GET /raw_apps/list");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -108,7 +108,10 @@ async fn test_script_endpoints(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
let resp = authed_get(port, "raw/p", "u/test-user/test_script.ts").await;
|
||||
assert_eq!(resp.status(), 200);
|
||||
let body = resp.text().await?;
|
||||
assert!(body.contains("return 42"), "expected script content, got: {body}");
|
||||
assert!(
|
||||
body.contains("return 42"),
|
||||
"expected script content, got: {body}"
|
||||
);
|
||||
|
||||
// --- raw by hash (requires .ts suffix) ---
|
||||
let resp = authed_get(port, "raw/h", &format!("{hash}.ts")).await;
|
||||
@@ -131,12 +134,10 @@ async fn test_script_endpoints(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
assert!(list.iter().any(|s| s["path"] == "u/test-user/test_script"));
|
||||
|
||||
// list with path_start filter
|
||||
let resp = authed(client().get(format!(
|
||||
"{base}/list?path_start=u/test-user/another"
|
||||
)))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
let resp = authed(client().get(format!("{base}/list?path_start=u/test-user/another")))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), 200);
|
||||
let list = resp.json::<Vec<serde_json::Value>>().await?;
|
||||
assert_eq!(list.len(), 1);
|
||||
@@ -233,12 +234,7 @@ async fn test_script_endpoints(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
200,
|
||||
"history_update: {}",
|
||||
resp.text().await?
|
||||
);
|
||||
assert_eq!(resp.status(), 200, "history_update: {}", resp.text().await?);
|
||||
|
||||
// --- toggle_workspace_error_handler (EE-gated, expect 400 in OSS) ---
|
||||
let resp = authed(client().post(script_url(
|
||||
@@ -268,22 +264,13 @@ async fn test_script_endpoints(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
200,
|
||||
"tokened_raw: {}",
|
||||
resp.text().await?
|
||||
);
|
||||
assert_eq!(resp.status(), 200, "tokened_raw: {}", resp.text().await?);
|
||||
|
||||
// --- archive by path ---
|
||||
let resp = authed(client().post(script_url(
|
||||
port,
|
||||
"archive/p",
|
||||
"u/test-user/another_script",
|
||||
)))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
let resp = authed(client().post(script_url(port, "archive/p", "u/test-user/another_script")))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), 200);
|
||||
|
||||
// archived script should still be gettable
|
||||
@@ -333,12 +320,10 @@ async fn test_script_endpoints(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
// ===== Hub endpoints (require external network, expect 500 or 200) =====
|
||||
|
||||
// --- hub/top ---
|
||||
let resp = authed(client().get(format!(
|
||||
"http://localhost:{port}/api/scripts/hub/top"
|
||||
)))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
let resp = authed(client().get(format!("http://localhost:{port}/api/scripts/hub/top")))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(
|
||||
resp.status() == 200 || resp.status() == 500,
|
||||
"hub/top: unexpected status {}",
|
||||
@@ -372,12 +357,10 @@ async fn test_script_endpoints(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
);
|
||||
|
||||
// --- integrations hub/list ---
|
||||
let resp = authed(client().get(format!(
|
||||
"http://localhost:{port}/api/integrations/hub/list"
|
||||
)))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
let resp = authed(client().get(format!("http://localhost:{port}/api/integrations/hub/list")))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(
|
||||
resp.status() == 200 || resp.status() == 500,
|
||||
"integrations hub/list: unexpected status {}",
|
||||
@@ -386,3 +369,97 @@ async fn test_script_endpoints(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
|
||||
async fn test_auto_parent_resolves_parent_hash(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
initialize_tracing().await;
|
||||
let server = ApiServer::start(db.clone()).await?;
|
||||
let port = server.addr.port();
|
||||
let base = format!("http://localhost:{port}/api/w/test-workspace/scripts");
|
||||
|
||||
// Create v1
|
||||
let resp = authed(client().post(format!("{base}/create")))
|
||||
.json(&new_script(
|
||||
"u/test-user/auto_parent_test",
|
||||
"v1",
|
||||
"export async function main() { return 1; }",
|
||||
))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), 201, "create v1: {}", resp.text().await?);
|
||||
|
||||
// Get the hash of v1
|
||||
let resp = authed_get(port, "get/p", "u/test-user/auto_parent_test").await;
|
||||
let body = resp.json::<serde_json::Value>().await?;
|
||||
let v1_hash = body["hash"].as_str().unwrap().to_string();
|
||||
|
||||
// Create v2 using auto_parent (no parent_hash provided)
|
||||
let mut v2 = new_script(
|
||||
"u/test-user/auto_parent_test",
|
||||
"v2",
|
||||
"export async function main() { return 2; }",
|
||||
);
|
||||
v2["auto_parent"] = json!(true);
|
||||
let resp = authed(client().post(format!("{base}/create")))
|
||||
.json(&v2)
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
201,
|
||||
"create v2 with auto_parent: {}",
|
||||
resp.text().await?
|
||||
);
|
||||
|
||||
// Get v2 and verify its parent_hash points to v1
|
||||
let resp = authed_get(port, "get/p", "u/test-user/auto_parent_test").await;
|
||||
let body = resp.json::<serde_json::Value>().await?;
|
||||
assert_eq!(body["summary"], "v2");
|
||||
let v2_hash = body["hash"].as_str().unwrap().to_string();
|
||||
assert_ne!(v2_hash, v1_hash);
|
||||
|
||||
// v2's parent_hashes should contain v1
|
||||
let parent_hashes = body["parent_hashes"].as_array().unwrap();
|
||||
assert!(
|
||||
parent_hashes
|
||||
.iter()
|
||||
.any(|h| h.as_str() == Some(v1_hash.as_str())),
|
||||
"v2 parent_hashes should contain v1 hash {v1_hash}, got: {parent_hashes:?}"
|
||||
);
|
||||
|
||||
// Create v3 with auto_parent to confirm it chains correctly
|
||||
let mut v3 = new_script(
|
||||
"u/test-user/auto_parent_test",
|
||||
"v3",
|
||||
"export async function main() { return 3; }",
|
||||
);
|
||||
v3["auto_parent"] = json!(true);
|
||||
let resp = authed(client().post(format!("{base}/create")))
|
||||
.json(&v3)
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
201,
|
||||
"create v3 with auto_parent: {}",
|
||||
resp.text().await?
|
||||
);
|
||||
|
||||
let resp = authed_get(port, "get/p", "u/test-user/auto_parent_test").await;
|
||||
let body = resp.json::<serde_json::Value>().await?;
|
||||
assert_eq!(body["summary"], "v3");
|
||||
|
||||
// v3's parent_hashes should contain v2 (and transitively v1)
|
||||
let parent_hashes = body["parent_hashes"].as_array().unwrap();
|
||||
assert!(
|
||||
parent_hashes
|
||||
.iter()
|
||||
.any(|h| h.as_str() == Some(v2_hash.as_str())),
|
||||
"v3 parent_hashes should contain v2 hash {v2_hash}, got: {parent_hashes:?}"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -0,0 +1,465 @@
|
||||
//! Integration tests for sensitive log masking.
|
||||
//!
|
||||
//! A single comprehensive test that runs real bun scripts through real workers,
|
||||
//! covering all masking scenarios: secret variables, non-secret variables,
|
||||
//! multiple secrets, mid-string secrets, `$encrypted:` args, resources
|
||||
//! referencing secret variables, and cross-job isolation.
|
||||
//!
|
||||
//! Run with:
|
||||
//! cargo test -p windmill-api-integration-tests --test sensitive_log_masking -- --nocapture
|
||||
//!
|
||||
//! Requires: bun runtime, live database (migrations applied by sqlx::test).
|
||||
|
||||
use futures::StreamExt;
|
||||
use serde_json::json;
|
||||
use sqlx::{Pool, Postgres};
|
||||
use uuid::Uuid;
|
||||
use windmill_common::jobs::{JobPayload, RawCode};
|
||||
use windmill_common::scripts::ScriptLang;
|
||||
use windmill_common::worker::to_raw_value;
|
||||
use windmill_test_utils::*;
|
||||
|
||||
fn client() -> reqwest::Client {
|
||||
reqwest::Client::new()
|
||||
}
|
||||
|
||||
fn authed(builder: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
|
||||
builder.header("Authorization", "Bearer SECRET_TOKEN")
|
||||
}
|
||||
|
||||
/// Helper: create a variable via the API.
|
||||
async fn create_variable(port: u16, path: &str, value: &str, is_secret: bool) {
|
||||
let base = format!("http://localhost:{port}/api/w/test-workspace/variables");
|
||||
let resp = authed(client().post(format!("{base}/create")))
|
||||
.json(&json!({
|
||||
"path": path,
|
||||
"value": value,
|
||||
"is_secret": is_secret,
|
||||
"description": "test variable for log masking"
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
201,
|
||||
"failed to create variable {path}: {}",
|
||||
resp.text().await.unwrap_or_default()
|
||||
);
|
||||
}
|
||||
|
||||
/// Helper: create a resource via the API.
|
||||
async fn create_resource(port: u16, path: &str, value: serde_json::Value) {
|
||||
let base = format!("http://localhost:{port}/api/w/test-workspace/resources");
|
||||
let resp = authed(client().post(format!("{base}/create")))
|
||||
.json(&json!({
|
||||
"path": path,
|
||||
"value": value,
|
||||
"resource_type": "object",
|
||||
"description": "test resource for log masking"
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
201,
|
||||
"failed to create resource {path}: {}",
|
||||
resp.text().await.unwrap_or_default()
|
||||
);
|
||||
}
|
||||
|
||||
/// Helper: fetch job logs from the job_logs table.
|
||||
async fn get_job_logs(db: &Pool<Postgres>, job_id: Uuid) -> Option<String> {
|
||||
sqlx::query_scalar!(
|
||||
r#"SELECT logs as "logs!" FROM job_logs WHERE job_id = $1"#,
|
||||
job_id,
|
||||
)
|
||||
.fetch_optional(db)
|
||||
.await
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
/// Helper: push a bun preview job and return its UUID.
|
||||
async fn push_bun_job(db: &Pool<Postgres>, code: String) -> Uuid {
|
||||
RunJob::from(JobPayload::Code(RawCode {
|
||||
hash: None,
|
||||
content: code,
|
||||
path: None,
|
||||
language: ScriptLang::Bun,
|
||||
lock: None,
|
||||
cache_ttl: None,
|
||||
cache_ignore_s3_path: None,
|
||||
dedicated_worker: None,
|
||||
concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default()
|
||||
.into(),
|
||||
debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(),
|
||||
modules: None,
|
||||
}))
|
||||
.push(db)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Helper: push a bun preview job with encrypted args.
|
||||
async fn push_bun_job_with_encrypted_arg(
|
||||
db: &Pool<Postgres>,
|
||||
code: String,
|
||||
arg_name: &str,
|
||||
plaintext_value: &str,
|
||||
) -> Uuid {
|
||||
// We need to know the job_id in advance to encrypt with the right key suffix.
|
||||
let job_id = Uuid::new_v4();
|
||||
|
||||
// Encrypt the value the same way the frontend does:
|
||||
// build_crypt_with_key_suffix(db, workspace, root_job_id)
|
||||
let mc = windmill_common::variables::build_crypt_with_key_suffix(
|
||||
db,
|
||||
"test-workspace",
|
||||
&job_id.to_string(),
|
||||
)
|
||||
.await
|
||||
.expect("build_crypt_with_key_suffix");
|
||||
|
||||
// Encrypt the JSON-serialized string value
|
||||
let json_str = serde_json::to_string(plaintext_value).unwrap();
|
||||
let encrypted = windmill_common::variables::encrypt(&mc, &json_str);
|
||||
let arg_value = format!("$encrypted:{encrypted}");
|
||||
|
||||
let mut args = std::collections::HashMap::new();
|
||||
args.insert(arg_name.to_string(), to_raw_value(&json!(arg_value)));
|
||||
|
||||
RunJob::from(JobPayload::Code(RawCode {
|
||||
hash: None,
|
||||
content: code,
|
||||
path: None,
|
||||
language: ScriptLang::Bun,
|
||||
lock: None,
|
||||
cache_ttl: None,
|
||||
cache_ignore_s3_path: None,
|
||||
dedicated_worker: None,
|
||||
concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default()
|
||||
.into(),
|
||||
debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(),
|
||||
modules: None,
|
||||
}))
|
||||
.job_id(job_id)
|
||||
.arg(arg_name, json!(arg_value))
|
||||
.push(db)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Comprehensive test covering all sensitive log masking scenarios in a single
|
||||
/// test function to amortize server/worker startup cost.
|
||||
///
|
||||
/// Scenarios covered (each as a separate job inside the same worker):
|
||||
/// 1. Secret variable fetched and logged → masked
|
||||
/// 2. Non-secret variable fetched and logged → NOT masked (no false positives)
|
||||
/// 3. Two different secrets fetched and logged in the same job → both masked
|
||||
/// 4. Secret embedded mid-string (e.g. "token=SECRET&user=bob") → masked
|
||||
/// 5. Same secret logged 3 times → all occurrences masked
|
||||
/// 6. `$encrypted:` password arg logged → masked
|
||||
/// 7. Resource referencing a secret variable via `$var:` → secret masked when logged
|
||||
/// 8. Cross-job isolation: job A's secret does NOT leak into job B's logs
|
||||
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
|
||||
async fn test_sensitive_log_masking(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
initialize_tracing().await;
|
||||
let server = ApiServer::start(db.clone()).await?;
|
||||
let port = server.addr.port();
|
||||
|
||||
// === Setup: create variables and resources ===
|
||||
let secret1 = "alpha_secret_value_9x7k2m";
|
||||
let secret2 = "beta_secret_token_4j8n3p";
|
||||
let plain_val = "plain_visible_value_12345";
|
||||
let encrypted_password = "encrypted_pass_w0rd_zq5r";
|
||||
let resource_secret = "resource_db_password_h7t2";
|
||||
|
||||
create_variable(port, "u/test-user/secret_alpha", secret1, true).await;
|
||||
create_variable(port, "u/test-user/secret_beta", secret2, true).await;
|
||||
create_variable(port, "u/test-user/plain_var", plain_val, false).await;
|
||||
// Secret variable that will be referenced by a resource via $var:
|
||||
create_variable(port, "u/test-user/res_secret_var", resource_secret, true).await;
|
||||
// Resource whose "password" field references the secret variable
|
||||
create_resource(
|
||||
port,
|
||||
"u/test-user/db_with_secret",
|
||||
json!({"host": "db.example.com", "password": "$var:u/test-user/res_secret_var"}),
|
||||
)
|
||||
.await;
|
||||
|
||||
let mut completed = listen_for_completed_jobs(&db).await;
|
||||
let db2 = db.clone();
|
||||
in_test_worker(
|
||||
db.clone(),
|
||||
async move {
|
||||
// ================================================================
|
||||
// Scenario 1: Secret variable fetched and console.logged → masked
|
||||
// ================================================================
|
||||
let job1 = push_bun_job(
|
||||
&db2,
|
||||
r#"import * as wmill from "windmill-client";
|
||||
export async function main() {
|
||||
const secret = await wmill.getVariable("u/test-user/secret_alpha");
|
||||
console.log("The secret value is: " + secret);
|
||||
return "ok";
|
||||
}"#
|
||||
.into(),
|
||||
)
|
||||
.await;
|
||||
completed.next().await;
|
||||
let cjob1 = completed_job(job1, &db2).await;
|
||||
assert!(cjob1.success, "scenario 1 job failed");
|
||||
let logs1 = get_job_logs(&db2, job1).await.expect("scenario 1: no logs");
|
||||
|
||||
assert!(
|
||||
!logs1.contains(secret1),
|
||||
"scenario 1: secret value leaked in logs\nLogs:\n{logs1}"
|
||||
);
|
||||
assert!(
|
||||
logs1.contains("The secret value is: alp*****"),
|
||||
"scenario 1: expected masked output with first 3 chars\nLogs:\n{logs1}"
|
||||
);
|
||||
assert!(
|
||||
logs1.contains("[windmill] secret value was masked for security reasons, use string transformations to display full value"),
|
||||
"scenario 1: expected security notice\nLogs:\n{logs1}"
|
||||
);
|
||||
|
||||
// ================================================================
|
||||
// Scenario 2: Non-secret variable → NOT masked (no false positives)
|
||||
// ================================================================
|
||||
let job2 = push_bun_job(
|
||||
&db2,
|
||||
r#"import * as wmill from "windmill-client";
|
||||
export async function main() {
|
||||
const val = await wmill.getVariable("u/test-user/plain_var");
|
||||
console.log("The plain value is: " + val);
|
||||
return "ok";
|
||||
}"#
|
||||
.into(),
|
||||
)
|
||||
.await;
|
||||
completed.next().await;
|
||||
let cjob2 = completed_job(job2, &db2).await;
|
||||
assert!(cjob2.success, "scenario 2 job failed");
|
||||
let logs2 = get_job_logs(&db2, job2).await.expect("scenario 2: no logs");
|
||||
|
||||
assert!(
|
||||
logs2.contains(plain_val),
|
||||
"scenario 2: plain value should appear unmasked\nLogs:\n{logs2}"
|
||||
);
|
||||
|
||||
// ================================================================
|
||||
// Scenario 3: Two different secrets fetched in the same job → both masked
|
||||
// ================================================================
|
||||
let job3 = push_bun_job(
|
||||
&db2,
|
||||
r#"import * as wmill from "windmill-client";
|
||||
export async function main() {
|
||||
const s1 = await wmill.getVariable("u/test-user/secret_alpha");
|
||||
const s2 = await wmill.getVariable("u/test-user/secret_beta");
|
||||
console.log("secret1=" + s1);
|
||||
console.log("secret2=" + s2);
|
||||
return "ok";
|
||||
}"#
|
||||
.into(),
|
||||
)
|
||||
.await;
|
||||
completed.next().await;
|
||||
let cjob3 = completed_job(job3, &db2).await;
|
||||
assert!(cjob3.success, "scenario 3 job failed");
|
||||
let logs3 = get_job_logs(&db2, job3).await.expect("scenario 3: no logs");
|
||||
|
||||
assert!(
|
||||
!logs3.contains(secret1),
|
||||
"scenario 3: secret1 leaked\nLogs:\n{logs3}"
|
||||
);
|
||||
assert!(
|
||||
!logs3.contains(secret2),
|
||||
"scenario 3: secret2 leaked\nLogs:\n{logs3}"
|
||||
);
|
||||
assert!(
|
||||
logs3.contains("secret1=alp*****"),
|
||||
"scenario 3: secret1 not masked\nLogs:\n{logs3}"
|
||||
);
|
||||
assert!(
|
||||
logs3.contains("secret2=bet*****"),
|
||||
"scenario 3: secret2 not masked\nLogs:\n{logs3}"
|
||||
);
|
||||
|
||||
// ================================================================
|
||||
// Scenario 4: Secret embedded mid-string → masked in place
|
||||
// ================================================================
|
||||
let job4 = push_bun_job(
|
||||
&db2,
|
||||
r#"import * as wmill from "windmill-client";
|
||||
export async function main() {
|
||||
const secret = await wmill.getVariable("u/test-user/secret_alpha");
|
||||
console.log("token=" + secret + "&user=bob&format=json");
|
||||
return "ok";
|
||||
}"#
|
||||
.into(),
|
||||
)
|
||||
.await;
|
||||
completed.next().await;
|
||||
let cjob4 = completed_job(job4, &db2).await;
|
||||
assert!(cjob4.success, "scenario 4 job failed");
|
||||
let logs4 = get_job_logs(&db2, job4).await.expect("scenario 4: no logs");
|
||||
|
||||
assert!(
|
||||
!logs4.contains(secret1),
|
||||
"scenario 4: secret leaked mid-string\nLogs:\n{logs4}"
|
||||
);
|
||||
assert!(
|
||||
logs4.contains("token=alp*****&user=bob&format=json"),
|
||||
"scenario 4: mid-string masking failed\nLogs:\n{logs4}"
|
||||
);
|
||||
|
||||
// ================================================================
|
||||
// Scenario 5: Same secret logged 3 times → all occurrences masked
|
||||
// ================================================================
|
||||
let job5 = push_bun_job(
|
||||
&db2,
|
||||
r#"import * as wmill from "windmill-client";
|
||||
export async function main() {
|
||||
const secret = await wmill.getVariable("u/test-user/secret_beta");
|
||||
console.log("First: " + secret);
|
||||
console.log("Second: " + secret);
|
||||
console.log("Third: " + secret);
|
||||
return "ok";
|
||||
}"#
|
||||
.into(),
|
||||
)
|
||||
.await;
|
||||
completed.next().await;
|
||||
let cjob5 = completed_job(job5, &db2).await;
|
||||
assert!(cjob5.success, "scenario 5 job failed");
|
||||
let logs5 = get_job_logs(&db2, job5).await.expect("scenario 5: no logs");
|
||||
|
||||
assert!(
|
||||
!logs5.contains(secret2),
|
||||
"scenario 5: secret leaked\nLogs:\n{logs5}"
|
||||
);
|
||||
let mask_count = logs5.matches("bet*****").count();
|
||||
assert!(
|
||||
mask_count >= 3,
|
||||
"scenario 5: expected >= 3 masked occurrences, found {mask_count}\nLogs:\n{logs5}"
|
||||
);
|
||||
// Security notice should appear only once even though masking happened 3 times
|
||||
let notice_count = logs5.matches("[windmill] secret value was masked").count();
|
||||
assert_eq!(
|
||||
notice_count, 1,
|
||||
"scenario 5: security notice should appear exactly once, found {notice_count}\nLogs:\n{logs5}"
|
||||
);
|
||||
|
||||
// ================================================================
|
||||
// Scenario 6: $encrypted: password arg → masked when logged
|
||||
// ================================================================
|
||||
let job6 = push_bun_job_with_encrypted_arg(
|
||||
&db2,
|
||||
r#"export async function main(password: string) {
|
||||
console.log("password is: " + password);
|
||||
return "ok";
|
||||
}"#
|
||||
.into(),
|
||||
"password",
|
||||
encrypted_password,
|
||||
)
|
||||
.await;
|
||||
completed.next().await;
|
||||
let cjob6 = completed_job(job6, &db2).await;
|
||||
assert!(cjob6.success, "scenario 6 job failed");
|
||||
let logs6 = get_job_logs(&db2, job6).await.expect("scenario 6: no logs");
|
||||
|
||||
assert!(
|
||||
!logs6.contains(encrypted_password),
|
||||
"scenario 6: encrypted password leaked\nLogs:\n{logs6}"
|
||||
);
|
||||
assert!(
|
||||
logs6.contains("password is: enc*****"),
|
||||
"scenario 6: encrypted password not masked\nLogs:\n{logs6}"
|
||||
);
|
||||
|
||||
// ================================================================
|
||||
// Scenario 7: Resource with $var: referencing a secret → masked
|
||||
// ================================================================
|
||||
let job7 = push_bun_job(
|
||||
&db2,
|
||||
r#"import * as wmill from "windmill-client";
|
||||
export async function main() {
|
||||
const res = await wmill.getResource("u/test-user/db_with_secret");
|
||||
console.log("db password: " + res.password);
|
||||
console.log("db host: " + res.host);
|
||||
return "ok";
|
||||
}"#
|
||||
.into(),
|
||||
)
|
||||
.await;
|
||||
completed.next().await;
|
||||
let cjob7 = completed_job(job7, &db2).await;
|
||||
assert!(cjob7.success, "scenario 7 job failed");
|
||||
let logs7 = get_job_logs(&db2, job7).await.expect("scenario 7: no logs");
|
||||
|
||||
assert!(
|
||||
!logs7.contains(resource_secret),
|
||||
"scenario 7: resource secret leaked\nLogs:\n{logs7}"
|
||||
);
|
||||
assert!(
|
||||
logs7.contains("db password: res*****"),
|
||||
"scenario 7: resource secret not masked\nLogs:\n{logs7}"
|
||||
);
|
||||
// Non-secret field should remain visible
|
||||
assert!(
|
||||
logs7.contains("db host: db.example.com"),
|
||||
"scenario 7: non-secret resource field should be visible\nLogs:\n{logs7}"
|
||||
);
|
||||
|
||||
// ================================================================
|
||||
// Scenario 8: Cross-job isolation — job A fetches secret_alpha,
|
||||
// then job B logs "alpha_secret_value_9x7k2m" as a
|
||||
// literal string (not fetched as a secret).
|
||||
// Job B should NOT mask it because the secret belongs
|
||||
// to job A which already completed.
|
||||
// ================================================================
|
||||
// Job A: fetch the secret (registers it) then completes
|
||||
let job_a = push_bun_job(
|
||||
&db2,
|
||||
r#"import * as wmill from "windmill-client";
|
||||
export async function main() {
|
||||
const s = await wmill.getVariable("u/test-user/secret_alpha");
|
||||
console.log("fetched secret");
|
||||
return "ok";
|
||||
}"#
|
||||
.into(),
|
||||
)
|
||||
.await;
|
||||
completed.next().await;
|
||||
let cjob_a = completed_job(job_a, &db2).await;
|
||||
assert!(cjob_a.success, "scenario 8 job A failed");
|
||||
|
||||
// Job B: logs the same string as a hardcoded literal (NOT fetched as secret)
|
||||
// Since job A already completed and unregistered, and job B never
|
||||
// fetched the secret, it should NOT be masked.
|
||||
let job_b_code = format!(
|
||||
r#"export async function main() {{
|
||||
console.log("literal value: {secret1}");
|
||||
return "ok";
|
||||
}}"#
|
||||
);
|
||||
let job_b = push_bun_job(&db2, job_b_code).await;
|
||||
completed.next().await;
|
||||
let cjob_b = completed_job(job_b, &db2).await;
|
||||
assert!(cjob_b.success, "scenario 8 job B failed");
|
||||
let logs_b = get_job_logs(&db2, job_b)
|
||||
.await
|
||||
.expect("scenario 8 job B: no logs");
|
||||
|
||||
assert!(
|
||||
logs_b.contains(secret1),
|
||||
"scenario 8: job B should show the literal string unmasked (it never fetched a secret)\nLogs:\n{logs_b}"
|
||||
);
|
||||
},
|
||||
port,
|
||||
)
|
||||
.await;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
36
backend/windmill-api-integration-tests/tests/service_logs.rs
Normal file
36
backend/windmill-api-integration-tests/tests/service_logs.rs
Normal file
@@ -0,0 +1,36 @@
|
||||
use sqlx::{Pool, Postgres};
|
||||
use windmill_test_utils::*;
|
||||
|
||||
fn client() -> reqwest::Client {
|
||||
reqwest::Client::new()
|
||||
}
|
||||
|
||||
fn authed(builder: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
|
||||
builder.header("Authorization", "Bearer SECRET_TOKEN")
|
||||
}
|
||||
|
||||
fn assert_2xx(status: u16, body: &str, endpoint: &str) {
|
||||
assert!(
|
||||
(200..300).contains(&status),
|
||||
"{endpoint} returned {status}: {body}",
|
||||
);
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
|
||||
async fn test_service_logs_2xx(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
initialize_tracing().await;
|
||||
let server = ApiServer::start(db.clone()).await?;
|
||||
let port = server.addr.port();
|
||||
let base = format!("http://localhost:{port}/api/service_logs");
|
||||
|
||||
let resp = authed(client().get(format!("{base}/list_files")))
|
||||
.send()
|
||||
.await?;
|
||||
assert_2xx(
|
||||
resp.status().as_u16(),
|
||||
&resp.text().await?,
|
||||
"GET /list_files",
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
116
backend/windmill-api-integration-tests/tests/settings.rs
Normal file
116
backend/windmill-api-integration-tests/tests/settings.rs
Normal file
@@ -0,0 +1,116 @@
|
||||
use serde_json::json;
|
||||
use sqlx::{Pool, Postgres};
|
||||
use windmill_test_utils::*;
|
||||
|
||||
fn client() -> reqwest::Client {
|
||||
reqwest::Client::new()
|
||||
}
|
||||
|
||||
fn authed(builder: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
|
||||
builder.header("Authorization", "Bearer SECRET_TOKEN")
|
||||
}
|
||||
|
||||
fn assert_2xx(status: u16, body: &str, endpoint: &str) {
|
||||
assert!(
|
||||
(200..300).contains(&status),
|
||||
"{endpoint} returned {status}: {body}",
|
||||
);
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
|
||||
async fn test_settings_2xx(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
initialize_tracing().await;
|
||||
let server = ApiServer::start(db.clone()).await?;
|
||||
let port = server.addr.port();
|
||||
let base = format!("http://localhost:{port}/api/settings");
|
||||
|
||||
let resp = authed(client().get(format!("{base}/envs"))).send().await?;
|
||||
assert_2xx(resp.status().as_u16(), &resp.text().await?, "GET /envs");
|
||||
|
||||
let resp = authed(client().get(format!("{base}/global/hub_base_url")))
|
||||
.send()
|
||||
.await?;
|
||||
assert_2xx(
|
||||
resp.status().as_u16(),
|
||||
&resp.text().await?,
|
||||
"GET /global/hub_base_url",
|
||||
);
|
||||
|
||||
let resp = authed(client().post(format!("{base}/global/test_key")))
|
||||
.json(&json!({"value": "test"}))
|
||||
.send()
|
||||
.await?;
|
||||
assert_2xx(
|
||||
resp.status().as_u16(),
|
||||
&resp.text().await?,
|
||||
"POST /global/test_key",
|
||||
);
|
||||
|
||||
let resp = authed(client().get(format!("{base}/instance_config")))
|
||||
.send()
|
||||
.await?;
|
||||
assert_2xx(
|
||||
resp.status().as_u16(),
|
||||
&resp.text().await?,
|
||||
"GET /instance_config",
|
||||
);
|
||||
|
||||
let resp = authed(client().get(format!("{base}/instance_config/yaml")))
|
||||
.send()
|
||||
.await?;
|
||||
assert_2xx(
|
||||
resp.status().as_u16(),
|
||||
&resp.text().await?,
|
||||
"GET /instance_config/yaml",
|
||||
);
|
||||
|
||||
let resp = authed(client().get(format!("{base}/latest_key_renewal_attempt")))
|
||||
.send()
|
||||
.await?;
|
||||
assert_2xx(
|
||||
resp.status().as_u16(),
|
||||
&resp.text().await?,
|
||||
"GET /latest_key_renewal_attempt",
|
||||
);
|
||||
|
||||
let resp = authed(client().post(format!("{base}/sync_cached_resource_types")))
|
||||
.send()
|
||||
.await?;
|
||||
assert_2xx(
|
||||
resp.status().as_u16(),
|
||||
&resp.text().await?,
|
||||
"POST /sync_cached_resource_types",
|
||||
);
|
||||
|
||||
// --- Reachability only (need external services) ---
|
||||
|
||||
let resp = authed(
|
||||
client()
|
||||
.post(format!("{base}/test_smtp"))
|
||||
.json(&json!({"to": "test@test.com", "subject": "test", "content": "test"})),
|
||||
)
|
||||
.send()
|
||||
.await?;
|
||||
let status = resp.status().as_u16();
|
||||
let body = resp.text().await?;
|
||||
assert!(
|
||||
status != 404 || !body.is_empty(),
|
||||
"Router-level 404 for POST /test_smtp"
|
||||
);
|
||||
|
||||
let resp = authed(
|
||||
client()
|
||||
.post(format!("{base}/test_license_key"))
|
||||
.json(&json!({"license_key": "fake"})),
|
||||
)
|
||||
.send()
|
||||
.await?;
|
||||
let status = resp.status().as_u16();
|
||||
let body = resp.text().await?;
|
||||
assert!(
|
||||
status != 404 || !body.is_empty(),
|
||||
"Router-level 404 for POST /test_license_key"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
41
backend/windmill-api-integration-tests/tests/trash.rs
Normal file
41
backend/windmill-api-integration-tests/tests/trash.rs
Normal file
@@ -0,0 +1,41 @@
|
||||
use sqlx::{Pool, Postgres};
|
||||
use windmill_test_utils::*;
|
||||
|
||||
fn client() -> reqwest::Client {
|
||||
reqwest::Client::new()
|
||||
}
|
||||
|
||||
fn authed(builder: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
|
||||
builder.header("Authorization", "Bearer SECRET_TOKEN")
|
||||
}
|
||||
|
||||
fn assert_2xx(status: u16, body: &str, endpoint: &str) {
|
||||
assert!(
|
||||
(200..300).contains(&status),
|
||||
"{endpoint} returned {status}: {body}",
|
||||
);
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
|
||||
async fn test_trash_endpoints(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
initialize_tracing().await;
|
||||
let server = ApiServer::start(db.clone()).await?;
|
||||
let port = server.addr.port();
|
||||
let base = format!("http://localhost:{port}/api/w/test-workspace/trash");
|
||||
|
||||
// GET /trash/list → 200 (admin, empty array)
|
||||
let resp = authed(client().get(format!("{base}/list"))).send().await?;
|
||||
let status = resp.status().as_u16();
|
||||
let body = resp.text().await?;
|
||||
assert_2xx(status, &body, "GET /trash/list");
|
||||
|
||||
// POST /trash/empty → 200 (admin)
|
||||
let resp = authed(client().post(format!("{base}/empty")))
|
||||
.send()
|
||||
.await?;
|
||||
let status = resp.status().as_u16();
|
||||
let body = resp.text().await?;
|
||||
assert_2xx(status, &body, "POST /trash/empty");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -14,6 +14,7 @@ use serde_json::json;
|
||||
use sqlx::{Pool, Postgres};
|
||||
use std::time::Duration;
|
||||
|
||||
#[allow(unused_imports)]
|
||||
use windmill_test_utils::*;
|
||||
|
||||
/// Row shape for querying deployment callback jobs from v2_job_queue
|
||||
@@ -27,6 +28,7 @@ struct DeploymentCallbackJob {
|
||||
}
|
||||
|
||||
/// Poll for deployment callback jobs in the queue for a given script path
|
||||
#[allow(dead_code)]
|
||||
async fn get_deployment_callback_jobs(
|
||||
db: &Pool<Postgres>,
|
||||
script_path: &str,
|
||||
@@ -63,6 +65,7 @@ async fn get_deployment_callback_jobs(
|
||||
}
|
||||
|
||||
/// Configure git sync for the test workspace with workspace dependencies enabled
|
||||
#[allow(dead_code)]
|
||||
async fn setup_git_sync_config(db: &Pool<Postgres>, sync_script_path: &str) -> anyhow::Result<()> {
|
||||
let git_sync_config = json!({
|
||||
"include_type": ["workspacedependencies"],
|
||||
@@ -87,6 +90,7 @@ async fn setup_git_sync_config(db: &Pool<Postgres>, sync_script_path: &str) -> a
|
||||
}
|
||||
|
||||
/// Create a git repository resource for testing
|
||||
#[allow(dead_code)]
|
||||
async fn create_git_repo_resource(db: &Pool<Postgres>) -> anyhow::Result<()> {
|
||||
sqlx::query(
|
||||
r#"
|
||||
@@ -107,6 +111,7 @@ async fn create_git_repo_resource(db: &Pool<Postgres>) -> anyhow::Result<()> {
|
||||
}
|
||||
|
||||
/// Create a dummy sync script for testing (with version >= 28103 for debouncing support)
|
||||
#[allow(dead_code)]
|
||||
async fn create_sync_script(db: &Pool<Postgres>, path: &str) -> anyhow::Result<i64> {
|
||||
let hash: i64 = rand::random::<i64>().unsigned_abs() as i64;
|
||||
sqlx::query(
|
||||
@@ -126,6 +131,7 @@ async fn create_sync_script(db: &Pool<Postgres>, path: &str) -> anyhow::Result<i
|
||||
}
|
||||
|
||||
/// Create a folder for the versioned script path
|
||||
#[allow(dead_code)]
|
||||
async fn create_folder(db: &Pool<Postgres>, name: &str) -> anyhow::Result<()> {
|
||||
sqlx::query(
|
||||
r#"
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
use sqlx::{Pool, Postgres};
|
||||
use windmill_test_utils::*;
|
||||
|
||||
fn client() -> reqwest::Client {
|
||||
reqwest::Client::new()
|
||||
}
|
||||
|
||||
fn authed(builder: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
|
||||
builder.header("Authorization", "Bearer SECRET_TOKEN")
|
||||
}
|
||||
|
||||
fn assert_2xx(status: u16, body: &str, endpoint: &str) {
|
||||
assert!(
|
||||
(200..300).contains(&status),
|
||||
"{endpoint} returned {status}: {body}",
|
||||
);
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
|
||||
async fn test_workspace_deps_2xx(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
initialize_tracing().await;
|
||||
let server = ApiServer::start(db.clone()).await?;
|
||||
let port = server.addr.port();
|
||||
let base = format!("http://localhost:{port}/api/w/test-workspace/workspace_dependencies");
|
||||
|
||||
let resp = authed(client().get(format!("{base}/list"))).send().await?;
|
||||
assert_2xx(resp.status().as_u16(), &resp.text().await?, "GET /list");
|
||||
|
||||
let resp = authed(client().get(format!("{base}/get_latest/python3")))
|
||||
.send()
|
||||
.await?;
|
||||
assert_2xx(
|
||||
resp.status().as_u16(),
|
||||
&resp.text().await?,
|
||||
"GET /get_latest/python3",
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -599,59 +599,60 @@ async fn test_workspace_endpoints(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), 200, "tarball: {}", resp.status());
|
||||
|
||||
// ===== Fork operations (on the newly created workspace) =====
|
||||
// ===== Fork operations (EE-only: CE limits workspace count to 2) =====
|
||||
#[cfg(feature = "enterprise")]
|
||||
{
|
||||
let new_ws_base = format!("http://localhost:{port}/api/w/new-test-ws/workspaces");
|
||||
let resp = authed(client().post(format!("{new_ws_base}/create_fork")))
|
||||
.json(&json!({
|
||||
"id": "wm-fork-test-ws",
|
||||
"name": "Forked Test Workspace"
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), 200, "create_fork: {}", resp.text().await?);
|
||||
|
||||
// --- create_fork (workspace-scoped, from new-test-ws) ---
|
||||
let new_ws_base = format!("http://localhost:{port}/api/w/new-test-ws/workspaces");
|
||||
let resp = authed(client().post(format!("{new_ws_base}/create_fork")))
|
||||
.json(&json!({
|
||||
"id": "wm-fork-test-ws",
|
||||
"name": "Forked Test Workspace"
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), 200, "create_fork: {}", resp.text().await?);
|
||||
// verify fork exists
|
||||
let resp = authed(client().post(format!("{global_base}/exists")))
|
||||
.json(&json!({"id": "wm-fork-test-ws"}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.json::<bool>().await?, true);
|
||||
|
||||
// verify fork exists
|
||||
let resp = authed(client().post(format!("{global_base}/exists")))
|
||||
.json(&json!({"id": "wm-fork-test-ws"}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.json::<bool>().await?, true);
|
||||
// --- change_workspace_id ---
|
||||
let fork_ws_base = format!("http://localhost:{port}/api/w/wm-fork-test-ws/workspaces");
|
||||
let resp = authed(client().post(format!("{fork_ws_base}/change_workspace_id")))
|
||||
.json(&json!({
|
||||
"new_id": "wm-fork-renamed",
|
||||
"new_name": "Renamed Fork"
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
200,
|
||||
"change_workspace_id: {}",
|
||||
resp.text().await?
|
||||
);
|
||||
|
||||
// --- change_workspace_id ---
|
||||
let fork_ws_base = format!("http://localhost:{port}/api/w/wm-fork-test-ws/workspaces");
|
||||
let resp = authed(client().post(format!("{fork_ws_base}/change_workspace_id")))
|
||||
.json(&json!({
|
||||
"new_id": "wm-fork-renamed",
|
||||
"new_name": "Renamed Fork"
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
200,
|
||||
"change_workspace_id: {}",
|
||||
resp.text().await?
|
||||
);
|
||||
// verify renamed workspace exists
|
||||
let resp = authed(client().post(format!("{global_base}/exists")))
|
||||
.json(&json!({"id": "wm-fork-renamed"}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.json::<bool>().await?, true);
|
||||
|
||||
// verify renamed workspace exists
|
||||
let resp = authed(client().post(format!("{global_base}/exists")))
|
||||
.json(&json!({"id": "wm-fork-renamed"}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.json::<bool>().await?, true);
|
||||
|
||||
// clean up renamed fork
|
||||
let resp = authed(client().delete(format!("{global_base}/delete/wm-fork-renamed")))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), 200);
|
||||
// clean up renamed fork
|
||||
let resp = authed(client().delete(format!("{global_base}/delete/wm-fork-renamed")))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), 200);
|
||||
}
|
||||
|
||||
// --- archive workspace (on the newly created one, not our main test workspace) ---
|
||||
let new_ws_base = format!("http://localhost:{port}/api/w/new-test-ws/workspaces");
|
||||
@@ -803,3 +804,21 @@ async fn test_get_copilot_info_ignores_empty_instance_ai_row(
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
|
||||
async fn test_get_imports(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
initialize_tracing().await;
|
||||
let server = ApiServer::start(db.clone()).await?;
|
||||
let port = server.addr.port();
|
||||
let base = format!("http://localhost:{port}/api/w/test-workspace/workspaces");
|
||||
|
||||
let resp = authed(client().get(format!("{base}/get_imports/u/test-user/nonexistent_script")))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), 200);
|
||||
let imports = resp.json::<Vec<String>>().await?;
|
||||
assert!(imports.is_empty());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -25,8 +25,8 @@ use uuid::Uuid;
|
||||
pub fn global_service() -> Router {
|
||||
Router::new()
|
||||
.route("/list", get(list_concurrency_groups))
|
||||
.route("/prune/*concurrency_key", delete(prune_concurrency_group))
|
||||
.route("/:job_id/key", get(get_concurrency_key))
|
||||
.route("/prune/{*concurrency_key}", delete(prune_concurrency_group))
|
||||
.route("/{job_id}/key", get(get_concurrency_key))
|
||||
}
|
||||
|
||||
pub fn workspaced_service() -> Router {
|
||||
|
||||
@@ -22,13 +22,13 @@ pub fn workspaced_service() -> Router {
|
||||
.allow_origin(Any);
|
||||
|
||||
Router::new()
|
||||
.route("/get/:id", post(get_job_metrics).layer(cors.clone()))
|
||||
.route("/get/{id}", post(get_job_metrics).layer(cors.clone()))
|
||||
.route(
|
||||
"/set_progress/:id",
|
||||
"/set_progress/{id}",
|
||||
post(set_job_progress).layer(cors.clone()),
|
||||
)
|
||||
.route(
|
||||
"/get_progress/:id",
|
||||
"/get_progress/{id}",
|
||||
get(get_job_progress).layer(cors.clone()),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -511,18 +511,14 @@ pub struct ResumeUrls {
|
||||
|
||||
pub struct QueryOrBody<D>(pub Option<D>);
|
||||
|
||||
#[axum::async_trait]
|
||||
impl<S, D> FromRequest<S, axum::body::Body> for QueryOrBody<D>
|
||||
impl<S, D> FromRequest<S> for QueryOrBody<D>
|
||||
where
|
||||
D: DeserializeOwned,
|
||||
S: Send + Sync,
|
||||
{
|
||||
type Rejection = Response;
|
||||
|
||||
async fn from_request(
|
||||
req: Request<axum::body::Body>,
|
||||
state: &S,
|
||||
) -> std::result::Result<Self, Self::Rejection> {
|
||||
async fn from_request(req: Request, state: &S) -> std::result::Result<Self, Self::Rejection> {
|
||||
return if req.method() == axum::http::Method::GET {
|
||||
let Query(InPayload { payload }) = Query::from_request(req, state)
|
||||
.await
|
||||
|
||||
@@ -119,10 +119,10 @@ struct FileEntry {
|
||||
pub fn workspaced_service() -> Router {
|
||||
Router::new()
|
||||
// Use wildcards for package names to support scoped packages like @scope/package
|
||||
.route("/metadata/*package", get(get_package_metadata))
|
||||
.route("/resolve/*package", get(resolve_package_version))
|
||||
.route("/filetree/*package_version", get(get_package_filetree))
|
||||
.route("/file/*package_version_filepath", get(get_package_file))
|
||||
.route("/metadata/{*package}", get(get_package_metadata))
|
||||
.route("/resolve/{*package}", get(resolve_package_version))
|
||||
.route("/filetree/{*package_version}", get(get_package_filetree))
|
||||
.route("/file/{*package_version_filepath}", get(get_package_file))
|
||||
.layer(
|
||||
CorsLayer::new()
|
||||
.allow_origin(Any)
|
||||
|
||||
@@ -56,12 +56,12 @@ pub fn workspaced_service() -> Router {
|
||||
Router::new()
|
||||
.route("/list", get(list_schedule))
|
||||
.route("/list_with_jobs", get(list_schedule_with_jobs))
|
||||
.route("/get/*path", get(get_schedule))
|
||||
.route("/exists/*path", get(exists_schedule))
|
||||
.route("/get/{*path}", get(get_schedule))
|
||||
.route("/exists/{*path}", get(exists_schedule))
|
||||
.route("/create", post(create_schedule))
|
||||
.route("/update/*path", post(edit_schedule))
|
||||
.route("/delete/*path", delete(delete_schedule))
|
||||
.route("/setenabled/*path", post(set_enabled))
|
||||
.route("/update/{*path}", post(edit_schedule))
|
||||
.route("/delete/{*path}", delete(delete_schedule))
|
||||
.route("/setenabled/{*path}", post(set_enabled))
|
||||
.route("/setdefaulthandler", post(set_default_error_handler))
|
||||
// .route("/catchup/*path", post(do_catchup).get(list_catchup))
|
||||
}
|
||||
@@ -963,6 +963,15 @@ async fn delete_schedule(
|
||||
)));
|
||||
}
|
||||
|
||||
// Capture row for trashbin before deleting
|
||||
let trash_data: Option<serde_json::Value> = sqlx::query_scalar(
|
||||
"SELECT jsonb_build_object('row', to_jsonb(t)) FROM schedule t WHERE path = $1 AND workspace_id = $2",
|
||||
)
|
||||
.bind(path)
|
||||
.bind(&w_id)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?;
|
||||
|
||||
let del = sqlx::query_scalar!(
|
||||
"DELETE FROM schedule WHERE path = $1 AND workspace_id = $2 RETURNING 1",
|
||||
path,
|
||||
@@ -979,6 +988,18 @@ async fn delete_schedule(
|
||||
)));
|
||||
}
|
||||
|
||||
if let Some(data) = trash_data {
|
||||
windmill_common::trashbin::move_to_trash(
|
||||
&mut *tx,
|
||||
&w_id,
|
||||
"schedule",
|
||||
path,
|
||||
data,
|
||||
&authed.username,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
audit_log(
|
||||
&mut *tx,
|
||||
&authed,
|
||||
|
||||
@@ -190,18 +190,18 @@ impl ScriptWDraft<ScriptRunnableSettingsHandle> {
|
||||
pub fn global_service() -> Router {
|
||||
Router::new()
|
||||
.route("/hub/top", get(get_top_hub_scripts))
|
||||
.route("/hub/get/*path", get(get_hub_script_by_path))
|
||||
.route("/hub/get_full/*path", get(get_full_hub_script_by_path))
|
||||
.route("/hub/pick/*path", get(pick_hub_script_by_path))
|
||||
.route("/hub/get/{*path}", get(get_hub_script_by_path))
|
||||
.route("/hub/get_full/{*path}", get(get_full_hub_script_by_path))
|
||||
.route("/hub/pick/{*path}", get(pick_hub_script_by_path))
|
||||
}
|
||||
|
||||
pub fn global_unauthed_service() -> Router {
|
||||
Router::new()
|
||||
.route(
|
||||
"/tokened_raw/:workspace/:token/*path",
|
||||
"/tokened_raw/{workspace}/{token}/{*path}",
|
||||
get(get_tokened_raw_script_by_path),
|
||||
)
|
||||
.route("/empty_ts/*path", get(get_empty_ts_script_by_path))
|
||||
.route("/empty_ts/{*path}", get(get_empty_ts_script_by_path))
|
||||
}
|
||||
|
||||
pub fn workspaced_service() -> Router {
|
||||
@@ -210,35 +210,36 @@ pub fn workspaced_service() -> Router {
|
||||
.route("/list_search", get(list_search_scripts))
|
||||
.route("/create", post(create_script))
|
||||
.route("/create_snapshot", post(create_snapshot_script))
|
||||
.route("/archive/p/*path", post(archive_script_by_path))
|
||||
.route("/get/draft/*path", get(get_script_by_path_w_draft))
|
||||
.route("/get/p/*path", get(get_script_by_path))
|
||||
.route("/list_tokens/*path", get(list_tokens))
|
||||
.route("/raw/p/*path", get(raw_script_by_path))
|
||||
.route("/raw_unpinned/p/*path", get(raw_script_by_path_unpinned))
|
||||
.route("/exists/p/*path", get(exists_script_by_path))
|
||||
.route("/archive/h/:hash", post(archive_script_by_hash))
|
||||
.route("/delete/h/:hash", post(delete_script_by_hash))
|
||||
.route("/delete/p/*path", post(delete_script_by_path))
|
||||
.route("/archive/p/{*path}", post(archive_script_by_path))
|
||||
.route("/get/draft/{*path}", get(get_script_by_path_w_draft))
|
||||
.route("/get/p/{*path}", get(get_script_by_path))
|
||||
.route("/list_tokens/{*path}", get(list_tokens))
|
||||
.route("/raw/p/{*path}", get(raw_script_by_path))
|
||||
.route("/raw_unpinned/p/{*path}", get(raw_script_by_path_unpinned))
|
||||
.route("/exists/p/{*path}", get(exists_script_by_path))
|
||||
.route("/archive/h/{hash}", post(archive_script_by_hash))
|
||||
.route("/delete/h/{hash}", post(delete_script_by_hash))
|
||||
.route("/delete/p/{*path}", post(delete_script_by_path))
|
||||
.route("/delete_bulk", delete(delete_scripts_bulk))
|
||||
.route("/get/h/:hash", get(get_script_by_hash))
|
||||
.route("/raw/h/:hash", get(raw_script_by_hash))
|
||||
.route("/deployment_status/h/:hash", get(get_deployment_status))
|
||||
.route("/get/h/{hash}", get(get_script_by_hash))
|
||||
.route("/raw/h/{hash}", get(raw_script_by_hash))
|
||||
.route("/deployment_status/h/{hash}", get(get_deployment_status))
|
||||
.route("/list_paths", get(list_paths))
|
||||
.route(
|
||||
"/toggle_workspace_error_handler/p/*path",
|
||||
"/toggle_workspace_error_handler/p/{*path}",
|
||||
post(toggle_workspace_error_handler),
|
||||
)
|
||||
.route("/history/p/*path", get(get_script_history))
|
||||
.route("/get_latest_version/*path", get(get_latest_version))
|
||||
.route("/history/p/{*path}", get(get_script_history))
|
||||
.route("/get_latest_version/{*path}", get(get_latest_version))
|
||||
.route(
|
||||
"/list_paths_from_workspace_runnable/*path",
|
||||
"/list_paths_from_workspace_runnable/{*path}",
|
||||
get(list_paths_from_workspace_runnable),
|
||||
)
|
||||
.route(
|
||||
"/history_update/h/:hash/p/*path",
|
||||
"/history_update/h/{hash}/p/{*path}",
|
||||
post(update_script_history),
|
||||
)
|
||||
.route("/list_dedicated_with_deps", get(list_dedicated_with_deps))
|
||||
// Temporary raw script storage for CLI lock generation
|
||||
.route("/raw_temp/store", post(store_raw_script_temp))
|
||||
.route("/raw_temp/diff", post(diff_raw_scripts_with_deployed))
|
||||
@@ -604,7 +605,7 @@ impl HandleDeploymentMetadata {
|
||||
}
|
||||
|
||||
async fn create_script_internal<'c>(
|
||||
ns: NewScript,
|
||||
mut ns: NewScript,
|
||||
w_id: String,
|
||||
authed: ApiAuthed,
|
||||
db: sqlx::Pool<Postgres>,
|
||||
@@ -674,6 +675,17 @@ async fn create_script_internal<'c>(
|
||||
.to_owned(),
|
||||
));
|
||||
};
|
||||
// When auto_parent is set, serialize concurrent creates for the same (workspace, path)
|
||||
// so the clashing_script query always sees the latest committed head.
|
||||
if ns.auto_parent.unwrap_or(false) {
|
||||
sqlx::query_scalar!(
|
||||
"SELECT pg_advisory_xact_lock(hashtext($1 || '/' || $2))",
|
||||
&w_id,
|
||||
&ns.path
|
||||
)
|
||||
.fetch_one(&mut *tx)
|
||||
.await?;
|
||||
}
|
||||
let clashing_script = sqlx::query_as::<_, Script<ScriptRunnableSettingsHandle>>(
|
||||
"SELECT * FROM script WHERE path = $1 AND archived = false AND workspace_id = $2",
|
||||
)
|
||||
@@ -686,6 +698,15 @@ async fn create_script_internal<'c>(
|
||||
perms: serde_json::Value,
|
||||
p_path: String,
|
||||
}
|
||||
// When auto_parent is set, resolve parent_hash to the current head for this path
|
||||
// within the transaction. The advisory lock above ensures the second concurrent
|
||||
// request waits until the first commits, so this query sees the updated head.
|
||||
if ns.auto_parent.unwrap_or(false) {
|
||||
if let Some(ref cs) = clashing_script {
|
||||
ns.parent_hash = Some(cs.hash.clone());
|
||||
}
|
||||
}
|
||||
|
||||
let parent_hashes_and_perms: Option<ParentInfo> = match (&ns.parent_hash, clashing_script) {
|
||||
(None, None) => Ok(None),
|
||||
(None, Some(s)) if !s.draft_only.unwrap_or(false) => Err(Error::BadRequest(format!(
|
||||
@@ -2239,33 +2260,58 @@ async fn delete_script_by_path(
|
||||
.await?
|
||||
.unwrap_or(false);
|
||||
|
||||
let script = if !draft_only {
|
||||
if !draft_only {
|
||||
require_admin(authed.is_admin, &authed.username)?;
|
||||
sqlx::query_scalar!(
|
||||
"DELETE FROM script WHERE path = $1 AND workspace_id = $2 RETURNING path",
|
||||
}
|
||||
|
||||
// Capture all script versions and drafts for trashbin before deleting
|
||||
let trash_scripts: Vec<serde_json::Value> = sqlx::query_scalar(
|
||||
"SELECT to_jsonb(t) FROM script t WHERE path = $1 AND workspace_id = $2",
|
||||
)
|
||||
.bind(path)
|
||||
.bind(&w_id)
|
||||
.fetch_all(&mut *tx)
|
||||
.await?;
|
||||
|
||||
let trash_drafts: Vec<serde_json::Value> = sqlx::query_scalar(
|
||||
"SELECT to_jsonb(t) FROM draft t WHERE path = $1 AND workspace_id = $2 AND typ = 'script'",
|
||||
)
|
||||
.bind(path)
|
||||
.bind(&w_id)
|
||||
.fetch_all(&mut *tx)
|
||||
.await?;
|
||||
|
||||
let script = sqlx::query_scalar!(
|
||||
"DELETE FROM script WHERE path = $1 AND workspace_id = $2 RETURNING path",
|
||||
path,
|
||||
w_id
|
||||
)
|
||||
.fetch_one(&mut *tx)
|
||||
.await
|
||||
.map_err(|e| Error::internal_err(format!("deleting script by path {w_id}: {e:#}")))?;
|
||||
|
||||
if !trash_scripts.is_empty() {
|
||||
let mut trash_data = serde_json::json!({"scripts": trash_scripts});
|
||||
if !trash_drafts.is_empty() {
|
||||
trash_data["drafts"] = serde_json::Value::Array(trash_drafts);
|
||||
}
|
||||
windmill_common::trashbin::move_to_trash(
|
||||
&mut *tx,
|
||||
&w_id,
|
||||
"script",
|
||||
path,
|
||||
w_id
|
||||
trash_data,
|
||||
&authed.username,
|
||||
)
|
||||
.fetch_one(&db)
|
||||
.await
|
||||
.map_err(|e| Error::internal_err(format!("deleting script by path {w_id}: {e:#}")))?
|
||||
} else {
|
||||
sqlx::query_scalar!(
|
||||
"DELETE FROM script WHERE path = $1 AND workspace_id = $2 RETURNING path",
|
||||
path,
|
||||
w_id
|
||||
)
|
||||
.fetch_one(&mut *tx)
|
||||
.await
|
||||
.map_err(|e| Error::internal_err(format!("deleting script by path {w_id}: {e:#}")))?
|
||||
};
|
||||
.await?;
|
||||
}
|
||||
|
||||
sqlx::query!(
|
||||
"DELETE FROM draft WHERE path = $1 AND workspace_id = $2 AND typ = 'script'",
|
||||
path,
|
||||
w_id
|
||||
)
|
||||
.execute(&db)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
if !query.keep_captures.unwrap_or(false) {
|
||||
@@ -2274,7 +2320,7 @@ async fn delete_script_by_path(
|
||||
path,
|
||||
w_id
|
||||
)
|
||||
.execute(&db)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
sqlx::query!(
|
||||
@@ -2282,7 +2328,7 @@ async fn delete_script_by_path(
|
||||
path,
|
||||
w_id
|
||||
)
|
||||
.execute(&db)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
}
|
||||
|
||||
@@ -2369,6 +2415,30 @@ async fn delete_scripts_bulk(
|
||||
|
||||
let mut tx = db.begin().await?;
|
||||
|
||||
// Capture scripts for trashbin per path before bulk delete
|
||||
for path in &request.paths {
|
||||
let trash_scripts: Vec<serde_json::Value> = sqlx::query_scalar(
|
||||
"SELECT to_jsonb(t) FROM script t WHERE path = $1 AND workspace_id = $2",
|
||||
)
|
||||
.bind(path)
|
||||
.bind(&w_id)
|
||||
.fetch_all(&mut *tx)
|
||||
.await?;
|
||||
|
||||
if !trash_scripts.is_empty() {
|
||||
let trash_data = serde_json::json!({"scripts": trash_scripts});
|
||||
windmill_common::trashbin::move_to_trash(
|
||||
&mut *tx,
|
||||
&w_id,
|
||||
"script",
|
||||
path,
|
||||
trash_data,
|
||||
&authed.username,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
|
||||
let mut deleted_paths = sqlx::query_scalar!(
|
||||
"DELETE FROM script WHERE workspace_id = $1 AND path = ANY($2) RETURNING path",
|
||||
w_id,
|
||||
@@ -2480,6 +2550,62 @@ async fn guard_script_from_debounce_data(ns: &NewScript) -> Result<()> {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct DedicatedScriptDeps {
|
||||
path: String,
|
||||
language: ScriptLang,
|
||||
workspace_dep_names: Vec<String>,
|
||||
}
|
||||
|
||||
async fn list_dedicated_with_deps(
|
||||
authed: ApiAuthed,
|
||||
Extension(user_db): Extension<UserDB>,
|
||||
Path(w_id): Path<String>,
|
||||
) -> JsonResult<Vec<DedicatedScriptDeps>> {
|
||||
let mut tx = user_db.begin(&authed).await?;
|
||||
|
||||
let rows = sqlx::query!(
|
||||
"SELECT DISTINCT ON (path) path, language AS \"language: ScriptLang\", content FROM script
|
||||
WHERE workspace_id = $1
|
||||
AND archived = false
|
||||
AND dedicated_worker = true
|
||||
AND language = ANY($2::SCRIPT_LANG[])
|
||||
ORDER BY path, created_at DESC",
|
||||
&w_id,
|
||||
&[
|
||||
ScriptLang::Python3,
|
||||
ScriptLang::Bun,
|
||||
ScriptLang::Bunnative,
|
||||
ScriptLang::Deno,
|
||||
] as &[ScriptLang],
|
||||
)
|
||||
.fetch_all(&mut *tx)
|
||||
.await?;
|
||||
|
||||
tx.commit().await?;
|
||||
|
||||
let result = rows
|
||||
.into_iter()
|
||||
.map(|row| {
|
||||
let dep_names =
|
||||
windmill_common::scripts::extract_workspace_dependencies_annotated_refs(
|
||||
&row.language,
|
||||
&row.content,
|
||||
&row.path,
|
||||
)
|
||||
.map(|refs| refs.external)
|
||||
.unwrap_or_default();
|
||||
DedicatedScriptDeps {
|
||||
path: row.path,
|
||||
language: row.language,
|
||||
workspace_dep_names: dep_names,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(Json(result))
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Temporary Raw Script Storage for CLI Lock Generation
|
||||
// ============================================================================
|
||||
@@ -2508,11 +2634,9 @@ async fn store_raw_script_temp(
|
||||
.await?;
|
||||
|
||||
// Clean up old entries (1 week TTL)
|
||||
sqlx::query!(
|
||||
"DELETE FROM raw_script_temp WHERE created_at < NOW() - INTERVAL '1 week'"
|
||||
)
|
||||
.execute(&db)
|
||||
.await?;
|
||||
sqlx::query!("DELETE FROM raw_script_temp WHERE created_at < NOW() - INTERVAL '1 week'")
|
||||
.execute(&db)
|
||||
.await?;
|
||||
|
||||
Ok(Json(hash))
|
||||
}
|
||||
@@ -2560,7 +2684,7 @@ async fn diff_raw_scripts_with_deployed(
|
||||
FROM script s \
|
||||
WHERE s.path = local.path AND s.workspace_id = $3 AND s.archived = false \
|
||||
ORDER BY s.created_at DESC LIMIT 1 \
|
||||
) deployed ON deployed.deployed_hash = local.hash"
|
||||
) deployed ON deployed.deployed_hash = local.hash",
|
||||
)
|
||||
.bind(&paths)
|
||||
.bind(&hashes)
|
||||
@@ -2582,7 +2706,7 @@ async fn diff_raw_scripts_with_deployed(
|
||||
AND wd.language = $3::SCRIPT_LANG \
|
||||
AND wd.name IS NOT DISTINCT FROM $4 \
|
||||
AND encode(sha256(convert_to(wd.content, 'UTF8')), 'hex') = $5 \
|
||||
)"
|
||||
)",
|
||||
)
|
||||
.bind(&dep.path)
|
||||
.bind(&w_id)
|
||||
|
||||
@@ -45,8 +45,8 @@ use windmill_common::{
|
||||
global_settings::{
|
||||
AI_CONFIG_SETTING, APP_WORKSPACED_ROUTE_SETTING, AUTOMATE_USERNAME_CREATION_SETTING,
|
||||
CRITICAL_ALERT_MUTE_UI_SETTING, DEFAULT_TAGS_WORKSPACES_SETTING, DISABLE_HUB_SETTING,
|
||||
EMAIL_DOMAIN_SETTING, ENV_SETTINGS, HUB_ACCESSIBLE_URL_SETTING, HUB_BASE_URL_SETTING,
|
||||
WS_BASE_URL_SETTING,
|
||||
EMAIL_DOMAIN_SETTING, ENV_SETTINGS, HTTP_ROUTE_WORKSPACED_ROUTE_SETTING,
|
||||
HUB_ACCESSIBLE_URL_SETTING, HUB_BASE_URL_SETTING, WS_BASE_URL_SETTING,
|
||||
},
|
||||
instance_config::{self, ApplyMode, InstanceConfig},
|
||||
server::Smtp,
|
||||
@@ -58,7 +58,7 @@ pub fn global_service() -> Router {
|
||||
let r = Router::new()
|
||||
.route("/envs", get(get_local_settings))
|
||||
.route(
|
||||
"/global/:key",
|
||||
"/global/{key}",
|
||||
post(set_global_setting).get(get_global_setting),
|
||||
)
|
||||
.route("/list_global", get(list_global_settings))
|
||||
@@ -80,7 +80,7 @@ pub fn global_service() -> Router {
|
||||
.route("/test_critical_channels", post(test_critical_channels))
|
||||
.route("/critical_alerts", get(get_critical_alerts))
|
||||
.route(
|
||||
"/critical_alerts/:id/acknowledge",
|
||||
"/critical_alerts/{id}/acknowledge",
|
||||
post(acknowledge_critical_alert),
|
||||
)
|
||||
.route(
|
||||
@@ -92,7 +92,7 @@ pub fn global_service() -> Router {
|
||||
post(refresh_custom_instance_user_pwd),
|
||||
)
|
||||
.route(
|
||||
"/setup_custom_instance_pg_database/:name",
|
||||
"/setup_custom_instance_pg_database/{name}",
|
||||
post(setup_custom_instance_pg_database),
|
||||
)
|
||||
.route(
|
||||
@@ -424,6 +424,74 @@ async fn run_setting_pre_write_hook(
|
||||
}
|
||||
}
|
||||
}
|
||||
HTTP_ROUTE_WORKSPACED_ROUTE_SETTING => {
|
||||
let serde_json::Value::Bool(workspaced_route) = value else {
|
||||
return Err(error::Error::BadRequest(format!(
|
||||
"{} setting expected to be boolean",
|
||||
HTTP_ROUTE_WORKSPACED_ROUTE_SETTING
|
||||
)));
|
||||
};
|
||||
|
||||
if !*workspaced_route {
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
#[allow(unused)]
|
||||
struct DuplicateRoute {
|
||||
route_path: String,
|
||||
workspace_id: String,
|
||||
http_method: String,
|
||||
}
|
||||
let duplicate_routes = sqlx::query_as!(
|
||||
DuplicateRoute,
|
||||
r#"
|
||||
SELECT
|
||||
route_path,
|
||||
workspace_id,
|
||||
http_method::TEXT AS "http_method!"
|
||||
FROM
|
||||
http_trigger
|
||||
WHERE
|
||||
workspaced_route IS FALSE
|
||||
AND route_path_key IN (
|
||||
SELECT
|
||||
route_path_key
|
||||
FROM
|
||||
http_trigger
|
||||
WHERE
|
||||
workspaced_route IS FALSE
|
||||
GROUP BY
|
||||
route_path_key, http_method
|
||||
HAVING COUNT(*) > 1
|
||||
)
|
||||
ORDER BY route_path_key
|
||||
"#
|
||||
)
|
||||
.fetch_all(db)
|
||||
.await?;
|
||||
|
||||
if !duplicate_routes.is_empty() {
|
||||
tracing::error!(
|
||||
"Cannot disable {} setting as duplicate http routes were found: {:?}",
|
||||
HTTP_ROUTE_WORKSPACED_ROUTE_SETTING,
|
||||
&duplicate_routes
|
||||
);
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct ErrorResponse {
|
||||
error: String,
|
||||
details: Vec<DuplicateRoute>,
|
||||
}
|
||||
|
||||
let error_response = ErrorResponse {
|
||||
error: "Duplicate HTTP route paths detected".to_string(),
|
||||
details: duplicate_routes,
|
||||
};
|
||||
|
||||
return Err(error::Error::JsonErr(
|
||||
serde_json::to_value(error_response).unwrap(),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
Ok(())
|
||||
@@ -541,6 +609,7 @@ pub async fn get_global_setting(
|
||||
&& key != DISABLE_HUB_SETTING
|
||||
&& key != EMAIL_DOMAIN_SETTING
|
||||
&& key != APP_WORKSPACED_ROUTE_SETTING
|
||||
&& key != HTTP_ROUTE_WORKSPACED_ROUTE_SETTING
|
||||
&& key != WS_BASE_URL_SETTING
|
||||
{
|
||||
require_super_admin(&db, &authed.email).await?;
|
||||
@@ -1124,6 +1193,59 @@ struct CachedResourceType {
|
||||
description: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct HubResourceTypeRaw {
|
||||
id: i64,
|
||||
name: String,
|
||||
schema: Option<String>,
|
||||
app: String,
|
||||
description: Option<String>,
|
||||
}
|
||||
|
||||
async fn fetch_resource_types_from_hub() -> error::Result<Vec<CachedResourceType>> {
|
||||
let response = HTTP_CLIENT
|
||||
.get(format!(
|
||||
"{}/resource_types/list",
|
||||
windmill_common::DEFAULT_HUB_BASE_URL
|
||||
))
|
||||
.header("Accept", "application/json")
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| error::Error::InternalErr(format!("Failed to fetch from hub: {}", e)))?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(error::Error::InternalErr(format!(
|
||||
"Hub returned status {}",
|
||||
response.status()
|
||||
)));
|
||||
}
|
||||
|
||||
let raw_types: Vec<HubResourceTypeRaw> = response
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| error::Error::InternalErr(format!("Failed to parse hub response: {}", e)))?;
|
||||
|
||||
Ok(raw_types
|
||||
.into_iter()
|
||||
.filter_map(|rt| {
|
||||
let schema = match rt.schema {
|
||||
Some(s) => match serde_json::from_str(&s) {
|
||||
Ok(v) => Some(v),
|
||||
Err(_) => return None,
|
||||
},
|
||||
None => None,
|
||||
};
|
||||
Some(CachedResourceType {
|
||||
id: rt.id,
|
||||
name: rt.name,
|
||||
schema,
|
||||
app: rt.app,
|
||||
description: rt.description,
|
||||
})
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn sync_cached_resource_types(
|
||||
Extension(db): Extension<DB>,
|
||||
authed: ApiAuthed,
|
||||
@@ -1133,16 +1255,12 @@ async fn sync_cached_resource_types(
|
||||
use windmill_common::worker::HUB_RT_CACHE_DIR;
|
||||
let cache_path = format!("{}/resource_types.json", *HUB_RT_CACHE_DIR);
|
||||
|
||||
let content = tokio::fs::read_to_string(&cache_path).await.map_err(|e| {
|
||||
error::Error::NotFound(format!(
|
||||
"No cached resource types found at {}: {}",
|
||||
cache_path, e
|
||||
))
|
||||
})?;
|
||||
|
||||
let cached_types: Vec<CachedResourceType> = serde_json::from_str(&content).map_err(|e| {
|
||||
error::Error::InternalErr(format!("Failed to parse cached resource types: {}", e))
|
||||
})?;
|
||||
let cached_types = match tokio::fs::read_to_string(&cache_path).await {
|
||||
Ok(content) => serde_json::from_str::<Vec<CachedResourceType>>(&content).map_err(|e| {
|
||||
error::Error::InternalErr(format!("Failed to parse cached resource types: {}", e))
|
||||
})?,
|
||||
Err(_) => fetch_resource_types_from_hub().await?,
|
||||
};
|
||||
|
||||
let mut synced_count = 0;
|
||||
|
||||
|
||||
@@ -34,3 +34,4 @@ time.workspace = true
|
||||
tokio.workspace = true
|
||||
tower-cookies.workspace = true
|
||||
tracing.workspace = true
|
||||
url.workspace = true
|
||||
|
||||
@@ -49,13 +49,13 @@ use windmill_common::users::truncate_token;
|
||||
use windmill_common::users::COOKIE_NAME;
|
||||
use windmill_common::utils::paginate;
|
||||
use windmill_common::worker::CLOUD_HOSTED;
|
||||
use windmill_common::BASE_URL;
|
||||
use windmill_common::{
|
||||
auth::{get_folders_for_user, get_groups_for_user},
|
||||
db::UserDB,
|
||||
error::{self, Error, JsonResult, Result},
|
||||
utils::{not_found_if_none, rd_string, require_admin, Pagination, StripPath},
|
||||
};
|
||||
use windmill_common::{BASE_URL, HUB_BASE_URL};
|
||||
use windmill_git_sync::handle_deployment_metadata;
|
||||
|
||||
const COOKIE_PATH: &str = "/";
|
||||
@@ -66,32 +66,32 @@ pub fn workspaced_service() -> Router {
|
||||
.route("/list_usage", get(list_user_usage))
|
||||
.route("/list_usernames", get(list_usernames))
|
||||
.route("/exists", post(exists_username))
|
||||
.route("/get/:user", get(get_workspace_user))
|
||||
.route("/update/:user", post(update_workspace_user))
|
||||
.route("/delete/:user", delete(delete_workspace_user))
|
||||
.route("/convert_to_group/:user", post(convert_user_to_group))
|
||||
.route("/is_owner/*path", get(is_owner_of_path))
|
||||
.route("/whois/:username", get(whois))
|
||||
.route("/get/{user}", get(get_workspace_user))
|
||||
.route("/update/{user}", post(update_workspace_user))
|
||||
.route("/delete/{user}", delete(delete_workspace_user))
|
||||
.route("/convert_to_group/{user}", post(convert_user_to_group))
|
||||
.route("/is_owner/{*path}", get(is_owner_of_path))
|
||||
.route("/whois/{username}", get(whois))
|
||||
.route("/whoami", get(whoami))
|
||||
.route("/leave", post(leave_workspace))
|
||||
.route("/username_to_email/:username", get(username_to_email))
|
||||
.route("/username_to_email/{username}", get(username_to_email))
|
||||
}
|
||||
|
||||
pub fn global_service() -> Router {
|
||||
Router::new()
|
||||
.route("/exists/:email", get(exists_email))
|
||||
.route("/exists/{email}", get(exists_email))
|
||||
.route("/email", get(get_email))
|
||||
.route("/whoami", get(global_whoami))
|
||||
.route("/list_invites", get(list_invites))
|
||||
.route("/decline_invite", post(decline_invite))
|
||||
.route("/accept_invite", post(accept_invite))
|
||||
.route("/list_as_super_admin", get(list_users_as_super_admin))
|
||||
.route("/set_login_type/:user", post(set_login_type))
|
||||
.route("/update/:user", post(update_user))
|
||||
.route("/delete/:user", delete(delete_user))
|
||||
.route("/username_info/:user", get(get_instance_username_info))
|
||||
.route("/set_login_type/{user}", post(set_login_type))
|
||||
.route("/update/{user}", post(update_user))
|
||||
.route("/delete/{user}", delete(delete_user))
|
||||
.route("/username_info/{user}", get(get_instance_username_info))
|
||||
.route("/tokens/create", post(create_token))
|
||||
.route("/tokens/delete/:token_prefix", delete(delete_token))
|
||||
.route("/tokens/delete/{token_prefix}", delete(delete_token))
|
||||
.route("/tokens/list", get(list_tokens))
|
||||
.route("/tokens/impersonate", post(impersonate))
|
||||
.route("/usage", get(get_usage))
|
||||
@@ -157,6 +157,7 @@ pub struct GlobalUserInfo {
|
||||
operator_only: Option<bool>,
|
||||
first_time_user: bool,
|
||||
role_source: String,
|
||||
disabled: bool,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Debug)]
|
||||
@@ -213,6 +214,7 @@ pub struct EditUser {
|
||||
pub is_super_admin: Option<bool>,
|
||||
pub is_devops: Option<bool>,
|
||||
pub name: Option<String>,
|
||||
pub disabled: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -396,7 +398,7 @@ async fn list_users_as_super_admin(
|
||||
GlobalUserInfo,
|
||||
"WITH active_users AS (SELECT distinct username as email FROM (SELECT username, timestamp, operation FROM audit_partitioned UNION ALL SELECT username, timestamp, operation FROM audit) AS a WHERE timestamp > NOW() - INTERVAL '1 month' AND (operation = 'users.login' OR operation = 'oauth.login' OR operation = 'users.token.refresh')),
|
||||
authors as (SELECT distinct email FROM usr WHERE usr.operator IS false)
|
||||
SELECT email, email NOT IN (SELECT email FROM authors) as operator_only, login_type::text, verified, super_admin, devops, name, company, username, first_time_user, role_source
|
||||
SELECT email, email NOT IN (SELECT email FROM authors) as operator_only, login_type::text, verified, super_admin, devops, name, company, username, first_time_user, role_source, disabled
|
||||
FROM password
|
||||
WHERE email IN (SELECT email FROM active_users)
|
||||
ORDER BY super_admin DESC, devops DESC
|
||||
@@ -409,7 +411,7 @@ async fn list_users_as_super_admin(
|
||||
} else {
|
||||
sqlx::query_as!(
|
||||
GlobalUserInfo,
|
||||
"SELECT email, login_type::text, verified, super_admin, devops, name, company, username, NULL::bool as operator_only, first_time_user, role_source FROM password ORDER BY super_admin DESC, devops DESC, email LIMIT \
|
||||
"SELECT email, login_type::text, verified, super_admin, devops, name, company, username, NULL::bool as operator_only, first_time_user, role_source, disabled FROM password ORDER BY super_admin DESC, devops DESC, email LIMIT \
|
||||
$1 OFFSET $2",
|
||||
per_page as i32,
|
||||
offset as i32
|
||||
@@ -577,12 +579,44 @@ async fn logout(
|
||||
}
|
||||
tx.commit().await?;
|
||||
if let Some(rd) = rd {
|
||||
Ok((StatusCode::TEMPORARY_REDIRECT, [(LOCATION, rd)]).into_response())
|
||||
if is_valid_logout_redirect(&rd).await {
|
||||
Ok((StatusCode::TEMPORARY_REDIRECT, [(LOCATION, rd)]).into_response())
|
||||
} else {
|
||||
tracing::warn!("Blocked logout redirect to non-whitelisted URL: {}", rd);
|
||||
Ok((StatusCode::OK, "logged out successfully".to_string()).into_response())
|
||||
}
|
||||
} else {
|
||||
Ok((StatusCode::OK, "logged out successfully".to_string()).into_response())
|
||||
}
|
||||
}
|
||||
|
||||
async fn is_valid_logout_redirect(rd: &str) -> bool {
|
||||
// Allow relative paths (same-origin redirects)
|
||||
if rd.starts_with('/') && !rd.starts_with("//") {
|
||||
return true;
|
||||
}
|
||||
let parsed = match url::Url::parse(rd) {
|
||||
Ok(u) => u,
|
||||
Err(_) => return false,
|
||||
};
|
||||
let host: &str = match parsed.host_str() {
|
||||
Some(h) => h,
|
||||
None => return false,
|
||||
};
|
||||
if host == "windmill.dev" || host.ends_with(".windmill.dev") {
|
||||
return true;
|
||||
}
|
||||
let hub_url = HUB_BASE_URL.read().await.clone();
|
||||
if let Ok(hub_parsed) = url::Url::parse(&hub_url) {
|
||||
if let Some(hub_host) = hub_parsed.host_str() {
|
||||
if host == hub_host {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
async fn whoami(
|
||||
Extension(db): Extension<DB>,
|
||||
Path(w_id): Path<String>,
|
||||
@@ -625,7 +659,7 @@ async fn global_whoami(
|
||||
) -> JsonResult<GlobalUserInfo> {
|
||||
let user = sqlx::query_as!(
|
||||
GlobalUserInfo,
|
||||
"SELECT email, login_type::TEXT, super_admin, devops, verified, name, company, username, NULL::bool as operator_only, first_time_user, role_source FROM password WHERE \
|
||||
"SELECT email, login_type::TEXT, super_admin, devops, verified, name, company, username, NULL::bool as operator_only, first_time_user, role_source, disabled FROM password WHERE \
|
||||
email = $1",
|
||||
email
|
||||
)
|
||||
@@ -648,6 +682,7 @@ async fn global_whoami(
|
||||
operator_only: None,
|
||||
first_time_user: false,
|
||||
role_source: "manual".to_string(),
|
||||
disabled: false,
|
||||
}))
|
||||
} else {
|
||||
Err(user.unwrap_err())
|
||||
@@ -1407,6 +1442,22 @@ async fn update_user(
|
||||
.await?;
|
||||
}
|
||||
|
||||
if let Some(d) = eu.disabled {
|
||||
sqlx::query_scalar!(
|
||||
"UPDATE password SET disabled = $1 WHERE email = $2",
|
||||
d,
|
||||
&email_to_update
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
if d {
|
||||
// Delete all tokens for immediate session revocation
|
||||
sqlx::query!("DELETE FROM token WHERE email = $1", &email_to_update)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
|
||||
audit_log(
|
||||
&mut *tx,
|
||||
&authed,
|
||||
@@ -1429,6 +1480,9 @@ async fn delete_user(
|
||||
require_super_admin(&db, &authed.email).await?;
|
||||
let mut tx = db.begin().await?;
|
||||
|
||||
sqlx::query!("DELETE FROM token WHERE email = $1", &email_to_delete)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
sqlx::query!("DELETE FROM password WHERE email = $1", &email_to_delete)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
@@ -1687,7 +1741,7 @@ async fn login(
|
||||
};
|
||||
let email_w_h: Option<(String, String, bool)> = sqlx::query_as(
|
||||
"SELECT email, password_hash, super_admin FROM password WHERE email = $1 AND login_type = \
|
||||
'password'",
|
||||
'password' AND disabled = false",
|
||||
)
|
||||
.bind(&email)
|
||||
.fetch_optional(&mut *tx)
|
||||
@@ -1776,7 +1830,7 @@ async fn refresh_token(
|
||||
}
|
||||
|
||||
let super_admin = sqlx::query_scalar!(
|
||||
"SELECT super_admin FROM password WHERE email = $1",
|
||||
"SELECT super_admin FROM password WHERE email = $1 AND disabled = false",
|
||||
&authed.email
|
||||
)
|
||||
.fetch_optional(&mut *tx)
|
||||
|
||||
@@ -78,7 +78,8 @@ pub fn workspaced_service() -> Router {
|
||||
.route("/delete_invite", post(delete_invite))
|
||||
.route("/rebuild_dependency_map", post(rebuild_dependency_map))
|
||||
.route("/get_dependency_map", get(get_dependency_map))
|
||||
.route("/get_dependents/*imported_path", get(get_dependents))
|
||||
.route("/get_dependents/{*imported_path}", get(get_dependents))
|
||||
.route("/get_imports/{*importer_path}", get(get_imports))
|
||||
.route("/get_dependents_amounts", post(get_dependents_amounts))
|
||||
.route("/get_settings", get(get_settings))
|
||||
.route(
|
||||
@@ -151,14 +152,14 @@ pub fn workspaced_service() -> Router {
|
||||
post(create_workspace_fork_branch),
|
||||
)
|
||||
.route(
|
||||
"/reset_diff_tally/:fork_workspace_id",
|
||||
"/reset_diff_tally/{fork_workspace_id}",
|
||||
post(reset_workspace_diffs),
|
||||
)
|
||||
.route("/compare/:target_workspace_id", get(compare_workspaces))
|
||||
.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",
|
||||
"/protection_rules/{rule_name}",
|
||||
post(update_protection_rule).delete(delete_protection_rule),
|
||||
)
|
||||
.route("/log_chat", post(log_ai_chat))
|
||||
@@ -175,9 +176,9 @@ pub fn global_service() -> Router {
|
||||
.route("/exists", post(exists_workspace))
|
||||
.route("/exists_username", post(exists_username))
|
||||
.route("/allowed_domain_auto_invite", get(is_allowed_auto_domain))
|
||||
.route("/unarchive/:workspace", post(unarchive_workspace))
|
||||
.route("/unarchive/{workspace}", post(unarchive_workspace))
|
||||
.route(
|
||||
"/delete/:workspace",
|
||||
"/delete/{workspace}",
|
||||
delete(crate::workspaces_extra::delete_workspace),
|
||||
)
|
||||
.route(
|
||||
@@ -651,25 +652,23 @@ async fn get_settings(
|
||||
}
|
||||
|
||||
async fn get_copilot_settings_state(
|
||||
authed: ApiAuthed,
|
||||
_authed: ApiAuthed,
|
||||
Path(w_id): Path<String>,
|
||||
Extension(user_db): Extension<UserDB>,
|
||||
Extension(db): Extension<DB>,
|
||||
) -> JsonResult<CopilotSettingsState> {
|
||||
let mut tx = user_db.begin(&authed).await?;
|
||||
let workspace_ai_config = sqlx::query_scalar!(
|
||||
"SELECT ai_config FROM workspace_settings WHERE workspace_id = $1",
|
||||
&w_id
|
||||
)
|
||||
.fetch_optional(&mut *tx)
|
||||
.fetch_optional(&db)
|
||||
.await
|
||||
.map_err(|e| Error::internal_err(format!("getting workspace ai settings: {e:#}")))?;
|
||||
let workspace_ai_config = not_found_if_none(workspace_ai_config, "workspace settings", &w_id)?;
|
||||
let instance_ai_config: Option<serde_json::Value> =
|
||||
sqlx::query_scalar("SELECT value FROM global_settings WHERE name = 'ai_config'")
|
||||
.fetch_optional(&mut *tx)
|
||||
.fetch_optional(&db)
|
||||
.await
|
||||
.map_err(|e| Error::internal_err(format!("getting instance ai settings: {e:#}")))?;
|
||||
tx.commit().await?;
|
||||
|
||||
Ok(Json(build_copilot_settings_state(
|
||||
has_ai_providers(workspace_ai_config.as_ref()),
|
||||
@@ -1690,6 +1689,18 @@ async fn check_git_sync_access(_db: &DB, _w_id: &str) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Anchor the CE-only query for `cargo sqlx prepare` (which runs with --features enterprise)
|
||||
#[cfg(feature = "enterprise")]
|
||||
#[allow(dead_code)]
|
||||
async fn _sqlx_anchor_ce_user_count(db: &DB, w_id: &str) {
|
||||
let _ = sqlx::query_scalar!(
|
||||
"SELECT COUNT(*) FROM usr WHERE workspace_id = $1 AND disabled = false",
|
||||
w_id
|
||||
)
|
||||
.fetch_one(db)
|
||||
.await;
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "enterprise"))]
|
||||
async fn check_git_sync_access(db: &DB, w_id: &str) -> Result<()> {
|
||||
let user_count: i64 = sqlx::query_scalar!(
|
||||
@@ -4346,6 +4357,30 @@ async fn get_dependents(
|
||||
Ok(Json(dependents))
|
||||
}
|
||||
|
||||
async fn get_imports(
|
||||
Extension(db): Extension<DB>,
|
||||
Path((w_id, importer_path)): Path<(String, String)>,
|
||||
_authed: ApiAuthed,
|
||||
) -> JsonResult<Vec<String>> {
|
||||
tracing::debug!(
|
||||
workspace_id = %w_id,
|
||||
importer_path = %importer_path,
|
||||
"API: Getting imports for importer path"
|
||||
);
|
||||
|
||||
let imports = ScopedDependencyMap::get_imports(&importer_path, &w_id, &db).await?;
|
||||
|
||||
tracing::debug!(
|
||||
workspace_id = %w_id,
|
||||
importer_path = %importer_path,
|
||||
imports_count = imports.len(),
|
||||
"API: Found imports: {:?}",
|
||||
imports
|
||||
);
|
||||
|
||||
Ok(Json(imports))
|
||||
}
|
||||
|
||||
#[derive(Serialize, Debug)]
|
||||
struct DependentsAmount {
|
||||
imported_path: String,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
openapi: "3.0.3"
|
||||
|
||||
info:
|
||||
version: 1.664.0
|
||||
version: 1.666.0
|
||||
title: Windmill API
|
||||
|
||||
contact:
|
||||
@@ -588,6 +588,8 @@ paths:
|
||||
type: boolean
|
||||
name:
|
||||
type: string
|
||||
disabled:
|
||||
type: boolean
|
||||
responses:
|
||||
"200":
|
||||
description: user updated
|
||||
@@ -2712,6 +2714,30 @@ paths:
|
||||
items:
|
||||
$ref: "#/components/schemas/DependencyDependent"
|
||||
|
||||
/w/{workspace}/workspaces/get_imports/{importer_path}:
|
||||
get:
|
||||
summary: get script imports for an importer path
|
||||
operationId: getImports
|
||||
tags:
|
||||
- workspace
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/WorkspaceId"
|
||||
- name: importer_path
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
description: The script path to get imports for
|
||||
responses:
|
||||
"200":
|
||||
description: list of imported script paths
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
|
||||
/w/{workspace}/workspaces/get_dependents_amounts:
|
||||
post:
|
||||
summary: get dependents amounts for multiple imported paths
|
||||
@@ -4832,6 +4858,11 @@ paths:
|
||||
mcp_server_url:
|
||||
type: string
|
||||
description: "MCP server URL for MCP OAuth token refresh"
|
||||
scopes:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
description: "OAuth scopes to use for token refresh. Overrides instance-level scopes."
|
||||
required:
|
||||
- refresh_token
|
||||
- expires_in
|
||||
@@ -6940,6 +6971,60 @@ paths:
|
||||
schema:
|
||||
type: string
|
||||
|
||||
/w/{workspace}/scripts/list_dedicated_with_deps:
|
||||
get:
|
||||
summary: list dedicated worker scripts with workspace dependency annotations
|
||||
operationId: listDedicatedWithDeps
|
||||
tags:
|
||||
- script
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/WorkspaceId"
|
||||
responses:
|
||||
"200":
|
||||
description: list of dedicated scripts with their workspace dependency names
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
path:
|
||||
type: string
|
||||
language:
|
||||
type: string
|
||||
enum:
|
||||
- python3
|
||||
- deno
|
||||
- go
|
||||
- bash
|
||||
- powershell
|
||||
- postgresql
|
||||
- mysql
|
||||
- bigquery
|
||||
- snowflake
|
||||
- mssql
|
||||
- graphql
|
||||
- nativets
|
||||
- bun
|
||||
- bunnative
|
||||
- php
|
||||
- rust
|
||||
- ansible
|
||||
- csharp
|
||||
- oracledb
|
||||
- duckdb
|
||||
- java
|
||||
- ruby
|
||||
workspace_dep_names:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
required:
|
||||
- path
|
||||
- language
|
||||
- workspace_dep_names
|
||||
|
||||
/w/{workspace}/scripts/raw/p/{path}:
|
||||
get:
|
||||
summary: raw script by path
|
||||
@@ -10644,6 +10729,10 @@ paths:
|
||||
in: query
|
||||
schema:
|
||||
type: boolean
|
||||
- name: fast
|
||||
in: query
|
||||
schema:
|
||||
type: boolean
|
||||
|
||||
responses:
|
||||
"200":
|
||||
@@ -23412,6 +23501,8 @@ components:
|
||||
role_source:
|
||||
type: string
|
||||
enum: ["manual", "instance_group"]
|
||||
disabled:
|
||||
type: boolean
|
||||
|
||||
required:
|
||||
- email
|
||||
@@ -23420,6 +23511,7 @@ components:
|
||||
- verified
|
||||
- first_time_user
|
||||
- role_source
|
||||
- disabled
|
||||
|
||||
Flow:
|
||||
allOf:
|
||||
|
||||
@@ -607,11 +607,11 @@ fn transform_fim_to_chat_completions(body: &Bytes) -> Result<(Bytes, String)> {
|
||||
}
|
||||
|
||||
pub fn global_service() -> Router {
|
||||
Router::new().route("/proxy/*ai", post(global_proxy).get(global_proxy))
|
||||
Router::new().route("/proxy/{*ai}", post(global_proxy).get(global_proxy))
|
||||
}
|
||||
|
||||
pub fn workspaced_service() -> Router {
|
||||
let router = Router::new().route("/proxy/*ai", post(proxy).get(proxy));
|
||||
let router = Router::new().route("/proxy/{*ai}", post(proxy).get(proxy));
|
||||
|
||||
#[cfg(feature = "bedrock")]
|
||||
let router = router.route("/check_bedrock_credentials", get(check_bedrock_credentials));
|
||||
|
||||
@@ -259,7 +259,7 @@ pub async fn get_approval_form_details(
|
||||
}
|
||||
};
|
||||
|
||||
let flow_value = &flow_data.flow;
|
||||
let flow_value = flow_data.value();
|
||||
let flow_step_id = flow_step_id.unwrap_or("");
|
||||
let module = flow_value.modules.iter().find(|m| m.id == flow_step_id);
|
||||
|
||||
|
||||
@@ -83,48 +83,54 @@ pub fn workspaced_service() -> Router {
|
||||
Router::new()
|
||||
.route("/list", get(list_apps))
|
||||
.route("/list_search", get(list_search_apps))
|
||||
.route("/get/p/*path", get(get_app))
|
||||
.route("/get/lite/*path", get(get_app_lite))
|
||||
.route("/get/draft/*path", get(get_app_w_draft))
|
||||
.route("/secret_of/*path", get(get_secret_id))
|
||||
.route("/get/p/{*path}", get(get_app))
|
||||
.route("/get/lite/{*path}", get(get_app_lite))
|
||||
.route("/get/draft/{*path}", get(get_app_w_draft))
|
||||
.route("/secret_of/{*path}", get(get_secret_id))
|
||||
.route(
|
||||
"/secret_of_latest_version/*path",
|
||||
"/secret_of_latest_version/{*path}",
|
||||
get(get_latest_version_secret_id),
|
||||
)
|
||||
.route("/get/v/*id", get(get_app_by_id))
|
||||
.route("/get_data/v/*id", get(get_raw_app_data))
|
||||
.route("/exists/*path", get(exists_app))
|
||||
.route("/update/*path", post(update_app))
|
||||
.route("/update_raw/*path", post(update_app_raw))
|
||||
.route("/delete/*path", delete(delete_app))
|
||||
.route("/get/v/{*id}", get(get_app_by_id))
|
||||
.route("/get_data/v/{*id}", get(get_raw_app_data))
|
||||
.route("/exists/{*path}", get(exists_app))
|
||||
.route("/update/{*path}", post(update_app))
|
||||
.route("/update_raw/{*path}", post(update_app_raw))
|
||||
.route("/delete/{*path}", delete(delete_app))
|
||||
.route("/create", post(create_app))
|
||||
.route("/create_raw", post(create_app_raw))
|
||||
.route("/history/p/*path", get(get_app_history))
|
||||
.route("/get_latest_version/*path", get(get_latest_version))
|
||||
.route("/history_update/a/:id/v/:version", post(update_app_history))
|
||||
.route("/history/p/{*path}", get(get_app_history))
|
||||
.route("/get_latest_version/{*path}", get(get_latest_version))
|
||||
.route(
|
||||
"/list_paths_from_workspace_runnable/:runnable_kind/*path",
|
||||
"/history_update/a/{id}/v/{version}",
|
||||
post(update_app_history),
|
||||
)
|
||||
.route(
|
||||
"/list_paths_from_workspace_runnable/{runnable_kind}/{*path}",
|
||||
get(list_paths_from_workspace_runnable),
|
||||
)
|
||||
.route("/custom_path_exists/*custom_path", get(custom_path_exists))
|
||||
.route(
|
||||
"/custom_path_exists/{*custom_path}",
|
||||
get(custom_path_exists),
|
||||
)
|
||||
.route("/sign_s3_objects", post(sign_s3_objects))
|
||||
}
|
||||
|
||||
pub fn unauthed_service() -> Router {
|
||||
Router::new()
|
||||
.route("/execute_component/*path", post(execute_component))
|
||||
.route("/upload_s3_file/*path", post(upload_s3_file_from_app))
|
||||
.route("/execute_component/{*path}", post(execute_component))
|
||||
.route("/upload_s3_file/{*path}", post(upload_s3_file_from_app))
|
||||
.route("/delete_s3_file", delete(delete_s3_file_from_app))
|
||||
.route("/download_s3_file/*path", get(download_s3_file_from_app))
|
||||
.route("/public_app/:secret", get(get_public_app_by_secret))
|
||||
.route("/public_resource/*path", get(get_public_resource))
|
||||
.route("/get_data/v/*id", get(get_raw_app_data))
|
||||
.route("/download_s3_file/{*path}", get(download_s3_file_from_app))
|
||||
.route("/public_app/{secret}", get(get_public_app_by_secret))
|
||||
.route("/public_resource/{*path}", get(get_public_resource))
|
||||
.route("/get_data/v/{*id}", get(get_raw_app_data))
|
||||
}
|
||||
pub fn global_service() -> Router {
|
||||
Router::new()
|
||||
.route("/hub/list", get(list_hub_apps))
|
||||
.route("/hub/get/:id", get(get_hub_app_by_id))
|
||||
.route("/hub/get_raw/:id", get(get_hub_raw_app_by_id))
|
||||
.route("/hub/get/{id}", get(get_hub_app_by_id))
|
||||
.route("/hub/get_raw/{id}", get(get_hub_raw_app_by_id))
|
||||
}
|
||||
|
||||
#[derive(FromRow, Deserialize, Serialize)]
|
||||
@@ -1451,6 +1457,30 @@ async fn delete_app(
|
||||
|
||||
let mut tx = user_db.begin(&authed).await?;
|
||||
|
||||
// Capture all related data for trashbin before deleting (CASCADE will remove app_version, etc.)
|
||||
let trash_app: Option<serde_json::Value> =
|
||||
sqlx::query_scalar("SELECT to_jsonb(t) FROM app t WHERE path = $1 AND workspace_id = $2")
|
||||
.bind(path)
|
||||
.bind(&w_id)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?;
|
||||
|
||||
let trash_app_versions: Vec<serde_json::Value> = sqlx::query_scalar(
|
||||
"SELECT to_jsonb(t) FROM app_version t WHERE app_id = (SELECT id FROM app WHERE path = $1 AND workspace_id = $2)",
|
||||
)
|
||||
.bind(path)
|
||||
.bind(&w_id)
|
||||
.fetch_all(&mut *tx)
|
||||
.await?;
|
||||
|
||||
let trash_drafts: Vec<serde_json::Value> = sqlx::query_scalar(
|
||||
"SELECT to_jsonb(t) FROM draft t WHERE path = $1 AND workspace_id = $2 AND typ = 'app'",
|
||||
)
|
||||
.bind(path)
|
||||
.bind(&w_id)
|
||||
.fetch_all(&mut *tx)
|
||||
.await?;
|
||||
|
||||
sqlx::query!(
|
||||
"DELETE FROM draft WHERE path = $1 AND workspace_id = $2 AND typ = 'app'",
|
||||
path,
|
||||
@@ -1467,6 +1497,25 @@ async fn delete_app(
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
if let Some(app_data) = trash_app {
|
||||
let mut trash_data = serde_json::json!({"row": app_data});
|
||||
if !trash_app_versions.is_empty() {
|
||||
trash_data["app_versions"] = serde_json::Value::Array(trash_app_versions);
|
||||
}
|
||||
if !trash_drafts.is_empty() {
|
||||
trash_data["drafts"] = serde_json::Value::Array(trash_drafts);
|
||||
}
|
||||
windmill_common::trashbin::move_to_trash(
|
||||
&mut *tx,
|
||||
&w_id,
|
||||
"app",
|
||||
path,
|
||||
trash_data,
|
||||
&authed.username,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
audit_log(
|
||||
&mut *tx,
|
||||
&authed,
|
||||
|
||||
@@ -451,8 +451,7 @@ where
|
||||
}
|
||||
}
|
||||
|
||||
#[axum::async_trait]
|
||||
impl<S> FromRequest<S, axum::body::Body> for RawWebhookArgs
|
||||
impl<S> FromRequest<S> for RawWebhookArgs
|
||||
where
|
||||
S: Send + Sync,
|
||||
{
|
||||
|
||||
@@ -19,7 +19,7 @@ use crate::db::ApiAuthed;
|
||||
pub fn workspaced_service() -> Router {
|
||||
Router::new()
|
||||
.route("/list", get(list_audit))
|
||||
.route("/get/:id", get(get_audit))
|
||||
.route("/get/{id}", get(get_audit))
|
||||
}
|
||||
|
||||
async fn get_audit(
|
||||
|
||||
@@ -93,22 +93,22 @@ pub fn workspaced_service() -> Router {
|
||||
Router::new()
|
||||
.route("/set_config", post(set_config))
|
||||
.route(
|
||||
"/ping_config/:trigger_kind/:runnable_kind/*path",
|
||||
"/ping_config/{trigger_kind}/{runnable_kind}/{*path}",
|
||||
post(ping_config),
|
||||
)
|
||||
.route("/get_configs/:runnable_kind/*path", get(get_configs))
|
||||
.route("/list/:runnable_kind/*path", get(list_captures))
|
||||
.route("/get_configs/{runnable_kind}/{*path}", get(get_configs))
|
||||
.route("/list/{runnable_kind}/{*path}", get(list_captures))
|
||||
.route(
|
||||
"/move/:runnable_kind/*path",
|
||||
"/move/{runnable_kind}/{*path}",
|
||||
post(move_captures_and_configs),
|
||||
)
|
||||
.route("/:id", delete(delete_capture))
|
||||
.route("/:id", get(get_capture))
|
||||
.route("/{id}", delete(delete_capture))
|
||||
.route("/{id}", get(get_capture))
|
||||
}
|
||||
|
||||
pub fn workspaced_unauthed_service() -> Router {
|
||||
let router = Router::new().route(
|
||||
"/webhook/:runnable_kind/*path",
|
||||
"/webhook/{runnable_kind}/{*path}",
|
||||
head(|| async {}).post(webhook_payload),
|
||||
);
|
||||
|
||||
@@ -118,12 +118,12 @@ pub fn workspaced_unauthed_service() -> Router {
|
||||
))]
|
||||
{
|
||||
#[cfg(feature = "http_trigger")]
|
||||
let router = router.route("/http/:runnable_kind/:path/*route_path", {
|
||||
let router = router.route("/http/{runnable_kind}/{path}/{*route_path}", {
|
||||
head(|| async {}).fallback(http_payload)
|
||||
});
|
||||
|
||||
#[cfg(all(feature = "enterprise", feature = "gcp_trigger", feature = "private"))]
|
||||
let router = router.route("/gcp/:runnable_kind/*path", post(gcp_payload));
|
||||
let router = router.route("/gcp/{runnable_kind}/{*path}", post(gcp_payload));
|
||||
|
||||
router
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ use windmill_common::{db::UserDB, error::Result, utils::StripPath};
|
||||
pub fn workspaced_service() -> Router {
|
||||
Router::new()
|
||||
.route("/create", post(create_draft))
|
||||
.route("/delete/:kind/*path", delete(delete_draft))
|
||||
.route("/delete/{kind}/{*path}", delete(delete_draft))
|
||||
}
|
||||
|
||||
#[derive(sqlx::Type, Serialize, Deserialize, Debug, PartialEq, Clone)]
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user