mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-13 08:02:47 +00:00
Merge remote-tracking branch 'origin/main' into sqs-trigger-ci-coverage
# Conflicts: # .github/workflows/backend-test.yml # backend/ee-repo-ref.txt
This commit is contained in:
@@ -319,4 +319,4 @@ jobs:
|
||||
# never reaches it. Pin the target dir (matching the cache step above) so
|
||||
# its own tests run off this compile rather than a second bundled build.
|
||||
(cd windmill-duckdb-ffi-internal && export CARGO_TARGET_DIR="$PWD/target" && ./build_dev.sh && cargo test --release -p windmill_duckdb_ffi_internal)
|
||||
DENO_PATH=$(which deno) BUN_PATH=$(which bun) NODE_BIN_PATH=$(which node) GO_PATH=$(which go) UV_PATH=$(which uv) PHP_PATH=$(which php) COMPOSER_PATH=$(which composer) RUBY_PATH=$(which ruby) RUBY_BUNDLE_PATH=$(which bundle) RUBY_GEM_PATH=$(which gem) POWERSHELL_PATH=$(which pwsh) DOTNET_PATH=$(which dotnet) cargo test --features enterprise,deno_core,duckdb,license,python,rust,scoped_cache,parquet,private,private_registry_test,csharp,php,ruby,mysql,quickjs,mcp,run_inline,sqs_trigger --all -- --nocapture --test-threads=10
|
||||
DENO_PATH=$(which deno) BUN_PATH=$(which bun) NODE_BIN_PATH=$(which node) GO_PATH=$(which go) UV_PATH=$(which uv) PHP_PATH=$(which php) COMPOSER_PATH=$(which composer) RUBY_PATH=$(which ruby) RUBY_BUNDLE_PATH=$(which bundle) RUBY_GEM_PATH=$(which gem) POWERSHELL_PATH=$(which pwsh) DOTNET_PATH=$(which dotnet) cargo test --features enterprise,deno_core,duckdb,license,python,rust,scoped_cache,parquet,private,private_registry_test,csharp,php,ruby,mysql,quickjs,mcp,run_inline,smtp,sqs_trigger --all -- --nocapture --test-threads=10
|
||||
|
||||
@@ -40,11 +40,14 @@ jobs:
|
||||
# an author_association gate: the pull_request webhook payload reports private org
|
||||
# members as CONTRIBUTOR/NONE (only public members show as MEMBER), which silently
|
||||
# skips auto-review for every private member.
|
||||
# Dependabot-triggered runs get a separate secret scope, so this job has no API
|
||||
# key and cannot review. workflow_call stays open for an explicit request.
|
||||
if: |
|
||||
github.event_name == 'workflow_call' ||
|
||||
(
|
||||
github.event.pull_request.draft == false &&
|
||||
github.event.pull_request.head.repo.fork == false
|
||||
github.event.pull_request.head.repo.fork == false &&
|
||||
github.event.pull_request.user.login != 'dependabot[bot]'
|
||||
)
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
@@ -13,7 +13,8 @@ on:
|
||||
|
||||
jobs:
|
||||
notify_discord_when_pr_opened:
|
||||
if: (github.event.pull_request.draft == false) && (github.event.action == 'opened' || github.event.action == 'ready_for_review')
|
||||
# No thread is opened for Dependabot PRs, so nothing downstream may assume one.
|
||||
if: (github.event.pull_request.draft == false) && (github.event.action == 'opened' || github.event.action == 'ready_for_review') && (github.event.pull_request.user.login != 'dependabot[bot]')
|
||||
uses: ./.github/workflows/shareable-discord-notification.yml
|
||||
with:
|
||||
PR_TITLE: ${{ github.event.pull_request.title }}
|
||||
@@ -28,7 +29,8 @@ jobs:
|
||||
DISCORD_BOT_TOKEN: ${{ secrets.DISCORD_AI_BOT_TOKEN }}
|
||||
|
||||
merge_success_emoji:
|
||||
if: github.event.action == 'closed'
|
||||
# Must match open_thread's exclusion: this reacts to a thread that was never created.
|
||||
if: github.event.action == 'closed' && github.event.pull_request.user.login != 'dependabot[bot]'
|
||||
uses: ./.github/workflows/shareable-discord-notification.yml
|
||||
with:
|
||||
PR_STATUS: "merged"
|
||||
|
||||
@@ -40,6 +40,11 @@ jobs:
|
||||
runs-on: ubicloud
|
||||
if: (github.event_name != 'workflow_dispatch') || (github.event.inputs &&
|
||||
!github.event.inputs.ee)
|
||||
# Jobs that build from, extract or retag this image must reference it by
|
||||
# this digest: concurrent main-branch runs push :dev too, so a tag run
|
||||
# reading :dev can ship a main build under the release version.
|
||||
outputs:
|
||||
digest: ${{ steps.docker_build.outputs.digest }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
@@ -112,6 +117,8 @@ jobs:
|
||||
build_ee:
|
||||
runs-on: ubicloud
|
||||
if: (github.event_name != 'workflow_dispatch') || github.event.inputs.ee
|
||||
outputs:
|
||||
digest: ${{ steps.docker_build.outputs.digest }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
@@ -192,31 +199,31 @@ jobs:
|
||||
- uses: actions/checkout@v4
|
||||
- run: |
|
||||
# pulling docker image with desired arch so that actions-docker-extract doesn't do it
|
||||
docker pull --platform "linux/$ARCH" ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ env.DEV_SHA }}
|
||||
docker pull --platform "linux/$ARCH" ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-ee:${{ env.DEV_SHA }}
|
||||
docker pull --platform "linux/$ARCH" ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}@${{ needs.build.outputs.digest }}
|
||||
docker pull --platform "linux/$ARCH" ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-ee@${{ needs.build_ee.outputs.digest }}
|
||||
|
||||
- run: |
|
||||
# Checks the image is in docker prior to running actions-docker-extract. It fails if not
|
||||
# Also useful to visually check that the arch is the right opencontainers
|
||||
docker image inspect ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ env.DEV_SHA }}
|
||||
docker image inspect ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-ee:${{ env.DEV_SHA }}
|
||||
docker image inspect ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}@${{ needs.build.outputs.digest }}
|
||||
docker image inspect ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-ee@${{ needs.build_ee.outputs.digest }}
|
||||
|
||||
- uses: shrink/actions-docker-extract@v3
|
||||
id: extract
|
||||
with:
|
||||
image: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ env.DEV_SHA }}
|
||||
image: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}@${{ needs.build.outputs.digest }}
|
||||
path: "/usr/src/app/windmill"
|
||||
|
||||
- uses: shrink/actions-docker-extract@v3
|
||||
id: extract-duckdb-ffi-internal
|
||||
with:
|
||||
image: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ env.DEV_SHA }}
|
||||
image: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}@${{ needs.build.outputs.digest }}
|
||||
path: "/usr/src/app/libwindmill_duckdb_ffi_internal.so"
|
||||
|
||||
- uses: shrink/actions-docker-extract@v3
|
||||
id: extract-ee
|
||||
with:
|
||||
image: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-ee:${{ env.DEV_SHA }}
|
||||
image: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-ee@${{ needs.build_ee.outputs.digest }}
|
||||
path: "/usr/src/app/windmill"
|
||||
|
||||
- name: Rename binary with corresponding architecture
|
||||
@@ -373,8 +380,8 @@ jobs:
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Tag main and latest
|
||||
run: |
|
||||
docker buildx imagetools create ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ env.DEV_SHA }} --tag ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
|
||||
docker buildx imagetools create ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ env.DEV_SHA }} --tag ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:main
|
||||
docker buildx imagetools create ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}@${{ needs.build.outputs.digest }} --tag ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
|
||||
docker buildx imagetools create ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}@${{ needs.build.outputs.digest }} --tag ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:main
|
||||
|
||||
- uses: sigstore/cosign-installer@v4.1.2
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
@@ -409,8 +416,8 @@ jobs:
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Tag main and latest for ee
|
||||
run: |
|
||||
docker buildx imagetools create ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-ee:${{ env.DEV_SHA }} --tag ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-ee:latest
|
||||
docker buildx imagetools create ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-ee:${{ env.DEV_SHA }} --tag ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-ee:main
|
||||
docker buildx imagetools create ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-ee@${{ needs.build_ee.outputs.digest }} --tag ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-ee:latest
|
||||
docker buildx imagetools create ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-ee@${{ needs.build_ee.outputs.digest }} --tag ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-ee:main
|
||||
|
||||
- uses: sigstore/cosign-installer@v4.1.2
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
@@ -447,12 +454,18 @@ jobs:
|
||||
dockerhub-user: windmilllabs
|
||||
dockerhub-password: ${{ secrets.DOCKER_PAT }}
|
||||
|
||||
# Filed under main, not under the release tag this job runs on. The scanned image is
|
||||
# `-ee:main` and the Security tab only reads the default branch, so tag-only uploads
|
||||
# left main's last Docker Scout result at 2025-01-29: the tool showed as failing and
|
||||
# its alerts could never close. `sha` is the release commit, which is on main.
|
||||
- name: Upload SARIF result
|
||||
id: upload-sarif
|
||||
if: ${{ github.event_name != 'pull_request_target' }}
|
||||
uses: github/codeql-action/upload-sarif@v2
|
||||
uses: github/codeql-action/upload-sarif@v4
|
||||
with:
|
||||
sarif_file: sarif.output.json
|
||||
ref: refs/heads/main
|
||||
sha: ${{ github.sha }}
|
||||
|
||||
# docker_scout_ee:
|
||||
# runs-on: ubicloud
|
||||
@@ -549,6 +562,8 @@ jobs:
|
||||
push: true
|
||||
sbom: ${{ startsWith(github.ref, 'refs/tags/v') && github.event_name == 'push' }}
|
||||
file: "./docker/DockerfileCuda"
|
||||
build-args: |
|
||||
BASE_IMAGE=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-ee@${{ needs.build_ee.outputs.digest }}
|
||||
tags: |
|
||||
${{ steps.meta-ee-public.outputs.tags }}
|
||||
labels: |
|
||||
@@ -602,6 +617,8 @@ jobs:
|
||||
push: true
|
||||
sbom: ${{ startsWith(github.ref, 'refs/tags/v') && github.event_name == 'push' }}
|
||||
file: "./docker/DockerfileSlim"
|
||||
build-args: |
|
||||
BASE_IMAGE=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}@${{ needs.build.outputs.digest }}
|
||||
tags: |
|
||||
${{ steps.meta-ee-public.outputs.tags }}
|
||||
labels: |
|
||||
@@ -656,6 +673,8 @@ jobs:
|
||||
push: true
|
||||
sbom: ${{ startsWith(github.ref, 'refs/tags/v') && github.event_name == 'push' }}
|
||||
file: "./docker/DockerfileSlimEe"
|
||||
build-args: |
|
||||
BASE_IMAGE=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-ee@${{ needs.build_ee.outputs.digest }}
|
||||
tags: |
|
||||
${{ steps.meta-ee-public.outputs.tags }}
|
||||
labels: |
|
||||
@@ -709,6 +728,8 @@ jobs:
|
||||
push: true
|
||||
sbom: ${{ startsWith(github.ref, 'refs/tags/v') && github.event_name == 'push' }}
|
||||
file: "./docker/DockerfileFull"
|
||||
build-args: |
|
||||
BASE_IMAGE=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}@${{ needs.build.outputs.digest }}
|
||||
tags: |
|
||||
${{ steps.meta-public.outputs.tags }}
|
||||
labels: |
|
||||
@@ -761,6 +782,8 @@ jobs:
|
||||
push: true
|
||||
sbom: ${{ startsWith(github.ref, 'refs/tags/v') && github.event_name == 'push' }}
|
||||
file: "./docker/DockerfileFullEe"
|
||||
build-args: |
|
||||
BASE_IMAGE=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-ee@${{ needs.build_ee.outputs.digest }}
|
||||
tags: |
|
||||
${{ steps.meta-ee-public.outputs.tags }}
|
||||
labels: |
|
||||
|
||||
@@ -38,11 +38,14 @@ jobs:
|
||||
# an author_association gate: the pull_request webhook payload reports private org
|
||||
# members as CONTRIBUTOR/NONE (only public members show as MEMBER), which silently
|
||||
# skips auto-review for every private member.
|
||||
# Dependabot-triggered runs get a separate secret scope, so this job has no API
|
||||
# key and cannot review. workflow_call stays open for an explicit request.
|
||||
if: |
|
||||
github.event_name == 'workflow_call' ||
|
||||
(
|
||||
github.event.pull_request.draft == false &&
|
||||
github.event.pull_request.head.repo.fork == false
|
||||
github.event.pull_request.head.repo.fork == false &&
|
||||
github.event.pull_request.user.login != 'dependabot[bot]'
|
||||
)
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
@@ -37,11 +37,14 @@ jobs:
|
||||
# an author_association gate: the pull_request webhook payload reports private org
|
||||
# members as CONTRIBUTOR/NONE (only public members show as MEMBER), which silently
|
||||
# skips auto-review for every private member.
|
||||
# Dependabot-triggered runs get a separate secret scope, so this job has no API
|
||||
# key and cannot review. workflow_call stays open for an explicit request.
|
||||
if: |
|
||||
github.event_name == 'workflow_call' ||
|
||||
(
|
||||
(github.event.pull_request.draft == false || github.event.pull_request.ready_for_review == true) &&
|
||||
github.event.pull_request.head.repo.fork == false
|
||||
github.event.pull_request.head.repo.fork == false &&
|
||||
github.event.pull_request.user.login != 'dependabot[bot]'
|
||||
)
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
{
|
||||
".": "1.815.0"
|
||||
".": "1.817.0"
|
||||
}
|
||||
|
||||
@@ -1,5 +1,59 @@
|
||||
# Changelog
|
||||
|
||||
## [1.817.0](https://github.com/windmill-labs/windmill/compare/v1.816.0...v1.817.0) (2026-09-22)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add workspace mentions to AI composer ([#11264](https://github.com/windmill-labs/windmill/issues/11264)) ([e7ba595](https://github.com/windmill-labs/windmill/commit/e7ba5954b69514d72c140f9b1e697fb2236df23e))
|
||||
* allow workspace-scoped dynamic custom tags ([#11283](https://github.com/windmill-labs/windmill/issues/11283)) ([efa7a0a](https://github.com/windmill-labs/windmill/commit/efa7a0a70af43f8827542059dd450326a9b88d62))
|
||||
* check dynamic worker tags on the tag they resolve to ([#11271](https://github.com/windmill-labs/windmill/issues/11271)) ([69fafb9](https://github.com/windmill-labs/windmill/commit/69fafb9262f90184e6c2dc9a84f1c3feeb9d9638))
|
||||
* deploy from the UI to a workspace on another instance ([#11245](https://github.com/windmill-labs/windmill/issues/11245)) ([4d12ea4](https://github.com/windmill-labs/windmill/commit/4d12ea461454f89367bd91daa7e8c39a89035507))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* add missing credential fields to settings redaction lists ([#11272](https://github.com/windmill-labs/windmill/issues/11272)) ([2a40f25](https://github.com/windmill-labs/windmill/commit/2a40f25bda81d41b9f578338c85656744f59ebfc))
|
||||
* apply SSRF validation to workspace webhook URLs ([#11285](https://github.com/windmill-labs/windmill/issues/11285)) ([586699c](https://github.com/windmill-labs/windmill/commit/586699c4938b5203920a43e69a706310078b93f5))
|
||||
* apply token path scopes to the native trigger list ([#11281](https://github.com/windmill-labs/windmill/issues/11281)) ([faf7b22](https://github.com/windmill-labs/windmill/commit/faf7b22be0827738c90c1f3ed6cd3e214d6c63d3))
|
||||
* check workflow-as-code task tags against CUSTOM_TAGS ([#11273](https://github.com/windmill-labs/windmill/issues/11273)) ([2699d50](https://github.com/windmill-labs/windmill/commit/2699d5065ea3dfe985636733fb6022ca23187291))
|
||||
* clear the impersonation token from session storage on logout ([#11274](https://github.com/windmill-labs/windmill/issues/11274)) ([9936673](https://github.com/windmill-labs/windmill/commit/9936673ee62fb6dd929a654a6138aeca687c0fb7))
|
||||
* enforce token path scopes on GET /raw_apps/list ([#11284](https://github.com/windmill-labs/windmill/issues/11284)) ([9ad2c91](https://github.com/windmill-labs/windmill/commit/9ad2c91ddb661ab60ada9ec4039ef8d025602969))
|
||||
* keep a cancel that lands after a worker last read its queue row ([#11246](https://github.com/windmill-labs/windmill/issues/11246)) ([02006f3](https://github.com/windmill-labs/windmill/commit/02006f364a4217e967435d9e71d8617c03b65413))
|
||||
* size the AI chat output budget by model, not provider ([#11286](https://github.com/windmill-labs/windmill/issues/11286)) ([7d0f2ce](https://github.com/windmill-labs/windmill/commit/7d0f2ce3bf920a68f1ea22d59481d239947f1b51))
|
||||
* stop cutting DeepSeek chat turns off mid-thought ([#11282](https://github.com/windmill-labs/windmill/issues/11282)) ([5821700](https://github.com/windmill-labs/windmill/commit/58217006ece56bbaf0447af67cac487e72b928c7))
|
||||
* stop the raw app editor stealing focus in ai sessions ([#11287](https://github.com/windmill-labs/windmill/issues/11287)) ([7322143](https://github.com/windmill-labs/windmill/commit/732214361f36eb07d65854118bd053c96ce681fb))
|
||||
|
||||
## [1.816.0](https://github.com/windmill-labs/windmill/compare/v1.815.0...v1.816.0) (2026-09-21)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add an ACL editor for data table roles ([#11063](https://github.com/windmill-labs/windmill/issues/11063)) ([9994e03](https://github.com/windmill-labs/windmill/commit/9994e03bc75404503001a5db37e175b66e7371f2))
|
||||
* add per-model context window override in workspace AI settings ([#11232](https://github.com/windmill-labs/windmill/issues/11232)) ([c637b06](https://github.com/windmill-labs/windmill/commit/c637b06a548a5725aceb7504bcb62383d35614ac))
|
||||
* **ai-chat:** disclose an app's exposure on deploy, refuse drag-and-drop apps ([#11137](https://github.com/windmill-labs/windmill/issues/11137)) ([85b36a2](https://github.com/windmill-labs/windmill/commit/85b36a2de5f3fa08750b6d2ab8203f4b745cfdd2))
|
||||
* data table roles in the DB manager and raw apps ([#11139](https://github.com/windmill-labs/windmill/issues/11139)) ([e1e3692](https://github.com/windmill-labs/windmill/commit/e1e3692fbc82d50c021ef8bf8ca7019d760705f0))
|
||||
* drafts follow their item through a move; behind means base ≠ head ([#10577](https://github.com/windmill-labs/windmill/issues/10577)) ([f00b2fc](https://github.com/windmill-labs/windmill/commit/f00b2fcb1e65cb8f8e95d55f7cc58b816ac7d89d))
|
||||
* edit variables, resources and triggers in their own session tab ([#11206](https://github.com/windmill-labs/windmill/issues/11206)) ([6186a06](https://github.com/windmill-labs/windmill/commit/6186a0645d3f3e92e06b666a6b5774b8af2b488f))
|
||||
* let every EE instance set the announcement banner ([#11238](https://github.com/windmill-labs/windmill/issues/11238)) ([87761d1](https://github.com/windmill-labs/windmill/commit/87761d179799026ebaf9ed91bf589852b5453ee3))
|
||||
* snap-collapse the sidebar by dragging it to the left edge ([#11225](https://github.com/windmill-labs/windmill/issues/11225)) ([56a6ad3](https://github.com/windmill-labs/windmill/commit/56a6ad322e8354695a0a96eef9e83ffa135b3ad4))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* bump git sync hub scripts to cli 1.815.1 ([#11239](https://github.com/windmill-labs/windmill/issues/11239)) ([9e0e835](https://github.com/windmill-labs/windmill/commit/9e0e835c6876305d3a44902fb1432fd9c2348a7c))
|
||||
* check custom_instance_user can connect during instance database setup ([#11244](https://github.com/windmill-labs/windmill/issues/11244)) ([1b0f119](https://github.com/windmill-labs/windmill/commit/1b0f119c7b8ac2aacf2e2b4428c57f661df87ae5))
|
||||
* **cli:** read wmill.yaml from the branch a git-sync deploy writes to ([#11236](https://github.com/windmill-labs/windmill/issues/11236)) ([974b026](https://github.com/windmill-labs/windmill/commit/974b0265f85f9cfac65bbd2f6ebbcb0e966e3301))
|
||||
* guard data table migration routes against operators and unauthorized authors ([#11243](https://github.com/windmill-labs/windmill/issues/11243)) ([b742544](https://github.com/windmill-labs/windmill/commit/b7425443e1e08be029fc676047f727f84b646323))
|
||||
* tell the user when a draft conflict has stopped their edits saving ([#11241](https://github.com/windmill-labs/windmill/issues/11241)) ([d8ebbc0](https://github.com/windmill-labs/windmill/commit/d8ebbc06f5c56dad2570115700d640105d856c57))
|
||||
|
||||
|
||||
### Performance Improvements
|
||||
|
||||
* index audit logs by workspace and operation ([#11235](https://github.com/windmill-labs/windmill/issues/11235)) ([3b8c326](https://github.com/windmill-labs/windmill/commit/3b8c3264ecb33884420b1bdac9cae9c841e721a0))
|
||||
* keep the sessions page fast with many ai sessions ([#11224](https://github.com/windmill-labs/windmill/issues/11224)) ([4cbc944](https://github.com/windmill-labs/windmill/commit/4cbc944c0bcb938300c964437d745d15a3ea608f))
|
||||
* retire the legacy pre-partitioning audit table ([#11240](https://github.com/windmill-labs/windmill/issues/11240)) ([9f2685d](https://github.com/windmill-labs/windmill/commit/9f2685d788e624c9ed9db5cbaee456ea21af2b29))
|
||||
|
||||
## [1.815.0](https://github.com/windmill-labs/windmill/compare/v1.814.0...v1.815.0) (2026-09-18)
|
||||
|
||||
|
||||
|
||||
@@ -98,9 +98,11 @@ export async function runEval<THelpers, TOutput>(
|
||||
// Wrap tools to intercept fn calls for tracking.
|
||||
// Cast to ProductionTool since the eval Tool has a narrower toolCallbacks type
|
||||
// but the actual callbacks passed at runtime will satisfy both interfaces.
|
||||
// `fn` forwards `this`: the chat loop calls it on a per-iteration copy carrying the def the
|
||||
// model was sent, and a tool reading `this.def` must see that one, as it does in production.
|
||||
const wrappedTools = tools.map((tool) => ({
|
||||
...tool,
|
||||
fn: async (p: any) => {
|
||||
fn: async function (this: unknown, p: any) {
|
||||
toolCallsCount++;
|
||||
toolsCalled.push(tool.def.function.name);
|
||||
let argumentsText = "";
|
||||
@@ -120,7 +122,7 @@ export async function runEval<THelpers, TOutput>(
|
||||
toolName: tool.def.function.name,
|
||||
argumentsText,
|
||||
});
|
||||
return tool.fn(p);
|
||||
return tool.fn.call(this, p);
|
||||
},
|
||||
}));
|
||||
|
||||
|
||||
+21
-11
@@ -1112,9 +1112,9 @@
|
||||
- opens the Kafka triggers page
|
||||
- does not write, deploy, or delete anything
|
||||
|
||||
- id: global-openpage7-compare-review
|
||||
- id: global-draft-does-not-open-compare
|
||||
prompt: |-
|
||||
Create a TypeScript script draft at f/evals/global/compare_review_demo that returns the string "ok" (no need to test it), then open the review page so I can look over the pending change and deploy it myself.
|
||||
Create a TypeScript script draft at f/evals/global/compare_review_demo that returns the string "ok" (no need to test it). I want to look over the change before it gets deployed.
|
||||
runtime:
|
||||
maxTurns: 8
|
||||
validate:
|
||||
@@ -1122,6 +1122,24 @@
|
||||
toolExpect:
|
||||
requiredToolsUsed:
|
||||
- write_script
|
||||
forbiddenToolsUsed:
|
||||
- open_page
|
||||
- deploy_workspace_item
|
||||
- delete_workspace_item
|
||||
skipJudge: true
|
||||
judgeChecklist:
|
||||
- creates the script draft without opening any workspace page
|
||||
- does not deploy or delete anything
|
||||
|
||||
- id: global-openpage7-compare-fork-deploy
|
||||
prompt: |-
|
||||
I'm done with my changes in this fork and want to ship them to the parent workspace myself. Take me there.
|
||||
runtime:
|
||||
maxTurns: 6
|
||||
validate:
|
||||
draftCountExactly: 0
|
||||
toolExpect:
|
||||
requiredToolsUsed:
|
||||
- open_page
|
||||
forbiddenToolsUsed:
|
||||
- deploy_workspace_item
|
||||
@@ -1131,17 +1149,9 @@
|
||||
field: page
|
||||
stringIncludesAnyOf:
|
||||
- compare
|
||||
# The eval chat is untracked (no modified-items mask), so the model must scope
|
||||
# the review by passing the item it changed explicitly — an omitted mask would
|
||||
# preselect every pending change in the workspace.
|
||||
- tool: open_page
|
||||
field: items
|
||||
stringIncludesAnyOf:
|
||||
- f/evals/global/compare_review_demo
|
||||
skipJudge: true
|
||||
judgeChecklist:
|
||||
- creates the script draft, then opens the Compare & Deploy review page instead of deploying itself
|
||||
- preselects only the created script on the review page
|
||||
- opens Compare & Deploy only because the user explicitly wants to deploy the fork into its parent
|
||||
- does not deploy or delete anything
|
||||
|
||||
- id: global-openpage8-runs-label-and-worker
|
||||
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "INSERT INTO remote_deploy_token\n (workspace_id, email, base_url, remote_workspace_id, token, remote_email, proxy_key,\n connected_at, member_since, target_changed_at)\n SELECT $1, $2, $3, $4, $5, $6, $7, $8, $9, $10\n WHERE $9::timestamptz IS NOT NULL\n OR EXISTS (SELECT 1 FROM token WHERE token_hash = $11)\n ON CONFLICT (workspace_id, email) DO UPDATE SET\n base_url = EXCLUDED.base_url, remote_workspace_id = EXCLUDED.remote_workspace_id,\n token = EXCLUDED.token, remote_email = EXCLUDED.remote_email,\n proxy_key = EXCLUDED.proxy_key, connected_at = EXCLUDED.connected_at,\n member_since = EXCLUDED.member_since, target_changed_at = EXCLUDED.target_changed_at\n WHERE remote_deploy_token.connected_at < EXCLUDED.connected_at\n RETURNING connected_at",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "connected_at",
|
||||
"type_info": "Timestamptz"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Varchar",
|
||||
"Varchar",
|
||||
"Varchar",
|
||||
"Varchar",
|
||||
"Text",
|
||||
"Varchar",
|
||||
"Varchar",
|
||||
"Timestamptz",
|
||||
"Timestamptz",
|
||||
"Timestamptz",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "132bab72036874c33805ff194fce6641817d09528f9d1b7210abe9881b817710"
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT remote_deploy_target FROM workspace_settings WHERE workspace_id = $1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "remote_deploy_target",
|
||||
"type_info": "Jsonb"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "14cef81d324504d9cc90a9ffeb9f73daf9e09cac11685988bc861cc348a0c518"
|
||||
}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "UPDATE v2_job_queue q SET scheduled_for = now()\n FROM (SELECT id, scheduled_for FROM v2_job_queue WHERE id = $1 AND workspace_id = $2 FOR UPDATE) prev\n WHERE q.id = prev.id AND NOT q.running AND q.suspend = 0 AND q.canceled_by IS NULL\n AND q.scheduled_for > now()\n RETURNING prev.scheduled_for",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "scheduled_for",
|
||||
"type_info": "Timestamptz"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Uuid",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "1740f8bd6f9e084a470a775151e751e1317e7398dfaf1dfde5c57169cc146c9d"
|
||||
}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "UPDATE remote_deploy_token SET token = $1\n WHERE workspace_id = $2 AND email = $3",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "207db6c65949b85fda6d6289921bb04695b1c50e17755dad3bd162254f67458f"
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT COUNT(DISTINCT email) FROM usr WHERE is_service_account AND NOT disabled",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "count",
|
||||
"type_info": "Int8"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": []
|
||||
},
|
||||
"nullable": [
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "3190736818379ad22628aae3c5a25ab0199084b35664cbb0a586917bfa0da075"
|
||||
}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT EXISTS(SELECT 1 FROM v2_job_queue WHERE id = $1 AND workspace_id = $2)",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "exists",
|
||||
"type_info": "Bool"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Uuid",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "372db51a00e5cf37f00af4b7962b30d71c74fffbba3f5ac6197c985fd73e5c11"
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "DELETE FROM token WHERE token_prefix = 'GONE_TOKEN'",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": []
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "3a0ac41e645225bbe54b83f246500ca014154e76eb322646e7e4e753b6420acd"
|
||||
}
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT email, token FROM remote_deploy_token\n WHERE workspace_id = $1 AND token <> '' FOR UPDATE",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "email",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "token",
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "3b6f1e9765de11ef2da0ec5388c72e4a01f50d48b76b90746eef72919c408e44"
|
||||
}
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n UPDATE schedule SET\n schedule = $1,\n timezone = $2,\n args = $3,\n on_failure = $4,\n on_failure_times = $5,\n on_failure_exact = $6,\n on_failure_extra_args = $7,\n on_recovery = $8,\n on_recovery_times = $9,\n on_recovery_extra_args = $10,\n on_success = $11,\n on_success_extra_args = $12,\n ws_error_handler_muted = $13,\n retry = $14,\n summary = $15,\n no_flow_overlap = $16,\n tag = $17,\n paused_until = $18,\n path = $19,\n workspace_id = $20,\n cron_version = COALESCE($21, cron_version),\n description = $22,\n dynamic_skip = $23,\n email = $24,\n edited_by = $25,\n permissioned_as = $26,\n labels = COALESCE($27, labels)\n WHERE path = $19 AND workspace_id = $20\n RETURNING\n workspace_id,\n path,\n edited_by,\n edited_at,\n schedule,\n timezone,\n enabled,\n script_path,\n is_flow,\n args AS \"args: _\",\n extra_perms,\n email,\n permissioned_as,\n error,\n on_failure,\n on_failure_times,\n on_failure_exact,\n on_failure_extra_args AS \"on_failure_extra_args: _\",\n on_recovery,\n on_recovery_times,\n on_recovery_extra_args AS \"on_recovery_extra_args: _\",\n on_success,\n on_success_extra_args AS \"on_success_extra_args: _\",\n ws_error_handler_muted,\n retry,\n no_flow_overlap,\n summary,\n description,\n tag,\n paused_until,\n cron_version,\n dynamic_skip,\n labels\n ",
|
||||
"query": "\n UPDATE schedule SET\n schedule = $1,\n timezone = $2,\n args = $3,\n on_failure = $4,\n on_failure_times = $5,\n on_failure_exact = $6,\n on_failure_extra_args = $7,\n on_recovery = $8,\n on_recovery_times = $9,\n on_recovery_extra_args = $10,\n on_success = $11,\n on_success_extra_args = $12,\n ws_error_handler_muted = $13,\n retry = $14,\n summary = $15,\n no_flow_overlap = $16,\n tag = $17,\n paused_until = $18,\n path = $19,\n workspace_id = $20,\n cron_version = COALESCE($21, cron_version),\n description = $22,\n dynamic_skip = $23,\n email = $24,\n edited_by = $25,\n edited_at = now(),\n permissioned_as = $26,\n labels = COALESCE($27, labels)\n WHERE path = $19 AND workspace_id = $20\n RETURNING\n workspace_id,\n path,\n edited_by,\n edited_at,\n schedule,\n timezone,\n enabled,\n script_path,\n is_flow,\n args AS \"args: _\",\n extra_perms,\n email,\n permissioned_as,\n error,\n on_failure,\n on_failure_times,\n on_failure_exact,\n on_failure_extra_args AS \"on_failure_extra_args: _\",\n on_recovery,\n on_recovery_times,\n on_recovery_extra_args AS \"on_recovery_extra_args: _\",\n on_success,\n on_success_extra_args AS \"on_success_extra_args: _\",\n ws_error_handler_muted,\n retry,\n no_flow_overlap,\n summary,\n description,\n tag,\n paused_until,\n cron_version,\n dynamic_skip,\n labels\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -236,5 +236,5 @@
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "bd2609f999054c5a2a85f2aafe4005bc22731c27f0bf27b3b06ec426a05eda2e"
|
||||
"hash": "4d2c91937d87d385290b918af0238825cdd7784d0d611cd0069b15d11039ac56"
|
||||
}
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT clock_timestamp() AS \"started_at!\", s.remote_deploy_target,\n s.remote_deploy_target_changed_at,\n (SELECT u.created_at FROM usr u\n WHERE u.workspace_id = s.workspace_id AND u.email = $2) AS member_since\n FROM workspace_settings s WHERE s.workspace_id = $1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "started_at!",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "remote_deploy_target",
|
||||
"type_info": "Jsonb"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "remote_deploy_target_changed_at",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "member_since",
|
||||
"type_info": "Timestamptz"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
null,
|
||||
true,
|
||||
true,
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "51e1741eb9e959945302fd6f7e8c621b9b03b92f6c20ab1bdd1637232f26952d"
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "INSERT INTO token (token_hash, token_prefix, token, email, label, super_admin, read_only)\n VALUES (encode(sha256('READ_ONLY_TOKEN'::bytea), 'hex'), 'READ_ONLY_', 'READ_ONLY_TOKEN',\n 'test@windmill.dev', 'read only', false, true)",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": []
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "530fe81babd82e7b4a9142962c62750560e459acc7c83c48d47d7bdad29051be"
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "UPDATE workspace_settings SET remote_deploy_target_changed_at = '2000-01-01'\n WHERE workspace_id = 'test-workspace'",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": []
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "5517b714165f959a1df1a0ac9cbe848344974bc2271459ba4ae5952274c11429"
|
||||
}
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT t.token, t.remote_email, t.proxy_key, t.connected_at FROM remote_deploy_token t\n JOIN workspace_settings s ON s.workspace_id = t.workspace_id\n WHERE t.workspace_id = $1 AND t.email = $2 AND t.base_url = $3\n AND t.remote_workspace_id = $4 AND t.token <> ''\n AND s.remote_deploy_target_changed_at IS NOT DISTINCT FROM t.target_changed_at\n AND (EXISTS (SELECT 1 FROM usr u WHERE u.workspace_id = t.workspace_id\n AND u.email = t.email AND u.created_at = t.member_since)\n OR EXISTS (SELECT 1 FROM password p WHERE p.email = t.email AND p.super_admin))",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "token",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "remote_email",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "proxy_key",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "connected_at",
|
||||
"type_info": "Timestamptz"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text",
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "5b292c49ce6b64f4de61f74e18831b3f107fc6d0312ac3da9247a922bcb05b67"
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "INSERT INTO remote_deploy_token\n (workspace_id, email, base_url, remote_workspace_id, token, remote_email, proxy_key,\n connected_at)\n VALUES ($1, $2, '', '', '', '', '', clock_timestamp())\n ON CONFLICT (workspace_id, email) DO UPDATE SET\n token = '', proxy_key = '', connected_at = clock_timestamp()",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Varchar",
|
||||
"Varchar"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "6b58f2df413a867a44311fd779a2017c41b9a0e3985a25372e5f64723124e473"
|
||||
}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "DELETE FROM remote_deploy_token WHERE workspace_id = $1\n AND ($2::text IS NULL OR base_url <> $2 OR remote_workspace_id <> $3)",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "81d5cceb2e405e1d17730dfa93a32f1bca7b235af9db581147d838454a4c9083"
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "UPDATE remote_deploy_token SET token = $1 WHERE workspace_id = 'test-workspace'",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "8ce4461e7dd087782a239972c173786891eb99e9ba86b11cfb17d28856babd2e"
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "UPDATE remote_deploy_token SET workspace_id = $1 WHERE workspace_id = $2",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Varchar",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "960d6eca64de008f821b1e96345530f057d91c0e0986fdc0de7d9a3ef0c143e2"
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "UPDATE remote_deploy_token SET connected_at = clock_timestamp() + interval '1 hour'\n WHERE workspace_id = 'test-workspace' AND email = 'test@windmill.dev'",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": []
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "965f9fb9d73f21695cf9c4018107bf8dec0a9b1c38a6776f8b3db7f2f8a883b7"
|
||||
}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "UPDATE v2_job_completed SET status = 'canceled'::job_status, canceled_by = $2, canceled_reason = $3 WHERE id = $1",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Uuid",
|
||||
"Varchar",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "9a2345e35c9f44578aa7dde994fdde0be9e0d192e0aee224bff78d8bd2ccbd8b"
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "UPDATE workspace_settings SET remote_deploy_target = $1::jsonb,\n remote_deploy_target_changed_at = CASE\n WHEN remote_deploy_target IS DISTINCT FROM $1::jsonb THEN clock_timestamp()\n ELSE remote_deploy_target_changed_at END\n WHERE workspace_id = $2",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Jsonb",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "9b61979cc78e8b7b63ffa6a59cf3d29ae8b924200c46da457a9346379c60fa9f"
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "DELETE FROM usr WHERE workspace_id = 'test-workspace' AND email = 'test@windmill.dev'",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": []
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "a866da25370c8b829fd8038c55c0f43e27a6eea453415495b66c50045defacff"
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "UPDATE remote_deploy_token SET token = 'not-under-this-key'\n WHERE workspace_id = 'test-workspace'",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": []
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "a9ae6c222727b7078fd25ed91f51c363a4e5719b5ceb17e8194c2eb038f59c68"
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "INSERT INTO token (token_hash, token_prefix, token, email, label, super_admin)\n VALUES (encode(sha256('GONE_TOKEN'::bytea), 'hex'), 'GONE_TOKEN', 'GONE_TOKEN',\n 'test@windmill.dev', 'gone', true)",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": []
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "af1bc312f13d7590a138e2de4996dab8e5ac9c774cf81df9da85ac3c40e8b3c7"
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "DELETE FROM remote_deploy_token WHERE email = $1 AND workspace_id = $2",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "b2014ff45992b34c08f27419c26e975ff2b1baee63651e0ce79156b331389923"
|
||||
}
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "UPDATE token SET label = $1\n WHERE email = $2 AND token_prefix = $3\n AND (label IS NULL OR (\n label <> 'session'\n AND label <> 'guest_session'\n AND lower(label) NOT LIKE 'ephemeral%'\n AND label <> 'debugger-token'\n AND label NOT LIKE 'mcp-oauth-%'\n AND NOT starts_with(label, 'embed_app:')\n AND NOT starts_with(label, 'sdk_app:')\n AND NOT starts_with(label, 'impersonation:')\n AND NOT starts_with(label, 'cli-login:')\n ))\n RETURNING token_prefix",
|
||||
"query": "UPDATE token SET label = $1\n WHERE email = $2 AND token_prefix = $3\n AND (label IS NULL OR (\n label <> 'session'\n AND label <> 'guest_session'\n AND lower(label) NOT LIKE 'ephemeral%'\n AND label <> 'debugger-token'\n AND label NOT LIKE 'mcp-oauth-%'\n AND NOT starts_with(label, 'embed_app:')\n AND NOT starts_with(label, 'sdk_app:')\n AND NOT starts_with(label, 'impersonation:')\n AND NOT starts_with(label, 'cli-login:')\n AND NOT starts_with(label, 'remote-deploy:')\n ))\n RETURNING token_prefix",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -20,5 +20,5 @@
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "383c80239525d9c4ee90e2f5cb6e46c8a0983c52ad15db083f84a9e344dc4c99"
|
||||
"hash": "b30fb28004897cd1d201a416cdabf124a055eb44b3c5892fd18ff7baf6010ae9"
|
||||
}
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT q.scheduled_for, s.path, s.schedule, s.cron_version, s.timezone\n FROM v2_job j\n JOIN v2_job_queue q USING (id)\n JOIN schedule s ON s.workspace_id = j.workspace_id AND s.path = j.trigger\n WHERE j.id = $1 AND j.workspace_id = $2 AND j.trigger_kind = 'schedule'\n AND j.parent_job IS NULL AND q.scheduled_for > now()",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "scheduled_for",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "path",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "schedule",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "cron_version",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "timezone",
|
||||
"type_info": "Varchar"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Uuid",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "c703da3c3e133536dda9721644561af624ab6de67a5c82967f4df8117fe50bc7"
|
||||
}
|
||||
-22
@@ -1,22 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "DELETE FROM v2_job_queue WHERE id = $1 RETURNING 1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "?column?",
|
||||
"type_info": "Int4"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Uuid"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "c92cc71e6d10c41368f7aa75b0799c2e1e9ca0ed33077ade8d7560e7cc21fa06"
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "DELETE FROM remote_deploy_token WHERE email = 'test@windmill.dev'",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": []
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "c96486a83ba0f4bf8daf3c525d6f67554643d0e730bec2e64840ad3d1862e540"
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "UPDATE workspace_settings\n SET remote_deploy_target = $1, remote_deploy_target_changed_at = now()\n WHERE workspace_id = 'test-workspace'",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Jsonb"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "cad45b99cd110589665e864f73403c2b12a3cf12ee81e67b0e9e520b3315e7d6"
|
||||
}
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "UPDATE v2_job_status SET\n flow_status = jsonb_set(\n jsonb_set(flow_status, ARRAY['modules', $3::INTEGER::TEXT, 'job'], to_jsonb($1::UUID::TEXT)),\n ARRAY['modules', $3::INTEGER::TEXT, 'type'],\n to_jsonb('InProgress'::text)\n )\n WHERE id = $2",
|
||||
"query": "UPDATE v2_job_status SET\n flow_status = jsonb_set(\n jsonb_set(flow_status, ARRAY['modules', $3::INTEGER::TEXT, 'job'], to_jsonb($1::UUID::TEXT)),\n ARRAY['modules', $3::INTEGER::TEXT, 'type'],\n to_jsonb('InProgress'::text)\n )\n WHERE id = $2\n AND NOT COALESCE(\n flow_status->'modules'->$3::INTEGER @> '{\"type\": \"InProgress\", \"parallel\": true}',\n false\n )",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
@@ -12,5 +12,5 @@
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "5c141db5cd6806cc27696a38b7f56a190dbea3ff59e2961658dd1677365e0fdf"
|
||||
"hash": "cbb59234f5a2d2e866aa6ceea8981bd3618b90615f7afab360dfbd6fb567b45b"
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "DELETE FROM remote_deploy_token WHERE workspace_id = $1 AND email = $2",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "d21d9d7e37045529ae4b73febc145cb080009a7a6d9ef33111755b470ff5abe2"
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "UPDATE usr SET created_at = created_at - interval '1 hour'\n WHERE workspace_id = 'test-workspace' AND email = 'test2@windmill.dev'",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": []
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "d305aed6172e25707492677b6cd3b8f3f6ea8f2c8f5bdc1a97736c8e52378ee9"
|
||||
}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT canceled_by IS NOT NULL AS \"canceled!\" FROM v2_job_completed WHERE id = $1 AND workspace_id = $2",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "canceled!",
|
||||
"type_info": "Bool"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Uuid",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "e2760debbb69182f1385bfe8e18933eeff7396430607c02cbd0cc682d5c34d61"
|
||||
}
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "DELETE FROM v2_job_queue WHERE id = $1 RETURNING canceled_by, canceled_reason",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "canceled_by",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "canceled_reason",
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Uuid"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
true,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "ed2eb32d445164e7ab5fb735e01f86505bc499320e08d1aad15a9a82c3823c0b"
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "INSERT INTO workspace_settings (workspace_id, slack_team_id, slack_name, slack_command_script, slack_email, customer_id, plan, webhook, ai_config, large_file_storage, git_sync, default_app, default_scripts, deploy_ui, mute_critical_alerts, color, operator_settings, teams_command_script, teams_team_id, teams_team_name, git_app_installations, git_credentials, ducklake, dbt_warehouses, slack_oauth_client_id, slack_oauth_client_secret, datatable, teams_team_guid, auto_invite, error_handler, success_handler, public_app_execution_limit_per_minute, error_handler_fallback_to_instance_alerts, guest_access_enabled, guest_jwt_public_key, guest_jwt_jwks_url, add_admins_and_developers_to_forks, remote_deploy_target, remote_deploy_target_changed_at) SELECT $1, slack_team_id, slack_name, slack_command_script, slack_email, customer_id, plan, webhook, ai_config, large_file_storage, git_sync, default_app, default_scripts, deploy_ui, mute_critical_alerts, color, operator_settings, teams_command_script, teams_team_id, teams_team_name, git_app_installations, git_credentials, ducklake, dbt_warehouses, slack_oauth_client_id, slack_oauth_client_secret, datatable, teams_team_guid, auto_invite, error_handler, success_handler, public_app_execution_limit_per_minute, error_handler_fallback_to_instance_alerts, guest_access_enabled, guest_jwt_public_key, guest_jwt_jwks_url, add_admins_and_developers_to_forks, remote_deploy_target, remote_deploy_target_changed_at FROM workspace_settings WHERE workspace_id = $2",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Varchar",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "ee3ed82783b5b7d2f41f16a1d94f006687bd9de4611203b5dd0c28eacee02842"
|
||||
}
|
||||
-15
@@ -1,15 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "INSERT INTO workspace_settings (workspace_id, slack_team_id, slack_name, slack_command_script, slack_email, customer_id, plan, webhook, ai_config, large_file_storage, git_sync, default_app, default_scripts, deploy_ui, mute_critical_alerts, color, operator_settings, teams_command_script, teams_team_id, teams_team_name, git_app_installations, git_credentials, ducklake, dbt_warehouses, slack_oauth_client_id, slack_oauth_client_secret, datatable, teams_team_guid, auto_invite, error_handler, success_handler, public_app_execution_limit_per_minute, error_handler_fallback_to_instance_alerts, guest_access_enabled, guest_jwt_public_key, guest_jwt_jwks_url, add_admins_and_developers_to_forks) SELECT $1, slack_team_id, slack_name, slack_command_script, slack_email, customer_id, plan, webhook, ai_config, large_file_storage, git_sync, default_app, default_scripts, deploy_ui, mute_critical_alerts, color, operator_settings, teams_command_script, teams_team_id, teams_team_name, git_app_installations, git_credentials, ducklake, dbt_warehouses, slack_oauth_client_id, slack_oauth_client_secret, datatable, teams_team_guid, auto_invite, error_handler, success_handler, public_app_execution_limit_per_minute, error_handler_fallback_to_instance_alerts, guest_access_enabled, guest_jwt_public_key, guest_jwt_jwks_url, add_admins_and_developers_to_forks FROM workspace_settings WHERE workspace_id = $2",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Varchar",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "eefa0588a6a927fd9b3f65e1df652fb2b4cf7983049d2c490940df360c7e2b30"
|
||||
}
|
||||
+6
-3
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT\n nt.external_id,\n nt.workspace_id,\n nt.service_name AS \"service_name!: ServiceName\",\n nt.script_path,\n nt.is_flow,\n nt.webhook_token_hash,\n nt.service_config,\n nt.error,\n nt.created_at,\n nt.updated_at,\n nt.summary,\n nt.enabled\n FROM\n native_trigger nt\n WHERE\n nt.workspace_id = $1 AND\n nt.service_name = $2 AND\n ($5::text IS NULL OR nt.script_path = $5) AND\n ($6::bool IS NULL OR nt.is_flow = $6) AND\n (\n (nt.is_flow = false AND EXISTS (\n SELECT 1 FROM script s\n WHERE s.workspace_id = nt.workspace_id\n AND s.path = nt.script_path\n ))\n OR\n (nt.is_flow = true AND EXISTS (\n SELECT 1 FROM flow f\n WHERE f.workspace_id = nt.workspace_id\n AND f.path = nt.script_path\n ))\n )\n LIMIT $3\n OFFSET $4\n ",
|
||||
"query": "\n SELECT\n nt.external_id,\n nt.workspace_id,\n nt.service_name AS \"service_name!: ServiceName\",\n nt.script_path,\n nt.is_flow,\n nt.webhook_token_hash,\n nt.service_config,\n nt.error,\n nt.created_at,\n nt.updated_at,\n nt.summary,\n nt.enabled\n FROM\n native_trigger nt\n WHERE\n nt.workspace_id = $1 AND\n nt.service_name = $2 AND\n ($5::text IS NULL OR nt.script_path = $5) AND\n ($6::bool IS NULL OR nt.is_flow = $6) AND\n (\n (nt.is_flow = false AND EXISTS (\n SELECT 1 FROM script s\n WHERE s.workspace_id = nt.workspace_id\n AND s.path = nt.script_path\n ))\n OR\n (nt.is_flow = true AND EXISTS (\n SELECT 1 FROM flow f\n WHERE f.workspace_id = nt.workspace_id\n AND f.path = nt.script_path\n ))\n ) AND\n ( $7\n OR nt.script_path = ANY($8)\n OR EXISTS ( SELECT 1 FROM unnest($9::text[]) AS pfx\n WHERE nt.script_path = pfx\n OR left(nt.script_path, length(pfx) + 1) = pfx || '/' ) )\n LIMIT $3\n OFFSET $4\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -93,7 +93,10 @@
|
||||
"Int8",
|
||||
"Int8",
|
||||
"Text",
|
||||
"Bool"
|
||||
"Bool",
|
||||
"Bool",
|
||||
"TextArray",
|
||||
"TextArray"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
@@ -111,5 +114,5 @@
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "a2122e8520268919e2ddc85ef46b5f9322229bb2a1e104b6eabaf2a697c2776a"
|
||||
"hash": "f3cd54017710536747b8aca797a8b983774ee2127dceb790181ed91b987f44ff"
|
||||
}
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT COUNT(*) FROM usr WHERE is_service_account = true",
|
||||
"query": "SELECT count(*) FROM remote_deploy_token WHERE email = 'test2@windmill.dev'",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -16,5 +16,5 @@
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "a37c2c4d5656d4b44433de84c454046f7586e36b7bd6a4679d70c359d4aacfcf"
|
||||
"hash": "f9692bba979c2b5a22e65106a330a0c0827dea6115daf9d006f8ccda0d041d76"
|
||||
}
|
||||
Generated
+172
-155
File diff suppressed because it is too large
Load Diff
+8
-7
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "windmill"
|
||||
version = "1.815.0"
|
||||
version = "1.817.0"
|
||||
authors.workspace = true
|
||||
edition.workspace = true
|
||||
|
||||
@@ -88,7 +88,7 @@ members = [
|
||||
exclude = ["./windmill-duckdb-ffi-internal", "./parsers/windmill-parser-wasm"]
|
||||
|
||||
[workspace.package]
|
||||
version = "1.815.0"
|
||||
version = "1.817.0"
|
||||
authors = ["Ruben Fiszel <ruben@windmill.dev>"]
|
||||
edition = "2021"
|
||||
|
||||
@@ -493,7 +493,7 @@ sql-builder = "^3"
|
||||
# behind a feature and changed `hash_password`), so a float breaks the build.
|
||||
argon2 = "0.6"
|
||||
quick_cache = "^0"
|
||||
rand = "=0.9.0"
|
||||
rand = "^0.9.3"
|
||||
rand_core = { version = "^0", features = ["std"] }
|
||||
arc-swap = "1"
|
||||
ed25519-dalek = { version = "2", features = ["rand_core"] }
|
||||
@@ -514,7 +514,7 @@ reqwest = { version = "^0.13", features = ["json", "stream", "gzip", "multipart"
|
||||
eventsource-stream = "0.2.3"
|
||||
time = "^0"
|
||||
serde_urlencoded = "^0"
|
||||
astral-tokio-tar = "^0.5.6"
|
||||
astral-tokio-tar = "^0.6.2"
|
||||
tempfile = "^3"
|
||||
x509-parser = "^0.16"
|
||||
tokio-util = { version = "=0.7.17", features = ["io"] }
|
||||
@@ -523,7 +523,7 @@ itertools = "^0.14.0"
|
||||
regex = "^1"
|
||||
semver = "^1"
|
||||
aws-sigv4 = "^1.3.4"
|
||||
aws-sdk-config = "=1.68.0"
|
||||
aws-sdk-config = "^1.94.0"
|
||||
aws-sdk-rds = "^1"
|
||||
async-trait = "0.1.88"
|
||||
|
||||
@@ -685,8 +685,8 @@ aws-sdk-bedrockruntime = "=1.122.0"
|
||||
aws-credential-types = "^1"
|
||||
aws-smithy-types = "^1"
|
||||
aws-sdk-secretsmanager = "^1"
|
||||
aws-sdk-sqs = "=1.77.0"
|
||||
aws-sdk-sts = "=1.79.0"
|
||||
aws-sdk-sqs = "^1.89.0"
|
||||
aws-sdk-sts = "^1.91.0"
|
||||
aws-sdk-sso = "=1.77.0"
|
||||
aws-sdk-ssooidc = "=1.78.0"
|
||||
rustls = "=0.23.35"
|
||||
@@ -757,4 +757,5 @@ hyper-http-proxy = { version = "1", default-features = false, features = ["rustl
|
||||
hyper-rustls = { version = "0.27", default-features = false, features = ["http1", "http2", "ring", "tls12"] }
|
||||
tokio-rustls = { version = "0.26", default-features = false, features = ["ring", "tls12"] }
|
||||
rustls-native-certs = "0.8"
|
||||
webpki-roots = "1"
|
||||
rcgen = "0.13"
|
||||
|
||||
@@ -97,7 +97,7 @@ published advisory history (73 GHSA advisories, several rated 9.9 critical).
|
||||
| id | threat | actor | surface | asset | impact | likelihood | status | controls | evidence |
|
||||
|---|---|---|---|---|---|---|---|---|---|
|
||||
| T1 | SQL injection in app/internal query builders and trigger clauses compromises the metadata DB and connected databases | remote_auth | EP8 | Database, downstream connected systems | critical | almost_certain | partially_mitigated | sqlx parameterized queries elsewhere; query-builder safety reviews | GHSA-225c-j3xq-g6x6, GHSA-78p7-jc72-gv66, GHSA-hvc7-f67h-jx3g, GHSA-wrrg-f89m-f84q, GHSA-79vf-3qwm-2w64, GHSA-55p6-fxj4-v983, GHSA-5g4v-49rj-r52r, GHSA-x6cq-7xr8-53x3, 2cf4bb180b |
|
||||
| T2 | Server-side request forgery via proxies/executors reaches cloud metadata, internal network, and downstream credentials | remote_auth | EP6, EP7 | Cloud metadata, internal network, downstream connected systems, resource creds | critical | almost_certain | partially_mitigated | SSRF URL validation + redirect-following disabled added piecemeal; MCP private URL access requires the instance-wide `ALLOW_PRIVATE_MCP_SERVER_URLS` opt-in; WebSocket trigger URLs (stored, test, and runnable-resolved) are SSRF-validated at connect time behind the `ALLOW_PRIVATE_WEBSOCKET_URLS` opt-in, and the trigger test route now requires `:write` scope; SAML IdP metadata URLs are SSRF-validated at load time behind the `ALLOW_PRIVATE_SAML_METADATA_URLS` opt-in; outbound network isolation (`clone_newnet`) is opt-in and off by default | GHSA-3ggp-h37f-5qfw, GHSA-98qq-g8rh-xhff, GHSA-hfw8-27mx-63jm, GHSA-3r59-qvvc-774j, GHSA-4pj9-w5jc-g8w7, GHSA-8hh3-jf25-78j5, GHSA-3pjm-4w7f-3r2w, GHSA-f44c-x9hq-h68r, GHSA-j4h4-f8fj-3m3c, 4b06881918, 96a8eb63d4, dbd3942ef3 |
|
||||
| T2 | Server-side request forgery via proxies/executors reaches cloud metadata, internal network, and downstream credentials | remote_auth | EP6, EP7 | Cloud metadata, internal network, downstream connected systems, resource creds | critical | almost_certain | partially_mitigated | SSRF URL validation + redirect-following disabled added piecemeal; MCP private URL access requires the instance-wide `ALLOW_PRIVATE_MCP_SERVER_URLS` opt-in; WebSocket trigger URLs (stored, test, and runnable-resolved) are SSRF-validated at connect time behind the `ALLOW_PRIVATE_WEBSOCKET_URLS` opt-in, and the trigger test route now requires `:write` scope; SAML IdP metadata URLs are SSRF-validated at load time behind the `ALLOW_PRIVATE_SAML_METADATA_URLS` opt-in; workspace webhook URLs are SSRF-checked when saved, not when sent, behind the `ALLOW_PRIVATE_WEBHOOK_URLS` opt-in; outbound network isolation (`clone_newnet`) is opt-in and off by default | GHSA-3ggp-h37f-5qfw, GHSA-98qq-g8rh-xhff, GHSA-hfw8-27mx-63jm, GHSA-3r59-qvvc-774j, GHSA-4pj9-w5jc-g8w7, GHSA-8hh3-jf25-78j5, GHSA-3pjm-4w7f-3r2w, GHSA-f44c-x9hq-h68r, GHSA-j4h4-f8fj-3m3c, 4b06881918, 96a8eb63d4, dbd3942ef3 |
|
||||
| T3 | Broken authorization / IDOR lets a scoped token or low-privilege member read scripts, job data, and secrets across folders and workspaces | remote_auth | EP5, EP2, EP1 | Scripts, job data, secrets, isolation | critical | almost_certain | partially_mitigated | RLS, token scopes, folder ACLs, view-token HMAC (added incrementally); on managed, sensitive tenants can opt into dedicated DB/worker/namespace, but the shared tier IS the software boundary | GHSA-qfg7-x243-5hg4, GHSA-8x8x-88qc-qp4r, GHSA-2ppx-66jv-wpw5, GHSA-x3x7-g97v-mp59, GHSA-j276-g4h8-g6h5, GHSA-8mv7-hmrg-96xv, GHSA-x2wf-f962-7frq, GHSA-qc7c-gcw6-h4xp, GHSA-vxc5-w28p-m9xw, GHSA-2g34-wfvr-5qqj, GHSA-w7p6-wpxm-pp66, 7edf3f0212, 89a7a37776, ab11c7747a, 664edcdfb7 |
|
||||
| T4 | Remote code execution by injecting attacker-controlled identifiers into generated worker wrappers | remote_auth | EP10 | Worker host, isolation, downstream | critical | likely | partially_mitigated | entrypoint/env-var-name validation added | GHSA-wxjq-w5pj-jqhx, GHSA-5f5q-2vg2-r2x4, GHSA-8q8j-mm3g-5c2q (CVE-2026-33881), bf93657fee, bd05bcadde, 22ec4da5f0 |
|
||||
| T5 | Worker compromise & cross-tenant access via weak-by-default isolation (nsjail off by default → user code runs with only PID-ns `unshare`); sandbox escape where nsjail/dind/podman is enabled | remote_auth | EP9, EP15 | Worker host, isolation, downstream | critical | likely | unmitigated | nsjail off by default everywhere (`DISABLE_NSJAIL=true`); shipped compose gives PID-ns `unshare` only (`FAVOR_UNSHARE_PID=true`), bare installs get no isolation. Where nsjail enabled: read-only remounts, jail-tmp refusal, podman socket gating | GHSA-6qr8-xhg4-453q, GHSA-3vpp-vf62-wqp6, f8467f38c8, df5aec0f5d, f1b6746e0e |
|
||||
|
||||
@@ -1 +1 @@
|
||||
38699f705abb4e223d86e18b30d004aeaf589401
|
||||
85683db6bfc78840da48fd9ca1bc22d8651162e4
|
||||
|
||||
@@ -128,11 +128,16 @@ def extract_separate_schemas(parameters: List[Dict[str, Any]], request_body: Opt
|
||||
r for r in body_schema['required'] if r in include_fields
|
||||
]
|
||||
|
||||
# Apply opaque_fields: simplify listed properties to {"type": "object"}
|
||||
# Apply opaque_fields: simplify listed properties to {"type": "object"},
|
||||
# keeping the description, which is all a caller learns about the field
|
||||
if body_schema and opaque_fields and 'properties' in body_schema:
|
||||
for field in opaque_fields:
|
||||
if field in body_schema['properties']:
|
||||
body_schema['properties'][field] = {"type": "object"}
|
||||
opaque = {"type": "object"}
|
||||
description = body_schema['properties'][field].get('description')
|
||||
if description:
|
||||
opaque['description'] = description
|
||||
body_schema['properties'][field] = opaque
|
||||
|
||||
# If we have required fields specified and a body schema, update the required array
|
||||
if body_schema and required_fields:
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
DROP TABLE IF EXISTS remote_deploy_token;
|
||||
ALTER TABLE workspace_settings
|
||||
DROP COLUMN IF EXISTS remote_deploy_target,
|
||||
DROP COLUMN IF EXISTS remote_deploy_target_changed_at;
|
||||
@@ -0,0 +1,35 @@
|
||||
-- The workspace on another Windmill instance this workspace deploys into from the UI:
|
||||
-- `{"base_url": ..., "workspace_id": ...}`, and when it last changed. A token connected under an
|
||||
-- earlier change counts for nothing, even once the setting points back at the target it was for.
|
||||
ALTER TABLE workspace_settings
|
||||
ADD COLUMN remote_deploy_target JSONB,
|
||||
ADD COLUMN remote_deploy_target_changed_at TIMESTAMPTZ;
|
||||
|
||||
-- One user's token for the remote deploy target, encrypted with the workspace key.
|
||||
-- `base_url`/`remote_workspace_id` name the target it was granted for: a token is only
|
||||
-- ever sent to that target, so re-pointing the workspace setting cannot redirect it.
|
||||
-- Keyed by the account rather than by membership, since a superadmin deploys from workspaces
|
||||
-- it is not a member of. The account key cascades: whatever deletes or renames an account
|
||||
-- takes its tokens along, so a later account with the same address cannot inherit them.
|
||||
CREATE TABLE remote_deploy_token (
|
||||
workspace_id VARCHAR(50) NOT NULL REFERENCES workspace(id) ON DELETE CASCADE,
|
||||
email VARCHAR(255) NOT NULL REFERENCES password(email) ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
base_url VARCHAR(1000) NOT NULL,
|
||||
remote_workspace_id VARCHAR(50) NOT NULL,
|
||||
token TEXT NOT NULL,
|
||||
remote_email VARCHAR(255) NOT NULL,
|
||||
-- Part of every proxy URL, and readable only by its owner through the API: a link from
|
||||
-- elsewhere, which rides the session cookie, cannot know it and so cannot spend the token.
|
||||
proxy_key VARCHAR(64) NOT NULL,
|
||||
-- When the connect that wrote the row started, or when a disconnect emptied it.
|
||||
connected_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
-- What the connect ran under: the `created_at` of the owner's membership (NULL for a
|
||||
-- superadmin with none) and the target's `remote_deploy_target_changed_at`. The row counts
|
||||
-- only while both are still the same, so a re-add or a target change voids it.
|
||||
member_since TIMESTAMPTZ,
|
||||
target_changed_at TIMESTAMPTZ,
|
||||
PRIMARY KEY (workspace_id, email)
|
||||
);
|
||||
|
||||
GRANT ALL ON remote_deploy_token TO windmill_user;
|
||||
GRANT ALL ON remote_deploy_token TO windmill_admin;
|
||||
@@ -0,0 +1,6 @@
|
||||
-- `created_by` tells this migration's row from one a user created by hand; a hub
|
||||
-- sync updates in place and leaves it alone.
|
||||
DELETE FROM resource_type
|
||||
WHERE workspace_id = 'admins'
|
||||
AND name = 'ai_instruction'
|
||||
AND created_by = 'system';
|
||||
@@ -0,0 +1,15 @@
|
||||
-- Folder instructions for the AI chat: a markdown file resource whose
|
||||
-- `value.content` applies to every item under the directory it lives in.
|
||||
-- `format_extension` makes the resource editor render it as a plain .md file.
|
||||
-- Seeded under 'admins' so every workspace sees it before the next hub sync.
|
||||
INSERT INTO resource_type (workspace_id, name, schema, description, created_by, format_extension, edited_at)
|
||||
VALUES (
|
||||
'admins',
|
||||
'ai_instruction',
|
||||
'{"type": "object", "properties": {"content": {"type": "string"}}}',
|
||||
'Instructions for the AI chat that apply to every item under the folder this resource lives in, like an AGENTS.md: an ai_instruction at f/billing/AGENTS is given to the assistant whenever it works on something under f/billing/. The file body is the instructions it follows.',
|
||||
'system',
|
||||
'md',
|
||||
now()
|
||||
)
|
||||
ON CONFLICT (workspace_id, name) DO NOTHING;
|
||||
@@ -16,11 +16,13 @@ use windmill_parser::{
|
||||
|
||||
use swc_common::{sync::Lrc, FileName, SourceMap, SourceMapper, Span, Spanned};
|
||||
use swc_ecma_ast::{
|
||||
ArrayLit, AssignPat, BigInt, BindingIdent, Bool, Decl, ExportDecl, Expr, Ident, IdentName, Lit,
|
||||
MemberExpr, MemberProp, ModuleDecl, ModuleItem, Number, ObjectLit, ObjectPat, Param, Pat, Stmt,
|
||||
Str, TsArrayType, TsEntityName, TsInterfaceDecl, TsKeywordType, TsKeywordTypeKind, TsLit,
|
||||
TsLitType, TsOptionalType, TsParenthesizedType, TsPropertySignature, TsType, TsTypeAliasDecl,
|
||||
TsTypeAnn, TsTypeElement, TsTypeLit, TsTypeRef, TsUnionOrIntersectionType, TsUnionType,
|
||||
ArrayLit, AssignPat, BigInt, BindingIdent, Bool, Decl, ExportDecl, Expr, ExprOrSpread, Ident,
|
||||
IdentName, KeyValueProp, Lit, MemberExpr, MemberProp, ModuleDecl, ModuleItem, Number,
|
||||
ObjectLit, ObjectPat, Param, Pat, Prop, PropName, PropOrSpread, Stmt, Str, TsArrayType,
|
||||
TsEntityName, TsInterfaceDecl, TsKeywordType, TsKeywordTypeKind, TsLit, TsLitType,
|
||||
TsOptionalType, TsParenthesizedType, TsPropertySignature, TsType, TsTypeAliasDecl, TsTypeAnn,
|
||||
TsTypeElement, TsTypeLit, TsTypeRef, TsUnionOrIntersectionType, TsUnionType, UnaryExpr,
|
||||
UnaryOp,
|
||||
};
|
||||
use swc_ecma_parser::{lexer::Lexer, EsSyntax, Parser, StringInput, Syntax, TsSyntax};
|
||||
|
||||
@@ -578,6 +580,15 @@ fn parse_param(
|
||||
}
|
||||
};
|
||||
|
||||
let literal = match &*right {
|
||||
Expr::Object(_) | Expr::Array(_) if !skip_dflt => literal_json(&right),
|
||||
_ => None,
|
||||
};
|
||||
// Types an untyped object default that is not a plain literal (`{ a: x }`).
|
||||
let literal_shape = match &*right {
|
||||
Expr::Object(_) => Some(Typ::Object(ObjectType::new(None, None))),
|
||||
_ => None,
|
||||
};
|
||||
let dflt = if skip_dflt {
|
||||
None
|
||||
} else {
|
||||
@@ -593,14 +604,19 @@ fn parse_param(
|
||||
Expr::Lit(Lit::Num(Number { value, .. })) => Some(serde_json::json!(value)),
|
||||
Expr::Lit(Lit::BigInt(BigInt { value, .. })) => Some(serde_json::json!(value)),
|
||||
Expr::Lit(Lit::Bool(Bool { value, .. })) => Some(Value::Bool(value)),
|
||||
Expr::Object(ObjectLit { span, .. }) => eval_span(span, cm),
|
||||
Expr::Array(ArrayLit { span, .. }) => eval_span(span, cm),
|
||||
Expr::Object(ObjectLit { span, .. }) | Expr::Array(ArrayLit { span, .. }) => {
|
||||
literal.or_else(|| eval_span(span, cm))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
};
|
||||
|
||||
if typ == Typ::Unknown && dflt.is_some() {
|
||||
typ = json_to_typ(dflt.as_ref().unwrap(), false);
|
||||
if typ == Typ::Unknown {
|
||||
match &dflt {
|
||||
Some(d) => typ = json_to_typ(d, false),
|
||||
None if !skip_dflt => typ = literal_shape.unwrap_or(Typ::Unknown),
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
Ok(Arg {
|
||||
otyp,
|
||||
@@ -636,6 +652,59 @@ fn parse_param(
|
||||
r
|
||||
}
|
||||
|
||||
/// A default written as a plain literal, read off the AST: `['a']` and `{ a: 1 }` are
|
||||
/// not JSON, and outside wasm nothing can evaluate them (see `eval_sync`).
|
||||
fn literal_json(expr: &Expr) -> Option<Value> {
|
||||
let num = |v: f64| {
|
||||
if v.fract() == 0.0 && v.abs() < 9007199254740992.0 {
|
||||
Some(serde_json::json!(v as i64))
|
||||
} else {
|
||||
serde_json::Number::from_f64(v).map(Value::Number)
|
||||
}
|
||||
};
|
||||
match expr {
|
||||
Expr::Lit(Lit::Str(s)) => Some(Value::String(s.value.to_string())),
|
||||
Expr::Lit(Lit::Num(n)) => num(n.value),
|
||||
Expr::Lit(Lit::Bool(b)) => Some(Value::Bool(b.value)),
|
||||
Expr::Lit(Lit::Null(_)) => Some(Value::Null),
|
||||
Expr::Unary(UnaryExpr { op: UnaryOp::Minus, arg, .. }) => match &**arg {
|
||||
Expr::Lit(Lit::Num(n)) => num(-n.value),
|
||||
_ => None,
|
||||
},
|
||||
Expr::Paren(p) => literal_json(&p.expr),
|
||||
Expr::Array(a) => a
|
||||
.elems
|
||||
.iter()
|
||||
.map(|e| match e {
|
||||
Some(ExprOrSpread { spread: None, expr }) => literal_json(expr),
|
||||
_ => None,
|
||||
})
|
||||
.collect::<Option<Vec<_>>>()
|
||||
.map(Value::Array),
|
||||
Expr::Object(o) => o
|
||||
.props
|
||||
.iter()
|
||||
.map(|p| match p {
|
||||
PropOrSpread::Prop(p) => match &**p {
|
||||
Prop::KeyValue(KeyValueProp { key, value }) => {
|
||||
let key = match key {
|
||||
PropName::Ident(i) => i.sym.to_string(),
|
||||
PropName::Str(s) => s.value.to_string(),
|
||||
PropName::Num(n) => n.value.to_string(),
|
||||
_ => return None,
|
||||
};
|
||||
Some((key, literal_json(value)?))
|
||||
}
|
||||
_ => None,
|
||||
},
|
||||
_ => None,
|
||||
})
|
||||
.collect::<Option<serde_json::Map<_, _>>>()
|
||||
.map(Value::Object),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn eval_span(span: Span, cm: &Lrc<SourceMap>) -> Option<Value> {
|
||||
let expr = cm
|
||||
.span_to_snippet(span)
|
||||
@@ -1361,5 +1430,7 @@ pub fn eval_sync(code: &str) -> Result<serde_json::Value, String> {
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn eval_sync(_code: &str) -> Result<serde_json::Value, String> {
|
||||
panic!("eval_sync is only available in wasm32")
|
||||
// The server parses signatures with defaults too: a default that is not JSON
|
||||
// (`{ a: 1 }`, `['x']`) is then left out rather than failing the parse.
|
||||
Err("evaluating a JavaScript default needs the wasm build".to_string())
|
||||
}
|
||||
|
||||
@@ -7,6 +7,38 @@ mod tests {
|
||||
remove_pinned_import_specifiers,
|
||||
};
|
||||
|
||||
/// The server parses defaults natively, where a non-JSON literal cannot be evaluated
|
||||
/// but must still type and default the argument as the editor's wasm parse does.
|
||||
#[test]
|
||||
fn non_json_literal_defaults_parse_natively() {
|
||||
let code =
|
||||
"export function main(opts = { enabled: true, 'n': -1 }, tags = ['a'], o = { a: x }) {}";
|
||||
let sig = parse_deno_signature(code, false, false, None).unwrap();
|
||||
let defaults: Vec<_> = sig
|
||||
.args
|
||||
.iter()
|
||||
.map(|a| (a.typ.clone(), a.default.clone()))
|
||||
.collect();
|
||||
assert_eq!(
|
||||
defaults,
|
||||
vec![
|
||||
(
|
||||
Typ::Object(ObjectType::new(
|
||||
None,
|
||||
Some(vec![
|
||||
ObjectProperty::new("enabled".into(), Box::new(Typ::Bool)),
|
||||
ObjectProperty::new("n".into(), Box::new(Typ::Int)),
|
||||
])
|
||||
)),
|
||||
Some(json!({"enabled": true, "n": -1}))
|
||||
),
|
||||
(Typ::List(Box::new(Typ::Str(None))), Some(json!(["a"]))),
|
||||
// Not a plain literal: typed by its shape, with no default value.
|
||||
(Typ::Object(ObjectType::new(None, None)), None),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_imports_basic() {
|
||||
let code = r#"
|
||||
|
||||
+113
-68
@@ -775,9 +775,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cmov"
|
||||
version = "0.5.3"
|
||||
version = "0.5.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3f88a43d011fc4a6876cb7344703e297c71dda42494fee094d5f7c76bf13f746"
|
||||
checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a"
|
||||
|
||||
[[package]]
|
||||
name = "colorchoice"
|
||||
@@ -1530,7 +1530,7 @@ dependencies = [
|
||||
"cfg-if",
|
||||
"js-sys",
|
||||
"libc",
|
||||
"wasi",
|
||||
"wasi 0.11.1+wasi-snapshot-preview1",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
@@ -2580,7 +2580,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"wasi",
|
||||
"wasi 0.11.1+wasi-snapshot-preview1",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
@@ -2816,7 +2816,7 @@ dependencies = [
|
||||
"num-integer",
|
||||
"num-iter",
|
||||
"num-traits",
|
||||
"rand 0.8.5",
|
||||
"rand 0.8.6",
|
||||
"smallvec",
|
||||
"zeroize",
|
||||
]
|
||||
@@ -2902,15 +2902,14 @@ checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
|
||||
|
||||
[[package]]
|
||||
name = "openssl"
|
||||
version = "0.10.78"
|
||||
version = "0.10.80"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f38c4372413cdaaf3cc79dd92d29d7d9f5ab09b51b10dded508fb90bb70b9222"
|
||||
checksum = "a45fa2aa886c42762255da344f0a0d313e254066c46aad76f300c3d3da62d967"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"cfg-if",
|
||||
"foreign-types",
|
||||
"libc",
|
||||
"once_cell",
|
||||
"openssl-macros",
|
||||
"openssl-sys",
|
||||
]
|
||||
@@ -2934,9 +2933,9 @@ checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
|
||||
|
||||
[[package]]
|
||||
name = "openssl-sys"
|
||||
version = "0.9.114"
|
||||
version = "0.9.117"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "13ce1245cd07fcc4cfdb438f7507b0c7e4f3849a69fd84d52374c66d83741bb6"
|
||||
checksum = "b47e7e6bb2c38cd930d25a23b40fa52e068c10e85f3e03a7f5ba5aaca5713695"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"libc",
|
||||
@@ -3047,6 +3046,16 @@ dependencies = [
|
||||
"phf_shared 0.12.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "phf"
|
||||
version = "0.13.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf"
|
||||
dependencies = [
|
||||
"phf_shared 0.13.1",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "phf_codegen"
|
||||
version = "0.11.3"
|
||||
@@ -3064,7 +3073,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d"
|
||||
dependencies = [
|
||||
"phf_shared 0.11.3",
|
||||
"rand 0.8.5",
|
||||
"rand 0.8.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3098,6 +3107,15 @@ dependencies = [
|
||||
"siphasher 1.0.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "phf_shared"
|
||||
version = "0.13.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266"
|
||||
dependencies = [
|
||||
"siphasher 1.0.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "php-parser-rs"
|
||||
version = "0.1.3"
|
||||
@@ -3169,9 +3187,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "postgres-protocol"
|
||||
version = "0.6.11"
|
||||
version = "0.6.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "56201207dac53e2f38e848e31b4b91616a6bb6e0c7205b77718994a7f49e70fc"
|
||||
checksum = "08808e3c483c46e999108051c78334f473d5adb59d78bb80a1268c7e6aa6c514"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"byteorder",
|
||||
@@ -3187,9 +3205,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "postgres-types"
|
||||
version = "0.2.9"
|
||||
version = "0.2.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "613283563cd90e1dfc3518d548caee47e0e725455ed619881f5cf21f36de4b48"
|
||||
checksum = "851ca9db4932932d69f3ea811b1abe63087a0f740a47692619dd40d4899b68be"
|
||||
dependencies = [
|
||||
"array-init",
|
||||
"bit-vec 0.6.3",
|
||||
@@ -3197,7 +3215,7 @@ dependencies = [
|
||||
"chrono",
|
||||
"fallible-iterator",
|
||||
"postgres-protocol",
|
||||
"serde",
|
||||
"serde_core",
|
||||
"serde_json",
|
||||
"uuid",
|
||||
]
|
||||
@@ -3394,15 +3412,15 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "quinn-proto"
|
||||
version = "0.11.14"
|
||||
version = "0.11.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098"
|
||||
checksum = "4fcb935c5bec503c2f0e306bdd3e58bb9029dcb14fa8d9ac76e3a5256ac0763e"
|
||||
dependencies = [
|
||||
"aws-lc-rs",
|
||||
"bytes",
|
||||
"getrandom 0.3.4",
|
||||
"lru-slab",
|
||||
"rand 0.9.0",
|
||||
"rand 0.9.5",
|
||||
"ring",
|
||||
"rustc-hash 2.1.1",
|
||||
"rustls",
|
||||
@@ -3457,9 +3475,9 @@ checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09"
|
||||
|
||||
[[package]]
|
||||
name = "rand"
|
||||
version = "0.8.5"
|
||||
version = "0.8.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404"
|
||||
checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"rand_chacha 0.3.1",
|
||||
@@ -3468,13 +3486,12 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rand"
|
||||
version = "0.9.0"
|
||||
version = "0.9.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3779b94aeb87e8bd4e834cee3650289ee9e0d5677f976ecdb6d219e5f4f6cd94"
|
||||
checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41"
|
||||
dependencies = [
|
||||
"rand_chacha 0.9.0",
|
||||
"rand_core 0.9.5",
|
||||
"zerocopy",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3752,7 +3769,7 @@ version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "503c78f59814e2664c9980b739b19e40f549233ecf39928040ee54fdd431f614"
|
||||
dependencies = [
|
||||
"rand 0.8.5",
|
||||
"rand 0.8.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3828,7 +3845,7 @@ dependencies = [
|
||||
"borsh",
|
||||
"bytes",
|
||||
"num-traits",
|
||||
"rand 0.8.5",
|
||||
"rand 0.8.6",
|
||||
"rkyv",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -3944,9 +3961,9 @@ checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f"
|
||||
|
||||
[[package]]
|
||||
name = "rustls-webpki"
|
||||
version = "0.103.9"
|
||||
version = "0.103.13"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53"
|
||||
checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e"
|
||||
dependencies = [
|
||||
"aws-lc-rs",
|
||||
"ring",
|
||||
@@ -4574,7 +4591,7 @@ dependencies = [
|
||||
"memchr",
|
||||
"once_cell",
|
||||
"percent-encoding",
|
||||
"rand 0.8.5",
|
||||
"rand 0.8.6",
|
||||
"rsa",
|
||||
"serde",
|
||||
"sha1",
|
||||
@@ -4585,7 +4602,7 @@ dependencies = [
|
||||
"thiserror 2.0.18",
|
||||
"tracing",
|
||||
"uuid",
|
||||
"whoami",
|
||||
"whoami 1.6.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4616,7 +4633,7 @@ dependencies = [
|
||||
"memchr",
|
||||
"num-bigint",
|
||||
"once_cell",
|
||||
"rand 0.8.5",
|
||||
"rand 0.8.6",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2 0.10.9",
|
||||
@@ -4626,7 +4643,7 @@ dependencies = [
|
||||
"thiserror 2.0.18",
|
||||
"tracing",
|
||||
"uuid",
|
||||
"whoami",
|
||||
"whoami 1.6.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5043,9 +5060,9 @@ checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369"
|
||||
|
||||
[[package]]
|
||||
name = "tar"
|
||||
version = "0.4.45"
|
||||
version = "0.4.46"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "22692a6476a21fa75fdfc11d452fda482af402c008cdbaf3476414e122040973"
|
||||
checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840"
|
||||
dependencies = [
|
||||
"filetime",
|
||||
"libc",
|
||||
@@ -5254,9 +5271,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tokio-postgres"
|
||||
version = "0.7.13"
|
||||
version = "0.7.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6c95d533c83082bb6490e0189acaa0bbeef9084e60471b696ca6988cd0541fb0"
|
||||
checksum = "a528f7d280f6d5b9cd149635c8705b0dd049754bc67d81d31fa25169a93809d3"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"byteorder",
|
||||
@@ -5267,15 +5284,15 @@ dependencies = [
|
||||
"log",
|
||||
"parking_lot",
|
||||
"percent-encoding",
|
||||
"phf 0.11.3",
|
||||
"phf 0.13.1",
|
||||
"pin-project-lite",
|
||||
"postgres-protocol",
|
||||
"postgres-types",
|
||||
"rand 0.9.0",
|
||||
"socket2 0.5.10",
|
||||
"rand 0.10.1",
|
||||
"socket2 0.6.3",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
"whoami",
|
||||
"whoami 2.1.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5777,7 +5794,7 @@ dependencies = [
|
||||
"getopts",
|
||||
"log",
|
||||
"phf_codegen",
|
||||
"rand 0.8.5",
|
||||
"rand 0.8.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5899,6 +5916,15 @@ version = "0.11.1+wasi-snapshot-preview1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
|
||||
|
||||
[[package]]
|
||||
name = "wasi"
|
||||
version = "0.14.7+wasi-0.2.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "883478de20367e224c0090af9cf5f9fa85bed63a95c1abf3afc5c083ebc06e8c"
|
||||
dependencies = [
|
||||
"wasip2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasip2"
|
||||
version = "1.0.2+wasi-0.2.9"
|
||||
@@ -5923,6 +5949,15 @@ version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b"
|
||||
|
||||
[[package]]
|
||||
name = "wasite"
|
||||
version = "1.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "66fe902b4a6b8028a753d5424909b764ccf79b7a209eac9bf97e59cda9f71a42"
|
||||
dependencies = [
|
||||
"wasi 0.14.7+wasi-0.2.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen"
|
||||
version = "0.2.103"
|
||||
@@ -6134,7 +6169,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5d4a4db5077702ca3015d3d02d74974948aba2ad9e12ab7df718ee64ccd7e97d"
|
||||
dependencies = [
|
||||
"libredox",
|
||||
"wasite",
|
||||
"wasite 0.1.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "whoami"
|
||||
version = "2.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8fae98cf96deed1b7572272dfc777713c249ae40aa1cf8862e091e8b745f5361"
|
||||
dependencies = [
|
||||
"libredox",
|
||||
"wasite 1.0.2",
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
@@ -6171,7 +6216,7 @@ checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
|
||||
|
||||
[[package]]
|
||||
name = "windmill-common"
|
||||
version = "1.815.0"
|
||||
version = "1.817.0"
|
||||
dependencies = [
|
||||
"aho-corasick",
|
||||
"anyhow",
|
||||
@@ -6216,7 +6261,7 @@ dependencies = [
|
||||
"pkcs1",
|
||||
"postgres-native-tls",
|
||||
"quick_cache",
|
||||
"rand 0.9.0",
|
||||
"rand 0.9.5",
|
||||
"regex",
|
||||
"reqwest",
|
||||
"reqwest-middleware",
|
||||
@@ -6257,7 +6302,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-macros"
|
||||
version = "1.815.0"
|
||||
version = "1.817.0"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@@ -6269,7 +6314,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser"
|
||||
version = "1.815.0"
|
||||
version = "1.817.0"
|
||||
dependencies = [
|
||||
"convert_case",
|
||||
"serde",
|
||||
@@ -6278,7 +6323,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-bash"
|
||||
version = "1.815.0"
|
||||
version = "1.817.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -6290,7 +6335,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-csharp"
|
||||
version = "1.815.0"
|
||||
version = "1.817.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde_json",
|
||||
@@ -6302,7 +6347,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-go"
|
||||
version = "1.815.0"
|
||||
version = "1.817.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"gosyn",
|
||||
@@ -6314,7 +6359,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-graphql"
|
||||
version = "1.815.0"
|
||||
version = "1.817.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -6326,7 +6371,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-java"
|
||||
version = "1.815.0"
|
||||
version = "1.817.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde_json",
|
||||
@@ -6338,7 +6383,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-nu"
|
||||
version = "1.815.0"
|
||||
version = "1.817.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"nu-parser",
|
||||
@@ -6349,7 +6394,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-php"
|
||||
version = "1.815.0"
|
||||
version = "1.817.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"itertools 0.14.0",
|
||||
@@ -6360,7 +6405,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-py"
|
||||
version = "1.815.0"
|
||||
version = "1.817.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"itertools 0.14.0",
|
||||
@@ -6372,7 +6417,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-py-asset"
|
||||
version = "1.815.0"
|
||||
version = "1.817.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"rustpython-ast",
|
||||
@@ -6383,7 +6428,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-py-imports"
|
||||
version = "1.815.0"
|
||||
version = "1.817.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-recursion",
|
||||
@@ -6405,7 +6450,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-r"
|
||||
version = "1.815.0"
|
||||
version = "1.817.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde_json",
|
||||
@@ -6417,7 +6462,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-ruby"
|
||||
version = "1.815.0"
|
||||
version = "1.817.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -6431,7 +6476,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-rust"
|
||||
version = "1.815.0"
|
||||
version = "1.817.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"convert_case",
|
||||
@@ -6448,7 +6493,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-sql"
|
||||
version = "1.815.0"
|
||||
version = "1.817.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -6461,7 +6506,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-sql-asset"
|
||||
version = "1.815.0"
|
||||
version = "1.817.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde",
|
||||
@@ -6473,7 +6518,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-ts"
|
||||
version = "1.815.0"
|
||||
version = "1.817.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -6491,7 +6536,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-ts-asset"
|
||||
version = "1.815.0"
|
||||
version = "1.817.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde-wasm-bindgen",
|
||||
@@ -6507,7 +6552,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-wac"
|
||||
version = "1.815.0"
|
||||
version = "1.817.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"rustpython-ast",
|
||||
@@ -6523,7 +6568,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-wasm"
|
||||
version = "1.815.0"
|
||||
version = "1.817.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"getrandom 0.2.17",
|
||||
@@ -6555,7 +6600,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-yaml"
|
||||
version = "1.815.0"
|
||||
version = "1.817.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -6569,14 +6614,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-types"
|
||||
version = "1.815.0"
|
||||
version = "1.817.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bitflags",
|
||||
"chrono",
|
||||
"hex",
|
||||
"itertools 0.14.0",
|
||||
"rand 0.9.0",
|
||||
"rand 0.9.5",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sqlx",
|
||||
|
||||
@@ -12,7 +12,7 @@ resolver = "2"
|
||||
members = ["."]
|
||||
|
||||
[workspace.package]
|
||||
version = "1.815.0"
|
||||
version = "1.817.0"
|
||||
edition = "2021"
|
||||
authors = ["Ruben Fiszel <ruben@windmill.dev>"]
|
||||
|
||||
|
||||
@@ -0,0 +1,402 @@
|
||||
use serde_json::{json, Map, Value};
|
||||
|
||||
use crate::{MainArgSignature, ObjectProperty, Typ};
|
||||
|
||||
/// The run-form schema of a script's `main`, derived the way the editor derives it
|
||||
/// (`inferArgs` in the frontend, `argSigToJsonSchemaType` in `windmill-utils-internal`),
|
||||
/// so a script deployed without a schema gets the one the editor would have sent.
|
||||
/// `previous` is the schema of the version being superseded: as in the editor, it is
|
||||
/// updated in place, so what the user set on it (argument order, descriptions, ...)
|
||||
/// survives wherever the code still agrees with it.
|
||||
pub fn main_arg_schema(sig: &MainArgSignature, previous: Option<&Value>) -> Value {
|
||||
let mut schema = match previous {
|
||||
Some(Value::Object(p)) => p.clone(),
|
||||
_ => json!({
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"type": "object",
|
||||
})
|
||||
.as_object()
|
||||
.cloned()
|
||||
.unwrap_or_default(),
|
||||
};
|
||||
let mut old_props = match schema.remove("properties") {
|
||||
Some(Value::Object(p)) => p,
|
||||
_ => Map::new(),
|
||||
};
|
||||
let mut properties = Map::new();
|
||||
let mut required = vec![];
|
||||
for arg in &sig.args {
|
||||
let mut prop = match old_props.remove(&arg.name) {
|
||||
Some(Value::Object(p)) => p,
|
||||
_ => fresh_property(),
|
||||
};
|
||||
if !matches!(arg.typ, Typ::OneOf(_)) {
|
||||
prop.remove("oneOf");
|
||||
}
|
||||
apply_typ(&arg.typ, &mut prop);
|
||||
// `T | T[]` marks an argument debouncing accumulates into.
|
||||
if let Some(otyp) = arg
|
||||
.otyp
|
||||
.as_deref()
|
||||
.filter(|o| o.contains('[') && o.contains('|'))
|
||||
{
|
||||
prop.insert("originalType".into(), json!(otyp));
|
||||
}
|
||||
match &arg.default {
|
||||
Some(d) => prop.insert("default".into(), d.clone()),
|
||||
None => prop.remove("default"),
|
||||
};
|
||||
if !arg.has_default {
|
||||
required.push(json!(arg.name));
|
||||
}
|
||||
properties.insert(arg.name.clone(), Value::Object(prop));
|
||||
}
|
||||
schema.insert("properties".into(), Value::Object(properties));
|
||||
schema.insert("required".into(), Value::Array(required));
|
||||
if sig.has_cmd_binding == Some(true) {
|
||||
schema.insert("x-windmill-ps-cmd-binding".into(), json!(true));
|
||||
schema.insert(
|
||||
"x-windmill-ps-supports-should-process".into(),
|
||||
json!(sig.supports_should_process.unwrap_or(false)),
|
||||
);
|
||||
} else {
|
||||
schema.remove("x-windmill-ps-cmd-binding");
|
||||
schema.remove("x-windmill-ps-supports-should-process");
|
||||
}
|
||||
Value::Object(schema)
|
||||
}
|
||||
|
||||
/// Keys a user sets on a property in the schema editor, kept whatever the code says.
|
||||
const USER_FIELDS: &[&str] = &[
|
||||
"description",
|
||||
"pattern",
|
||||
"min",
|
||||
"max",
|
||||
"currency",
|
||||
"currencyLocale",
|
||||
"multiselect",
|
||||
"customErrorMessage",
|
||||
"required",
|
||||
"showExpr",
|
||||
"password",
|
||||
"order",
|
||||
"dateFormat",
|
||||
"title",
|
||||
"placeholder",
|
||||
];
|
||||
|
||||
/// Item keys a list the parser cannot see into keeps from the previous schema.
|
||||
const ITEMS_USER_FIELDS: &[&str] = &[
|
||||
"properties",
|
||||
"required",
|
||||
"additionalProperties",
|
||||
"enum",
|
||||
"resourceType",
|
||||
"contentEncoding",
|
||||
"description",
|
||||
];
|
||||
|
||||
fn fresh_property() -> Map<String, Value> {
|
||||
let mut p = Map::new();
|
||||
p.insert("description".into(), json!(""));
|
||||
p.insert("type".into(), json!(""));
|
||||
p
|
||||
}
|
||||
|
||||
fn object_properties(
|
||||
props: &[ObjectProperty],
|
||||
old: Option<&Map<String, Value>>,
|
||||
) -> Map<String, Value> {
|
||||
props
|
||||
.iter()
|
||||
.map(|p| {
|
||||
let mut prop = old
|
||||
.and_then(|o| o.get(&p.key))
|
||||
.and_then(Value::as_object)
|
||||
.cloned()
|
||||
.unwrap_or_else(fresh_property);
|
||||
apply_typ(&p.typ, &mut prop);
|
||||
(p.key.clone(), Value::Object(prop))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn kept_items(old: &Map<String, Value>) -> Value {
|
||||
let old_items = old.get("items").and_then(Value::as_object);
|
||||
let mut items = Map::new();
|
||||
items.insert(
|
||||
"type".into(),
|
||||
old_items
|
||||
.and_then(|i| i.get("type"))
|
||||
.filter(|t| t.as_str().is_some_and(|t| !t.is_empty()))
|
||||
.cloned()
|
||||
.unwrap_or(json!("object")),
|
||||
);
|
||||
if let Some(old_items) = old_items {
|
||||
for f in ITEMS_USER_FIELDS {
|
||||
if let Some(v) = old_items.get(*f) {
|
||||
items.insert(f.to_string(), v.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
Value::Object(items)
|
||||
}
|
||||
|
||||
/// Port of `argSigToJsonSchemaType`: rewrites `old` in place to carry `typ`.
|
||||
fn apply_typ(typ: &Typ, old: &mut Map<String, Value>) {
|
||||
let mut new = Map::new();
|
||||
// Keys the editor sets to `undefined`, which drops them from the stored JSON.
|
||||
let mut unset: Vec<&str> = vec![];
|
||||
let mut set = |k: &str, v: Value| {
|
||||
new.insert(k.to_string(), v);
|
||||
};
|
||||
match typ {
|
||||
Typ::Int => set("type", json!("integer")),
|
||||
Typ::Float => set("type", json!("number")),
|
||||
Typ::Bool => set("type", json!("boolean")),
|
||||
Typ::Email => {
|
||||
set("type", json!("string"));
|
||||
set("format", json!("email"));
|
||||
}
|
||||
Typ::Sql => {
|
||||
set("type", json!("string"));
|
||||
set("format", json!("sql"));
|
||||
}
|
||||
Typ::Bytes => {
|
||||
set("type", json!("string"));
|
||||
set("contentEncoding", json!("base64"));
|
||||
set("originalType", json!("bytes"));
|
||||
}
|
||||
Typ::Datetime => {
|
||||
set("type", json!("string"));
|
||||
set("format", json!("date-time"));
|
||||
}
|
||||
Typ::Date => {
|
||||
set("type", json!("string"));
|
||||
set("format", json!("date"));
|
||||
}
|
||||
Typ::OneOf(variants) => {
|
||||
set("type", json!("object"));
|
||||
let old_variants = old.get("oneOf").and_then(Value::as_array);
|
||||
let one_of: Vec<Value> = variants
|
||||
.iter()
|
||||
.map(|v| {
|
||||
let old_variant = old_variants
|
||||
.and_then(|o| o.iter().find(|o| o.get("title") == Some(&json!(v.label))))
|
||||
.and_then(Value::as_object);
|
||||
let old_props = old_variant
|
||||
.and_then(|o| o.get("properties"))
|
||||
.and_then(Value::as_object);
|
||||
let mut variant = json!({
|
||||
"type": "object",
|
||||
"title": v.label,
|
||||
"properties": object_properties(&v.properties, old_props),
|
||||
});
|
||||
if let Some(order) = old_variant.and_then(|o| o.get("order")) {
|
||||
variant["order"] = order.clone();
|
||||
}
|
||||
variant
|
||||
})
|
||||
.collect();
|
||||
set("oneOf", Value::Array(one_of));
|
||||
}
|
||||
Typ::Object(o) => {
|
||||
set("type", json!("object"));
|
||||
if let Some(name) = &o.name {
|
||||
set("format", json!(format!("resource-{name}")));
|
||||
}
|
||||
if let Some(props) = &o.props {
|
||||
let old_props = old.get("properties").and_then(Value::as_object);
|
||||
set(
|
||||
"properties",
|
||||
Value::Object(object_properties(props, old_props)),
|
||||
);
|
||||
}
|
||||
}
|
||||
Typ::Str(variants) => {
|
||||
set("type", json!("string"));
|
||||
match variants {
|
||||
Some(v) => {
|
||||
set("originalType", json!("enum"));
|
||||
set("enum", json!(v));
|
||||
}
|
||||
None => {
|
||||
set("originalType", json!("string"));
|
||||
match old.get("enum") {
|
||||
Some(e) if old.get("originalType") == Some(&json!("string")) => {
|
||||
set("enum", e.clone())
|
||||
}
|
||||
_ => unset.push("enum"),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Typ::Resource(name) => {
|
||||
set("type", json!("object"));
|
||||
set("format", json!(format!("resource-{name}")));
|
||||
}
|
||||
Typ::DynSelect(name) => {
|
||||
set("type", json!("object"));
|
||||
set("format", json!(format!("dynselect-{name}")));
|
||||
}
|
||||
Typ::DynMultiselect(name) => {
|
||||
set("type", json!("object"));
|
||||
set("format", json!(format!("dynmultiselect-{name}")));
|
||||
}
|
||||
Typ::List(inner) => {
|
||||
set("type", json!("array"));
|
||||
let (items, original_type) = match inner.as_ref() {
|
||||
Typ::Int | Typ::Float => (json!({"type": "number"}), "number[]"),
|
||||
Typ::Bytes => (
|
||||
json!({"type": "string", "contentEncoding": "base64"}),
|
||||
"bytes[]",
|
||||
),
|
||||
Typ::Str(Some(v)) => (json!({"type": "string", "enum": v}), "enum[]"),
|
||||
Typ::Str(None) => {
|
||||
let mut items = json!({"type": "string"});
|
||||
if let Some(e) = old.get("items").and_then(|i| i.get("enum")) {
|
||||
items["enum"] = e.clone();
|
||||
}
|
||||
(items, "string[]")
|
||||
}
|
||||
Typ::Resource(r) => (json!({"type": "resource", "resourceType": r}), "resource[]"),
|
||||
Typ::Object(o) => {
|
||||
if let Some(name) = &o.name {
|
||||
set("format", json!(format!("resource-{name}")));
|
||||
}
|
||||
let items = match o.props.as_deref().filter(|p| !p.is_empty()) {
|
||||
Some(props) => json!({
|
||||
"type": "object",
|
||||
"properties": object_properties(props, None),
|
||||
}),
|
||||
None => kept_items(old),
|
||||
};
|
||||
(items, "record[]")
|
||||
}
|
||||
_ => (kept_items(old), "object[]"),
|
||||
};
|
||||
set("items", items);
|
||||
set("originalType", json!(original_type));
|
||||
}
|
||||
// The parser cannot tell: whatever type the user gave the argument stands.
|
||||
Typ::Unknown => set("type", old.get("type").cloned().unwrap_or(json!("object"))),
|
||||
}
|
||||
|
||||
for f in USER_FIELDS {
|
||||
if let Some(v) = old.get(*f) {
|
||||
new.insert(f.to_string(), v.clone());
|
||||
}
|
||||
}
|
||||
|
||||
let old_type = old.get("type").cloned();
|
||||
let new_type = new.get("type").cloned();
|
||||
let old_format = old
|
||||
.get("format")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::to_string);
|
||||
let new_format = new
|
||||
.get("format")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::to_string);
|
||||
let is_date = |f: &Option<String>| matches!(f.as_deref(), Some("date" | "date-time"));
|
||||
let mut keep_old_format = false;
|
||||
// A changed type takes every key `new` sets, so none of the adjustments below
|
||||
// apply: running them would carry the old type's date format or items over.
|
||||
if old_type != new_type {
|
||||
} else if new_format.as_deref() == Some("date-time") && old_format.as_deref() == Some("date") {
|
||||
new.insert("format".into(), json!("date"));
|
||||
} else if is_date(&new_format) {
|
||||
keep_old_format = true;
|
||||
} else if old.get("items").and_then(|i| i.get("type"))
|
||||
!= new.get("items").and_then(|i| i.get("type"))
|
||||
{
|
||||
old.remove("items");
|
||||
} else if old_type == Some(json!("string")) && old_format.is_some() {
|
||||
keep_old_format = true;
|
||||
}
|
||||
if keep_old_format {
|
||||
match old.get("format") {
|
||||
Some(f) => {
|
||||
new.insert("format".into(), f.clone());
|
||||
}
|
||||
None => {
|
||||
new.remove("format");
|
||||
unset.push("format");
|
||||
}
|
||||
}
|
||||
}
|
||||
if (old_type != new_type || new_type != Some(json!("string"))) && !new.contains_key("format") {
|
||||
old.remove("format");
|
||||
}
|
||||
|
||||
for k in unset {
|
||||
if !new.contains_key(k) {
|
||||
old.remove(k);
|
||||
}
|
||||
}
|
||||
old.extend(new);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::{Arg, ObjectType};
|
||||
|
||||
fn arg(name: &str, typ: Typ, default: Option<Value>) -> Arg {
|
||||
Arg {
|
||||
name: name.to_string(),
|
||||
has_default: default.is_some(),
|
||||
default,
|
||||
typ,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keeps_what_the_user_set_on_the_previous_version() {
|
||||
let sig = MainArgSignature {
|
||||
args: vec![
|
||||
arg("n", Typ::Int, None),
|
||||
arg("db", Typ::Resource("postgresql".into()), None),
|
||||
arg(
|
||||
"tags",
|
||||
Typ::List(Box::new(Typ::Str(None))),
|
||||
Some(json!(["a"])),
|
||||
),
|
||||
arg(
|
||||
"opts",
|
||||
Typ::Object(ObjectType::new(None, None)),
|
||||
Some(json!({})),
|
||||
),
|
||||
arg("any", Typ::Unknown, None),
|
||||
],
|
||||
..Default::default()
|
||||
};
|
||||
let previous = json!({
|
||||
"type": "object",
|
||||
"order": ["db", "n"],
|
||||
"properties": {
|
||||
"n": {"type": "integer", "description": "how many", "min": 1, "nullable": true},
|
||||
"tags": {"type": "array", "items": {"type": "string", "enum": ["a", "b"]}},
|
||||
"gone": {"type": "string"},
|
||||
},
|
||||
});
|
||||
let schema = main_arg_schema(&sig, Some(&previous));
|
||||
assert_eq!(schema["order"], json!(["db", "n"]));
|
||||
assert_eq!(schema["required"], json!(["n", "db", "any"]));
|
||||
assert_eq!(
|
||||
schema["properties"]["n"],
|
||||
json!({"type": "integer", "description": "how many", "min": 1, "nullable": true})
|
||||
);
|
||||
assert!(schema["properties"].get("gone").is_none());
|
||||
assert_eq!(schema["properties"]["db"]["format"], "resource-postgresql");
|
||||
assert_eq!(
|
||||
schema["properties"]["tags"]["items"],
|
||||
json!({"type": "string", "enum": ["a", "b"]})
|
||||
);
|
||||
assert_eq!(schema["properties"]["tags"]["default"], json!(["a"]));
|
||||
assert_eq!(schema["properties"]["opts"]["type"], "object");
|
||||
// Untyped in the code and new: any JSON value, as the editor stores it.
|
||||
assert_eq!(schema["properties"]["any"]["type"], "");
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@ use serde_json::Value;
|
||||
pub mod asset_parser;
|
||||
pub mod duckdb_builtins;
|
||||
pub mod duckdb_macros;
|
||||
pub mod json_schema;
|
||||
pub mod sql_materialize;
|
||||
|
||||
/// S3 output format for SQL queries (moved here to avoid pulling sqlx into WASM via windmill-types)
|
||||
|
||||
+5
-5
@@ -1944,13 +1944,13 @@ async fn process_notify_event(
|
||||
#[cfg(feature = "http_trigger")]
|
||||
"notify_http_trigger_change" => {
|
||||
tracing::info!("HTTP trigger change detected: {}", payload);
|
||||
match windmill_api::triggers::http::refresh_routers(db, true).await {
|
||||
Ok(_) => {
|
||||
match windmill_api::triggers::http::refresh_loaded_routers(db, true).await {
|
||||
Ok(true) => {
|
||||
tracing::info!("Refreshed HTTP routers (trigger change)");
|
||||
}
|
||||
Ok(false) => {}
|
||||
Err(err) => {
|
||||
tracing::error!("Error refreshing HTTP routers (trigger change): {err:#}");
|
||||
windmill_api::triggers::http::invalidate_routers();
|
||||
return false;
|
||||
}
|
||||
};
|
||||
@@ -2207,8 +2207,8 @@ async fn process_notify_event(
|
||||
tracing::error!(error = %e, "Could not reload http route workspaced route setting");
|
||||
}
|
||||
#[cfg(feature = "http_trigger")]
|
||||
match windmill_api::triggers::http::refresh_routers(db, false).await {
|
||||
Ok((true, _)) => {
|
||||
match windmill_api::triggers::http::refresh_loaded_routers(db, false).await {
|
||||
Ok(true) => {
|
||||
tracing::info!(
|
||||
"Refreshed HTTP routers (http workspaced route setting change)"
|
||||
);
|
||||
|
||||
@@ -171,6 +171,9 @@ postgres_trigger: path(char), script_path(char), is_flow(bool), workspace_id(cha
|
||||
FK: (workspace_id) -> workspace(id)
|
||||
raw_app: path(char), version(int), workspace_id(char), summary(char), edited_at(ts), data(text), extra_perms(jsonb), labels(text[])
|
||||
FK: (workspace_id) -> workspace(id)
|
||||
remote_deploy_token: workspace_id(char), email(char), base_url(char), remote_workspace_id(char), token(text), remote_email(char), proxy_key(char), connected_at(timestamptz), member_since(timestamptz), target_changed_at(timestamptz)
|
||||
FK: (email) -> password(email)
|
||||
FK: (workspace_id) -> workspace(id)
|
||||
resource: workspace_id(char), path(char), value(jsonb), description(text), resource_type(char), extra_perms(jsonb), edited_at(ts), created_by(char), labels(text[])
|
||||
FK: (workspace_id) -> workspace(id)
|
||||
resource_type: workspace_id(char), name(char), schema(jsonb), description(text), edited_at(ts), created_by(char), format_extension(char), is_fileset(bool), display_name(char)
|
||||
@@ -234,7 +237,7 @@ workspace_protection_rule: workspace_id(char), name(char), rules(int), bypass_gr
|
||||
FK: (workspace_id) -> workspace(id)
|
||||
workspace_runnable_dependencies: flow_path(char), runnable_path(char), script_hash(bigint), runnable_is_flow(bool), workspace_id(char), app_path(char), id(bigint), runnable_is_agent(bool)
|
||||
FK: (app_path, workspace_id) -> app(path, workspace_id) | (flow_path, workspace_id) -> flow(path, workspace_id)
|
||||
workspace_settings: workspace_id(char), slack_team_id(char), slack_name(char), slack_command_script(char), slack_email(char), customer_id(char), plan(char), webhook(text), ai_config(jsonb), large_file_storage(jsonb), git_sync(jsonb), default_app(char), default_scripts(jsonb), deploy_ui(jsonb), mute_critical_alerts(bool), color(char), operator_settings(jsonb), teams_command_script(text), teams_team_id(text), teams_team_name(text), git_app_installations(jsonb), ducklake(jsonb), slack_oauth_client_id(char), slack_oauth_client_secret(char), datatable(jsonb), teams_team_guid(text), auto_invite(jsonb), error_handler(jsonb), success_handler(jsonb), public_app_execution_limit_per_minute(int), dbt_warehouses(jsonb), guest_access_enabled(bool), guest_jwt_public_key(text), guest_jwt_jwks_url(text), ai_sessions_backup_generation(int), add_admins_and_developers_to_forks(bool)
|
||||
workspace_settings: workspace_id(char), slack_team_id(char), slack_name(char), slack_command_script(char), slack_email(char), customer_id(char), plan(char), webhook(text), ai_config(jsonb), large_file_storage(jsonb), git_sync(jsonb), default_app(char), default_scripts(jsonb), deploy_ui(jsonb), mute_critical_alerts(bool), color(char), operator_settings(jsonb), teams_command_script(text), teams_team_id(text), teams_team_name(text), git_app_installations(jsonb), ducklake(jsonb), slack_oauth_client_id(char), slack_oauth_client_secret(char), datatable(jsonb), teams_team_guid(text), auto_invite(jsonb), error_handler(jsonb), success_handler(jsonb), public_app_execution_limit_per_minute(int), dbt_warehouses(jsonb), guest_access_enabled(bool), guest_jwt_public_key(text), guest_jwt_jwks_url(text), ai_sessions_backup_generation(int), add_admins_and_developers_to_forks(bool), remote_deploy_target(jsonb), remote_deploy_target_changed_at(ts)
|
||||
FK: (workspace_id) -> workspace(id)
|
||||
zombie_job_counter: job_id(uuid), counter(int)
|
||||
FK: (job_id) -> v2_job(id)
|
||||
|
||||
@@ -577,24 +577,13 @@ async fn guests_mode_needs_a_scopable_path(db: Pool<Postgres>) -> anyhow::Result
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(resp.status(), 400, "renamed to a `,` path while in Guests");
|
||||
let resp = authed(
|
||||
client().post(format!("{ws}/apps/update/{APP_PATH}")),
|
||||
ADMIN_TOKEN,
|
||||
)
|
||||
.json(&json!({ "path": "u/test-user/My App" }))
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
200,
|
||||
"a space is literal: {}",
|
||||
resp.text().await?
|
||||
);
|
||||
|
||||
// Set on update: an app that already sits on such a path cannot be switched.
|
||||
// Set on update: an app that already sits on such a path cannot be switched. Path
|
||||
// validation now refuses these paths on every write, so plant the kind of app saved
|
||||
// before it did.
|
||||
let resp = authed(client().post(format!("{ws}/apps/create")), ADMIN_TOKEN)
|
||||
.json(&json!({
|
||||
"path": "u/test-user/x:y",
|
||||
"path": "u/test-user/legacy",
|
||||
"summary": "App",
|
||||
"value": {},
|
||||
"policy": { "execution_mode": "publisher", "triggerables_v2": {} }
|
||||
@@ -602,6 +591,12 @@ async fn guests_mode_needs_a_scopable_path(db: Pool<Postgres>) -> anyhow::Result
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(resp.status(), 201, "{}", resp.text().await?);
|
||||
sqlx::query(
|
||||
"UPDATE app SET path = 'u/test-user/x:y'
|
||||
WHERE workspace_id = 'test-workspace' AND path = 'u/test-user/legacy'",
|
||||
)
|
||||
.execute(&db)
|
||||
.await?;
|
||||
let resp = authed(
|
||||
client().post(format!("{ws}/apps/update/u/test-user/x:y")),
|
||||
ADMIN_TOKEN,
|
||||
@@ -610,6 +605,8 @@ async fn guests_mode_needs_a_scopable_path(db: Pool<Postgres>) -> anyhow::Result
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(resp.status(), 400, "switched to Guests on a `:` path");
|
||||
let body = resp.text().await?;
|
||||
assert!(body.contains("cannot be set to Guests"), "{body}");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1303,6 +1303,62 @@ async fn test_bun_wac_inline_task_cache_is_per_task(db: Pool<Postgres>) -> anyho
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A task that picks its own worker tag is held to CUSTOM_TAGS like a flow step: with none
|
||||
/// set, only a superadmin may send it to `gpu`, so the workflow fails instead of queueing it.
|
||||
#[sqlx::test(fixtures("base"))]
|
||||
async fn test_bun_wac_task_tag_outside_custom_tags(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
initialize_tracing().await;
|
||||
let server = ApiServer::start(db.clone()).await?;
|
||||
let port = server.addr.port();
|
||||
|
||||
let content = r#"
|
||||
import { workflow, task } from "windmill-client";
|
||||
|
||||
const onGpu = task(async (n: number) => {
|
||||
return n * 2;
|
||||
}, { tag: "gpu" });
|
||||
|
||||
export const main = workflow(async (n: number) => {
|
||||
return await onGpu(n);
|
||||
});
|
||||
"#
|
||||
.to_owned();
|
||||
|
||||
let job = RunJob::from(JobPayload::Code(RawCode {
|
||||
hash: None,
|
||||
content,
|
||||
path: None,
|
||||
language: ScriptLang::Bun,
|
||||
lock: None,
|
||||
concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default()
|
||||
.into(),
|
||||
debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(),
|
||||
cache_ttl: None,
|
||||
cache_ignore_s3_path: None,
|
||||
dedicated_worker: None,
|
||||
modules: None,
|
||||
tag: None,
|
||||
}))
|
||||
.arg("n", serde_json::json!(5))
|
||||
.as_user("test-user-2", "test2@windmill.dev")
|
||||
.run_until_complete(&db, false, port)
|
||||
.await;
|
||||
|
||||
assert!(!job.success, "the workflow must fail");
|
||||
let result = job.json_result().unwrap();
|
||||
let message = result["error"]["message"].as_str().unwrap_or_default();
|
||||
assert!(
|
||||
message.contains("task 'onGpu' cannot run on tag 'gpu'"),
|
||||
"unexpected error: {result}"
|
||||
);
|
||||
let children: i64 = sqlx::query_scalar("SELECT count(*) FROM v2_job WHERE parent_job = $1")
|
||||
.bind(job.id)
|
||||
.fetch_one(&db)
|
||||
.await?;
|
||||
assert_eq!(children, 0, "no child is queued on the refused tag");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Environment Variable Tests
|
||||
// ============================================================================
|
||||
|
||||
@@ -2877,6 +2877,131 @@ async fn test_flow_env_imported_flow_with_nested_branch(db: Pool<Postgres>) -> a
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// A branch/loop sub-flow of a deployed flow is marked at push when its parent has no flow_env,
|
||||
// own or inherited, so its worker skips `fetch_root_flow_env`. With an env two scopes up (root
|
||||
// → loop → imported flow without env → parallel loop → leaf) nothing may be marked and the
|
||||
// root's value must reach the leaf; with no env anywhere every loop iteration is marked.
|
||||
#[cfg(feature = "deno_core")]
|
||||
#[sqlx::test(fixtures("base"))]
|
||||
async fn test_flow_env_marks_sub_flows_only_without_ancestor_env(
|
||||
db: Pool<Postgres>,
|
||||
) -> anyhow::Result<()> {
|
||||
initialize_tracing().await;
|
||||
let server = ApiServer::start(db.clone()).await?;
|
||||
let port = server.addr.port();
|
||||
|
||||
// Deployed, so their loops run as `FlowNode` sub-flows.
|
||||
let deploy = |path: &'static str, version: i64, value: serde_json::Value| {
|
||||
let db = db.clone();
|
||||
async move {
|
||||
sqlx::query(
|
||||
"INSERT INTO flow(workspace_id, summary, description, path, versions, schema, value, edited_by) VALUES ('test-workspace', '', '', $1, ARRAY[$2]::bigint[], '{}'::jsonb, $3, 'system')",
|
||||
)
|
||||
.bind(path)
|
||||
.bind(version)
|
||||
.bind(&value)
|
||||
.execute(&db)
|
||||
.await?;
|
||||
sqlx::query(
|
||||
"INSERT INTO flow_version(id, workspace_id, path, schema, value, created_by) VALUES ($1, 'test-workspace', $2, '{}'::jsonb, $3, 'system')",
|
||||
)
|
||||
.bind(version)
|
||||
.bind(path)
|
||||
.bind(&value)
|
||||
.execute(&db)
|
||||
.await?;
|
||||
RunJob::from(JobPayload::FlowDependencies {
|
||||
path: path.to_string(),
|
||||
dedicated_worker: None,
|
||||
version,
|
||||
debouncing_settings: Default::default(),
|
||||
})
|
||||
.run_until_complete(&db, false, port)
|
||||
.await;
|
||||
anyhow::Ok(())
|
||||
}
|
||||
};
|
||||
deploy(
|
||||
"f/system/imported_loop_no_env",
|
||||
9991004,
|
||||
json!({ "modules": [{ "id": "inner", "value": {
|
||||
"type": "forloopflow",
|
||||
"iterator": { "type": "javascript", "expr": "[1, 2]" },
|
||||
"skip_failures": false,
|
||||
"parallel": true,
|
||||
"modules": [{ "id": "leaf", "value": {
|
||||
"type": "rawscript",
|
||||
"language": "deno",
|
||||
"content": "export function main(key: any) { return key ?? null; }",
|
||||
"input_transforms": { "key": { "type": "javascript", "expr": "flow_env.KEY" } }
|
||||
}}, { "id": "tail", "value": {
|
||||
// A single simple step is inlined rather than stored as a flow node.
|
||||
"type": "rawscript",
|
||||
"language": "deno",
|
||||
"content": "export function main(prev: any) { return prev; }",
|
||||
"input_transforms": { "prev": { "type": "javascript", "expr": "results.leaf" } }
|
||||
}}]
|
||||
}}]}),
|
||||
)
|
||||
.await?;
|
||||
let root = |flow_env: serde_json::Value| {
|
||||
json!({ "flow_env": flow_env, "modules": [{ "id": "outer", "value": {
|
||||
"type": "forloopflow",
|
||||
"iterator": { "type": "javascript", "expr": "[1]" },
|
||||
"skip_failures": false,
|
||||
"parallel": false,
|
||||
"modules": [{ "id": "import", "value": {
|
||||
"type": "flow",
|
||||
"path": "f/system/imported_loop_no_env",
|
||||
"input_transforms": {}
|
||||
}}]
|
||||
}}]})
|
||||
};
|
||||
deploy(
|
||||
"f/system/root_with_env",
|
||||
9991005,
|
||||
root(json!({ "KEY": "from-root" })),
|
||||
)
|
||||
.await?;
|
||||
deploy("f/system/root_no_env", 9991006, root(json!(null))).await?;
|
||||
|
||||
let run = |path: &'static str, version: i64| {
|
||||
let db = db.clone();
|
||||
async move {
|
||||
let completed = RunJob::from(JobPayload::Flow {
|
||||
path: path.to_string(),
|
||||
dedicated_worker: None,
|
||||
apply_preprocessor: false,
|
||||
version,
|
||||
labels: None,
|
||||
})
|
||||
.run_until_complete(&db, false, port)
|
||||
.await;
|
||||
// The mark of every loop iteration pushed under the run.
|
||||
let marks = sqlx::query_as::<_, (String, Option<bool>)>(
|
||||
"SELECT j.kind::text, (c.flow_status->>'no_inherited_flow_env')::bool
|
||||
FROM v2_job j JOIN v2_job_completed c ON c.id = j.id
|
||||
WHERE COALESCE(j.root_job, j.parent_job) = $1
|
||||
AND j.flow_step_id IN ('outer', 'inner')",
|
||||
)
|
||||
.bind(completed.id)
|
||||
.fetch_all(&db)
|
||||
.await?;
|
||||
anyhow::Ok((completed.json_result().unwrap(), marks))
|
||||
}
|
||||
};
|
||||
|
||||
let (result, marks) = run("f/system/root_with_env", 9991005).await?;
|
||||
assert_eq!(result, json!([["from-root", "from-root"]]));
|
||||
assert_eq!(marks, vec![("flownode".to_string(), None); 3]);
|
||||
|
||||
let (result, marks) = run("f/system/root_no_env", 9991006).await?;
|
||||
assert_eq!(result, json!([[null, null]]));
|
||||
assert_eq!(marks, vec![("flownode".to_string(), Some(true)); 3]);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// stop_after_if predicate sees flow_env. Regression for the eval at line 614
|
||||
// of `update_flow_status_after_job_completion_internal` which used to pass
|
||||
// `None` for flow_env unconditionally.
|
||||
|
||||
@@ -1608,3 +1608,34 @@ async fn declarative_sync_rejects_an_unusable_default_allowed_origins(db: Pool<P
|
||||
.await
|
||||
.expect("a valid origin list must sync");
|
||||
}
|
||||
|
||||
/// The accent color is interpolated into a stylesheet every user loads, so the operator
|
||||
/// path must refuse anything but `#rrggbb` just like the settings API does.
|
||||
#[sqlx::test(fixtures("base"))]
|
||||
async fn declarative_sync_rejects_a_non_hex_accent_color(db: Pool<Postgres>) {
|
||||
clear_settings_and_configs(&db).await;
|
||||
|
||||
let mut desired = BTreeMap::new();
|
||||
desired.insert(
|
||||
"accent_color".to_string(),
|
||||
serde_json::json!("#000;}body{display:none"),
|
||||
);
|
||||
let err = windmill_common::instance_config::sync_global_settings_declarative(
|
||||
&db,
|
||||
&BTreeMap::new(),
|
||||
&desired,
|
||||
)
|
||||
.await
|
||||
.expect_err("a non-hex accent color must fail the sync");
|
||||
assert!(err.to_string().contains("accent_color"), "got: {err}");
|
||||
assert!(get_global_setting(&db, "accent_color").await.is_none());
|
||||
|
||||
desired.insert("accent_color".to_string(), serde_json::json!("#1f9d55"));
|
||||
windmill_common::instance_config::sync_global_settings_declarative(
|
||||
&db,
|
||||
&BTreeMap::new(),
|
||||
&desired,
|
||||
)
|
||||
.await
|
||||
.expect("a hex accent color must sync");
|
||||
}
|
||||
|
||||
@@ -239,6 +239,7 @@ mod job_payload {
|
||||
let result = RunJob::from(JobPayload::FlowNode {
|
||||
id,
|
||||
path: "f/system/hello_with_nodes_flow/forloop-0".into(),
|
||||
no_inherited_flow_env: false,
|
||||
})
|
||||
.arg("iter", json!({ "value": "tests", "index": 0 }))
|
||||
.run_until_complete(&db, false, port)
|
||||
|
||||
@@ -516,10 +516,14 @@ async fn test_single_job_read_authorization(db: Pool<Postgres>) -> anyhow::Resul
|
||||
Some("SECRET_TOKEN_2"),
|
||||
)
|
||||
.await;
|
||||
assert!(status.is_success(), "owner must mint a resume secret: {secret}");
|
||||
assert!(
|
||||
status.is_success(),
|
||||
"owner must mint a resume secret: {secret}"
|
||||
);
|
||||
let secret = secret.trim().trim_matches('"').to_string();
|
||||
let approval_result =
|
||||
format!("completed/get_result/{STEP_JOB}?suspended_job={STEP_JOB}&resume_id=0&secret={secret}");
|
||||
let approval_result = format!(
|
||||
"completed/get_result/{STEP_JOB}?suspended_job={STEP_JOB}&resume_id=0&secret={secret}"
|
||||
);
|
||||
let (status, body) = get(&base, &approval_result, None).await;
|
||||
assert!(
|
||||
status.is_success(),
|
||||
@@ -991,6 +995,89 @@ async fn test_single_job_read_authorization(db: Pool<Postgres>) -> anyhow::Resul
|
||||
!status.is_success(),
|
||||
"a public token must not let an anonymous caller cancel the run (got {status}): {body}"
|
||||
);
|
||||
// ---- RUN_NOW is gated like cancel, and only moves a job still waiting for a
|
||||
// future start. ----
|
||||
let run_now = format!("queue/run_now/{RUNNING_JOB}");
|
||||
let (status, body) = post(&authed_base, &run_now, Some("SECRET_TOKEN_3")).await;
|
||||
assert_eq!(
|
||||
status,
|
||||
reqwest::StatusCode::FORBIDDEN,
|
||||
"viewer must not start another user's job early (got {status}): {body}"
|
||||
);
|
||||
let (status, body) = post(&authed_base, &run_now, Some("SECRET_TOKEN_2")).await;
|
||||
assert_eq!(
|
||||
status,
|
||||
reqwest::StatusCode::BAD_REQUEST,
|
||||
"a running job has no future start to move (got {status}): {body}"
|
||||
);
|
||||
sqlx::query(
|
||||
"UPDATE v2_job_queue SET running = false, scheduled_for = now() + interval '1 hour'
|
||||
WHERE id = $1::uuid",
|
||||
)
|
||||
.bind(RUNNING_JOB)
|
||||
.execute(&db)
|
||||
.await?;
|
||||
let (status, body) = post(&authed_base, &run_now, Some("SECRET_TOKEN_2")).await;
|
||||
assert!(
|
||||
status.is_success(),
|
||||
"owner must start their deferred job now (got {status}): {body}"
|
||||
);
|
||||
let due: bool =
|
||||
sqlx::query_scalar("SELECT scheduled_for <= now() FROM v2_job_queue WHERE id = $1::uuid")
|
||||
.bind(RUNNING_JOB)
|
||||
.fetch_one(&db)
|
||||
.await?;
|
||||
assert!(due, "run_now must make the job due immediately");
|
||||
|
||||
// A schedule tick that is not due yet is refused: the schedule would queue that same
|
||||
// tick again on completion and run twice.
|
||||
sqlx::query(
|
||||
"INSERT INTO schedule (workspace_id, path, edited_by, schedule, script_path, permissioned_as)
|
||||
VALUES ('test-workspace', 'u/test-user-2/daily', 'test-user-2', '0 0 3 * * *',
|
||||
'u/test-user-2/running_secret', 'u/test-user-2')",
|
||||
)
|
||||
.execute(&db)
|
||||
.await?;
|
||||
sqlx::query(
|
||||
"UPDATE v2_job SET trigger_kind = 'schedule', trigger = 'u/test-user-2/daily'
|
||||
WHERE id = $1::uuid",
|
||||
)
|
||||
.bind(RUNNING_JOB)
|
||||
.execute(&db)
|
||||
.await?;
|
||||
sqlx::query(
|
||||
"UPDATE v2_job_queue SET scheduled_for =
|
||||
(date_trunc('day', now() AT TIME ZONE 'UTC') + interval '1 day 3 hours') AT TIME ZONE 'UTC'
|
||||
WHERE id = $1::uuid",
|
||||
)
|
||||
.bind(RUNNING_JOB)
|
||||
.execute(&db)
|
||||
.await?;
|
||||
let (status, body) = post(&authed_base, &run_now, Some("SECRET_TOKEN_2")).await;
|
||||
assert_eq!(
|
||||
status,
|
||||
reqwest::StatusCode::BAD_REQUEST,
|
||||
"an upcoming schedule tick must not be started early (got {status}): {body}"
|
||||
);
|
||||
// A tick a concurrency limit pushed past its time is off the cron occurrences and
|
||||
// may still be started early.
|
||||
sqlx::query(
|
||||
"UPDATE v2_job_queue SET scheduled_for = now() + interval '1 hour 37 seconds 123 milliseconds'
|
||||
WHERE id = $1::uuid",
|
||||
)
|
||||
.bind(RUNNING_JOB)
|
||||
.execute(&db)
|
||||
.await?;
|
||||
let (status, body) = post(&authed_base, &run_now, Some("SECRET_TOKEN_2")).await;
|
||||
assert!(
|
||||
status.is_success(),
|
||||
"a deferred schedule tick must still start now (got {status}): {body}"
|
||||
);
|
||||
sqlx::query("UPDATE v2_job SET trigger_kind = NULL, trigger = NULL WHERE id = $1::uuid")
|
||||
.bind(RUNNING_JOB)
|
||||
.execute(&db)
|
||||
.await?;
|
||||
|
||||
// The owner still cancels their own job (no over-blocking). Keep this last: it
|
||||
// takes RUNNING_JOB out of the queue.
|
||||
let (status, body) = post(
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
//! A cancel reaches the queue row first and the worker only later: one landing after the worker's
|
||||
//! last read of that row completes the run carrying no `canceled_by` of its own, and a perpetual
|
||||
//! script must still stop rather than restart as if nothing had been cancelled.
|
||||
|
||||
use serde_json::json;
|
||||
use sqlx::{types::Json, Pool, Postgres};
|
||||
use uuid::Uuid;
|
||||
use windmill_queue::{add_completed_job, get_mini_completed_job};
|
||||
|
||||
const W_ID: &str = "test-workspace";
|
||||
const PATH: &str = "u/test-user/loop";
|
||||
|
||||
async fn start_perpetual_run(db: &Pool<Postgres>) -> anyhow::Result<Uuid> {
|
||||
sqlx::query(
|
||||
"INSERT INTO script (workspace_id, hash, path, summary, description, content, created_by, \
|
||||
language, lock, restart_unless_cancelled) \
|
||||
VALUES ($1, 1, $2, '', '', 'echo', 'test-user', 'bash', '', true)",
|
||||
)
|
||||
.bind(W_ID)
|
||||
.bind(PATH)
|
||||
.execute(db)
|
||||
.await?;
|
||||
let id = Uuid::new_v4();
|
||||
sqlx::query(
|
||||
"INSERT INTO v2_job (id, workspace_id, created_by, created_at, permissioned_as, \
|
||||
permissioned_as_email, kind, runnable_id, runnable_path, script_lang, tag, args, \
|
||||
visible_to_owner) \
|
||||
VALUES ($1, $2, 'test-user', now(), 'u/test-user', 'test@windmill.dev', 'script', 1, \
|
||||
$3, 'bash', 'bash', '{\"n\": 1}', true)",
|
||||
)
|
||||
.bind(id)
|
||||
.bind(W_ID)
|
||||
.bind(PATH)
|
||||
.execute(db)
|
||||
.await?;
|
||||
sqlx::query(
|
||||
"INSERT INTO v2_job_queue (id, workspace_id, scheduled_for, running, started_at, tag) \
|
||||
VALUES ($1, $2, now(), true, now(), 'bash')",
|
||||
)
|
||||
.bind(id)
|
||||
.bind(W_ID)
|
||||
.execute(db)
|
||||
.await?;
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
/// Waits for another connection to be stuck on the queue row of `id`, which is where the
|
||||
/// completion's delete lands while the cancel is uncommitted.
|
||||
async fn wait_until_blocked_on(db: &Pool<Postgres>, id: Uuid) -> anyhow::Result<()> {
|
||||
for _ in 0..100 {
|
||||
let blocked: bool = sqlx::query_scalar(
|
||||
"SELECT EXISTS (SELECT 1 FROM pg_locks l JOIN v2_job_queue q \
|
||||
ON q.ctid = ('(' || l.page || ',' || l.tuple || ')')::tid \
|
||||
WHERE NOT l.granted AND l.locktype = 'tuple' AND q.id = $1) \
|
||||
OR EXISTS (SELECT 1 FROM pg_stat_activity \
|
||||
WHERE wait_event_type = 'Lock' AND query LIKE '%v2_job_queue%')",
|
||||
)
|
||||
.bind(id)
|
||||
.fetch_one(db)
|
||||
.await?;
|
||||
if blocked {
|
||||
return Ok(());
|
||||
}
|
||||
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
|
||||
}
|
||||
anyhow::bail!("the completion never reached the delete, so the race was not exercised")
|
||||
}
|
||||
|
||||
/// The same cancel, one step earlier: still being written when the completion reads the queue row,
|
||||
/// which reads it without a lock. Only the delete that follows waits for the writer.
|
||||
#[sqlx::test(fixtures("base"))]
|
||||
async fn a_cancel_still_being_written_is_kept(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
let running = start_perpetual_run(&db).await?;
|
||||
let job = get_mini_completed_job(&running, W_ID, &db).await?.unwrap();
|
||||
|
||||
let mut cancel = db.begin().await?;
|
||||
sqlx::query(
|
||||
"UPDATE v2_job_queue SET canceled_by = 'test-user', canceled_reason = 'stop' WHERE id = $1",
|
||||
)
|
||||
.bind(running)
|
||||
.execute(&mut *cancel)
|
||||
.await?;
|
||||
|
||||
let completing = tokio::spawn({
|
||||
let db = db.clone();
|
||||
async move {
|
||||
add_completed_job(
|
||||
&db,
|
||||
&job,
|
||||
true,
|
||||
false,
|
||||
Json(&json!("done")),
|
||||
None,
|
||||
0,
|
||||
None,
|
||||
false,
|
||||
None,
|
||||
false,
|
||||
)
|
||||
.await
|
||||
}
|
||||
});
|
||||
// Committing on a timer would let a slow completion read the cancel through the insert
|
||||
// instead, leaving the half this test is for unexercised and green. Waiting for the delete to
|
||||
// block on the row is what puts it there.
|
||||
wait_until_blocked_on(&db, running).await?;
|
||||
cancel.commit().await?;
|
||||
completing.await??;
|
||||
|
||||
let (status, canceled_by): (String, Option<String>) =
|
||||
sqlx::query_as("SELECT status::text, canceled_by FROM v2_job_completed WHERE id = $1")
|
||||
.bind(running)
|
||||
.fetch_one(&db)
|
||||
.await?;
|
||||
assert_eq!(status, "canceled", "the run is recorded as canceled");
|
||||
assert_eq!(canceled_by.as_deref(), Some("test-user"));
|
||||
|
||||
let queued: Vec<Uuid> = sqlx::query_scalar(
|
||||
"SELECT q.id FROM v2_job_queue q JOIN v2_job j USING (id) \
|
||||
WHERE j.workspace_id = $1 AND j.runnable_path = $2",
|
||||
)
|
||||
.bind(W_ID)
|
||||
.bind(PATH)
|
||||
.fetch_all(&db)
|
||||
.await?;
|
||||
assert!(
|
||||
queued.is_empty(),
|
||||
"a canceled perpetual run queues no next one: {queued:?}"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[sqlx::test(fixtures("base"))]
|
||||
async fn a_cancel_landing_after_the_worker_read_its_row_is_kept(
|
||||
db: Pool<Postgres>,
|
||||
) -> anyhow::Result<()> {
|
||||
let running = start_perpetual_run(&db).await?;
|
||||
sqlx::query(
|
||||
"UPDATE v2_job_queue SET canceled_by = 'test-user', canceled_reason = 'stop' WHERE id = $1",
|
||||
)
|
||||
.bind(running)
|
||||
.execute(&db)
|
||||
.await?;
|
||||
|
||||
let job = get_mini_completed_job(&running, W_ID, &db).await?.unwrap();
|
||||
add_completed_job(
|
||||
&db,
|
||||
&job,
|
||||
true,
|
||||
false,
|
||||
Json(&json!("done")),
|
||||
None,
|
||||
0,
|
||||
// What the worker carries when the cancel landed after it last read the row.
|
||||
None,
|
||||
false,
|
||||
None,
|
||||
false,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let (status, canceled_by): (String, Option<String>) =
|
||||
sqlx::query_as("SELECT status::text, canceled_by FROM v2_job_completed WHERE id = $1")
|
||||
.bind(running)
|
||||
.fetch_one(&db)
|
||||
.await?;
|
||||
assert_eq!(status, "canceled", "the run is recorded as canceled");
|
||||
assert_eq!(canceled_by.as_deref(), Some("test-user"));
|
||||
|
||||
let queued: Vec<Uuid> = sqlx::query_scalar(
|
||||
"SELECT q.id FROM v2_job_queue q JOIN v2_job j USING (id) \
|
||||
WHERE j.workspace_id = $1 AND j.runnable_path = $2",
|
||||
)
|
||||
.bind(W_ID)
|
||||
.bind(PATH)
|
||||
.fetch_all(&db)
|
||||
.await?;
|
||||
assert!(
|
||||
queued.is_empty(),
|
||||
"a canceled perpetual run queues no next one: {queued:?}"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
//! `GET /raw_apps/list` must honor a path-scoped token. The route layer only checks
|
||||
//! the scope domain and RLS knows nothing of token scopes, so the per-path filter
|
||||
//! lives in the handler, ahead of the page's LIMIT.
|
||||
|
||||
use sqlx::{Pool, Postgres};
|
||||
use windmill_test_utils::*;
|
||||
|
||||
async fn list_paths(port: u16, token: &str, query: &str) -> anyhow::Result<Vec<String>> {
|
||||
let resp = reqwest::Client::new()
|
||||
.get(format!(
|
||||
"http://localhost:{port}/api/w/test-workspace/raw_apps/list?{query}"
|
||||
))
|
||||
.header("Authorization", format!("Bearer {token}"))
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(resp.status(), 200, "list: {}", resp.text().await?);
|
||||
let body: Vec<serde_json::Value> = resp.json().await?;
|
||||
let mut paths: Vec<String> = body
|
||||
.iter()
|
||||
.map(|a| a["path"].as_str().unwrap().to_string())
|
||||
.collect();
|
||||
paths.sort();
|
||||
Ok(paths)
|
||||
}
|
||||
|
||||
#[sqlx::test(fixtures("base"))]
|
||||
async fn test_raw_apps_list_filters_by_token_scope(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
initialize_tracing().await;
|
||||
|
||||
// The list sorts by edited_at desc, so the out-of-scope apps fill the first page
|
||||
// unless the scope is applied before the LIMIT.
|
||||
for (path, age_minutes) in [
|
||||
("f/other/c", 0),
|
||||
("u/test-userx/d", 1),
|
||||
("u/test-user/a", 2),
|
||||
("u/test-user/sub/b", 3),
|
||||
// A `prefix/*` grant also covers the path at the prefix itself.
|
||||
("u/test-user", 4),
|
||||
] {
|
||||
sqlx::query(
|
||||
"INSERT INTO raw_app (path, workspace_id, data, edited_at)
|
||||
VALUES ($1, 'test-workspace', '', now() - make_interval(mins => $2))",
|
||||
)
|
||||
.bind(path)
|
||||
.bind(age_minutes)
|
||||
.execute(&db)
|
||||
.await?;
|
||||
}
|
||||
sqlx::query(
|
||||
"INSERT INTO token (token_hash, token_prefix, token, email, label, scopes)
|
||||
VALUES (encode(sha256('SCOPED_TOKEN'::bytea), 'hex'), 'SCOPED_TOK', 'SCOPED_TOKEN',
|
||||
'test@windmill.dev', 'scoped', ARRAY['raw_apps:read:u/test-user/*'])",
|
||||
)
|
||||
.execute(&db)
|
||||
.await?;
|
||||
|
||||
let server = ApiServer::start(db.clone()).await?;
|
||||
let port = server.addr.port();
|
||||
|
||||
// The same user through an unscoped token sees every row.
|
||||
assert_eq!(
|
||||
list_paths(port, "SECRET_TOKEN", "").await?,
|
||||
[
|
||||
"f/other/c",
|
||||
"u/test-user",
|
||||
"u/test-user/a",
|
||||
"u/test-user/sub/b",
|
||||
"u/test-userx/d"
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
list_paths(port, "SCOPED_TOKEN", "per_page=3").await?,
|
||||
["u/test-user", "u/test-user/a", "u/test-user/sub/b"]
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use serde_json::{json, Value};
|
||||
use sqlx::{Pool, Postgres};
|
||||
use windmill_api_client::types::{NewScript, ScriptLang};
|
||||
use windmill_test_utils::init_client;
|
||||
|
||||
const PATH: &str = "u/test-user/inferred_schema";
|
||||
|
||||
fn bun_ns(content: &str, parent_hash: Option<String>) -> NewScript {
|
||||
NewScript {
|
||||
draft_only: None,
|
||||
content: content.into(),
|
||||
language: ScriptLang::Bun,
|
||||
lock: None,
|
||||
parent_hash,
|
||||
path: PATH.into(),
|
||||
concurrent_limit: None,
|
||||
concurrency_time_window_s: None,
|
||||
cache_ttl: None,
|
||||
dedicated_worker: None,
|
||||
description: "".to_string(),
|
||||
envs: vec![],
|
||||
is_template: None,
|
||||
kind: None,
|
||||
summary: "".to_string(),
|
||||
tag: None,
|
||||
// What this client sends for "no schema".
|
||||
schema: HashMap::new(),
|
||||
ws_error_handler_muted: Some(false),
|
||||
priority: None,
|
||||
delete_after_secs: None,
|
||||
timeout: None,
|
||||
restart_unless_cancelled: None,
|
||||
deployment_message: None,
|
||||
concurrency_key: None,
|
||||
visible_to_runner_only: None,
|
||||
auto_kind: None,
|
||||
codebase: None,
|
||||
has_preprocessor: None,
|
||||
on_behalf_of_email: None,
|
||||
assets: vec![],
|
||||
modules: None,
|
||||
}
|
||||
}
|
||||
|
||||
async fn head(db: &Pool<Postgres>) -> anyhow::Result<(i64, Value)> {
|
||||
Ok(sqlx::query_as(
|
||||
"SELECT hash, schema::jsonb FROM script \
|
||||
WHERE workspace_id = 'test-workspace' AND path = $1 AND archived = false",
|
||||
)
|
||||
.bind(PATH)
|
||||
.fetch_one(db)
|
||||
.await?)
|
||||
}
|
||||
|
||||
/// A deploy without a schema (MCP, a bare API call) gets the one the editor would
|
||||
/// have sent, and a new version keeps what the user wrote on the previous one's.
|
||||
#[sqlx::test(fixtures("base"))]
|
||||
async fn schemaless_deploy_infers_schema_keeping_annotations(
|
||||
db: Pool<Postgres>,
|
||||
) -> anyhow::Result<()> {
|
||||
let (client, _port, _s) = init_client(db.clone()).await;
|
||||
|
||||
client
|
||||
.create_script(
|
||||
"test-workspace",
|
||||
&bun_ns(
|
||||
"export function main(n: number, name = \"x\") { return n }",
|
||||
None,
|
||||
),
|
||||
)
|
||||
.await?;
|
||||
let (hash, schema) = head(&db).await?;
|
||||
assert_eq!(schema["required"], json!(["n"]));
|
||||
assert_eq!(schema["properties"]["n"]["type"], "number");
|
||||
assert_eq!(schema["properties"]["name"]["default"], "x");
|
||||
|
||||
sqlx::query(
|
||||
"UPDATE script SET schema = jsonb_set(schema::jsonb, '{properties,n,description}', \
|
||||
'\"how many\"')::json WHERE hash = $1",
|
||||
)
|
||||
.bind(hash)
|
||||
.execute(&db)
|
||||
.await?;
|
||||
|
||||
client
|
||||
.create_script(
|
||||
"test-workspace",
|
||||
&bun_ns(
|
||||
"export function main(n: number, flag: boolean) { return n }",
|
||||
Some(format!("{hash:016x}")),
|
||||
),
|
||||
)
|
||||
.await?;
|
||||
let (hash, schema) = head(&db).await?;
|
||||
assert_eq!(schema["required"], json!(["n", "flag"]));
|
||||
assert_eq!(schema["properties"]["n"]["description"], "how many");
|
||||
assert_eq!(schema["properties"]["flag"]["type"], "boolean");
|
||||
assert!(schema["properties"].get("name").is_none());
|
||||
|
||||
// Code that does not parse keeps the schema rather than losing it for good.
|
||||
client
|
||||
.create_script(
|
||||
"test-workspace",
|
||||
&bun_ns(
|
||||
"export function main(n: number {",
|
||||
Some(format!("{hash:016x}")),
|
||||
),
|
||||
)
|
||||
.await?;
|
||||
assert_eq!(head(&db).await?.1, schema);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
+282
-14
@@ -5439,7 +5439,7 @@ async fn test_duckdb_ffi(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
}
|
||||
|
||||
/// Test that flow substeps with tags that are not available for the workspace fail.
|
||||
/// This validates that `check_tag_available_for_workspace_internal` is properly called
|
||||
/// This validates that `check_tag_available_for_push` is properly called
|
||||
/// when pushing jobs from worker_flow.
|
||||
#[sqlx::test(fixtures("base"))]
|
||||
#[serial]
|
||||
@@ -5512,17 +5512,178 @@ async fn test_flow_substep_tag_availability_check(db: Pool<Postgres>) -> anyhow:
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A step's `$flow_expr[...]` tag is checked on the queue it resolves to, not on its text: the
|
||||
/// custom tags list `bun` alone, which admits the step resolving to it and refuses the one that
|
||||
/// lands elsewhere.
|
||||
#[cfg(feature = "deno_core")]
|
||||
#[sqlx::test(fixtures("base"))]
|
||||
#[serial]
|
||||
async fn test_flow_substep_tag_checked_on_resolved_value(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
use windmill_common::worker::{CustomTags, CUSTOM_TAGS_PER_WORKSPACE};
|
||||
|
||||
initialize_tracing().await;
|
||||
let server = ApiServer::start(db.clone()).await?;
|
||||
|
||||
CUSTOM_TAGS_PER_WORKSPACE.store(std::sync::Arc::new(CustomTags::from(vec![
|
||||
"bun".to_string()
|
||||
])));
|
||||
|
||||
let step = |id: &str, tag: Option<&str>| {
|
||||
json!({
|
||||
"id": id,
|
||||
"value": {
|
||||
"type": "rawscript",
|
||||
"language": "deno",
|
||||
"content": "export function main() { return { lang: 'bun' } }",
|
||||
"tag": tag,
|
||||
},
|
||||
})
|
||||
};
|
||||
let flow: FlowValue = serde_json::from_value(json!({
|
||||
"modules": [
|
||||
step("a", None),
|
||||
step("b", Some("$flow_expr[results.a.lang]")),
|
||||
step("c", Some("$flow_expr[results.a.lang]-gpu")),
|
||||
],
|
||||
}))?;
|
||||
|
||||
// A non-superadmin, so the custom tags apply.
|
||||
let job = RunJob::from(JobPayload::RawFlow { value: flow, path: None, restarted_from: None })
|
||||
.as_user("test-user-2", "test2@windmill.dev")
|
||||
.run_until_complete(&db, false, server.addr.port())
|
||||
.await;
|
||||
CUSTOM_TAGS_PER_WORKSPACE.store(std::sync::Arc::new(CustomTags::default()));
|
||||
|
||||
let b_tag = sqlx::query_scalar::<_, String>(
|
||||
"SELECT tag FROM v2_job WHERE parent_job = $1 AND flow_step_id = 'b'",
|
||||
)
|
||||
.bind(job.id)
|
||||
.fetch_one(&db)
|
||||
.await?;
|
||||
assert_eq!(b_tag, "bun");
|
||||
|
||||
assert!(!job.success);
|
||||
let result = job.json_result().unwrap();
|
||||
let message = result["error"]["message"].as_str().unwrap_or_default();
|
||||
assert!(
|
||||
message.contains("(resolved to bun-gpu) is not included in the allowed CUSTOM_TAGS"),
|
||||
"got {result:?}"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A flow whose preprocessor runs lands on its tag filled in from the preprocessor's output, so
|
||||
/// running it judges the tag as written: even raw args that fill it in to a listed tag do not
|
||||
/// admit a template nobody listed.
|
||||
#[cfg(feature = "deno_core")]
|
||||
#[sqlx::test(fixtures("base", "hello"))]
|
||||
#[serial]
|
||||
async fn test_flow_tag_judged_as_written_before_preprocessor(
|
||||
db: Pool<Postgres>,
|
||||
) -> anyhow::Result<()> {
|
||||
use windmill_common::worker::{CustomTags, CUSTOM_TAGS_PER_WORKSPACE};
|
||||
|
||||
initialize_tracing().await;
|
||||
let server = ApiServer::start(db.clone()).await?;
|
||||
let port = server.addr.port();
|
||||
|
||||
let set_flow_tag = |tag: &'static str| {
|
||||
let db = db.clone();
|
||||
async move {
|
||||
sqlx::query("UPDATE flow SET tag = $1 WHERE path = 'f/system/hello_with_preprocessor'")
|
||||
.bind(tag)
|
||||
.execute(&db)
|
||||
.await?;
|
||||
// Flow info is cached per version, which a real redeploy would bump.
|
||||
windmill_common::FLOW_INFO_CACHE
|
||||
.remove(&("test-workspace".to_string(), 1443253234253456));
|
||||
anyhow::Ok(())
|
||||
}
|
||||
};
|
||||
// As a non-superadmin, so the custom tags apply.
|
||||
let run_as_user = |entries: &[&str], query: &str| {
|
||||
CUSTOM_TAGS_PER_WORKSPACE.store(std::sync::Arc::new(CustomTags::from(
|
||||
entries.iter().map(|e| e.to_string()).collect(),
|
||||
)));
|
||||
reqwest::Client::new()
|
||||
.post(format!(
|
||||
"http://localhost:{port}/api/w/test-workspace/jobs/run/f/f/system/hello_with_preprocessor{query}"
|
||||
))
|
||||
.bearer_auth("SECRET_TOKEN_2")
|
||||
.json(&json!({ "foo": "bar" }))
|
||||
.send()
|
||||
};
|
||||
|
||||
sqlx::query(
|
||||
"UPDATE flow SET extra_perms = '{\"u/test-user-2\": false}'
|
||||
WHERE path = 'f/system/hello_with_preprocessor'",
|
||||
)
|
||||
.execute(&db)
|
||||
.await?;
|
||||
set_flow_tag("pp-$args[foo]").await?;
|
||||
let refused = run_as_user(&["pp-bar"], "").await?;
|
||||
let refused_status = refused.status();
|
||||
let refused_body = refused.text().await?;
|
||||
// `push` drops a `?tag=` of such a flow, so it does not stand in for the flow's own tag.
|
||||
let overridden = run_as_user(&["pp-bar"], "?tag=pp-bar").await?.status();
|
||||
let admitted = run_as_user(&["pp-$args[foo]"], "").await?.status();
|
||||
// A tag the preprocessor cannot change is known now, so patterns apply to it.
|
||||
set_flow_tag("pp-large").await?;
|
||||
let static_admitted = run_as_user(&["pp-$args[size]"], "").await?.status();
|
||||
|
||||
// Nothing a flow's tag can read resolves `$flow_expr[...]`, so the flow keeps its tag.
|
||||
set_flow_tag("$flow_expr[flow_input.foo]").await?;
|
||||
CUSTOM_TAGS_PER_WORKSPACE.store(std::sync::Arc::new(CustomTags::default()));
|
||||
let unresolvable = RunJob::from(JobPayload::Flow {
|
||||
path: "f/system/hello_with_preprocessor".to_string(),
|
||||
dedicated_worker: None,
|
||||
apply_preprocessor: true,
|
||||
version: 1443253234253456,
|
||||
labels: None,
|
||||
})
|
||||
.as_user("test-user-2", "test2@windmill.dev")
|
||||
.run_until_complete(&db, false, port)
|
||||
.await;
|
||||
|
||||
assert_eq!(refused_status, 400, "got {refused_body}");
|
||||
assert!(
|
||||
refused_body.contains("Tag pp-$args[foo] is not included"),
|
||||
"got {refused_body}"
|
||||
);
|
||||
assert_eq!(overridden, 400);
|
||||
assert!(admitted.is_success(), "got {admitted}");
|
||||
assert!(static_admitted.is_success(), "got {static_admitted}");
|
||||
|
||||
assert!(unresolvable.success, "got {:?}", unresolvable.json_result());
|
||||
let tag = sqlx::query_scalar::<_, String>("SELECT tag FROM v2_job WHERE id = $1")
|
||||
.bind(unresolvable.id)
|
||||
.fetch_one(&db)
|
||||
.await?;
|
||||
assert_eq!(tag, "flow");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The `*` fork marker only grants through a real `parent_workspace_id` lineage lookup, which the
|
||||
/// parse-level unit tests cannot reach: they hand `applies_to_workspace` a synthetic chain, so a
|
||||
/// regression in the lookup or in the `is_fork_scoped()` gate that skips it would pass them.
|
||||
#[sqlx::test(fixtures("base"))]
|
||||
#[serial]
|
||||
async fn test_fork_marker_tag_admission_through_lineage(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
use windmill_common::jobs::check_tag_available_for_workspace_internal;
|
||||
use windmill_common::worker::{CustomTags, CUSTOM_TAGS_PER_WORKSPACE};
|
||||
|
||||
initialize_tracing().await;
|
||||
|
||||
// For a non-superadmin caller (a superadmin would bypass the scope check entirely).
|
||||
async fn allowed(db: &Pool<Postgres>, w_id: &str, tag: &str) -> bool {
|
||||
let no_args = std::collections::HashMap::new();
|
||||
let args = windmill_queue::PushArgs::from(&no_args);
|
||||
windmill_queue::check_tag_available_for_push(db, w_id, tag, &args, false, None)
|
||||
.await
|
||||
.is_ok()
|
||||
}
|
||||
|
||||
// The ancestor chain is cached process-wide by workspace id, so use one no other test takes.
|
||||
let fork = "wm-fork-tagmarker";
|
||||
sqlx::query!(
|
||||
@@ -5538,27 +5699,18 @@ async fn test_fork_marker_tag_admission_through_lineage(db: Pool<Postgres>) -> a
|
||||
"bare(test-workspace)".to_string(),
|
||||
])));
|
||||
|
||||
// A non-superadmin caller (a superadmin would bypass the scope check entirely).
|
||||
let is_super_admin = false;
|
||||
|
||||
for (w_id, tag) in [("test-workspace", "bare"), ("test-workspace", "forky")] {
|
||||
assert!(
|
||||
check_tag_available_for_workspace_internal(&db, w_id, tag, is_super_admin, None)
|
||||
.await
|
||||
.is_ok(),
|
||||
allowed(&db, w_id, tag).await,
|
||||
"{tag} should be available in the workspace it names"
|
||||
);
|
||||
}
|
||||
assert!(
|
||||
check_tag_available_for_workspace_internal(&db, fork, "forky", is_super_admin, None)
|
||||
.await
|
||||
.is_ok(),
|
||||
allowed(&db, fork, "forky").await,
|
||||
"a `*` tag must be granted to a fork through its parent lineage"
|
||||
);
|
||||
assert!(
|
||||
check_tag_available_for_workspace_internal(&db, fork, "bare", is_super_admin, None)
|
||||
.await
|
||||
.is_err(),
|
||||
!allowed(&db, fork, "bare").await,
|
||||
"an unmarked tag must not reach a fork of the workspace it names"
|
||||
);
|
||||
|
||||
@@ -5567,6 +5719,122 @@ async fn test_fork_marker_tag_admission_through_lineage(db: Pool<Postgres>) -> a
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A workspace-scoped pattern admits the tags in its range only from the workspaces its scope
|
||||
/// allows, and a tag listed by its own name with a scope stays inside that scope whatever pattern
|
||||
/// it fits. The fork lineage and `$workspace` are resolved for a matching pattern.
|
||||
#[sqlx::test(fixtures("base"))]
|
||||
#[serial]
|
||||
async fn test_scoped_custom_tag_pattern_admission(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
use windmill_common::worker::{CustomTags, CUSTOM_TAGS_PER_WORKSPACE};
|
||||
|
||||
initialize_tracing().await;
|
||||
|
||||
// For a non-superadmin caller (a superadmin would bypass the scope check entirely).
|
||||
async fn check(db: &Pool<Postgres>, w_id: &str, tag: &str, size: &str) -> Result<(), String> {
|
||||
let args = std::collections::HashMap::from([(
|
||||
"size".to_string(),
|
||||
serde_json::value::to_raw_value(size).unwrap(),
|
||||
)]);
|
||||
let args = windmill_queue::PushArgs::from(&args);
|
||||
windmill_queue::check_tag_available_for_push(db, w_id, tag, &args, false, None)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
async fn allowed(db: &Pool<Postgres>, w_id: &str, tag: &str, size: &str) -> bool {
|
||||
check(db, w_id, tag, size).await.is_ok()
|
||||
}
|
||||
async fn allowed_as_written(db: &Pool<Postgres>, w_id: &str, tag: &str) -> bool {
|
||||
windmill_common::jobs::check_tag_available_for_workspace_internal(
|
||||
db,
|
||||
w_id,
|
||||
tag,
|
||||
None,
|
||||
std::future::ready(w_id.to_string()),
|
||||
false,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.is_ok()
|
||||
}
|
||||
|
||||
// The ancestor chain is cached process-wide by workspace id, so use one no other test takes.
|
||||
let fork = "wm-fork-tagpattern";
|
||||
sqlx::query!(
|
||||
"INSERT INTO workspace (id, name, owner, parent_workspace_id)
|
||||
VALUES ($1, $1, 'test-user', 'test-workspace')",
|
||||
fork
|
||||
)
|
||||
.execute(&db)
|
||||
.await?;
|
||||
|
||||
CUSTOM_TAGS_PER_WORKSPACE.store(std::sync::Arc::new(CustomTags::from(vec![
|
||||
"gpu-$args[size]".to_string(),
|
||||
"$args[size]-secret(test-workspace)".to_string(),
|
||||
"gpu-secret(other)".to_string(),
|
||||
"$workspace-$args[size](test-workspace*)".to_string(),
|
||||
"urgent".to_string(),
|
||||
"urgent(other)".to_string(),
|
||||
])));
|
||||
|
||||
let confined = check(&db, "test-workspace", "gpu-$args[size]", "secret").await;
|
||||
let checks = [
|
||||
(
|
||||
"scoped pattern from its workspace",
|
||||
allowed(&db, "test-workspace", "cpu-secret", "").await,
|
||||
),
|
||||
(
|
||||
"scoped pattern refused elsewhere",
|
||||
!allowed(&db, "other", "cpu-secret", "").await,
|
||||
),
|
||||
("confined tag refused past the patterns it fits", confined.is_err()),
|
||||
(
|
||||
"global pattern for the tags nothing confines",
|
||||
allowed(&db, "test-workspace", "gpu-large", "").await,
|
||||
),
|
||||
(
|
||||
"confined tag from its workspace",
|
||||
allowed(&db, "other", "gpu-secret", "").await,
|
||||
),
|
||||
// Listing the tag by name without a scope as well keeps it open everywhere.
|
||||
(
|
||||
"tag listed both globally and scoped",
|
||||
allowed(&db, "test-workspace", "urgent", "").await,
|
||||
),
|
||||
// The fork's `$workspace` is its parent's, whose tags its lineage reaches. Written as the
|
||||
// resolved tag, not as the entry, so only the pattern can admit it.
|
||||
(
|
||||
"fork-scoped `$workspace` pattern from a fork",
|
||||
allowed(&db, fork, "test-workspace-large", "").await,
|
||||
),
|
||||
(
|
||||
"fork-scoped `$workspace` pattern refused elsewhere",
|
||||
!allowed(&db, "other", "$workspace-$args[size]", "large").await,
|
||||
),
|
||||
(
|
||||
"as written from its workspace",
|
||||
allowed_as_written(&db, "test-workspace", "$args[size]-secret").await,
|
||||
),
|
||||
(
|
||||
"as written refused elsewhere",
|
||||
!allowed_as_written(&db, "other", "$args[size]-secret").await,
|
||||
),
|
||||
];
|
||||
CUSTOM_TAGS_PER_WORKSPACE.store(std::sync::Arc::new(CustomTags::default()));
|
||||
|
||||
for (what, ok) in checks {
|
||||
assert!(ok, "{what}");
|
||||
}
|
||||
let confined = confined.unwrap_err();
|
||||
assert!(
|
||||
confined.contains("the custom tag gpu-secret(other) restricts it to other workspaces")
|
||||
&& confined.contains("gpu-$args[size]")
|
||||
&& confined.contains("$args[size]-secret(test-workspace)"),
|
||||
"got {confined}"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(all(feature = "quickjs", feature = "python"))]
|
||||
#[sqlx::test(fixtures("base"))]
|
||||
async fn test_whileloop_propagates_inner_iterator_eval_failure(
|
||||
|
||||
@@ -1073,7 +1073,8 @@ fn no_auth_admin_authed() -> ApiAuthed {
|
||||
}
|
||||
|
||||
/// Resolves OptJobAuthed from request parts.
|
||||
/// Takes ownership of Parts and returns them back.
|
||||
/// Takes ownership of Parts and returns them back, memoized into the extensions so a
|
||||
/// second extraction in the same request is free.
|
||||
#[allow(unreachable_code, unused_mut)]
|
||||
pub async fn resolve_opt_job_authed(
|
||||
mut parts: Parts,
|
||||
@@ -1089,10 +1090,11 @@ pub async fn resolve_opt_job_authed(
|
||||
));
|
||||
}
|
||||
|
||||
let already_authed = parts.extensions.get::<OptJobAuthed>().cloned();
|
||||
|
||||
if let Some(authed) = already_authed {
|
||||
return Ok((authed, parts));
|
||||
// Safe only while what the route checks below read is fixed before the first
|
||||
// extraction: path and method always are, `workspace_id` only while every
|
||||
// `GatewayWorkspaceId` writer stays layered outside auth extraction.
|
||||
if let Some(opt_job_authed) = parts.extensions.get::<OptJobAuthed>().cloned() {
|
||||
return Ok((opt_job_authed, parts));
|
||||
}
|
||||
|
||||
let already_tokened = parts.extensions.get::<Tokened>().cloned();
|
||||
@@ -1185,6 +1187,9 @@ pub async fn resolve_opt_job_authed(
|
||||
if let Some(workspace_id) = workspace_id {
|
||||
Span::current().record("workspace_id", &workspace_id);
|
||||
}
|
||||
// Separate from the ApiAuthed insert above, which handlers taking
|
||||
// `Extension<ApiAuthed>` read.
|
||||
parts.extensions.insert(opt_job_authed.clone());
|
||||
return Ok((opt_job_authed, parts));
|
||||
}
|
||||
}
|
||||
@@ -1311,3 +1316,69 @@ pub async fn list_tokens_internal(
|
||||
|
||||
Ok(Json(tokens))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Dropping the `AUTH_CACHE` entry between the two resolutions is what makes a memo
|
||||
/// hit observable: only a working memo can answer the second one.
|
||||
#[tokio::test]
|
||||
async fn resolve_opt_job_authed_is_memoized() {
|
||||
// Never connected: an AUTH_CACHE hit resolves without a query, and AuthCache
|
||||
// only needs the handle to exist. Without the timeout a regression — which
|
||||
// falls through to a real lookup — spends sqlx's 30s default failing.
|
||||
let db = sqlx::postgres::PgPoolOptions::new()
|
||||
.acquire_timeout(std::time::Duration::from_millis(50))
|
||||
.connect_lazy("postgres://memo@127.0.0.1:1/memo")
|
||||
.expect("lazy pool");
|
||||
|
||||
let token = "memo_guard_token";
|
||||
// Workspace-scoped: a job token on a workspace-less route is refused by
|
||||
// check_job_token_for_global_route before the memo is ever taken.
|
||||
let key = ("memo_workspace".to_string(), token.to_string());
|
||||
let job_id = uuid::Uuid::from_u128(0x6d656d6f);
|
||||
AUTH_CACHE.insert(
|
||||
key.clone(),
|
||||
ExpiringAuthCache {
|
||||
authed: ApiAuthed {
|
||||
email: "memo@windmill.dev".to_string(),
|
||||
username: "memo".to_string(),
|
||||
..Default::default()
|
||||
},
|
||||
expiry: chrono::Utc::now() + chrono::Duration::hours(1),
|
||||
job_id: Some(job_id),
|
||||
},
|
||||
);
|
||||
|
||||
#[cfg(feature = "enterprise")]
|
||||
let cache = AuthCache::new(db, None, None);
|
||||
#[cfg(not(feature = "enterprise"))]
|
||||
let cache = AuthCache::new(db, None);
|
||||
|
||||
let mut parts = http::Request::builder()
|
||||
.uri("/api/w/memo_workspace/jobs/list")
|
||||
.header(http::header::AUTHORIZATION, format!("Bearer {token}"))
|
||||
.body(())
|
||||
.unwrap()
|
||||
.into_parts()
|
||||
.0;
|
||||
parts.extensions.insert(Arc::new(cache));
|
||||
|
||||
let (_, parts) = resolve_opt_job_authed(parts)
|
||||
.await
|
||||
.map_err(|(e, _)| e)
|
||||
.expect("first resolution");
|
||||
|
||||
AUTH_CACHE.remove(&key);
|
||||
|
||||
let (second, _) = resolve_opt_job_authed(parts)
|
||||
.await
|
||||
.map_err(|(e, _)| e)
|
||||
.expect("second resolution must hit the memo, not re-resolve");
|
||||
assert_eq!(second.authed.username, "memo");
|
||||
// The wrapper must survive whole: memoizing only the inner ApiAuthed and
|
||||
// rebuilding around it drops the provenance forbid_elevated_job_token gates on.
|
||||
assert_eq!(second.job_id, Some(job_id));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,60 +115,6 @@ async fn login_link_is_single_use_and_same_origin(db: Pool<Postgres>) -> anyhow:
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
|
||||
async fn confirmed_login_link_is_spent_by_the_click_not_the_page(
|
||||
db: Pool<Postgres>,
|
||||
) -> anyhow::Result<()> {
|
||||
initialize_tracing().await;
|
||||
let server = ApiServer::start(db.clone()).await?;
|
||||
let port = server.addr.port();
|
||||
let base = format!("http://localhost:{port}/api");
|
||||
|
||||
let resp = client()
|
||||
.post(format!("{base}/users/login_links"))
|
||||
.header("Authorization", "Bearer SECRET_TOKEN")
|
||||
.json(&json!({"email": "test2@windmill.dev", "confirm": true}))
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(resp.status(), 201);
|
||||
let link = resp.json::<serde_json::Value>().await?;
|
||||
// The URL handed out is the frontend page, not the API path that signs in on a GET.
|
||||
let token = link["url"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.split_once("/user/login_link?token=")
|
||||
.expect("confirmation page url")
|
||||
.1
|
||||
.to_string();
|
||||
|
||||
let confirm = || {
|
||||
client()
|
||||
.post(format!("{base}/auth/login_link/{token}"))
|
||||
.send()
|
||||
};
|
||||
let resp = confirm().await?;
|
||||
assert_eq!(resp.status(), 200);
|
||||
assert!(resp
|
||||
.headers()
|
||||
.get_all("set-cookie")
|
||||
.iter()
|
||||
.any(|c| c.to_str().unwrap().starts_with("token=")));
|
||||
assert_eq!(
|
||||
resp.json::<serde_json::Value>().await?["location"],
|
||||
"/user/workspaces"
|
||||
);
|
||||
|
||||
let resp = confirm().await?;
|
||||
assert_eq!(resp.status(), 200);
|
||||
assert!(resp.headers().get("set-cookie").is_none());
|
||||
assert_eq!(
|
||||
resp.json::<serde_json::Value>().await?["location"],
|
||||
"/user/login_link_expired?reason=used"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
|
||||
async fn login_link_mint_can_require_a_login_type(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
initialize_tracing().await;
|
||||
|
||||
@@ -12,7 +12,7 @@ use serde_json::json;
|
||||
use sqlx::{Pool, Postgres};
|
||||
|
||||
use axum::http::StatusCode;
|
||||
use windmill_api_auth::ApiAuthed;
|
||||
use windmill_api_auth::{ApiAuthed, ScopePathFilter};
|
||||
use windmill_common::{
|
||||
error::Error,
|
||||
variables::{build_crypt, encrypt},
|
||||
@@ -661,6 +661,7 @@ async fn test_rename_moves_native_trigger(db: Pool<Postgres>) -> anyhow::Result<
|
||||
None,
|
||||
Some("f/test/after"),
|
||||
Some(false),
|
||||
ScopePathFilter::AllowAll,
|
||||
)
|
||||
.await?;
|
||||
assert_eq!(
|
||||
@@ -672,6 +673,55 @@ async fn test_rename_moves_native_trigger(db: Pool<Postgres>) -> anyhow::Result<
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 6. Token scope filtering on list
|
||||
// ============================================================================
|
||||
|
||||
/// A `native_triggers:read:f/test/*` token must only list triggers under `f/test/`, on the `/`
|
||||
/// boundary (`f/testx/` is out of scope).
|
||||
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
|
||||
async fn test_list_native_triggers_scope_filter(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
for (i, path) in ["f/testx/out", "f/test/in"].into_iter().enumerate() {
|
||||
insert_test_script(&db, path).await?;
|
||||
store_native_trigger(
|
||||
&db,
|
||||
"test-workspace",
|
||||
ServiceName::Nextcloud,
|
||||
&format!("ext-{i}"),
|
||||
&NativeTriggerConfig {
|
||||
script_path: path.to_string(),
|
||||
is_flow: false,
|
||||
webhook_token: format!("abcdefghij123456789{i}"),
|
||||
},
|
||||
json!({"event": "OCP\\Files\\Events\\Node\\NodeCreatedEvent"}),
|
||||
None,
|
||||
true,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
let triggers = list_native_triggers(
|
||||
&db,
|
||||
"test-workspace",
|
||||
ServiceName::Nextcloud,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
ScopePathFilter::Restricted { exact: vec![], prefix: vec!["f/test".to_string()] },
|
||||
)
|
||||
.await?;
|
||||
assert_eq!(
|
||||
triggers
|
||||
.iter()
|
||||
.map(|t| t.script_path.as_str())
|
||||
.collect::<Vec<_>>(),
|
||||
vec!["f/test/in"]
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// --- parse_stop_channel_params ---
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -0,0 +1,297 @@
|
||||
use serde_json::json;
|
||||
use sqlx::{Pool, Postgres};
|
||||
|
||||
use windmill_common::variables::{build_crypt, encrypt};
|
||||
use windmill_test_utils::*;
|
||||
|
||||
fn client() -> reqwest::Client {
|
||||
reqwest::Client::new()
|
||||
}
|
||||
|
||||
fn authed(builder: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
|
||||
builder.header("Authorization", "Bearer SECRET_TOKEN")
|
||||
}
|
||||
|
||||
/// The deploy target is this very server, which the proxy has no way to tell from another
|
||||
/// instance: it only ever knows the configured URL, the caller's stored token, and the path.
|
||||
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
|
||||
async fn test_remote_deploy_proxy(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
initialize_tracing().await;
|
||||
let server = ApiServer::start(db.clone()).await?;
|
||||
let port = server.addr.port();
|
||||
let base = format!("http://localhost:{port}/api/w/test-workspace/remote_deploy");
|
||||
|
||||
// Planted rather than set through the route, which is enterprise-gated. Unlike the route, it
|
||||
// deletes no token for another target, which is what a connect landing just after the
|
||||
// route's cleanup leaves behind.
|
||||
let set_target = |base_url: String, workspace_id: &'static str| {
|
||||
let db = db.clone();
|
||||
async move {
|
||||
sqlx::query!(
|
||||
"UPDATE workspace_settings
|
||||
SET remote_deploy_target = $1, remote_deploy_target_changed_at = now()
|
||||
WHERE workspace_id = 'test-workspace'",
|
||||
json!({ "base_url": base_url, "workspace_id": workspace_id })
|
||||
)
|
||||
.execute(&db)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
};
|
||||
set_target(format!("http://localhost:{port}"), "test-workspace").await;
|
||||
|
||||
let resp = authed(client().get(format!("{base}/target")))
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(resp.status(), 200);
|
||||
let status = resp.json::<serde_json::Value>().await?;
|
||||
assert_eq!(status["target"]["workspace_id"], "test-workspace");
|
||||
assert!(status["connection"].is_null());
|
||||
|
||||
// Deploying before connecting names the step that is missing, rather than reaching the target
|
||||
// with the caller's credentials for this instance.
|
||||
let resp = authed(client().get(format!("{base}/proxy/none/users/whoami")))
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(resp.status(), 400);
|
||||
assert!(resp.text().await?.contains("Connect to"));
|
||||
|
||||
let target =
|
||||
json!({ "base_url": format!("http://localhost:{port}"), "workspace_id": "test-workspace" });
|
||||
|
||||
// A token obtained for another target is refused before it is sent anywhere.
|
||||
let resp = authed(client().post(format!("{base}/connect")))
|
||||
.json(&json!({"token": "SECRET_TOKEN", "target": { "base_url": format!("http://localhost:{port}"), "workspace_id": "elsewhere" }}))
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(resp.status(), 400);
|
||||
|
||||
// A token the target refuses is not stored.
|
||||
let resp = authed(client().post(format!("{base}/connect")))
|
||||
.json(&json!({"token": "not-a-token", "target": target}))
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(resp.status(), 400);
|
||||
|
||||
let resp = authed(client().post(format!("{base}/connect")))
|
||||
.json(&json!({"token": "SECRET_TOKEN", "target": target}))
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(resp.status(), 200);
|
||||
let connection = resp.json::<serde_json::Value>().await?;
|
||||
assert_eq!(connection["remote_email"], "test@windmill.dev");
|
||||
let key = connection["proxy_key"].as_str().unwrap().to_string();
|
||||
|
||||
let resp = authed(client().get(format!("{base}/proxy/{key}/users/whoami")))
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(resp.status(), 200);
|
||||
// Whatever the remote returns is served from this origin, so it must never render as a page.
|
||||
let csp = resp.headers()["content-security-policy"]
|
||||
.to_str()?
|
||||
.to_string();
|
||||
assert!(csp.starts_with("sandbox"), "{csp}");
|
||||
assert_eq!(
|
||||
resp.json::<serde_json::Value>().await?["email"],
|
||||
"test@windmill.dev"
|
||||
);
|
||||
|
||||
// A link from elsewhere rides the session cookie but cannot know the key, so it cannot spend
|
||||
// the stored token.
|
||||
// Nor can a credential that may not use the proxy read the key, to build such a link itself.
|
||||
sqlx::query!(
|
||||
"INSERT INTO token (token_hash, token_prefix, token, email, label, super_admin, read_only)
|
||||
VALUES (encode(sha256('READ_ONLY_TOKEN'::bytea), 'hex'), 'READ_ONLY_', 'READ_ONLY_TOKEN',
|
||||
'test@windmill.dev', 'read only', false, true)"
|
||||
)
|
||||
.execute(&db)
|
||||
.await?;
|
||||
let resp = client()
|
||||
.get(format!("{base}/target"))
|
||||
.header("Authorization", "Bearer READ_ONLY_TOKEN")
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(resp.status(), 200);
|
||||
assert!(resp.json::<serde_json::Value>().await?["connection"].is_null());
|
||||
|
||||
let guessed = "x".repeat(key.len());
|
||||
let resp = authed(client().get(format!("{base}/proxy/{guessed}/users/whoami")))
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(resp.status(), 400);
|
||||
|
||||
// A connect locks nothing against a key rotation, so its row can land under the old key: that
|
||||
// is no connection, which the drawer offers to replace, rather than an error on every deploy.
|
||||
sqlx::query!(
|
||||
"UPDATE remote_deploy_token SET token = 'not-under-this-key'
|
||||
WHERE workspace_id = 'test-workspace'"
|
||||
)
|
||||
.execute(&db)
|
||||
.await?;
|
||||
let resp = authed(client().get(format!("{base}/target")))
|
||||
.send()
|
||||
.await?;
|
||||
assert!(resp.json::<serde_json::Value>().await?["connection"].is_null());
|
||||
|
||||
// A target that refuses the stored token must not answer 401: the browser reads an
|
||||
// unhandled 401 as its own session having expired and logs the user out of this instance.
|
||||
let mc = build_crypt(&db, "test-workspace").await?;
|
||||
sqlx::query!(
|
||||
"UPDATE remote_deploy_token SET token = $1 WHERE workspace_id = 'test-workspace'",
|
||||
encrypt(&mc, "revoked-token")
|
||||
)
|
||||
.execute(&db)
|
||||
.await?;
|
||||
let resp = authed(client().get(format!("{base}/proxy/{key}/users/whoami")))
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(resp.status(), 502);
|
||||
|
||||
// Nor does a connect lock anything against a removal from the workspace, so its row can land
|
||||
// after the removal cleared the table: a row from an earlier membership must not come back
|
||||
// with a re-add, even one whose `created_at` reads earlier than the connect (it is the start
|
||||
// of the re-adding transaction).
|
||||
let as_test2 =
|
||||
|builder: reqwest::RequestBuilder| builder.header("Authorization", "Bearer SECRET_TOKEN_2");
|
||||
let resp = as_test2(client().post(format!("{base}/connect")))
|
||||
.json(&json!({"token": "SECRET_TOKEN_2", "target": target}))
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(resp.status(), 200);
|
||||
let key2 = resp.json::<serde_json::Value>().await?["proxy_key"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.to_string();
|
||||
let resp = as_test2(client().get(format!("{base}/target")))
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(
|
||||
resp.json::<serde_json::Value>().await?["connection"]["proxy_key"],
|
||||
key2.as_str()
|
||||
);
|
||||
sqlx::query!(
|
||||
"UPDATE usr SET created_at = created_at - interval '1 hour'
|
||||
WHERE workspace_id = 'test-workspace' AND email = 'test2@windmill.dev'"
|
||||
)
|
||||
.execute(&db)
|
||||
.await?;
|
||||
let resp = as_test2(client().get(format!("{base}/target")))
|
||||
.send()
|
||||
.await?;
|
||||
assert!(resp.json::<serde_json::Value>().await?["connection"].is_null());
|
||||
let resp = as_test2(client().get(format!("{base}/proxy/{key2}/users/whoami")))
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(resp.status(), 400);
|
||||
assert!(resp.text().await?.contains("Connect to"));
|
||||
|
||||
// Deleting the account must take its token with it, or the next account created with that
|
||||
// address would act on the remote as this one.
|
||||
let resp = authed(client().delete(format!(
|
||||
"http://localhost:{port}/api/users/delete/test2@windmill.dev"
|
||||
)))
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(resp.status(), 200);
|
||||
let left = sqlx::query_scalar!(
|
||||
"SELECT count(*) FROM remote_deploy_token WHERE email = 'test2@windmill.dev'"
|
||||
)
|
||||
.fetch_one(&db)
|
||||
.await?;
|
||||
assert_eq!(left, Some(0));
|
||||
|
||||
// The token was granted for one target and is never sent to another, so re-pointing the
|
||||
// workspace leaves the caller unconnected instead of handing its token to the new target.
|
||||
set_target(format!("http://localhost:{port}"), "other-workspace").await;
|
||||
let resp = authed(client().get(format!("{base}/target")))
|
||||
.send()
|
||||
.await?;
|
||||
assert!(resp.json::<serde_json::Value>().await?["connection"].is_null());
|
||||
let resp = authed(client().get(format!("{base}/proxy/{key}/users/whoami")))
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(resp.status(), 400);
|
||||
|
||||
// The row for the old target outlived the change, as one from a connect in flight across it
|
||||
// would: pointing the setting back must not revive it, even with a stamp that reads earlier
|
||||
// than the connect.
|
||||
set_target(format!("http://localhost:{port}"), "test-workspace").await;
|
||||
sqlx::query!(
|
||||
"UPDATE workspace_settings SET remote_deploy_target_changed_at = '2000-01-01'
|
||||
WHERE workspace_id = 'test-workspace'"
|
||||
)
|
||||
.execute(&db)
|
||||
.await?;
|
||||
let resp = authed(client().get(format!("{base}/target")))
|
||||
.send()
|
||||
.await?;
|
||||
assert!(resp.json::<serde_json::Value>().await?["connection"].is_null());
|
||||
|
||||
// A connect in flight across a disconnect must not undo it when it lands. The disconnect is
|
||||
// stamped an hour ahead, as if every connect starting now had started before it.
|
||||
let resp = authed(client().post(format!("{base}/connect")))
|
||||
.json(&json!({"token": "SECRET_TOKEN", "target": target}))
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(resp.status(), 200);
|
||||
let resp = authed(client().post(format!("{base}/disconnect")))
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(resp.status(), 200);
|
||||
sqlx::query!(
|
||||
"UPDATE remote_deploy_token SET connected_at = clock_timestamp() + interval '1 hour'
|
||||
WHERE workspace_id = 'test-workspace' AND email = 'test@windmill.dev'"
|
||||
)
|
||||
.execute(&db)
|
||||
.await?;
|
||||
let resp = authed(client().post(format!("{base}/connect")))
|
||||
.json(&json!({"token": "SECRET_TOKEN", "target": target}))
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(resp.status(), 400);
|
||||
let resp = authed(client().get(format!("{base}/target")))
|
||||
.send()
|
||||
.await?;
|
||||
assert!(resp.json::<serde_json::Value>().await?["connection"].is_null());
|
||||
|
||||
// A superadmin outside the workspace has no membership to bind to, so the row is bound to the
|
||||
// account by the credential making the request: an account deleted during the remote call,
|
||||
// and its address taken by another, must not inherit it. A deleted credential that auth still
|
||||
// holds in its cache stands in for one whose account went away mid-call.
|
||||
sqlx::query!(
|
||||
"DELETE FROM usr WHERE workspace_id = 'test-workspace' AND email = 'test@windmill.dev'"
|
||||
)
|
||||
.execute(&db)
|
||||
.await?;
|
||||
sqlx::query!("DELETE FROM remote_deploy_token WHERE email = 'test@windmill.dev'")
|
||||
.execute(&db)
|
||||
.await?;
|
||||
sqlx::query!(
|
||||
"INSERT INTO token (token_hash, token_prefix, token, email, label, super_admin)
|
||||
VALUES (encode(sha256('GONE_TOKEN'::bytea), 'hex'), 'GONE_TOKEN', 'GONE_TOKEN',
|
||||
'test@windmill.dev', 'gone', true)"
|
||||
)
|
||||
.execute(&db)
|
||||
.await?;
|
||||
let as_gone =
|
||||
|builder: reqwest::RequestBuilder| builder.header("Authorization", "Bearer GONE_TOKEN");
|
||||
let resp = as_gone(client().get(format!("{base}/target")))
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(resp.status(), 200);
|
||||
sqlx::query!("DELETE FROM token WHERE token_prefix = 'GONE_TOKEN'")
|
||||
.execute(&db)
|
||||
.await?;
|
||||
let resp = as_gone(client().post(format!("{base}/connect")))
|
||||
.json(&json!({"token": "SECRET_TOKEN", "target": target}))
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(resp.status(), 400);
|
||||
let resp = authed(client().post(format!("{base}/connect")))
|
||||
.json(&json!({"token": "SECRET_TOKEN", "target": target}))
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(resp.status(), 200);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -319,6 +319,14 @@ async fn test_workspace_endpoints(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), 200);
|
||||
|
||||
let resp = authed(client().post(format!("{base}/edit_webhook")))
|
||||
.json(&json!({"webhook": "http://127.0.0.1/hook"}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), 400);
|
||||
assert!(resp.text().await?.contains("ALLOW_PRIVATE_WEBHOOK_URLS"));
|
||||
|
||||
// --- edit_auto_invite (EE-gated) ---
|
||||
let resp = authed(client().post(format!("{base}/edit_auto_invite")))
|
||||
.json(&json!({"operator": false, "invite_all": false, "auto_add": false}))
|
||||
|
||||
@@ -40,8 +40,8 @@ use windmill_common::{
|
||||
FlowVersionInfo, DB,
|
||||
};
|
||||
use windmill_queue::{
|
||||
cancel_job, get_result_and_success_by_id_from_flow, parse_result_object, push, PushArgs,
|
||||
PushArgsOwned, PushIsolationLevel,
|
||||
cancel_job, check_tag_available_for_push, get_result_and_success_by_id_from_flow,
|
||||
parse_result_object, push, tag_reads_args, PushArgs, PushArgsOwned, PushIsolationLevel,
|
||||
};
|
||||
|
||||
use crate::types::RunJobQuery;
|
||||
@@ -50,7 +50,27 @@ use crate::types::RunJobQuery;
|
||||
// Tag / license checks
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// `args` must be the ones the job is pushed with, see [`windmill_queue::check_tag_available_for_push`].
|
||||
pub async fn check_tag_available_for_workspace(
|
||||
db: &DB,
|
||||
w_id: &str,
|
||||
tag: &Option<String>,
|
||||
args: &PushArgs<'_>,
|
||||
authed: &ApiAuthed,
|
||||
) -> error::Result<()> {
|
||||
if let Some(tag) = tag.as_deref().filter(|t| !t.is_empty()) {
|
||||
let tags = get_scope_tags(authed);
|
||||
// Job-aware: a WM_TOKEN running as a superadmin must not unlock restricted tags.
|
||||
let is_super_admin = windmill_api_auth::is_super_admin_authed(db, authed).await?;
|
||||
check_tag_available_for_push(db, w_id, tag, args, is_super_admin, tags).await
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// [`check_tag_available_for_workspace`] for a tag filled in from values nothing can check yet,
|
||||
/// such as a flow's preprocessor output: only an entry spelled exactly like it admits it.
|
||||
pub async fn check_tag_as_written_available_for_workspace(
|
||||
db: &DB,
|
||||
w_id: &str,
|
||||
tag: &Option<String>,
|
||||
@@ -60,7 +80,16 @@ pub async fn check_tag_available_for_workspace(
|
||||
let tags = get_scope_tags(authed);
|
||||
// Job-aware: a WM_TOKEN running as a superadmin must not unlock restricted tags.
|
||||
let is_super_admin = windmill_api_auth::is_super_admin_authed(db, authed).await?;
|
||||
check_tag_available_for_workspace_internal(db, w_id, tag, is_super_admin, tags).await
|
||||
check_tag_available_for_workspace_internal(
|
||||
db,
|
||||
w_id,
|
||||
tag,
|
||||
None,
|
||||
std::future::ready(w_id.to_string()),
|
||||
is_super_admin,
|
||||
tags,
|
||||
)
|
||||
.await
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
@@ -760,9 +789,25 @@ pub async fn run_flow<'c>(
|
||||
..
|
||||
} = flow_version_info;
|
||||
|
||||
let tag = run_query.tag.clone().or(tag);
|
||||
let flow_tag = tag;
|
||||
let tag = run_query.tag.clone().or(flow_tag.clone());
|
||||
let push_args = PushArgs { args: &args.args, extra: args.extra };
|
||||
let apply_preprocessor =
|
||||
!run_query.skip_preprocessor.unwrap_or(false) && has_preprocessor.unwrap_or(false);
|
||||
|
||||
check_tag_available_for_workspace(&db, &w_id, &tag, &authed).await?;
|
||||
if !apply_preprocessor || run_query.tag.is_some() {
|
||||
check_tag_available_for_workspace(&db, &w_id, &tag, &push_args, &authed).await?;
|
||||
}
|
||||
// `push` drops the tag of a flow whose preprocessor runs, and the worker then puts the flow on
|
||||
// its own tag filled in from the preprocessor's output, after this caller's identity is gone.
|
||||
// So that tag is the one to check, and an `$args[...]` in it is judged as written.
|
||||
if apply_preprocessor {
|
||||
if flow_tag.as_deref().is_some_and(tag_reads_args) {
|
||||
check_tag_as_written_available_for_workspace(&db, &w_id, &flow_tag, &authed).await?;
|
||||
} else {
|
||||
check_tag_available_for_workspace(&db, &w_id, &flow_tag, &push_args, &authed).await?;
|
||||
}
|
||||
}
|
||||
let scheduled_for = run_query.get_scheduled_for(&db).await?;
|
||||
|
||||
let return_tx = tx_o.is_some();
|
||||
@@ -798,11 +843,10 @@ pub async fn run_flow<'c>(
|
||||
path: flow_path.to_string(),
|
||||
dedicated_worker,
|
||||
version,
|
||||
apply_preprocessor: !run_query.skip_preprocessor.unwrap_or(false)
|
||||
&& has_preprocessor.unwrap_or(false),
|
||||
apply_preprocessor,
|
||||
labels,
|
||||
},
|
||||
PushArgs { args: &args.args, extra: args.extra },
|
||||
push_args,
|
||||
authed.display_username(),
|
||||
email,
|
||||
permissioned_as,
|
||||
@@ -988,7 +1032,8 @@ pub async fn push_script_job_by_path_into_queue<'c>(
|
||||
let scheduled_for = run_query.get_scheduled_for(&db).await?;
|
||||
|
||||
let tag = run_query.tag.clone().or(tag);
|
||||
check_tag_available_for_workspace(&db, &w_id, &tag, &authed).await?;
|
||||
let push_args = PushArgs { args: &args.args, extra: args.extra };
|
||||
check_tag_available_for_workspace(&db, &w_id, &tag, &push_args, &authed).await?;
|
||||
|
||||
let return_tx = tx_o.is_some();
|
||||
|
||||
@@ -1020,7 +1065,7 @@ pub async fn push_script_job_by_path_into_queue<'c>(
|
||||
tx,
|
||||
&w_id,
|
||||
job_payload,
|
||||
PushArgs { args: &args.args, extra: args.extra },
|
||||
push_args,
|
||||
authed.display_username(),
|
||||
email,
|
||||
permissioned_as,
|
||||
|
||||
@@ -619,6 +619,7 @@ async fn edit_schedule(
|
||||
dynamic_skip = $23,
|
||||
email = $24,
|
||||
edited_by = $25,
|
||||
edited_at = now(),
|
||||
permissioned_as = $26,
|
||||
labels = COALESCE($27, labels)
|
||||
WHERE path = $19 AND workspace_id = $20
|
||||
|
||||
@@ -30,7 +30,11 @@ windmill-parser-ts-asset.workspace = true
|
||||
windmill-parser-sql-asset.workspace = true
|
||||
windmill-parser-sql.workspace = true
|
||||
windmill-parser-yaml.workspace = true
|
||||
windmill-parser-bash.workspace = true
|
||||
windmill-parser-go.workspace = true
|
||||
windmill-parser-graphql.workspace = true
|
||||
|
||||
anyhow.workspace = true
|
||||
axum.workspace = true
|
||||
futures.workspace = true
|
||||
http.workspace = true
|
||||
|
||||
@@ -1132,6 +1132,96 @@ async fn validate_dbt_relation(
|
||||
})
|
||||
}
|
||||
|
||||
/// The schema the editor would have derived from `content`, for a deploy that sends
|
||||
/// none (MCP, a bare API call). `None` for a language whose parser this build lacks,
|
||||
/// or code that does not parse.
|
||||
fn infer_main_schema(
|
||||
language: &ScriptLang,
|
||||
kind: Option<&ScriptKind>,
|
||||
content: &str,
|
||||
previous: Option<&serde_json::Value>,
|
||||
) -> Option<Schema> {
|
||||
let entrypoint =
|
||||
matches!(kind, Some(ScriptKind::Preprocessor)).then(|| "preprocessor".to_string());
|
||||
// The database a SQL script runs against is an argument unless the code names
|
||||
// it (`-- database f/...`), as in the editor.
|
||||
let with_db = |sig: anyhow::Result<windmill_parser::MainArgSignature>, resource: &str| {
|
||||
sig.map(|mut sig| {
|
||||
if windmill_parser_sql::parse_db_resource(content).is_none() {
|
||||
sig.args.insert(
|
||||
0,
|
||||
windmill_parser::Arg {
|
||||
name: "database".to_string(),
|
||||
typ: windmill_parser::Typ::Resource(resource.to_string()),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
}
|
||||
sig
|
||||
})
|
||||
};
|
||||
let sig = match language {
|
||||
ScriptLang::Bun | ScriptLang::Bunnative | ScriptLang::Deno | ScriptLang::Nativets => {
|
||||
windmill_parser_ts::parse_deno_signature(content, false, false, entrypoint)
|
||||
}
|
||||
#[cfg(feature = "python")]
|
||||
ScriptLang::Python3 => {
|
||||
windmill_parser_py::parse_python_signature(content, entrypoint, false)
|
||||
}
|
||||
ScriptLang::Go => windmill_parser_go::parse_go_sig(content),
|
||||
ScriptLang::Bash => windmill_parser_bash::parse_bash_sig(content),
|
||||
ScriptLang::Powershell => windmill_parser_bash::parse_powershell_sig(content),
|
||||
ScriptLang::Postgresql => {
|
||||
with_db(windmill_parser_sql::parse_pgsql_sig(content), "postgresql")
|
||||
}
|
||||
ScriptLang::Mysql => with_db(windmill_parser_sql::parse_mysql_sig(content), "mysql"),
|
||||
ScriptLang::Bigquery => {
|
||||
with_db(windmill_parser_sql::parse_bigquery_sig(content), "bigquery")
|
||||
}
|
||||
ScriptLang::Snowflake => with_db(
|
||||
windmill_parser_sql::parse_snowflake_sig(content),
|
||||
"snowflake",
|
||||
),
|
||||
ScriptLang::Mssql => with_db(
|
||||
windmill_parser_sql::parse_mssql_sig(content),
|
||||
"ms_sql_server",
|
||||
),
|
||||
ScriptLang::OracleDB => {
|
||||
with_db(windmill_parser_sql::parse_oracledb_sig(content), "oracledb")
|
||||
}
|
||||
ScriptLang::DuckDb => windmill_parser_sql::parse_duckdb_sig(content),
|
||||
ScriptLang::Graphql => {
|
||||
windmill_parser_graphql::parse_graphql_sig(content).map(|mut sig| {
|
||||
sig.args.insert(
|
||||
0,
|
||||
windmill_parser::Arg {
|
||||
name: "api".to_string(),
|
||||
typ: windmill_parser::Typ::Resource("graphql".to_string()),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
sig
|
||||
})
|
||||
}
|
||||
ScriptLang::Ansible => windmill_parser_yaml::parse_ansible_sig(content),
|
||||
_ => return None,
|
||||
};
|
||||
match sig {
|
||||
Ok(sig) => serde_json::value::to_raw_value(&windmill_parser::json_schema::main_arg_schema(
|
||||
&sig, previous,
|
||||
))
|
||||
.ok()
|
||||
.map(|v| Schema(sqlx::types::Json(v))),
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
"could not infer the schema of a {} script: {e:#}",
|
||||
language.as_str()
|
||||
);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn create_script_internal<'c>(
|
||||
mut ns: NewScript,
|
||||
w_id: String,
|
||||
@@ -1359,6 +1449,40 @@ async fn create_script_internal<'c>(
|
||||
parent_adopted_from_retired_path = ns.parent_hash.is_some();
|
||||
}
|
||||
|
||||
// Before hashing, so the hash and the no-op check see the schema that gets stored.
|
||||
// `{}` counts as absent: an agent filling every tool argument sends it for "none".
|
||||
let schema_absent = ns.schema.as_ref().is_none_or(|s| {
|
||||
serde_json::from_str::<serde_json::Map<String, serde_json::Value>>(s.0.get())
|
||||
.is_ok_and(|m| m.is_empty())
|
||||
});
|
||||
if schema_absent && !matches!(ns.language, ScriptLang::Dbt) {
|
||||
let previous = match &ns.parent_hash {
|
||||
Some(p_hash) => sqlx::query_scalar::<_, Option<sqlx::types::Json<serde_json::Value>>>(
|
||||
"SELECT schema FROM script WHERE hash = $1 AND workspace_id = $2",
|
||||
)
|
||||
.bind(p_hash.0)
|
||||
.bind(&w_id)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?
|
||||
.flatten()
|
||||
.map(|s| s.0),
|
||||
None => None,
|
||||
};
|
||||
// Code the server cannot parse keeps the previous version's schema, as it
|
||||
// does in the editor, rather than losing it for good.
|
||||
ns.schema = infer_main_schema(
|
||||
&ns.language,
|
||||
ns.kind.as_ref(),
|
||||
&ns.content,
|
||||
previous.as_ref(),
|
||||
)
|
||||
.or_else(|| {
|
||||
previous
|
||||
.and_then(|p| serde_json::value::to_raw_value(&p).ok())
|
||||
.map(|v| Schema(sqlx::types::Json(v)))
|
||||
});
|
||||
}
|
||||
|
||||
// Must stay below the parent resolution above: an auto_parent deploy hashed before
|
||||
// it carries a first deploy's lineage, so redeploying content the path has held
|
||||
// before collides with that archived version instead of superseding it. The
|
||||
|
||||
@@ -58,9 +58,9 @@ use windmill_common::{
|
||||
error::{self, pg_error_message, JsonResult, Result},
|
||||
get_database_url,
|
||||
global_settings::{
|
||||
AI_CONFIG_SETTING, APP_WORKSPACED_ROUTE_SETTING, AUTOMATE_USERNAME_CREATION_SETTING,
|
||||
CRITICAL_ALERT_MUTE_UI_SETTING, CUSTOM_TAGS_SETTING, DEFAULT_TAGS_WORKSPACES_SETTING,
|
||||
DISABLE_HUB_SETTING, EMAIL_DOMAIN_SETTING, ENV_SETTINGS,
|
||||
ACCENT_COLOR_SETTING, AI_CONFIG_SETTING, APP_WORKSPACED_ROUTE_SETTING,
|
||||
AUTOMATE_USERNAME_CREATION_SETTING, CRITICAL_ALERT_MUTE_UI_SETTING, CUSTOM_TAGS_SETTING,
|
||||
DEFAULT_TAGS_WORKSPACES_SETTING, DISABLE_HUB_SETTING, EMAIL_DOMAIN_SETTING, ENV_SETTINGS,
|
||||
GITHUB_APP_WEBHOOK_BASE_URL_SETTING, HTTP_ROUTE_DEFAULT_ALLOWED_ORIGINS_SETTING,
|
||||
HTTP_ROUTE_WORKSPACED_ROUTE_SETTING, HUB_ACCESSIBLE_URL_SETTING, HUB_BASE_URL_SETTING,
|
||||
INSTANCE_BANNER_SETTING, MAX_RETENTION_OVERRIDE_WORKSPACES,
|
||||
@@ -115,6 +115,17 @@ async fn get_ruff_config_unauthed(Extension(db): Extension<DB>) -> error::Result
|
||||
.unwrap())
|
||||
}
|
||||
|
||||
/// The announcement banner and accent color, which every signed-in session reads on each
|
||||
/// full page load.
|
||||
async fn get_instance_ui(
|
||||
Extension(db): Extension<DB>,
|
||||
_authed: ApiAuthed,
|
||||
) -> JsonResult<windmill_common::global_settings::InstanceUi> {
|
||||
Ok(Json(
|
||||
windmill_common::global_settings::get_instance_ui(&db).await?,
|
||||
))
|
||||
}
|
||||
|
||||
pub fn global_service() -> Router {
|
||||
#[warn(unused_mut)]
|
||||
let r = Router::new()
|
||||
@@ -126,6 +137,7 @@ pub fn global_service() -> Router {
|
||||
"/global/{key}",
|
||||
post(set_global_setting).get(get_global_setting),
|
||||
)
|
||||
.route("/instance_ui", get(get_instance_ui))
|
||||
.route("/list_global", get(list_global_settings))
|
||||
.route("/github_app_stale_webhooks", get(github_app_stale_webhooks))
|
||||
.route(
|
||||
@@ -1216,6 +1228,12 @@ async fn run_setting_pre_write_hook(
|
||||
}
|
||||
}
|
||||
}
|
||||
ACCENT_COLOR_SETTING => match value {
|
||||
serde_json::Value::Null => {}
|
||||
serde_json::Value::String(s) if s.trim().is_empty() => {}
|
||||
v => windmill_common::global_settings::validate_accent_color(v)
|
||||
.map_err(|e| error::Error::BadRequest(format!("{ACCENT_COLOR_SETTING}: {e}")))?,
|
||||
},
|
||||
_ => {}
|
||||
}
|
||||
Ok(())
|
||||
|
||||
@@ -171,10 +171,7 @@ pub fn make_unauthed_service() -> Router {
|
||||
.route("/logout", post(logout).get(logout))
|
||||
.route("/is_first_time_setup", get(is_first_time_setup))
|
||||
.route("/request_password_reset", post(request_password_reset))
|
||||
.route(
|
||||
"/login_link/{token}",
|
||||
get(consume_login_link).post(confirm_login_link),
|
||||
)
|
||||
.route("/login_link/{token}", get(consume_login_link))
|
||||
.route("/is_smtp_configured", get(is_smtp_configured))
|
||||
.route(
|
||||
"/is_password_login_disabled",
|
||||
@@ -2566,6 +2563,14 @@ pub async fn delete_workspace_user_internal(
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
|
||||
sqlx::query!(
|
||||
"DELETE FROM remote_deploy_token WHERE email = $1 AND workspace_id = $2",
|
||||
email_to_delete,
|
||||
w_id
|
||||
)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
|
||||
sqlx::query!(
|
||||
"DELETE FROM usr_to_group WHERE usr = $1 AND workspace_id = $2",
|
||||
username_to_delete,
|
||||
@@ -3287,12 +3292,9 @@ async fn impersonate(
|
||||
}
|
||||
|
||||
const LOGIN_LINK_DEFAULT_TTL_S: u32 = 600;
|
||||
// Long enough for a link sent by email to still work when it is read. `require_login_type` is
|
||||
// only checked at mint, so a much longer cap would need re-checking it when the link is opened.
|
||||
const LOGIN_LINK_MAX_TTL_S: u32 = 7200;
|
||||
const LOGIN_LINK_MAX_TTL_S: u32 = 900;
|
||||
const LOGIN_LINK_DEFAULT_RD: &str = "/user/workspaces";
|
||||
const LOGIN_LINK_EXPIRED_PAGE: &str = "/user/login_link_expired";
|
||||
const LOGIN_LINK_CONFIRM_PAGE: &str = "/user/login_link";
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct NewLoginLink {
|
||||
@@ -3303,9 +3305,6 @@ pub struct NewLoginLink {
|
||||
/// account it created can require `pending_oauth`, so the link stops working once the
|
||||
/// owner has set a password or signed in with a provider.
|
||||
pub require_login_type: Option<String>,
|
||||
/// Hand out a page that signs in only when its button is clicked. Mail scanners open links
|
||||
/// on delivery, and opening the plain link spends it, so a link sent by email sets this.
|
||||
pub confirm: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
@@ -3456,12 +3455,11 @@ async fn create_login_link(
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
|
||||
let base_url = (**BASE_URL.load()).clone();
|
||||
let url = if nl.confirm.unwrap_or(false) {
|
||||
format!("{base_url}{LOGIN_LINK_CONFIRM_PAGE}?token={token}")
|
||||
} else {
|
||||
format!("{base_url}/api/auth/login_link/{token}")
|
||||
};
|
||||
let url = format!(
|
||||
"{}/api/auth/login_link/{}",
|
||||
(**BASE_URL.load()).clone(),
|
||||
token
|
||||
);
|
||||
Ok((StatusCode::CREATED, Json(LoginLink { url, expires_at })))
|
||||
}
|
||||
|
||||
@@ -3707,45 +3705,19 @@ async fn consume_login_link(
|
||||
Path(token): Path<String>,
|
||||
Query(query): Query<LoginLinkQuery>,
|
||||
) -> Result<Response> {
|
||||
let location = redeem_login_link(&headers, cookies, &db, &token, query.rd).await?;
|
||||
Ok(login_link_redirect(location))
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct LoginLinkLocation {
|
||||
location: String,
|
||||
}
|
||||
|
||||
/// The confirmation page's click. It answers with where to go rather than redirecting, and the
|
||||
/// page navigates there itself.
|
||||
async fn confirm_login_link(
|
||||
headers: axum::http::HeaderMap,
|
||||
cookies: Cookies,
|
||||
Extension(db): Extension<DB>,
|
||||
Path(token): Path<String>,
|
||||
) -> JsonResult<LoginLinkLocation> {
|
||||
let location = redeem_login_link(&headers, cookies, &db, &token, None).await?;
|
||||
Ok(Json(LoginLinkLocation { location }))
|
||||
}
|
||||
|
||||
/// Spends the link and sets the session cookie, returning the post-login destination; or
|
||||
/// returns the explanation page, with no session, when the link cannot be used.
|
||||
async fn redeem_login_link(
|
||||
headers: &axum::http::HeaderMap,
|
||||
cookies: Cookies,
|
||||
db: &DB,
|
||||
token: &str,
|
||||
requested_rd: Option<String>,
|
||||
) -> Result<String> {
|
||||
let bounce = |reason: &str| Ok(format!("{LOGIN_LINK_EXPIRED_PAGE}?reason={reason}"));
|
||||
let bounce = |reason: &str| {
|
||||
Ok(login_link_redirect(format!(
|
||||
"{LOGIN_LINK_EXPIRED_PAGE}?reason={reason}"
|
||||
)))
|
||||
};
|
||||
if token.len() != 32 {
|
||||
return bounce("invalid");
|
||||
}
|
||||
let t_hash = hash_token(token);
|
||||
let t_hash = hash_token(&token);
|
||||
// The account is unknown until the row is read, so only the global and per-IP tiers
|
||||
// apply here; a 32-char random token leaves nothing for the per-account tier to guard.
|
||||
windmill_common::login_rate_limit::check_and_increment_login_attempt(
|
||||
headers,
|
||||
&headers,
|
||||
&t_hash[..TOKEN_PREFIX_LEN],
|
||||
)?;
|
||||
|
||||
@@ -3812,10 +3784,11 @@ async fn redeem_login_link(
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
|
||||
Ok(link
|
||||
let rd = link
|
||||
.rd
|
||||
.or_else(|| same_origin_rd(requested_rd))
|
||||
.unwrap_or_else(|| LOGIN_LINK_DEFAULT_RD.to_string()))
|
||||
.or_else(|| same_origin_rd(query.rd))
|
||||
.unwrap_or_else(|| LOGIN_LINK_DEFAULT_RD.to_string());
|
||||
Ok(login_link_redirect(rd))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -4050,6 +4023,7 @@ async fn update_token_label(
|
||||
AND NOT starts_with(label, 'sdk_app:')
|
||||
AND NOT starts_with(label, 'impersonation:')
|
||||
AND NOT starts_with(label, 'cli-login:')
|
||||
AND NOT starts_with(label, 'remote-deploy:')
|
||||
))
|
||||
RETURNING token_prefix",
|
||||
req.label.as_deref(),
|
||||
@@ -4096,13 +4070,25 @@ async fn leave_workspace(
|
||||
&format!("u/{}", authed.username),
|
||||
)
|
||||
.await?;
|
||||
sqlx::query!(
|
||||
let left = sqlx::query!(
|
||||
"DELETE FROM usr WHERE workspace_id = $1 AND username = $2",
|
||||
&w_id,
|
||||
authed.username
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
.await?
|
||||
.rows_affected();
|
||||
// A superadmin deploys from workspaces it is no member of; leaving none is no reason to drop
|
||||
// its connection.
|
||||
if left > 0 {
|
||||
sqlx::query!(
|
||||
"DELETE FROM remote_deploy_token WHERE email = $1 AND workspace_id = $2",
|
||||
&authed.email,
|
||||
&w_id
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
}
|
||||
|
||||
audit_log(
|
||||
&mut *tx,
|
||||
|
||||
@@ -35,6 +35,7 @@ windmill-store.workspace = true
|
||||
|
||||
axum.workspace = true
|
||||
chrono.workspace = true
|
||||
constant_time_eq.workspace = true
|
||||
futures = { workspace = true, optional = true }
|
||||
hex.workspace = true
|
||||
magic-crypt.workspace = true
|
||||
@@ -42,6 +43,7 @@ http.workspace = true
|
||||
hyper.workspace = true
|
||||
lazy_static.workspace = true
|
||||
regex.workspace = true
|
||||
reqwest.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
sha2.workspace = true
|
||||
@@ -49,5 +51,6 @@ sqlx.workspace = true
|
||||
tokio.workspace = true
|
||||
tokio-postgres.workspace = true
|
||||
tracing.workspace = true
|
||||
url.workspace = true
|
||||
uuid.workspace = true
|
||||
strum.workspace = true
|
||||
|
||||
@@ -9,6 +9,7 @@ pub mod datatable_permissions;
|
||||
pub mod datatable_permissions_oss;
|
||||
pub mod datatable_replay_oss;
|
||||
pub mod deployment_requests;
|
||||
pub mod remote_deploy;
|
||||
pub mod workspaces;
|
||||
pub mod workspaces_extra;
|
||||
pub mod workspaces_oss;
|
||||
|
||||
@@ -0,0 +1,794 @@
|
||||
/*
|
||||
* Author: Ruben Fiszel
|
||||
* Copyright: Windmill Labs, Inc 2026
|
||||
* This file and its contents are licensed under the AGPLv3 License.
|
||||
* Please see the included NOTICE for copyright information and
|
||||
* LICENSE-AGPL for a copy of the license.
|
||||
*/
|
||||
|
||||
//! Deploying from the UI into a workspace of another Windmill instance.
|
||||
//!
|
||||
//! The browser runs the same deploy it runs between two workspaces of this instance, and sends
|
||||
//! every call aimed at the target through [`proxy`], which forwards it to the remote workspace
|
||||
//! with the caller's own token for that instance. The remote authorizes and audits the deploy as
|
||||
//! that person, so nothing here needs to know what a deploy is made of.
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use axum::{
|
||||
body::{Body, Bytes},
|
||||
extract::{DefaultBodyLimit, Extension, OriginalUri, Path},
|
||||
http::{header, HeaderMap, Method, StatusCode},
|
||||
response::Response,
|
||||
routing::{any, get, post},
|
||||
Json, Router,
|
||||
};
|
||||
use chrono::{DateTime, Utc};
|
||||
use lazy_static::lazy_static;
|
||||
use magic_crypt::MagicCrypt256;
|
||||
use regex::Regex;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use windmill_api_auth::{is_effectively_unscoped, ApiAuthed, Tokened};
|
||||
use windmill_audit::{audit_oss::audit_log, ActionKind};
|
||||
use windmill_common::{
|
||||
auth::hash_token,
|
||||
error::{error_source_chain, Error, Result},
|
||||
ssrf::validate_url_for_ssrf,
|
||||
utils::{configure_client, rd_string, require_admin},
|
||||
variables::{build_crypt, decrypt, encrypt},
|
||||
worker::CLOUD_HOSTED,
|
||||
DB,
|
||||
};
|
||||
|
||||
lazy_static! {
|
||||
static ref REMOTE_WORKSPACE_ID: Regex = Regex::new("^[a-zA-Z0-9_-]{1,50}$").unwrap();
|
||||
/// Shared so that a deploy's calls reuse connections. A cloud instance instead builds a client
|
||||
/// per request, pinned to the addresses it just validated for that target.
|
||||
static ref REMOTE_CLIENT: reqwest::Client = remote_client_builder().build().unwrap();
|
||||
}
|
||||
|
||||
const PROXY_PREFIX: &str = "/remote_deploy/proxy/";
|
||||
|
||||
pub fn workspaced_service(proxy_body_limit: usize) -> Router {
|
||||
Router::new()
|
||||
.route("/target", get(get_target).post(set_target))
|
||||
.route("/connect", post(connect))
|
||||
.route("/disconnect", post(disconnect))
|
||||
// `{key}` is the caller's `proxy_key`. The session cookie is `SameSite=Lax`, so it rides a
|
||||
// top-level navigation from any site: without a value such a link cannot know, it could
|
||||
// spend the stored token, to run something on the remote or to render its content on this
|
||||
// origin. `Sec-Fetch-Site` is no substitute, since plain-http instances never receive it.
|
||||
.route(
|
||||
"/proxy/{key}/{*rest}",
|
||||
any(proxy).layer(DefaultBodyLimit::max(proxy_body_limit)),
|
||||
)
|
||||
}
|
||||
|
||||
/// A request URI as logs should record it: with the proxy key masked, since that key is what keeps
|
||||
/// a link from spending a stored remote token.
|
||||
pub struct RedactedUri<'a>(pub &'a axum::http::Uri);
|
||||
|
||||
impl std::fmt::Display for RedactedUri<'_> {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
let Some((head, keyed)) = self.0.path().split_once(PROXY_PREFIX) else {
|
||||
return self.0.fmt(f);
|
||||
};
|
||||
let rest = keyed.find('/').map_or("", |i| &keyed[i..]);
|
||||
write!(f, "{head}{PROXY_PREFIX}***{rest}")?;
|
||||
match self.0.query() {
|
||||
Some(query) => write!(f, "?{query}"),
|
||||
None => Ok(()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||
pub struct RemoteDeployTarget {
|
||||
/// Root URL of the remote instance, without `/api`.
|
||||
pub base_url: String,
|
||||
pub workspace_id: String,
|
||||
}
|
||||
|
||||
impl RemoteDeployTarget {
|
||||
fn normalized(self) -> Result<Self> {
|
||||
let base_url = self.base_url.trim().trim_end_matches('/').to_string();
|
||||
let parsed = url::Url::parse(&base_url)
|
||||
.map_err(|e| Error::BadRequest(format!("Invalid remote instance URL: {e}")))?;
|
||||
if !matches!(parsed.scheme(), "http" | "https") || parsed.host_str().is_none() {
|
||||
return Err(Error::BadRequest(
|
||||
"The remote instance URL must be an http(s) URL with a host".to_string(),
|
||||
));
|
||||
}
|
||||
if !parsed.username().is_empty()
|
||||
|| parsed.password().is_some()
|
||||
|| parsed.query().is_some()
|
||||
|| parsed.fragment().is_some()
|
||||
{
|
||||
return Err(Error::BadRequest(
|
||||
"The remote instance URL must not carry credentials, a query or a fragment"
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
let workspace_id = self.workspace_id.trim().to_string();
|
||||
if !REMOTE_WORKSPACE_ID.is_match(&workspace_id) {
|
||||
return Err(Error::BadRequest(format!(
|
||||
"Invalid remote workspace id: {workspace_id}"
|
||||
)));
|
||||
}
|
||||
Ok(Self { base_url, workspace_id })
|
||||
}
|
||||
|
||||
fn api_url(&self, suffix: &str) -> String {
|
||||
format!("{}/api/w/{}/{suffix}", self.base_url, self.workspace_id)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct RemoteDeployConnection {
|
||||
remote_email: String,
|
||||
/// Goes in every proxy URL, see [`workspaced_service`].
|
||||
proxy_key: String,
|
||||
connected_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct RemoteDeployStatus {
|
||||
target: Option<RemoteDeployTarget>,
|
||||
/// The caller's own connection to `target`.
|
||||
connection: Option<RemoteDeployConnection>,
|
||||
}
|
||||
|
||||
/// A stored token acts on another instance as its owner, with none of the restrictions of the
|
||||
/// credential that reaches it. Only the owner acting directly with full rights may use or replace
|
||||
/// it: a job token runs as whoever a `wm_deployers` member pointed `on_behalf_of` at, and a
|
||||
/// scoped or read-only token was never granted this.
|
||||
fn require_own_credentials(authed: &ApiAuthed) -> Result<()> {
|
||||
if authed.job_id.is_some()
|
||||
|| authed.read_only
|
||||
|| !is_effectively_unscoped(authed.scopes.as_deref())
|
||||
{
|
||||
return Err(Error::PermissionDenied(
|
||||
"Deploying to a remote instance requires your own session or an unscoped token, \
|
||||
not a job, scoped or read-only token"
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn parse_target(target: Option<serde_json::Value>) -> Result<Option<RemoteDeployTarget>> {
|
||||
target
|
||||
.map(serde_json::from_value)
|
||||
.transpose()
|
||||
.map_err(|e| Error::internal_err(format!("reading the remote deploy target: {e}")))
|
||||
}
|
||||
|
||||
fn no_target(w_id: &str) -> Error {
|
||||
Error::BadRequest(format!(
|
||||
"Workspace {w_id} has no remote deploy target. An admin sets it in the workspace \
|
||||
settings, under Dev workspace"
|
||||
))
|
||||
}
|
||||
|
||||
async fn load_target(db: &DB, w_id: &str) -> Result<Option<RemoteDeployTarget>> {
|
||||
let target = sqlx::query_scalar!(
|
||||
"SELECT remote_deploy_target FROM workspace_settings WHERE workspace_id = $1",
|
||||
w_id
|
||||
)
|
||||
.fetch_optional(db)
|
||||
.await?
|
||||
.flatten();
|
||||
parse_target(target)
|
||||
}
|
||||
|
||||
async fn require_target(db: &DB, w_id: &str) -> Result<RemoteDeployTarget> {
|
||||
load_target(db, w_id).await?.ok_or_else(|| no_target(w_id))
|
||||
}
|
||||
|
||||
fn remote_client_builder() -> reqwest::ClientBuilder {
|
||||
configure_client(reqwest::ClientBuilder::new())
|
||||
.user_agent("windmill/remote-deploy")
|
||||
.redirect(reqwest::redirect::Policy::none())
|
||||
.connect_timeout(Duration::from_secs(10))
|
||||
.timeout(Duration::from_secs(120))
|
||||
}
|
||||
|
||||
async fn remote_client(target: &RemoteDeployTarget) -> Result<reqwest::Client> {
|
||||
// A workspace admin chose the URL. A self-hosted instance may deploy into its own private
|
||||
// network, as it may reach a private git remote; a cloud workspace must not.
|
||||
if !*CLOUD_HOSTED {
|
||||
return Ok(REMOTE_CLIENT.clone());
|
||||
}
|
||||
validate_url_for_ssrf(&target.base_url)
|
||||
.await?
|
||||
.apply_dns_pinning(remote_client_builder())
|
||||
.build()
|
||||
.map_err(|e| Error::internal_err(format!("building the remote deploy client: {e}")))
|
||||
}
|
||||
|
||||
fn unreachable(target: &RemoteDeployTarget, e: reqwest::Error) -> Error {
|
||||
Error::BadGateway(format!(
|
||||
"Could not reach the remote instance {}: {}",
|
||||
target.base_url,
|
||||
error_source_chain(&e)
|
||||
))
|
||||
}
|
||||
|
||||
/// Responses carrying a `proxy_key` must not be kept by any cache between here and the browser.
|
||||
const NO_STORE: [(header::HeaderName, &str); 1] = [(header::CACHE_CONTROL, "no-store")];
|
||||
|
||||
struct StoredConnection {
|
||||
token: String,
|
||||
remote_email: String,
|
||||
proxy_key: String,
|
||||
connected_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// The caller's connection to `target`, if they may still use it.
|
||||
///
|
||||
/// `connect` takes no lock against what clears these rows (a removal from the workspace, a target
|
||||
/// change, a key rotation): writers take those rows in every order, so any lock it held could close
|
||||
/// a deadlock. A row can therefore land after one of them ran, however long its remote call took,
|
||||
/// and is voided here instead, by identity rather than by comparing times: it counts only while
|
||||
/// the target setting and the owner's membership are the very ones it was connected under (see
|
||||
/// `member_since`, `target_changed_at`) or the owner is a superadmin, and while it decrypts. An
|
||||
/// emptied row is a disconnect's.
|
||||
async fn load_connection(
|
||||
db: &DB,
|
||||
w_id: &str,
|
||||
email: &str,
|
||||
target: &RemoteDeployTarget,
|
||||
) -> Result<Option<StoredConnection>> {
|
||||
let Some(row) = sqlx::query_as!(
|
||||
StoredConnection,
|
||||
"SELECT t.token, t.remote_email, t.proxy_key, t.connected_at FROM remote_deploy_token t
|
||||
JOIN workspace_settings s ON s.workspace_id = t.workspace_id
|
||||
WHERE t.workspace_id = $1 AND t.email = $2 AND t.base_url = $3
|
||||
AND t.remote_workspace_id = $4 AND t.token <> ''
|
||||
AND s.remote_deploy_target_changed_at IS NOT DISTINCT FROM t.target_changed_at
|
||||
AND (EXISTS (SELECT 1 FROM usr u WHERE u.workspace_id = t.workspace_id
|
||||
AND u.email = t.email AND u.created_at = t.member_since)
|
||||
OR EXISTS (SELECT 1 FROM password p WHERE p.email = t.email AND p.super_admin))",
|
||||
w_id,
|
||||
email,
|
||||
&target.base_url,
|
||||
&target.workspace_id
|
||||
)
|
||||
.fetch_optional(db)
|
||||
.await?
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
match decrypt(&build_crypt(db, w_id).await?, row.token) {
|
||||
Ok(token) => Ok(Some(StoredConnection { token, ..row })),
|
||||
Err(e) => {
|
||||
tracing::warn!("remote deploy token of {email} in {w_id} does not decrypt: {e}");
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn get_target(
|
||||
authed: ApiAuthed,
|
||||
Extension(db): Extension<DB>,
|
||||
Path(w_id): Path<String>,
|
||||
) -> Result<(
|
||||
[(header::HeaderName, &'static str); 1],
|
||||
Json<RemoteDeployStatus>,
|
||||
)> {
|
||||
let target = load_target(&db, &w_id).await?;
|
||||
// The connection carries the proxy key. A credential that may not use the proxy (see
|
||||
// `require_own_credentials`) must not read it either: it could hand its owner a working link.
|
||||
let connection = match &target {
|
||||
Some(target) if require_own_credentials(&authed).is_ok() => {
|
||||
load_connection(&db, &w_id, &authed.email, target)
|
||||
.await?
|
||||
.map(|c| RemoteDeployConnection {
|
||||
remote_email: c.remote_email,
|
||||
proxy_key: c.proxy_key,
|
||||
connected_at: c.connected_at,
|
||||
})
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
Ok((NO_STORE, Json(RemoteDeployStatus { target, connection })))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct SetRemoteDeployTarget {
|
||||
target: Option<RemoteDeployTarget>,
|
||||
}
|
||||
|
||||
async fn set_target(
|
||||
authed: ApiAuthed,
|
||||
Extension(db): Extension<DB>,
|
||||
Path(w_id): Path<String>,
|
||||
Json(request): Json<SetRemoteDeployTarget>,
|
||||
) -> Result<String> {
|
||||
if !cfg!(feature = "enterprise") {
|
||||
return Err(Error::BadRequest(
|
||||
"Deploying to another instance is only available on Windmill Enterprise Edition"
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
// Everyone who connects afterwards hands their token to this URL.
|
||||
require_own_credentials(&authed)?;
|
||||
require_admin(authed.is_admin, &authed.username)?;
|
||||
let target = request
|
||||
.target
|
||||
.map(RemoteDeployTarget::normalized)
|
||||
.transpose()?;
|
||||
|
||||
let mut tx = db.begin().await?;
|
||||
let (base_url, remote_workspace_id) = match &target {
|
||||
Some(t) => (Some(t.base_url.as_str()), Some(t.workspace_id.as_str())),
|
||||
None => (None, None),
|
||||
};
|
||||
audit_log(
|
||||
&mut *tx,
|
||||
&authed,
|
||||
"workspaces.edit_remote_deploy_target",
|
||||
ActionKind::Update,
|
||||
&w_id,
|
||||
None,
|
||||
Some(
|
||||
[
|
||||
("base_url", base_url.unwrap_or("")),
|
||||
("remote_workspace_id", remote_workspace_id.unwrap_or("")),
|
||||
]
|
||||
.into(),
|
||||
),
|
||||
)
|
||||
.await?;
|
||||
let target_json = target
|
||||
.as_ref()
|
||||
.map(serde_json::to_value)
|
||||
.transpose()
|
||||
.map_err(|e| Error::internal_err(e.to_string()))?;
|
||||
sqlx::query!(
|
||||
"UPDATE workspace_settings SET remote_deploy_target = $1::jsonb,
|
||||
remote_deploy_target_changed_at = CASE
|
||||
WHEN remote_deploy_target IS DISTINCT FROM $1::jsonb THEN clock_timestamp()
|
||||
ELSE remote_deploy_target_changed_at END
|
||||
WHERE workspace_id = $2",
|
||||
target_json,
|
||||
&w_id
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
// A token is only ever sent to the target it was granted for, so the ones for any other
|
||||
// target are dead credentials.
|
||||
sqlx::query!(
|
||||
"DELETE FROM remote_deploy_token WHERE workspace_id = $1
|
||||
AND ($2::text IS NULL OR base_url <> $2 OR remote_workspace_id <> $3)",
|
||||
&w_id,
|
||||
base_url,
|
||||
remote_workspace_id
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
|
||||
Ok(match target {
|
||||
Some(t) => format!(
|
||||
"Workspace {w_id} deploys to {} on {}",
|
||||
t.workspace_id, t.base_url
|
||||
),
|
||||
None => format!("Removed the remote deploy target of workspace {w_id}"),
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct ConnectRequest {
|
||||
token: String,
|
||||
/// The target the caller got the token for, as the drawer showed it.
|
||||
target: RemoteDeployTarget,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct RemoteWhoami {
|
||||
email: String,
|
||||
}
|
||||
|
||||
async fn connect(
|
||||
authed: ApiAuthed,
|
||||
Tokened { token: credential }: Tokened,
|
||||
Extension(db): Extension<DB>,
|
||||
Path(w_id): Path<String>,
|
||||
Json(request): Json<ConnectRequest>,
|
||||
) -> Result<(
|
||||
[(header::HeaderName, &'static str); 1],
|
||||
Json<RemoteDeployConnection>,
|
||||
)> {
|
||||
require_own_credentials(&authed)?;
|
||||
// Read before the remote call, which can take long enough for any of it to change: the row is
|
||||
// bound to the target version and the membership seen here (see `load_connection`), and
|
||||
// stamped with when this connect started, so that a disconnect or a newer connect made while
|
||||
// it runs keeps it from landing.
|
||||
let start = sqlx::query!(
|
||||
r#"SELECT clock_timestamp() AS "started_at!", s.remote_deploy_target,
|
||||
s.remote_deploy_target_changed_at,
|
||||
(SELECT u.created_at FROM usr u
|
||||
WHERE u.workspace_id = s.workspace_id AND u.email = $2) AS member_since
|
||||
FROM workspace_settings s WHERE s.workspace_id = $1"#,
|
||||
&w_id,
|
||||
&authed.email
|
||||
)
|
||||
.fetch_optional(&db)
|
||||
.await?
|
||||
.ok_or_else(|| no_target(&w_id))?;
|
||||
let target = parse_target(start.remote_deploy_target)?.ok_or_else(|| no_target(&w_id))?;
|
||||
// A token is only ever sent to the instance it was meant for. Without this, re-pointing the
|
||||
// target between the user getting a token and posting it here would hand it to the new one;
|
||||
// after this check the token still goes to `target` only, never to what the setting became.
|
||||
let requested = request.target.normalized()?;
|
||||
if requested.base_url != target.base_url || requested.workspace_id != target.workspace_id {
|
||||
return Err(Error::BadRequest(
|
||||
"The remote deploy target changed since you started connecting; reload and connect \
|
||||
again"
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
let token = request.token.trim();
|
||||
if token.is_empty() {
|
||||
return Err(Error::BadRequest("The token is empty".to_string()));
|
||||
}
|
||||
|
||||
let response = remote_client(&target)
|
||||
.await?
|
||||
.get(target.api_url("users/whoami"))
|
||||
.bearer_auth(token)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| unreachable(&target, e))?;
|
||||
let status = response.status();
|
||||
if !status.is_success() {
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
return Err(Error::BadRequest(format!(
|
||||
"{} did not accept this token for workspace {} ({status}): {body}",
|
||||
target.base_url, target.workspace_id
|
||||
)));
|
||||
}
|
||||
let whoami: RemoteWhoami = response.json().await.map_err(|e| {
|
||||
Error::BadGateway(format!(
|
||||
"{} did not answer like a Windmill instance: {}",
|
||||
target.base_url,
|
||||
error_source_chain(&e)
|
||||
))
|
||||
})?;
|
||||
|
||||
let proxy_key = rd_string(32);
|
||||
let mc = build_crypt(&db, &w_id).await?;
|
||||
// No lock against removals, target changes or key rotations: `load_connection` voids a row
|
||||
// that lands after one of them. A disconnect or a newer connect stamps the row itself, which
|
||||
// this one then leaves alone. Without a membership to bind to, the row is bound to the account
|
||||
// through the credential making this request, which deleting the account removes (except
|
||||
// through `leave_instance`, which leaves its tokens): the address could otherwise be deleted
|
||||
// and taken by a new account while the remote call runs, and the foreign key would accept it.
|
||||
let mut tx = db.begin().await?;
|
||||
let connected_at = sqlx::query_scalar!(
|
||||
"INSERT INTO remote_deploy_token
|
||||
(workspace_id, email, base_url, remote_workspace_id, token, remote_email, proxy_key,
|
||||
connected_at, member_since, target_changed_at)
|
||||
SELECT $1, $2, $3, $4, $5, $6, $7, $8, $9, $10
|
||||
WHERE $9::timestamptz IS NOT NULL
|
||||
OR EXISTS (SELECT 1 FROM token WHERE token_hash = $11)
|
||||
ON CONFLICT (workspace_id, email) DO UPDATE SET
|
||||
base_url = EXCLUDED.base_url, remote_workspace_id = EXCLUDED.remote_workspace_id,
|
||||
token = EXCLUDED.token, remote_email = EXCLUDED.remote_email,
|
||||
proxy_key = EXCLUDED.proxy_key, connected_at = EXCLUDED.connected_at,
|
||||
member_since = EXCLUDED.member_since, target_changed_at = EXCLUDED.target_changed_at
|
||||
WHERE remote_deploy_token.connected_at < EXCLUDED.connected_at
|
||||
RETURNING connected_at",
|
||||
&w_id,
|
||||
&authed.email,
|
||||
&target.base_url,
|
||||
&target.workspace_id,
|
||||
encrypt(&mc, token),
|
||||
&whoami.email,
|
||||
&proxy_key,
|
||||
start.started_at,
|
||||
start.member_since,
|
||||
start.remote_deploy_target_changed_at,
|
||||
hash_token(&credential)
|
||||
)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
Error::BadRequest(match start.member_since {
|
||||
Some(_) => "This connect was not saved: you disconnected or connected again while it \
|
||||
was running"
|
||||
.to_string(),
|
||||
None => format!(
|
||||
"This connect was not saved: you disconnected, connected again or signed out \
|
||||
while it was running. Connecting workspace {w_id} without being one of its \
|
||||
members needs a session or an API token of this instance"
|
||||
),
|
||||
})
|
||||
})?;
|
||||
audit_log(
|
||||
&mut *tx,
|
||||
&authed,
|
||||
"workspaces.remote_deploy_connect",
|
||||
ActionKind::Create,
|
||||
&w_id,
|
||||
Some(&whoami.email),
|
||||
Some([("base_url", target.base_url.as_str())].into()),
|
||||
)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
|
||||
Ok((
|
||||
NO_STORE,
|
||||
Json(RemoteDeployConnection { remote_email: whoami.email, proxy_key, connected_at }),
|
||||
))
|
||||
}
|
||||
|
||||
async fn disconnect(
|
||||
authed: ApiAuthed,
|
||||
Extension(db): Extension<DB>,
|
||||
Path(w_id): Path<String>,
|
||||
) -> Result<String> {
|
||||
require_own_credentials(&authed)?;
|
||||
let mut tx = db.begin().await?;
|
||||
// Emptied rather than deleted, and stamped, even with no connection yet: a connect that started
|
||||
// before this must not bring one back when it lands (see `connect`).
|
||||
sqlx::query!(
|
||||
"INSERT INTO remote_deploy_token
|
||||
(workspace_id, email, base_url, remote_workspace_id, token, remote_email, proxy_key,
|
||||
connected_at)
|
||||
VALUES ($1, $2, '', '', '', '', '', clock_timestamp())
|
||||
ON CONFLICT (workspace_id, email) DO UPDATE SET
|
||||
token = '', proxy_key = '', connected_at = clock_timestamp()",
|
||||
&w_id,
|
||||
&authed.email
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
audit_log(
|
||||
&mut *tx,
|
||||
&authed,
|
||||
"workspaces.remote_deploy_disconnect",
|
||||
ActionKind::Delete,
|
||||
&w_id,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
Ok(format!(
|
||||
"Disconnected from the remote deploy target of {w_id}"
|
||||
))
|
||||
}
|
||||
|
||||
/// The remote URL for the request path after the proxy prefix and key, as the client sent it.
|
||||
///
|
||||
/// `url` rewrites a path while parsing it: it resolves dot segments (`%2e` spellings included)
|
||||
/// and turns backslashes into slashes. Either would reach a route other than the one this
|
||||
/// instance authorized — outside the remote workspace, or a route its read-only check does not
|
||||
/// recognize — so a path the parser would change is refused rather than forwarded.
|
||||
fn forwarded_url(
|
||||
target: &RemoteDeployTarget,
|
||||
original_path: &str,
|
||||
query: Option<&str>,
|
||||
) -> Result<url::Url> {
|
||||
let suffix = original_path
|
||||
.split_once(PROXY_PREFIX)
|
||||
.and_then(|(_, keyed)| keyed.split_once('/'))
|
||||
.map(|(_, suffix)| suffix)
|
||||
.unwrap_or_default();
|
||||
let invalid = || Error::BadRequest(format!("Invalid remote deploy path: {suffix}"));
|
||||
let base_path = url::Url::parse(&target.base_url)
|
||||
.map_err(|_| invalid())?
|
||||
.path()
|
||||
.trim_end_matches('/')
|
||||
.to_string();
|
||||
let mut url = url::Url::parse(&target.api_url(suffix)).map_err(|_| invalid())?;
|
||||
let expected_path = format!("{base_path}/api/w/{}/{suffix}", target.workspace_id);
|
||||
if suffix.is_empty() || url.path() != expected_path {
|
||||
return Err(invalid());
|
||||
}
|
||||
url.set_query(query);
|
||||
Ok(url)
|
||||
}
|
||||
|
||||
async fn proxy(
|
||||
authed: ApiAuthed,
|
||||
Extension(db): Extension<DB>,
|
||||
Path((w_id, key, _rest)): Path<(String, String, String)>,
|
||||
OriginalUri(uri): OriginalUri,
|
||||
method: Method,
|
||||
headers: HeaderMap,
|
||||
body: Bytes,
|
||||
) -> Result<Response> {
|
||||
require_own_credentials(&authed)?;
|
||||
let target = require_target(&db, &w_id).await?;
|
||||
let url = forwarded_url(&target, uri.path(), uri.query())?;
|
||||
let stored = load_connection(&db, &w_id, &authed.email, &target)
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
Error::BadRequest(format!(
|
||||
"Connect to {} with your own token before deploying there",
|
||||
target.base_url
|
||||
))
|
||||
})?;
|
||||
if !constant_time_eq::constant_time_eq(key.as_bytes(), stored.proxy_key.as_bytes()) {
|
||||
return Err(Error::BadRequest(
|
||||
"This remote deploy link is not yours or is out of date; reload the page".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
// Only what describes the payload: the caller's cookie and token belong to this instance.
|
||||
let mut request = remote_client(&target)
|
||||
.await?
|
||||
.request(method, url)
|
||||
.bearer_auth(stored.token)
|
||||
.body(body);
|
||||
for name in [header::CONTENT_TYPE, header::ACCEPT] {
|
||||
if let Some(value) = headers.get(&name) {
|
||||
request = request.header(name, value.clone());
|
||||
}
|
||||
}
|
||||
let response = request.send().await.map_err(|e| unreachable(&target, e))?;
|
||||
|
||||
let status = response.status();
|
||||
// Passed through, a 401 would read as this instance's session having expired and log the
|
||||
// user out of it.
|
||||
if status == StatusCode::UNAUTHORIZED {
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
return Err(Error::BadGateway(format!(
|
||||
"{} rejected your stored token: {body}",
|
||||
target.base_url
|
||||
)));
|
||||
}
|
||||
if status.is_redirection() {
|
||||
let location = response
|
||||
.headers()
|
||||
.get(header::LOCATION)
|
||||
.and_then(|l| l.to_str().ok())
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
return Err(Error::BadGateway(format!(
|
||||
"{} redirected to {location}. Set the remote deploy target to the URL it redirects to",
|
||||
target.base_url
|
||||
)));
|
||||
}
|
||||
// The body is the remote's and may be anything its users stored (an uploaded file, a job
|
||||
// result typed `text/html`); served from this origin it must never render as a page here.
|
||||
let mut builder = Response::builder()
|
||||
.status(status)
|
||||
.header(header::X_CONTENT_TYPE_OPTIONS, "nosniff")
|
||||
.header(
|
||||
header::CONTENT_SECURITY_POLICY,
|
||||
"sandbox; default-src 'none'",
|
||||
);
|
||||
if let Some(content_type) = response.headers().get(header::CONTENT_TYPE) {
|
||||
builder = builder.header(header::CONTENT_TYPE, content_type);
|
||||
}
|
||||
builder
|
||||
.body(Body::from_stream(response.bytes_stream()))
|
||||
.map_err(|e| Error::internal_err(format!("building the proxied response: {e}")))
|
||||
}
|
||||
|
||||
/// Move the stored remote tokens to a new workspace key. A token that no longer decrypts is
|
||||
/// dropped: its owner connects again. A disconnect's emptied row is kept, as the stamp that keeps
|
||||
/// an older connect from landing.
|
||||
pub(crate) async fn reencrypt_tokens(
|
||||
conn: &mut sqlx::PgConnection,
|
||||
w_id: &str,
|
||||
old: &MagicCrypt256,
|
||||
new: &MagicCrypt256,
|
||||
) -> Result<()> {
|
||||
let rows = sqlx::query!(
|
||||
"SELECT email, token FROM remote_deploy_token
|
||||
WHERE workspace_id = $1 AND token <> '' FOR UPDATE",
|
||||
w_id
|
||||
)
|
||||
.fetch_all(&mut *conn)
|
||||
.await?;
|
||||
for row in rows {
|
||||
match decrypt(old, row.token) {
|
||||
Ok(plain) => {
|
||||
sqlx::query!(
|
||||
"UPDATE remote_deploy_token SET token = $1
|
||||
WHERE workspace_id = $2 AND email = $3",
|
||||
encrypt(new, &plain),
|
||||
w_id,
|
||||
row.email
|
||||
)
|
||||
.execute(&mut *conn)
|
||||
.await?;
|
||||
}
|
||||
Err(_) => {
|
||||
sqlx::query!(
|
||||
"DELETE FROM remote_deploy_token WHERE workspace_id = $1 AND email = $2",
|
||||
w_id,
|
||||
row.email
|
||||
)
|
||||
.execute(&mut *conn)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn forwarded_url_refuses_paths_the_parser_rewrites() {
|
||||
let target = RemoteDeployTarget {
|
||||
base_url: "https://prod.example.com/windmill".to_string(),
|
||||
workspace_id: "prod".to_string(),
|
||||
};
|
||||
let prefix = "/api/w/dev/remote_deploy/proxy/someKey";
|
||||
let url = forwarded_url(
|
||||
&target,
|
||||
&format!("{prefix}/scripts/get/p/f/team/my%20script"),
|
||||
Some("with_starred_info=true"),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
url.as_str(),
|
||||
"https://prod.example.com/windmill/api/w/prod/scripts/get/p/f/team/my%20script?with_starred_info=true"
|
||||
);
|
||||
for path in [
|
||||
format!("{prefix}/../../users/list"),
|
||||
format!("{prefix}/%2e%2e/%2e%2e/users/list"),
|
||||
format!("{prefix}/jobs\\run_wait_result\\p/f/team/action"),
|
||||
prefix.to_string(),
|
||||
] {
|
||||
assert!(
|
||||
forwarded_url(&target, &path, None).is_err(),
|
||||
"{path} should be refused"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn redacted_uri_masks_the_proxy_key() {
|
||||
let proxied: axum::http::Uri = "/api/w/dev/remote_deploy/proxy/secretKey/flows/get/f/a?x=1"
|
||||
.parse()
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
RedactedUri(&proxied).to_string(),
|
||||
"/api/w/dev/remote_deploy/proxy/***/flows/get/f/a?x=1"
|
||||
);
|
||||
let other: axum::http::Uri = "/api/w/dev/flows/list?x=1".parse().unwrap();
|
||||
assert_eq!(RedactedUri(&other).to_string(), "/api/w/dev/flows/list?x=1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn target_normalization() {
|
||||
let target = RemoteDeployTarget {
|
||||
base_url: " https://prod.example.com/ ".to_string(),
|
||||
workspace_id: " prod ".to_string(),
|
||||
}
|
||||
.normalized()
|
||||
.unwrap();
|
||||
assert_eq!(target.base_url, "https://prod.example.com");
|
||||
assert_eq!(
|
||||
target.api_url("flows/create"),
|
||||
"https://prod.example.com/api/w/prod/flows/create"
|
||||
);
|
||||
|
||||
for (base_url, workspace_id) in [
|
||||
("ftp://prod.example.com", "prod"),
|
||||
("https://user:pass@prod.example.com", "prod"),
|
||||
("https://prod.example.com?token=x", "prod"),
|
||||
("https://prod.example.com", "prod/../admins"),
|
||||
] {
|
||||
assert!(
|
||||
RemoteDeployTarget {
|
||||
base_url: base_url.to_string(),
|
||||
workspace_id: workspace_id.to_string(),
|
||||
}
|
||||
.normalized()
|
||||
.is_err(),
|
||||
"{base_url} {workspace_id} should be refused"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ use windmill_api_auth::{
|
||||
};
|
||||
use windmill_api_users::users::WorkspaceInvite;
|
||||
use windmill_common::email_oss::send_email_if_possible;
|
||||
use windmill_common::ssrf::{validate_webhook_url, webhook_ssrf_error_message};
|
||||
use windmill_common::usernames::{get_instance_username_or_create_pending, VALID_USERNAME};
|
||||
use windmill_common::webhook::WebhookShared;
|
||||
use windmill_common::{BASE_URL, DB};
|
||||
@@ -1923,6 +1924,16 @@ async fn edit_webhook(
|
||||
));
|
||||
}
|
||||
|
||||
// An empty URL is stored as-is and means "no webhook" to the sender.
|
||||
if let Some(webhook) = ew.webhook.as_deref().filter(|w| !w.is_empty()) {
|
||||
validate_webhook_url(webhook).await.map_err(|e| {
|
||||
Error::BadRequest(format!(
|
||||
"Webhook URL is not allowed: {}",
|
||||
webhook_ssrf_error_message(&e)
|
||||
))
|
||||
})?;
|
||||
}
|
||||
|
||||
let mut tx = db.begin().await?;
|
||||
|
||||
if let Some(webhook) = &ew.webhook {
|
||||
@@ -5960,6 +5971,13 @@ async fn set_encryption_key(
|
||||
&new_encryption_key,
|
||||
)
|
||||
.await?;
|
||||
crate::remote_deploy::reencrypt_tokens(
|
||||
&mut tx,
|
||||
&w_id,
|
||||
&previous_encryption_key,
|
||||
&new_encryption_key,
|
||||
)
|
||||
.await?;
|
||||
|
||||
tx.commit().await?;
|
||||
|
||||
@@ -10143,13 +10161,25 @@ async fn leave_workspace(
|
||||
&format!("u/{}", authed.username),
|
||||
)
|
||||
.await?;
|
||||
sqlx::query!(
|
||||
let left = sqlx::query!(
|
||||
"DELETE FROM usr WHERE workspace_id = $1 AND email = $2",
|
||||
&w_id,
|
||||
&authed.email
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
.await?
|
||||
.rows_affected();
|
||||
// A superadmin deploys from workspaces it is no member of; leaving none is no reason to drop
|
||||
// its connection.
|
||||
if left > 0 {
|
||||
sqlx::query!(
|
||||
"DELETE FROM remote_deploy_token WHERE email = $1 AND workspace_id = $2",
|
||||
&authed.email,
|
||||
&w_id
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
}
|
||||
|
||||
audit_log(
|
||||
&mut *tx,
|
||||
|
||||
@@ -113,7 +113,7 @@ pub(crate) async fn change_workspace_id(
|
||||
// Duplicate workspace settings (keep copy in old workspace for reference)
|
||||
info!("Duplicating workspace_settings table");
|
||||
sqlx::query!(
|
||||
"INSERT INTO workspace_settings (workspace_id, slack_team_id, slack_name, slack_command_script, slack_email, customer_id, plan, webhook, ai_config, large_file_storage, git_sync, default_app, default_scripts, deploy_ui, mute_critical_alerts, color, operator_settings, teams_command_script, teams_team_id, teams_team_name, git_app_installations, git_credentials, ducklake, dbt_warehouses, slack_oauth_client_id, slack_oauth_client_secret, datatable, teams_team_guid, auto_invite, error_handler, success_handler, public_app_execution_limit_per_minute, error_handler_fallback_to_instance_alerts, guest_access_enabled, guest_jwt_public_key, guest_jwt_jwks_url, add_admins_and_developers_to_forks) SELECT $1, slack_team_id, slack_name, slack_command_script, slack_email, customer_id, plan, webhook, ai_config, large_file_storage, git_sync, default_app, default_scripts, deploy_ui, mute_critical_alerts, color, operator_settings, teams_command_script, teams_team_id, teams_team_name, git_app_installations, git_credentials, ducklake, dbt_warehouses, slack_oauth_client_id, slack_oauth_client_secret, datatable, teams_team_guid, auto_invite, error_handler, success_handler, public_app_execution_limit_per_minute, error_handler_fallback_to_instance_alerts, guest_access_enabled, guest_jwt_public_key, guest_jwt_jwks_url, add_admins_and_developers_to_forks FROM workspace_settings WHERE workspace_id = $2",
|
||||
"INSERT INTO workspace_settings (workspace_id, slack_team_id, slack_name, slack_command_script, slack_email, customer_id, plan, webhook, ai_config, large_file_storage, git_sync, default_app, default_scripts, deploy_ui, mute_critical_alerts, color, operator_settings, teams_command_script, teams_team_id, teams_team_name, git_app_installations, git_credentials, ducklake, dbt_warehouses, slack_oauth_client_id, slack_oauth_client_secret, datatable, teams_team_guid, auto_invite, error_handler, success_handler, public_app_execution_limit_per_minute, error_handler_fallback_to_instance_alerts, guest_access_enabled, guest_jwt_public_key, guest_jwt_jwks_url, add_admins_and_developers_to_forks, remote_deploy_target, remote_deploy_target_changed_at) SELECT $1, slack_team_id, slack_name, slack_command_script, slack_email, customer_id, plan, webhook, ai_config, large_file_storage, git_sync, default_app, default_scripts, deploy_ui, mute_critical_alerts, color, operator_settings, teams_command_script, teams_team_id, teams_team_name, git_app_installations, git_credentials, ducklake, dbt_warehouses, slack_oauth_client_id, slack_oauth_client_secret, datatable, teams_team_guid, auto_invite, error_handler, success_handler, public_app_execution_limit_per_minute, error_handler_fallback_to_instance_alerts, guest_access_enabled, guest_jwt_public_key, guest_jwt_jwks_url, add_admins_and_developers_to_forks, remote_deploy_target, remote_deploy_target_changed_at FROM workspace_settings WHERE workspace_id = $2",
|
||||
&rw.new_id,
|
||||
&old_id
|
||||
)
|
||||
@@ -841,6 +841,14 @@ pub(crate) async fn change_workspace_id(
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
sqlx::query!(
|
||||
"UPDATE remote_deploy_token SET workspace_id = $1 WHERE workspace_id = $2",
|
||||
&rw.new_id,
|
||||
&old_id
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
info!("Updating variable table");
|
||||
sqlx::query!(
|
||||
"UPDATE variable SET workspace_id = $1 WHERE workspace_id = $2",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
openapi: "3.0.3"
|
||||
|
||||
info:
|
||||
version: 1.815.0
|
||||
version: 1.817.0
|
||||
title: Windmill API
|
||||
|
||||
contact:
|
||||
@@ -529,30 +529,6 @@ paths:
|
||||
responses:
|
||||
"302":
|
||||
description: redirected to the post-login destination, or to /user/login_link_expired when the link is used, expired or unknown
|
||||
post:
|
||||
security: []
|
||||
summary: consume a single-use login link from its confirmation page, set the session cookie and answer where to go
|
||||
operationId: confirmLoginLink
|
||||
tags:
|
||||
- user
|
||||
parameters:
|
||||
- name: token
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
description: the post-login destination, or /user/login_link_expired when the link is used, expired or unknown
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required:
|
||||
- location
|
||||
properties:
|
||||
location:
|
||||
type: string
|
||||
|
||||
/auth/reset_password:
|
||||
post:
|
||||
@@ -1786,6 +1762,26 @@ paths:
|
||||
schema:
|
||||
type: string
|
||||
|
||||
/settings/instance_ui:
|
||||
get:
|
||||
summary: get the instance-wide announcement banner and accent color
|
||||
description: |
|
||||
Readable by any authenticated user; every page load reads it. Each value
|
||||
is the raw stored setting, or null when unset.
|
||||
operationId: getInstanceUi
|
||||
tags:
|
||||
- setting
|
||||
responses:
|
||||
"200":
|
||||
description: the instance_banner and accent_color settings
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
instance_banner: {}
|
||||
accent_color: {}
|
||||
|
||||
/settings_u/ruff_config:
|
||||
get:
|
||||
summary: get instance ruff config (unauthenticated)
|
||||
@@ -4211,6 +4207,100 @@ paths:
|
||||
deploy_to:
|
||||
type: string
|
||||
|
||||
/w/{workspace}/remote_deploy/target:
|
||||
get:
|
||||
summary: get the workspace's deploy target on another instance, and the caller's connection to it
|
||||
description: |
|
||||
Once connected, deploy calls aimed at the target go through
|
||||
`/w/{workspace}/remote_deploy/proxy/{proxy_key}/{route}`, which forwards any method to
|
||||
`{base_url}/api/w/{target workspace}/{route}` with the caller's stored token.
|
||||
operationId: getRemoteDeployTarget
|
||||
tags:
|
||||
- workspace
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/WorkspaceId"
|
||||
responses:
|
||||
"200":
|
||||
description: remote deploy target and the caller's connection to it
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/RemoteDeployStatus"
|
||||
post:
|
||||
summary: set or clear the workspace's deploy target on another instance
|
||||
description: Changing the target drops every token stored for the previous one.
|
||||
operationId: setRemoteDeployTarget
|
||||
tags:
|
||||
- workspace
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/WorkspaceId"
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
target:
|
||||
$ref: "#/components/schemas/RemoteDeployTarget"
|
||||
responses:
|
||||
"200":
|
||||
description: status
|
||||
content:
|
||||
text/plain:
|
||||
schema:
|
||||
type: string
|
||||
|
||||
/w/{workspace}/remote_deploy/connect:
|
||||
post:
|
||||
summary: store the caller's own token for the remote deploy target
|
||||
description: |
|
||||
The token is checked against the target's whoami before it is stored. `target` names the
|
||||
target the token was obtained for, and the request is refused if the workspace now points
|
||||
elsewhere, so a token is never sent to an instance it was not meant for.
|
||||
operationId: connectRemoteDeploy
|
||||
tags:
|
||||
- workspace
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/WorkspaceId"
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
token:
|
||||
type: string
|
||||
target:
|
||||
$ref: "#/components/schemas/RemoteDeployTarget"
|
||||
required:
|
||||
- token
|
||||
- target
|
||||
responses:
|
||||
"200":
|
||||
description: the stored connection
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/RemoteDeployConnection"
|
||||
|
||||
/w/{workspace}/remote_deploy/disconnect:
|
||||
post:
|
||||
summary: forget the caller's token for the remote deploy target
|
||||
operationId: disconnectRemoteDeploy
|
||||
tags:
|
||||
- workspace
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/WorkspaceId"
|
||||
responses:
|
||||
"200":
|
||||
description: status
|
||||
content:
|
||||
text/plain:
|
||||
schema:
|
||||
type: string
|
||||
|
||||
/w/{workspace}/workspaces/is_premium:
|
||||
get:
|
||||
summary: get if workspace is premium
|
||||
@@ -6928,7 +7018,7 @@ paths:
|
||||
type: string
|
||||
expires_in_s:
|
||||
type: integer
|
||||
description: link lifetime in seconds, at most 7200 (default 600)
|
||||
description: link lifetime in seconds, at most 900 (default 600)
|
||||
rd:
|
||||
type: string
|
||||
description: same-origin path the browser lands on after login (default /user/workspaces)
|
||||
@@ -6938,12 +7028,6 @@ paths:
|
||||
mint only while the account still has this login type (for example
|
||||
pending_oauth), so a link stops working once the owner has set a password
|
||||
or signed in with a provider
|
||||
confirm:
|
||||
type: boolean
|
||||
description: >-
|
||||
return a /user/login_link page that signs in only when its button is
|
||||
clicked, instead of a link spent by opening it; set it for links sent by
|
||||
email, which mail scanners open on delivery (default false)
|
||||
responses:
|
||||
"201":
|
||||
description: login link minted
|
||||
@@ -10601,6 +10685,9 @@ paths:
|
||||
- kind
|
||||
- tag
|
||||
- deployment_message
|
||||
- schema
|
||||
x-mcp-tool-opaque-fields:
|
||||
- schema
|
||||
tags:
|
||||
- script
|
||||
parameters:
|
||||
@@ -10640,6 +10727,9 @@ paths:
|
||||
- kind
|
||||
- tag
|
||||
- deployment_message
|
||||
- schema
|
||||
x-mcp-tool-opaque-fields:
|
||||
- schema
|
||||
tags:
|
||||
- script
|
||||
parameters:
|
||||
@@ -16220,6 +16310,29 @@ paths:
|
||||
items:
|
||||
type: string
|
||||
|
||||
/w/{workspace}/jobs/queue/run_now/{id}:
|
||||
post:
|
||||
summary: start a queued job scheduled for later now
|
||||
description: |
|
||||
Moves the start of a queued job that is waiting for a future time (scheduled, debounced,
|
||||
re-scheduled by a concurrency limit, or a flow step waiting on a sleep or a retry delay)
|
||||
to now, keeping the same job id. A job with a
|
||||
concurrency limit is still checked against its limit when it is pulled. A schedule's
|
||||
upcoming tick is refused, since the schedule would queue that tick again.
|
||||
operationId: runQueuedJobNow
|
||||
tags:
|
||||
- job
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/WorkspaceId"
|
||||
- $ref: "#/components/parameters/JobId"
|
||||
responses:
|
||||
"200":
|
||||
description: job id
|
||||
content:
|
||||
text/plain:
|
||||
schema:
|
||||
type: string
|
||||
|
||||
/w/{workspace}/jobs/queue/cancel_selection:
|
||||
post:
|
||||
summary: cancel jobs based on the given uuids
|
||||
@@ -29658,6 +29771,15 @@ components:
|
||||
type: string
|
||||
schema:
|
||||
type: object
|
||||
description: >-
|
||||
JSON Schema of the arguments of `main`, which is what a run form and an
|
||||
MCP tool offer. Omitted (or `{}`), it is inferred from `content` for
|
||||
TypeScript, Python, Go, Bash, PowerShell, SQL, GraphQL and Ansible
|
||||
scripts. For other languages, or code that does not parse, a new script
|
||||
gets none. A new version of an existing script also keeps what the
|
||||
previous version's schema says about each argument, or that whole schema
|
||||
when nothing can be inferred. A dbt script always takes its schema from its
|
||||
descriptor, whatever is sent.
|
||||
is_template:
|
||||
type: boolean
|
||||
lock:
|
||||
@@ -31078,6 +31200,7 @@ components:
|
||||
- "jobs"
|
||||
- "jobs.cancel"
|
||||
- "jobs.force_cancel"
|
||||
- "jobs.run_now"
|
||||
- "jobs.disapproval"
|
||||
- "jobs.delete"
|
||||
- "account.delete"
|
||||
@@ -36562,6 +36685,44 @@ components:
|
||||
items:
|
||||
$ref: "#/components/schemas/GitSyncObjectType"
|
||||
|
||||
RemoteDeployTarget:
|
||||
type: object
|
||||
properties:
|
||||
base_url:
|
||||
type: string
|
||||
description: root URL of the remote Windmill instance, without /api
|
||||
workspace_id:
|
||||
type: string
|
||||
description: workspace on the remote instance that deploys land in
|
||||
required:
|
||||
- base_url
|
||||
- workspace_id
|
||||
|
||||
RemoteDeployConnection:
|
||||
type: object
|
||||
properties:
|
||||
remote_email:
|
||||
type: string
|
||||
description: identity the stored token has on the remote instance
|
||||
proxy_key:
|
||||
type: string
|
||||
description: goes in every proxy URL, `/w/{workspace}/remote_deploy/proxy/{proxy_key}/{route}`
|
||||
connected_at:
|
||||
type: string
|
||||
format: date-time
|
||||
required:
|
||||
- remote_email
|
||||
- proxy_key
|
||||
- connected_at
|
||||
|
||||
RemoteDeployStatus:
|
||||
type: object
|
||||
properties:
|
||||
target:
|
||||
$ref: "#/components/schemas/RemoteDeployTarget"
|
||||
connection:
|
||||
$ref: "#/components/schemas/RemoteDeployConnection"
|
||||
|
||||
WorkspaceDefaultScripts:
|
||||
type: object
|
||||
properties:
|
||||
|
||||
@@ -69,7 +69,7 @@ use windmill_common::{
|
||||
user_drafts::{overlay_or_draft_only, DraftUserRef, UserDraftItemKind, WithDraftOverlay},
|
||||
users::username_to_permissioned_as,
|
||||
utils::{
|
||||
http_get_from_hub, not_found_if_none, paginate, paginate_optional,
|
||||
check_proper_path, http_get_from_hub, not_found_if_none, paginate, paginate_optional,
|
||||
query_elems_from_hub, require_admin, strip_json_nul, Pagination, RunnableKind, StripPath,
|
||||
},
|
||||
variables::{build_crypt, build_crypt_with_key_suffix, encrypt},
|
||||
@@ -2506,6 +2506,7 @@ async fn create_app_internal<'a>(
|
||||
// inside process_app_multipart!, so checking after this call would leave a
|
||||
// denied app committed in the DB.
|
||||
check_scopes(&authed, || format!("apps:write:{}", &app.path))?;
|
||||
check_proper_path(&app.path)?;
|
||||
validate_frontend_sdk_scopes(&app.policy)?;
|
||||
if raw_app {
|
||||
validate_raw_app_path_keys(&app.value.0)?;
|
||||
@@ -3252,6 +3253,7 @@ async fn create_app_raw_source(
|
||||
// Before the compile, which costs a job on a worker: it must not run for a
|
||||
// path already taken, nor for one the caller can't write. `create_app_internal`
|
||||
// rejects both, but only after the sources have been built.
|
||||
check_proper_path(&path)?;
|
||||
if app_exists(&db, &w_id, &path).await? {
|
||||
return Err(Error::BadRequest(format!("App {path} already exists")));
|
||||
}
|
||||
@@ -3462,6 +3464,9 @@ async fn update_app_internal<'a>(
|
||||
// the token's write scope, not just the source path.
|
||||
if let Some(npath) = ns.path.as_deref() {
|
||||
check_scopes(&authed, || format!("apps:write:{}", npath))?;
|
||||
if npath != path {
|
||||
check_proper_path(npath)?;
|
||||
}
|
||||
}
|
||||
|
||||
if raw_app {
|
||||
@@ -4450,9 +4455,11 @@ async fn execute_component(
|
||||
// — like `/jobs/run/preview` — confine it to worker tags the caller may use
|
||||
// (a `if_jobs:filter_tags`-restricted token must not escape its filter).
|
||||
// `is_preview` implies an authed caller (the guard above returns otherwise).
|
||||
let push_args = PushArgs { args: &args.args, extra: args.extra };
|
||||
if is_preview {
|
||||
if let Some(authed) = opt_authed.as_ref() {
|
||||
crate::jobs::check_tag_available_for_workspace(&db, &w_id, &tag, authed).await?;
|
||||
crate::jobs::check_tag_available_for_workspace(&db, &w_id, &tag, &push_args, authed)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
// Identity is already resolved to the requesting user in preview mode (the
|
||||
@@ -4483,7 +4490,7 @@ async fn execute_component(
|
||||
tx,
|
||||
&w_id,
|
||||
job_payload,
|
||||
PushArgs { args: &args.args, extra: args.extra },
|
||||
push_args,
|
||||
&username,
|
||||
email,
|
||||
permissioned_as,
|
||||
|
||||
@@ -116,7 +116,9 @@ use windmill_common::{
|
||||
query_builders,
|
||||
scripts::{ScriptHash, ScriptLang},
|
||||
users::username_to_permissioned_as,
|
||||
utils::{not_found_if_none, now_from_db, paginate, require_admin, Pagination, StripPath},
|
||||
utils::{
|
||||
not_found_if_none, now_from_db, paginate, require_admin, Pagination, ScheduleType, StripPath,
|
||||
},
|
||||
};
|
||||
|
||||
use windmill_common::{
|
||||
@@ -304,6 +306,7 @@ pub fn workspaced_service() -> Router {
|
||||
.route("/queue/position/{timestamp}", get(get_queue_position))
|
||||
.route("/queue/scheduled_for/{id}", get(get_scheduled_for))
|
||||
.route("/queue/cancel_selection", post(cancel_selection))
|
||||
.route("/queue/run_now/{id}", post(run_queued_job_now))
|
||||
.route("/completed/count", get(count_completed_jobs))
|
||||
.route("/completed/count_jobs", get(count_completed_jobs_detail))
|
||||
.route(
|
||||
@@ -835,6 +838,113 @@ async fn force_cancel(
|
||||
}
|
||||
}
|
||||
|
||||
/// Moves a queued job's `scheduled_for` to now so the next pull picks it up, keeping its id.
|
||||
/// A job with a concurrency limit still goes through the usual check at pull time and is
|
||||
/// re-scheduled again if its key is still saturated.
|
||||
async fn run_queued_job_now(
|
||||
authed: ApiAuthed,
|
||||
Extension(db): Extension<DB>,
|
||||
Extension(user_db): Extension<UserDB>,
|
||||
Path((w_id, id)): Path<(String, Uuid)>,
|
||||
) -> error::Result<String> {
|
||||
require_job_update_read_access(&db, &user_db, &authed, &w_id, &id, None).await?;
|
||||
refuse_upcoming_schedule_tick(&db, &w_id, id).await?;
|
||||
|
||||
let mut tx = db.begin().await?;
|
||||
let previous = sqlx::query_scalar!(
|
||||
"UPDATE v2_job_queue q SET scheduled_for = now()
|
||||
FROM (SELECT id, scheduled_for FROM v2_job_queue WHERE id = $1 AND workspace_id = $2 FOR UPDATE) prev
|
||||
WHERE q.id = prev.id AND NOT q.running AND q.suspend = 0 AND q.canceled_by IS NULL
|
||||
AND q.scheduled_for > now()
|
||||
RETURNING prev.scheduled_for",
|
||||
id,
|
||||
w_id,
|
||||
)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?;
|
||||
|
||||
let Some(previous) = previous else {
|
||||
tx.commit().await?;
|
||||
return Err(
|
||||
if sqlx::query_scalar!(
|
||||
"SELECT EXISTS(SELECT 1 FROM v2_job_queue WHERE id = $1 AND workspace_id = $2)",
|
||||
id,
|
||||
w_id
|
||||
)
|
||||
.fetch_one(&db)
|
||||
.await?
|
||||
.unwrap_or(false)
|
||||
{
|
||||
Error::BadRequest(format!(
|
||||
"job {id} is not waiting for a future start: it is running, suspended, \
|
||||
canceled or already due"
|
||||
))
|
||||
} else {
|
||||
Error::NotFound(format!("queued job id {id} does not exist"))
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
audit_log(
|
||||
&mut *tx,
|
||||
&authed,
|
||||
"jobs.run_now",
|
||||
ActionKind::Update,
|
||||
&w_id,
|
||||
Some(&id.to_string()),
|
||||
Some([("previous_scheduled_for", previous.to_rfc3339().as_str())].into()),
|
||||
)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
|
||||
windmill_queue::append_logs(
|
||||
&id,
|
||||
&w_id,
|
||||
format!(
|
||||
"\nStart moved from {previous} to now by {}\n",
|
||||
authed.display_username()
|
||||
),
|
||||
&db.clone().into(),
|
||||
)
|
||||
.await;
|
||||
|
||||
Ok(id.to_string())
|
||||
}
|
||||
|
||||
/// A schedule queues its next tick when the current one completes, computed from the
|
||||
/// completion time. Starting a tick that is not yet due would therefore queue that same
|
||||
/// tick again and run the schedule twice. A tick pushed past its time by a concurrency
|
||||
/// limit no longer sits on a cron occurrence, so it may still be started early.
|
||||
async fn refuse_upcoming_schedule_tick(db: &DB, w_id: &str, id: Uuid) -> error::Result<()> {
|
||||
let Some(tick) = sqlx::query!(
|
||||
"SELECT q.scheduled_for, s.path, s.schedule, s.cron_version, s.timezone
|
||||
FROM v2_job j
|
||||
JOIN v2_job_queue q USING (id)
|
||||
JOIN schedule s ON s.workspace_id = j.workspace_id AND s.path = j.trigger
|
||||
WHERE j.id = $1 AND j.workspace_id = $2 AND j.trigger_kind = 'schedule'
|
||||
AND j.parent_job IS NULL AND q.scheduled_for > now()",
|
||||
id,
|
||||
w_id,
|
||||
)
|
||||
.fetch_optional(db)
|
||||
.await?
|
||||
else {
|
||||
return Ok(());
|
||||
};
|
||||
let sched = ScheduleType::from_str(&tick.schedule, tick.cron_version.as_deref(), false)?;
|
||||
let tz =
|
||||
chrono_tz::Tz::from_str(&tick.timezone).map_err(|e| Error::BadRequest(e.to_string()))?;
|
||||
let just_before = (tick.scheduled_for - chrono::Duration::milliseconds(1)).with_timezone(&tz);
|
||||
if sched.find_next(&just_before)? == tick.scheduled_for {
|
||||
return Err(Error::BadRequest(format!(
|
||||
"job {id} is the upcoming tick of schedule {}; run the schedule's script or flow \
|
||||
directly instead",
|
||||
tick.path
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct QueuePosition {
|
||||
position: Option<i64>,
|
||||
@@ -7226,7 +7336,10 @@ pub async fn run_workflow_as_code(
|
||||
) -> error::Result<(StatusCode, String)> {
|
||||
#[cfg(feature = "enterprise")]
|
||||
check_license_key_valid().await?;
|
||||
check_tag_available_for_workspace(&db, &w_id, &run_query.tag, &authed).await?;
|
||||
let mut extra = HashMap::new();
|
||||
extra.insert(ENTRYPOINT_OVERRIDE.to_string(), to_raw_value(&entrypoint));
|
||||
let args = PushArgs { args: &task.args.unwrap_or_else(HashMap::new), extra: Some(extra) };
|
||||
check_tag_available_for_workspace(&db, &w_id, &run_query.tag, &args, &authed).await?;
|
||||
check_scopes(&authed, || format!("jobs:run"))?;
|
||||
|
||||
if !is_valid_entrypoint_name(&entrypoint) {
|
||||
@@ -7320,10 +7433,6 @@ pub async fn run_workflow_as_code(
|
||||
i += 1;
|
||||
}
|
||||
|
||||
let mut extra = HashMap::new();
|
||||
extra.insert(ENTRYPOINT_OVERRIDE.to_string(), to_raw_value(&entrypoint));
|
||||
|
||||
let args = PushArgs { args: &task.args.unwrap_or_else(HashMap::new), extra: Some(extra) };
|
||||
let scheduled_for = run_query.get_scheduled_for(&db).await?;
|
||||
|
||||
let tag = run_query.tag.clone().or(tag).or(Some(job.tag));
|
||||
@@ -7360,7 +7469,7 @@ pub async fn run_workflow_as_code(
|
||||
tx,
|
||||
&w_id,
|
||||
job_payload,
|
||||
PushArgs { args: &args.args, extra: args.extra },
|
||||
args,
|
||||
authed.display_username(),
|
||||
email,
|
||||
permissioned_as,
|
||||
@@ -7646,7 +7755,8 @@ pub async fn run_wait_result_job_by_path_get(
|
||||
.await?;
|
||||
|
||||
let tag = run_query.tag.clone().or(tag);
|
||||
check_tag_available_for_workspace(&db, &w_id, &tag, &authed).await?;
|
||||
let push_args = PushArgs { args: &args.args, extra: args.extra };
|
||||
check_tag_available_for_workspace(&db, &w_id, &tag, &push_args, &authed).await?;
|
||||
|
||||
let (email, permissioned_as, push_authed, tx) =
|
||||
if let Some(on_behalf_of) = on_behalf_authed.as_ref() {
|
||||
@@ -7670,7 +7780,7 @@ pub async fn run_wait_result_job_by_path_get(
|
||||
tx,
|
||||
&w_id,
|
||||
job_payload,
|
||||
PushArgs { args: &args.args, extra: args.extra },
|
||||
push_args,
|
||||
authed.display_username(),
|
||||
email,
|
||||
permissioned_as,
|
||||
@@ -7791,7 +7901,8 @@ pub async fn run_wait_result_script_by_path_internal(
|
||||
.await?;
|
||||
|
||||
let tag = run_query.tag.clone().or(tag);
|
||||
check_tag_available_for_workspace(&db, &w_id, &tag, &authed).await?;
|
||||
let push_args = PushArgs { args: &args.args, extra: args.extra };
|
||||
check_tag_available_for_workspace(&db, &w_id, &tag, &push_args, &authed).await?;
|
||||
|
||||
let (email, permissioned_as, push_authed, tx) =
|
||||
if let Some(on_behalf_of) = on_behalf_of.as_ref() {
|
||||
@@ -7815,7 +7926,7 @@ pub async fn run_wait_result_script_by_path_internal(
|
||||
tx,
|
||||
&w_id,
|
||||
job_payload,
|
||||
PushArgs { args: &args.args, extra: args.extra },
|
||||
push_args,
|
||||
authed.display_username(),
|
||||
email,
|
||||
permissioned_as,
|
||||
@@ -7904,7 +8015,8 @@ pub async fn run_wait_result_script_by_hash(
|
||||
check_scopes(&authed, || format!("jobs:run:scripts:{path}"))?;
|
||||
|
||||
let tag = run_query.tag.clone().or(tag);
|
||||
check_tag_available_for_workspace(&db, &w_id, &tag, &authed).await?;
|
||||
let push_args = PushArgs { args: &args.args, extra: args.extra };
|
||||
check_tag_available_for_workspace(&db, &w_id, &tag, &push_args, &authed).await?;
|
||||
|
||||
let (email, permissioned_as, push_authed, tx) = if let Some(obo) = on_behalf_of.as_ref() {
|
||||
(
|
||||
@@ -7940,7 +8052,7 @@ pub async fn run_wait_result_script_by_hash(
|
||||
&& has_preprocessor.unwrap_or(false),
|
||||
labels,
|
||||
},
|
||||
PushArgs { args: &args.args, extra: args.extra },
|
||||
push_args,
|
||||
authed.display_username(),
|
||||
email,
|
||||
permissioned_as,
|
||||
@@ -8465,7 +8577,6 @@ async fn run_preview_script(
|
||||
require_path_read_access_for_preview(&authed, &preview.path)?;
|
||||
let scheduled_for = run_query.get_scheduled_for(&db).await?;
|
||||
let tag = run_query.tag.clone().or(preview.tag.clone());
|
||||
check_tag_available_for_workspace(&db, &w_id, &tag, &authed).await?;
|
||||
let tx = PushIsolationLevel::Isolated(user_db.clone(), authed.clone().into());
|
||||
|
||||
let preview_args = preview.args.unwrap_or_default();
|
||||
@@ -8484,6 +8595,7 @@ async fn run_preview_script(
|
||||
}
|
||||
let extra = if extra.is_empty() { None } else { Some(extra) };
|
||||
let push_args = PushArgs { extra, args: &preview_args };
|
||||
check_tag_available_for_workspace(&db, &w_id, &tag, &push_args, &authed).await?;
|
||||
|
||||
let (uuid, tx) = push(
|
||||
&db,
|
||||
@@ -8845,7 +8957,6 @@ async fn run_bundle_preview_script(
|
||||
|
||||
let scheduled_for = run_query.get_scheduled_for(&db).await?;
|
||||
let tag = run_query.tag.clone().or(preview.tag.clone());
|
||||
check_tag_available_for_workspace(&db, &w_id, &tag, &authed).await?;
|
||||
let ltx = PushIsolationLevel::Isolated(user_db.clone(), authed.clone().into());
|
||||
|
||||
let args = preview.args.unwrap_or_default();
|
||||
@@ -8860,6 +8971,7 @@ async fn run_bundle_preview_script(
|
||||
m
|
||||
});
|
||||
let push_args = PushArgs { extra, args: &args };
|
||||
check_tag_available_for_workspace(&db, &w_id, &tag, &push_args, &authed).await?;
|
||||
|
||||
is_tar = match preview.kind {
|
||||
Some(PreviewKind::Tarbundle) => true,
|
||||
@@ -9511,7 +9623,6 @@ async fn run_preview_flow_job(
|
||||
require_path_read_access_for_preview(&authed, &raw_flow.path)?;
|
||||
let scheduled_for = run_query.get_scheduled_for(&db).await?;
|
||||
let tag = run_query.tag.clone().or(raw_flow.tag.clone());
|
||||
check_tag_available_for_workspace(&db, &w_id, &tag, &authed).await?;
|
||||
let tx = PushIsolationLevel::Isolated(user_db.clone(), authed.clone().into());
|
||||
|
||||
let chat_input_enabled = raw_flow.value.chat_input_enabled.unwrap_or(false);
|
||||
@@ -9529,6 +9640,8 @@ async fn run_preview_flow_job(
|
||||
to_raw_value(temp_script_refs),
|
||||
);
|
||||
}
|
||||
check_tag_available_for_workspace(&db, &w_id, &tag, &PushArgs::from(&flow_args), &authed)
|
||||
.await?;
|
||||
|
||||
let (uuid, mut tx) = push(
|
||||
&db,
|
||||
@@ -9774,11 +9887,6 @@ async fn run_dynamic_select(
|
||||
}
|
||||
}
|
||||
|
||||
// Same tag-permission gate a normal run gets (run_flow / push_script_job_by_path_into_queue):
|
||||
// a caller allowed to read the flow must still be allowed to use its worker tag. No-op for
|
||||
// inline (tag is None); the script branch checked this inside its helper and returned above.
|
||||
check_tag_available_for_workspace(&db, &w_id, &tag, &authed).await?;
|
||||
|
||||
// Invoke the dyn-select entrypoint instead of `main`.
|
||||
let mut args = request.args.unwrap_or_default();
|
||||
args.insert(
|
||||
@@ -9786,6 +9894,11 @@ async fn run_dynamic_select(
|
||||
serde_json::value::to_raw_value(&request.entrypoint_function)?,
|
||||
);
|
||||
|
||||
// Same tag-permission gate a normal run gets (run_flow / push_script_job_by_path_into_queue):
|
||||
// a caller allowed to read the flow must still be allowed to use its worker tag. No-op for
|
||||
// inline (tag is None); the script branch checked this inside its helper and returned above.
|
||||
check_tag_available_for_workspace(&db, &w_id, &tag, &PushArgs::from(&args), &authed).await?;
|
||||
|
||||
let scheduled_for = run_query.get_scheduled_for(&db).await?;
|
||||
let tx = PushIsolationLevel::Isolated(user_db.clone(), authed.clone().into());
|
||||
|
||||
@@ -9920,8 +10033,9 @@ pub async fn run_job_by_hash_inner(
|
||||
}
|
||||
let scheduled_for = run_query.get_scheduled_for(&db).await?;
|
||||
let tag = run_query.tag.clone().or(tag);
|
||||
let push_args = PushArgs { args: &args.args, extra: args.extra };
|
||||
|
||||
check_tag_available_for_workspace(&db, &w_id, &tag, &authed).await?;
|
||||
check_tag_available_for_workspace(&db, &w_id, &tag, &push_args, &authed).await?;
|
||||
|
||||
let (email, permissioned_as, push_authed, tx) = if let Some(obo) = on_behalf_of.as_ref() {
|
||||
(
|
||||
@@ -9957,7 +10071,7 @@ pub async fn run_job_by_hash_inner(
|
||||
&& has_preprocessor.unwrap_or(false),
|
||||
labels,
|
||||
},
|
||||
PushArgs { args: &args.args, extra: args.extra },
|
||||
push_args,
|
||||
authed.display_username(),
|
||||
email,
|
||||
permissioned_as,
|
||||
|
||||
@@ -534,7 +534,9 @@ pub async fn run_server(
|
||||
#[cfg(feature = "http_trigger")]
|
||||
{
|
||||
let http_killpill_rx = killpill_rx.resubscribe();
|
||||
triggers::http::refresh_routers_loop(&db, http_killpill_rx).await;
|
||||
// A worker only serves `/r` when one of its own jobs calls the local API, so it loads the
|
||||
// routers on that first request rather than at startup.
|
||||
triggers::http::refresh_routers_loop(&db, http_killpill_rx, server_mode).await;
|
||||
}
|
||||
|
||||
let triggers_service = triggers::generate_trigger_routers();
|
||||
@@ -731,6 +733,12 @@ pub async fn run_server(
|
||||
path_autocomplete::workspaced_service(),
|
||||
)
|
||||
.nest("/raw_apps", raw_apps::workspaced_service())
|
||||
.nest(
|
||||
"/remote_deploy",
|
||||
windmill_api_workspaces::remote_deploy::workspaced_service(
|
||||
request_size_limit * 5,
|
||||
),
|
||||
)
|
||||
// CORS so the opaque-origin app iframe can read
|
||||
// resources/list, resources/type/* with a scoped token.
|
||||
.nest(
|
||||
|
||||
@@ -699,6 +699,10 @@ pub fn all_tools() -> Vec<EndpointTool> {
|
||||
"content": {
|
||||
"type": "string"
|
||||
},
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"description": "JSON Schema of the arguments of `main`, which is what a run form and an MCP tool offer. Omitted (or `{}`), it is inferred from `content` for TypeScript, Python, Go, Bash, PowerShell, SQL, GraphQL and Ansible scripts. For other languages, or code that does not parse, a new script gets none. A new version of an existing script also keeps what the previous version's schema says about each argument, or that whole schema when nothing can be inferred. A dbt script always takes its schema from its descriptor, whatever is sent."
|
||||
},
|
||||
"language": {
|
||||
"type": "string",
|
||||
"description": "Possible values: python3, deno, go, bash, powershell, postgresql, mysql, bigquery, snowflake, mssql, oracledb, graphql, nativets, bun, php, rust, ansible, csharp, nu, java, ruby, rlang, duckdb, bunnative, dbt"
|
||||
@@ -757,6 +761,10 @@ is, a different one moves it there and archives the old path"),
|
||||
"content": {
|
||||
"type": "string"
|
||||
},
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"description": "JSON Schema of the arguments of `main`, which is what a run form and an MCP tool offer. Omitted (or `{}`), it is inferred from `content` for TypeScript, Python, Go, Bash, PowerShell, SQL, GraphQL and Ansible scripts. For other languages, or code that does not parse, a new script gets none. A new version of an existing script also keeps what the previous version's schema says about each argument, or that whole schema when nothing can be inferred. A dbt script always takes its schema from its descriptor, whatever is sent."
|
||||
},
|
||||
"language": {
|
||||
"type": "string",
|
||||
"description": "Possible values: python3, deno, go, bash, powershell, postgresql, mysql, bigquery, snowflake, mssql, oracledb, graphql, nativets, bun, php, rust, ansible, csharp, nu, java, ruby, rlang, duckdb, bunnative, dbt"
|
||||
@@ -1024,10 +1032,12 @@ is, a different one moves it there and archives the old path"),
|
||||
"description": "Detailed documentation for this flow"
|
||||
},
|
||||
"value": {
|
||||
"type": "object"
|
||||
"type": "object",
|
||||
"description": "The flow structure containing modules and optional preprocessor/failure handlers"
|
||||
},
|
||||
"schema": {
|
||||
"type": "object"
|
||||
"type": "object",
|
||||
"description": "JSON Schema for flow inputs. Use this to define input parameters, their types, defaults, and validation. For resource inputs, set type to 'object' and format to 'resource-<type>' (e.g., 'resource-stripe')"
|
||||
},
|
||||
"path": {
|
||||
"type": "string"
|
||||
@@ -1080,10 +1090,12 @@ is, a different one moves it there and archives the old path"),
|
||||
"description": "Detailed documentation for this flow"
|
||||
},
|
||||
"value": {
|
||||
"type": "object"
|
||||
"type": "object",
|
||||
"description": "The flow structure containing modules and optional preprocessor/failure handlers"
|
||||
},
|
||||
"schema": {
|
||||
"type": "object"
|
||||
"type": "object",
|
||||
"description": "JSON Schema for flow inputs. Use this to define input parameters, their types, defaults, and validation. For resource inputs, set type to 'object' and format to 'resource-<type>' (e.g., 'resource-stripe')"
|
||||
},
|
||||
"tag": {
|
||||
"type": "string"
|
||||
|
||||
@@ -386,6 +386,12 @@ impl McpBackend for WindmillBackend {
|
||||
})
|
||||
}
|
||||
|
||||
async fn runnable_list_fingerprint(&self, workspace_id: &str) -> BackendResult<String> {
|
||||
windmill_common::runnables::runnable_list_fingerprint(&self.db, workspace_id)
|
||||
.await
|
||||
.map_err(|e| ErrorData::internal_error(e.to_string(), None))
|
||||
}
|
||||
|
||||
fn all_endpoint_tools(&self) -> Vec<EndpointTool> {
|
||||
all_tools()
|
||||
}
|
||||
|
||||
@@ -5,7 +5,10 @@
|
||||
* Please see the included NOTICE for copyright information and
|
||||
* LICENSE-AGPL for a copy of the license.
|
||||
*/
|
||||
use crate::{db::ApiAuthed, utils::check_scopes};
|
||||
use crate::{
|
||||
db::ApiAuthed,
|
||||
utils::{build_scope_path_filter, check_scopes, ScopePathFilter},
|
||||
};
|
||||
use axum::{
|
||||
body::Body,
|
||||
extract::{Extension, Json, Path, Query},
|
||||
@@ -103,6 +106,31 @@ async fn list_apps(
|
||||
}
|
||||
}
|
||||
|
||||
// In the WHERE, not a retain after the fetch: the result is paginated, and a
|
||||
// post-fetch filter would return short pages and let a page's size report how
|
||||
// many raw apps the token may not read. One `?` per term, since a chained `.bind`
|
||||
// substitutes into a `?` inside the value an earlier one inserted; `starts_with`,
|
||||
// since LIKE reads a `_` in the path as a wildcard.
|
||||
if let ScopePathFilter::Restricted { exact, prefix } =
|
||||
build_scope_path_filter(&authed, "raw_apps", "read")
|
||||
{
|
||||
let terms: Vec<String> = exact
|
||||
.iter()
|
||||
.chain(&prefix)
|
||||
.map(|p| "app.path = ?".bind(p))
|
||||
.chain(
|
||||
prefix
|
||||
.iter()
|
||||
.map(|p| "starts_with(app.path, ?)".bind(&format!("{p}/"))),
|
||||
)
|
||||
.collect();
|
||||
sqlb.and_where(if terms.is_empty() {
|
||||
"false".to_string()
|
||||
} else {
|
||||
format!("({})", terms.join(" OR "))
|
||||
});
|
||||
}
|
||||
|
||||
let sql = sqlb.sql().map_err(|e| Error::internal_err(e.to_string()))?;
|
||||
let mut tx = user_db.begin(&authed).await?;
|
||||
let rows = sqlx::query_as::<_, ListableApp>(&sql)
|
||||
|
||||
@@ -14,6 +14,7 @@ use axum::response::Response as AxumResponse;
|
||||
use hyper::Response;
|
||||
use tower_http::trace::{MakeSpan, OnFailure, OnResponse};
|
||||
use uuid::Uuid;
|
||||
use windmill_api_workspaces::remote_deploy::RedactedUri;
|
||||
use windmill_common::log_context::{with_log_context, LogContext};
|
||||
|
||||
lazy_static::lazy_static! {
|
||||
@@ -86,7 +87,7 @@ impl<B> MakeSpan<B> for MyMakeSpan {
|
||||
tracing::error_span!(
|
||||
"request",
|
||||
method = %request.method(),
|
||||
uri = %request.uri(),
|
||||
uri = %RedactedUri(request.uri()),
|
||||
username = field::Empty,
|
||||
workspace_id = field::Empty,
|
||||
traceId = tracing_id,
|
||||
@@ -113,7 +114,7 @@ pub async fn log_context_middleware(request: Request, next: Next) -> AxumRespons
|
||||
|
||||
let ctx = LogContext {
|
||||
method: Some(request.method().to_string()),
|
||||
uri: Some(request.uri().to_string()),
|
||||
uri: Some(RedactedUri(request.uri()).to_string()),
|
||||
trace_id,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
@@ -381,7 +381,7 @@ async fn get_http_route_trigger(
|
||||
let routers_cache = HTTP_ROUTERS_CACHE.read().await;
|
||||
|
||||
let routers_cache = if routers_cache.routers.is_empty() {
|
||||
tracing::warn!("HTTP routers are not loaded, loading from db");
|
||||
tracing::info!("HTTP routers are not loaded, loading from db");
|
||||
// refresh_routers takes the write lock, so holding this read guard across it deadlocks.
|
||||
drop(routers_cache);
|
||||
let (_, routers_cache) = refresh_routers(db, false).await?;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user