Compare commits

...

60 Commits

Author SHA1 Message Date
hcourdent
4067425342 Result node message 2024-11-15 16:32:38 +01:00
Lucas Abel
f7ce4d1c8b benchmarks: reduce 'big' task job count (#4718) 2024-11-15 15:56:41 +01:00
Ruben Fiszel
44f3dcc2b3 improve variable and resource not visible error message 2024-11-15 10:16:58 +01:00
Alexander Petric
c32038a76d feat(monitoring): workspace critical alerts (#4684)
* critical alert ui

* updating ui, backend logic

* revert

* type check fix npm

* checking out cli files from main

* moving alert icon

* adding sqlx mock data

* more sqlx changes

* feat(frontend): nodes from flow can be connected directly in expr input through a plug icon (#4652)

* Add flow prop picker

# Conflicts:
#	frontend/src/lib/components/propertyPicker/PropPicker.svelte

* fix unwanted copy

* cleaning

* Fix unset context

* move button and always display input

* fix unwanted proppicker display

* update

* update

* clean all

* clean all

---------

Co-authored-by: Ruben Fiszel <ruben@rubenfiszel.com>

* replace hide/show with toggle

* adding mutable setting and navigation to settings to configure channels

* merge fix

* ee non ee changees

* auto-acknowledge when muted

* pr comments

* fix bad log

* user inner modal component

* update unaknowledge alerts after acknowledging from modal

* aknowledge -> acknowledge

* format

* adding backend support for workspace critical alerts

* immediately check for alerts

* immediately check for alerts

* adding openapi changes

* simplify loading of superadmin/ee

* update modal logic

* frontend logic update

* sqlx prepare

* adding mute functionality for workspace critical alerts + show alerts button for instance and workspace settings

* npm / rust warnings & errors

* reverting non-mac cargo toml

* reverting non-mac cargo toml

* mute toggles in critical alert ui

* ui polish

* adding acknowledged_workspace column

* sqlx prepare

* make sure we wait for all stores to be loaded

* adding workspace mute logic to report_critical alert

* auto ack recovered alerts

* toggle workspace as superadmin and critical alerts in logs menu

* adding critical alert button if width < 786px

* refresh on change

* Move notification to logs (#4698)

* Move notification to logs

* removing unused button

---------

Co-authored-by: Alexander Petric <petric.al@gmail.com>

* tailwind typo

* auto ack on mute

* don't change deref

* sqlx prep

* avoid renaming db column, keep acknowledged instead of acknowledged_global

* hide critical alert menu when not ee

* if workspace muted, also acknowledge global when workspace set

* removing save button for mute setting and improve ux/responsiveness

* fix: deployment callbacks have a concurrency limit of 1 on same path

* sqlx prep

* ee-repo ref

* z-[9999] for modal

---------

Co-authored-by: Guilhem <guilhemlemouel@gmail.com>
Co-authored-by: Ruben Fiszel <ruben@rubenfiszel.com>
Co-authored-by: Ruben Fiszel <ruben@windmill.dev>
2024-11-15 09:48:48 +01:00
Ruben Fiszel
b868e446fc improve error messages on windows 2024-11-15 09:43:54 +01:00
pyranota
bb937498bb Fix dirs in uv install (#4717) 2024-11-15 03:03:05 +01:00
Ruben Fiszel
029462bc57 nits logs 2024-11-15 01:15:51 +01:00
Ruben Fiszel
089826e5b5 delete venv folder if pip install didn't succeed 2024-11-15 01:11:17 +01:00
pyranota
f240d1322a feat: Handle pip install by uv (#4517)
* feat: Handle `pip install` by `uv`

Dirty and untested, but already something working

* Integrate with NSJAIL and prepare fallbacks

* Refactor fallback
no_uv disable compile and install
where no_uv_install and no_uv_compile are a bit more specific

* Remove `--disable-pip-version-check`
Reason:
   warning: pip's `--disable-pip-version-check` has no effect

* Fix backend compilation error

* Pip fallback overwrite UV's cache

* Initially refactor cache (No S3)

* Support S3

* Remove unused import

* Handle flags for NSJAIL

* Return deleted flag

* Update Dockerfile

* Update docker-image.yml

* Update docker-image.yml

* Add --link-mode=copy and remove -v

* Fix NSJAIL INDEX_URL

* Fix flock and windows

* Update python_executor.rs

* Remove line from Dockerfile

We dont need it and to trigger build

* fixing for windows

* Dont pin python to specific version

* Change TMP for windows

* Revert docker-image.yml

* Disable UV for ansible

Will be enabled later.
Needs proper testing and its better to split onto 2 PRs with first modifying python and second ansible

---------

Co-authored-by: Ruben Fiszel <ruben@windmill.dev>
Co-authored-by: Alexander Petric <petric.al@gmail.com>
2024-11-15 00:12:56 +01:00
Ruben Fiszel
47facb3826 chore(main): release 1.424.0 (#4709)
* chore(main): release 1.424.0

* Apply automatic changes

---------

Co-authored-by: rubenfiszel <rubenfiszel@users.noreply.github.com>
2024-11-14 21:28:20 +01:00
Ruben Fiszel
c44fc4f5ab fix: deployment callbacks have a concurrency limit of 1 on same path 2024-11-14 21:15:26 +01:00
Ruben Fiszel
2a78359af7 fix: add countCompletedJobs api 2024-11-14 20:44:29 +01:00
Ruben Fiszel
15f1b7cf7d update openapi 2024-11-14 16:21:07 +01:00
Ruben Fiszel
180bb82643 fix: autoscaling when count < min worker set to min_workers 2024-11-14 16:09:34 +01:00
dieriba
556b4a41a1 feat: Support mistral anthropic for ai (#4692)
* wip: openai proxy and other ai proxy integration

* fixing migration script

* wip: support different ai provider in front, fix proxy openai

* wip: adding frontend ai provider

* updated copilot types

* wip: working on anthropic integration

* done AI proxy front

* adding new type and support for anthropic

* updating gitignore

* adding streaming response

* added streaming prompt

* push lib/gen

* wip: fixing anthropic

* anthropic fully supported

* fix backend missing var error and fully support stream event for anthropic

* remove gen directory

* fixing openapi file

* add support for mistral, and update create workspace components

* remove deref.json

* remove package-json

* openapi

* fix ui enable code

* added utility function for init workspace ai provider

* fix workspace switch bug

* update anthropic property and fixed frontend error

* fix workspace settings

* update error message and fix typo migration file

* chore: update openapi file

* fix dev file

* add .sqlx

* all

* update sqlx

---------

Co-authored-by: dieriba <t.dieriba@gmail.com>
Co-authored-by: Ruben Fiszel <ruben@windmill.dev>
Co-authored-by: Ruben Fiszel <ruben@rubenfiszel.com>
2024-11-14 14:24:35 +01:00
Ruben Fiszel
2a1bff3160 feat: allow setting password and login type from superadmin UI 2024-11-14 13:22:48 +01:00
Lucas Abel
50ff183bae feat(backend): monitor minimal version of living workers (#4704) 2024-11-14 12:33:50 +01:00
Lucas Abel
44ed4045f7 api: cleanup job data structure (#4705) 2024-11-14 12:25:30 +01:00
Lucas Abel
4fdca87de9 nit: cleanup raw_flow usage (#4707)
* nit: cleanup `raw_flow` usage

* nit: refactor two queries into one
2024-11-14 12:23:27 +01:00
Ruben Fiszel
10b6b1dc04 fix: add queue_couts api 2024-11-14 11:03:26 +01:00
Lucas Abel
9163060c90 nix: nit fixes + bench script (#4703) 2024-11-14 09:08:11 +01:00
Lucas Abel
2d572695ef nit: generalize usage of has_failure_module (#4706) 2024-11-14 08:49:12 +01:00
Ruben Fiszel
ca8020cf82 chore(main): release 1.423.2 (#4701)
* chore(main): release 1.423.2

* Apply automatic changes

---------

Co-authored-by: rubenfiszel <rubenfiszel@users.noreply.github.com>
2024-11-14 00:35:12 +01:00
Ruben Fiszel
84bd06e906 fix: fix intempestive expr type change in flow transform 2024-11-14 00:24:02 +01:00
Ruben Fiszel
4b8f3580a4 add LOGS_TO_STDOUT to ee 2024-11-13 23:30:52 +01:00
Alexander Petric
9c71503d74 trigger windows build (#4699) 2024-11-13 22:23:52 +01:00
Ruben Fiszel
ce01fa1677 split more ee log logic 2024-11-13 18:49:41 +01:00
Ruben Fiszel
4a39e45476 fix getUser openapi 2024-11-13 16:54:58 +01:00
Lucas Abel
60186b8c9f more benchmark methods (#4695)
* feat(backend): add `rawscript` to `add_batch_jobs` API

* bonus: improve `add_batch_jobs` API performances for `flow` kind

* add `bigrawscript` and `bigscriptinflow` to `benchmark_oneoff.ts`

* add `bigrawscript` and `bigscriptinflow` to the bench suite
2024-11-13 15:53:01 +01:00
Ruben Fiszel
9f7aa01cac chore(main): release 1.423.1 (#4693)
* chore(main): release 1.423.1

* Apply automatic changes

---------

Co-authored-by: rubenfiszel <rubenfiszel@users.noreply.github.com>
2024-11-13 11:23:44 +01:00
Ruben Fiszel
de00944f09 fix(autoscaling): autoscaling thresholds to be >= and not > 2024-11-13 00:21:45 +01:00
Ruben Fiszel
c1b220be26 chore(main): release 1.423.0 (#4691)
* chore(main): release 1.423.0

* Apply automatic changes

---------

Co-authored-by: rubenfiszel <rubenfiszel@users.noreply.github.com>
2024-11-12 23:41:33 +01:00
Ruben Fiszel
50861fccc0 fix: support multiple pip-extra-index-url with commas 2024-11-12 23:31:00 +01:00
Ruben Fiszel
5e9870a8a9 fix: prevent underflow for autoscaling scalein 2024-11-12 22:34:54 +01:00
HugoCasa
1671005100 feat: s3 input available for public apps (#4685) 2024-11-12 20:07:59 +01:00
HugoCasa
3d9ca62ab6 ts client s3 upload add content type/disposition (#4690) 2024-11-12 16:04:36 +01:00
Ruben Fiszel
c3d49a352e small nit 2024-11-12 10:46:59 +01:00
Ruben Fiszel
7784c14726 chore(main): release 1.422.1 (#4688)
* chore(main): release 1.422.1

* Apply automatic changes

---------

Co-authored-by: rubenfiszel <rubenfiszel@users.noreply.github.com>
2024-11-12 10:44:41 +01:00
Ruben Fiszel
e5e174ae95 fix: fix password inputs 2024-11-12 10:40:10 +01:00
Ruben Fiszel
1f09311a08 nit toast 2024-11-12 10:15:16 +01:00
Ruben Fiszel
d832cd8b5f chore(main): release 1.422.0 (#4678)
* chore(main): release 1.422.0

* Apply automatic changes

---------

Co-authored-by: rubenfiszel <rubenfiszel@users.noreply.github.com>
2024-11-11 10:37:17 +01:00
Ruben Fiszel
d9d63a4e31 fix autocomplete z-indez on flow editor 2024-11-11 10:34:12 +01:00
Ruben Fiszel
d44976f35e feat: expandable subflows in flows (#4683)
* all

* nit right panel flow props

* nits
2024-11-11 10:05:18 +01:00
Ruben Fiszel
02170032af fix python preprocessor indent 2024-11-10 00:05:57 +01:00
Alexander Petric
de9a839af4 improve modal loading in critical alert ui (#4679)
* critical alert ui

* updating ui, backend logic

* revert

* type check fix npm

* checking out cli files from main

* moving alert icon

* adding sqlx mock data

* more sqlx changes

* feat(frontend): nodes from flow can be connected directly in expr input through a plug icon (#4652)

* Add flow prop picker

# Conflicts:
#	frontend/src/lib/components/propertyPicker/PropPicker.svelte

* fix unwanted copy

* cleaning

* Fix unset context

* move button and always display input

* fix unwanted proppicker display

* update

* update

* clean all

* clean all

---------

Co-authored-by: Ruben Fiszel <ruben@rubenfiszel.com>

* replace hide/show with toggle

* adding mutable setting and navigation to settings to configure channels

* merge fix

* ee non ee changees

* auto-acknowledge when muted

* pr comments

* fix bad log

* user inner modal component

* update unaknowledge alerts after acknowledging from modal

* aknowledge -> acknowledge

* format

* immediately check for alerts

* immediately check for alerts

* simplify loading of superadmin/ee

* update modal logic

---------

Co-authored-by: Guilhem <guilhemlemouel@gmail.com>
Co-authored-by: Ruben Fiszel <ruben@rubenfiszel.com>
Co-authored-by: Ruben Fiszel <ruben@windmill.dev>
2024-11-09 03:50:36 +01:00
Alexander Petric
d9148eaa78 feat(frontend): critical alerts UI (#4653) 2024-11-09 00:42:10 +01:00
HugoCasa
274eb78152 http routes static assets nits (#4676) 2024-11-08 20:25:03 +01:00
Ruben Fiszel
2774d394ad nit right panel flow props 2024-11-08 19:45:37 +01:00
Ruben Fiszel
58194521b1 nit right panel flow props 2024-11-08 19:35:21 +01:00
Ruben Fiszel
0d96cfb5a8 nit right panel flow props 2024-11-08 19:20:22 +01:00
Ruben Fiszel
4c98410b83 chore(main): release 1.421.2 (#4675)
* chore(main): release 1.421.2

* Apply automatic changes

---------

Co-authored-by: rubenfiszel <rubenfiszel@users.noreply.github.com>
2024-11-08 16:57:09 +01:00
Ruben Fiszel
8c6f5a320b propagate exit code in error result when relevant 2024-11-08 16:37:58 +01:00
Ruben Fiszel
8bc9a021a8 fix(bash): correctly propagate exit errors 2024-11-08 16:33:01 +01:00
Ruben Fiszel
1c7d295c52 chore(main): release 1.421.1 (#4674)
* chore(main): release 1.421.1

* Apply automatic changes

---------

Co-authored-by: rubenfiszel <rubenfiszel@users.noreply.github.com>
2024-11-08 15:43:28 +01:00
Ruben Fiszel
237307ba18 nits client 2024-11-08 15:43:03 +01:00
Ruben Fiszel
3ea12f1821 fix(python-client): fix small break params of write_s3_file 2024-11-08 15:35:41 +01:00
Ruben Fiszel
ae70c37363 nit app focus 2024-11-08 12:08:30 +01:00
VuRsd
2eb9cfd0a9 add pysocks import handling
add PySocks dependency mapping
2024-11-08 09:56:28 +01:00
Ruben Fiszel
f9eb64aae2 nit flow inputs 2024-11-07 23:30:48 +01:00
Ruben Fiszel
fbdeb6be09 nits plug sleep & suspend groups 2024-11-07 22:06:39 +01:00
190 changed files with 8863 additions and 2744 deletions

View File

@@ -0,0 +1,58 @@
name: Build windows executable for this branch
on:
workflow_dispatch:
env:
CARGO_INCREMENTAL: 0
SQLX_OFFLINE: true
DISABLE_EMBEDDING: true
RUST_LOG: info
jobs:
cargo_build_windows:
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
- name: Read EE repo commit hash
shell: pwsh
run: |
$ee_repo_ref = Get-Content .\backend\ee-repo-ref.txt
echo "ee_repo_ref=$ee_repo_ref" | Out-File -FilePath $env:GITHUB_ENV -Append
- name: Checkout windmill-ee-private repository
uses: actions/checkout@v4
with:
repository: windmill-labs/windmill-ee-private
path: ./windmill-ee-private
ref: ${{ env.ee_repo_ref }}
token: ${{ secrets.WINDMILL_EE_PRIVATE_ACCESS }}
fetch-depth: 0
- name: Substitute EE code
shell: bash
run: |
./backend/substitute_ee_code.sh --copy --dir ./windmill-ee-private
- name: Cargo build windows
timeout-minutes: 90
run: |
vcpkg.exe install openssl-windows:x64-windows
vcpkg.exe install openssl:x64-windows-static
vcpkg.exe integrate install
$env:VCPKGRS_DYNAMIC=1
$env:OPENSSL_DIR="${Env:VCPKG_INSTALLATION_ROOT}\installed\x64-windows-static"
mkdir frontend/build && cd backend
New-Item -Path . -Name "windmill-api/openapi-deref.yaml" -ItemType "File" -Force
cargo build --release --features=enterprise,stripe,embedding,parquet,prometheus,openidconnect,cloud,jemalloc,tantivy,deno_core
- name: Rename binary with corresponding architecture
run: |
Rename-Item -Path ".\backend\target\release\windmill.exe" -NewName "windmill-ee.exe"
- name: Upload artifact
uses: actions/upload-artifact@v3
with:
name: windmill-ee-binary
path: ./backend/target/release/windmill-ee.exe

2
.gitignore vendored
View File

@@ -6,4 +6,4 @@ frontend/src/routes/test.svelte
CaddyfileRemoteMalo
*.swp
**/.idea/
.direnv
.direnv

View File

@@ -1,5 +1,78 @@
# Changelog
## [1.424.0](https://github.com/windmill-labs/windmill/compare/v1.423.2...v1.424.0) (2024-11-14)
### Features
* allow setting password and login type from superadmin UI ([2a1bff3](https://github.com/windmill-labs/windmill/commit/2a1bff3160b65eadba399229b5f53950ec2c0d48))
* **backend:** monitor minimal version of living workers ([#4704](https://github.com/windmill-labs/windmill/issues/4704)) ([50ff183](https://github.com/windmill-labs/windmill/commit/50ff183baeaa2a39c2b0188ee8dd6172b08ae801))
* Support mistral anthropic for ai ([#4692](https://github.com/windmill-labs/windmill/issues/4692)) ([556b4a4](https://github.com/windmill-labs/windmill/commit/556b4a41a1d010bb8a1796f485d343c1e412d278))
### Bug Fixes
* add countCompletedJobs api ([2a78359](https://github.com/windmill-labs/windmill/commit/2a78359af7b8e4700634c2ad2745c1d14d2da4f7))
* add queue_couts api ([10b6b1d](https://github.com/windmill-labs/windmill/commit/10b6b1dc04cb2b2d4ec5ceb26a5dbd2ac7bd1394))
* autoscaling when count &lt; min worker set to min_workers ([180bb82](https://github.com/windmill-labs/windmill/commit/180bb826436f9960def8c6cf3e9692714ffdf917))
* deployment callbacks have a concurrency limit of 1 on same path ([c44fc4f](https://github.com/windmill-labs/windmill/commit/c44fc4f5ab8bcb5dffec08d5ed4ccd61feb1cb13))
## [1.423.2](https://github.com/windmill-labs/windmill/compare/v1.423.1...v1.423.2) (2024-11-13)
### Bug Fixes
* fix intempestive expr type change in flow transform ([84bd06e](https://github.com/windmill-labs/windmill/commit/84bd06e90650e949c11e2b09447a7bad1ab60a95))
## [1.423.1](https://github.com/windmill-labs/windmill/compare/v1.423.0...v1.423.1) (2024-11-12)
### Bug Fixes
* **autoscaling:** autoscaling thresholds to be &gt;= and not > ([de00944](https://github.com/windmill-labs/windmill/commit/de00944f09699ff33a382f5a14f515ccf90b2454))
## [1.423.0](https://github.com/windmill-labs/windmill/compare/v1.422.1...v1.423.0) (2024-11-12)
### Features
* s3 input available for public apps ([#4685](https://github.com/windmill-labs/windmill/issues/4685)) ([1671005](https://github.com/windmill-labs/windmill/commit/167100510032ab53cd609fb2c7629e67faceb093))
### Bug Fixes
* prevent underflow for autoscaling scalein ([5e9870a](https://github.com/windmill-labs/windmill/commit/5e9870a8a993032ec7d45c62e0023008da42c74a))
* support multiple pip-extra-index-url with commas ([50861fc](https://github.com/windmill-labs/windmill/commit/50861fccc0cd86d4c76120c3306adf94f375330e))
## [1.422.1](https://github.com/windmill-labs/windmill/compare/v1.422.0...v1.422.1) (2024-11-12)
### Bug Fixes
* fix password inputs ([e5e174a](https://github.com/windmill-labs/windmill/commit/e5e174ae9516f4c6b94ceb6e258b467f5c9a1f1a))
## [1.422.0](https://github.com/windmill-labs/windmill/compare/v1.421.2...v1.422.0) (2024-11-11)
### Features
* expandable subflows in flows ([#4683](https://github.com/windmill-labs/windmill/issues/4683)) ([d44976f](https://github.com/windmill-labs/windmill/commit/d44976f35e45ade510d1ec220b5a1503e11f3db9))
* **frontend:** critical alerts UI ([#4653](https://github.com/windmill-labs/windmill/issues/4653)) ([d9148ea](https://github.com/windmill-labs/windmill/commit/d9148eaa78680a93d81d71847a7df67e01f3c110))
## [1.421.2](https://github.com/windmill-labs/windmill/compare/v1.421.1...v1.421.2) (2024-11-08)
### Bug Fixes
* **bash:** correctly propagate exit errors ([8bc9a02](https://github.com/windmill-labs/windmill/commit/8bc9a021a88391147c9170f56b5a0edddd55bc7d))
## [1.421.1](https://github.com/windmill-labs/windmill/compare/v1.421.0...v1.421.1) (2024-11-08)
### Bug Fixes
* **python-client:** fix small break params of write_s3_file ([3ea12f1](https://github.com/windmill-labs/windmill/commit/3ea12f1821500e8b84549b892e3e1bb56a6ace4b))
## [1.421.0](https://github.com/windmill-labs/windmill/compare/v1.420.1...v1.421.0) (2024-11-07)

2
backend/.gitignore vendored
View File

@@ -2,7 +2,7 @@ target/
.env
oauth.json
oauth2.json
windmill-api/openapi-deref.yaml
tracing.folded
heaptrack*
index/
windmill-api/openapi-*.*

View File

@@ -0,0 +1,14 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE alerts SET acknowledged_workspace = true, acknowledged = true WHERE workspace_id = $1",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text"
]
},
"nullable": []
},
"hash": "00588a40dde5189ac1c61505f17acb0f4c244c60477427505bf5bd1b104d3bf9"
}

View File

@@ -1,14 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO alerts (alert_type, message) VALUES ('recovered_critical_error', $1)",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text"
]
},
"nullable": []
},
"hash": "00ce4ed3ca0eac7cb6283b047353a64b9e78c4beb423f04baef9a53fbf87e9f9"
}

View File

@@ -1,12 +1,12 @@
{
"db_name": "PostgreSQL",
"query": "SELECT openai_resource_path, code_completion_enabled FROM workspace_settings WHERE workspace_id = $1",
"query": "SELECT ai_resource, code_completion_enabled FROM workspace_settings WHERE workspace_id = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "openai_resource_path",
"type_info": "Varchar"
"name": "ai_resource",
"type_info": "Jsonb"
},
{
"ordinal": 1,
@@ -24,5 +24,5 @@
false
]
},
"hash": "ceb97024ebf1a1c00ea1ed4952f66b229ca4622c537cc252d8ed52b4d24270ee"
"hash": "0230bd64d2b6719c3536a106e18a68c7b43b3a9a9efa92b5517132e9c0d8a25b"
}

View File

@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "SELECT\n -- slack_team_id, \n -- slack_name, \n -- slack_command_script, \n -- CASE WHEN slack_email = 'missing@email.xyz' THEN NULL ELSE slack_email END AS slack_email,\n auto_invite_domain IS NOT NULL AS \"auto_invite_enabled!\",\n CASE WHEN auto_invite_operator IS TRUE THEN 'operator' ELSE 'developer' END AS \"auto_invite_as!\", \n CASE WHEN auto_add IS TRUE THEN 'add' ELSE 'invite' END AS \"auto_invite_mode!\", \n webhook, \n deploy_to, \n error_handler, \n openai_resource_path, \n code_completion_enabled, \n error_handler_extra_args, \n error_handler_muted_on_cancel, \n large_file_storage, \n git_sync,\n default_app,\n default_scripts,\n workspace.name\n FROM workspace_settings\n LEFT JOIN workspace ON workspace.id = workspace_settings.workspace_id\n WHERE workspace_id = $1",
"query": "SELECT\n -- slack_team_id, \n -- slack_name, \n -- slack_command_script, \n -- CASE WHEN slack_email = 'missing@email.xyz' THEN NULL ELSE slack_email END AS slack_email,\n auto_invite_domain IS NOT NULL AS \"auto_invite_enabled!\",\n CASE WHEN auto_invite_operator IS TRUE THEN 'operator' ELSE 'developer' END AS \"auto_invite_as!\", \n CASE WHEN auto_add IS TRUE THEN 'add' ELSE 'invite' END AS \"auto_invite_mode!\", \n webhook, \n deploy_to, \n error_handler, \n ai_resource, \n code_completion_enabled, \n error_handler_extra_args, \n error_handler_muted_on_cancel, \n large_file_storage, \n git_sync,\n default_app,\n default_scripts,\n workspace.name\n FROM workspace_settings\n LEFT JOIN workspace ON workspace.id = workspace_settings.workspace_id\n WHERE workspace_id = $1",
"describe": {
"columns": [
{
@@ -35,8 +35,8 @@
},
{
"ordinal": 6,
"name": "openai_resource_path",
"type_info": "Varchar"
"name": "ai_resource",
"type_info": "Jsonb"
},
{
"ordinal": 7,
@@ -102,5 +102,5 @@
false
]
},
"hash": "188534f4b29f6461b1a6214d060f183c830b19a403ebb7b8be55a691675010c3"
"hash": "0331a81262e2d3c1bcfaeb64617b11eb68ab4599d64e5e5af639a9ac5d791fd0"
}

View File

@@ -1,16 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE workspace_settings SET openai_resource_path = $1, code_completion_enabled = $2 WHERE workspace_id = $3",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Bool",
"Text"
]
},
"nullable": []
},
"hash": "034583442e6f8ae38d6c4e4aac26f17c8d9d0e657f28276228fc90d3e22e1304"
}

View File

@@ -0,0 +1,18 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO alerts (alert_type, message, acknowledged, acknowledged_workspace, workspace_id, resource)\n VALUES ('critical_error', $1, $2, $3, $4, $5)",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Bool",
"Bool",
"Text",
"Text"
]
},
"nullable": []
},
"hash": "044e2b428ee6e2dd4543c87ad8835e239cf7567d18b8b3fa6608ea3a9d206ca7"
}

View File

@@ -1,14 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM healthchecks WHERE check_type = $1",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text"
]
},
"nullable": []
},
"hash": "0ee63ef2dd5c88edba2a1f56d31f29876724922f148dad7af35b36efbf70207a"
}

View File

@@ -0,0 +1,16 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE workspace_settings SET ai_resource = $1, code_completion_enabled = $2 WHERE workspace_id = $3",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Jsonb",
"Bool",
"Text"
]
},
"nullable": []
},
"hash": "1610c79b8d238e3a35d795f34a2a0100b933b7f199d41cc2f554d57c811d55f3"
}

View File

@@ -65,8 +65,8 @@
},
{
"ordinal": 12,
"name": "openai_resource_path",
"type_info": "Varchar"
"name": "ai_resource",
"type_info": "Jsonb"
},
{
"ordinal": 13,
@@ -117,6 +117,11 @@
"ordinal": 22,
"name": "deploy_ui",
"type_info": "Jsonb"
},
{
"ordinal": 23,
"name": "mute_critical_alerts",
"type_info": "Bool"
}
],
"parameters": {
@@ -147,6 +152,7 @@
true,
false,
true,
true,
true
]
},

View File

@@ -0,0 +1,29 @@
{
"db_name": "PostgreSQL",
"query": "SELECT\n flow_status AS \"flow_status!: Json<Box<RawValue>>\",\n raw_flow->'modules'->(flow_status->'step')::int AS \"module: Json<Box<RawValue>>\"\n FROM queue WHERE id = $1 AND workspace_id = $2 LIMIT 1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "flow_status!: Json<Box<RawValue>>",
"type_info": "Jsonb"
},
{
"ordinal": 1,
"name": "module: Json<Box<RawValue>>",
"type_info": "Jsonb"
}
],
"parameters": {
"Left": [
"Uuid",
"Text"
]
},
"nullable": [
true,
null
]
},
"hash": "1e7ce0c140410ae799f9c0c5772e4be4506bfd238c88f1b1c3ddf39b29071446"
}

View File

@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "SELECT value\n FROM resource\n WHERE path = $1 AND workspace_id = $2",
"query": "SELECT value\n FROM resource\n WHERE path = $1 AND workspace_id = $2",
"describe": {
"columns": [
{
@@ -19,5 +19,5 @@
true
]
},
"hash": "acdaa5151f8f7f37bb8c8c5a7d146789887e47db9695fc26b1dfaedd735e1e60"
"hash": "286fa00c088df146c08c1934556f06a4243c66787ab8759a8045e60effd2fb77"
}

View File

@@ -0,0 +1,18 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO alerts (alert_type, message, acknowledged, acknowledged_workspace, workspace_id, resource)\n VALUES ('recovered_critical_error', $1, $2, $3, $4, $5)",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Bool",
"Bool",
"Text",
"Text"
]
},
"nullable": []
},
"hash": "28987898c7e4b172d466bf08f33c2da733e71f8a253b0122c17b9e021919809e"
}

View File

@@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "SELECT raw_flow->'failure_module' != 'null'::jsonb\n FROM completed_job\n WHERE id = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "?column?",
"type_info": "Bool"
}
],
"parameters": {
"Left": [
"Uuid"
]
},
"nullable": [
null
]
},
"hash": "31dd23f6768052e486668172e15eba1a3f9b6a8e5678a7ee8e5456ff4405d6f9"
}

View File

@@ -0,0 +1,54 @@
{
"db_name": "PostgreSQL",
"query": "SELECT id, alert_type, message, created_at, COALESCE(acknowledged, false) AS acknowledged, workspace_id\n FROM alerts\n WHERE COALESCE(acknowledged, false) = $1\n ORDER BY created_at DESC\n LIMIT $2 OFFSET $3",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Int4"
},
{
"ordinal": 1,
"name": "alert_type",
"type_info": "Varchar"
},
{
"ordinal": 2,
"name": "message",
"type_info": "Text"
},
{
"ordinal": 3,
"name": "created_at",
"type_info": "Timestamptz"
},
{
"ordinal": 4,
"name": "acknowledged",
"type_info": "Bool"
},
{
"ordinal": 5,
"name": "workspace_id",
"type_info": "Text"
}
],
"parameters": {
"Left": [
"Bool",
"Int8",
"Int8"
]
},
"nullable": [
false,
false,
false,
false,
null,
true
]
},
"hash": "344b3a5d9683273a956b5156fed5ab9fdff1a7252198e4ae290d8c8f937ff177"
}

View File

@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE password SET login_type = $1 WHERE email = $2",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Text"
]
},
"nullable": []
},
"hash": "349396e8fdd96d45875110bc06767e6eb876792ec8f83e9b03c2fb46bb12e0b9"
}

View File

@@ -0,0 +1,14 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE alerts SET acknowledged = true, acknowledged_workspace = true WHERE resource = $1 AND alert_type = 'critical_error'",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text"
]
},
"nullable": []
},
"hash": "4a3a8207627418ba7b7eabaccd7ec72884f2a1eacf0ab0e0c6ccbf56c654c14a"
}

View File

@@ -1,14 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO alerts (alert_type, message) VALUES ('critical_error', $1)",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text"
]
},
"nullable": []
},
"hash": "4c0067c2135a259aea5cc2db60f7375a9a33671be8ef406427d90f67a98c9c9f"
}

View File

@@ -0,0 +1,53 @@
{
"db_name": "PostgreSQL",
"query": "SELECT id, alert_type, message, created_at, COALESCE(acknowledged, false) AS acknowledged, workspace_id\n FROM alerts\n ORDER BY created_at DESC\n LIMIT $1 OFFSET $2",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Int4"
},
{
"ordinal": 1,
"name": "alert_type",
"type_info": "Varchar"
},
{
"ordinal": 2,
"name": "message",
"type_info": "Text"
},
{
"ordinal": 3,
"name": "created_at",
"type_info": "Timestamptz"
},
{
"ordinal": 4,
"name": "acknowledged",
"type_info": "Bool"
},
{
"ordinal": 5,
"name": "workspace_id",
"type_info": "Text"
}
],
"parameters": {
"Left": [
"Int8",
"Int8"
]
},
"nullable": [
false,
false,
false,
false,
null,
true
]
},
"hash": "4d30c5a2894d655741d167f5589f5816583f0bae78a0dd3270dedb377dfa944a"
}

View File

@@ -65,8 +65,8 @@
},
{
"ordinal": 12,
"name": "openai_resource_path",
"type_info": "Varchar"
"name": "ai_resource",
"type_info": "Jsonb"
},
{
"ordinal": 13,
@@ -117,6 +117,11 @@
"ordinal": 22,
"name": "deploy_ui",
"type_info": "Jsonb"
},
{
"ordinal": 23,
"name": "mute_critical_alerts",
"type_info": "Bool"
}
],
"parameters": {
@@ -147,6 +152,7 @@
true,
false,
true,
true,
true
]
},

View File

@@ -1,15 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE workspace_settings SET openai_resource_path = NULL, code_completion_enabled = $1 WHERE workspace_id = $2",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Bool",
"Text"
]
},
"nullable": []
},
"hash": "6268eabd561502a44e273d6391102278974623f7a7bcad6891d7abadf4d3ea03"
}

View File

@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE alerts\n SET\n acknowledged = true,\n acknowledged_workspace = CASE\n WHEN $2::text IS NOT NULL AND workspace_id = $2 THEN true\n ELSE acknowledged_workspace\n END\n WHERE id = $1",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Int4",
"Text"
]
},
"nullable": []
},
"hash": "65da41c7ded54cdee8d33211561c068b72294cc99ff44ed0a13179df508ebc6a"
}

View File

@@ -5,7 +5,7 @@
"columns": [
{
"ordinal": 0,
"name": "bool",
"name": "?column?",
"type_info": "Bool"
}
],

View File

@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE workspace_settings SET ai_resource = NULL, code_completion_enabled = $1 WHERE workspace_id = $2",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Bool",
"Text"
]
},
"nullable": []
},
"hash": "6940ddc5ee8d2a1048213d46f52d964b199604ba66dedbe9f89cea727d726a2d"
}

View File

@@ -0,0 +1,54 @@
{
"db_name": "PostgreSQL",
"query": "SELECT id, alert_type, message, created_at, COALESCE(acknowledged_workspace, false) AS acknowledged, workspace_id\n FROM alerts\n WHERE workspace_id = $1\n ORDER BY created_at DESC\n LIMIT $2 OFFSET $3",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Int4"
},
{
"ordinal": 1,
"name": "alert_type",
"type_info": "Varchar"
},
{
"ordinal": 2,
"name": "message",
"type_info": "Text"
},
{
"ordinal": 3,
"name": "created_at",
"type_info": "Timestamptz"
},
{
"ordinal": 4,
"name": "acknowledged",
"type_info": "Bool"
},
{
"ordinal": 5,
"name": "workspace_id",
"type_info": "Text"
}
],
"parameters": {
"Left": [
"Text",
"Int8",
"Int8"
]
},
"nullable": [
false,
false,
false,
false,
null,
true
]
},
"hash": "777e7084edf9a5d14f299ff479ae43c8ead47a6a531d4b031a1342c9b2f16506"
}

View File

@@ -0,0 +1,14 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE alerts \n SET\n acknowledged = true,\n acknowledged_workspace = CASE\n WHEN $1::text IS NOT NULL THEN true\n ELSE acknowledged_workspace\n END\n WHERE ($1::text IS NOT NULL AND workspace_id = $1)\n OR ($1::text IS NULL)",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text"
]
},
"nullable": []
},
"hash": "7c32176755c6ea2b6ae531860d436caae3fa256fc0803749ec5107632669adb3"
}

View File

@@ -0,0 +1,55 @@
{
"db_name": "PostgreSQL",
"query": "SELECT id, alert_type, message, created_at, COALESCE(acknowledged_workspace, false) AS acknowledged, workspace_id\n FROM alerts\n WHERE workspace_id = $1 AND COALESCE(acknowledged_workspace, false) = $2\n ORDER BY created_at DESC\n LIMIT $3 OFFSET $4",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Int4"
},
{
"ordinal": 1,
"name": "alert_type",
"type_info": "Varchar"
},
{
"ordinal": 2,
"name": "message",
"type_info": "Text"
},
{
"ordinal": 3,
"name": "created_at",
"type_info": "Timestamptz"
},
{
"ordinal": 4,
"name": "acknowledged",
"type_info": "Bool"
},
{
"ordinal": 5,
"name": "workspace_id",
"type_info": "Text"
}
],
"parameters": {
"Left": [
"Text",
"Bool",
"Int8",
"Int8"
]
},
"nullable": [
false,
false,
false,
false,
null,
true
]
},
"hash": "7c5a29de07cbe42326a15d4d7fd9714c20b767508e27368be6de38d7b18a3a4d"
}

View File

@@ -0,0 +1,12 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE alerts SET acknowledged = true",
"describe": {
"columns": [],
"parameters": {
"Left": []
},
"nullable": []
},
"hash": "a59fae29ebcc9aa53308b777ead3d2b652618ac454f139db738f4499cb4eb079"
}

View File

@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "WITH uuid_table as (\n select gen_random_uuid() as uuid from generate_series(1, $11)\n )\n INSERT INTO queue \n (id, script_hash, script_path, job_kind, language, args, tag, created_by, permissioned_as, email, scheduled_for, workspace_id, concurrent_limit, concurrency_time_window_s, timeout)\n (SELECT uuid, $1, $2, $3, $4, ('{ \"uuid\": \"' || uuid || '\" }')::jsonb, $5, $6, $7, $8, $9, $10, $12, $13, $14 FROM uuid_table) \n RETURNING id",
"query": "WITH uuid_table as (\n select gen_random_uuid() as uuid from generate_series(1, $11)\n )\n INSERT INTO queue \n (id, script_hash, script_path, job_kind, language, args, tag, created_by, permissioned_as, email, scheduled_for, workspace_id, concurrent_limit, concurrency_time_window_s, timeout, raw_code, raw_lock, raw_flow, flow_status)\n (SELECT uuid, $1, $2, $3, $4, ('{ \"uuid\": \"' || uuid || '\" }')::jsonb, $5, $6, $7, $8, $9, $10, $12, $13, $14, $15, $16, $17, $18 FROM uuid_table) \n RETURNING id",
"describe": {
"columns": [
{
@@ -72,12 +72,16 @@
"Int4",
"Int4",
"Int4",
"Int4"
"Int4",
"Text",
"Text",
"Jsonb",
"Jsonb"
]
},
"nullable": [
false
]
},
"hash": "0a686ca61444d7ad7484071727aa039a6ea6697e5a49a633b767c052aa3e0a18"
"hash": "a69ba7471d1a5faa145bebbd17d43e6dbe02e9e92ebbc31a50c99c3a04719284"
}

View File

@@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "SELECT raw_flow->'failure_module' != 'null'::jsonb\n FROM queue\n WHERE id = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "?column?",
"type_info": "Bool"
}
],
"parameters": {
"Left": [
"Uuid"
]
},
"nullable": [
null
]
},
"hash": "aa6907b7266ee437dfbd77a9bf6c047fda88f3e72a0d10323a5e4020cf857c42"
}

View File

@@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "SELECT wm_version FROM worker_ping WHERE wm_version != $1 AND ping_at > now() - interval '5 minutes'",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "wm_version",
"type_info": "Varchar"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
false
]
},
"hash": "ad03e5acf10ef94abc37cb9f56b1775c67f075c8bc83458e8be2e242347218d6"
}

View File

@@ -0,0 +1,23 @@
{
"db_name": "PostgreSQL",
"query": "SELECT flow_version.value AS \"value: sqlx::types::Json<Box<RawValue>>\" FROM flow \n LEFT JOIN flow_version\n ON flow_version.id = flow.versions[array_upper(flow.versions, 1)]\n WHERE flow.path = $1 AND flow.workspace_id = $2",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "value: sqlx::types::Json<Box<RawValue>>",
"type_info": "Jsonb"
}
],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": [
false
]
},
"hash": "ae543dfa106fa6ad4e9bf45cda1110d4702a80f455c63af6fcbd7cf45bbc4f7a"
}

View File

@@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "SELECT ai_resource FROM workspace_settings WHERE workspace_id = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "ai_resource",
"type_info": "Jsonb"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
true
]
},
"hash": "ceda377fa534656ac12a7f41db77db1f054ec751855c8db7352b0e9c20b63ef8"
}

View File

@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "SELECT raw_flow->'modules'->($1)->'value'->>'type' = 'flow' FROM queue WHERE id = $2",
"query": "SELECT raw_flow->'modules'->($1)::text->'value'->>'type' = 'flow' FROM queue WHERE id = $2 LIMIT 1",
"describe": {
"columns": [
{
@@ -19,5 +19,5 @@
null
]
},
"hash": "3e539fef054ad31bc1736e27276087775a721a6ee7ae35b03fd4ce3563ea3838"
"hash": "de1abe57b6aa61155f747a3bcb98359f70eade6654b5a884915f07f3ef3fe15e"
}

View File

@@ -1,12 +1,12 @@
{
"db_name": "PostgreSQL",
"query": "SELECT openai_resource_path FROM workspace_settings WHERE workspace_id = $1",
"query": "SELECT mute_critical_alerts FROM workspace_settings WHERE workspace_id = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "openai_resource_path",
"type_info": "Varchar"
"name": "mute_critical_alerts",
"type_info": "Bool"
}
],
"parameters": {
@@ -18,5 +18,5 @@
true
]
},
"hash": "f8b689ec3e09f14dc02ac9e9f98d252a14d3a2acfadcd9cd6ba27ca9799f4706"
"hash": "e05dbe046e846c092a96b6b0a9d872c721169d418586be2d15cee6b929049098"
}

View File

@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE workspace_settings SET mute_critical_alerts = $1 WHERE workspace_id = $2",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Bool",
"Text"
]
},
"nullable": []
},
"hash": "f22168826350797e88153b65a5b1e906a7868f34c062f154b2ee4f24c57aa5c3"
}

368
backend/Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
[package]
name = "windmill"
version = "1.421.0"
version = "1.424.0"
authors.workspace = true
edition.workspace = true
@@ -29,7 +29,7 @@ members = [
]
[workspace.package]
version = "1.421.0"
version = "1.424.0"
authors = ["Ruben Fiszel <ruben@windmill.dev>"]
edition = "2021"
@@ -180,6 +180,7 @@ tokio-util = { version = "^0", features = ["io"] }
json-pointer = "^0"
itertools = "^0"
regex = "^1"
semver = "^1"
deno_fetch = "0.195.0"
deno_tls = "0.158.0"

View File

@@ -1 +1 @@
6534b0f31fb4a75dd706fca2ce91e37e77e4ad02
51dcbf93b0d127af9f33fa346cc63fcd2475d4fa

View File

@@ -0,0 +1,7 @@
-- Add down migration script here
ALTER TABLE workspace_settings
ALTER COLUMN ai_resource TYPE text
USING ai_resource->>'path';
ALTER TABLE workspace_settings
RENAME COLUMN ai_resource to openai_resource_path;

View File

@@ -0,0 +1,7 @@
-- Add up migration script here
ALTER TABLE workspace_settings
ALTER COLUMN openai_resource_path TYPE jsonb
USING jsonb_build_object('provider', 'openai', 'path', openai_resource_path);
ALTER TABLE workspace_settings
RENAME COLUMN openai_resource_path TO ai_resource;

View File

@@ -0,0 +1 @@
ALTER TABLE alerts DROP COLUMN acknowledged;

View File

@@ -0,0 +1,9 @@
-- Step 1: Add the new column 'acknowledged' to the 'alerts' table
ALTER TABLE alerts
ADD COLUMN acknowledged BOOLEAN DEFAULT NULL;
-- Step 2: Update all existing rows to set 'acknowledged' to true
-- we don't want to pop up notifications to all users after migrations
-- but only show new alerts from the point of the upgrade
UPDATE alerts
SET acknowledged = TRUE;

View File

@@ -0,0 +1,4 @@
ALTER TABLE alerts DROP COLUMN workspace_id;
ALTER TABLE alerts DROP COLUMN acknowledged_workspace;
ALTER TABLE alerts DROP COLUMN resource;
ALTER TABLE workspace_settings DROP COLUMN mute_critical_alerts;

View File

@@ -0,0 +1,4 @@
ALTER TABLE alerts ADD COLUMN workspace_id TEXT DEFAULT NULL;
ALTER TABLE alerts ADD COLUMN acknowledged_workspace BOOL DEFAULT NULL;
ALTER TABLE alerts ADD COLUMN resource TEXT DEFAULT NULL;
ALTER TABLE workspace_settings ADD COLUMN mute_critical_alerts BOOL DEFAULT NULL;

View File

@@ -62,6 +62,7 @@ static PYTHON_IMPORTS_REPLACEMENT: phf::Map<&'static str, &'static str> = phf_ma
"lokalise" => "python-lokalise-api",
"msgraph" => "msgraph-sdk",
"pythonjsonlogger" => "python-json-logger",
"socks" => "PySocks",
};
fn replace_import(x: String) -> String {

View File

@@ -7,7 +7,6 @@
*/
use anyhow::Context;
use git_version::git_version;
use monitor::{
reload_timeout_wait_result_setting, send_current_log_file_to_object_store,
send_logs_to_object_store,
@@ -31,18 +30,19 @@ use windmill_common::ee::{maybe_renew_license_key_on_start, LICENSE_KEY_ID, LICE
use windmill_common::{
global_settings::{
BASE_URL_SETTING, BUNFIG_INSTALL_SCOPES_SETTING, CRITICAL_ERROR_CHANNELS_SETTING,
CUSTOM_TAGS_SETTING, DEFAULT_TAGS_PER_WORKSPACE_SETTING, DEFAULT_TAGS_WORKSPACES_SETTING,
ENV_SETTINGS, EXPOSE_DEBUG_METRICS_SETTING, EXPOSE_METRICS_SETTING,
EXTRA_PIP_INDEX_URL_SETTING, HUB_BASE_URL_SETTING, JOB_DEFAULT_TIMEOUT_SECS_SETTING,
JWT_SECRET_SETTING, KEEP_JOB_DIR_SETTING, LICENSE_KEY_SETTING, NPM_CONFIG_REGISTRY_SETTING,
OAUTH_SETTING, PIP_INDEX_URL_SETTING, REQUEST_SIZE_LIMIT_SETTING,
REQUIRE_PREEXISTING_USER_FOR_OAUTH_SETTING, RETENTION_PERIOD_SECS_SETTING,
SAML_METADATA_SETTING, SCIM_TOKEN_SETTING, SMTP_SETTING, TIMEOUT_WAIT_RESULT_SETTING,
BASE_URL_SETTING, BUNFIG_INSTALL_SCOPES_SETTING, CRITICAL_ALERT_MUTE_UI_SETTING,
CRITICAL_ERROR_CHANNELS_SETTING, CUSTOM_TAGS_SETTING, DEFAULT_TAGS_PER_WORKSPACE_SETTING,
DEFAULT_TAGS_WORKSPACES_SETTING, ENV_SETTINGS, EXPOSE_DEBUG_METRICS_SETTING,
EXPOSE_METRICS_SETTING, EXTRA_PIP_INDEX_URL_SETTING, HUB_BASE_URL_SETTING,
JOB_DEFAULT_TIMEOUT_SECS_SETTING, JWT_SECRET_SETTING, KEEP_JOB_DIR_SETTING,
LICENSE_KEY_SETTING, NPM_CONFIG_REGISTRY_SETTING, OAUTH_SETTING, PIP_INDEX_URL_SETTING,
REQUEST_SIZE_LIMIT_SETTING, REQUIRE_PREEXISTING_USER_FOR_OAUTH_SETTING,
RETENTION_PERIOD_SECS_SETTING, SAML_METADATA_SETTING, SCIM_TOKEN_SETTING, SMTP_SETTING,
TIMEOUT_WAIT_RESULT_SETTING,
},
scripts::ScriptLang,
stats_ee::schedule_stats,
utils::{hostname, rd_string, Mode},
utils::{hostname, rd_string, Mode, GIT_VERSION},
worker::{reload_custom_tags_setting, HUB_CACHE_DIR, TMP_DIR, WORKER_GROUP},
DB, METRICS_ENABLED,
};
@@ -67,16 +67,17 @@ use windmill_worker::{
get_hub_script_content_and_requirements, BUN_BUNDLE_CACHE_DIR, BUN_CACHE_DIR,
BUN_DEPSTAR_CACHE_DIR, DENO_CACHE_DIR, DENO_CACHE_DIR_DEPS, DENO_CACHE_DIR_NPM,
GO_BIN_CACHE_DIR, GO_CACHE_DIR, LOCK_CACHE_DIR, PIP_CACHE_DIR, POWERSHELL_CACHE_DIR,
RUST_CACHE_DIR, TAR_PIP_CACHE_DIR, TMP_LOGS_DIR, UV_CACHE_DIR,
PY311_CACHE_DIR, RUST_CACHE_DIR, TAR_PIP_CACHE_DIR, TMP_LOGS_DIR, UV_CACHE_DIR,
};
use crate::monitor::{
initial_load, load_keep_job_dir, load_metrics_debug_enabled, load_require_preexisting_user,
load_tag_per_workspace_enabled, load_tag_per_workspace_workspaces, monitor_db, monitor_pool,
reload_base_url_setting, reload_bunfig_install_scopes_setting,
reload_critical_error_channels_setting, reload_extra_pip_index_url_setting,
reload_hub_base_url_setting, reload_job_default_timeout_setting, reload_jwt_secret_setting,
reload_license_key, reload_npm_config_registry_setting, reload_pip_index_url_setting,
reload_critical_alert_mute_ui_setting, reload_critical_error_channels_setting,
reload_extra_pip_index_url_setting, reload_hub_base_url_setting,
reload_job_default_timeout_setting, reload_jwt_secret_setting, reload_license_key,
reload_npm_config_registry_setting, reload_pip_index_url_setting,
reload_retention_period_setting, reload_scim_token_setting, reload_smtp_config,
reload_worker_config,
};
@@ -84,7 +85,6 @@ use crate::monitor::{
#[cfg(feature = "parquet")]
use crate::monitor::reload_s3_cache_setting;
const GIT_VERSION: &str = git_version!(args = ["--tag", "--always"], fallback = "unknown-version");
const DEFAULT_NUM_WORKERS: usize = 1;
const DEFAULT_PORT: u16 = 8000;
const DEFAULT_SERVER_BIND_ADDR: Ipv4Addr = Ipv4Addr::new(0, 0, 0, 0);
@@ -817,6 +817,12 @@ Windmill Community Edition {GIT_VERSION}
tracing::error!(error = %e, "Could not reload jwt secret setting");
}
},
CRITICAL_ALERT_MUTE_UI_SETTING => {
tracing::info!("Critical alert UI setting changed");
if let Err(e) = reload_critical_alert_mute_ui_setting(&db).await {
tracing::error!(error = %e, "Could not reload critical alert UI setting");
}
},
a @_ => {
tracing::info!("Unrecognized Global Setting Change Payload: {:?}", a);
}
@@ -991,13 +997,14 @@ pub async fn run_workers<R: rsmq_async::RsmqConnection + Send + Sync + Clone + '
for x in [
LOCK_CACHE_DIR,
TMP_LOGS_DIR,
PIP_CACHE_DIR,
UV_CACHE_DIR,
TAR_PIP_CACHE_DIR,
DENO_CACHE_DIR,
DENO_CACHE_DIR_DEPS,
DENO_CACHE_DIR_NPM,
BUN_CACHE_DIR,
PY311_CACHE_DIR,
PIP_CACHE_DIR,
BUN_DEPSTAR_CACHE_DIR,
BUN_BUNDLE_CACHE_DIR,
GO_CACHE_DIR,

View File

@@ -34,12 +34,12 @@ use windmill_common::{
error,
flow_status::FlowStatusModule,
global_settings::{
BASE_URL_SETTING, BUNFIG_INSTALL_SCOPES_SETTING, CRITICAL_ERROR_CHANNELS_SETTING,
DEFAULT_TAGS_PER_WORKSPACE_SETTING, DEFAULT_TAGS_WORKSPACES_SETTING,
EXPOSE_DEBUG_METRICS_SETTING, EXPOSE_METRICS_SETTING, EXTRA_PIP_INDEX_URL_SETTING,
HUB_BASE_URL_SETTING, JOB_DEFAULT_TIMEOUT_SECS_SETTING, JWT_SECRET_SETTING,
KEEP_JOB_DIR_SETTING, LICENSE_KEY_SETTING, NPM_CONFIG_REGISTRY_SETTING, OAUTH_SETTING,
PIP_INDEX_URL_SETTING, REQUEST_SIZE_LIMIT_SETTING,
BASE_URL_SETTING, BUNFIG_INSTALL_SCOPES_SETTING, CRITICAL_ALERT_MUTE_UI_SETTING,
CRITICAL_ERROR_CHANNELS_SETTING, DEFAULT_TAGS_PER_WORKSPACE_SETTING,
DEFAULT_TAGS_WORKSPACES_SETTING, EXPOSE_DEBUG_METRICS_SETTING, EXPOSE_METRICS_SETTING,
EXTRA_PIP_INDEX_URL_SETTING, HUB_BASE_URL_SETTING, JOB_DEFAULT_TIMEOUT_SECS_SETTING,
JWT_SECRET_SETTING, KEEP_JOB_DIR_SETTING, LICENSE_KEY_SETTING, NPM_CONFIG_REGISTRY_SETTING,
OAUTH_SETTING, PIP_INDEX_URL_SETTING, REQUEST_SIZE_LIMIT_SETTING,
REQUIRE_PREEXISTING_USER_FOR_OAUTH_SETTING, RETENTION_PERIOD_SECS_SETTING,
SAML_METADATA_SETTING, SCIM_TOKEN_SETTING, TIMEOUT_WAIT_RESULT_SETTING,
},
@@ -51,11 +51,11 @@ use windmill_common::{
utils::{now_from_db, rd_string, report_critical_error, Mode},
worker::{
load_worker_config, make_pull_query, make_suspended_pull_query, reload_custom_tags_setting,
DEFAULT_TAGS_PER_WORKSPACE, DEFAULT_TAGS_WORKSPACES, SMTP_CONFIG, WORKER_CONFIG,
WORKER_GROUP,
update_min_version, DEFAULT_TAGS_PER_WORKSPACE, DEFAULT_TAGS_WORKSPACES, SMTP_CONFIG,
WORKER_CONFIG, WORKER_GROUP,
},
BASE_URL, CRITICAL_ERROR_CHANNELS, DB, DEFAULT_HUB_BASE_URL, HUB_BASE_URL, JOB_RETENTION_SECS,
METRICS_DEBUG_ENABLED, METRICS_ENABLED,
BASE_URL, CRITICAL_ALERT_MUTE_UI_ENABLED, CRITICAL_ERROR_CHANNELS, DB, DEFAULT_HUB_BASE_URL,
HUB_BASE_URL, JOB_RETENTION_SECS, METRICS_DEBUG_ENABLED, METRICS_ENABLED,
};
use windmill_queue::cancel_job;
use windmill_worker::{
@@ -131,6 +131,10 @@ pub async fn initial_load(
tracing::error!("Error loading expose debug metrics: {e:#}");
}
if let Err(e) = reload_critical_alert_mute_ui_setting(db).await {
tracing::error!("Error loading critical alert mute ui setting: {e:#}");
}
if let Err(e) = load_tag_per_workspace_enabled(db).await {
tracing::error!("Error loading default tag per workpsace: {e:#}");
}
@@ -226,6 +230,24 @@ pub async fn load_tag_per_workspace_workspaces(db: &DB) -> error::Result<()> {
Ok(())
}
pub async fn reload_critical_alert_mute_ui_setting(db: &DB) -> error::Result<()> {
if let Ok(Some(serde_json::Value::Bool(t))) =
load_value_from_global_settings(db, CRITICAL_ALERT_MUTE_UI_SETTING).await
{
CRITICAL_ALERT_MUTE_UI_ENABLED.store(t, Ordering::Relaxed);
if t {
if let Err(e) = sqlx::query!("UPDATE alerts SET acknowledged = true")
.execute(db)
.await
{
tracing::error!("Error updating alerts: {}", e.to_string());
}
}
}
Ok(())
}
pub async fn load_metrics_debug_enabled(db: &DB) -> error::Result<()> {
let metrics_enabled = load_value_from_global_settings(db, EXPOSE_DEBUG_METRICS_SETTING).await;
match metrics_enabled {
@@ -1065,6 +1087,10 @@ pub async fn monitor_db(
}
};
let update_min_worker_version_f = async {
update_min_version(db).await;
};
join!(
expired_items_f,
zombie_jobs_f,
@@ -1073,6 +1099,7 @@ pub async fn monitor_db(
worker_groups_alerts_f,
jobs_waiting_alerts_f,
apply_autoscaling_f,
update_min_worker_version_f,
);
}
@@ -1303,7 +1330,7 @@ async fn handle_zombie_jobs<R: rsmq_async::RsmqConnection + Send + Sync + Clone>
ON CONFLICT (job_id) DO UPDATE SET logs = job_logs.logs || '\nRestarted job after not receiving job''s ping for too long the ' || now() || '\n\n' WHERE job_logs.job_id = $1", r.id)
.execute(db).await;
tracing::error!(error_message);
report_critical_error(error_message, db.clone()).await;
report_critical_error(error_message, db.clone(), Some(&r.workspace_id), None).await;
}
}
@@ -1417,7 +1444,7 @@ async fn handle_zombie_flows(
flow.id, flow.workspace_id
);
tracing::error!(error_message);
report_critical_error(error_message, db.clone()).await;
report_critical_error(error_message, db.clone(), Some(&flow.workspace_id), None).await;
// if the flow hasn't started and is a zombie, we can simply restart it
sqlx::query!(
"UPDATE queue SET running = false, started_at = null WHERE id = $1 AND canceled = false",
@@ -1437,7 +1464,7 @@ async fn handle_zombie_flows(
format!("Flow {id} was cancelled because it")
}
);
report_critical_error(reason.clone(), db.clone()).await;
report_critical_error(reason.clone(), db.clone(), Some(&flow.workspace_id), None).await;
cancel_zombie_flow_job(db, flow, &rsmq, reason).await?;
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,7 @@
openapi: "3.0.3"
info:
version: 1.421.0
version: 1.424.0
title: Windmill API
contact:
@@ -190,7 +190,7 @@ paths:
schema:
type: string
/w/{workspace}/users/{username}:
/w/{workspace}/users/get/{username}:
get:
summary: get user (require admin privilege)
operationId: getUser
@@ -283,6 +283,71 @@ paths:
text/plain:
schema:
type: string
/users/set_password_of/{user}:
post:
summary: set password for a specific user (require super admin)
operationId: setPasswordForUser
tags:
- user
parameters:
- name: user
in: path
required: true
schema:
type: string
requestBody:
description: set password
required: true
content:
application/json:
schema:
type: object
properties:
password:
type: string
required:
- password
responses:
"200":
description: password set
content:
text/plain:
schema:
type: string
/users/set_login_type/{user}:
post:
summary: set login type for a specific user (require super admin)
operationId: setLoginTypeForUser
tags:
- user
parameters:
- name: user
in: path
required: true
schema:
type: string
requestBody:
description: set login type
required: true
content:
application/json:
schema:
type: object
properties:
login_type:
type: string
required:
- login_type
responses:
"200":
description: login type set
content:
text/plain:
schema:
type: string
/users/create:
post:
@@ -773,6 +838,78 @@ paths:
schema:
type: string
/settings/critical_alerts:
get:
summary: Get all critical alerts
operationId: getCriticalAlerts
tags:
- setting
parameters:
- in: query
name: page
schema:
type: integer
default: 1
description: The page number to retrieve (minimum value is 1)
- in: query
name: page_size
schema:
type: integer
default: 10
maximum: 100
description: Number of alerts per page (maximum is 100)
- in: query
name: acknowledged
schema:
type: boolean
nullable: true
description: Filter by acknowledgment status; true for acknowledged, false for unacknowledged, and omit for all alerts
responses:
"200":
description: Successfully retrieved all critical alerts
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/CriticalAlert'
/settings/critical_alerts/{id}/acknowledge:
post:
summary: Acknowledge a critical alert
operationId: acknowledgeCriticalAlert
tags:
- setting
parameters:
- in: path
name: id
required: true
schema:
type: integer
description: The ID of the critical alert to acknowledge
responses:
"200":
description: Successfully acknowledged the critical alert
content:
application/json:
schema:
type: string
example: "Critical alert acknowledged"
/settings/critical_alerts/acknowledge_all:
post:
summary: Acknowledge all unacknowledged critical alerts
operationId: acknowledgeAllCriticalAlerts
tags:
- setting
responses:
"200":
description: Successfully acknowledged all unacknowledged critical alerts.
content:
application/json:
schema:
type: string
example: "All unacknowledged critical alerts acknowledged"
/settings/test_license_key:
post:
@@ -1544,8 +1681,8 @@ paths:
type: string
deploy_to:
type: string
openai_resource_path:
type: string
ai_resource:
$ref: "#/components/schemas/AiResource"
code_completion_enabled:
type: boolean
error_handler:
@@ -1564,6 +1701,8 @@ paths:
type: string
default_scripts:
$ref: "#/components/schemas/WorkspaceDefaultScripts"
mute_critical_alerts:
type: boolean
required:
- code_completion_enabled
- automatic_billing
@@ -1829,8 +1968,8 @@ paths:
required:
- code_completion_enabled
properties:
openai_resource_path:
type: string
ai_resource:
$ref: "#/components/schemas/AiResource"
code_completion_enabled:
type: boolean
responses:
@@ -1858,12 +1997,15 @@ paths:
schema:
type: object
properties:
exists_openai_resource_path:
ai_provider:
type: string
exists_ai_resource:
type: boolean
code_completion_enabled:
type: boolean
required:
- exists_openai_resource_path
- ai_provider
- exists_ai_resource
- code_completion_enabled
/w/{workspace}/workspaces/edit_error_handler:
@@ -2577,6 +2719,112 @@ paths:
items:
$ref: "#/components/schemas/ContextualVariable"
/w/{workspace}/workspaces/critical_alerts:
get:
summary: Get all critical alerts for this workspace
operationId: workspaceGetCriticalAlerts
tags:
- setting
parameters:
- $ref: "#/components/parameters/WorkspaceId"
- in: query
name: page
schema:
type: integer
default: 1
description: The page number to retrieve (minimum value is 1)
- in: query
name: page_size
schema:
type: integer
default: 10
maximum: 100
description: Number of alerts per page (maximum is 100)
- in: query
name: acknowledged
schema:
type: boolean
nullable: true
description: Filter by acknowledgment status; true for acknowledged, false for unacknowledged, and omit for all alerts
responses:
"200":
description: Successfully retrieved all critical alerts
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/CriticalAlert'
/w/{workspace}/workspaces/critical_alerts/{id}/acknowledge:
post:
summary: Acknowledge a critical alert for this workspace
operationId: workspaceAcknowledgeCriticalAlert
tags:
- setting
parameters:
- $ref: "#/components/parameters/WorkspaceId"
- in: path
name: id
required: true
schema:
type: integer
description: The ID of the critical alert to acknowledge
responses:
"200":
description: Successfully acknowledged the critical alert
content:
application/json:
schema:
type: string
example: "Critical alert acknowledged"
/w/{workspace}/workspaces/critical_alerts/acknowledge_all:
post:
summary: Acknowledge all unacknowledged critical alerts for this workspace
operationId: workspaceAcknowledgeAllCriticalAlerts
tags:
- setting
parameters:
- $ref: "#/components/parameters/WorkspaceId"
responses:
"200":
description: Successfully acknowledged all unacknowledged critical alerts.
content:
application/json:
schema:
type: string
example: "All unacknowledged critical alerts acknowledged"
/w/{workspace}/workspaces/critical_alerts/mute:
post:
summary: Mute critical alert UI for this workspace
operationId: workspaceMuteCriticalAlertsUI
tags:
- setting
parameters:
- $ref: "#/components/parameters/WorkspaceId"
requestBody:
description: Boolean flag to mute critical alerts.
required: true
content:
application/json:
schema:
type: object
properties:
mute_critical_alerts:
type: boolean
description: Whether critical alerts should be muted.
example: true
responses:
'200':
description: Successfully updated mute critical alert settings.
content:
application/json:
schema:
type: string
example: "Updated mute critical alert UI settings for workspace: workspace_id"
/oauth/login_callback/{client_name}:
post:
security: []
@@ -5914,6 +6162,39 @@ paths:
required:
- database_length
/w/{workspace}/jobs/completed/count_jobs:
get:
summary: count number of completed jobs with filter
operationId: countCompletedJobs
tags:
- job
parameters:
- $ref: "#/components/parameters/WorkspaceId"
- name: completed_after_s_ago
in: query
schema:
type: integer
- name: success
in: query
schema:
type: boolean
- name: tags
in: query
schema:
type: string
- name: all_workspaces
in: query
schema:
type: boolean
responses:
"200":
description: Count of completed jobs
content:
application/json:
schema:
type: integer
/w/{workspace}/jobs/queue/list_filtered_uuids:
get:
summary: get the ids of all jobs matching the given filters
@@ -8116,6 +8397,23 @@ paths:
- id
- values
/workers/queue_counts:
get:
summary: get counts of jobs waiting for an executor per tag
operationId: getCountsOfJobsWaitingPerTag
tags:
- worker
responses:
"200":
description: queue counts
content:
application/json:
schema:
type: object
additionalProperties:
type: integer
/configs/list_worker_groups:
get:
summary: list worker groups
@@ -10040,6 +10338,16 @@ components:
schemas:
$ref: "../../openflow.openapi.yaml#/components/schemas"
AiResource:
type: object
properties:
path:
type: string
provider:
type: string
required:
- path
- provider
Script:
type: object
properties:
@@ -10858,7 +11166,7 @@ components:
- "jobs.disapproval"
- "jobs.delete"
- "account.delete"
- "openai.request"
- "ai.request"
- "resources.create"
- "resources.update"
- "resources.delete"
@@ -12114,6 +12422,10 @@ components:
type: object
additionalProperties:
type: object
s3_inputs:
type: array
items:
type: object
execution_mode:
type: string
enum: [viewer, publisher, anonymous]
@@ -12721,3 +13033,27 @@ components:
type: string
format: date-time
CriticalAlert:
type: object
properties:
id:
type: integer
description: Unique identifier for the alert
alert_type:
type: string
description: Type of alert (e.g., critical_error)
message:
type: string
description: The message content of the alert
created_at:
type: string
format: date-time
description: Time when the alert was created
acknowledged:
type: boolean
nullable: true
description: Acknowledgment status of the alert, can be true, false, or null if not set
workspace_id:
type: string
nullable: true
description: Workspace id if the alert is in the scope of a workspace

View File

@@ -0,0 +1,507 @@
use crate::{
db::{ApiAuthed, DB},
variables::decrypt,
};
use anthropic::AnthropicCache;
use axum::{
body::Bytes,
extract::{Path, Query},
response::IntoResponse,
routing::post,
Extension, Router,
};
use lazy_static::lazy_static;
use mistral::MistralCache;
use openai::OpenaiCache;
use quick_cache::sync::Cache;
use reqwest::{Client, RequestBuilder};
use serde::{Deserialize, Deserializer};
use windmill_audit::audit_ee::audit_log;
use windmill_audit::ActionKind;
use windmill_common::error::{to_anyhow, Result};
use windmill_common::variables::build_crypt;
use windmill_common::error::Error;
use serde_json::value::{RawValue, Value};
use std::collections::HashMap;
lazy_static::lazy_static! {
static ref HTTP_CLIENT: Client = reqwest::ClientBuilder::new()
.timeout(std::time::Duration::from_secs(60 * 5))
.user_agent("windmill/beta")
.build().unwrap();
}
trait AiRequest {
fn prepare_request(self, path: &str, body: Bytes) -> Result<RequestBuilder>;
}
mod openai {
use super::*;
use super::{get_variable_or_self, KeyCache};
const API_VERSION: &str = "2023-05-15";
#[derive(Deserialize, Debug)]
struct OpenaiResource {
api_key: String,
organization_id: Option<String>,
}
#[derive(Deserialize, Debug)]
struct OpenaiClientCredentialsOauthResource {
client_id: String,
client_secret: String,
token_url: String,
user: Option<String>,
}
#[derive(Deserialize, Debug)]
#[serde(untagged, rename_all = "snake_case")]
enum OpenaiConfig {
Resource(OpenaiResource),
ClientCredentialsOauthResource(OpenaiClientCredentialsOauthResource),
}
lazy_static::lazy_static! {
pub static ref OPENAI_AZURE_BASE_PATH: Option<String> = std::env::var("OPENAI_AZURE_BASE_PATH").ok();
}
#[derive(Deserialize, Debug)]
struct OpenaiCredentials {
access_token: String,
}
#[derive(Clone, Debug, Deserialize)]
pub struct OpenaiCache {
api_key: String,
organization_id: Option<String>,
azure_base_path: Option<String>,
user: Option<String>,
}
impl OpenaiCache {
pub fn new(
api_key: String,
organization_id: Option<String>,
azure_base_path: Option<String>,
user: Option<String>,
) -> Self {
Self { api_key, organization_id, azure_base_path, user }
}
}
const BASE_URL: &str = "https://api.openai.com/v1";
impl AiRequest for OpenaiCache {
fn prepare_request(self, openai_path: &str, mut body: Bytes) -> Result<RequestBuilder> {
let OpenaiCache { api_key, azure_base_path, organization_id, user } = self;
if user.is_some() {
tracing::debug!("Adding user to request body");
let mut json_body: HashMap<String, Box<RawValue>> = serde_json::from_slice(&body)
.map_err(|e| {
Error::InternalErr(format!("Failed to parse request body: {}", e))
})?;
let user_json_string = serde_json::Value::String(user.unwrap()).to_string(); // makes sure to escape characters
json_body.insert(
"user".to_string(),
RawValue::from_string(user_json_string)
.map_err(|e| Error::InternalErr(format!("Failed to parse user: {}", e)))?,
);
body = serde_json::to_vec(&json_body)
.map_err(|e| {
Error::InternalErr(format!("Failed to reserialize request body: {}", e))
})?
.into();
}
let base_url = if let Some(base_url) = azure_base_path {
base_url
} else {
BASE_URL.to_string()
};
let url = format!("{}/{}", base_url, openai_path);
let mut request = HTTP_CLIENT
.post(url)
.header("content-type", "application/json")
.body(body);
if base_url != BASE_URL {
request = request
.header("api-key", api_key)
.query(&[("api-version", API_VERSION)])
} else {
request = request.header("authorization", format!("Bearer {}", api_key))
}
if let Some(org_id) = organization_id {
request = request.header("OpenAI-Organization", org_id);
}
Ok(request)
}
}
async fn get_openai_key_using_credentials_flow(
mut resource: OpenaiClientCredentialsOauthResource,
db: &DB,
w_id: &str,
) -> Result<String> {
resource.client_id = get_variable_or_self(resource.client_id, db, w_id).await?;
resource.client_secret = get_variable_or_self(resource.client_secret, db, w_id).await?;
resource.token_url = get_variable_or_self(resource.token_url, db, w_id).await?;
let mut params = HashMap::new();
params.insert("grant_type", "client_credentials");
let response = HTTP_CLIENT
.post(resource.token_url)
.form(&params)
.basic_auth(resource.client_id, Some(resource.client_secret))
.send()
.await
.map_err(|err| {
Error::InternalErr(format!(
"Failed to get OpenAI credentials using credentials flow: {}",
err
))
})?;
let response = response.json::<OpenaiCredentials>().await.map_err(|err| {
Error::InternalErr(format!(
"Failed to parse OpenAI credentials from credentials flow: {}",
err
))
})?;
Ok(response.access_token)
}
pub async fn get_cached_value(db: &DB, w_id: &str, resource: Value) -> Result<KeyCache> {
let config = serde_json::from_value(resource)
.map_err(|e| Error::InternalErr(format!("validating openai resource {e:#}")))?;
let mut user = None::<String>;
let mut resource = match config {
OpenaiConfig::Resource(resource) => {
tracing::debug!("Getting OpenAI key from static resource");
resource
}
OpenaiConfig::ClientCredentialsOauthResource(resource) => {
tracing::debug!("Getting OpenAI key with client credentials flow");
user = resource.user.clone();
let token = get_openai_key_using_credentials_flow(resource, db, w_id).await?;
OpenaiResource { api_key: token, organization_id: None }
}
};
resource.api_key = get_variable_or_self(resource.api_key, db, w_id).await?;
if let Some(organization_id) = resource.organization_id {
resource.organization_id = Some(get_variable_or_self(organization_id, db, w_id).await?);
}
if user.is_some() {
user = Some(get_variable_or_self(user.unwrap(), db, w_id).await?);
}
let azure_base_path = sqlx::query_scalar!(
"SELECT value
FROM global_settings
WHERE name = 'openai_azure_base_path'",
)
.fetch_optional(db)
.await?;
let azure_base_path = if let Some(azure_base_path) = azure_base_path {
Some(
serde_json::from_value::<String>(azure_base_path).map_err(|e| {
Error::InternalErr(format!("validating openai azure base path {e:#}"))
})?,
)
} else {
OPENAI_AZURE_BASE_PATH.clone()
};
let workspace_cache = OpenaiCache::new(
resource.api_key.clone(),
resource.organization_id.clone(),
azure_base_path.clone(),
user.clone(),
);
Ok(KeyCache::Openai(workspace_cache))
}
}
mod anthropic {
use super::*;
#[derive(Clone, Deserialize, Debug)]
pub struct AnthropicCache {
#[serde(rename = "apiKey")]
pub api_key: String,
}
const API_VERSION: &str = "2023-06-01";
impl AnthropicCache {
pub fn new(api_key: String) -> Self {
Self { api_key }
}
}
const BASE_URL: &str = "https://api.anthropic.com";
impl AiRequest for AnthropicCache {
fn prepare_request(self, anthropic_path: &str, body: Bytes) -> Result<RequestBuilder> {
let AnthropicCache { api_key } = self;
let url = format!("{}/{}", BASE_URL, anthropic_path);
let request = HTTP_CLIENT
.post(url)
.header("x-api-key", api_key)
.header("anthropic-version", API_VERSION)
.header("content-type", "application/json")
.body(body);
Ok(request)
}
}
pub async fn get_cached_value(db: &DB, w_id: &str, resource: Value) -> Result<KeyCache> {
let mut resource: AnthropicCache = serde_json::from_value(resource)
.map_err(|e| Error::InternalErr(format!("validating anthropic resource {e:#}")))?;
resource.api_key = get_variable_or_self(resource.api_key, db, w_id).await?;
let workspace_cache = AnthropicCache::new(resource.api_key);
Ok(KeyCache::Anthropic(workspace_cache))
}
}
mod mistral {
use super::*;
#[derive(Deserialize, Clone, Debug)]
pub struct MistralCache {
#[serde(rename = "apiKey")]
pub api_key: String,
}
impl MistralCache {
pub fn new(api_key: String) -> Self {
Self { api_key }
}
}
const BASE_URL: &str = "https://api.mistral.ai";
impl AiRequest for MistralCache {
fn prepare_request(self, mistral_path: &str, body: Bytes) -> Result<RequestBuilder> {
let MistralCache { api_key } = self;
let url = format!("{}/{}", BASE_URL, mistral_path);
let request = HTTP_CLIENT
.post(url)
.header("content-type", "application/json")
.header("Accept", "application/json")
.header("authorization", format!("Bearer {}", api_key))
.body(body);
Ok(request)
}
}
pub async fn get_cached_value(db: &DB, w_id: &str, resource: Value) -> Result<KeyCache> {
let mut resource: MistralCache = serde_json::from_value(resource)
.map_err(|e| Error::InternalErr(format!("validating mistral resource {e:#}")))?;
resource.api_key = get_variable_or_self(resource.api_key, db, w_id).await?;
let workspace_cache = MistralCache::new(resource.api_key);
Ok(KeyCache::Mistral(workspace_cache))
}
}
#[derive(Clone, Debug)]
pub enum KeyCache {
Openai(OpenaiCache),
Anthropic(AnthropicCache),
Mistral(MistralCache),
}
#[derive(Clone, Debug)]
pub struct AiCache {
pub path: String,
pub cached_key: KeyCache,
pub expires_at: std::time::Instant,
}
impl AiCache {
pub fn new(path: String, cached_key: KeyCache) -> Self {
Self {
path,
cached_key,
expires_at: std::time::Instant::now() + std::time::Duration::from_secs(60),
}
}
fn is_expired(&self) -> bool {
self.expires_at < std::time::Instant::now()
}
}
lazy_static! {
pub static ref AI_KEY_CACHE: Cache<String, AiCache> = Cache::new(500);
}
struct Variable {
value: String,
is_secret: bool,
}
#[derive(Deserialize, Debug)]
struct ProxyQueryParams {
no_cache: Option<bool>,
}
async fn get_variable_or_self(path: String, db: &DB, w_id: &str) -> Result<String> {
if !path.starts_with("$var:") {
return Ok(path);
}
let path = path.strip_prefix("$var:").unwrap().to_string();
let mut variable = sqlx::query_as!(
Variable,
"SELECT value, is_secret
FROM variable
WHERE path = $1 AND workspace_id = $2",
&path,
&w_id
)
.fetch_one(db)
.await?;
if variable.is_secret {
let mc = build_crypt(db, w_id).await?;
variable.value = decrypt(&mc, variable.value)?;
}
Ok(variable.value)
}
#[derive(Deserialize, Debug)]
pub struct AiResource {
pub path: String,
#[serde(deserialize_with = "check_if_valid_ai_provider")]
pub provider: String,
}
fn check_if_valid_ai_provider<'de, D>(provider: D) -> std::result::Result<String, D::Error>
where
D: Deserializer<'de>,
{
let provider = String::deserialize(provider)?;
match provider.as_str() {
"anthropic" | "openai" | "mistral" => Ok(provider),
_ => Err(serde::de::Error::custom(
"Only the following Ai providers are supported: openai, anthropic and mistral"
.to_string(),
)),
}
}
pub fn workspaced_service() -> Router {
let router = Router::new().route("/proxy/*ai", post(proxy));
router
}
async fn proxy(
authed: ApiAuthed,
Extension(db): Extension<DB>,
Path((w_id, ai_path)): Path<(String, String)>,
Query(query_params): Query<ProxyQueryParams>,
body: Bytes,
) -> impl IntoResponse {
let workspace_cache = AI_KEY_CACHE.get(&w_id);
let ai_cache = match workspace_cache {
Some(cache) if !cache.is_expired() && !query_params.no_cache.unwrap_or(false) => {
cache.cached_key
}
_ => {
let ai_resource = sqlx::query_scalar!(
"SELECT ai_resource FROM workspace_settings WHERE workspace_id = $1",
&w_id
)
.fetch_one(&db)
.await?;
if ai_resource.is_none() {
return Err(Error::InternalErr("AI resource not configured".to_string()));
}
let ai_resource = serde_json::from_value::<AiResource>(ai_resource.unwrap())
.map_err(|e| Error::BadRequest(e.to_string()))?;
let ai_resource_path = ai_resource.path;
let resource = sqlx::query_scalar!(
"SELECT value
FROM resource
WHERE path = $1 AND workspace_id = $2",
&ai_resource_path,
&w_id
)
.fetch_optional(&db)
.await?
.ok_or_else(|| {
Error::InternalErr(format!(
"Could not find the {} resource at path {ai_resource_path}, update the resource path in the workspace settings", ai_resource.provider
))
})?;
if resource.is_none() {
return Err(Error::InternalErr(format!(
"{} resource missing value",
ai_resource.provider
)));
}
let resource = resource.unwrap();
let ai_cache = match ai_resource.provider.as_str() {
"openai" => openai::get_cached_value(&db, &w_id, resource).await,
"anthropic" => anthropic::get_cached_value(&db, &w_id, resource).await,
"mistral" => mistral::get_cached_value(&db, &w_id, resource).await,
provider => {
return Err(Error::BadRequest(format!("{} is not supported", provider)))
}
};
let ai_cache = ai_cache?;
AI_KEY_CACHE.insert(
w_id.clone(),
AiCache::new(ai_resource_path, ai_cache.clone()),
);
ai_cache
}
};
let (path, request) = match ai_cache {
KeyCache::Openai(cached) => ("openai_path", cached.prepare_request(&ai_path, body)),
KeyCache::Anthropic(cached) => ("anthropic_path", cached.prepare_request(&ai_path, body)),
KeyCache::Mistral(cached) => ("mistral_path", cached.prepare_request(&ai_path, body)),
};
let response = request?.send().await.map_err(to_anyhow)?;
let mut tx = db.begin().await?;
audit_log(
&mut *tx,
&authed,
"ai.request",
ActionKind::Execute,
&w_id,
Some(&authed.email),
Some([(path, &format!("{:?}", ai_path)[..])].into()),
)
.await?;
tx.commit().await?;
if response.error_for_status_ref().is_err() {
let err_msg = response.text().await.unwrap_or("".to_string());
return Err(Error::AiError(err_msg));
}
let status_code = response.status();
let headers = response.headers().clone();
let stream = response.bytes_stream();
Ok((status_code, headers, axum::body::Body::from_stream(stream)))
}

View File

@@ -7,6 +7,12 @@ use std::collections::HashMap;
* Please see the included NOTICE for copyright information and
* LICENSE-AGPL for a copy of the license.
*/
#[cfg(feature = "parquet")]
use crate::{job_helpers_ee::{
get_random_file_name, get_s3_resource, get_workspace_s3_resource, upload_file_internal,
UploadFileResponse,
}, users::fetch_api_authed_from_permissioned_as};
use crate::{
db::{ApiAuthed, DB},
resources::get_resource_value_interpolated_internal,
@@ -23,7 +29,13 @@ use axum::{
Router,
};
use hyper::StatusCode;
#[cfg(feature = "parquet")]
use itertools::Itertools;
use magic_crypt::MagicCryptTrait;
#[cfg(feature = "parquet")]
use object_store::{Attribute, Attributes};
#[cfg(feature = "parquet")]
use regex::Regex;
use serde::{Deserialize, Serialize};
use serde_json::{json, value::RawValue};
use sha2::{Digest, Sha256};
@@ -32,6 +44,8 @@ use sqlx::{types::Uuid, FromRow};
use std::str;
use windmill_audit::audit_ee::audit_log;
use windmill_audit::ActionKind;
#[cfg(feature = "parquet")]
use windmill_common::s3_helpers::build_object_store_client;
use windmill_common::{
apps::ListAppQuery,
db::UserDB,
@@ -69,6 +83,7 @@ pub fn workspaced_service() -> Router {
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("/public_app/:secret", get(get_public_app_by_secret))
.route("/public_resource/*path", get(get_public_resource))
}
@@ -179,6 +194,14 @@ pub struct PolicyTriggerableInputs {
allow_user_resources: AllowUserResources,
}
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct S3Input {
allowed_resources: Vec<String>,
allow_user_resources: bool,
allow_workspace_resource: bool,
file_key_regex: String,
}
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct Policy {
pub on_behalf_of: Option<String>,
@@ -192,6 +215,7 @@ pub struct Policy {
#[serde(skip_serializing_if = "Option::is_none")]
pub triggerables_v2: Option<HashMap<String, PolicyTriggerableInputs>>,
pub execution_mode: ExecutionMode,
pub s3_inputs: Option<Vec<S3Input>>,
}
#[derive(Deserialize)]
@@ -432,9 +456,7 @@ async fn get_latest_version(
authed: ApiAuthed,
Extension(user_db): Extension<UserDB>,
Path((w_id, path)): Path<(String, StripPath)>,
) -> JsonResult<Option<AppHistory>> {
let mut tx = user_db.begin(&authed).await?;
let row = sqlx::query!(
"SELECT a.id as app_id, av.id as version_id, dm.deployment_msg as deployment_msg
@@ -457,7 +479,6 @@ async fn get_latest_version(
} else {
return Ok(Json(None));
}
}
async fn update_app_history(
@@ -1067,6 +1088,49 @@ fn digest(code: &str) -> String {
format!("rawscript/{:x}", result)
}
async fn get_on_behalf_details_from_policy_and_authed(
policy: &Policy,
opt_authed: &Option<ApiAuthed>,
) -> Result<(String, String, String)> {
let (username, permissioned_as, email) = match policy.execution_mode {
ExecutionMode::Anonymous => {
let username = opt_authed
.as_ref()
.map(|a| a.username.clone())
.unwrap_or_else(|| "anonymous".to_string());
let (permissioned_as, email) = get_on_behalf_of(&policy)?;
(username, permissioned_as, email)
}
ExecutionMode::Publisher => {
let username = opt_authed
.as_ref()
.map(|a| a.username.clone())
.ok_or_else(|| {
Error::BadRequest(
"publisher execution mode requires authentication".to_string(),
)
})?;
let (permissioned_as, email) = get_on_behalf_of(&policy)?;
(username, permissioned_as, email)
}
ExecutionMode::Viewer => {
let (username, email) = opt_authed
.as_ref()
.map(|a| (a.username.clone(), a.email.clone()))
.ok_or_else(|| {
Error::BadRequest("Required to be authed in viewer mode".to_string())
})?;
(
username.clone(),
username_to_permissioned_as(&username),
email,
)
}
};
Ok((username, permissioned_as, email))
}
async fn execute_component(
OptAuthed(opt_authed): OptAuthed,
Extension(db): Extension<DB>,
@@ -1129,6 +1193,7 @@ async fn execute_component(
triggerables_v2: Some(hm),
on_behalf_of: None,
on_behalf_of_email: None,
s3_inputs: None,
}
}
_ => {
@@ -1146,41 +1211,8 @@ async fn execute_component(
}
};
let (username, permissioned_as, email) = match policy.execution_mode {
ExecutionMode::Anonymous => {
let username = opt_authed
.as_ref()
.map(|a| a.username.clone())
.unwrap_or_else(|| "anonymous".to_string());
let (permissioned_as, email) = get_on_behalf_of(&policy)?;
(username, permissioned_as, email)
}
ExecutionMode::Publisher => {
let username = opt_authed
.as_ref()
.map(|a| a.username.clone())
.ok_or_else(|| {
Error::BadRequest(
"publisher execution mode requires authentication".to_string(),
)
})?;
let (permissioned_as, email) = get_on_behalf_of(&policy)?;
(username, permissioned_as, email)
}
ExecutionMode::Viewer => {
let (username, email) = opt_authed
.as_ref()
.map(|a| (a.username.clone(), a.email.clone()))
.ok_or_else(|| {
Error::BadRequest("Required to be authed in viewer mode".to_string())
})?;
(
username.clone(),
username_to_permissioned_as(&username),
email,
)
}
};
let (username, permissioned_as, email) =
get_on_behalf_details_from_policy_and_authed(&policy, &opt_authed).await?;
let (job_payload, (args, job_id), tag) = match payload {
ExecuteApp { args, component, raw_code: Some(raw_code), path: None, .. } => {
@@ -1249,6 +1281,248 @@ async fn execute_component(
Ok(uuid.to_string())
}
#[cfg(not(feature = "parquet"))]
async fn upload_s3_file_from_app() -> Result<()> {
return Err(Error::BadRequest(
"This endpoint requires the parquet feature to be enabled".to_string(),
));
}
#[cfg(feature = "parquet")]
#[derive(Debug, Deserialize, Clone)]
struct UploadFileToS3Query {
file_key: Option<String>,
file_extension: Option<String>,
s3_resource_path: Option<String>,
content_type: Option<String>,
content_disposition: Option<String>,
force_viewer_file_key_regex: Option<String>,
force_viewer_allow_user_resources: Option<bool>,
force_viewer_allow_workspace_resource: Option<bool>,
force_viewer_allowed_resources: Option<String>,
}
#[cfg(feature = "parquet")]
async fn upload_s3_file_from_app(
OptAuthed(opt_authed): OptAuthed,
Extension(db): Extension<DB>,
Path((w_id, path)): Path<(String, StripPath)>,
Query(query): Query<UploadFileToS3Query>,
request: axum::extract::Request,
) -> JsonResult<UploadFileResponse> {
let policy = if let Some(file_key_regex) = query.force_viewer_file_key_regex {
Some(Policy {
execution_mode: ExecutionMode::Viewer,
triggerables: None,
triggerables_v2: None,
on_behalf_of: None,
on_behalf_of_email: None,
s3_inputs: Some(vec![S3Input {
file_key_regex: file_key_regex,
allow_user_resources: query.force_viewer_allow_user_resources.unwrap_or(false),
allow_workspace_resource: query
.force_viewer_allow_workspace_resource
.unwrap_or(false),
allowed_resources: query
.force_viewer_allowed_resources
.map(|s| s.split(',').map(|s| s.to_string()).collect())
.unwrap_or_default(),
}]),
})
} else {
let policy_o = sqlx::query_scalar!(
"SELECT policy from app WHERE path = $1 AND workspace_id = $2",
&path.0,
&w_id
)
.fetch_optional(&db)
.await?;
policy_o
.map(|p| serde_json::from_value::<Policy>(p).map_err(to_anyhow))
.transpose()?
};
let user_db = UserDB::new(db.clone());
let (s3_resource_opt, file_key) = if policy.as_ref().is_some_and(|p| p.s3_inputs.is_some()) {
let policy = policy.unwrap();
let s3_inputs = policy.s3_inputs.as_ref().unwrap();
let (username, permissioned_as, email) =
get_on_behalf_details_from_policy_and_authed(&policy, &opt_authed).await?;
let on_behalf_authed =
fetch_api_authed_from_permissioned_as(permissioned_as, email, &w_id, &db, username)
.await?;
if let Some(file_key) = query.file_key {
// file key is provided => requires workspace, user or list policy and must match the regex
let matching_s3_inputs = if let Some(ref s3_resource_path) = query.s3_resource_path {
s3_inputs
.iter()
.filter(|s3_input| {
s3_input.allowed_resources.contains(s3_resource_path)
|| s3_input.allow_user_resources
})
.sorted_by_key(|i| i.allow_user_resources) // consider user resources last
.collect::<Vec<_>>()
} else {
s3_inputs
.iter()
.filter(|s3_input| s3_input.allow_workspace_resource)
.collect::<Vec<_>>()
};
let matched_input = matching_s3_inputs.iter().find(|s3_input| {
match Regex::new(&s3_input.file_key_regex) {
Ok(re) => re.is_match(&file_key),
Err(e) => {
tracing::error!("Error compiling regex: {}", e);
false
}
}
});
if let Some(matched_input) = matched_input {
if let Some(ref s3_resource_path) = query.s3_resource_path {
if matched_input.allow_user_resources {
if let Some(authed) = opt_authed {
(
Some(
get_s3_resource(
&authed,
&db,
Some(user_db),
"",
&w_id,
s3_resource_path,
None,
None,
)
.await?,
),
file_key,
)
} else {
return Err(Error::BadRequest(
"User resources are not allowed without being logged in"
.to_string(),
));
}
} else {
(
Some(
get_s3_resource(
&on_behalf_authed,
&db,
Some(user_db),
"",
&w_id,
s3_resource_path,
None,
None,
)
.await?,
),
file_key,
)
}
} else {
let (_, s3_resource_opt) =
get_workspace_s3_resource(&on_behalf_authed, &db, None, "", &w_id, None)
.await?;
(s3_resource_opt, file_key)
}
} else {
return Err(Error::BadRequest(
"No matching s3 resource found for the given file key".to_string(),
));
}
} else {
// no file key => requires unnamed upload policy => allow workspace resource and file_key_regex is empty
let has_unnamed_policy = s3_inputs.iter().any(|s3_input| {
s3_input.allow_workspace_resource && s3_input.file_key_regex.is_empty()
});
if !has_unnamed_policy {
return Err(Error::BadRequest(
"no policy found for unnamed s3 file uplooad".to_string(),
));
}
// for now, we place all files into `windmill_uploads` folder with a random name
// TODO: make the folder configurable via the workspace settings
let file_key = get_random_file_name(query.file_extension);
let (_, s3_resource_opt) =
get_workspace_s3_resource(&on_behalf_authed, &db, None, "", &w_id, None).await?;
(s3_resource_opt, file_key)
}
} else {
// backward compatibility (no policy)
// if no policy but logged in, use the user's auth to get the s3 resource
if let Some(authed) = opt_authed {
let file_key = query
.file_key
.unwrap_or_else(|| get_random_file_name(query.file_extension));
if let Some(ref s3_resource_path) = query.s3_resource_path {
(
Some(
get_s3_resource(
&authed,
&db,
Some(user_db),
"",
&w_id,
s3_resource_path,
None,
None,
)
.await?,
),
file_key,
)
} else {
let (_, s3_resource) =
get_workspace_s3_resource(&authed, &db, None, "", &w_id, None).await?;
(s3_resource, file_key)
}
} else {
return Err(Error::BadRequest("Missing s3 policy".to_string()));
}
};
let s3_resource = s3_resource_opt.ok_or(Error::InternalErr(
"No files storage resource defined at the workspace level".to_string(),
))?;
let s3_client = build_object_store_client(&s3_resource).await?;
let options = Attributes::from_iter(vec![
(
Attribute::ContentType,
query.content_type.unwrap_or_else(|| {
mime_guess::from_path(&file_key)
.first_or_octet_stream()
.to_string()
}),
),
(
Attribute::ContentDisposition,
query.content_disposition.unwrap_or("inline".to_string()),
),
])
.into();
upload_file_internal(s3_client, &file_key, request, options).await?;
return Ok(Json(UploadFileResponse { file_key }));
}
fn get_on_behalf_of(policy: &Policy) -> Result<(String, String)> {
let permissioned_as = policy
.on_behalf_of

View File

@@ -1,9 +1,21 @@
use axum::Router;
use serde::Serialize;
use uuid::Uuid;
use windmill_common::s3_helpers::StorageResourceType;
#[cfg(feature = "parquet")]
use crate::db::{ApiAuthed, DB};
#[cfg(feature = "parquet")]
use object_store::{ObjectStore, PutMultipartOpts};
#[cfg(feature = "parquet")]
use std::sync::Arc;
use windmill_common::error;
#[cfg(feature = "parquet")]
use windmill_common::{db::UserDB, s3_helpers::ObjectStoreResource};
#[derive(Serialize)]
pub struct UploadFileResponse {
pub file_key: String,
}
pub fn workspaced_service() -> Router {
Router::new()
@@ -21,3 +33,29 @@ pub async fn get_workspace_s3_resource<'c>(
// implementation is not open source
Ok((None, None))
}
pub fn get_random_file_name(_file_extension: Option<String>) -> String {
todo!()
}
pub async fn get_s3_resource<'c>(
_authed: &ApiAuthed,
_db: &DB,
_user_db: Option<UserDB>,
_token: &str,
_w_id: &str,
_resource_path: &str,
_resource_type: Option<StorageResourceType>,
_job_id: Option<Uuid>,
) -> error::Result<ObjectStoreResource> {
todo!()
}
pub async fn upload_file_internal(
_s3_client: Arc<dyn ObjectStore>,
_file_key: &str,
_request: axum::extract::Request,
_options: PutMultipartOpts,
) -> error::Result<()> {
todo!()
}

View File

@@ -12,6 +12,7 @@ use quick_cache::sync::Cache;
use serde_json::value::RawValue;
use sqlx::Pool;
use std::collections::HashMap;
use std::ops::{Deref, DerefMut};
#[cfg(feature = "prometheus")]
use std::sync::atomic::Ordering;
use tokio::io::AsyncReadExt;
@@ -65,7 +66,7 @@ use windmill_common::{
db::UserDB,
error::{self, to_anyhow, Error},
flow_status::{Approval, FlowStatus, FlowStatusModule},
flows::FlowValue,
flows::{add_virtual_items_if_necessary, FlowValue},
jobs::{script_path_to_payload, CompletedJob, JobKind, JobPayload, QueuedJob, RawCode},
oauth2::HmacSha256,
scripts::{ScriptHash, ScriptLang},
@@ -205,6 +206,7 @@ pub fn workspaced_service() -> Router {
.route("/queue/list_filtered_uuids", get(list_filtered_uuids))
.route("/queue/cancel_selection", post(cancel_selection))
.route("/completed/count", get(count_completed_jobs))
.route("/completed/count_jobs", get(count_completed_jobs_detail))
.route(
"/completed/list",
get(list_completed_jobs).layer(cors.clone()),
@@ -600,7 +602,7 @@ async fn get_flow_job_debug_info(
Extension(db): Extension<DB>,
Path((w_id, id)): Path<(String, Uuid)>,
) -> error::Result<Response> {
let job = get_queued_job(&id, w_id.as_str(), &db).await?;
let job = get_queued_job_ex(&db, &w_id, id, false, None).await?;
if let Some(job) = job {
let is_flow = job.is_flow();
if job.is_flow_step || !is_flow {
@@ -734,6 +736,67 @@ fn generate_get_job_query(no_logs: bool, table: &str) -> String {
{join}
WHERE id = $1 AND {table}.workspace_id = $2");
}
pub async fn get_queued_job_ex(
db: &DB,
workspace_id: &str,
job_id: Uuid,
no_logs: bool,
// first optional is if authed need to be checked, second is the opt_authed itself
opt_authed: Option<&Option<ApiAuthed>>,
) -> error::Result<Option<JobExtended<QueuedJob>>> {
let query = if no_logs { &*GET_QUEUED_JOB_QUERY_NO_LOGS } else { &*GET_QUEUED_JOB_QUERY };
let job = sqlx::query_as::<_, JobExtended<QueuedJob>>(query)
.bind(job_id)
.bind(workspace_id)
.fetch_optional(db)
.await?;
if let Some(job) = job.as_ref() {
if opt_authed.is_some_and(|x| x.is_none()) && job.created_by != "anonymous" {
return Err(Error::BadRequest(
"As a non logged in user, you can only see jobs ran by anonymous users".to_string(),
));
}
}
Ok(job)
}
pub async fn get_completed_job_ex(
db: &DB,
workspace_id: &str,
job_id: Uuid,
no_logs: bool,
// first optional is if authed need to be checked, second is the opt_authed itself
opt_authed: Option<&Option<ApiAuthed>>,
) -> error::Result<Option<JobExtended<CompletedJob>>> {
let query = if no_logs { &*GET_COMPLETED_JOB_QUERY_NO_LOGS } else { &*GET_COMPLETED_JOB_QUERY };
let cjob = sqlx::query_as::<_, JobExtended<CompletedJob>>(query)
.bind(job_id)
.bind(workspace_id)
.fetch_optional(db)
.await?;
if let Some(job) = cjob.as_ref() {
if opt_authed.is_some_and(|x| x.is_none()) && job.created_by != "anonymous" {
return Err(Error::BadRequest(
"As a non logged in user, you can only see jobs ran by anonymous users".to_string(),
));
}
}
if let Some(mut cjob) = cjob {
let CompletedJobWithFormattedResult { mut cj, result } = format_completed_job_result(cjob.inner);
cj.result = match result {
Some(FormattedResult::RawValue(rv)) => rv,
Some(FormattedResult::Vec(v)) => Some(to_raw_value(&v)),
None => None,
}.map(sqlx::types::Json);
cjob.inner = cj;
return Ok(Some(cjob));
}
Ok(cjob)
}
pub async fn get_job_internal(
db: &DB,
workspace_id: &str,
@@ -742,48 +805,18 @@ pub async fn get_job_internal(
// first optional is if authed need to be checked, second is the opt_authed itself
opt_authed: Option<&Option<ApiAuthed>>,
) -> error::Result<Job> {
let cjob_maybe = sqlx::query_as::<_, CompletedJob>(if no_logs {
&*GET_COMPLETED_JOB_QUERY_NO_LOGS
} else {
&*GET_COMPLETED_JOB_QUERY
})
.bind(job_id)
.bind(workspace_id)
.fetch_optional(db)
.await?
.map(Job::CompletedJob);
if let Some(cjob) = cjob_maybe {
Ok(match cjob {
Job::CompletedJob(cjob) => {
if opt_authed.is_some_and(|x| x.is_none()) && cjob.created_by != "anonymous" {
return Err(Error::BadRequest(
"As a non logged in user, you can only see jobs ran by anonymous users"
.to_string(),
));
}
Job::CompletedJobWithFormattedResult(format_completed_job_result(cjob))
}
cjob => cjob,
})
} else {
let job_o = sqlx::query_as::<_, QueuedJob>(if no_logs {
&*GET_QUEUED_JOB_QUERY_NO_LOGS
} else {
&*GET_QUEUED_JOB_QUERY
})
.bind(job_id)
.bind(workspace_id)
.fetch_optional(db)
let cjob = get_completed_job_ex(db, workspace_id, job_id, no_logs, opt_authed.clone())
.await?
.map(Job::QueuedJob);
let job: Job = not_found_if_none(job_o, "Job", job_id.to_string())?;
if opt_authed.is_some_and(|x| x.is_none()) && job.created_by() != "anonymous" {
return Err(Error::BadRequest(
"As a non logged in user, you can only see jobs ran by anonymous users".to_string(),
));
.map(Job::CompletedJob);
match cjob {
Some(cjob) => Ok(cjob),
None => {
let job_maybe = get_queued_job_ex(db, workspace_id, job_id, no_logs, opt_authed)
.await?
.map(Job::QueuedJob);
not_found_if_none(job_maybe, "Job", job_id.to_string())
}
Ok(job)
}
}
@@ -1560,6 +1593,50 @@ async fn count_queue_jobs(
))
}
#[derive(Deserialize)]
pub struct CountCompletedJobsQuery {
completed_after_s_ago: Option<i64>,
success: Option<bool>,
tags: Option<String>,
all_workspaces: Option<bool>,
}
async fn count_completed_jobs_detail(
Extension(db): Extension<DB>,
Path(w_id): Path<String>,
Query(query): Query<CountCompletedJobsQuery>,
) -> error::JsonResult<i64> {
let mut sqlb = SqlBuilder::select_from("completed_job");
sqlb
.field("COUNT(*) as count");
if !query.all_workspaces.unwrap_or(false) {
sqlb.and_where_eq("workspace_id", "?".bind(&w_id));
}
if let Some(after_s_ago) = query.completed_after_s_ago {
let after = Utc::now() - chrono::Duration::seconds(after_s_ago);
sqlb.and_where_gt("started_at + duration_ms / 1000 * interval '1 second'", "?".bind(&after.to_rfc3339()));
}
if let Some(success) = query.success {
sqlb.and_where_eq("success", "?".bind(&success));
}
if let Some(tags) = query.tags {
sqlb.and_where_in("tag", &tags.split(",").map(|t| format!("'{}'", t)).collect::<Vec<_>>());
}
let sql = sqlb.sql()?;
let stats = sqlx::query_scalar::<_, i64>(&sql)
.fetch_one(&db)
.await?;
Ok(Json(stats))
}
async fn count_completed_jobs(
Extension(db): Extension<DB>,
Path(w_id): Path<String>,
@@ -1735,7 +1812,6 @@ async fn resume_suspended_job_internal(
let trigger_email = match &parent_flow {
Job::CompletedJob(job) => &job.email,
Job::QueuedJob(job) => &job.email,
Job::CompletedJobWithFormattedResult(job) => &job.cj.email,
};
conditionally_require_authed_user(authed.clone(), flow_status, trigger_email)?;
@@ -2010,7 +2086,6 @@ pub async fn get_suspended_job_flow(
let trigger_email = match &flow {
Job::CompletedJob(job) => &job.email,
Job::QueuedJob(job) => &job.email,
Job::CompletedJobWithFormattedResult(job) => &job.cj.email,
};
conditionally_require_authed_user(authed.clone(), flow_status.clone(), trigger_email)?;
@@ -2225,13 +2300,45 @@ pub async fn get_resume_urls(
Ok(Json(res))
}
#[derive(sqlx::FromRow, Debug, Serialize)]
pub struct JobExtended<T> {
#[sqlx(flatten)]
#[serde(flatten)]
inner: T,
#[sqlx(skip)]
#[serde(skip_serializing_if = "Option::is_none")]
pub self_wait_time_ms: Option<i64>,
#[sqlx(skip)]
#[serde(skip_serializing_if = "Option::is_none")]
pub aggregate_wait_time_ms: Option<i64>,
}
impl<T> JobExtended<T> {
pub fn new(self_wait_time_ms: Option<i64>, aggregate_wait_time_ms: Option<i64>, inner: T) -> Self {
Self { inner, self_wait_time_ms, aggregate_wait_time_ms }
}
}
impl<T> Deref for JobExtended<T> {
type Target = T;
fn deref(&self) -> &Self::Target {
&self.inner
}
}
impl<T> DerefMut for JobExtended<T> {
fn deref_mut(&mut self) -> &mut Self::Target {
&mut self.inner
}
}
#[derive(Serialize, Debug)]
#[serde(tag = "type")]
pub enum Job {
QueuedJob(QueuedJob),
CompletedJob(CompletedJob),
#[serde(rename = "CompletedJob")]
CompletedJobWithFormattedResult(CompletedJobWithFormattedResult),
QueuedJob(JobExtended<QueuedJob>),
CompletedJob(JobExtended<CompletedJob>),
}
impl Job {
@@ -2239,27 +2346,6 @@ impl Job {
match self {
Job::QueuedJob(job) => &job.created_by,
Job::CompletedJob(job) => &job.created_by,
Job::CompletedJobWithFormattedResult(job) => &job.cj.created_by,
}
}
pub fn raw_flow(&self) -> Option<FlowValue> {
match self {
Job::QueuedJob(job) => job
.raw_flow
.as_ref()
.map(|rf| serde_json::from_str(rf.0.get()).ok())
.flatten(),
Job::CompletedJob(job) => job
.raw_flow
.as_ref()
.map(|rf| serde_json::from_str(rf.0.get()).ok())
.flatten(),
Job::CompletedJobWithFormattedResult(job) => job
.cj
.raw_flow
.as_ref()
.map(|rf| serde_json::from_str(rf.0.get()).ok())
.flatten(),
}
}
@@ -2279,13 +2365,6 @@ impl Job {
job.logs = Some(logs.to_string());
}
}
Job::CompletedJobWithFormattedResult(job) => {
if let Some(ref mut l) = job.cj.logs {
l.push_str(logs);
} else {
job.cj.logs = Some(logs.to_string());
}
}
}
}
@@ -2293,7 +2372,6 @@ impl Job {
match self {
Job::QueuedJob(job) => job.logs.as_ref().map(|l| l.len()),
Job::CompletedJob(job) => job.logs.as_ref().map(|l| l.len()),
Job::CompletedJobWithFormattedResult(job) => job.cj.logs.as_ref().map(|l| l.len()),
}
}
@@ -2301,7 +2379,6 @@ impl Job {
match self {
Job::QueuedJob(job) => job.logs.clone(),
Job::CompletedJob(job) => job.logs.clone(),
Job::CompletedJobWithFormattedResult(job) => job.cj.logs.clone(),
}
}
pub fn flow_status(&self) -> Option<FlowStatus> {
@@ -2316,19 +2393,12 @@ impl Job {
.as_ref()
.map(|rf| serde_json::from_str(rf.0.get()).ok())
.flatten(),
Job::CompletedJobWithFormattedResult(job) => job
.cj
.flow_status
.as_ref()
.map(|rf| serde_json::from_str(rf.0.get()).ok())
.flatten(),
}
}
pub fn is_flow_step(&self) -> bool {
match self {
Job::QueuedJob(job) => job.is_flow_step,
Job::CompletedJob(job) => job.is_flow_step,
Job::CompletedJobWithFormattedResult(job) => job.cj.is_flow_step,
}
}
@@ -2343,7 +2413,6 @@ impl Job {
match self {
Job::QueuedJob(job) => &job.job_kind,
Job::CompletedJob(job) => &job.job_kind,
Job::CompletedJobWithFormattedResult(job) => &job.cj.job_kind,
}
}
@@ -2351,7 +2420,6 @@ impl Job {
match self {
Job::QueuedJob(job) => job.id,
Job::CompletedJob(job) => job.id,
Job::CompletedJobWithFormattedResult(job) => job.cj.id,
}
}
@@ -2359,7 +2427,6 @@ impl Job {
match self {
Job::QueuedJob(job) => &job.workspace_id,
Job::CompletedJob(job) => &job.workspace_id,
Job::CompletedJobWithFormattedResult(job) => &job.cj.workspace_id,
}
}
@@ -2367,7 +2434,6 @@ impl Job {
match self {
Job::QueuedJob(job) => job.script_path.as_ref(),
Job::CompletedJob(job) => job.script_path.as_ref(),
Job::CompletedJobWithFormattedResult(job) => job.cj.script_path.as_ref(),
}
.map(String::as_str)
.unwrap_or("tmp/main")
@@ -2377,7 +2443,6 @@ impl Job {
match self {
Job::QueuedJob(job) => job.args.as_ref(),
Job::CompletedJob(job) => job.args.as_ref(),
Job::CompletedJobWithFormattedResult(job) => job.cj.args.as_ref(),
}
}
@@ -2426,10 +2491,6 @@ impl Job {
job.self_wait_time_ms = self_wait_time;
job.aggregate_wait_time_ms = aggregate_wait_time;
}
Job::CompletedJobWithFormattedResult(job) => {
job.cj.self_wait_time_ms = self_wait_time;
job.cj.aggregate_wait_time_ms = aggregate_wait_time;
}
}
Ok(())
}
@@ -2557,86 +2618,82 @@ impl UnifiedJob {
impl<'a> From<UnifiedJob> for Job {
fn from(uj: UnifiedJob) -> Self {
match uj.typ.as_ref() {
"CompletedJob" => Job::CompletedJob(CompletedJob {
workspace_id: uj.workspace_id,
id: uj.id,
parent_job: uj.parent_job,
created_by: uj.created_by,
created_at: uj.created_at,
started_at: uj.started_at.unwrap_or(uj.created_at),
duration_ms: uj.duration_ms.unwrap(),
success: uj.success.unwrap(),
script_hash: uj.script_hash,
script_path: uj.script_path,
args: None,
result: None,
logs: None,
flow_status: None,
deleted: uj.deleted,
canceled: uj.canceled,
canceled_by: uj.canceled_by,
raw_code: None,
canceled_reason: None,
job_kind: uj.job_kind,
schedule_path: uj.schedule_path,
permissioned_as: uj.permissioned_as,
raw_flow: None,
is_flow_step: uj.is_flow_step,
language: uj.language,
is_skipped: uj.is_skipped,
email: uj.email,
visible_to_owner: uj.visible_to_owner,
mem_peak: uj.mem_peak,
tag: uj.tag,
priority: uj.priority,
labels: uj.labels,
self_wait_time_ms: uj.self_wait_time_ms,
aggregate_wait_time_ms: uj.aggregate_wait_time_ms,
}),
"QueuedJob" => Job::QueuedJob(QueuedJob {
workspace_id: uj.workspace_id,
id: uj.id,
parent_job: uj.parent_job,
created_by: uj.created_by,
created_at: uj.created_at,
started_at: uj.started_at,
script_hash: uj.script_hash,
script_path: uj.script_path,
args: None,
running: uj.running.unwrap(),
scheduled_for: uj.scheduled_for.unwrap(),
logs: None,
flow_status: None,
raw_code: None,
raw_lock: None,
canceled: uj.canceled,
canceled_by: uj.canceled_by,
canceled_reason: None,
last_ping: None,
job_kind: uj.job_kind,
schedule_path: uj.schedule_path,
permissioned_as: uj.permissioned_as,
raw_flow: None,
is_flow_step: uj.is_flow_step,
language: uj.language,
same_worker: false,
pre_run_error: None,
email: uj.email,
visible_to_owner: uj.visible_to_owner,
suspend: uj.suspend,
mem_peak: uj.mem_peak,
root_job: None,
leaf_jobs: None,
tag: uj.tag,
concurrent_limit: uj.concurrent_limit,
concurrency_time_window_s: uj.concurrency_time_window_s,
timeout: None,
flow_step_id: None,
cache_ttl: None,
priority: uj.priority,
self_wait_time_ms: uj.self_wait_time_ms,
aggregate_wait_time_ms: uj.aggregate_wait_time_ms,
}),
"CompletedJob" => Job::CompletedJob(JobExtended::new(uj.self_wait_time_ms, uj.aggregate_wait_time_ms, CompletedJob {
workspace_id: uj.workspace_id,
id: uj.id,
parent_job: uj.parent_job,
created_by: uj.created_by,
created_at: uj.created_at,
started_at: uj.started_at.unwrap_or(uj.created_at),
duration_ms: uj.duration_ms.unwrap(),
success: uj.success.unwrap(),
script_hash: uj.script_hash,
script_path: uj.script_path,
args: None,
result: None,
logs: None,
flow_status: None,
deleted: uj.deleted,
canceled: uj.canceled,
canceled_by: uj.canceled_by,
raw_code: None,
canceled_reason: None,
job_kind: uj.job_kind,
schedule_path: uj.schedule_path,
permissioned_as: uj.permissioned_as,
raw_flow: None,
is_flow_step: uj.is_flow_step,
language: uj.language,
is_skipped: uj.is_skipped,
email: uj.email,
visible_to_owner: uj.visible_to_owner,
mem_peak: uj.mem_peak,
tag: uj.tag,
priority: uj.priority,
labels: uj.labels,
})),
"QueuedJob" => Job::QueuedJob(JobExtended::new(uj.self_wait_time_ms, uj.aggregate_wait_time_ms, QueuedJob {
workspace_id: uj.workspace_id,
id: uj.id,
parent_job: uj.parent_job,
created_by: uj.created_by,
created_at: uj.created_at,
started_at: uj.started_at,
script_hash: uj.script_hash,
script_path: uj.script_path,
args: None,
running: uj.running.unwrap(),
scheduled_for: uj.scheduled_for.unwrap(),
logs: None,
flow_status: None,
raw_code: None,
raw_lock: None,
canceled: uj.canceled,
canceled_by: uj.canceled_by,
canceled_reason: None,
last_ping: None,
job_kind: uj.job_kind,
schedule_path: uj.schedule_path,
permissioned_as: uj.permissioned_as,
raw_flow: None,
is_flow_step: uj.is_flow_step,
language: uj.language,
same_worker: false,
pre_run_error: None,
email: uj.email,
visible_to_owner: uj.visible_to_owner,
suspend: uj.suspend,
mem_peak: uj.mem_peak,
root_job: None,
leaf_jobs: None,
tag: uj.tag,
concurrent_limit: uj.concurrent_limit,
concurrency_time_window_s: uj.concurrency_time_window_s,
timeout: None,
flow_step_id: None,
cache_ttl: None,
priority: uj.priority,
})),
t => panic!("job type {} not valid", t),
}
}
@@ -3079,7 +3136,7 @@ pub async fn run_workflow_as_code(
i += 1;
}
let job = get_queued_job(&job_id, &w_id, &db).await?;
let job = get_queued_job_ex(&db, &w_id, job_id, true, None).await?;
if *CLOUD_HOSTED {
tracing::info!("workflow_as_code_tracing id {i} ");
@@ -3087,6 +3144,7 @@ pub async fn run_workflow_as_code(
}
let job = not_found_if_none(job, "Queued Job", &job_id.to_string())?;
let JobExtended { inner: job, .. } = job;
let (job_payload, tag, _delete_after_use, timeout) = match job.job_kind {
JobKind::Preview => (
JobPayload::Code(RawCode {
@@ -4275,18 +4333,26 @@ async fn run_flow_dependencies_job(
wait_result
}
#[derive(Deserialize)]
struct BatchRawScript {
content: String,
language: Option<ScriptLang>,
lock: Option<String>,
}
#[derive(Deserialize)]
struct BatchInfo {
kind: String,
flow_value: Option<FlowValue>,
path: Option<String>,
rawscript: Option<BatchRawScript>,
}
#[tracing::instrument(level = "trace", skip_all)]
async fn add_batch_jobs(
authed: ApiAuthed,
Extension(db): Extension<DB>,
Extension(rsmq): Extension<Option<rsmq_async::MultiplexedRsmq>>,
Extension(_rsmq): Extension<Option<rsmq_async::MultiplexedRsmq>>,
Path((w_id, n)): Path<(String, i32)>,
Json(batch_info): Json<BatchInfo>,
) -> error::JsonResult<Vec<Uuid>> {
@@ -4302,6 +4368,10 @@ async fn add_batch_jobs(
concurrent_limit,
concurrent_time_window_s,
timeout,
raw_code,
raw_lock,
raw_flow,
flow_status
) = match batch_info.kind.as_str() {
"script" => {
if let Some(path) = batch_info.path {
@@ -4329,6 +4399,10 @@ async fn add_batch_jobs(
concurrent_limit,
concurrency_time_window_s,
timeout,
None,
None,
None,
None,
)
} else {
Err(anyhow::anyhow!(
@@ -4336,61 +4410,72 @@ async fn add_batch_jobs(
))?
}
}
"flow" => {
let mut uuids: Vec<Uuid> = Vec::new();
let payload = if let Some(ref fv) = batch_info.flow_value {
JobPayload::RawFlow { value: fv.clone(), path: None, restarted_from: None }
} else {
if let Some(path) = batch_info.path.as_ref() {
JobPayload::Flow {
path: path.to_string(),
dedicated_worker: None,
apply_preprocessor: false,
}
} else {
Err(anyhow::anyhow!(
"Path is required if no value is not provided"
))?
}
};
let mut tx = PushIsolationLevel::IsolatedRoot(db.clone(), rsmq);
for _ in 0..n {
let ehm = HashMap::new();
let (uuid, ntx) = push(
&db,
tx,
&w_id,
payload.clone(),
PushArgs::from(&ehm),
authed.display_username(),
&authed.email,
username_to_permissioned_as(&authed.username),
"rawscript" => {
if let Some(rawscript) = batch_info.rawscript {
(
None,
None,
JobKind::Preview,
rawscript.language,
None,
None,
None,
None,
None,
false,
false,
None,
true,
Some(rawscript.content),
rawscript.lock,
None,
None,
None,
None,
Some(&authed.clone().into()),
)
.await?;
tx = PushIsolationLevel::Transaction(ntx);
uuids.push(uuid);
} else {
Err(anyhow::anyhow!(
"rawscript is required for `rawscript` kind"
))?
}
match tx {
PushIsolationLevel::Transaction(tx) => {
tx.commit().await?;
}
_ => (),
}
return Ok(Json(uuids));
}
"flow" => {
let (mut value, job_kind, path) = if let Some(value) = batch_info.flow_value {
(value, JobKind::FlowPreview, None)
} else if let Some(path) = batch_info.path {
let value_json = sqlx::query!(
"SELECT flow_version.value AS \"value: sqlx::types::Json<Box<RawValue>>\" FROM flow
LEFT JOIN flow_version
ON flow_version.id = flow.versions[array_upper(flow.versions, 1)]
WHERE flow.path = $1 AND flow.workspace_id = $2",
&path, &w_id
)
.fetch_optional(&db)
.await?
.ok_or_else(|| Error::InternalErr(format!("not found flow at path {:?}", path)))?;
let value =
serde_json::from_str::<FlowValue>(value_json.value.get()).map_err(|err| {
Error::InternalErr(format!(
"could not convert json to flow for {path}: {err:?}"
))
})?;
(value, JobKind::Flow, Some(path))
} else {
Err(anyhow::anyhow!(
"Path is required if no value is not provided"
))?
};
add_virtual_items_if_necessary(&mut value.modules);
let flow_status = FlowStatus::new(&value);
(
None, // script_hash
path, // script_path
job_kind, // job_kind
None, // language
None, // dedicated_worker
value.concurrency_key.clone(), // custom_concurrency_key
value.concurrent_limit.clone(), // concurrent_limit
value.concurrency_time_window_s, // concurrency_time_window_s
None, // timeout
None, // raw_code
None, // raw_lock
Some(value), // raw_flow
Some(flow_status), // flow_status
)
}
"noop" => (
None,
@@ -4402,6 +4487,10 @@ async fn add_batch_jobs(
None,
None,
None,
None,
None,
None,
None,
),
_ => {
return Err(error::Error::BadRequest(format!(
@@ -4428,8 +4517,8 @@ async fn add_batch_jobs(
select gen_random_uuid() as uuid from generate_series(1, $11)
)
INSERT INTO queue
(id, script_hash, script_path, job_kind, language, args, tag, created_by, permissioned_as, email, scheduled_for, workspace_id, concurrent_limit, concurrency_time_window_s, timeout)
(SELECT uuid, $1, $2, $3, $4, ('{ "uuid": "' || uuid || '" }')::jsonb, $5, $6, $7, $8, $9, $10, $12, $13, $14 FROM uuid_table)
(id, script_hash, script_path, job_kind, language, args, tag, created_by, permissioned_as, email, scheduled_for, workspace_id, concurrent_limit, concurrency_time_window_s, timeout, raw_code, raw_lock, raw_flow, flow_status)
(SELECT uuid, $1, $2, $3, $4, ('{ "uuid": "' || uuid || '" }')::jsonb, $5, $6, $7, $8, $9, $10, $12, $13, $14, $15, $16, $17, $18 FROM uuid_table)
RETURNING id"#,
hash.map(|h| h.0),
path,
@@ -4444,7 +4533,11 @@ async fn add_batch_jobs(
n,
concurrent_limit,
concurrent_time_window_s,
timeout
timeout,
raw_code,
raw_lock,
raw_flow.map(sqlx::types::Json) as Option<sqlx::types::Json<FlowValue>>,
flow_status.map(sqlx::types::Json) as Option<sqlx::types::Json<FlowStatus>>
)
.fetch_all(&db)
.await?;
@@ -5023,24 +5116,10 @@ async fn get_completed_job<'a>(
Extension(db): Extension<DB>,
Path((w_id, id)): Path<(String, Uuid)>,
) -> error::Result<Response> {
let job_o = sqlx::query_as::<_, CompletedJob>("SELECT id, workspace_id, parent_job, created_by, created_at, duration_ms, success, script_hash, script_path,
CASE WHEN args is null or pg_column_size(args) < 90000 THEN args ELSE '\"WINDMILL_TOO_BIG\"'::jsonb END as args, CASE WHEN result is null or pg_column_size(result) < 90000 THEN result ELSE '\"WINDMILL_TOO_BIG\"'::jsonb END as result, logs, deleted, raw_code, canceled, canceled_by, canceled_reason, job_kind,
schedule_path, permissioned_as, flow_status, raw_flow, is_flow_step, language, started_at, is_skipped,
raw_lock, email, visible_to_owner, mem_peak, tag, priority, result->'wm_labels' as labels FROM completed_job WHERE id = $1 AND workspace_id = $2")
.bind(id)
.bind(&w_id)
.fetch_optional(&db)
let job_o = get_completed_job_ex(&db, &w_id, id, false, Some(&opt_authed))
.await?;
let cj = not_found_if_none(job_o, "Completed Job", id.to_string())?;
if opt_authed.is_none() && cj.created_by != "anonymous" {
return Err(Error::BadRequest(
"As a non logged in user, you can only see jobs ran by anonymous users".to_string(),
));
}
let cj = format_completed_job_result(cj);
let response = Json(cj).into_response();
// let extra_log = query_scalar!(
// "SELECT substr(logs, $1) as logs FROM large_logs WHERE workspace_id = $2 AND job_id = $3",

View File

@@ -25,7 +25,6 @@ use argon2::Argon2;
use axum::extract::DefaultBodyLimit;
use axum::{middleware::from_extractor, routing::get, Extension, Router};
use db::DB;
use git_version::git_version;
use http::HeaderValue;
use reqwest::Client;
use std::collections::HashMap;
@@ -40,7 +39,7 @@ use tower_http::{
};
use windmill_common::db::UserDB;
use windmill_common::worker::{ALL_TAGS, CLOUD_HOSTED};
use windmill_common::{BASE_URL, INSTANCE_NAME};
use windmill_common::{BASE_URL, INSTANCE_NAME, utils::GIT_VERSION};
use crate::scim_ee::has_scim_token;
use windmill_common::error::AppError;
@@ -63,13 +62,14 @@ mod http_triggers;
mod indexer_ee;
mod inputs;
mod integration;
mod ai;
#[cfg(feature = "parquet")]
mod job_helpers_ee;
pub mod job_metrics;
pub mod jobs;
pub mod oauth2_ee;
mod oidc_ee;
mod openai;
mod raw_apps;
mod resources;
mod saml_ee;
@@ -93,11 +93,9 @@ mod workers;
mod workspaces;
mod workspaces_ee;
pub const GIT_VERSION: &str =
git_version!(args = ["--tag", "--always"], fallback = "unknown-version");
pub const DEFAULT_BODY_LIMIT: usize = 2097152 * 100; // 200MB
lazy_static::lazy_static! {
pub static ref REQUEST_SIZE_LIMIT: Arc<RwLock<usize>> = Arc::new(RwLock::new(DEFAULT_BODY_LIMIT));
@@ -282,7 +280,7 @@ pub async fn run_server(
.nest("/job_helpers", job_helpers_service)
.nest("/jobs", jobs::workspaced_service())
.nest("/oauth", oauth2_ee::workspaced_service())
.nest("/openai", openai::workspaced_service())
.nest("/ai", ai::workspaced_service())
.nest("/raw_apps", raw_apps::workspaced_service())
.nest("/resources", resources::workspaced_service())
.nest("/schedules", schedule::workspaced_service())

View File

@@ -1,344 +0,0 @@
use std::collections::HashMap;
use crate::db::{ApiAuthed, DB};
use axum::{
body::Bytes,
extract::{Extension, Path, Query},
response::IntoResponse,
routing::post,
Router,
};
use quick_cache::sync::Cache;
use reqwest::Client;
use serde_json::value::RawValue;
use windmill_audit::audit_ee::audit_log;
use windmill_audit::ActionKind;
use windmill_common::{
error::{to_anyhow, Error},
variables::build_crypt,
};
use crate::variables::decrypt;
use serde::Deserialize;
lazy_static::lazy_static! {
static ref HTTP_CLIENT: Client = reqwest::ClientBuilder::new()
.timeout(std::time::Duration::from_secs(60 * 5))
.user_agent("windmill/beta")
.build().unwrap();
}
pub fn workspaced_service() -> Router {
let router = Router::new().route("/proxy/*openai_path", post(proxy));
router
}
#[derive(Deserialize)]
struct OpenaiResource {
api_key: String,
organization_id: Option<String>,
}
#[derive(Deserialize)]
struct OpenaiClientCredentialsOauthResource {
client_id: String,
client_secret: String,
token_url: String,
user: Option<String>,
}
#[derive(Deserialize)]
#[serde(untagged)]
enum OpenaiConfig {
Resource(OpenaiResource),
ClientCredentialsOauthResource(OpenaiClientCredentialsOauthResource),
}
struct Variable {
value: String,
is_secret: bool,
}
async fn get_variable_or_self(path: String, db: &DB, w_id: &String) -> Result<String, Error> {
if !path.starts_with("$var:") {
return Ok(path);
}
let path = path.strip_prefix("$var:").unwrap().to_string();
let mut variable = sqlx::query_as!(
Variable,
"SELECT value, is_secret
FROM variable
WHERE path = $1 AND workspace_id = $2",
&path,
&w_id
)
.fetch_one(db)
.await?;
if variable.is_secret {
let mc = build_crypt(&db, &w_id).await?;
variable.value = decrypt(&mc, variable.value)?;
}
Ok(variable.value)
}
lazy_static::lazy_static! {
pub static ref OPENAI_AZURE_BASE_PATH: Option<String> = std::env::var("OPENAI_AZURE_BASE_PATH").ok();
static ref OPENAI_KEY_CACHE: Cache<String, OpenaiKeyCache> = Cache::new(500);
}
#[derive(Deserialize)]
struct OpenaiCredentials {
access_token: String,
}
async fn get_openai_key_using_credentials_flow(
mut resource: OpenaiClientCredentialsOauthResource,
db: &DB,
w_id: &String,
) -> Result<String, Error> {
resource.client_id = get_variable_or_self(resource.client_id, &db, &w_id).await?;
resource.client_secret = get_variable_or_self(resource.client_secret, &db, &w_id).await?;
resource.token_url = get_variable_or_self(resource.token_url, &db, &w_id).await?;
let mut params = HashMap::new();
params.insert("grant_type", "client_credentials");
let response = HTTP_CLIENT
.post(resource.token_url)
.form(&params)
.basic_auth(resource.client_id, Some(resource.client_secret))
.send()
.await
.map_err(|err| {
Error::InternalErr(format!(
"Failed to get OpenAI credentials using credentials flow: {}",
err
))
})?;
let response = response.json::<OpenaiCredentials>().await.map_err(|err| {
Error::InternalErr(format!(
"Failed to parse OpenAI credentials from credentials flow: {}",
err
))
})?;
Ok(response.access_token)
}
#[derive(Clone)]
struct OpenaiKeyCache {
api_key: String,
organization_id: Option<String>,
azure_base_path: Option<String>,
user: Option<String>,
expires_at: std::time::Instant,
}
impl OpenaiKeyCache {
fn new(
api_key: String,
organization_id: Option<String>,
azure_base_path: Option<String>,
expires_at: std::time::Instant,
user: Option<String>,
) -> Self {
Self { api_key, organization_id, azure_base_path, expires_at, user }
}
fn is_expired(&self) -> bool {
self.expires_at < std::time::Instant::now()
}
}
#[derive(Deserialize)]
struct ProxyQueryParams {
no_cache: Option<bool>,
}
async fn proxy(
authed: ApiAuthed,
Extension(db): Extension<DB>,
Path((w_id, openai_path)): Path<(String, String)>,
Query(query_params): Query<ProxyQueryParams>,
mut body: Bytes,
) -> impl IntoResponse {
let workspace_cache = OPENAI_KEY_CACHE.get(&w_id);
let (api_key, organization_id, azure_base_path, user) = if query_params
.no_cache
.unwrap_or(false)
|| workspace_cache.is_none()
|| workspace_cache.clone().unwrap().is_expired()
{
let openai_resource_path = sqlx::query_scalar!(
"SELECT openai_resource_path FROM workspace_settings WHERE workspace_id = $1",
&w_id
)
.fetch_one(&db)
.await?;
if openai_resource_path.is_none() {
return Err(Error::InternalErr(
"OpenAI resource not configured".to_string(),
));
}
let openai_resource_path = openai_resource_path.unwrap();
let resource = sqlx::query_scalar!(
"SELECT value
FROM resource
WHERE path = $1 AND workspace_id = $2",
&openai_resource_path,
&w_id
)
.fetch_optional(&db)
.await?
.ok_or_else(|| {
Error::InternalErr(format!(
"Could not find the OpenAI resource at path {openai_resource_path}, update the resource path in the workspace settings"
))
})?;
if resource.is_none() {
return Err(Error::InternalErr(
"OpenAI resource missing value".to_string(),
));
}
let config: OpenaiConfig = serde_json::from_value(resource.unwrap())
.map_err(|e| Error::InternalErr(format!("validating openai resource {e:#}")))?;
let mut user = None::<String>;
let mut resource = match config {
OpenaiConfig::Resource(resource) => {
tracing::debug!("Getting OpenAI key from static resource");
resource
}
OpenaiConfig::ClientCredentialsOauthResource(resource) => {
tracing::debug!("Getting OpenAI key with client credentials flow");
user = resource.user.clone();
let token = get_openai_key_using_credentials_flow(resource, &db, &w_id).await?;
OpenaiResource { api_key: token, organization_id: None }
}
};
resource.api_key = get_variable_or_self(resource.api_key, &db, &w_id).await?;
if resource.organization_id.is_some() {
resource.organization_id =
Some(get_variable_or_self(resource.organization_id.unwrap(), &db, &w_id).await?);
}
if user.is_some() {
user = Some(get_variable_or_self(user.unwrap(), &db, &w_id).await?);
}
let expires_at = std::time::Instant::now() + std::time::Duration::from_secs(60);
let azure_base_path = sqlx::query_scalar!(
"SELECT value
FROM global_settings
WHERE name = 'openai_azure_base_path'",
)
.fetch_optional(&db)
.await?;
let azure_base_path = if let Some(azure_base_path) = azure_base_path {
Some(
serde_json::from_value::<String>(azure_base_path).map_err(|e| {
Error::InternalErr(format!("validating openai azure base path {e:#}"))
})?,
)
} else {
OPENAI_AZURE_BASE_PATH.clone()
};
let workspace_cache = OpenaiKeyCache::new(
resource.api_key.clone(),
resource.organization_id.clone(),
azure_base_path.clone(),
expires_at,
user.clone(),
);
OPENAI_KEY_CACHE.insert(w_id.clone(), workspace_cache);
(
resource.api_key,
resource.organization_id,
azure_base_path,
user,
)
} else {
tracing::debug!("Using cached OpenAI key");
let workspace_cache = workspace_cache.unwrap();
(
workspace_cache.api_key.clone(),
workspace_cache.organization_id.clone(),
workspace_cache.azure_base_path.clone(),
workspace_cache.user.clone(),
)
};
if user.is_some() {
tracing::debug!("Adding user to request body");
let mut json_body: HashMap<String, Box<RawValue>> = serde_json::from_slice(&body)
.map_err(|e| Error::InternalErr(format!("Failed to parse request body: {}", e)))?;
let user_json_string = serde_json::Value::String(user.unwrap()).to_string(); // makes sure to escape characters
json_body.insert(
"user".to_string(),
RawValue::from_string(user_json_string)
.map_err(|e| Error::InternalErr(format!("Failed to parse user: {}", e)))?,
);
body = serde_json::to_vec(&json_body)
.map_err(|e| Error::InternalErr(format!("Failed to reserialize request body: {}", e)))?
.into();
}
let base_url = if let Some(base_url) = azure_base_path {
base_url
} else {
"https://api.openai.com/v1".to_string()
};
let url = format!("{}/{}", base_url, openai_path);
let mut request = HTTP_CLIENT
.post(url)
.header("content-type", "application/json")
.body(body);
if base_url != "https://api.openai.com/v1" {
request = request
.header("api-key", api_key)
.query(&[("api-version", "2023-05-15")])
} else {
request = request.header("authorization", format!("Bearer {}", api_key))
}
if let Some(org_id) = organization_id {
request = request.header("OpenAI-Organization", org_id);
}
let response = request.send().await.map_err(to_anyhow)?;
let mut tx = db.begin().await?;
audit_log(
&mut *tx,
&authed,
"openai.request",
ActionKind::Execute,
&w_id,
Some(&authed.email),
Some([("openai_path", &format!("{:?}", openai_path)[..])].into()),
)
.await?;
tx.commit().await?;
if response.error_for_status_ref().is_err() {
return Err(Error::OpenAIError(
response.text().await.unwrap_or("".to_string()),
));
}
let status_code = response.status();
let headers = response.headers().clone();
let stream = response.bytes_stream();
Ok((status_code, headers, axum::body::Body::from_stream(stream)))
}

View File

@@ -300,7 +300,7 @@ async fn get_resource(
.await?;
tx.commit().await?;
if resource_o.is_none() {
explain_resource_perm_error(&path, &w_id, &db).await?;
explain_resource_perm_error(&path, &w_id, &db, &authed).await?;
}
let resource = not_found_if_none(resource_o, "Resource", path)?;
Ok(Json(resource))
@@ -343,7 +343,7 @@ async fn get_resource_value(
tx.commit().await?;
if value_o.is_none() {
explain_resource_perm_error(&path, &w_id, &db).await?;
explain_resource_perm_error(&path, &w_id, &db, &authed).await?;
}
let value = not_found_if_none(value_o, "Resource", path)?;
@@ -354,6 +354,7 @@ async fn explain_resource_perm_error(
path: &str,
w_id: &str,
db: &sqlx::Pool<Postgres>,
authed: &ApiAuthed,
) -> windmill_common::error::Result<()> {
let extra_perms = sqlx::query_scalar!(
"SELECT extra_perms from resource WHERE path = $1 AND workspace_id = $2",
@@ -378,12 +379,12 @@ async fn explain_resource_perm_error(
.fetch_optional(db)
.await?;
return Err(Error::NotAuthorized(format!(
"Resource exists but you don't have access to it:\nresource perms: {}\nfolder perms: {}",
"Resource exists but you don't have access to it:\nresource perms: {}\nfolder perms: {}\nauthed as: {authed:?}",
serde_json::to_string_pretty(&extra_perms).unwrap_or_default(), serde_json::to_string_pretty(&folder_extra_perms).unwrap_or_default()
)));
} else {
return Err(Error::NotAuthorized(format!(
"Resource exists but you don't have access to it:\nresource perms: {}",
"Resource exists but you don't have access to it:\nresource perms: {}\nauthed as: {authed:?}",
serde_json::to_string_pretty(&extra_perms).unwrap_or_default()
)));
}
@@ -457,7 +458,7 @@ pub async fn get_resource_value_interpolated_internal(
.await?;
tx.commit().await?;
if value_o.is_none() {
explain_resource_perm_error(path, workspace, db).await?;
explain_resource_perm_error(path, workspace, db, &authed).await?;
}
let value = not_found_if_none(value_o, "Resource", path)?;

View File

@@ -58,7 +58,10 @@ pub fn global_service() -> Router {
)
.route("/renew_license_key", post(renew_license_key))
.route("/customer_portal", post(create_customer_portal_session))
.route("/test_critical_channels", post(test_critical_channels));
.route("/test_critical_channels", post(test_critical_channels))
.route("/critical_alerts", get(get_critical_alerts))
.route("/critical_alerts/:id/acknowledge", post(acknowledge_critical_alert))
.route("/critical_alerts/acknowledge_all", post(acknowledge_all_critical_alerts));
#[cfg(feature = "parquet")]
{
@@ -430,3 +433,50 @@ pub async fn test_critical_channels(
pub async fn test_critical_channels() -> Result<String> {
Ok("Critical channels require EE".to_string())
}
#[cfg(feature = "enterprise")]
pub async fn get_critical_alerts(
Extension(db): Extension<DB>,
authed: ApiAuthed,
Query(params): Query<crate::utils::AlertQueryParams>,
) -> JsonResult<Vec<crate::utils::CriticalAlert>> {
require_super_admin(&db, &authed.email).await?;
crate::utils::get_critical_alerts(db, params, None).await
}
#[cfg(not(feature = "enterprise"))]
pub async fn get_critical_alerts() -> error::Error {
error::Error::NotFound("Critical Alerts require EE".to_string())
}
#[cfg(feature = "enterprise")]
pub async fn acknowledge_critical_alert(
Extension(db): Extension<DB>,
authed: ApiAuthed,
Path(id): Path<i32>,
) -> error::Result<String> {
require_super_admin(&db, &authed.email).await?;
crate::utils::acknowledge_critical_alert(db, None, id).await
}
#[cfg(not(feature = "enterprise"))]
pub async fn acknowledge_critical_alert() -> error::Error {
error::Error::NotFound("Critical Alerts require EE".to_string())
}
#[cfg(feature = "enterprise")]
pub async fn acknowledge_all_critical_alerts(
Extension(db): Extension<DB>,
authed: ApiAuthed,
) -> error::Result<String> {
require_super_admin(&db, &authed.email).await?;
crate::utils::acknowledge_all_critical_alerts(db, None).await
}
#[cfg(not(feature = "enterprise"))]
pub async fn acknowledge_all_critical_alerts() -> error::Error {
error::Error::NotFound("Critical Alerts require EE".to_string())
}

View File

@@ -90,6 +90,9 @@ pub fn global_service() -> Router {
.route("/accept_invite", post(accept_invite))
.route("/list_as_super_admin", get(list_users_as_super_admin))
.route("/setpassword", post(set_password))
.route("/set_password_of/:user", post(set_password_of_user))
.route("/set_login_type/:user", post(set_login_type))
.route("/create", post(create_user))
.route("/update/:user", post(update_user))
.route("/delete/:user", delete(delete_user))
@@ -746,10 +749,20 @@ pub async fn fetch_api_authed(
username_override: String,
) -> error::Result<ApiAuthed> {
let permissioned_as = username_to_permissioned_as(username.as_str());
fetch_api_authed_from_permissioned_as(permissioned_as, email, w_id, db, username_override).await
}
pub async fn fetch_api_authed_from_permissioned_as(
permissioned_as: String,
email: String,
w_id: &str,
db: &DB,
username_override: String,
) -> error::Result<ApiAuthed> {
let authed =
fetch_authed_from_permissioned_as(permissioned_as, email.clone(), w_id, db).await?;
Ok(ApiAuthed {
username: username,
username: authed.username,
email: email,
is_admin: authed.is_admin,
is_operator: authed.is_operator,
@@ -856,6 +869,12 @@ pub struct EditPassword {
pub password: String,
}
#[derive(Deserialize)]
pub struct EditLoginType {
pub login_type: String,
}
#[derive(FromRow, Serialize)]
pub struct TruncatedToken {
pub label: Option<String>,
@@ -2018,7 +2037,52 @@ async fn set_password(
authed: ApiAuthed,
Json(ep): Json<EditPassword>,
) -> Result<String> {
crate::users_ee::set_password(db, argon2, authed, ep).await
let email = authed.email.clone();
crate::users_ee::set_password(db, argon2, authed, &email, ep).await
}
async fn set_password_of_user(
Extension(db): Extension<DB>,
Extension(argon2): Extension<Arc<Argon2<'_>>>,
Path(email): Path<String>,
authed: ApiAuthed,
Json(ep): Json<EditPassword>,
) -> Result<String> {
require_super_admin(&db, &authed.email).await?;
crate::users_ee::set_password(db, argon2, authed, &email, ep).await
}
async fn set_login_type(
Extension(db): Extension<DB>,
Path(email): Path<String>,
authed: ApiAuthed,
Json(et): Json<EditLoginType>,
) -> Result<String> {
require_super_admin(&db, &authed.email).await?;
let mut tx = db.begin().await?;
sqlx::query!(
"UPDATE password SET login_type = $1 WHERE email = $2",
et.login_type,
email
)
.execute(&mut *tx)
.await?;
audit_log(
&mut *tx,
&authed,
"users.set_login_type",
ActionKind::Update,
"global",
Some(&email),
None,
)
.await?;
tx.commit().await?;
Ok(format!("login type of {} updated to {}", email, et.login_type))
}
async fn login(

View File

@@ -27,6 +27,7 @@ pub async fn set_password(
_db: DB,
_argon2: Arc<Argon2<'_>>,
_authed: ApiAuthed,
_user_email: &str,
_ep: EditPassword,
) -> Result<String> {
Err(Error::InternalErr(

View File

@@ -16,6 +16,12 @@ use windmill_common::{
DB,
};
#[cfg(feature = "enterprise")]
use windmill_common::error::JsonResult;
#[cfg(feature = "enterprise")]
use axum::Json;
#[derive(Deserialize)]
pub struct WithStarredInfoQuery {
pub with_starred_info: Option<bool>,
@@ -162,3 +168,159 @@ pub fn content_plain(body: Body) -> Response {
.body(body)
.unwrap()
}
use serde::Serialize;
#[derive(Serialize)]
pub struct CriticalAlert {
id: i32,
alert_type: String,
message: String,
created_at: chrono::DateTime<chrono::Utc>,
acknowledged: Option<bool>,
workspace_id: Option<String>,
}
#[cfg(feature = "enterprise")]
#[derive(Deserialize, Debug)]
pub struct AlertQueryParams {
pub page: Option<i32>,
pub page_size: Option<i32>,
pub acknowledged: Option<bool>,
}
#[cfg(feature = "enterprise")]
pub async fn get_critical_alerts(
db: DB,
params: AlertQueryParams,
workspace_id: Option<String>,
) -> JsonResult<Vec<CriticalAlert>> {
let page = params.page.unwrap_or(1).max(1);
let page_size = params.page_size.unwrap_or(10).min(100) as i64;
let offset = ((page - 1) * page_size as i32) as i64;
let alerts = if let Some(workspace_id) = workspace_id {
// `workspace_id` is provided => workspace admin
if params.acknowledged.is_none() {
// Case: return all rows where `workspace_id` matches
sqlx::query_as!(
CriticalAlert,
"SELECT id, alert_type, message, created_at, COALESCE(acknowledged_workspace, false) AS acknowledged, workspace_id
FROM alerts
WHERE workspace_id = $1
ORDER BY created_at DESC
LIMIT $2 OFFSET $3",
workspace_id,
page_size,
offset
)
.fetch_all(&db)
.await?
} else {
// Case: return rows where `acknowledged_workspace` matches `params.acknowledged`
sqlx::query_as!(
CriticalAlert,
"SELECT id, alert_type, message, created_at, COALESCE(acknowledged_workspace, false) AS acknowledged, workspace_id
FROM alerts
WHERE workspace_id = $1 AND COALESCE(acknowledged_workspace, false) = $2
ORDER BY created_at DESC
LIMIT $3 OFFSET $4",
workspace_id,
params.acknowledged,
page_size,
offset
)
.fetch_all(&db)
.await?
}
} else {
// `workspace_id` is not provided => superadmin
if params.acknowledged.is_none() {
// Case: Return all rows unfiltered with global acknowledged as acknowledged
sqlx::query_as!(
CriticalAlert,
"SELECT id, alert_type, message, created_at, COALESCE(acknowledged, false) AS acknowledged, workspace_id
FROM alerts
ORDER BY created_at DESC
LIMIT $1 OFFSET $2",
page_size,
offset
)
.fetch_all(&db)
.await?
} else {
// Case: Return rows where global acknowledged matches params.acknowledged
sqlx::query_as!(
CriticalAlert,
"SELECT id, alert_type, message, created_at, COALESCE(acknowledged, false) AS acknowledged, workspace_id
FROM alerts
WHERE COALESCE(acknowledged, false) = $1
ORDER BY created_at DESC
LIMIT $2 OFFSET $3",
params.acknowledged,
page_size,
offset
)
.fetch_all(&db)
.await?
}
};
Ok(Json(alerts))
}
#[cfg(feature = "enterprise")]
pub async fn acknowledge_critical_alert(
db: DB,
workspace_id: Option<String>,
id: i32,
) -> error::Result<String> {
sqlx::query!(
"UPDATE alerts
SET
acknowledged = true,
acknowledged_workspace = CASE
WHEN $2::text IS NOT NULL AND workspace_id = $2 THEN true
ELSE acknowledged_workspace
END
WHERE id = $1",
id,
workspace_id
)
.execute(&db)
.await?;
tracing::info!(
"Acknowledged critical alert with id: {}{}",
id,
workspace_id.map_or_else(|| "".to_string(), |w| format!(" for workspace_id: {}", w))
);
Ok("Critical alert acknowledged".to_string())
}
#[cfg(feature = "enterprise")]
pub async fn acknowledge_all_critical_alerts(
db: DB,
workspace_id: Option<String>,
) -> error::Result<String> {
sqlx::query!(
"UPDATE alerts
SET
acknowledged = true,
acknowledged_workspace = CASE
WHEN $1::text IS NOT NULL THEN true
ELSE acknowledged_workspace
END
WHERE ($1::text IS NOT NULL AND workspace_id = $1)
OR ($1::text IS NULL)",
workspace_id
)
.execute(&db)
.await?;
tracing::info!(
"Acknowledged all unacknowledged critical alerts{}",
workspace_id.map_or_else(|| "".to_string(), |w| format!(" for workspace_id: {}", w))
);
Ok("All unacknowledged critical alerts acknowledged".to_string())
}

View File

@@ -754,12 +754,14 @@ async fn disable_with_error(db: &DB, ws_trigger: &WebsocketTrigger, error: Strin
)
.execute(db).await {
Ok(_) => {
report_critical_error(format!("Disabling websocket {} because of error: {}", ws_trigger.url, error), db.clone()).await;
report_critical_error(format!("Disabling websocket {} because of error: {}", ws_trigger.url, error), db.clone(), Some(&ws_trigger.workspace_id), None).await;
},
Err(disable_err) => {
report_critical_error(
format!("Could not disable websocket {} with err {}, disabling because of error {}", ws_trigger.path, disable_err, error),
db.clone()
db.clone(),
Some(&ws_trigger.workspace_id),
None,
).await;
}
}
@@ -896,7 +898,7 @@ async fn listen_to_websocket(
}
if should_handle {
if let Err(err) = run_job(&db, rsmq.clone(), &ws_trigger, text).await {
report_critical_error(format!("Failed to trigger job from websocket {}: {:?}", ws_trigger.url, err), db.clone()).await;
report_critical_error(format!("Failed to trigger job from websocket {}: {:?}", ws_trigger.url, err), db.clone(), Some(&ws_trigger.workspace_id), None).await;
};
}
},

View File

@@ -36,6 +36,7 @@ pub fn global_service() -> Router {
)
.route("/get_default_tags", get(get_default_tags))
.route("/queue_metrics", get(get_queue_metrics))
.route("/queue_counts", get(get_queue_counts))
}
#[derive(FromRow, Serialize, Deserialize)]
@@ -176,3 +177,12 @@ async fn get_queue_metrics(
Ok(Json(queue_metrics))
}
async fn get_queue_counts(
authed: ApiAuthed,
Extension(db): Extension<DB>,
) -> JsonResult<std::collections::HashMap<String, u32>> {
require_super_admin(&db, &authed.email).await?;
let queue_counts = windmill_common::queue::get_queue_counts(&db).await;
Ok(Json(queue_counts))
}

View File

@@ -8,6 +8,7 @@
use std::collections::HashMap;
use crate::ai::{AiResource, AI_KEY_CACHE};
use crate::db::ApiAuthed;
use crate::users_ee::send_email_if_possible;
use crate::utils::get_instance_username_or_create_pending;
@@ -118,7 +119,11 @@ pub fn workspaced_service() -> Router {
.route("/change_workspace_name", post(change_workspace_name))
.route("/change_workspace_id", post(change_workspace_id))
.route("/usage", get(get_usage))
.route("/used_triggers", get(get_used_triggers));
.route("/used_triggers", get(get_used_triggers))
.route("/critical_alerts", get(get_critical_alerts))
.route("/critical_alerts/:id/acknowledge", post(acknowledge_critical_alert))
.route("/critical_alerts/acknowledge_all", post(acknowledge_all_critical_alerts))
.route("/critical_alerts/mute", post(mute_critical_alerts));
#[cfg(feature = "stripe")]
{
@@ -168,7 +173,7 @@ pub struct WorkspaceSettings {
pub plan: Option<String>,
pub webhook: Option<String>,
pub deploy_to: Option<String>,
pub openai_resource_path: Option<String>,
pub ai_resource: Option<serde_json::Value>,
pub code_completion_enabled: bool,
pub error_handler: Option<String>,
pub error_handler_extra_args: Option<serde_json::Value>,
@@ -179,6 +184,7 @@ pub struct WorkspaceSettings {
pub default_app: Option<String>,
pub automatic_billing: bool,
pub default_scripts: Option<serde_json::Value>,
pub mute_critical_alerts: Option<bool>,
}
#[derive(FromRow, Serialize, Debug)]
@@ -234,7 +240,7 @@ struct EditWebhook {
#[derive(Deserialize)]
struct EditCopilotConfig {
openai_resource_path: Option<String>,
ai_resource: Option<serde_json::Value>,
code_completion_enabled: bool,
}
@@ -645,23 +651,33 @@ async fn edit_copilot_config(
let mut tx = db.begin().await?;
if let Some(openai_resource_path) = &eo.openai_resource_path {
if let Some(ai_resource) = &eo.ai_resource {
let path = serde_json::from_value::<AiResource>(ai_resource.clone())
.map_err(|e| Error::BadRequest(e.to_string()))?
.path;
sqlx::query!(
"UPDATE workspace_settings SET openai_resource_path = $1, code_completion_enabled = $2 WHERE workspace_id = $3",
openai_resource_path,
"UPDATE workspace_settings SET ai_resource = $1, code_completion_enabled = $2 WHERE workspace_id = $3",
ai_resource,
eo.code_completion_enabled,
&w_id
)
.execute(&mut *tx)
.await?;
if let Some(cached) = AI_KEY_CACHE.get(&w_id) {
if cached.path != path {
AI_KEY_CACHE.remove(&w_id);
}
}
} else {
sqlx::query!(
"UPDATE workspace_settings SET openai_resource_path = NULL, code_completion_enabled = $1 WHERE workspace_id = $2",
"UPDATE workspace_settings SET ai_resource = NULL, code_completion_enabled = $1 WHERE workspace_id = $2",
eo.code_completion_enabled,
&w_id,
)
.execute(&mut *tx)
.await?;
AI_KEY_CACHE.remove(&w_id);
}
audit_log(
&mut *tx,
@@ -672,10 +688,7 @@ async fn edit_copilot_config(
Some(&authed.email),
Some(
[
(
"openai_resource_path",
&format!("{:?}", eo.openai_resource_path)[..],
),
("ai_resource", &format!("{:?}", eo.ai_resource)[..]),
(
"code_completion_enabled",
&format!("{:?}", eo.code_completion_enabled)[..],
@@ -692,7 +705,8 @@ async fn edit_copilot_config(
#[derive(Serialize)]
struct CopilotInfo {
pub exists_openai_resource_path: bool,
pub ai_provider: String,
pub exists_ai_resource: bool,
pub code_completion_enabled: bool,
}
async fn get_copilot_info(
@@ -701,16 +715,32 @@ async fn get_copilot_info(
) -> JsonResult<CopilotInfo> {
let mut tx = db.begin().await?;
let record = sqlx::query!(
"SELECT openai_resource_path, code_completion_enabled FROM workspace_settings WHERE workspace_id = $1",
"SELECT ai_resource, code_completion_enabled FROM workspace_settings WHERE workspace_id = $1",
&w_id
)
.fetch_one(&mut *tx)
.await
.map_err(|e| Error::InternalErr(format!("getting openai_resource_path and code_completion_enabled: {e:#}")))?;
.map_err(|e| Error::InternalErr(format!("getting ai_resource and code_completion_enabled: {e:#}")))?;
tx.commit().await?;
let (ai_provider, exists_ai_resource) = if let Some(ai_resource) = record.ai_resource {
let ai_resource = serde_json::from_value::<AiResource>(ai_resource);
let exist = ai_resource.is_ok();
(
if exist {
ai_resource.unwrap().provider
} else {
"".to_string()
},
exist,
)
} else {
("".to_string(), false)
};
Ok(Json(CopilotInfo {
exists_openai_resource_path: record.openai_resource_path.is_some(),
ai_provider,
exists_ai_resource,
code_completion_enabled: record.code_completion_enabled,
}))
}
@@ -2223,7 +2253,7 @@ struct SimplifiedSettings {
error_handler: Option<String>,
error_handler_extra_args: Option<Value>,
error_handler_muted_on_cancel: bool,
openai_resource_path: Option<String>,
ai_resource: Option<serde_json::Value>,
code_completion_enabled: bool,
large_file_storage: Option<Value>,
git_sync: Option<Value>,
@@ -2588,7 +2618,7 @@ async fn tarball_workspace(
webhook,
deploy_to,
error_handler,
openai_resource_path,
ai_resource,
code_completion_enabled,
error_handler_extra_args,
error_handler_muted_on_cancel,
@@ -3050,3 +3080,93 @@ async fn get_usage(Extension(db): Extension<DB>, Path(w_id): Path<String>) -> Re
.unwrap_or(0);
Ok(usage.to_string())
}
#[cfg(feature = "enterprise")]
pub async fn get_critical_alerts(
Extension(db): Extension<DB>,
Path(w_id): Path<String>,
authed: ApiAuthed,
Query(params): Query<crate::utils::AlertQueryParams>,
) -> JsonResult<Vec<crate::utils::CriticalAlert>> {
require_admin(authed.is_admin, &authed.username)?;
crate::utils::get_critical_alerts(db, params, Some(w_id)).await
}
#[cfg(not(feature = "enterprise"))]
pub async fn get_critical_alerts() -> Error {
Error::NotFound("Critical Alerts require EE".to_string())
}
#[cfg(feature = "enterprise")]
pub async fn acknowledge_critical_alert(
Extension(db): Extension<DB>,
Path((w_id, id)): Path<(String, i32)>,
authed: ApiAuthed,
) -> Result<String> {
require_admin(authed.is_admin, &authed.username)?;
crate::utils::acknowledge_critical_alert(db, Some(w_id), id).await
}
#[cfg(not(feature = "enterprise"))]
pub async fn acknowledge_critical_alert() -> Error {
Error::NotFound("Critical Alerts require EE".to_string())
}
#[cfg(feature = "enterprise")]
pub async fn acknowledge_all_critical_alerts(
Extension(db): Extension<DB>,
Path(w_id): Path<String>,
authed: ApiAuthed,
) -> Result<String> {
require_admin(authed.is_admin, &authed.username)?;
crate::utils::acknowledge_all_critical_alerts(db, Some(w_id)).await
}
#[cfg(not(feature = "enterprise"))]
pub async fn acknowledge_all_critical_alerts() -> Error {
Error::NotFound("Critical Alerts require EE".to_string())
}
#[cfg(feature = "enterprise")]
#[derive(Deserialize)]
pub struct MuteCriticalAlertRequest {
pub mute_critical_alerts: Option<bool>,
}
#[cfg(feature = "enterprise")]
async fn mute_critical_alerts(
Extension(db): Extension<DB>,
Path(w_id): Path<String>,
ApiAuthed { is_admin, username, .. }: ApiAuthed,
Json(m_r): Json<MuteCriticalAlertRequest>,
) -> Result<String> {
require_admin(is_admin, &username)?;
let mute_alerts = m_r.mute_critical_alerts.unwrap_or(false);
if mute_alerts {
sqlx::query!(
"UPDATE alerts SET acknowledged_workspace = true, acknowledged = true WHERE workspace_id = $1",
&w_id
)
.execute(&db)
.await?;
}
sqlx::query!(
"UPDATE workspace_settings SET mute_critical_alerts = $1 WHERE workspace_id = $2",
mute_alerts,
&w_id
)
.execute(&db)
.await?;
Ok(format!("Updated mute criticital alert ui settings for workspace: {}", &w_id))
}
#[cfg(not(feature = "enterprise"))]
pub async fn mute_critical_alerts() -> Error {
Error::NotFound("Critical Alerts require EE".to_string())
}

View File

@@ -58,6 +58,7 @@ async-stream.workspace = true
const_format.workspace = true
crc.workspace = true
windmill-macros.workspace = true
semver.workspace = true
[target.'cfg(not(target_env = "msvc"))'.dependencies]
tikv-jemalloc-ctl = { optional = true, workspace = true }

View File

@@ -59,7 +59,7 @@ pub enum Error {
#[error("Error: {0:#?}")]
JsonErr(serde_json::Value),
#[error("{0}")]
OpenAIError(String),
AiError(String),
#[error("{0}")]
AlreadyCompleted(String),
}
@@ -90,7 +90,7 @@ impl IntoResponse for Error {
Self::RequireAdmin(_) => axum::http::StatusCode::FORBIDDEN,
Self::SqlErr(_)
| Self::BadRequest(_)
| Self::OpenAIError(_)
| Self::AiError(_)
| Self::QuotaExceeded(_) => axum::http::StatusCode::BAD_REQUEST,
_ => axum::http::StatusCode::INTERNAL_SERVER_ERROR,
};

View File

@@ -16,6 +16,7 @@ use rand::Rng;
use serde::{Deserialize, Serialize, Serializer};
use crate::{
error::Error,
more_serde::{default_empty_string, default_id, default_null, default_true, is_default},
scripts::{Schema, ScriptHash, ScriptLang},
};
@@ -651,3 +652,29 @@ pub fn add_virtual_items_if_necessary(modules: &mut Vec<FlowModule>) {
});
}
}
pub async fn has_failure_module<'c>(flow: sqlx::types::Uuid, db: &sqlx::Pool<sqlx::Postgres>, completed: bool) -> Result<bool, Error> {
if completed {
sqlx::query_scalar!(
"SELECT raw_flow->'failure_module' != 'null'::jsonb
FROM completed_job
WHERE id = $1",
flow
)
} else {
sqlx::query_scalar!(
"SELECT raw_flow->'failure_module' != 'null'::jsonb
FROM queue
WHERE id = $1",
flow
)
}
.fetch_one(db)
.await
.map_err(|e| {
Error::InternalErr(format!(
"error during retrieval of has_failure_module: {e:#}"
))
})
.map(|v| v.unwrap_or(false))
}

View File

@@ -29,6 +29,7 @@ pub const AUTOMATE_USERNAME_CREATION_SETTING: &str = "automate_username_creation
pub const HUB_BASE_URL_SETTING: &str = "hub_base_url";
pub const HUB_ACCESSIBLE_URL_SETTING: &str = "hub_accessible_url";
pub const CRITICAL_ERROR_CHANNELS_SETTING: &str = "critical_error_channels";
pub const CRITICAL_ALERT_MUTE_UI_SETTING: &str = "critical_alert_mute_ui";
pub const DEV_INSTANCE_SETTING: &str = "dev_instance";
pub const JWT_SECRET_SETTING: &str = "jwt_secret";
pub const EMAIL_DOMAIN_SETTING: &str = "email_domain";

View File

@@ -108,13 +108,6 @@ pub struct QueuedJob {
pub cache_ttl: Option<i32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub priority: Option<i16>,
#[serde(skip_serializing_if = "Option::is_none")]
#[sqlx(skip)]
pub self_wait_time_ms: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
#[sqlx(skip)]
pub aggregate_wait_time_ms: Option<i64>,
}
impl QueuedJob {
@@ -198,8 +191,6 @@ impl Default for QueuedJob {
flow_step_id: None,
cache_ttl: None,
priority: None,
self_wait_time_ms: None,
aggregate_wait_time_ms: None,
}
}
}
@@ -253,13 +244,6 @@ pub struct CompletedJob {
pub priority: Option<i16>,
#[serde(skip_serializing_if = "Option::is_none")]
pub labels: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
#[sqlx(skip)]
pub self_wait_time_ms: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
#[sqlx(skip)]
pub aggregate_wait_time_ms: Option<i64>,
}
impl CompletedJob {

View File

@@ -86,6 +86,8 @@ lazy_static::lazy_static! {
pub static ref METRICS_ENABLED: AtomicBool = AtomicBool::new(std::env::var("METRICS_PORT").is_ok() || std::env::var("METRICS_ADDR").is_ok());
pub static ref METRICS_DEBUG_ENABLED: AtomicBool = AtomicBool::new(false);
pub static ref CRITICAL_ALERT_MUTE_UI_ENABLED: AtomicBool = AtomicBool::new(false);
pub static ref BASE_URL: Arc<RwLock<String>> = Arc::new(RwLock::new("".to_string()));
pub static ref IS_READY: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);

View File

@@ -21,6 +21,7 @@ use reqwest::Client;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use sqlx::{Pool, Postgres};
use semver::Version;
pub const MAX_PER_PAGE: usize = 10000;
pub const DEFAULT_PER_PAGE: usize = 1000;
@@ -28,12 +29,22 @@ pub const DEFAULT_PER_PAGE: usize = 1000;
pub const GIT_VERSION: &str =
git_version!(args = ["--tag", "--always"], fallback = "unknown-version");
use crate::CRITICAL_ALERT_MUTE_UI_ENABLED;
use std::sync::atomic::Ordering;
#[cfg(feature = "enterprise")]
use crate::worker::CLOUD_HOSTED;
lazy_static::lazy_static! {
pub static ref HTTP_CLIENT: Client = reqwest::ClientBuilder::new()
.user_agent("windmill/beta")
.timeout(std::time::Duration::from_secs(20))
.connect_timeout(std::time::Duration::from_secs(10))
.build().unwrap();
pub static ref GIT_SEM_VERSION: Version = Version::parse(
// skip first `v` character.
GIT_VERSION.split_at(1).1
).unwrap_or(Version::new(0, 1, 0));
}
#[derive(Deserialize, Clone)]
@@ -241,12 +252,40 @@ pub fn generate_lock_id(database_name: &str) -> i64 {
0x3d32ad9e * (CRC_IEEE.checksum(database_name.as_bytes()) as i64)
}
pub async fn report_critical_error(error_message: String, _db: DB) -> () {
pub async fn report_critical_error(
error_message: String,
_db: DB,
workspace_id: Option<&str>,
resource: Option<&str>,
) -> () {
tracing::error!("CRITICAL ERROR: {error_message}");
let mute_global = CRITICAL_ALERT_MUTE_UI_ENABLED.load(Ordering::Relaxed);
let mute_workspace = if let Some(workspace_id) = workspace_id {
match fetch_mute_workspace(&_db, workspace_id).await {
Ok(flag) => flag,
Err(err) => {
tracing::error!("Error fetching mute_workspace: {}", err);
false
}
}
} else {
false
};
// we ack_global if mute_global is true, or if mute_workspace is true
// but we ignore global mute setting for ack_workspace
let acknowledge_workspace = mute_workspace;
let acknowledge_global = mute_global || mute_workspace;
if let Err(err) = sqlx::query!(
"INSERT INTO alerts (alert_type, message) VALUES ('critical_error', $1)",
error_message
"INSERT INTO alerts (alert_type, message, acknowledged, acknowledged_workspace, workspace_id, resource)
VALUES ('critical_error', $1, $2, $3, $4, $5)",
error_message,
acknowledge_global,
acknowledge_workspace,
workspace_id,
resource,
)
.execute(&_db)
.await
@@ -255,27 +294,86 @@ pub async fn report_critical_error(error_message: String, _db: DB) -> () {
}
#[cfg(feature = "enterprise")]
send_critical_alert(error_message, &_db, CriticalAlertKind::CriticalError, None).await;
if *CLOUD_HOSTED && workspace_id.is_some() {
tracing::error!(error_message)
} else {
send_critical_alert(error_message, &_db, CriticalAlertKind::CriticalError, None).await;
}
}
pub async fn report_recovered_critical_error(message: String, _db: DB) -> () {
pub async fn report_recovered_critical_error(
message: String,
_db: DB,
workspace_id: Option<&str>,
resource: Option<&str>,
) -> () {
tracing::info!("RECOVERED CRITICAL ERROR: {message}");
if let Err(err) = sqlx::query!(
"INSERT INTO alerts (alert_type, message) VALUES ('recovered_critical_error', $1)",
message
"INSERT INTO alerts (alert_type, message, acknowledged, acknowledged_workspace, workspace_id, resource)
VALUES ('recovered_critical_error', $1, $2, $3, $4, $5)",
message,
true,
true,
workspace_id,
resource,
)
.execute(&_db)
.await
{
tracing::error!("Failed to save critical error to database: {}", err);
tracing::error!("Failed to save recovered critical error to database: {}", err);
}
// acknowledge all alerts with the same resource
if let Some(resource) = resource {
if let Err(err) = sqlx::query!(
"UPDATE alerts SET acknowledged = true, acknowledged_workspace = true WHERE resource = $1 AND alert_type = 'critical_error'",
resource,
)
.execute(&_db)
.await
{
tracing::error!("Failed to acknowledge critical error alerts for resource {}: {}", resource, err);
}
}
#[cfg(feature = "enterprise")]
send_critical_alert(
message,
&_db,
CriticalAlertKind::RecoveredCriticalError,
None,
)
.await;
if *CLOUD_HOSTED && workspace_id.is_some() {
tracing::error!(message);
} else {
send_critical_alert(
message,
&_db,
CriticalAlertKind::RecoveredCriticalError,
None,
)
.await;
}
}
pub async fn fetch_mute_workspace(_db: &DB, workspace_id: &str) -> Result<bool> {
match sqlx::query!(
"SELECT mute_critical_alerts FROM workspace_settings WHERE workspace_id = $1",
workspace_id
)
.fetch_optional(_db)
.await
{
Ok(Some(record)) => Ok(record.mute_critical_alerts.unwrap_or(false)),
Ok(None) => {
tracing::warn!(
"Workspace ID {} not found in workspace_settings table",
workspace_id
);
Ok(false)
}
Err(err) => {
tracing::error!(
"Error querying workspace_settings for workspace_id {}: {}",
workspace_id,
err
);
Err(Error::SqlErr(err))
}
}
}

View File

@@ -1,6 +1,7 @@
use const_format::concatcp;
use itertools::Itertools;
use regex::Regex;
use semver::Version;
use serde::{Deserialize, Serialize};
use serde_json::value::RawValue;
use std::{
@@ -88,6 +89,7 @@ lazy_static::lazy_static! {
.and_then(|x| x.parse::<bool>().ok())
.unwrap_or(false);
pub static ref MIN_VERSION: Arc<RwLock<Version>> = Arc::new(RwLock::new(Version::new(0, 0, 0)));
}
pub async fn make_suspended_pull_query(wc: &WorkerConfig) {
@@ -308,6 +310,8 @@ fn parse_file<T: FromStr>(path: &str) -> Option<T> {
pub struct PythonAnnotations {
pub no_cache: bool,
pub no_uv: bool,
pub no_uv_install: bool,
pub no_uv_compile: bool,
}
#[annotations("//")]
@@ -548,6 +552,31 @@ pub fn get_windmill_memory_usage() -> Option<i64> {
}
}
pub async fn update_min_version<'c, E: sqlx::Executor<'c, Database = sqlx::Postgres>>(executor: E) -> bool {
use crate::utils::{GIT_VERSION, GIT_SEM_VERSION};
// fetch all pings with a different version than self from the last 5 minutes.
let pings = sqlx::query_scalar!(
"SELECT wm_version FROM worker_ping WHERE wm_version != $1 AND ping_at > now() - interval '5 minutes'",
GIT_VERSION
).fetch_all(executor).await.unwrap_or_default();
let cur_version = GIT_SEM_VERSION.clone();
let min_version = pings
.iter()
.filter(|x| !x.is_empty())
.filter_map(|x| semver::Version::parse(x.split_at(1).1).ok())
.min()
.unwrap_or_else(|| cur_version.clone());
if min_version != cur_version {
tracing::info!("Minimal worker version: {min_version}");
}
*MIN_VERSION.write().await = min_version.clone();
min_version >= cur_version
}
pub async fn update_ping(worker_instance: &str, worker_name: &str, ip: &str, db: &DB) {
let (tags, dw) = {
let wc = WORKER_CONFIG.read().await.clone();

View File

@@ -71,6 +71,9 @@ use windmill_common::BASE_URL;
#[cfg(feature = "cloud")]
use windmill_common::users::SUPERADMIN_SYNC_EMAIL;
#[cfg(feature = "enterprise")]
use windmill_common::flows::has_failure_module;
#[cfg(feature = "enterprise")]
use windmill_common::worker::CLOUD_HOSTED;
@@ -482,13 +485,6 @@ where
}
}
#[cfg(feature = "enterprise")]
#[derive(Deserialize)]
struct RawFlowFailureModule {
#[cfg(feature = "enterprise")]
failure_module: Option<Box<RawValue>>,
}
#[instrument(level = "trace", skip_all)]
pub async fn add_completed_job_error<R: rsmq_async::RsmqConnection + Clone + Send>(
db: &Pool<Postgres>,
@@ -928,6 +924,8 @@ pub async fn add_completed_job<
.unwrap_or("".to_string()),
),
db.clone(),
Some(&w_id),
None,
)
.await;
} else if queued_job.email == SCHEDULE_ERROR_HANDLER_USER_EMAIL {
@@ -952,15 +950,7 @@ pub async fn add_completed_job<
} else if !skip_downstream_error_handlers
&& (matches!(queued_job.job_kind, JobKind::Script)
|| matches!(queued_job.job_kind, JobKind::Flow)
&& queued_job
.raw_flow
.as_ref()
.and_then(|v| {
serde_json::from_str::<RawFlowFailureModule>((**v).get())
.ok()
.and_then(|v| v.failure_module)
})
.is_none())
&& !has_failure_module(job_id, db, true).await.unwrap_or(false))
&& queued_job.parent_job.is_none()
{
let result = serde_json::from_str(
@@ -1004,7 +994,7 @@ pub async fn add_completed_job<
"Could not push workspace error handler for failed job ({base_url}/run/{}?workspace={w_id}): {}",
queued_job.id,
err
), db.clone())
), db.clone(), Some(&w_id), None)
.await;
}
}
@@ -1187,10 +1177,10 @@ pub async fn report_error_to_workspace_handler_or_critical_side_channel<
queued_job.id,
err
);
report_critical_error(error_message, db.clone()).await;
report_critical_error(error_message, db.clone(), Some(&w_id), None).await;
}
} else {
report_critical_error(error_message, db.clone()).await;
report_critical_error(error_message, db.clone(), Some(&w_id), None).await;
}
}
@@ -3682,15 +3672,15 @@ pub async fn push<'c, 'd, R: rsmq_async::RsmqConnection + Send + 'c>(
}
JobPayload::DeploymentCallback { path } => (
None,
Some(path),
Some(path.clone()),
None,
JobKind::DeploymentCallback,
None,
None,
None,
None,
None,
None,
Some(format!("{workspace_id}:git_sync")),
Some(1),
Some(0),
None,
None,
None,

View File

@@ -0,0 +1,93 @@
name: "python download pip"
mode: ONCE
hostname: "python"
log_level: ERROR
time_limit: 900
rlimit_as: 2048
rlimit_cpu: 1000
rlimit_fsize: 1024
rlimit_nofile: 64
envar: "HOME=/user"
envar: "LD_LIBRARY_PATH=/usr/local/lib:$LD_LIBRARY_PATH"
cwd: "/tmp"
clone_newnet: false
clone_newuser: {CLONE_NEWUSER}
keep_caps: true
keep_env: true
mount {
src: "/bin"
dst: "/bin"
is_bind: true
}
mount {
src: "/lib"
dst: "/lib"
is_bind: true
}
mount {
src: "/lib64"
dst: "/lib64"
is_bind: true
mandatory: false
}
mount {
src: "/usr"
dst: "/usr"
is_bind: true
}
mount {
src: "/etc"
dst: "/etc"
is_bind: true
}
mount {
src: "/dev/null"
dst: "/dev/null"
is_bind: true
rw: true
}
mount {
dst: "/tmp"
fstype: "tmpfs"
rw: true
options: "size=500000000"
}
mount {
src: "{WORKER_DIR}/download_deps.py.pip.sh"
dst: "/download_deps.sh"
is_bind: true
}
mount {
src: "{CACHE_DIR}"
dst: "{CACHE_DIR}"
is_bind: true
rw: true
}
mount {
src: "/dev/urandom"
dst: "/dev/urandom"
is_bind: true
}
exec_bin {
path: "/bin/sh"
arg: "/download_deps.sh"
}

View File

@@ -0,0 +1,24 @@
#/bin/sh
INDEX_URL_ARG=$([ -z "$INDEX_URL" ] && echo ""|| echo "--index-url $INDEX_URL" )
EXTRA_INDEX_URL_ARG=$([ -z "$EXTRA_INDEX_URL" ] && echo ""|| echo "--extra-index-url $EXTRA_INDEX_URL" )
TRUSTED_HOST_ARG=$([ -z "$TRUSTED_HOST" ] && echo "" || echo "--trusted-host $TRUSTED_HOST")
if [ ! -z "$INDEX_URL" ]
then
echo "\$INDEX_URL is set to $INDEX_URL"
fi
if [ ! -z "$EXTRA_INDEX_URL" ]
then
echo "\$EXTRA_INDEX_URL is set to $EXTRA_INDEX_URL"
fi
if [ ! -z "$TRUSTED_HOST" ]
then
echo "\$TRUSTED_HOST is set to $TRUSTED_HOST"
fi
CMD="/usr/local/bin/python3 -m pip install -v \"$REQ\" -I -t \"$TARGET\" --no-cache --no-color --no-deps --isolated --no-warn-conflicts --disable-pip-version-check $INDEX_URL_ARG $EXTRA_INDEX_URL_ARG $TRUSTED_HOST_ARG"
echo $CMD
eval $CMD

View File

@@ -19,6 +19,18 @@ then
echo "\$TRUSTED_HOST is set to $TRUSTED_HOST"
fi
CMD="/usr/local/bin/python3 -m pip install -v \"$REQ\" -I -t \"$TARGET\" --no-cache --no-color --no-deps --isolated --no-warn-conflicts --disable-pip-version-check $INDEX_URL_ARG $EXTRA_INDEX_URL_ARG $TRUSTED_HOST_ARG"
CMD="/usr/local/bin/uv pip install
\"$REQ\"
--target \"$TARGET\"
--no-cache
--no-config
--no-color
--no-deps
--link-mode=copy
$INDEX_URL_ARG $EXTRA_INDEX_URL_ARG $TRUSTED_HOST_ARG
--index-strategy unsafe-best-match
--system
"
echo $CMD
eval $CMD

View File

@@ -116,6 +116,8 @@ async fn handle_ansible_python_deps(
job_dir,
worker_dir,
&mut Some(occupancy_metrics),
true,
true,
)
.await?;
additional_python_paths.append(&mut venv_path);

View File

@@ -89,8 +89,18 @@ cat bp | tail -1 >> ./result2.out &
# Run main.sh in the same process group
{bash} ./main.sh "$@" 2>&1 | tee bp &
# Wait for all background processes to finish
wait
pid=$!
# Wait for main.sh to finish and capture its exit status
wait $pid
exit_status=$?
# Clean up the named pipe and background processes
rm -f bp
# Exit with the captured status
exit $exit_status
"#,
bash = BIN_BASH.as_str(),
);

View File

@@ -41,7 +41,7 @@ use tokio::{io::AsyncWriteExt, process::Child, time::Instant};
use crate::{
AuthedClient, AuthedClientBackgroundTask, JOB_DEFAULT_TIMEOUT, MAX_RESULT_SIZE,
MAX_TIMEOUT_DURATION,
MAX_TIMEOUT_DURATION, PATH_ENV,
};
pub async fn build_args_map<'a>(
@@ -860,11 +860,17 @@ pub async fn save_in_cache(
}
fn tentatively_improve_error(err: Error, executable: &str) -> Error {
#[cfg(unix)]
let err_msg = "No such file or directory (os error 2)";
#[cfg(windows)]
let err_msg = "program not found";
if err
.to_string()
.contains("No such file or directory (os error 2)")
.contains(&err_msg)
{
return Error::InternalErr(format!("Executable {executable} not found on worker"));
return Error::InternalErr(format!("Executable {executable} not found on worker. PATH: {}", *PATH_ENV));
}
return err;
}

View File

@@ -20,13 +20,21 @@ use std::sync::Arc;
pub async fn build_tar_and_push(
s3_client: Arc<dyn ObjectStore>,
folder: String,
no_uv: bool,
) -> error::Result<()> {
use object_store::path::Path;
use crate::PY311_CACHE_DIR;
tracing::info!("Started building and pushing piptar {folder}");
let start = Instant::now();
let folder_name = folder.split("/").last().unwrap();
let tar_path = format!("{PIP_CACHE_DIR}/{folder_name}_tar.tar",);
let prefix = if no_uv {
PIP_CACHE_DIR
} else {
PY311_CACHE_DIR
};
let tar_path = format!("{prefix}/{folder_name}_tar.tar",);
let tar_file = std::fs::File::create(&tar_path)?;
let mut tar = tar::Builder::new(tar_file);
@@ -46,7 +54,10 @@ pub async fn build_tar_and_push(
// })?;
if let Err(e) = s3_client
.put(
&Path::from(format!("/tar/pip/{folder_name}.tar")),
&Path::from(format!(
"/tar/{}/{folder_name}.tar",
if no_uv { "pip" } else { "python_311" }
)),
std::fs::read(&tar_path)?.into(),
)
.await
@@ -71,7 +82,11 @@ pub async fn build_tar_and_push(
}
#[cfg(all(feature = "enterprise", feature = "parquet"))]
pub async fn pull_from_tar(client: Arc<dyn ObjectStore>, folder: String) -> error::Result<()> {
pub async fn pull_from_tar(
client: Arc<dyn ObjectStore>,
folder: String,
no_uv: bool,
) -> error::Result<()> {
use windmill_common::s3_helpers::attempt_fetch_bytes;
let folder_name = folder.split("/").last().unwrap();
@@ -79,7 +94,10 @@ pub async fn pull_from_tar(client: Arc<dyn ObjectStore>, folder: String) -> erro
tracing::info!("Attempting to pull piptar {folder_name} from bucket");
let start = Instant::now();
let tar_path = format!("tar/pip/{folder_name}.tar");
let tar_path = format!(
"tar/{}/{folder_name}.tar",
if no_uv { "pip" } else { "python_311" }
);
let bytes = attempt_fetch_bytes(client, &tar_path).await?;
// tracing::info!("B: {target} {folder}");

View File

@@ -48,6 +48,7 @@ use futures::{
use crate::common::{resolve_job_timeout, OccupancyMetrics};
use crate::job_logger::{append_job_logs, append_with_limit, LARGE_LOG_THRESHOLD_SIZE};
use crate::job_logger_ee::process_streaming_log_lines;
use crate::{MAX_RESULT_SIZE, MAX_WAIT_FOR_SIGINT, MAX_WAIT_FOR_SIGTERM};
lazy_static::lazy_static! {
@@ -434,6 +435,8 @@ pub async fn handle_child(
}
}
async fn get_mem_peak(pid: Option<u32>, nsjail: bool) -> i32 {
if pid.is_none() {
return -1;
@@ -689,19 +692,24 @@ fn child_joined_output_stream(
let stdout = BufReader::new(stdout).lines();
let stderr = BufReader::new(stderr).lines();
stream::select(lines_to_stream(stderr), lines_to_stream(stdout))
stream::select(lines_to_stream(stderr, true), lines_to_stream(stdout, false))
}
pub fn lines_to_stream<R: tokio::io::AsyncBufRead + Unpin>(
mut lines: tokio::io::Lines<R>,
stderr: bool,
) -> impl futures::Stream<Item = io::Result<String>> {
stream::poll_fn(move |cx| {
std::pin::Pin::new(&mut lines)
.poll_next_line(cx)
.map(|result| result.transpose())
.map(|result| {
process_streaming_log_lines(result, stderr)
})
})
}
pub fn process_status(status: ExitStatus) -> error::Result<()> {
if status.success() {
Ok(())

View File

@@ -1,15 +1,6 @@
use itertools::Itertools;
use swc_ecma_parser::lexer::util::CharExt;
#[cfg(all(feature = "enterprise", feature = "parquet"))]
use object_store::path::Path;
use regex::Regex;
#[cfg(all(feature = "enterprise", feature = "parquet"))]
use windmill_common::s3_helpers::OBJECT_STORE_CACHE_SETTINGS;
use windmill_common::error::{self};
use windmill_common::worker::{CLOUD_HOSTED, TMP_DIR};
use windmill_common::worker::CLOUD_HOSTED;
use windmill_queue::append_logs;
@@ -19,6 +10,12 @@ use std::sync::Arc;
use uuid::Uuid;
use windmill_common::DB;
use crate::job_logger_ee::default_disk_log_storage;
#[cfg(all(feature = "enterprise", feature = "parquet"))]
use crate::job_logger_ee::s3_storage;
pub enum CompactLogs {
#[cfg(not(all(feature = "enterprise", feature = "parquet")))]
NotEE,
@@ -28,150 +25,7 @@ pub enum CompactLogs {
S3,
}
async fn compact_logs(
job_id: Uuid,
w_id: &str,
db: &DB,
nlogs: String,
total_size: Arc<AtomicU32>,
compact_kind: CompactLogs,
_worker_name: &str,
) -> error::Result<(String, String)> {
let mut prev_logs = sqlx::query_scalar!(
"SELECT logs FROM job_logs WHERE job_id = $1 AND workspace_id = $2",
job_id,
w_id
)
.fetch_optional(db)
.await?
.flatten()
.unwrap_or_default();
let size = prev_logs.char_indices().count() as i32;
let nlogs_len = nlogs.char_indices().count();
let to_keep_in_db = usize::max(
usize::min(nlogs_len, 3000),
nlogs_len % LARGE_LOG_THRESHOLD_SIZE,
);
let extra_split = to_keep_in_db < nlogs_len;
let stored_in_storage_len = if extra_split {
nlogs_len - to_keep_in_db
} else {
0
};
let extra_to_newline = nlogs
.chars()
.skip(stored_in_storage_len)
.find_position(|x| x.is_line_break())
.map(|(i, _)| i)
.unwrap_or(to_keep_in_db);
let stored_in_storage_to_newline = stored_in_storage_len + extra_to_newline;
let (append_to_storage, stored_in_db) = if extra_split {
if stored_in_storage_to_newline == nlogs.len() {
(nlogs.as_ref(), "".to_string())
} else {
let split_idx = nlogs
.char_indices()
.nth(stored_in_storage_to_newline)
.map(|(i, _)| i)
.unwrap_or(0);
let (append_to_storage, stored_in_db) = nlogs.split_at(split_idx);
// tracing::error!("{append_to_storage} ||||| {stored_in_db}");
// tracing::error!(
// "{:?} {:?} {} {}",
// excess_prev_logs.lines().last(),
// current_logs.lines().next(),
// split_idx,
// excess_size_modulo
// );
(append_to_storage, stored_in_db.to_string())
}
} else {
// tracing::error!("{:?}", nlogs.lines().last());
("", nlogs.to_string())
};
let new_size_with_excess = size + stored_in_storage_to_newline as i32;
let new_size = total_size.fetch_add(
new_size_with_excess as u32,
std::sync::atomic::Ordering::SeqCst,
) + new_size_with_excess as u32;
let path = format!(
"logs/{job_id}/{}_{new_size}.txt",
chrono::Utc::now().timestamp_millis()
);
let mut new_current_logs = match compact_kind {
CompactLogs::NoS3 => format!("\n[windmill] No object storage set in instance settings. Previous logs have been saved to disk at {path}"),
CompactLogs::S3 => format!("\n[windmill] Previous logs have been saved to object storage at {path}"),
#[cfg(not(all(feature = "enterprise", feature = "parquet")))]
CompactLogs::NotEE => format!("\n[windmill] Previous logs have been saved to disk at {path}"),
};
new_current_logs.push_str(&stored_in_db);
sqlx::query!(
"UPDATE job_logs SET logs = $1, log_offset = $2,
log_file_index = array_append(coalesce(log_file_index, array[]::text[]), $3)
WHERE workspace_id = $4 AND job_id = $5",
new_current_logs,
new_size as i32,
path,
w_id,
job_id
)
.execute(db)
.await?;
prev_logs.push_str(&append_to_storage);
return Ok((prev_logs, path));
}
async fn default_disk_log_storage(
job_id: Uuid,
w_id: &str,
db: &DB,
nlogs: String,
total_size: Arc<AtomicU32>,
compact_kind: CompactLogs,
worker_name: &str,
) {
match compact_logs(
job_id,
&w_id,
&db,
nlogs,
total_size,
compact_kind,
worker_name,
)
.await
{
Err(e) => tracing::error!("Could not compact logs for job {job_id}: {e:?}",),
Ok((prev_logs, path)) => {
let path = format!("{}/{}", TMP_DIR, path);
let splitted = &path.split("/").collect_vec();
tokio::fs::create_dir_all(splitted.into_iter().take(splitted.len() - 1).join("/"))
.await
.map_err(|e| {
tracing::error!("Could not create logs directory: {e:?}",);
e
})
.ok();
let created = tokio::fs::File::create(&path).await;
if let Err(e) = created {
tracing::error!("Could not create logs file {path}: {e:?}",);
return;
}
if let Err(e) = tokio::fs::write(&path, prev_logs).await {
tracing::error!("Could not write to logs file {path}: {e:?}");
} else {
tracing::info!("Logs length of {job_id} has exceeded a threshold. Previous logs have been saved to disk at {path}");
}
}
}
}
pub(crate) async fn append_job_logs(
job_id: Uuid,
@@ -184,43 +38,7 @@ pub(crate) async fn append_job_logs(
) -> () {
if must_compact_logs {
#[cfg(all(feature = "enterprise", feature = "parquet"))]
if let Some(os) = OBJECT_STORE_CACHE_SETTINGS.read().await.clone() {
match compact_logs(
job_id,
&w_id,
&db,
logs,
total_size,
CompactLogs::S3,
&worker_name,
)
.await
{
Err(e) => tracing::error!("Could not compact logs for job {job_id}: {e:?}",),
Ok((prev_logs, path)) => {
tracing::info!("Logs length of {job_id} has exceeded a threshold. Previous logs have been saved to object storage at {path}");
let path2 = path.clone();
if let Err(e) = os
.put(&Path::from(path), prev_logs.to_string().into_bytes().into())
.await
{
tracing::error!("Could not save logs to s3: {e:?}");
}
tracing::info!("Logs of {job_id} saved to object storage at {path2}");
}
}
} else {
default_disk_log_storage(
job_id,
&w_id,
&db,
logs,
total_size,
CompactLogs::NoS3,
&worker_name,
)
.await;
}
s3_storage(job_id, &w_id, &db, logs, total_size, &worker_name).await;
#[cfg(not(all(feature = "enterprise", feature = "parquet")))]
{
@@ -252,6 +70,7 @@ pub fn append_with_limit(dst: &mut String, src: &str, limit: &mut usize) {
if *NO_LOGS_AT_ALL {
return;
}
let src_str;
let src = {
src_str = RE_00.replace_all(src, "");

View File

@@ -0,0 +1,30 @@
use std::io;
use std::sync::atomic::AtomicU32;
use std::sync::Arc;
use uuid::Uuid;
use windmill_common::DB;
use crate::job_logger::CompactLogs;
#[cfg(all(feature = "enterprise", feature = "parquet"))]
pub(crate) async fn s3_storage(_job_id: Uuid, _w_id: &String, _db: &sqlx::Pool<sqlx::Postgres>, _logs: &String, _total_size: &Arc<AtomicU32>, _worker_name: &String) {
tracing::info!("Logs length of {job_id} has exceeded a threshold. Implementation to store excess on s3 in not OSS");
}
pub(crate) async fn default_disk_log_storage(
job_id: Uuid,
_w_id: &str,
_db: &DB,
_nlogs: String,
_total_size: Arc<AtomicU32>,
_compact_kind: CompactLogs,
_worker_name: &str,
) {
tracing::info!("Logs length of {job_id} has exceeded a threshold. Implementation to store excess on disk in not OSS");
}
pub(crate) fn process_streaming_log_lines(r: Result<Option<String>, io::Error>, _stderr: bool) -> Option<Result<String, io::Error>> {
r.transpose()
}

View File

@@ -29,7 +29,7 @@ mod rust_executor;
mod worker;
mod worker_flow;
mod worker_lockfiles;
mod job_logger_ee;
pub use worker::*;
pub use result_processor::handle_job_error;

View File

@@ -1,4 +1,4 @@
use std::{collections::HashMap, process::Stdio};
use std::{collections::HashMap, fs, process::Stdio};
use itertools::Itertools;
use regex::Regex;
@@ -42,6 +42,10 @@ lazy_static::lazy_static! {
static ref USE_PIP_COMPILE: bool = std::env::var("USE_PIP_COMPILE")
.ok().map(|flag| flag == "true").unwrap_or(false);
/// Use pip install
static ref USE_PIP_INSTALL: bool = std::env::var("USE_PIP_INSTALL")
.ok().map(|flag| flag == "true").unwrap_or(false);
static ref RELATIVE_IMPORT_REGEX: Regex = Regex::new(r#"(import|from)\s(((u|f)\.)|\.)"#).unwrap();
@@ -50,6 +54,8 @@ lazy_static::lazy_static! {
}
const NSJAIL_CONFIG_DOWNLOAD_PY_CONTENT: &str = include_str!("../nsjail/download.py.config.proto");
const NSJAIL_CONFIG_DOWNLOAD_PY_CONTENT_FALLBACK: &str =
include_str!("../nsjail/download.py.pip.config.proto");
const NSJAIL_CONFIG_RUN_PYTHON3_CONTENT: &str = include_str!("../nsjail/run.python3.config.proto");
const RELATIVE_PYTHON_LOADER: &str = include_str!("../loader.py");
@@ -66,8 +72,8 @@ use crate::{
},
handle_child::handle_child,
AuthedClientBackgroundTask, DISABLE_NSJAIL, DISABLE_NUSER, HOME_ENV, LOCK_CACHE_DIR,
NSJAIL_PATH, PATH_ENV, PIP_CACHE_DIR, PIP_EXTRA_INDEX_URL, PIP_INDEX_URL, PROXY_ENVS, TZ_ENV,
UV_CACHE_DIR,
NSJAIL_PATH, PATH_ENV, PIP_CACHE_DIR, PIP_EXTRA_INDEX_URL, PIP_INDEX_URL, PROXY_ENVS,
PY311_CACHE_DIR, TZ_ENV, UV_CACHE_DIR,
};
#[cfg(windows)]
@@ -199,8 +205,11 @@ pub async fn uv_pip_compile(
.clone()
.map(handle_ephemeral_token);
if let Some(url) = pip_extra_index_url.as_ref() {
args.extend(["--extra-index-url", url, "--no-emit-index-url"]);
pip_args.push(format!("--extra-index-url {}", url));
url.split(",").for_each(|url| {
args.extend(["--extra-index-url", url]);
pip_args.push(format!("--extra-index-url {}", url));
});
args.push("--no-emit-index-url");
}
let pip_index_url = PIP_INDEX_URL
.read()
@@ -279,7 +288,9 @@ pub async fn uv_pip_compile(
.clone()
.map(handle_ephemeral_token);
if let Some(url) = pip_extra_index_url.as_ref() {
args.extend(["--extra-index-url", url]);
url.split(",").for_each(|url| {
args.extend(["--extra-index-url", url]);
});
}
let pip_index_url = PIP_INDEX_URL
.read()
@@ -309,7 +320,7 @@ pub async fn uv_pip_compile(
.args(args)
.stdout(Stdio::piped())
.stderr(Stdio::piped());
let child_process = start_child_process(child_cmd, "/usr/local/bin/uv").await?;
let child_process = start_child_process(child_cmd, uv_cmd).await?;
append_logs(&job_id, &w_id, logs, db).await;
handle_child(
job_id,
@@ -896,10 +907,10 @@ async fn handle_python_deps(
.unwrap_or_else(|| vec![])
.clone();
let annotations = windmill_common::worker::PythonAnnotations::parse(inner_content);
let requirements = match requirements_o {
Some(r) => r,
None => {
let annotation = windmill_common::worker::PythonAnnotations::parse(inner_content);
let mut already_visited = vec![];
let requirements = windmill_parser_py_imports::parse_python_imports(
@@ -924,8 +935,8 @@ async fn handle_python_deps(
worker_name,
w_id,
occupancy_metrics,
annotation.no_uv,
annotation.no_cache,
annotations.no_uv || annotations.no_uv_compile,
annotations.no_cache,
)
.await
.map_err(|e| {
@@ -950,6 +961,8 @@ async fn handle_python_deps(
job_dir,
worker_dir,
occupancy_metrics,
annotations.no_uv || annotations.no_uv_install,
false,
)
.await?;
additional_python_paths.append(&mut venv_path);
@@ -961,6 +974,7 @@ lazy_static::lazy_static! {
static ref PIP_SECRET_VARIABLE: Regex = Regex::new(r"\$\{PIP_SECRET:([^\s\}]+)\}").unwrap();
}
/// pip install, include cached or pull from S3
pub async fn handle_python_reqs(
requirements: Vec<&str>,
job_id: &Uuid,
@@ -972,12 +986,22 @@ pub async fn handle_python_reqs(
job_dir: &str,
worker_dir: &str,
occupancy_metrics: &mut Option<&mut OccupancyMetrics>,
// TODO: Remove (Deprecated)
mut no_uv_install: bool,
is_ansible: bool,
) -> error::Result<Vec<String>> {
let mut req_paths: Vec<String> = vec![];
let mut vars = vec![("PATH", PATH_ENV.as_str())];
let pip_extra_index_url;
let pip_index_url;
no_uv_install |= *USE_PIP_INSTALL;
if no_uv_install && !is_ansible {
append_logs(&job_id, w_id, "\nFallback to pip (Deprecated!)\n", db).await;
tracing::warn!("Fallback to pip");
}
if !*DISABLE_NSJAIL {
pip_extra_index_url = PIP_EXTRA_INDEX_URL
.read()
@@ -1008,10 +1032,21 @@ pub async fn handle_python_reqs(
let _ = write_file(
job_dir,
"download.config.proto",
&NSJAIL_CONFIG_DOWNLOAD_PY_CONTENT
.replace("{WORKER_DIR}", &worker_dir)
.replace("{CACHE_DIR}", PIP_CACHE_DIR)
.replace("{CLONE_NEWUSER}", &(!*DISABLE_NUSER).to_string()),
&(if no_uv_install {
NSJAIL_CONFIG_DOWNLOAD_PY_CONTENT_FALLBACK
} else {
NSJAIL_CONFIG_DOWNLOAD_PY_CONTENT
})
.replace("{WORKER_DIR}", &worker_dir)
.replace(
"{CACHE_DIR}",
if no_uv_install {
PIP_CACHE_DIR
} else {
PY311_CACHE_DIR
},
)
.replace("{CLONE_NEWUSER}", &(!*DISABLE_NUSER).to_string()),
)?;
};
@@ -1021,8 +1056,14 @@ pub async fn handle_python_reqs(
if req.starts_with('#') {
continue;
}
let py_prefix = if no_uv_install {
PIP_CACHE_DIR
} else {
PY311_CACHE_DIR
};
let venv_p = format!(
"{PIP_CACHE_DIR}/{}",
"{py_prefix}/{}",
req.replace(' ', "").replace('/', "").replace(':', "")
);
if metadata(&venv_p).await.is_ok() {
@@ -1068,7 +1109,10 @@ pub async fn handle_python_reqs(
.map(|(req, venv_p)| {
let os = os.clone();
async move {
if pull_from_tar(os, venv_p.clone()).await.is_ok() {
if pull_from_tar(os, venv_p.clone(), no_uv_install)
.await
.is_ok()
{
PullFromTar::Pulled(venv_p.to_string())
} else {
PullFromTar::NotPulled(req.to_string(), venv_p.to_string())
@@ -1109,7 +1153,11 @@ pub async fn handle_python_reqs(
for (req, venv_p) in req_with_penv {
let mut logs1 = String::new();
logs1.push_str("\n\n--- PIP INSTALL ---\n");
if no_uv_install {
logs1.push_str("\n\n--- PIP INSTALL ---\n");
} else {
logs1.push_str("\n\n--- UV PIP INSTALL ---\n");
}
logs1.push_str(&format!("\n{req} is being installed for the first time.\n It will be cached for all ulterior uses."));
append_logs(&job_id, w_id, logs1, db).await;
@@ -1145,21 +1193,47 @@ pub async fn handle_python_reqs(
#[cfg(windows)]
let req = format!("{}", req);
let mut command_args = vec![
PYTHON_PATH.as_str(),
"-m",
"pip",
"install",
&req,
"-I",
"--no-deps",
"--no-color",
"--isolated",
"--no-warn-conflicts",
"--disable-pip-version-check",
"-t",
venv_p.as_str(),
];
let mut command_args = if no_uv_install {
vec![
PYTHON_PATH.as_str(),
"-m",
"pip",
"install",
&req,
"-I",
"--no-deps",
"--no-color",
"--isolated",
"--no-warn-conflicts",
"--disable-pip-version-check",
"-t",
venv_p.as_str(),
]
} else {
vec![
UV_PATH.as_str(),
"pip",
"install",
&req,
"--no-deps",
"--no-color",
// "-p",
// "3.11",
// Prevent uv from discovering configuration files.
"--no-config",
"--link-mode=copy",
// TODO: Doublecheck it
"--system",
// Prefer main index over extra
// https://docs.astral.sh/uv/pip/compatibility/#packages-that-exist-on-multiple-indexes
// TODO: Use env variable that can be toggled from UI
"--index-strategy",
"unsafe-best-match",
"--target",
venv_p.as_str(),
"--no-cache",
]
};
let pip_extra_index_url = PIP_EXTRA_INDEX_URL
.read()
.await
@@ -1167,7 +1241,9 @@ pub async fn handle_python_reqs(
.map(handle_ephemeral_token);
if let Some(url) = pip_extra_index_url.as_ref() {
command_args.extend(["--extra-index-url", url]);
url.split(",").for_each(|url| {
command_args.extend(["--extra-index-url", url]);
});
}
let pip_index_url = PIP_INDEX_URL
.read()
@@ -1189,7 +1265,7 @@ pub async fn handle_python_reqs(
envs.push(("HOME", HOME_ENV.as_str()));
tracing::debug!("pip install command: {:?}", command_args);
tracing::debug!("uv pip install command: {:?}", command_args);
#[cfg(unix)]
{
@@ -1200,7 +1276,12 @@ pub async fn handle_python_reqs(
.envs(envs)
.args([
"-x",
&format!("{}/pip-{}.lock", LOCK_CACHE_DIR, fssafe_req),
&format!(
"{}/{}-{}.lock",
LOCK_CACHE_DIR,
if no_uv_install { "pip" } else { "py311" },
fssafe_req
),
"--command",
&command_args.join(" "),
])
@@ -1211,16 +1292,20 @@ pub async fn handle_python_reqs(
#[cfg(windows)]
{
let mut pip_cmd = Command::new(PYTHON_PATH.as_str());
pip_cmd
.env_clear()
let installer_path = if no_uv_install { command_args[0] } else { "uv" };
let mut cmd: Command = Command::new(&installer_path);
cmd.env_clear()
.envs(envs)
.envs(PROXY_ENVS.clone())
.env("SystemRoot", SYSTEM_ROOT.as_str())
.env(
"TMP",
std::env::var("TMP").unwrap_or_else(|_| String::from("/tmp")),
)
.args(&command_args[1..])
.stdout(Stdio::piped())
.stderr(Stdio::piped());
start_child_process(pip_cmd, PYTHON_PATH.as_str()).await?
start_child_process(cmd, installer_path).await?
}
};
@@ -1233,7 +1318,7 @@ pub async fn handle_python_reqs(
false,
worker_name,
&w_id,
&format!("pip install {req}"),
&format!("uv pip install {req}"),
None,
false,
occupancy_metrics,
@@ -1245,6 +1330,16 @@ pub async fn handle_python_reqs(
"finished setting up python dependencies {}",
job_id
);
if child.is_err() {
if let Err(e) = fs::remove_dir_all(&venv_p) {
tracing::warn!(
workspace_id = %w_id,
"failed to remove cache dir: {:?}",
e
);
}
}
child?;
#[cfg(all(feature = "enterprise", feature = "parquet"))]
@@ -1253,7 +1348,7 @@ pub async fn handle_python_reqs(
tracing::warn!("S3 cache not available in the pro plan");
} else {
let venv_p = venv_p.clone();
tokio::spawn(build_tar_and_push(os, venv_p));
tokio::spawn(build_tar_and_push(os, venv_p, no_uv_install));
}
}
req_paths.push(venv_p);

View File

@@ -295,6 +295,7 @@ pub async fn process_result(
message: format!("error during execution of the script:\n{}", err),
name: "ExecutionErr".to_string(),
step_id: job.flow_step_id.clone(),
exit_code: None,
}),
};
@@ -608,6 +609,8 @@ pub struct SerializedError {
pub name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub step_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub exit_code: Option<i32>,
}
pub fn extract_error_value(log_lines: &str, i: i32, step_id: Option<String>) -> Box<RawValue> {
return to_raw_value(&SerializedError {
@@ -617,5 +620,6 @@ pub fn extract_error_value(log_lines: &str, i: i32, step_id: Option<String>) ->
),
name: "ExecutionErr".to_string(),
step_id,
exit_code: Some(i),
});
}

View File

@@ -236,7 +236,14 @@ pub const TMP_LOGS_DIR: &str = concatcp!(TMP_DIR, "/logs");
pub const ROOT_CACHE_NOMOUNT_DIR: &str = concatcp!(TMP_DIR, "/cache_nomount/");
pub const LOCK_CACHE_DIR: &str = concatcp!(ROOT_CACHE_DIR, "lock");
// Used as fallback now
pub const PIP_CACHE_DIR: &str = concatcp!(ROOT_CACHE_DIR, "pip");
// pub const PY310_CACHE_DIR: &str = concatcp!(ROOT_CACHE_DIR, "python_310");
pub const PY311_CACHE_DIR: &str = concatcp!(ROOT_CACHE_DIR, "python_311");
// pub const PY312_CACHE_DIR: &str = concatcp!(ROOT_CACHE_DIR, "python_312");
// pub const PY313_CACHE_DIR: &str = concatcp!(ROOT_CACHE_DIR, "python_313");
pub const UV_CACHE_DIR: &str = concatcp!(ROOT_CACHE_DIR, "uv");
pub const TAR_PIP_CACHE_DIR: &str = concatcp!(ROOT_CACHE_DIR, "tar/pip");
pub const DENO_CACHE_DIR: &str = concatcp!(ROOT_CACHE_DIR, "deno");
@@ -257,6 +264,7 @@ const NUM_SECS_PING: u64 = 5;
const NUM_SECS_READINGS: u64 = 60;
const INCLUDE_DEPS_PY_SH_CONTENT: &str = include_str!("../nsjail/download_deps.py.sh");
const INCLUDE_DEPS_PY_SH_CONTENT_FALLBACK: &str = include_str!("../nsjail/download_deps.py.pip.sh");
pub const DEFAULT_CLOUD_TIMEOUT: u64 = 900;
pub const DEFAULT_SELFHOSTED_TIMEOUT: u64 = 604800; // 7 days
@@ -311,6 +319,7 @@ lazy_static::lazy_static! {
.and_then(|x| x.parse::<bool>().ok())
.unwrap_or(false);
// pub static ref DISABLE_NSJAIL: bool = false;
pub static ref DISABLE_NSJAIL: bool = std::env::var("DISABLE_NSJAIL")
.ok()
.and_then(|x| x.parse::<bool>().ok())
@@ -740,6 +749,13 @@ pub async fn run_worker<R: rsmq_async::RsmqConnection + Send + Sync + Clone + 's
"download_deps.py.sh",
INCLUDE_DEPS_PY_SH_CONTENT,
);
// TODO: Remove (Deprecated)
let _ = write_file(
&worker_dir,
"download_deps.py.pip.sh",
INCLUDE_DEPS_PY_SH_CONTENT_FALLBACK,
);
}
let mut last_ping = Instant::now() - Duration::from_secs(NUM_SECS_PING + 1);
@@ -1903,8 +1919,10 @@ async fn handle_queued_job<R: rsmq_async::RsmqConnection + Send + Sync + Clone>(
}
};
if job.is_flow() {
let flow = job.parse_raw_flow();
handle_flow(
job,
flow,
db,
&client.get_authed().await,
None,

View File

@@ -8,7 +8,6 @@
use std::collections::hash_map::DefaultHasher;
use std::collections::HashMap;
use std::hash::Hash;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
use std::time::Duration;
@@ -49,7 +48,7 @@ use windmill_common::{
Approval, BranchAllStatus, BranchChosen, FlowStatus, FlowStatusModule, RetryStatus,
MAX_RETRY_ATTEMPTS, MAX_RETRY_INTERVAL,
},
flows::{FlowModule, FlowModuleValue, FlowValue, InputTransform, Retry, Suspend},
flows::{has_failure_module, FlowModule, FlowModuleValue, FlowValue, InputTransform, Retry, Suspend},
};
use windmill_queue::schedule::get_schedule_opt;
use windmill_queue::{
@@ -177,11 +176,6 @@ struct RecoveryObject {
recover: Option<bool>,
}
#[derive(sqlx::FromRow, Deserialize)]
pub struct RowFlowStatus {
pub flow_status: sqlx::types::Json<Box<serde_json::value::RawValue>>,
pub current_module: Option<sqlx::types::Json<Box<serde_json::value::RawValue>>>,
}
// #[instrument(level = "trace", skip_all)]
pub async fn update_flow_status_after_job_completion_internal<
R: rsmq_async::RsmqConnection + Send + Sync + Clone,
@@ -207,6 +201,7 @@ pub async fn update_flow_status_after_job_completion_internal<
let (
should_continue_flow,
flow_job,
flow_value,
stop_early,
skip_if_stop_early,
nresult,
@@ -215,35 +210,28 @@ pub async fn update_flow_status_after_job_completion_internal<
) = {
// tracing::debug!("UPDATE FLOW STATUS: {flow:?} {success} {result:?} {w_id} {depth}");
let old_status_json = sqlx::query_as::<_, RowFlowStatus>(
"SELECT flow_status, raw_flow->'modules'->(flow_status->'step')::int as current_module FROM queue WHERE id = $1 AND workspace_id = $2",
let (old_status, current_module) = sqlx::query!(
"SELECT
flow_status AS \"flow_status!: Json<Box<RawValue>>\",
raw_flow->'modules'->(flow_status->'step')::int AS \"module: Json<Box<RawValue>>\"
FROM queue WHERE id = $1 AND workspace_id = $2 LIMIT 1",
flow, w_id
)
.bind(flow)
.bind(w_id)
.fetch_one(db)
.await
.map_err(|e| {
Error::InternalErr(format!(
"fetching flow status {flow} while reporting {success} {result:?}: {e:#}"
))
})?;
let old_status = serde_json::from_str::<FlowStatus>(old_status_json.flow_status.get())
.or_else(|e| {
Err(Error::InternalErr(format!(
"requiring status to be parsable as FlowStatus: {e:?}"
)))
})?;
let current_module = if let Some(x) = old_status_json.current_module {
Some(serde_json::from_str::<FlowModule>(x.0.get()).or_else(|e| {
Err(Error::InternalErr(format!(
.map_err(|e| Error::InternalErr(
format!("fetching flow status {flow} while reporting {success} {result:?}: {e:#}")
))
.and_then(|record| Ok((
serde_json::from_str::<FlowStatus>(record.flow_status.0.get()).map_err(|e| Error::InternalErr(
format!("requiring current module to be parsable as FlowStatus: {e:?}")
))?,
record.module.map(|json| {
serde_json::from_str::<FlowModule>(json.0.get()).map_err(|e| Error::InternalErr(format!(
"requiring current module to be parsable as FlowModule: {e:?}"
)))
})?)
} else {
None
};
}).transpose()?,
)))?;
let module_step = Step::from_i32_and_len(old_status.step, old_status.modules.len());
@@ -303,16 +291,15 @@ pub async fn update_flow_status_after_job_completion_internal<
let is_flow = if let Some(step) = step {
sqlx::query_scalar!(
"SELECT raw_flow->'modules'->($1)->'value'->>'type' = 'flow' FROM queue WHERE id = $2",
step as i32,
&flow
"SELECT raw_flow->'modules'->($1)::text->'value'->>'type' = 'flow' FROM queue WHERE id = $2 LIMIT 1",
step as i32, flow
)
.fetch_one(db)
.await
.map_err(|e| {
Error::InternalErr(format!("error during retrieval of step's type: {e:#}"))
})?
.unwrap_or(false)
.fetch_one(db)
.await
.map_err(|e| {
Error::InternalErr(format!("error during retrieval of step's type: {e:#}"))
})?
.unwrap_or(false)
} else {
false
};
@@ -938,10 +925,7 @@ pub async fn update_flow_status_after_job_completion_internal<
.unwrap_or_else(|| "none".to_string());
tracing::info!(id = %flow_job.id, root_id = %job_root, "update flow status");
let module = get_module(&flow_job, &module_step);
// tracing::error!(
// "UPDATE FLOW STATUS 3: {module:#?} {unrecoverable} {} {is_last_step} {success} {skip_error_handler} is_failure_step {is_failure_step}", flow_job.canceled
// );
let flow_value = flow_job.parse_raw_flow();
let should_continue_flow = match success {
_ if stop_early => false,
@@ -953,7 +937,14 @@ pub async fn update_flow_status_after_job_completion_internal<
}
false
if next_retry(
&module.and_then(|m| m.retry.clone()).unwrap_or_default(),
flow_value
.as_ref()
.and_then(|value| match module_step {
Step::PreprocessorStep => value.preprocessor_module.as_ref().and_then(|m| m.retry.as_ref()),
Step::Step(i) => value.modules.get(i).as_ref().and_then(|m| m.retry.as_ref()),
Step::FailureStep => value.failure_module.as_ref().and_then(|m| m.retry.as_ref()),
})
.unwrap_or(&Retry::default()),
&old_status.retry,
)
.is_some() =>
@@ -963,7 +954,7 @@ pub async fn update_flow_status_after_job_completion_internal<
false
if !is_failure_step
&& !skip_error_handler
&& has_failure_module(flow, db).await? =>
&& has_failure_module(flow, db, false).await? =>
{
true
}
@@ -975,6 +966,7 @@ pub async fn update_flow_status_after_job_completion_internal<
(
should_continue_flow,
flow_job,
flow_value,
stop_early,
skip_if_stop_early,
nresult,
@@ -1042,13 +1034,11 @@ pub async fn update_flow_status_after_job_completion_internal<
let args_hash =
hash_args(db, client, w_id, job_id_for_status, &flow_job.args).await;
let flow_path = flow_job.script_path();
let version_hash = if let Some(rc) = flow_job.raw_flow.as_ref() {
use std::hash::Hasher;
let mut s = DefaultHasher::new();
serde_json::to_string(&rc.0)
.unwrap_or_default()
.hash(&mut s);
format!("flow_{}", hex::encode(s.finish().to_be_bytes()))
let version_hash = if let Some(sqlx::types::Json(s)) = flow_job.raw_flow.as_ref() {
use std::hash::{Hash, Hasher};
let mut h = DefaultHasher::new();
s.get().hash(&mut h);
format!("flow_{}", hex::encode(h.finish().to_be_bytes()))
} else {
"flow_unknown".to_string()
};
@@ -1107,6 +1097,7 @@ pub async fn update_flow_status_after_job_completion_internal<
tracing::debug!(id = %flow_job.id, "start handle flow");
match handle_flow(
flow_job.clone(),
flow_value,
db,
client,
Some(nresult.clone()),
@@ -1239,19 +1230,6 @@ async fn retrieve_flow_jobs_results(
Ok(to_raw_value(&results))
}
fn get_module(flow_job: &QueuedJob, module_step: &Step) -> Option<FlowModule> {
let raw_flow = flow_job.parse_raw_flow();
if let Some(raw_flow) = raw_flow {
match module_step {
Step::PreprocessorStep => raw_flow.preprocessor_module.map(|x| *x.clone()),
Step::Step(i) => raw_flow.modules.get(*i).map(|x| x.clone()),
Step::FailureStep => raw_flow.failure_module.map(|x| *x.clone()),
}
} else {
None
}
}
async fn compute_skip_branchall_failure<'c>(
job: &Uuid,
branch: usize,
@@ -1290,23 +1268,6 @@ async fn compute_skip_branchall_failure<'c>(
}))
}
async fn has_failure_module<'c>(flow: Uuid, db: &DB) -> Result<bool, Error> {
sqlx::query_scalar::<_, Option<bool>>(
"SELECT raw_flow->'failure_module' != 'null'::jsonb
FROM queue
WHERE id = $1",
)
.bind(flow)
.fetch_one(db)
.await
.map_err(|e| {
Error::InternalErr(format!(
"error during retrieval of has_failure_module: {e:#}"
))
})
.map(|v| v.unwrap_or(false))
}
// async fn retrieve_cleanup_module<'c>(flow_uuid: Uuid, db: &DB) -> Result<FlowCleanupModule, Error> {
// tracing::warn!("Retrieving cleanup module of flow {}", flow_uuid);
// let raw_value = sqlx::query_scalar!(
@@ -1523,6 +1484,7 @@ async fn transform_input(
#[instrument(level = "trace", skip_all)]
pub async fn handle_flow<R: rsmq_async::RsmqConnection + Send + Sync + Clone>(
flow_job: Arc<QueuedJob>,
flow_value: Option<FlowValue>,
db: &sqlx::Pool<sqlx::Postgres>,
client: &AuthedClient,
last_result: Option<Arc<Box<RawValue>>>,
@@ -1531,8 +1493,7 @@ pub async fn handle_flow<R: rsmq_async::RsmqConnection + Send + Sync + Clone>(
rsmq: Option<R>,
job_completed_tx: Sender<SendResult>,
) -> anyhow::Result<()> {
let flow = flow_job
.parse_raw_flow()
let flow = flow_value
.with_context(|| "Unable to parse flow definition")?;
let status = flow_job
.parse_flow_status()

View File

@@ -1312,6 +1312,8 @@ async fn python_dep(
job_dir,
worker_dir,
occupancy_metrics,
false,
false,
)
.await;

View File

@@ -8,6 +8,7 @@ import { DenoLandProvider } from "https://deno.land/x/cliffy@v0.25.7/command/upg
import { sleep } from "https://deno.land/x/sleep@v1.2.1/mod.ts";
import * as windmill from "https://deno.land/x/windmill@v1.174.0/mod.ts";
import * as api from "https://deno.land/x/windmill@v1.174.0/windmill-api/index.ts";
import { VERSION, createBenchScript, getFlowPayload, login } from "./lib.ts";
@@ -172,8 +173,8 @@ export async function main({
kind: "script",
path: "f/benchmarks/" + kind,
});
} else if (["2steps"].includes(kind)) {
nStepsFlow = 2;
} else if (["2steps", "bigscriptinflow"].includes(kind)) {
nStepsFlow = kind == "2steps" ? 2 : 1;
const payload = getFlowPayload(kind);
body = JSON.stringify({
kind: "flow",
@@ -194,6 +195,15 @@ export async function main({
kind: "script",
path: kind.substr(7),
});
} else if (kind == "bigrawscript") {
noVerify = true;
body = JSON.stringify({
kind: "rawscript",
rawscript: {
language: api.RawScript.language.BASH,
content: "# let's bloat that bash script, 3.. 2.. 1.. BOOM\n".repeat(25000) + "echo \"$WM_FLOW_JOB_ID\"\n",
},
});
} else {
throw new Error("Unknown script pattern " + kind);
}
@@ -246,7 +256,7 @@ export async function main({
} else {
const elapsed = start ? Date.now() - start : 0;
completedJobs = await getCompletedJobsCount();
if (kind === "2steps" || kind.startsWith("flow:")) {
if (nStepsFlow > 0) {
completedJobs = Math.floor(completedJobs / (nStepsFlow + 1));
}
const avgThr = ((completedJobs / elapsed) * 1000).toFixed(2);

View File

@@ -2,7 +2,7 @@ import { sleep } from "https://deno.land/x/sleep@v1.2.1/mod.ts";
import * as windmill from "https://deno.land/x/windmill@v1.174.0/mod.ts";
import * as api from "https://deno.land/x/windmill@v1.174.0/windmill-api/index.ts";
export const VERSION = "v1.421.0";
export const VERSION = "v1.424.0";
export async function login(email: string, password: string): Promise<string> {
return await windmill.UserService.login({
@@ -235,6 +235,23 @@ export const getFlowPayload = (flowPattern: string): api.FlowPreview => {
],
},
};
} else if (flowPattern == "bigscriptinflow") {
return {
path: "bigscriptinflow",
args: {},
value: {
modules: [
{
value: {
input_transforms: {},
language: api.RawScript.language.BASH,
type: "rawscript",
content: "# let's bloat that bash script, 3.. 2.. 1.. BOOM\n".repeat(25000) + "echo \"$WM_FLOW_JOB_ID\"\n",
},
}
],
}
};
} else {
return {
path: "2steps",

View File

@@ -30,5 +30,13 @@
{
"kind": "nativets",
"jobs": 2000
},
{
"kind": "bigrawscript",
"jobs": 4000
},
{
"kind": "bigscriptinflow",
"jobs": 4000
}
]

View File

@@ -60,7 +60,7 @@ export {
// }
// });
export const VERSION = "1.421.0";
export const VERSION = "1.424.0";
const command = new Command()
.name("wmill")

View File

@@ -26,7 +26,10 @@
]);
in {
devShell = pkgs.mkShell {
buildInputs = buildInputs ++ (with pkgs; [ git go xcaddy sqlx-cli ]);
buildInputs = buildInputs ++ (with pkgs; [
git xcaddy sqlx-cli flock rust-analyzer
deno python3 python3Packages.pip go bun uv
]);
packages = [
(pkgs.writeScriptBin "wm-caddy" ''
cd ./frontend
@@ -56,6 +59,9 @@
sqlx database create
wm-migrate
'')
(pkgs.writeScriptBin "wm-bench" ''
deno run -A benchmarks/main.ts -e admin@windmill.dev -p changeme $*
'')
(pkgs.writeScriptBin "wm" ''
cd ./frontend
npm run dev $*
@@ -68,6 +74,12 @@
DATABASE_URL = "postgres://postgres:changeme@127.0.0.1:5432/";
REMOTE = "http://127.0.0.1:8000";
REMOTE_LSP = "http://127.0.0.1:3001";
DENO_PATH = "${pkgs.deno}/bin/deno";
PYTHON_PATH = "${pkgs.python3}/bin/python3";
GO_PATH = "${pkgs.go}/bin/go";
BUN_PATH = "${pkgs.bun}/bin/bun";
UV_PATH = "${pkgs.uv}/bin/uv";
FLOCK_PATH = "${pkgs.flock}/bin/flock";
};
packages.default = self.packages.${system}.windmill;
packages.windmill-client = pkgs.stdenv.mkDerivation {
@@ -82,7 +94,7 @@
npm config set strict-ssl false
cd frontend
npm install --verbose
npm run generate-backend-client-mac
npm run ${if pkgs.stdenv.isDarwin then "generate-backend-client-mac" else "generate-backend-client"}
NODE_OPTIONS="--max-old-space-size=8192" npm run build
'';

View File

@@ -1,14 +1,15 @@
{
"name": "windmill-components",
"version": "1.421.0",
"version": "1.424.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "windmill-components",
"version": "1.421.0",
"version": "1.424.0",
"license": "AGPL-3.0",
"dependencies": {
"@anthropic-ai/sdk": "^0.32.1",
"@aws-crypto/sha256-js": "^4.0.0",
"@codingame/monaco-vscode-configuration-service-override": "~8.0.2",
"@codingame/monaco-vscode-files-service-override": "~8.0.2",
@@ -21,6 +22,7 @@
"@codingame/monaco-vscode-standalone-typescript-language-features": "~8.0.2",
"@json2csv/plainjs": "^7.0.6",
"@leeoniya/ufuzzy": "^1.0.8",
"@mistralai/mistralai": "^1.3.0",
"@popperjs/core": "^2.11.6",
"@redocly/json-to-json-schema": "^0.0.1",
"@tanstack/svelte-table": "^8.9.9",
@@ -175,6 +177,30 @@
"node": ">=6.0.0"
}
},
"node_modules/@anthropic-ai/sdk": {
"version": "0.32.1",
"resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.32.1.tgz",
"integrity": "sha512-U9JwTrDvdQ9iWuABVsMLj8nJVwAyQz6QXvgLsVhryhCEPkLsbcP/MXxm+jYcAwLoV8ESbaTTjnD4kuAFa+Hyjg==",
"license": "MIT",
"dependencies": {
"@types/node": "^18.11.18",
"@types/node-fetch": "^2.6.4",
"abort-controller": "^3.0.0",
"agentkeepalive": "^4.2.1",
"form-data-encoder": "1.7.2",
"formdata-node": "^4.3.2",
"node-fetch": "^2.6.7"
}
},
"node_modules/@anthropic-ai/sdk/node_modules/@types/node": {
"version": "18.19.64",
"resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.64.tgz",
"integrity": "sha512-955mDqvO2vFf/oL7V3WiUtiz+BugyX8uVbaT2H8oj3+8dRyH2FLiNdowe7eNqRM7IOIZvzDH76EoAT+gwm6aIQ==",
"license": "MIT",
"dependencies": {
"undici-types": "~5.26.4"
}
},
"node_modules/@apidevtools/json-schema-ref-parser": {
"version": "11.6.1",
"resolved": "https://registry.npmjs.org/@apidevtools/json-schema-ref-parser/-/json-schema-ref-parser-11.6.1.tgz",
@@ -3659,6 +3685,14 @@
"resolved": "https://registry.npmjs.org/@mapbox/unitbezier/-/unitbezier-0.0.0.tgz",
"integrity": "sha512-HPnRdYO0WjFjRTSwO3frz1wKaU649OBFPX3Zo/2WZvuRi6zMiRGui8SnPQiQABgqCf8YikDe5t3HViTVw1WUzA=="
},
"node_modules/@mistralai/mistralai": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/@mistralai/mistralai/-/mistralai-1.3.0.tgz",
"integrity": "sha512-G5DPCSC8sEhG3LUEZDYLD7qEZWDuZXgaX3IcoC3a/ydm9jFuh2pRZtknsgMx2sU8d7kxRuxblY3fPH5C38wnhQ==",
"peerDependencies": {
"zod": ">= 3"
}
},
"node_modules/@nodelib/fs.scandir": {
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
@@ -13738,6 +13772,16 @@
"integrity": "sha512-A7AMeJfGefk317I/3tBoUYRcDcNavKEkpiPN/nQsBz/viI2GvT7BtrqdPD6rGqBFN8Ax7v4obf+Cl32JF9DDVw==",
"dev": true
},
"node_modules/zod": {
"version": "3.23.8",
"resolved": "https://registry.npmjs.org/zod/-/zod-3.23.8.tgz",
"integrity": "sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==",
"license": "MIT",
"peer": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
},
"node_modules/zstddec": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/zstddec/-/zstddec-0.1.0.tgz",

Some files were not shown because too many files have changed in this diff Show More